Use mocha directly for integration tests and move to shared/ (#12130)

This commit is contained in:
Felix Becker 2020-07-13 21:22:02 +02:00 committed by GitHub
parent 82f8b6d1d2
commit a1d87dc56f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 441 additions and 476 deletions

View File

@ -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",

View File

@ -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<Driver> {
const { loadExtension, sourcegraphBaseUrl, logBrowserConsole, keepBrowser } = options
export async function createDriverForTest(
options: DriverOptions = getConfig('sourcegraphBaseUrl', 'headless', 'slowMo')
): Promise<Driver> {
const { loadExtension, sourcegraphBaseUrl, logBrowserConsole = true, keepBrowser } = options
const args: string[] = []
const launchOptions: puppeteer.LaunchOptions = {
...options,

View File

@ -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.

View File

@ -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<TGraphQlOperationNames, (variables: any) => 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<TGraphQlOperations>) => 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: <O extends TGraphQlOperationNames>(
triggerRequest: () => Promise<void> | void,
operationName: O
) => Promise<Parameters<TGraphQlOperations[O]>[0]>
dispose: () => Promise<void>
}
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<TGraphQlOperationNames, (variables: any) => any>,
TGraphQlOperationNames extends string
>({
driver,
currentTest,
directory,
}: IntegrationTestOptions): Promise<IntegrationTestContext<TGraphQlOperations, TGraphQlOperationNames>> => {
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<O extends TGraphQlOperationNames> {
operationName: O
variables: Parameters<TGraphQlOperations[O]>[0]
}
let graphQlOverrides: Partial<TGraphQlOperations> = {}
const graphQlRequests = new Subject<GraphQLRequestEvent<TGraphQlOperationNames>>()
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<TGraphQlOperations[TGraphQlOperationNames]>[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<any> = { 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 <O extends TGraphQlOperationNames>(
triggerRequest: () => Promise<void> | void,
operationName: O
): Promise<Parameters<TGraphQlOperations[O]>[0]> => {
const requestPromise = graphQlRequests
.pipe(
first(
(request: GraphQLRequestEvent<TGraphQlOperationNames>): request is GraphQLRequestEvent<O> =>
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()
},
}
}

View File

@ -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<SharedGraphQlOperations> = {}

View File

@ -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<puppeteer.Request, { respond: (response: Puppeteer.Response) => void }>()
private pendingRequests = new Map<Puppeteer.Request, { respond: (response: Puppeteer.Response) => 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<puppeteer.Response>(resolve => (respond = resolve))
let respond: (response: Puppeteer.Response) => void
const responsePromise = new Promise<Puppeteer.Response>(resolve => (respond = resolve))
this.passThroughRequests.set(request, { respond: response => respond(response), responsePromise })
await request.continue()
const response = await responsePromise

View File

@ -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<WebIntegrationTestContext> => {
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`
<html>
<head>
<title>Sourcegraph Test</title>
</head>
<body>
<div id="root"></div>
<script>
window.context = ${JSON.stringify(jsContext)}
</script>
<script src="/.assets/scripts/app.bundle.js"></script>
</body>
</html>
`)
})
return {
...sharedTestContext,
overrideJsContext: overrides => {
jsContext = overrides
},
}
}

View File

@ -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<WebGraphQlOperations & SharedGraphQlOperations> = {
...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],
},

View File

@ -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<AllGraphQlOperations>
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: <O extends GraphQLOperationName>(
triggerRequest: () => Promise<void> | void,
operationName: O
) => Promise<Parameters<AllGraphQlOperations[O]>[0]>
}
type TestBody = (context: TestContext) => Promise<void>
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>
) => 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<never>()
server.get(new URL('/.assets/*path', sourcegraphBaseUrl).href).passthrough()
// GraphQL requests are not handled by HARs, but configured per-test.
interface GraphQLRequestEvent<O extends GraphQLOperationName> {
operationName: O
variables: Parameters<AllGraphQlOperations[O]>[0]
}
let graphQlOverrides: GraphQLOverrides = commonGraphQlResults
const graphQlRequests = new Subject<GraphQLRequestEvent<GraphQLOperationName>>()
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<AllGraphQlOperations[GraphQLOperationName]>[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<any> = { 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`
<html>
<head>
<title>Sourcegraph Test</title>
</head>
<body>
<div id="root"></div>
<script>
window.context = ${JSON.stringify(jsContext)}
</script>
<script src="/.assets/scripts/app.bundle.js"></script>
</body>
</html>
`)
})
// 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 <O extends GraphQLOperationName>(
triggerRequest: () => Promise<void> | void,
operationName: O
): Promise<Parameters<AllGraphQlOperations[O]>[0]> => {
const requestPromise = graphQlRequests
.pipe(
first(
(
request: GraphQLRequestEvent<GraphQLOperationName>
): request is GraphQLRequestEvent<O> =>
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()
})
})
}

View File

@ -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')

View File

@ -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')

View File

@ -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"