From b25756bc5e72cacefc355329232b7d81aa475a4f Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Tue, 15 Sep 2026 06:41:49 -0500 Subject: [PATCH 1/6] fix: isolate E2E artifacts and bound browser concurrency --- .github/workflows/pr.yml | 28 +++ .github/workflows/release.yml | 8 + .../realtime-trading/tests/e2e/smoke.spec.ts | 35 +++- nx.json | 9 +- package.json | 7 +- playwright.config.ts | 15 +- scripts/run-e2e-with-retry.mjs | 25 --- scripts/run-e2e.mjs | 19 ++ scripts/tests/e2e-infrastructure.test.mjs | 162 ++++++++++++++++++ 9 files changed, 263 insertions(+), 45 deletions(-) delete mode 100644 scripts/run-e2e-with-retry.mjs create mode 100644 scripts/run-e2e.mjs create mode 100644 scripts/tests/e2e-infrastructure.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d646d3ce88..02a74b4a8d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -42,6 +42,34 @@ jobs: - name: Stop Nx Agents if: ${{ always() }} run: npx nx-cloud stop-all-agents + e2e: + name: E2E + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Setup Tools + uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main + - name: Get base and head commits for `nx affected` + uses: nrwl/nx-set-shas@3e9ad7370203c1e93d109be57f3b72eb0eb511b1 # v4.4.0 + with: + main-branch-name: main + - name: Install Playwright Browsers + run: pnpm run test:e2e:install + - name: Run E2E Tests + run: pnpm run test:e2e:affected + - name: Upload E2E failure artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: e2e-failures + path: test-results/ + if-no-files-found: ignore + retention-days: 7 coverage: name: Coverage Report runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26a190976c..08c0f191cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,3 +73,11 @@ jobs: run: pnpm run test:e2e:install - name: Run E2E Tests run: pnpm run test:e2e + - name: Upload E2E failure artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: e2e-failures + path: test-results/ + if-no-files-found: ignore + retention-days: 7 diff --git a/examples/octane/realtime-trading/tests/e2e/smoke.spec.ts b/examples/octane/realtime-trading/tests/e2e/smoke.spec.ts index 992ddb1b2b..247454bf0a 100644 --- a/examples/octane/realtime-trading/tests/e2e/smoke.spec.ts +++ b/examples/octane/realtime-trading/tests/e2e/smoke.spec.ts @@ -83,6 +83,23 @@ test('runs the Octane realtime trading workload', async ({ page }) => { await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + // Exercise delivery and rendering without turning this smoke test into a + // throughput benchmark on shared CI runners. Configure controls while paused. + await setRangeValue(targetRateSlider, '3') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '1K samples/s', + ) + await publishInterval.selectOption('250') + await expect(publishInterval).toHaveValue('250') + await sparklineInterval.selectOption('100') + const swapRenderer = page.getByRole('checkbox', { + name: /Swap Tick component/, + }) + await swapRenderer.check() + await expect(swapRenderer).toBeChecked() + + const firstPrice = table.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() await resumeTradingFeed(page) await expect .poll(async () => { @@ -107,21 +124,21 @@ test('runs the Octane realtime trading workload', async ({ page }) => { ) .toBeGreaterThan(0) - const firstPrice = page.locator('tbody tr').first().getByRole('button') - const priceBeforeUpdate = await firstPrice.textContent() await expect .poll(() => firstPrice.textContent()) .not.toBe(priceBeforeUpdate) - await page.locator('.config-section input[type="checkbox"]').first().check() + // Row-model timing is sampled every twentieth call, so wait for a sample + // before stopping the lower-rate feed. + await expect + .poll(() => + page.evaluate( + () => performance.getEntriesByName('tanstack-row-model').length, + ), + ) + .toBeGreaterThan(0) await pauseTradingFeed(page) - expect( - await page.evaluate( - () => performance.getEntriesByName('tanstack-row-model').length > 0, - ), - ).toBe(true) - expect(errors).toEqual([]) } finally { await server.close() diff --git a/nx.json b/nx.json index 9a628d09f9..d90df48b16 100644 --- a/nx.json +++ b/nx.json @@ -46,7 +46,14 @@ }, "test:e2e": { "dependsOn": ["^build"], - "inputs": ["default", "^public"], + "inputs": [ + "default", + "^public", + "{workspaceRoot}/playwright.config.ts", + "{workspaceRoot}/tests/e2e/helpers/**/*", + "{workspaceRoot}/scripts/run-e2e.mjs", + "{workspaceRoot}/scripts/tests/e2e-infrastructure.test.mjs" + ], "cache": true }, "test:build": { diff --git a/package.json b/package.json index 1c20d991f1..620b9910f8 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "test:ci": "pnpm run test:compiler-examples && nx run-many --targets=test:eslint,test:sherif,test:knip,test:lib,test:types,test:build,build", "test:compiler-examples": "node scripts/verify-react-compiler-examples.mjs", "test:docs": "node scripts/verify-links.ts", - "test:e2e": "node scripts/run-e2e-with-retry.mjs", - "test:e2e:affected": "nx affected --target=test:e2e", + "test:e2e": "pnpm run test:e2e:infrastructure && node scripts/run-e2e.mjs", + "test:e2e:affected": "pnpm run test:e2e:infrastructure && node scripts/run-e2e.mjs --affected", "test:e2e:install": "playwright install chromium", "test:eslint": "nx affected --target=test:eslint", "test:intent": "intent validate && intent stale", @@ -48,7 +48,8 @@ "test:types": "nx affected --targets=test:types", "skills:versions:check": "node scripts/sync-skill-versions.mjs", "skills:versions:fix": "node scripts/sync-skill-versions.mjs --write", - "watch": "pnpm run build:all && nx watch --all -- pnpm run build:all" + "watch": "pnpm run build:all && nx watch --all -- pnpm run build:all", + "test:e2e:infrastructure": "node --test scripts/tests/e2e-infrastructure.test.mjs" }, "nx": { "includedScripts": [ diff --git a/playwright.config.ts b/playwright.config.ts index e8b89f9447..9b9180e84f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,20 +17,21 @@ function getProjectName() { export default defineConfig({ testDir, - // The unit of parallelism is the spec file, not the test. Each example's spec - // starts one dev server in `beforeAll` and shares it across its tests; with - // `fullyParallel` every test becomes its own job, so CI's two workers would - // split a single file and start that server twice. + // Separate Playwright processes must never clean each other's traces/results. + outputDir: path.join(import.meta.dirname, 'test-results', getProjectName()), + // Keep each spec serial: examples may share a dev server across its tests. + // Nx schedules separate example processes. fullyParallel: false, timeout: 60_000, expect: { timeout: 10_000, }, - retries: process.env.CI ? 1 : 0, - workers: process.env.CI ? 2 : undefined, + retries: 0, + // Nx owns concurrency; each example gets one browser worker. + workers: 1, use: { screenshot: 'only-on-failure', - trace: 'on-first-retry', + trace: 'retain-on-failure', video: 'off', }, projects: [ diff --git a/scripts/run-e2e-with-retry.mjs b/scripts/run-e2e-with-retry.mjs deleted file mode 100644 index 5860ebbc8e..0000000000 --- a/scripts/run-e2e-with-retry.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const nxArgs = ['run-many', '--target=test:e2e', ...process.argv.slice(2)] - -function runE2e(label) { - if (label) { - console.log(`\n${label}\n`) - } - - return spawnSync('nx', nxArgs, { - stdio: 'inherit', - env: process.env, - }) -} - -const first = runE2e() -if (first.status === 0) { - process.exit(0) -} - -const second = runE2e( - 'Some e2e projects failed. Retrying failed projects once (successful runs use Nx cache)...', -) - -process.exit(second.status ?? 1) diff --git a/scripts/run-e2e.mjs b/scripts/run-e2e.mjs new file mode 100644 index 0000000000..51ff2aa549 --- /dev/null +++ b/scripts/run-e2e.mjs @@ -0,0 +1,19 @@ +import { spawnSync } from 'node:child_process' + +const args = process.argv.slice(2) +const affected = args.includes('--affected') +// Each task owns a Vite server and Chromium. The general Nx default (5) is +// too expensive for the four-core CI runner, especially for 200K-row examples. +const result = spawnSync( + 'nx', + [ + affected ? 'affected' : 'run-many', + '--target=test:e2e', + '--parallel=2', + ...args.filter((arg) => arg !== '--affected'), + ], + { stdio: 'inherit', env: process.env }, +) + +if (result.error) console.error(result.error) +process.exit(result.status ?? 1) diff --git a/scripts/tests/e2e-infrastructure.test.mjs b/scripts/tests/e2e-infrastructure.test.mjs new file mode 100644 index 0000000000..e2ed2a01c2 --- /dev/null +++ b/scripts/tests/e2e-infrastructure.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import { once } from 'node:events' +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test } from 'node:test' + +const root = fileURLToPath(new URL('../../', import.meta.url)) +const playwright = path.join(root, 'node_modules/@playwright/test/cli.js') + +async function filesBelow(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + const nested = await Promise.all( + entries.map((entry) => { + const filename = path.join(directory, entry.name) + return entry.isDirectory() ? filesBelow(filename) : [filename] + }), + ) + return nested.flat() +} + +test( + 'overlapping Playwright processes retain both sets of failure artifacts', + { + timeout: 60_000, + }, + async () => { + // Keep fixtures inside the workspace so they resolve its Playwright install. + const parent = path.join(root, '.cache') + await mkdir(parent, { recursive: true }) + const fixture = await mkdtemp(path.join(parent, 'e2e-artifacts-')) + const output = path.join(root, 'test-results', path.basename(fixture)) + const marker = path.join(fixture, 'second-started') + const children = [] + try { + for (const name of ['first', 'second']) { + const directory = path.join(fixture, name, 'tests/e2e') + await mkdir(directory, { recursive: true }) + await writeFile( + path.join(directory, 'failure.spec.ts'), + ` + import { test, expect } from '@playwright/test' + import { existsSync, writeFileSync } from 'node:fs' + test('intentional failure', async ({ page }) => { + await page.setContent('

${name}

') + ${ + name === 'first' + ? `console.log('FIRST_READY'); await expect.poll(() => existsSync(${JSON.stringify(marker)}), { timeout: 20000 }).toBe(true)` + : `writeFileSync(${JSON.stringify(marker)}, 'ready')` + } + expect('intentional artifact failure').toBe('success') + }) + `, + ) + } + function start(name) { + const child = spawn( + process.execPath, + [ + playwright, + 'test', + '--config', + path.join(root, 'playwright.config.ts'), + '--reporter=line', + ], + { + cwd: root, + env: { + ...process.env, + CI: '1', + PLAYWRIGHT_TEST_DIR: path.join(fixture, name, 'tests/e2e'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + children.push(child) + let log = '' + const ready = new Promise((resolve) => { + child.stdout.on('data', (chunk) => { + log += chunk + if (log.includes('FIRST_READY')) resolve(true) + }) + child.once('close', () => resolve(false)) + }) + child.stderr.on('data', (chunk) => { + log += chunk + }) + const done = once(child, 'close').then(([code]) => ({ code, log })) + return { ready, done } + } + const first = start('first') + if (!(await first.ready)) assert.fail((await first.done).log) + const second = start('second') + for (const result of await Promise.all([first.done, second.done])) { + assert.equal(result.code, 1, result.log) + assert.match(result.log, /intentional artifact failure/) + assert.doesNotMatch(result.log, /ENOENT|Retry #/) + } + for (const name of ['first', 'second']) { + const files = await filesBelow(path.join(output, name)) + const trace = files.find((file) => file.endsWith('trace.zip')) + const screenshot = files.find((file) => file.endsWith('.png')) + assert.ok(trace, `${name} must retain its first-attempt trace`) + assert.ok(screenshot, `${name} must retain its screenshot`) + assert.equal((await readFile(trace)).subarray(0, 2).toString(), 'PK') + } + } finally { + for (const child of children) { + if (child.exitCode === null) child.kill() + } + await rm(fixture, { recursive: true, force: true }) + await rm(output, { recursive: true, force: true }) + } + }, +) + +test('the E2E runner propagates a failure without launching a retry', async () => { + const fixture = await mkdtemp( + path.join(root, 'node_modules/.cache/e2e-runner-'), + ) + try { + const calls = path.join(fixture, 'calls.jsonl') + await writeFile( + path.join(fixture, 'nx'), + `#!/usr/bin/env node + require('node:fs').appendFileSync(${JSON.stringify(calls)}, JSON.stringify(process.argv.slice(2)) + '\\n') + process.exit(7) + `, + { mode: 0o755 }, + ) + const result = spawnSync( + process.execPath, + ['scripts/run-e2e.mjs', '--affected', '--base=main'], + { + cwd: root, + env: { + ...process.env, + PATH: `${fixture}${path.delimiter}${process.env.PATH}`, + }, + encoding: 'utf8', + }, + ) + assert.equal(result.status, 7, result.stderr) + const invocations = (await readFile(calls, 'utf8')) + .trim() + .split('\n') + .map(JSON.parse) + assert.deepEqual(invocations, [ + ['affected', '--target=test:e2e', '--parallel=2', '--base=main'], + ]) + } finally { + await rm(fixture, { recursive: true, force: true }) + } +}) From 902ff0bb7f47280363094cbbd45ad2854974a954 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Tue, 15 Sep 2026 06:45:04 -0500 Subject: [PATCH 2/6] test: create E2E fixture directories on clean checkouts --- scripts/tests/e2e-infrastructure.test.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/tests/e2e-infrastructure.test.mjs b/scripts/tests/e2e-infrastructure.test.mjs index e2ed2a01c2..6ceb09667c 100644 --- a/scripts/tests/e2e-infrastructure.test.mjs +++ b/scripts/tests/e2e-infrastructure.test.mjs @@ -123,9 +123,9 @@ test( ) test('the E2E runner propagates a failure without launching a retry', async () => { - const fixture = await mkdtemp( - path.join(root, 'node_modules/.cache/e2e-runner-'), - ) + const parent = path.join(root, '.cache') + await mkdir(parent, { recursive: true }) + const fixture = await mkdtemp(path.join(parent, 'e2e-runner-')) try { const calls = path.join(fixture, 'calls.jsonl') await writeFile( From b3720aeca5dd3af69a6f7fc1715edcfb25b141a2 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Tue, 15 Sep 2026 06:45:34 -0500 Subject: [PATCH 3/6] test: use ESM in the E2E runner fixture --- scripts/tests/e2e-infrastructure.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/tests/e2e-infrastructure.test.mjs b/scripts/tests/e2e-infrastructure.test.mjs index 6ceb09667c..b55647302a 100644 --- a/scripts/tests/e2e-infrastructure.test.mjs +++ b/scripts/tests/e2e-infrastructure.test.mjs @@ -131,7 +131,8 @@ test('the E2E runner propagates a failure without launching a retry', async () = await writeFile( path.join(fixture, 'nx'), `#!/usr/bin/env node - require('node:fs').appendFileSync(${JSON.stringify(calls)}, JSON.stringify(process.argv.slice(2)) + '\\n') + import { appendFileSync } from 'node:fs' + appendFileSync(${JSON.stringify(calls)}, JSON.stringify(process.argv.slice(2)) + '\\n') process.exit(7) `, { mode: 0o755 }, From 433bdc31441c695f6f9a345944f78c389e3d1520 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Tue, 15 Sep 2026 07:52:51 -0500 Subject: [PATCH 4/6] fix: preserve E2E retries and clean up server processes --- nx.json | 2 +- package.json | 4 +- playwright.config.ts | 2 +- scripts/run-e2e-with-retry.mjs | 28 ++++ scripts/run-e2e.mjs | 19 --- scripts/tests/e2e-infrastructure.test.mjs | 176 +++++++++++++++++----- tests/e2e/helpers/startExampleServer.ts | 66 +++++--- 7 files changed, 222 insertions(+), 75 deletions(-) create mode 100644 scripts/run-e2e-with-retry.mjs delete mode 100644 scripts/run-e2e.mjs diff --git a/nx.json b/nx.json index d90df48b16..c108a0a124 100644 --- a/nx.json +++ b/nx.json @@ -51,7 +51,7 @@ "^public", "{workspaceRoot}/playwright.config.ts", "{workspaceRoot}/tests/e2e/helpers/**/*", - "{workspaceRoot}/scripts/run-e2e.mjs", + "{workspaceRoot}/scripts/run-e2e-with-retry.mjs", "{workspaceRoot}/scripts/tests/e2e-infrastructure.test.mjs" ], "cache": true diff --git a/package.json b/package.json index 620b9910f8..e389ef0d00 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "test:ci": "pnpm run test:compiler-examples && nx run-many --targets=test:eslint,test:sherif,test:knip,test:lib,test:types,test:build,build", "test:compiler-examples": "node scripts/verify-react-compiler-examples.mjs", "test:docs": "node scripts/verify-links.ts", - "test:e2e": "pnpm run test:e2e:infrastructure && node scripts/run-e2e.mjs", - "test:e2e:affected": "pnpm run test:e2e:infrastructure && node scripts/run-e2e.mjs --affected", + "test:e2e": "pnpm run test:e2e:infrastructure && node scripts/run-e2e-with-retry.mjs", + "test:e2e:affected": "pnpm run test:e2e:infrastructure && node scripts/run-e2e-with-retry.mjs --affected", "test:e2e:install": "playwright install chromium", "test:eslint": "nx affected --target=test:eslint", "test:intent": "intent validate && intent stale", diff --git a/playwright.config.ts b/playwright.config.ts index 9b9180e84f..25b66d796e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ expect: { timeout: 10_000, }, - retries: 0, + retries: process.env.CI ? 1 : 0, // Nx owns concurrency; each example gets one browser worker. workers: 1, use: { diff --git a/scripts/run-e2e-with-retry.mjs b/scripts/run-e2e-with-retry.mjs new file mode 100644 index 0000000000..09126e4f5b --- /dev/null +++ b/scripts/run-e2e-with-retry.mjs @@ -0,0 +1,28 @@ +import { spawnSync } from 'node:child_process' + +const args = process.argv.slice(2) +const affected = args.includes('--affected') +// Each task owns a Vite server and Chromium. The general Nx default (5) is +// too expensive for the four-core CI runner, especially for 200K-row examples. +function runE2e() { + return spawnSync( + 'nx', + [ + affected ? 'affected' : 'run-many', + '--target=test:e2e', + '--parallel=2', + ...args.filter((arg) => arg !== '--affected' && arg !== '--no-retry'), + ], + { stdio: 'inherit', env: process.env }, + ) +} +const first = runE2e() +if (first.status === 0) process.exit(0) +if (first.error) console.error(first.error) +if (args.includes('--no-retry')) process.exit(first.status ?? 1) +console.log( + 'Some e2e projects failed. Retrying failed projects once (successful runs use Nx cache)...', +) +const second = runE2e() +if (second.error) console.error(second.error) +process.exit(second.status ?? 1) diff --git a/scripts/run-e2e.mjs b/scripts/run-e2e.mjs deleted file mode 100644 index 51ff2aa549..0000000000 --- a/scripts/run-e2e.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const args = process.argv.slice(2) -const affected = args.includes('--affected') -// Each task owns a Vite server and Chromium. The general Nx default (5) is -// too expensive for the four-core CI runner, especially for 200K-row examples. -const result = spawnSync( - 'nx', - [ - affected ? 'affected' : 'run-many', - '--target=test:e2e', - '--parallel=2', - ...args.filter((arg) => arg !== '--affected'), - ], - { stdio: 'inherit', env: process.env }, -) - -if (result.error) console.error(result.error) -process.exit(result.status ?? 1) diff --git a/scripts/tests/e2e-infrastructure.test.mjs b/scripts/tests/e2e-infrastructure.test.mjs index b55647302a..3375f03d8e 100644 --- a/scripts/tests/e2e-infrastructure.test.mjs +++ b/scripts/tests/e2e-infrastructure.test.mjs @@ -70,6 +70,7 @@ test( '--config', path.join(root, 'playwright.config.ts'), '--reporter=line', + '--retries=0', ], { cwd: root, @@ -122,42 +123,149 @@ test( }, ) -test('the E2E runner propagates a failure without launching a retry', async () => { - const parent = path.join(root, '.cache') - await mkdir(parent, { recursive: true }) - const fixture = await mkdtemp(path.join(parent, 'e2e-runner-')) - try { - const calls = path.join(fixture, 'calls.jsonl') - await writeFile( - path.join(fixture, 'nx'), - `#!/usr/bin/env node +for (const retry of [true, false]) { + test(`the E2E runner ${retry ? 'retries once by default' : 'supports explicit verification without retries'}`, async () => { + const parent = path.join(root, '.cache') + await mkdir(parent, { recursive: true }) + const fixture = await mkdtemp(path.join(parent, 'e2e-runner-')) + try { + const calls = path.join(fixture, 'calls.jsonl') + await writeFile( + path.join(fixture, 'nx'), + `#!/usr/bin/env node import { appendFileSync } from 'node:fs' appendFileSync(${JSON.stringify(calls)}, JSON.stringify(process.argv.slice(2)) + '\\n') process.exit(7) `, - { mode: 0o755 }, - ) - const result = spawnSync( - process.execPath, - ['scripts/run-e2e.mjs', '--affected', '--base=main'], - { - cwd: root, - env: { - ...process.env, - PATH: `${fixture}${path.delimiter}${process.env.PATH}`, + { mode: 0o755 }, + ) + const result = spawnSync( + process.execPath, + [ + 'scripts/run-e2e-with-retry.mjs', + '--affected', + '--base=main', + ...(retry ? [] : ['--no-retry']), + ], + { + cwd: root, + env: { + ...process.env, + PATH: `${fixture}${path.delimiter}${process.env.PATH}`, + }, + encoding: 'utf8', }, - encoding: 'utf8', - }, - ) - assert.equal(result.status, 7, result.stderr) - const invocations = (await readFile(calls, 'utf8')) - .trim() - .split('\n') - .map(JSON.parse) - assert.deepEqual(invocations, [ - ['affected', '--target=test:e2e', '--parallel=2', '--base=main'], - ]) - } finally { - await rm(fixture, { recursive: true, force: true }) - } -}) + ) + assert.equal(result.status, 7, result.stderr) + const invocations = (await readFile(calls, 'utf8')) + .trim() + .split('\n') + .map(JSON.parse) + assert.deepEqual( + invocations, + Array.from({ length: retry ? 2 : 1 }, () => [ + 'affected', + '--target=test:e2e', + '--parallel=2', + '--base=main', + ]), + ) + } finally { + await rm(fixture, { recursive: true, force: true }) + } + }) +} + +test( + 'a failed test cleans up spawned server descendants', + { timeout: 30_000 }, + async () => { + const parent = path.join(root, '.cache') + await mkdir(parent, { recursive: true }) + const fixture = await mkdtemp(path.join(parent, 'e2e-cleanup-')) + const pidFile = path.join(fixture, 'server.pid') + let pid + try { + await mkdir(path.join(fixture, 'tests/e2e'), { recursive: true }) + await writeFile( + path.join(fixture, 'package.json'), + JSON.stringify({ dependencies: { 'ember-source': '*' } }), + ) + await writeFile( + path.join(fixture, 'pnpm'), + `#!/usr/bin/env node + const { spawn } = require('node:child_process') + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) + require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)) + console.log('http://127.0.0.1:18999/') + setInterval(() => {}, 1000) + `, + { mode: 0o755 }, + ) + await writeFile( + path.join(fixture, 'tests/e2e/cleanup.spec.ts'), + ` + import { test } from '@playwright/test' + import { startExampleServer } from ${JSON.stringify(path.join(root, 'tests/e2e/helpers/startExampleServer.ts'))} + let server + test.afterAll(async () => { await server?.close(); await server?.close() }) + test('fails before caller reaches cleanup', async () => { + server = await startExampleServer(${JSON.stringify(fixture)}) + throw new Error('intentional navigation failure') + }) + `, + ) + const child = spawn( + process.execPath, + [ + playwright, + 'test', + '--config', + path.join(root, 'playwright.config.ts'), + ], + { + cwd: fixture, + env: { + ...process.env, + PLAYWRIGHT_TEST_DIR: path.join(fixture, 'tests/e2e'), + PATH: `${fixture}${path.delimiter}${process.env.PATH}`, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + let log = '' + child.stdout.on('data', (chunk) => { + log += chunk + }) + child.stderr.on('data', (chunk) => { + log += chunk + }) + const [code] = await once(child, 'close') + assert.equal(code, 1, log) + assert.match(log, /intentional navigation failure/) + pid = Number(await readFile(pidFile, 'utf8')) + // Allow the OS to reap the killed descendant before checking its PID. + for (let attempt = 0; attempt < 50; attempt++) { + try { + process.kill(pid, 0) + } catch (error) { + assert.equal(error.code, 'ESRCH') + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + assert.fail(`server descendant ${pid} survived the failed test`) + } finally { + if (pid) { + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + await rm(fixture, { recursive: true, force: true }) + await rm( + path.join(root, 'test-results', '.cache', path.basename(fixture)), + { recursive: true, force: true }, + ) + } + }, +) diff --git a/tests/e2e/helpers/startExampleServer.ts b/tests/e2e/helpers/startExampleServer.ts index 556fe44f4a..3f32cf0d91 100644 --- a/tests/e2e/helpers/startExampleServer.ts +++ b/tests/e2e/helpers/startExampleServer.ts @@ -1,8 +1,36 @@ import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import { spawn } from 'node:child_process' +import type { ChildProcess } from 'node:child_process' +import { test } from '@playwright/test' import { createServer } from 'vite' +// Register cleanup before startup/navigation can fail. Killing pnpm alone leaves +// its Vite/Angular descendants alive on Linux, accumulating servers across tasks. +const children = new Set() +const servers = new Set<{ close: () => Promise }>() + +function killServer(child: ChildProcess) { + if (!child.pid || !children.has(child)) return + try { + if (process.platform === 'win32') child.kill('SIGKILL') + else process.kill(-child.pid, 'SIGKILL') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } + children.delete(child) +} + +process.once('exit', () => { + for (const child of children) killServer(child) +}) + +test.afterAll(async () => { + for (const child of children) killServer(child) + await Promise.all([...servers].map((server) => server.close())) + servers.clear() +}) + function hasDependency(exampleDir: string, dependency: string) { const pkgPath = path.join(exampleDir, 'package.json') if (!existsSync(pkgPath)) return false @@ -57,7 +85,15 @@ export async function startExampleServer(exampleDir: string) { }, }) + servers.add(server) await server.listen() + // Crawl the entry before browser navigation so dependency optimization + // does not invalidate module URLs while the smoke test loads them. + const entry = path.join(exampleDir, 'index.html') + if (existsSync(entry)) { + await server.transformIndexHtml('/', readFileSync(entry, 'utf8')) + await server.waitForRequestsIdle() + } const address = server.httpServer?.address() if (!address || typeof address === 'string') { @@ -67,7 +103,10 @@ export async function startExampleServer(exampleDir: string) { return { url: `http://127.0.0.1:${address.port}/`, - close: () => server.close(), + close: async () => { + await server.close() + servers.delete(server) + }, } } @@ -112,15 +151,17 @@ async function listenOnSpawnedVitePort(exampleDir: string) { FORCE_COLOR: '0', NO_COLOR: '1', }, + detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], }, ) + children.add(child) let output = '' const url = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { - child.kill() + killServer(child) reject( new Error( `Timed out starting Vite server for ${exampleDir}\n${output}`, @@ -160,13 +201,7 @@ async function listenOnSpawnedVitePort(exampleDir: string) { return { url, close: async () => { - if (child.exitCode !== null || child.signalCode !== null) { - return - } - child.kill() - await new Promise((resolve) => { - child.once('exit', () => resolve()) - }) + killServer(child) }, } } @@ -192,15 +227,17 @@ async function startAngularExampleServer(exampleDir: string) { FORCE_COLOR: '0', NO_COLOR: '1', }, + detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], }, ) + children.add(child) let output = '' const url = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { - child.kill() + killServer(child) reject( new Error( `Timed out starting Angular server for ${exampleDir}\n${output}`, @@ -240,14 +277,7 @@ async function startAngularExampleServer(exampleDir: string) { return { url, close: async () => { - if (child.exitCode !== null || child.signalCode !== null) { - return - } - - child.kill() - await new Promise((resolve) => { - child.once('exit', () => resolve()) - }) + killServer(child) }, } } From 7d8739bef3de367ed7e05383971476e97501ba4f Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Tue, 15 Sep 2026 11:03:48 -0500 Subject: [PATCH 5/6] fix: bound realtime smoke workloads and use Node process types --- .../realtime-trading/tests/e2e/smoke.spec.ts | 57 ++++++++---------- .../realtime-trading/tests/e2e/smoke.spec.ts | 56 +++++++++--------- .../realtime-trading/tests/e2e/smoke.spec.ts | 52 ++++++++--------- .../realtime-trading/tests/e2e/smoke.spec.ts | 58 +++++++++---------- .../realtime-trading/tests/e2e/smoke.spec.ts | 54 ++++++++--------- tests/e2e/helpers/startExampleServer.ts | 5 ++ 6 files changed, 133 insertions(+), 149 deletions(-) diff --git a/examples/alpine/realtime-trading/tests/e2e/smoke.spec.ts b/examples/alpine/realtime-trading/tests/e2e/smoke.spec.ts index 874881a758..c24e70f006 100644 --- a/examples/alpine/realtime-trading/tests/e2e/smoke.spec.ts +++ b/examples/alpine/realtime-trading/tests/e2e/smoke.spec.ts @@ -82,49 +82,42 @@ test('runs the Alpine realtime trading workload', async ({ page }) => { const publishInterval = page.getByTestId('publish-interval-select') await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) - await publishInterval.selectOption('100') - await resumeTradingFeed(page) - - await expect - .poll(async () => { - const text = await page.getByTestId('row-update-rate').textContent() - return Number(text?.replace(/\D/g, '') ?? 0) - }) - .toBeGreaterThan(0) - await expect - .poll(async () => { - const text = await page.getByTestId('worker-messages').textContent() - return Number(text?.replace(/\D/g, '') ?? 0) - }) - .toBeGreaterThan(0) - await expect - .poll(async () => - Number(await page.getByTestId('message-rate').textContent()), - ) - .toBeGreaterThan(0) - await expect - .poll(async () => - Number(await page.getByTestId('table-render-rate').textContent()), - ) - .toBeGreaterThan(0) + // Exercise delivery and rendering without turning this smoke test into a + // throughput benchmark on shared CI runners. Configure controls while paused. + await setRangeValue(targetRateSlider, '0') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '100 samples/s', + ) + await publishInterval.selectOption('500') + await expect(publishInterval).toHaveValue('500') + await sparklineInterval.selectOption('100') + const swapRenderer = page.getByRole('checkbox', { + name: /Swap Tick component/, + }) + await swapRenderer.check() + await expect(swapRenderer).toBeChecked() - const firstPrice = page.locator('tbody tr').first().getByRole('button') + const firstPrice = table.locator('tbody tr').first().getByRole('button') const priceBeforeUpdate = await firstPrice.textContent() + await resumeTradingFeed(page) await expect .poll(() => firstPrice.textContent()) .not.toBe(priceBeforeUpdate) - await page.locator('.config-section input[type="checkbox"]').first().check() await page.getByTestId('feed-toggle').click() await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') - expect( - await page.evaluate( - () => performance.getEntriesByName('tanstack-row-model').length > 0, - ), - ).toBe(true) + // Delivery is cumulative; instantaneous rates can legitimately fall to + // zero between samples on a busy runner. The price assertion above proves + // that a delivered update reached the rendered table. + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) expect(errors).toEqual([]) } finally { diff --git a/examples/lit/realtime-trading/tests/e2e/smoke.spec.ts b/examples/lit/realtime-trading/tests/e2e/smoke.spec.ts index 2481bbd76c..0a7646360f 100644 --- a/examples/lit/realtime-trading/tests/e2e/smoke.spec.ts +++ b/examples/lit/realtime-trading/tests/e2e/smoke.spec.ts @@ -137,46 +137,42 @@ test('runs the Lit realtime trading workload', async ({ page }) => { await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) - await resumeTradingFeed(page) - await expect - .poll(async () => { - const text = await page.getByTestId('row-update-rate').textContent() - return Number(text?.replace(/\D/g, '') ?? 0) - }) - .toBeGreaterThan(0) - await expect - .poll(async () => { - const text = await page.getByTestId('worker-messages').textContent() - return Number(text?.replace(/\D/g, '') ?? 0) - }) - .toBeGreaterThan(0) - await expect - .poll(async () => - Number(await page.getByTestId('message-rate').textContent()), - ) - .toBeGreaterThan(0) - await expect - .poll(async () => - Number(await page.getByTestId('table-render-rate').textContent()), - ) - .toBeGreaterThan(0) + // Exercise delivery and rendering without turning this smoke test into a + // throughput benchmark on shared CI runners. Configure controls while paused. + await setRangeValue(targetRateSlider, '0') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '100 samples/s', + ) + await publishInterval.selectOption('500') + await expect(publishInterval).toHaveValue('500') + await sparklineInterval.selectOption('100') + const swapRenderer = page.getByRole('checkbox', { + name: /Swap Tick component/, + }) + await swapRenderer.check() + await expect(swapRenderer).toBeChecked() - const firstPrice = page.locator('tbody tr').first().getByRole('button') + const firstPrice = table.locator('tbody tr').first().getByRole('button') const priceBeforeUpdate = await firstPrice.textContent() + await resumeTradingFeed(page) await expect .poll(() => firstPrice.textContent()) .not.toBe(priceBeforeUpdate) - await page.locator('.config-section input[type="checkbox"]').first().check() await pauseTradingFeed(page) await instrumentCount.selectOption('750') await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(750) - expect( - await page.evaluate( - () => performance.getEntriesByName('tanstack-row-model').length > 0, - ), - ).toBe(true) + + // Delivery is cumulative; instantaneous rates can legitimately fall to + // zero between samples on a busy runner. The price assertion above proves + // that a delivered update reached the rendered table. + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) expect(errors).toEqual([]) } finally { diff --git a/examples/solid/realtime-trading/tests/e2e/smoke.spec.ts b/examples/solid/realtime-trading/tests/e2e/smoke.spec.ts index a2ae4d5863..75bc5bb93f 100644 --- a/examples/solid/realtime-trading/tests/e2e/smoke.spec.ts +++ b/examples/solid/realtime-trading/tests/e2e/smoke.spec.ts @@ -138,9 +138,7 @@ test('runs the Solid realtime trading workload', async ({ page }) => { 4, ) await expect(table.locator('td[data-selection-left="true"]')).toHaveCount(3) - await resumeTradingFeed(page) - await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) @@ -172,38 +170,40 @@ test('runs the Solid realtime trading workload', async ({ page }) => { await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) - await expect - .poll(async () => { - const text = await page.getByTestId('row-update-rate').textContent() - return Number(text?.replace(/\D/g, '') ?? 0) - }) - .toBeGreaterThan(0) - await expect - .poll(async () => { - const text = await page.getByTestId('worker-messages').textContent() - return Number(text?.replace(/\D/g, '') ?? 0) - }) - .toBeGreaterThan(0) - await expect - .poll(async () => - Number(await page.getByTestId('message-rate').textContent()), - ) - .toBeGreaterThan(0) - await expect - .poll(async () => - Number(await page.getByTestId('table-render-rate').textContent()), - ) - .toBeGreaterThan(0) + // Exercise delivery and rendering without turning this smoke test into a + // throughput benchmark on shared CI runners. Configure controls while paused. + await setRangeValue(targetRateSlider, '0') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '100 samples/s', + ) + await publishInterval.selectOption('500') + await expect(publishInterval).toHaveValue('500') + await sparklineInterval.selectOption('100') + const swapRenderer = page.getByRole('checkbox', { + name: /Swap Tick component/, + }) + await swapRenderer.check() + await expect(swapRenderer).toBeChecked() - const firstPrice = page.locator('tbody tr').first().getByRole('button') + const firstPrice = table.locator('tbody tr').first().getByRole('button') const priceBeforeUpdate = await firstPrice.textContent() + await resumeTradingFeed(page) await expect .poll(() => firstPrice.textContent()) .not.toBe(priceBeforeUpdate) - await page.locator('.config-section input[type="checkbox"]').first().check() await pauseTradingFeed(page) + // Delivery is cumulative; instantaneous rates can legitimately fall to + // zero between samples on a busy runner. The price assertion above proves + // that a delivered update reached the rendered table. + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + expect(errors).toEqual([]) } finally { await server.close() diff --git a/examples/svelte/realtime-trading/tests/e2e/smoke.spec.ts b/examples/svelte/realtime-trading/tests/e2e/smoke.spec.ts index 4c1aeb6251..2355f46485 100644 --- a/examples/svelte/realtime-trading/tests/e2e/smoke.spec.ts +++ b/examples/svelte/realtime-trading/tests/e2e/smoke.spec.ts @@ -136,51 +136,45 @@ test('runs the Svelte realtime trading workload', async ({ page }) => { const publishInterval = page.getByTestId('publish-interval-select') await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) - await publishInterval.selectOption('100') - await resumeTradingFeed(page) - - await expect - .poll(async () => { - const text = await page.getByTestId('row-update-rate').textContent() - return Number(text?.replace(/\D/g, '') ?? 0) - }) - .toBeGreaterThan(0) - await expect - .poll(async () => { - const text = await page.getByTestId('worker-messages').textContent() - return Number(text?.replace(/\D/g, '') ?? 0) - }) - .toBeGreaterThan(0) - await expect - .poll(async () => - Number(await page.getByTestId('message-rate').textContent()), - ) - .toBeGreaterThan(0) - await expect - .poll(async () => - Number(await page.getByTestId('table-render-rate').textContent()), - ) - .toBeGreaterThan(0) + // Exercise delivery and rendering without turning this smoke test into a + // throughput benchmark on shared CI runners. Configure controls while paused. + await setRangeValue(targetRateSlider, '0') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '100 samples/s', + ) + await publishInterval.selectOption('500') + await expect(publishInterval).toHaveValue('500') + await sparklineInterval.selectOption('100') + const swapRenderer = page.getByRole('checkbox', { + name: /Swap Tick component/, + }) + await swapRenderer.check() + await expect(swapRenderer).toBeChecked() - const firstPrice = page.locator('tbody tr').first().getByRole('button') + const firstPrice = table.locator('tbody tr').first().getByRole('button') const priceBeforeUpdate = await firstPrice.textContent() + await resumeTradingFeed(page) await expect .poll(() => firstPrice.textContent()) .not.toBe(priceBeforeUpdate) - await page.locator('.config-section input[type="checkbox"]').first().check() await page.getByTestId('feed-toggle').click() await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + // Delivery is cumulative; instantaneous rates can legitimately fall to + // zero between samples on a busy runner. The price assertion above proves + // that a delivered update reached the rendered table. + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await instrumentCount.selectOption('750') await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(750) - expect( - await page.evaluate( - () => performance.getEntriesByName('tanstack-row-model').length > 0, - ), - ).toBe(true) expect(errors).toEqual([]) } finally { diff --git a/examples/vue/realtime-trading/tests/e2e/smoke.spec.ts b/examples/vue/realtime-trading/tests/e2e/smoke.spec.ts index 53f7a798c1..123a0b58a9 100644 --- a/examples/vue/realtime-trading/tests/e2e/smoke.spec.ts +++ b/examples/vue/realtime-trading/tests/e2e/smoke.spec.ts @@ -137,46 +137,42 @@ test('runs the Vue realtime trading workload', async ({ page }) => { await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + // Exercise delivery and rendering without turning this smoke test into a + // throughput benchmark on shared CI runners. Configure controls while paused. + await setRangeValue(targetRateSlider, '0') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '100 samples/s', + ) + await publishInterval.selectOption('500') + await expect(publishInterval).toHaveValue('500') + await sparklineInterval.selectOption('100') + const swapRenderer = page.getByRole('checkbox', { + name: /Swap Tick component/, + }) + await swapRenderer.check() + await expect(swapRenderer).toBeChecked() + + const firstPrice = table.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() await resumeTradingFeed(page) await expect - .poll(async () => { - const text = await page.getByTestId('row-update-rate').textContent() - return Number(text?.replace(/\D/g, '') ?? 0) - }) - .toBeGreaterThan(0) + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + + await pauseTradingFeed(page) + + // Delivery is cumulative; instantaneous rates can legitimately fall to + // zero between samples on a busy runner. The price assertion above proves + // that a delivered update reached the rendered table. await expect .poll(async () => { const text = await page.getByTestId('worker-messages').textContent() return Number(text?.replace(/\D/g, '') ?? 0) }) .toBeGreaterThan(0) - await expect - .poll(async () => - Number(await page.getByTestId('message-rate').textContent()), - ) - .toBeGreaterThan(0) - await expect - .poll(async () => - Number(await page.getByTestId('table-render-rate').textContent()), - ) - .toBeGreaterThan(0) - - const firstPrice = page.locator('tbody tr').first().getByRole('button') - const priceBeforeUpdate = await firstPrice.textContent() - await expect - .poll(() => firstPrice.textContent()) - .not.toBe(priceBeforeUpdate) - - await page.locator('.config-section input[type="checkbox"]').first().check() - await pauseTradingFeed(page) await instrumentCount.selectOption('750') await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(750) - expect( - await page.evaluate( - () => performance.getEntriesByName('tanstack-row-model').length > 0, - ), - ).toBe(true) expect(errors).toEqual([]) } finally { diff --git a/tests/e2e/helpers/startExampleServer.ts b/tests/e2e/helpers/startExampleServer.ts index 3f32cf0d91..0b6c33ff1d 100644 --- a/tests/e2e/helpers/startExampleServer.ts +++ b/tests/e2e/helpers/startExampleServer.ts @@ -1,10 +1,15 @@ import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' +import nodeProcess from 'node:process' import { spawn } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { test } from '@playwright/test' import { createServer } from 'vite' +// Browser examples declare a minimal global process, which also narrows the +// node:process export. This helper runs exclusively in a Node test worker. +const process = nodeProcess as NodeJS.Process + // Register cleanup before startup/navigation can fail. Killing pnpm alone leaves // its Vite/Angular descendants alive on Linux, accumulating servers across tasks. const children = new Set() From 1ce3037e6d3cddf67859518f3603a226dac37680 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Wed, 16 Sep 2026 05:31:18 -0500 Subject: [PATCH 6/6] ci: keep E2E in release audits rather than PRs --- .github/workflows/pr.yml | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 02a74b4a8d..d646d3ce88 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -42,34 +42,6 @@ jobs: - name: Stop Nx Agents if: ${{ always() }} run: npx nx-cloud stop-all-agents - e2e: - name: E2E - runs-on: ubuntu-latest - timeout-minutes: 120 - steps: - - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - name: Setup Tools - uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main - - name: Get base and head commits for `nx affected` - uses: nrwl/nx-set-shas@3e9ad7370203c1e93d109be57f3b72eb0eb511b1 # v4.4.0 - with: - main-branch-name: main - - name: Install Playwright Browsers - run: pnpm run test:e2e:install - - name: Run E2E Tests - run: pnpm run test:e2e:affected - - name: Upload E2E failure artifacts - if: ${{ failure() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: e2e-failures - path: test-results/ - if-no-files-found: ignore - retention-days: 7 coverage: name: Coverage Report runs-on: ubuntu-latest