sourcegraph/client/web-sveltekit/src/testing/integration.ts
Felix Kling fb1106be15
fix(svelte): Respect cody ignore settings (#63677)
This commit adds support for cody ignore settings to the SvelteKit app.
When cody is disabled on the instance or for the user, the cody button
and sidebar are not shown anymore.

Likewise if the repository is excluded from cody, the buttons and
sidebar are not shown.

The implementation was inspired by `useCodyIngore.ts`, but simplified
for use in the SvelteKit app. It seems that the file exclusion logic is
not actually used for the sidebar and thus was omitted. It also seems
that for the sidebar itself we are only checking whether the current
repository is excluded or not, which lets us simplify the whole setup
and simply pass a boolean (store) from the data loader, indicating
whether cody is enabled or not.

Furthermore I introduced zod to validate that the value of
`codyContextFilters.raw`, which is typed as `JSONValue`, has the
expected shape. We've run into issues in the past where such values have
just been cast to the expected Typescript type. zod adds runtime
validation.

Note that we use JSON schema base validation (with `ajv`) in some
places, but that requires importing and sending the whole JSON schema to
the client, which is something I'd like to avoid. The advantage JSON
schema is that we also use it for generating Go code. We should find a
way to use JSON schema but generate specific validators at build time.
There are also other libraries that do runtime validation and are
smaller but they don't necessarily allow asynchronous validation (which
we want to do because we only want to import the `re2js` library when
necessary; of course we could organize the code differently but it's
nice to be able to encapsulate this logic)

## Test plan

Manual testing and new integration tests.
2024-07-09 07:40:12 +00:00

343 lines
11 KiB
TypeScript

import { readFileSync } from 'node:fs'
import path from 'node:path'
import { faker } from '@faker-js/faker'
import { test as base, type Page, type Locator } from '@playwright/test'
import glob from 'glob'
import { buildSchema } from 'graphql'
import * as mime from 'mime-types'
import type { SearchEvent } from '../lib/shared'
import { GraphQLMockServer } from './graphql-mocking'
import type { TypeMocks, ObjectMock, UserMock, OperationMocks } from './graphql-type-mocks'
// For mocking EventSource for search results
declare global {
interface Window {
$$sources: EventSource[]
}
}
export { expect, defineConfig, type Locator, type Page } from '@playwright/test'
const defaultMocks: TypeMocks = {
Query: () => ({
// null means not signed in
currentUser: null,
}),
Person: () => {
const firstName = faker.person.firstName()
const lastName = faker.person.lastName()
return {
name: `${firstName} ${lastName}`,
email: faker.internet.email({ firstName, lastName }),
displayName: faker.internet.userName({ firstName, lastName }),
avatarURL: null,
}
},
User: () => ({
avatarURL: null,
}),
SettingsCascade: () => ({
// Ensure this is valid JSON
final: '{}',
}),
TemporarySettings: () => ({
// Ensure this is valid JSON
contents: '{}',
}),
GitBlob: () => ({
highlight: {
// Ensure this is valid JSON
lsif: '{}',
},
}),
GitRef: () => ({
url: faker.internet.url(),
}),
Signature: () => ({
date: faker.date.past().toISOString(),
}),
GitObjectID: () => faker.git.commitSha(),
GitCommit: () => ({
abbreviatedOID: faker.git.commitSha({ length: 7 }),
subject: faker.git.commitMessage(),
}),
JSONCString: () => '{}',
}
interface MockSearchStream {
publish(...events: SearchEvent[]): Promise<void>
close(): Promise<void>
}
const IS_BAZEL = process.env.BAZEL === '1'
const SCHEMA_DIR = `${IS_BAZEL ? '' : '../../'}cmd/frontend/graphqlbackend`
const ASSETS_DIR = process.env.ASSETS_DIR || './build'
const typeDefs = glob
.sync('**/*.graphql', { cwd: SCHEMA_DIR })
.map(file => readFileSync(path.join(SCHEMA_DIR, file), 'utf8'))
.join('\n')
class Sourcegraph {
private debugMode = false
constructor(private readonly page: Page, private readonly graphqlMock: GraphQLMockServer) {}
async setup(): Promise<void> {
// All assets are mocked and served from the filesystem. If you do want to use
// a local preview server or even backend, you can set this env var
if (!parseBool(process.env.DISABLE_APP_ASSETS_MOCKING)) {
// routes in playwright are tested in reverse registration order
// so in order to make this the fallback we register it first
// all unmatched routes are treated as routes within the application
// and so only route to the manifest
await this.page.route('/**/*', route => {
route.fulfill({
status: 200,
contentType: 'text/html',
body: readFileSync(path.join(ASSETS_DIR, 'index.html')),
})
})
// Intercept any asset calls and replace them with static files
await this.page.route(/.assets|_app/, route => {
const assetPath = new URL(route.request().url()).pathname.replace('/.assets/', '')
const asset = joinDistinct(ASSETS_DIR, assetPath)
const contentType = mime.contentType(path.basename(asset)) || undefined
route.fulfill({
status: 200,
contentType,
body: readFileSync(asset),
headers: {
'cache-control': 'public, max-age=31536000, immutable',
},
})
})
}
// mock graphql calls
await this.page.route(/\.api\/graphql/, route => {
const { query, variables, operationName } = JSON.parse(route.request().postData() ?? '')
const result = this.graphqlMock.query(
query,
variables,
operationName,
this.debugMode
? {
logGraphQLErrors: true,
warnOnMissingOperationMocks: true,
}
: undefined
)
route.fulfill({ json: result })
})
}
public debug() {
this.debugMode = true
}
public mockTypes(mocks: TypeMocks): void {
this.graphqlMock.addTypeMocks(mocks)
}
public mockOperations(mocks: OperationMocks): void {
this.graphqlMock.addOperationMocks(mocks)
}
/**
* Mocks an empty search result stream. Returns a function that can be called to simulate
* the search results being received. The returned function will wait for the search results
* page to be "ready" by waiting for the "Filter results" heading to be visible.
*/
public async mockSearchStream(): Promise<MockSearchStream> {
await this.page.addInitScript(() => {
window.$$sources = []
window.EventSource = class MockEventSource {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSED = 2
public readonly CONNECTING = 0
public readonly OPEN = 1
public readonly CLOSED = 2
private listeners: Record<string, EventListener[]> = {}
public readonly withCredentials = false
public readyState = 0
public onopen: EventListener | null = null
public onmessage: EventListener | null = null
public onerror: EventListener | null = null
public url: string
constructor(url: string | URL) {
this.readyState = 1
this.url = typeof url === 'string' ? url : url.href
console.log('Mocking event source for', url)
window.$$sources.push(this)
}
dispatchEvent(event: Event): boolean {
for (const listener of this.listeners[event.type] ?? []) {
listener(event)
}
return false
}
addEventListener(event: string, listener: any): void {
if (!this.listeners[event]) {
this.listeners[event] = []
}
this.listeners[event].push(listener)
}
removeEventListener(event: string, listener: any): void {
if (this.listeners[event]) {
this.listeners[event] = this.listeners[event].filter(l => l !== listener)
}
}
close(): void {
this.readyState = 2
}
}
})
return {
publish: async (...events: SearchEvent[]): Promise<void> => {
return this.page.evaluate(
([events]) => {
for (const event of events) {
for (const source of window.$$sources) {
source.dispatchEvent(new MessageEvent(event.type, { data: JSON.stringify(event.data) }))
}
}
},
[events]
)
},
close: async (): Promise<void> => {
return this.page.evaluate(() => {
for (const source of window.$$sources) {
source.close()
}
})
},
}
}
public fixture(fixtures: (ObjectMock & { __typename: NonNullable<ObjectMock['__typename']> })[]): void {
// @ts-expect-error - Unclear how to type this correctly. ObjectMock is missing string index signature
// which is required by addFixtures
this.graphqlMock.addFixtures(fixtures)
}
public setWindowContext(context: Partial<Window['context']>): Promise<void> {
return this.page.addInitScript(context => {
if (!window.context) {
// @ts-expect-error - Unclear how to type this correctly
window.context = {}
}
Object.assign(window.context, context)
}, context)
}
public signIn(userMock: UserMock = {}): void {
this.mockTypes({
Query: () => ({
currentUser: {
avatarURL: null,
...userMock,
},
}),
})
}
public signOut(): void {
this.mockTypes({
Query: () => ({
currentUser: null,
}),
})
}
/**
* Mock the current window context to be in "dotcom mode" (sourcegraph.com).
*/
public dotcomMode(): void {
this.setWindowContext({ sourcegraphDotComMode: true })
}
public teardown(): void {
this.graphqlMock.reset()
}
}
// joins two URLs which may have overlapping paths, ensuring that the result is a valid URL
function joinDistinct(baseURL: string, suffix: string): string {
const suffixSet = new Set(suffix.split('/'))
let url = ''
for (const part of baseURL.split('/')) {
if (suffixSet.has(part)) {
break
}
url = path.join(url, part)
}
return path.join(url, suffix)
}
interface Utils {
scrollYAt(locator: Locator, distance: number): Promise<void>
}
export const test = base.extend<{ sg: Sourcegraph; utils: Utils }, { graphqlMock: GraphQLMockServer }>({
utils: async ({ page }, use) => {
use({
async scrollYAt(locator: Locator, distance: number): Promise<void> {
// Position mouse over target that wheel events will scrolls the container
// that contains the target
const { x, y } = (await locator.boundingBox()) ?? { x: 0, y: 0 }
await page.mouse.move(x, y)
// Scroll list, which should load next page
await page.mouse.wheel(0, distance)
},
})
},
sg: [
async ({ page, graphqlMock }, use) => {
const sg = new Sourcegraph(page, graphqlMock)
await sg.setup()
await use(sg)
sg.teardown()
},
{ auto: true },
],
graphqlMock: [
async ({}, use) => {
const graphqlMock = new GraphQLMockServer({
schema: buildSchema(typeDefs),
mocks: defaultMocks,
typePolicies: {
GitBlob: {
keyField: 'canonicalURL',
},
GitTree: {
keyField: 'canonicalURL',
},
},
})
await use(graphqlMock)
},
{ scope: 'worker' },
],
})
function parseBool(s: string | undefined): boolean {
if (s === undefined) {
return false
}
return s.toLowerCase() === 'true'
}