diff --git a/package.json b/package.json index 0e53ab32c47..24cf2d09793 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "@types/jsdom": "12.2.4", "@types/lodash": "4.14.157", "@types/marked": "1.1.0", + "@types/mime-types": "2.1.0", "@types/mini-css-extract-plugin": "0.9.1", "@types/mkdirp-promise": "5.0.0", "@types/mocha": "7.0.2", @@ -208,6 +209,7 @@ "latest-version": "^5.1.0", "license-checker": "^25.0.1", "message-port-polyfill": "^0.2.0", + "mime-types": "^2.1.27", "mini-css-extract-plugin": "^0.9.0", "mkdirp-promise": "^5.0.1", "mocha": "^7.2.0", diff --git a/shared/src/testing/driver.ts b/shared/src/testing/driver.ts index 2e315214ac5..a6b4c9e3044 100644 --- a/shared/src/testing/driver.ts +++ b/shared/src/testing/driver.ts @@ -28,6 +28,7 @@ import getFreePort from 'get-port' import puppeteerFirefox from 'puppeteer-firefox' import webExt from 'web-ext' import { isDefined } from '../util/types' +import { getConfig } from './config' /** * Returns a Promise for the next emission of the given event on the given Puppeteer page. @@ -685,15 +686,17 @@ interface DriverOptions extends LaunchOptions { sourcegraphBaseUrl: string - /** If true, print browser console messages to stdout. */ + /** If not `false`, print browser console messages to stdout. */ logBrowserConsole?: boolean /** If true, keep browser open when driver is closed */ keepBrowser?: boolean } -export async function createDriverForTest(options: DriverOptions): Promise { - const { loadExtension, sourcegraphBaseUrl, logBrowserConsole, keepBrowser } = options +export async function createDriverForTest( + options: DriverOptions = getConfig('sourcegraphBaseUrl', 'headless', 'slowMo') +): Promise { + const { loadExtension, sourcegraphBaseUrl, logBrowserConsole = true, keepBrowser } = options const args: string[] = [] const launchOptions: puppeteer.LaunchOptions = { ...options, diff --git a/shared/src/testing/integration/README.md b/shared/src/testing/integration/README.md new file mode 100644 index 00000000000..9a1061e22f0 --- /dev/null +++ b/shared/src/testing/integration/README.md @@ -0,0 +1,19 @@ +# Integration tests + +This file contains integration tests for the Sourcegraph UI. + +The role of these integration tests is to provide in-browser testing of complex UI flows in isolation from the Sourcegraph backend. They are built on top of Puppeteer & Mocha. + +These tests are declared using the helpers declared in [`helpers.ts`](./helpers.ts), which wrap Mocha helpers and provide a similar API. + +Mocked data for the tests is generated by running them with a real backend, with the `RECORD` environment variable set to a truthy value: + +``` +env RECORD=true yarn test-integration +``` + +Responses will be intercepted and saved as JSON in the `__fixtures__` directory. + +When running the tests, static JS/CSS assets will be served from the `ui/assets` directory. Other requests will be intercepted, and responded to using fixture data. + +Before running tests, call `yarn build` to make sure to have up-to-date assets. diff --git a/shared/src/testing/integration/context.ts b/shared/src/testing/integration/context.ts new file mode 100644 index 00000000000..95e8c35b55d --- /dev/null +++ b/shared/src/testing/integration/context.ts @@ -0,0 +1,234 @@ +import { Test } from 'mocha' +import { Subject, throwError } from 'rxjs' +import { snakeCase } from 'lodash' +import { Driver } from '../driver' +import { recordCoverage } from '../coverage' +import { readFile } from 'mz/fs' +import mkdirp from 'mkdirp-promise' +import { Polly, PollyServer } from '@pollyjs/core' +import { PuppeteerAdapter } from './polly/PuppeteerAdapter' +import FSPersister from '@pollyjs/persister-fs' +import { ErrorGraphQLResult, SuccessGraphQLResult } from '../../graphql/graphql' +import { first, timeoutWith } from 'rxjs/operators' +import * as path from 'path' +import * as util from 'util' +import * as prettier from 'prettier' +import { keyExistsIn } from '../../util/types' +import { IGraphQLResponseError } from '../../graphql/schema' +import { readEnvironmentBoolean } from '../utils' +import { ResourceType } from 'puppeteer' +import * as mime from 'mime-types' + +// Reduce log verbosity +util.inspect.defaultOptions.depth = 0 +util.inspect.defaultOptions.maxStringLength = 80 + +Polly.register(PuppeteerAdapter as any) +Polly.register(FSPersister) + +const ASSETS_DIRECTORY = path.resolve(__dirname, '../../../../ui/assets') + +const record = readEnvironmentBoolean({ variable: 'RECORD', defaultValue: false }) + +export class IntegrationTestGraphQlError extends Error { + constructor(public errors: IGraphQLResponseError[]) { + super('graphql error for integration tests') + } +} + +export interface IntegrationTestContext< + TGraphQlOperations extends Record any>, + TGraphQlOperationNames extends string +> { + driver: Driver + server: PollyServer + + /** + * Configures fake responses for GraphQL queries and mutations. + * + * @param overrides The results to return, keyed by query name. + */ + overrideGraphQL: (overrides: Partial) => void + + /** + * Waits for a specific GraphQL query to happen and returns the variables passed to the request. + * If the query does not happen within a few seconds, it throws a timeout error. + * + * @param triggerRequest A callback called to trigger the request (e.g. clicking a button). The request MUST be triggered within this callback. + * @param operationName The name of the query to wait for. + * @returns The GraphQL variables of the query. + */ + waitForGraphQLRequest: ( + triggerRequest: () => Promise | void, + operationName: O + ) => Promise[0]> + + dispose: () => Promise +} + +export interface IntegrationTestOptions { + /** + * The test driver created in a `before()` hook. + */ + driver: Driver + + /** + * The value of `this.currentTest` in the `beforeEach()` hook. + * Make sure the hook function is not an arrow function to access it. + */ + currentTest: Test + + /** + * The directory (value of `__dirname`) of the test file. + */ + directory: string +} + +/** + * Should be called in a `beforeEach()` and saved into a local variable. + */ +export const createSharedIntegrationTestContext = async < + TGraphQlOperations extends Record any>, + TGraphQlOperationNames extends string +>({ + driver, + currentTest, + directory, +}: IntegrationTestOptions): Promise> => { + await driver.newPage() + await driver.page.setRequestInterception(true) + const recordingsDirectory = path.join(directory, '__fixtures__', snakeCase(currentTest.fullTitle())) + if (record) { + await mkdirp(recordingsDirectory) + } + const requestResourceTypes: ResourceType[] = ['xhr', 'fetch', 'document', 'script', 'stylesheet', 'image', 'font'] + const polly = new Polly(snakeCase(currentTest.title), { + adapters: ['puppeteer'], + adapterOptions: { + puppeteer: { + page: driver.page, + requestResourceTypes, + }, + }, + persister: 'fs', + persisterOptions: { + fs: { + recordingsDir: recordingsDirectory, + }, + }, + expiryStrategy: 'warn', + recordIfMissing: record, + matchRequestsBy: { + method: true, + body: true, + order: true, + // Origin header will change when running against a test instance + headers: false, + }, + mode: record ? 'record' : 'replay', + logging: false, + }) + const { server } = polly + + // Let browser handle data: URIs + server.get('data:*rest').passthrough() + + // Serve assets from disk + server.get(new URL('/.assets/*path', driver.sourcegraphBaseUrl).href).intercept(async (request, response) => { + const asset = request.params.path + const content = await readFile(path.join(ASSETS_DIRECTORY, asset), { + // Polly doesn't support Buffers or streams at the moment + encoding: 'utf-8', + }) + const contentType = mime.contentType(path.basename(asset)) + if (contentType) { + response.type(contentType) + } + // Cache all responses for the entire lifetime of the test run + response.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + response.send(content) + }) + + // GraphQL requests are not handled by HARs, but configured per-test. + interface GraphQLRequestEvent { + operationName: O + variables: Parameters[0] + } + let graphQlOverrides: Partial = {} + const graphQlRequests = new Subject>() + server.post(new URL('/.api/graphql', driver.sourcegraphBaseUrl).href).intercept((request, response) => { + const operationName = new URL(request.absoluteUrl).search.slice(1) as TGraphQlOperationNames + const { variables, query } = request.jsonBody() as { + query: string + variables: Parameters[0] + } + graphQlRequests.next({ operationName, variables }) + + const missingOverrideError = (): Error => { + const formattedQuery = prettier.format(query, { parser: 'graphql' }).trim() + const formattedVariables = util.inspect(variables) + const error = new Error( + `GraphQL query "${operationName}" has no configured mock response. Make sure the call to overrideGraphQL() includes a result for the "${operationName}" query:\n${formattedVariables} ⤵️\n${formattedQuery}` + ) + return error + } + if (!graphQlOverrides || !keyExistsIn(operationName, graphQlOverrides)) { + throw missingOverrideError() + } + const handler = graphQlOverrides[operationName] + if (!handler) { + throw missingOverrideError() + } + + try { + const result = handler(variables as any) + const graphQlResult: SuccessGraphQLResult = { data: result, errors: undefined } + response.json(graphQlResult) + } catch (error) { + if (!(error instanceof IntegrationTestGraphQlError)) { + throw error + } + + const graphQlError: ErrorGraphQLResult = { data: undefined, errors: error.errors } + response.json(graphQlError) + } + }) + + // Filter out 'server' header filled in by Caddy before persisting responses, + // otherwise tests will hang when replayed from recordings. + server + .any() + .on('beforePersist', (request, recording: { response: { headers: { name: string; value: string }[] } }) => { + recording.response.headers = recording.response.headers.filter(({ name }) => name !== 'server') + }) + + return { + driver, + server, + overrideGraphQL: overrides => { + graphQlOverrides = overrides + }, + waitForGraphQLRequest: async ( + triggerRequest: () => Promise | void, + operationName: O + ): Promise[0]> => { + const requestPromise = graphQlRequests + .pipe( + first( + (request: GraphQLRequestEvent): request is GraphQLRequestEvent => + request.operationName === operationName + ), + timeoutWith(4000, throwError(new Error(`Timeout waiting for GraphQL request "${operationName}"`))) + ) + .toPromise() + await triggerRequest() + const { variables } = await requestPromise + return variables + }, + dispose: async () => { + await polly.stop() + await recordCoverage(driver.browser) + await driver.page.close() + }, + } +} diff --git a/shared/src/testing/integration/graphQlResults.ts b/shared/src/testing/integration/graphQlResults.ts new file mode 100644 index 00000000000..3c466ad2dcc --- /dev/null +++ b/shared/src/testing/integration/graphQlResults.ts @@ -0,0 +1,9 @@ +import { SharedGraphQlOperations } from '../../graphql-operations' + +export const testUserID = 'TestUserID' +export const settingsID = 123 + +/** + * Predefined results for GraphQL requests that are made on almost every page. + */ +export const sharedGraphQlResults: Partial = {} diff --git a/web/src/integration/polly/PuppeteerAdapter.ts b/shared/src/testing/integration/polly/PuppeteerAdapter.ts similarity index 96% rename from web/src/integration/polly/PuppeteerAdapter.ts rename to shared/src/testing/integration/polly/PuppeteerAdapter.ts index 3931f2b44e9..0871152e76f 100644 --- a/web/src/integration/polly/PuppeteerAdapter.ts +++ b/shared/src/testing/integration/polly/PuppeteerAdapter.ts @@ -1,8 +1,7 @@ import { Polly, Request as PollyRequest } from '@pollyjs/core' -import Puppeteer from 'puppeteer' +import type * as Puppeteer from 'puppeteer' import PollyAdapter from '@pollyjs/adapter' import { Subscription, fromEvent } from 'rxjs' -import puppeteer from '../../../../shared/src/types/puppeteer-firefox' interface Response { statusCode: number @@ -44,7 +43,7 @@ export class PuppeteerAdapter extends PollyAdapter { * A map of all intercepted requests to their respond function, which will be called by the * 'response' event listener, causing Polly to record the response content. */ - private pendingRequests = new Map void }>() + private pendingRequests = new Map void }>() /** * Maps passthrough requests to an object containing: @@ -132,8 +131,8 @@ export class PuppeteerAdapter extends PollyAdapter { const { requestArguments: { request }, } = (pollyRequest as unknown) as PollyRequestArguments - let respond: (response: puppeteer.Response) => void - const responsePromise = new Promise(resolve => (respond = resolve)) + let respond: (response: Puppeteer.Response) => void + const responsePromise = new Promise(resolve => (respond = resolve)) this.passThroughRequests.set(request, { respond: response => respond(response), responsePromise }) await request.continue() const response = await responsePromise diff --git a/web/src/integration/context.ts b/web/src/integration/context.ts new file mode 100644 index 00000000000..d15d439eee0 --- /dev/null +++ b/web/src/integration/context.ts @@ -0,0 +1,64 @@ +import { + createSharedIntegrationTestContext, + IntegrationTestContext, + IntegrationTestOptions, +} from '../../../shared/src/testing/integration/context' +import { createJsContext } from './jscontext' +import { SourcegraphContext } from '../jscontext' +import { WebGraphQlOperations } from '../graphql-operations' +import { SharedGraphQlOperations } from '../../../shared/src/graphql-operations' +import html from 'tagged-template-noop' +import { commonWebGraphQlResults } from './graphQlResults' + +export interface WebIntegrationTestContext + extends IntegrationTestContext< + WebGraphQlOperations & SharedGraphQlOperations, + string & keyof (WebGraphQlOperations & SharedGraphQlOperations) + > { + /** + * Overrides `window.context` from the default created by `createJsContext()`. + */ + overrideJsContext: (jsContext: SourcegraphContext) => void +} + +/** + * Creates the intergation test context for integration tests testing the web app. + * This should be called in a `beforeEach()` hook and assigned to a variable `testContext` in the test scope. + */ +export const createWebIntegrationTestContext = async ({ + driver, + currentTest, + directory, +}: IntegrationTestOptions): Promise => { + const sharedTestContext = await createSharedIntegrationTestContext< + WebGraphQlOperations & SharedGraphQlOperations, + string & keyof (WebGraphQlOperations & SharedGraphQlOperations) + >({ driver, currentTest, directory }) + sharedTestContext.overrideGraphQL(commonWebGraphQlResults) + + // Serve all requests for index.html (everything that does not match the handlers above) the same index.html + let jsContext = createJsContext({ sourcegraphBaseUrl: sharedTestContext.driver.sourcegraphBaseUrl }) + sharedTestContext.server.get(new URL('/*path', driver.sourcegraphBaseUrl).href).intercept((request, response) => { + response.type('text/html').send(html` + + + Sourcegraph Test + + +
+ + + + + `) + }) + + return { + ...sharedTestContext, + overrideJsContext: overrides => { + jsContext = overrides + }, + } +} diff --git a/web/src/integration/graphQlResults.ts b/web/src/integration/graphQlResults.ts index a66963f27c9..639437581e5 100644 --- a/web/src/integration/graphQlResults.ts +++ b/web/src/integration/graphQlResults.ts @@ -1,14 +1,14 @@ -import { GraphQLOverrides } from './helpers' -import { StatusMessage, IOrg, IAlert } from '../../../shared/src/graphql/schema' -import { builtinAuthProvider, siteID, siteGQLID } from './jscontext' - -export const testUserID = 'TestUserID' -export const settingsID = 123 +import { StatusMessage } from '../../../shared/src/graphql/schema' +import { builtinAuthProvider, siteGQLID, siteID } from './jscontext' +import { WebGraphQlOperations } from '../graphql-operations' +import { SharedGraphQlOperations } from '../../../shared/src/graphql-operations' +import { testUserID, sharedGraphQlResults } from '../../../shared/src/testing/integration/graphQlResults' /** * Predefined results for GraphQL requests that are made on almost every page. */ -export const commonGraphQlResults: GraphQLOverrides = { +export const commonWebGraphQlResults: Partial = { + ...sharedGraphQlResults, CurrentAuthState: () => ({ currentUser: { __typename: 'User', @@ -19,10 +19,10 @@ export const commonGraphQlResults: GraphQLOverrides = { email: 'felix@sourcegraph.com', displayName: null, siteAdmin: true, - tags: [] as string[], + tags: [], url: '/users/test', settingsURL: '/users/test/settings', - organizations: { nodes: [] as IOrg[] }, + organizations: { nodes: [] }, session: { canSignOut: true }, viewerCanAdminister: true, }, @@ -56,7 +56,7 @@ export const commonGraphQlResults: GraphQLOverrides = { site: { needsRepositoryConfiguration: false, freeUsersExceeded: false, - alerts: [] as IAlert[], + alerts: [], authProviders: { nodes: [builtinAuthProvider], }, diff --git a/web/src/integration/helpers.ts b/web/src/integration/helpers.ts deleted file mode 100644 index 799a9030f51..00000000000 --- a/web/src/integration/helpers.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { describe as mochaDescribe, test as mochaTest, before as mochaBefore } from 'mocha' -import { Subscription, Subject, throwError } from 'rxjs' -import { snakeCase } from 'lodash' -import { createDriverForTest, Driver } from '../../../shared/src/testing/driver' -import { recordCoverage } from '../../../shared/src/testing/coverage' -import mkdirp from 'mkdirp-promise' -import express from 'express' -import { Polly } from '@pollyjs/core' -import { PuppeteerAdapter } from './polly/PuppeteerAdapter' -import FSPersister from '@pollyjs/persister-fs' -import { ErrorGraphQLResult, SuccessGraphQLResult } from '../../../shared/src/graphql/graphql' -import MockDate from 'mockdate' -import { first, timeoutWith } from 'rxjs/operators' -import * as path from 'path' -import * as util from 'util' -import { commonGraphQlResults } from './graphQlResults' -import * as prettier from 'prettier' -import html from 'tagged-template-noop' -import { createJsContext } from './jscontext' -import { SourcegraphContext } from '../jscontext' -import { WebGraphQlOperations } from '../graphql-operations' -import { SharedGraphQlOperations } from '../../../shared/src/graphql-operations' -import { keyExistsIn } from '../../../shared/src/util/types' -import { IGraphQLResponseError } from '../../../shared/src/graphql/schema' -import { getConfig } from '../../../shared/src/testing/config' - -// Reduce log verbosity -util.inspect.defaultOptions.depth = 0 -util.inspect.defaultOptions.maxStringLength = 80 - -Polly.register(PuppeteerAdapter as any) -Polly.register(FSPersister) - -const FIXTURES_DIRECTORY = `${__dirname}/__fixtures__` -const ASSETS_DIRECTORY = `${__dirname}/../../../ui/assets` - -type IntegrationTestInitGeneration = () => Promise<{ - driver: Driver - sourcegraphBaseUrl: string - subscriptions?: Subscription -}> - -export class IntegrationTestGraphQlError extends Error { - constructor(public errors: IGraphQLResponseError[]) { - super('graphql error for integration tests') - } -} - -type AllGraphQlOperations = WebGraphQlOperations & SharedGraphQlOperations -type GraphQLOperationName = keyof AllGraphQlOperations & string -export type GraphQLOverrides = Partial - -interface TestContext { - sourcegraphBaseUrl: string - driver: Driver - - /** - * Configures fake responses for GraphQL queries and mutations. - * - * @param overrides The results to return, keyed by query name. - */ - overrideGraphQL: (overrides: GraphQLOverrides) => void - - /** - * Overrides `window.context` from the default created by `createJsContext()`. - */ - overrideJsContext: (jsContext: SourcegraphContext) => void - - /** - * Waits for a specific GraphQL query to happen and returns the variables passed to the request. - * If the query does not happen within a few seconds, it throws a timeout error. - * - * @param triggerRequest A callback called to trigger the request (e.g. clicking a button). The request MUST be triggered within this callback. - * @param operationName The name of the query to wait for. - * @returns The GraphQL variables of the query. - */ - waitForGraphQLRequest: ( - triggerRequest: () => Promise | void, - operationName: O - ) => Promise[0]> -} - -type TestBody = (context: TestContext) => Promise - -interface IntegrationTestDefiner { - (title: string, run: TestBody): void - only: (title: string, run: TestBody) => void - skip: (title: string, run?: TestBody) => void -} - -type IntegrationTestBeforeGeneration = ( - setupLogic: (options: { sourcegraphBaseUrl: string; driver: Driver }) => Promise -) => void - -type IntegrationTestDescriber = ( - title: string, - suite: (helpers: { - before: IntegrationTestBeforeGeneration - test: IntegrationTestDefiner - it: IntegrationTestDefiner - describe: IntegrationTestDescriber - }) => void -) => void - -type IntegrationTestSuite = (helpers: { - /** - * Registers a calback that will be run before test data is generated, - * responsible for creating the {@link Driver} and providing the Sourcegraph URL. - */ - initGeneration: (setupLogic: IntegrationTestInitGeneration) => void - test: IntegrationTestDefiner - describe: IntegrationTestDescriber -}) => void - -/** - * Describes an integration test suite using wrappers over Mocha primitives. - * - * To record test data, set the RECORD environment variable to a truthy value. - * When recording, the tests will be run in Puppeteer against a real backend, - * and captured response fixtures will be saved to the `__fixtures__` directory. - * - * When running the tests, static CSS/JS assets will be served from the `ui/assets` directory. - * Other requests (for instance to the GraphQL API) will be stubbed using response - * stubs from the `__fixtures__` directory, through Puppeteer's request interception. - * - */ -export function describeIntegration(description: string, testSuite: IntegrationTestSuite): void { - const record = Boolean(process.env.RECORD) - mochaDescribe(description, () => { - let driver: Driver - let sourcegraphBaseUrl: string - const subscriptions = new Subscription() - - const test = (prefixes: string[]): IntegrationTestDefiner => { - const wrapTestBody = (title: string, run: TestBody) => async () => { - await driver.newPage() - await driver.page.setRequestInterception(true) - const recordingsDirectory = path.join(FIXTURES_DIRECTORY, ...prefixes.map(snakeCase)) - if (record) { - await mkdirp(recordingsDirectory) - } - const polly = new Polly(snakeCase(title), { - adapters: ['puppeteer'], - adapterOptions: { - puppeteer: { page: driver.page, requestResourceTypes: ['xhr', 'fetch', 'document'] }, - }, - persister: 'fs', - persisterOptions: { - fs: { - recordingsDir: recordingsDirectory, - }, - }, - expiryStrategy: 'warn', - recordIfMissing: record, - matchRequestsBy: { - method: true, - body: true, - order: true, - // Origin header will change when running against a test instance - headers: false, - url: { - pathname: true, - query: true, - hash: true, - // Allow recording tests against https://sourcegraph.test - // but running them against http:://localhost:8000 - protocol: false, - port: false, - hostname: false, - username: false, - password: false, - }, - }, - mode: record ? 'record' : 'replay', - logging: false, - }) - const { server } = polly - - const errors = new Subject() - - server.get(new URL('/.assets/*path', sourcegraphBaseUrl).href).passthrough() - - // GraphQL requests are not handled by HARs, but configured per-test. - interface GraphQLRequestEvent { - operationName: O - variables: Parameters[0] - } - let graphQlOverrides: GraphQLOverrides = commonGraphQlResults - const graphQlRequests = new Subject>() - server.post(new URL('/.api/graphql', sourcegraphBaseUrl).href).intercept((request, response) => { - // False positive - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const operationName = new URL(request.absoluteUrl).search.slice(1) as GraphQLOperationName - const { variables, query } = request.jsonBody() as { - query: string - variables: Parameters[0] - } - graphQlRequests.next({ operationName, variables }) - - const missingOverrideError = (): Error => { - const formattedQuery = prettier.format(query, { parser: 'graphql' }).trim() - const formattedVariables = util.inspect(variables) - const error = new Error( - `GraphQL query "${operationName}" has no configured mock response. Make sure the call to overrideGraphQL() includes a result for the "${operationName}" query:\n${formattedVariables} ⤵️\n${formattedQuery}` - ) - // Make test fail - errors.error(error) - return error - } - if (!graphQlOverrides || !keyExistsIn(operationName, graphQlOverrides)) { - throw missingOverrideError() - } - const handler = graphQlOverrides[operationName] - if (!handler) { - throw missingOverrideError() - } - - try { - const result = handler(variables as any) - const graphQlResult: SuccessGraphQLResult = { data: result, errors: undefined } - response.json(graphQlResult) - } catch (error) { - if (!(error instanceof IntegrationTestGraphQlError)) { - errors.error(error) - throw error - } - - const graphQlError: ErrorGraphQLResult = { data: undefined, errors: error.errors } - response.json(graphQlError) - } - }) - - // Serve all requests for index.html (everything that does not match the handlers above) the same index.html - let jsContext = createJsContext({ sourcegraphBaseUrl }) - server.get(new URL('/*path', sourcegraphBaseUrl).href).intercept((request, response) => { - response.type('text/html').send(html` - - - Sourcegraph Test - - -
- - - - - `) - }) - - // Filter out 'server' header filled in by Caddy before persisting responses, - // otherwise tests will hang when replayed from recordings. - server - .any() - .on( - 'beforePersist', - (request, recording: { response: { headers: { name: string; value: string }[] } }) => { - recording.response.headers = recording.response.headers.filter( - ({ name }) => name !== 'server' - ) - } - ) - try { - await Promise.race([ - errors.toPromise(), - run({ - sourcegraphBaseUrl, - driver, - overrideGraphQL: overrides => { - graphQlOverrides = overrides - }, - overrideJsContext: override => { - jsContext = override - }, - waitForGraphQLRequest: async ( - triggerRequest: () => Promise | void, - operationName: O - ): Promise[0]> => { - const requestPromise = graphQlRequests - .pipe( - first( - ( - request: GraphQLRequestEvent - ): request is GraphQLRequestEvent => - request.operationName === operationName - ), - timeoutWith( - 4000, - throwError( - new Error(`Timeout waiting for GraphQL request "${operationName}"`) - ) - ) - ) - .toPromise() - await triggerRequest() - const { variables } = await requestPromise - return variables - }, - }), - ]) - } finally { - await polly.stop() - await recordCoverage(driver.browser) - await driver.page.close() - } - } - return Object.assign( - (title: string, run: TestBody) => { - mochaTest(title, wrapTestBody(title, run)) - }, - { - only: (title: string, run: TestBody) => { - mochaTest.only(title, wrapTestBody(title, run)) - }, - skip: (title: string) => { - mochaTest.skip(title) - }, - } - ) - } - const before: IntegrationTestBeforeGeneration = setupLogic => { - if (record) { - mochaBefore(() => setupLogic({ driver, sourcegraphBaseUrl })) - } - } - const describe = (prefixes: string[]): IntegrationTestDescriber => (title, suite) => { - mochaDescribe(title, () => { - suite({ - describe: describe([...prefixes, title]), - before, - it: test([...prefixes, title]), - test: test([...prefixes, title]), - }) - }) - } - let initGenerationCallback: IntegrationTestInitGeneration = async () => { - // Reset date mocking - MockDate.reset() - const { sourcegraphBaseUrl, headless, slowMo } = getConfig('sourcegraphBaseUrl', 'headless', 'slowMo') - - // Start browser - const driver = await createDriverForTest({ - sourcegraphBaseUrl, - logBrowserConsole: true, - headless, - slowMo, - }) - return { driver, sourcegraphBaseUrl } - } - - const initGeneration = (logic: IntegrationTestInitGeneration): void => { - initGenerationCallback = logic - } - testSuite({ initGeneration, test: test([description]), describe: describe([description]) }) - mochaBefore(async () => { - if (!initGenerationCallback) { - throw new Error('initGeneration() was never called') - } - if (record) { - const setupResult = await initGenerationCallback() - driver = setupResult.driver - sourcegraphBaseUrl = setupResult.sourcegraphBaseUrl - if (setupResult.subscriptions) { - subscriptions.add(setupResult.subscriptions) - } - } else { - sourcegraphBaseUrl = 'http://localhost:8000' - driver = await createDriverForTest({ - sourcegraphBaseUrl, - logBrowserConsole: false, - }) - // Serve static assets from `ui/assets` - const app = express() - app.use('/.assets', express.static(ASSETS_DIRECTORY)) - const server = app.listen(8000) - subscriptions.add(() => server.close()) - } - }) - after(async () => { - await driver?.close() - // eslint-disable-next-line no-unused-expressions - subscriptions?.unsubscribe() - }) - }) -} diff --git a/web/src/integration/search.test.ts b/web/src/integration/search.test.ts index c16f5459028..deadb77a556 100644 --- a/web/src/integration/search.test.ts +++ b/web/src/integration/search.test.ts @@ -1,9 +1,11 @@ import assert from 'assert' import expect from 'expect' -import { describeIntegration } from './helpers' -import { commonGraphQlResults } from './graphQlResults' +import { commonWebGraphQlResults } from './graphQlResults' import { ILanguage, IRepository } from '../../../shared/src/graphql/schema' import { SearchResult } from '../graphql-operations' +import { Driver, createDriverForTest } from '../../../shared/src/testing/driver' +import { WebIntegrationTestContext, createWebIntegrationTestContext } from './context' +import { test } from 'mocha' const searchResults = (): SearchResult => ({ search: { @@ -62,19 +64,34 @@ const searchResults = (): SearchResult => ({ }, }) -describeIntegration('Search', ({ describe }) => { - describe('Interactive search mode', ({ test }) => { - test('Search mode component appears', async ({ sourcegraphBaseUrl, driver }) => { - await driver.page.goto(sourcegraphBaseUrl + '/search') +describe('Search', () => { + let driver: Driver + before(async () => { + driver = await createDriverForTest() + }) + after(() => driver?.close()) + let testContext: WebIntegrationTestContext + beforeEach(async function () { + testContext = await createWebIntegrationTestContext({ + driver, + currentTest: this.currentTest!, + directory: __dirname, + }) + }) + afterEach(() => testContext?.dispose()) + + describe('Interactive search mode', () => { + test('Search mode component appears', async () => { + await driver.page.goto(driver.sourcegraphBaseUrl + '/search') await driver.page.waitForSelector('.e2e-search-mode-toggle') expect(await driver.page.evaluate(() => document.querySelectorAll('.e2e-search-mode-toggle').length)).toBe( 1 ) }) - test('Filter buttons', async ({ sourcegraphBaseUrl, driver, overrideGraphQL }) => { - overrideGraphQL({ - ...commonGraphQlResults, + test('Filter buttons', async () => { + testContext.overrideGraphQL({ + ...commonWebGraphQlResults, SearchSuggestions: () => ({ search: { suggestions: [ @@ -94,7 +111,7 @@ describeIntegration('Search', ({ describe }) => { ], }), }) - await driver.page.goto(sourcegraphBaseUrl + '/search') + await driver.page.goto(driver.sourcegraphBaseUrl + '/search') await driver.page.waitForSelector('.e2e-search-mode-toggle', { visible: true }) await driver.page.click('.e2e-search-mode-toggle') await driver.page.click('.e2e-search-mode-toggle__interactive-mode') @@ -138,13 +155,13 @@ describeIntegration('Search', ({ describe }) => { await driver.assertWindowLocation('/search?q=repo:%22gorilla/mux%22+file:%22README%22&patternType=literal') // Delete filter - await driver.page.goto(sourcegraphBaseUrl + '/search?q=repo:gorilla/mux&patternType=literal') + await driver.page.goto(driver.sourcegraphBaseUrl + '/search?q=repo:gorilla/mux&patternType=literal') await driver.page.waitForSelector('.e2e-filter-input__delete-button', { visible: true }) await driver.page.click('.e2e-filter-input__delete-button') await driver.assertWindowLocation('/search?q=&patternType=literal') // Test suggestions - await driver.page.goto(sourcegraphBaseUrl + '/search') + await driver.page.goto(driver.sourcegraphBaseUrl + '/search') await driver.page.waitForSelector('.e2e-add-filter-button-repo', { visible: true }) await driver.page.click('.e2e-add-filter-button-repo') await driver.page.waitForSelector('.filter-input', { visible: true }) @@ -179,13 +196,9 @@ describeIntegration('Search', ({ describe }) => { ) }) - test('Updates query when searching from directory page', async ({ - sourcegraphBaseUrl, - driver, - overrideGraphQL, - }) => { - overrideGraphQL({ - ...commonGraphQlResults, + test('Updates query when searching from directory page', async () => { + testContext.overrideGraphQL({ + ...commonWebGraphQlResults, RepositoryRedirect: () => ({ repositoryRedirect: { __typename: 'Repository', @@ -211,7 +224,7 @@ describeIntegration('Search', ({ describe }) => { }, }), }) - await driver.page.goto(sourcegraphBaseUrl + '/github.com/sourcegraph/jsonrpc2') + await driver.page.goto(driver.sourcegraphBaseUrl + '/github.com/sourcegraph/jsonrpc2') await driver.page.waitForSelector('.filter-input') const filterInputValue = () => driver.page.evaluate(() => { @@ -221,16 +234,12 @@ describeIntegration('Search', ({ describe }) => { assert.strictEqual(await filterInputValue(), 'repo:^github\\.com/sourcegraph/jsonrpc2$') }) - test('Filter dropdown and finite-option filter inputs', async ({ - sourcegraphBaseUrl, - driver, - overrideGraphQL, - }) => { - overrideGraphQL({ - ...commonGraphQlResults, + test('Filter dropdown and finite-option filter inputs', async () => { + testContext.overrideGraphQL({ + ...commonWebGraphQlResults, Search: searchResults, }) - await driver.page.goto(sourcegraphBaseUrl + '/search') + await driver.page.goto(driver.sourcegraphBaseUrl + '/search') await driver.page.waitForSelector('.e2e-query-input', { visible: true }) await driver.page.waitForSelector('.e2e-filter-dropdown') await driver.page.type('.e2e-query-input', 'test') @@ -261,9 +270,9 @@ describeIntegration('Search', ({ describe }) => { }) }) - describe('Case sensitivity toggle', ({ test }) => { - test('Clicking toggle turns on case sensitivity', async ({ sourcegraphBaseUrl, driver }) => { - await driver.page.goto(sourcegraphBaseUrl + '/search') + describe('Case sensitivity toggle', () => { + test('Clicking toggle turns on case sensitivity', async () => { + await driver.page.goto(driver.sourcegraphBaseUrl + '/search') await driver.page.waitForSelector('.e2e-query-input', { visible: true }) await driver.page.waitForSelector('.e2e-case-sensitivity-toggle') await driver.page.type('.e2e-query-input', 'test') @@ -271,16 +280,12 @@ describeIntegration('Search', ({ describe }) => { await driver.assertWindowLocation('/search?q=test&patternType=literal&case=yes') }) - test('Clicking toggle turns off case sensitivity and removes case= URL parameter', async ({ - sourcegraphBaseUrl, - driver, - overrideGraphQL, - }) => { - overrideGraphQL({ - ...commonGraphQlResults, + test('Clicking toggle turns off case sensitivity and removes case= URL parameter', async () => { + testContext.overrideGraphQL({ + ...commonWebGraphQlResults, Search: searchResults, }) - await driver.page.goto(sourcegraphBaseUrl + '/search?q=test&patternType=literal&case=yes') + await driver.page.goto(driver.sourcegraphBaseUrl + '/search?q=test&patternType=literal&case=yes') await driver.page.waitForSelector('.e2e-query-input', { visible: true }) await driver.page.waitForSelector('.e2e-case-sensitivity-toggle') await driver.page.click('.e2e-case-sensitivity-toggle') @@ -288,13 +293,13 @@ describeIntegration('Search', ({ describe }) => { }) }) - describe('Structural search toggle', ({ test }) => { - test('Clicking toggle turns on structural search', async ({ sourcegraphBaseUrl, driver, overrideGraphQL }) => { - overrideGraphQL({ - ...commonGraphQlResults, + describe('Structural search toggle', () => { + test('Clicking toggle turns on structural search', async () => { + testContext.overrideGraphQL({ + ...commonWebGraphQlResults, Search: searchResults, }) - await driver.page.goto(sourcegraphBaseUrl + '/search') + await driver.page.goto(driver.sourcegraphBaseUrl + '/search') await driver.page.waitForSelector('.e2e-query-input', { visible: true }) await driver.page.waitForSelector('.e2e-structural-search-toggle') await driver.page.type('.e2e-query-input', 'test') @@ -302,32 +307,24 @@ describeIntegration('Search', ({ describe }) => { await driver.assertWindowLocation('/search?q=test&patternType=structural') }) - test('Clicking toggle turns on structural search and removes existing patternType parameter', async ({ - sourcegraphBaseUrl, - driver, - overrideGraphQL, - }) => { - overrideGraphQL({ - ...commonGraphQlResults, + test('Clicking toggle turns on structural search and removes existing patternType parameter', async () => { + testContext.overrideGraphQL({ + ...commonWebGraphQlResults, Search: searchResults, }) - await driver.page.goto(sourcegraphBaseUrl + '/search?q=test&patternType=regexp') + await driver.page.goto(driver.sourcegraphBaseUrl + '/search?q=test&patternType=regexp') await driver.page.waitForSelector('.e2e-query-input', { visible: true }) await driver.page.waitForSelector('.e2e-structural-search-toggle') await driver.page.click('.e2e-structural-search-toggle') await driver.assertWindowLocation('/search?q=test&patternType=structural') }) - test('Clicking toggle turns off structural saerch and reverts to default pattern type', async ({ - sourcegraphBaseUrl, - driver, - overrideGraphQL, - }) => { - overrideGraphQL({ - ...commonGraphQlResults, + test('Clicking toggle turns off structural saerch and reverts to default pattern type', async () => { + testContext.overrideGraphQL({ + ...commonWebGraphQlResults, Search: searchResults, }) - await driver.page.goto(sourcegraphBaseUrl + '/search?q=test&patternType=structural') + await driver.page.goto(driver.sourcegraphBaseUrl + '/search?q=test&patternType=structural') await driver.page.waitForSelector('.e2e-query-input', { visible: true }) await driver.page.waitForSelector('.e2e-structural-search-toggle') await driver.page.click('.e2e-structural-search-toggle') diff --git a/web/src/integration/settings.test.ts b/web/src/integration/settings.test.ts index 51e506c2574..5993193710a 100644 --- a/web/src/integration/settings.test.ts +++ b/web/src/integration/settings.test.ts @@ -1,13 +1,30 @@ import assert from 'assert' -import { describeIntegration } from './helpers' import { retry } from '../../../shared/src/testing/utils' -import { commonGraphQlResults, testUserID, settingsID } from './graphQlResults' +import { createDriverForTest, Driver } from '../../../shared/src/testing/driver' +import { commonWebGraphQlResults } from './graphQlResults' +import { createWebIntegrationTestContext, WebIntegrationTestContext } from './context' +import { settingsID, testUserID } from '../../../shared/src/testing/integration/graphQlResults' -describeIntegration('Settings', ({ describe }) => { - describe('User settings page', ({ it }) => { - it('updates user settings', async ({ driver, sourcegraphBaseUrl, overrideGraphQL, waitForGraphQLRequest }) => { - overrideGraphQL({ - ...commonGraphQlResults, +describe('Settings', () => { + let driver: Driver + before(async () => { + driver = await createDriverForTest() + }) + after(() => driver?.close()) + let testContext: WebIntegrationTestContext + beforeEach(async function () { + testContext = await createWebIntegrationTestContext({ + driver, + currentTest: this.currentTest!, + directory: __dirname, + }) + }) + afterEach(() => testContext?.dispose()) + + describe('User settings page', () => { + it('updates user settings', async () => { + testContext.overrideGraphQL({ + ...commonWebGraphQlResults, SettingsCascade: () => ({ settingsSubject: { settingsCascade: { @@ -61,7 +78,7 @@ describeIntegration('Settings', ({ describe }) => { ) } - await driver.page.goto(sourcegraphBaseUrl + '/users/test/settings') + await driver.page.goto(driver.sourcegraphBaseUrl + '/users/test/settings') await driver.page.waitForSelector('.e2e-settings-file .monaco-editor') await driver.page.waitForSelector('.e2e-save-toolbar-save') @@ -96,7 +113,7 @@ describeIntegration('Settings', ({ describe }) => { ) // Assert mutation is done when save button is clicked - const overrideSettingsVariables = await waitForGraphQLRequest(async () => { + const overrideSettingsVariables = await testContext.waitForGraphQLRequest(async () => { await driver.findElementWithText('Save changes', { action: 'click' }) }, 'OverwriteSettings') diff --git a/yarn.lock b/yarn.lock index 82db319e60a..6ac89c680ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2284,7 +2284,8 @@ integrity sha512-KWxkyphmlwam8kfYPSmoitKQRMGQCsr1ZRmNZgijT7ABKaVyk/+I5ezt2J213tM04Hi0vyg4L7iH1VCkNvm2Jw== "@sourcegraph/extension-api-types@link:packages/@sourcegraph/extension-api-types": - version "2.1.0" + version "0.0.0" + uid "" "@sourcegraph/prettierrc@^3.0.3": version "3.0.3" @@ -3508,6 +3509,11 @@ resolved "https://registry.npmjs.org/@types/marked/-/marked-1.1.0.tgz#53509b5f127e0c05c19176fcf1d743a41e00ff19" integrity sha512-j8XXj6/l9kFvCwMyVqozznqpd/nk80krrW+QiIJN60Uu9gX5Pvn4/qPJ2YngQrR3QREPwmrE1f9/EWKVTFzoEw== +"@types/mime-types@2.1.0": + version "2.1.0" + resolved "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.0.tgz#9ca52cda363f699c69466c2a6ccdaad913ea7a73" + integrity sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM= + "@types/mime@*": version "2.0.0" resolved "https://registry.npmjs.org/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b" @@ -14710,7 +14716,7 @@ mime-db@1.44.0, "mime-db@>= 1.40.0 < 2": resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== -mime-types@^2.1.12, mime-types@^2.1.14, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: +mime-types@^2.1.12, mime-types@^2.1.14, mime-types@^2.1.27, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: version "2.1.27" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== @@ -19940,7 +19946,8 @@ sourcegraph@^24.0.0: integrity sha512-FEmuGVRcSqMtE5DItZcDa1ylm7FykcswIN6M6xa0vFbyLv6INYxjGYOGq/WlH4/AUQC0abmaysPXlyJRkGgMyg== "sourcegraph@link:packages/sourcegraph-extension-api": - version "24.6.0" + version "0.0.0" + uid "" space-separated-tokens@^1.0.0: version "1.1.2"