mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 15:12:02 +00:00
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.
343 lines
11 KiB
TypeScript
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'
|
|
}
|