feat: improved DX and support for running component and e2e tests w/ gulp (#18135)

Co-authored-by: Tim Griesser <tgriesser10@gmail.com>
This commit is contained in:
Jessica Sachs
2021-09-30 12:11:47 -04:00
committed by GitHub
parent 2f5e53d442
commit d39b1694aa
43 changed files with 1113 additions and 339 deletions
+146 -52
View File
@@ -1,63 +1,135 @@
/**
* How the Cypress backend is started and watched. Formerly
* `node scripts/cypress.js open` or `node scripts/cypress.js run`
*
* @summary Gulp tasks to run the Cypress app.
*/
import chokidar from 'chokidar'
import path from 'path'
import childProcess, { ChildProcess } from 'child_process'
import pDefer from 'p-defer'
import { monorepoPaths } from '../monorepoPaths'
import { getGulpGlobal } from '../gulpConstants'
import { ENV_VARS, getGulpGlobal } from '../gulpConstants'
import { forked } from '../utils/childProcessUtils'
import { exitAndRemoveProcess } from './gulpRegistry'
import type { ChildProcess } from 'child_process'
/**
* Starts cypress, but watches the GraphQL files & restarts the server
* when any of those change
*/
export function startCypressWatch () {
const shouldWatch = getGulpGlobal('shouldWatch')
const pathToCli = path.resolve(monorepoPaths.root, 'cli', 'bin', 'cypress')
const watcher = shouldWatch ? chokidar.watch([
/**------------------------------------------------------------------------
* Cypress CLI
* Starts Cypress, like a user would.
* * openCypress - Normal `cypress open` command
* * runCypress - Normal `cypress run` command
*------------------------------------------------------------------------**/
export async function openCypressLaunchpad () {
return spawnCypressWithMode('open', 'dev', ENV_VARS.DEV_OPEN, ['--project', monorepoPaths.pkgLaunchpad])
}
export async function openCypressApp () {
return spawnCypressWithMode('open', 'dev', ENV_VARS.DEV_OPEN, ['--project', monorepoPaths.pkgApp])
}
export async function runCypressLaunchpad () {
return spawnCypressWithMode('run', 'dev', ENV_VARS.PROD, ['--project', monorepoPaths.pkgLaunchpad])
}
export async function runCypressApp () {
return spawnCypressWithMode('run', 'dev', ENV_VARS.PROD, ['--project', monorepoPaths.pkgApp])
}
export async function runCypressProd () {
return spawnCypressWithMode('run', 'prod', ENV_VARS.PROD)
}
/**------------------------------------------------------------------------
* Testing Tasks
* Building and running the Cypress app and graphql server for testing.
* * startCypressForTest - Start the Cypress server, but without watching
* * runCypressAgainstDist - Serve the dist'd frontend over file://
*------------------------------------------------------------------------**/
// Use the GQL Test Port (52300 by default, defined in ./gulp/gulpConstants)
// Spawns Cypress in "Test Cypress within Cypress" mode
export async function startCypressForTest () {
return spawnCypressWithMode('open', 'test', ENV_VARS.E2E_TEST_TARGET)
}
export async function runCypressAgainstDist () {
return spawnCypressWithMode('run', 'test', ENV_VARS.E2E_TEST_TARGET)
}
/**------------------------------------------------------------------------
* Start and Watch Utils
* * spawnCypressWithMode - Formerly known as: `node ./scripts/cypress.js run`
* * watchCypress - Watch the dev server and graphql files
*------------------------------------------------------------------------**/
async function spawnCypressWithMode (
mode: 'open' | 'run',
type: 'dev' | 'prod' | 'test',
env: Record<string, string> = {},
additionalArgv: string[] = [],
) {
let argv = process.argv.slice(3).concat(additionalArgv)
const debugFlag = getGulpGlobal('debug')
if (debugFlag) {
env = { ...env, CYPRESS_INTERNAL_DEV_DEBUG: debugFlag }
}
if (mode === 'open') {
if (!argv.includes('--project') && !argv.includes('--global')) {
argv.push('--global')
}
// If we've passed --record, it's for a "run" mode, probably in the same pipeline.
if (argv.includes('--record')) {
argv = argv.slice(0, argv.indexOf('--record'))
}
}
if (!argv.includes('--dev')) {
argv.push('--dev')
}
const finalEnv = {
...process.env,
...env,
LAUNCHPAD: '1',
}
return await forked(`cy:${mode}:${type}`, pathToCli, [mode, ...argv], {
env: finalEnv,
waitForData: false,
})
}
/**------------------------------------------------------------------------
* Watch Commands
* Starts Cypress, but watches the GraphQL files, and restarts the server.
* * startCypressWatch - Normal `cypress open` command, with watching
*------------------------------------------------------------------------**/
export async function startCypressWatch () {
const watcher = chokidar.watch([
'packages/graphql/src/**/*.{js,ts}',
'packages/server/lib/graphql/**/*.{js,ts}',
], {
cwd: monorepoPaths.root,
ignored: /\.gen\.ts/,
ignoreInitial: true,
}) : null
let child: ChildProcess | null = null
})
let isClosing = false
let isRestarting = false
let child: ChildProcess | null = null
const argv = process.argv.slice(3)
const pathToCli = path.resolve(monorepoPaths.root, 'cli', 'bin', 'cypress')
function openServer () {
if (child) {
child.removeAllListeners()
}
if (!argv.includes('--project') && !argv.includes('--global')) {
argv.push('--global')
}
if (!argv.includes('--dev')) {
argv.push('--dev')
}
const debugFlag = getGulpGlobal('debug')
if (debugFlag) {
process.env.CYPRESS_INTERNAL_DEV_DEBUG = debugFlag
}
child = childProcess.fork(pathToCli, ['open', ...argv], {
stdio: 'inherit',
execArgv: [],
env: {
...process.env,
LAUNCHPAD: '1',
CYPRESS_INTERNAL_DEV_WATCH: shouldWatch ? 'true' : undefined,
},
})
async function startCypressWithListeners () {
child = await spawnCypressWithMode('open', 'dev', ENV_VARS.DEV)
child.on('exit', (code) => {
if (isClosing) {
@@ -78,27 +150,49 @@ export function startCypressWatch () {
const dfd = pDefer()
if (child) {
child.on('exit', dfd.resolve)
isRestarting = true
child.send('close')
child.on('exit', dfd.resolve)
await exitAndRemoveProcess(child)
} else {
dfd.resolve()
}
await dfd.promise
if (child) {
child.removeAllListeners()
}
await startCypressWithListeners()
isRestarting = false
openServer()
}
if (shouldWatch) {
watcher?.on('add', restartServer)
watcher?.on('change', restartServer)
}
watcher.on('add', restartServer)
watcher.on('change', restartServer)
openServer()
await startCypressWithListeners()
process.on('beforeExit', () => {
isClosing = true
child?.send('close')
watcher.close()
})
}
export function wrapRunWithExit (proc: ChildProcess) {
function killAndExit (code: number) {
process.exit(code)
}
proc.on('exit', (code) => {
killAndExit(code ?? 0)
})
proc.on('error', (err) => {
console.error({ err })
killAndExit(1)
})
proc.on('disconnect', () => {
console.error('disconnected')
})
}
+3 -3
View File
@@ -11,7 +11,7 @@ import { nexusTypegen, watchNexusTypegen } from '../utils/nexusTypegenUtil'
import { monorepoPaths } from '../monorepoPaths'
import { spawned } from '../utils/childProcessUtils'
import { spawn } from 'child_process'
import { CYPRESS_INTERNAL_CLOUD_ENV } from '../gulpConstants'
import { DEFAULT_INTERNAL_CLOUD_ENV } from '../gulpConstants'
export async function nexusCodegen () {
return nexusTypegen({
@@ -89,12 +89,12 @@ const ENV_MAP = {
}
export async function syncRemoteGraphQL () {
if (!ENV_MAP[CYPRESS_INTERNAL_CLOUD_ENV]) {
if (!ENV_MAP[DEFAULT_INTERNAL_CLOUD_ENV]) {
throw new Error(`Expected --env to be one of ${Object.keys(ENV_MAP).join(', ')}`)
}
try {
const body = await rp.get(`${ENV_MAP[CYPRESS_INTERNAL_CLOUD_ENV]}/test-runner-graphql-schema`)
const body = await rp.get(`${ENV_MAP[DEFAULT_INTERNAL_CLOUD_ENV]}/test-runner-graphql-schema`)
// TODO(tim): fix
await fs.ensureDir(path.join(monorepoPaths.pkgGraphql, 'src/gen'))
+80
View File
@@ -0,0 +1,80 @@
import type { ChildProcess } from 'child_process'
import pDefer from 'p-defer'
import treeKill from 'tree-kill'
const childProcesses = new Set<ChildProcess>()
const exitedPids = new Set<number>()
let hasExited = false
export function addChildProcess (child: ChildProcess) {
if (hasExited) {
treeKill(child.pid)
return
}
childProcesses.add(child)
child.on('exit', () => {
if (!hasExited) {
exitAndRemoveProcess(child)
}
})
}
export async function exitAndRemoveProcess (child: ChildProcess) {
if (exitedPids.has(child.pid)) {
return
}
if (!childProcesses.has(child)) {
throw new Error(`Cannot remove child process ${child.pid}, it was never registered`)
}
childProcesses.delete(child)
const dfd = pDefer()
exitedPids.add(child.pid)
treeKill(child.pid, (err) => {
if (err) {
console.error(err)
}
dfd.resolve()
})
return dfd.promise
}
export async function exitAllProcesses () {
await Promise.all(Array.from(childProcesses).map(exitAndRemoveProcess))
}
process.stdin.resume() //so the program will not close instantly
export async function exitAfterAll () {
process.stdin.pause()
}
function exitHandler (msg: string) {
return async function _exitHandler (exitCode: number) {
hasExited = true
console.log(`Exiting due to ${msg} => code ${exitCode}`)
await exitAllProcesses()
process.exit(exitCode)
}
}
// do something when app is closing
process.on('exit', exitHandler('exit'))
// catches ctrl+c event
process.on('SIGINT', exitHandler('SIGINT'))
// catches "kill pid" (for example: nodemon restart)
process.on('SIGUSR1', exitHandler('SIGUSR1'))
process.on('SIGUSR2', exitHandler('SIGUSR2'))
// catches uncaught exceptions
process.on('uncaughtException', exitHandler('uncaughtException'))
+168 -27
View File
@@ -1,21 +1,104 @@
import type { SpawnOptions } from 'child_process'
import getenv from 'getenv'
import pDefer from 'p-defer'
import { monorepoPaths } from '../monorepoPaths'
import { AllSpawnableApps, spawned } from '../utils/childProcessUtils'
/**
* The Launchpad and App clients are both built with Vite and largely
* share the same test pipeline and build commands.
*
* @summary Build pipeline for the Vite frontend(s):
* @packages/launchpad + @packages/app
* @docs https://vitejs.dev
*/
const CYPRESS_VITE_APP_PORT = getenv.int('CYPRESS_VITE_APP_PORT', 3333)
const CYPRESS_VITE_LAUNCHPAD_PORT = getenv.int('CYPRESS_VITE_LAUNCHPAD_PORT', 3001)
import type { SpawnOptions } from 'child_process'
import { ENV_VARS } from '../gulpConstants'
import { monorepoPaths } from '../monorepoPaths'
import { AllSpawnableApps, spawned, spawnUntilMatch } from '../utils/childProcessUtils'
/**------------------------------------------------------------------------
* Local Development Workflow
* Spawn the Vite frontend dev servers in watch mode.
* * viteApp
* * viteLaunchpad
* * watchViteBuild
*------------------------------------------------------------------------**/
export function viteApp () {
return viteDev('vite-app', `vite --port ${CYPRESS_VITE_APP_PORT} --base /__vite__/`, {
const GQL_PORT = ENV_VARS.DEV.CYPRESS_INTERNAL_GQL_PORT
const APP_PORT = ENV_VARS.DEV.CYPRESS_INTERNAL_VITE_APP_PORT
return spawnViteDevServer('vite-app', `yarn vite --port ${APP_PORT} --base /__vite__/`, {
cwd: monorepoPaths.pkgApp,
env: {
VITE_CYPRESS_INTERNAL_GQL_PORT: GQL_PORT,
},
})
}
export function viteLaunchpad () {
return viteDev('vite-launchpad', `vite --port ${CYPRESS_VITE_LAUNCHPAD_PORT}`, {
const GQL_PORT = ENV_VARS.DEV.CYPRESS_INTERNAL_GQL_PORT
const LAUNCHPAD_PORT = ENV_VARS.DEV.CYPRESS_INTERNAL_VITE_LAUNCHPAD_PORT
return spawnViteDevServer('vite-launchpad', `yarn vite --port ${LAUNCHPAD_PORT}`, {
cwd: monorepoPaths.pkgLaunchpad,
env: {
VITE_CYPRESS_INTERNAL_GQL_PORT: GQL_PORT,
},
})
}
// This watcher task is generally used within cypress:open when running in
// end-to-end mode.
function watchViteBuild (
prefix: AllSpawnableApps,
command: string,
options: SpawnOptions = {},
) {
// This will match strings like "built in 200ms" and "built in 5s"
return spawnUntilMatch(prefix, {
command,
match: /built in (\d+)(m?s)/i,
options,
})
}
function spawnViteDevServer (
prefix: AllSpawnableApps,
command: string,
options: SpawnOptions = {},
) {
return spawnUntilMatch(prefix, {
command,
match: 'dev server running at',
options,
})
}
/**------------------------------------------------------------------------
* Build Tasks
* Build the Vite frontend(s) for production to be served by the Launchpad
* and App. Generally used in CI.
* * viteBuildApp
* * viteBuildLaunchpad
*------------------------------------------------------------------------**/
export function viteBuildApp () {
return spawned('vite:build-app', `yarn vite build`, {
cwd: monorepoPaths.pkgApp,
waitForExit: true,
env: {
...process.env,
NODE_ENV: 'production',
},
})
}
export function viteBuildLaunchpad () {
return spawned('vite:build-launchpad', `yarn vite build`, {
cwd: monorepoPaths.pkgLaunchpad,
waitForExit: true,
env: {
...process.env,
NODE_ENV: 'production',
},
})
}
@@ -33,25 +116,83 @@ export function viteCleanLaunchpad () {
})
}
function viteDev (
prefix: AllSpawnableApps,
command: string,
opts: SpawnOptions = {},
/**------------------------------------------------------------------------
* Testing Tasks
* Build and serve the Vite frontend(s) as web apps on a static server.
* * viteBuildLaunchpadForTest
* * viteBuildAppForTest
* * serveBuiltLaunchpadForTest
* * serveBuiltAppForTest
*------------------------------------------------------------------------**/
) {
const dfd = pDefer()
let ready = false
spawned(prefix, command, opts, {
tapOut (chunk, enc, cb) {
if (!ready && String(chunk).includes('dev server running at')) {
ready = true
setTimeout(() => dfd.resolve(), 20) // flush the rest of the chunks
}
cb(null, chunk)
// After running `serveBuiltLaunchpadForTest`, you're able to visit
// `http://localhost:5555` to access the Launchpad frontend.
export function serveBuiltLaunchpadForTest () {
return spawnUntilMatch('serve:launchpad-for-test', {
command: `yarn serve ./dist-e2e -p 5555`,
match: 'Accepting connections',
options: {
cwd: monorepoPaths.pkgLaunchpad,
},
})
}
export function viteBuildLaunchpadForTest () {
const GQL_PORT = ENV_VARS.E2E_TEST_TARGET.CYPRESS_INTERNAL_GQL_PORT
return spawned('vite:build-launchpad-for-test', `yarn vite build --outDir=./dist-e2e`, {
cwd: monorepoPaths.pkgLaunchpad,
waitForExit: true,
env: {
NODE_ENV: 'production',
VITE_CYPRESS_INTERNAL_GQL_PORT: GQL_PORT,
},
})
}
export async function viteBuildAndWatchLaunchpadForTest () {
const GQL_PORT = ENV_VARS.E2E_TEST_TARGET.CYPRESS_INTERNAL_GQL_PORT
return watchViteBuild('vite:build-watch-launchpad-for-test', `yarn vite build --watch --outDir=./dist-e2e`, {
cwd: monorepoPaths.pkgLaunchpad,
env: {
NODE_ENV: 'production',
VITE_CYPRESS_INTERNAL_GQL_PORT: GQL_PORT,
},
})
}
/**----------------------
*todo Implement E2E tests for the App.
*------------------------**/
export function viteBuildAppForTest () {
const GQL_PORT = ENV_VARS.E2E_TEST_TARGET.CYPRESS_INTERNAL_GQL_PORT
return spawned('vite:build-app-for-test', `yarn vite build --outDir=./dist-e2e`, {
cwd: monorepoPaths.pkgApp,
waitForExit: true,
env: {
VITE_CYPRESS_INTERNAL_GQL_PORT: GQL_PORT,
...process.env,
},
})
}
export function serveBuiltAppForTest () {
return spawned('serve:app-for-test', `yarn serve ./dist-e2e -p 5556`, {
cwd: monorepoPaths.pkgApp,
})
}
export async function viteBuildAndWatchAppForTest () {
const GQL_PORT = ENV_VARS.E2E_TEST_TARGET.CYPRESS_INTERNAL_GQL_PORT
return watchViteBuild('vite:build-watch-app-for-test', `yarn vite build --watch --outDir=./dist-e2e`, {
cwd: monorepoPaths.pkgApp,
env: {
NODE_ENV: 'production',
VITE_CYPRESS_INTERNAL_GQL_PORT: GQL_PORT,
},
})
return dfd.promise
}