From 735e034f23fba273f94bf473c871ae93b5490015 Mon Sep 17 00:00:00 2001 From: Justin Ling Date: Fri, 31 Jul 2026 01:16:25 +0800 Subject: [PATCH 1/2] Add test lanes and platform proof --- .c8rc.json | 21 ++++ .github/workflows/test-lanes.yml | 110 ++++++++++++++++ docs/content/development.mdx | 37 +++++- package-lock.json | 207 +++++++++++++++++++++++++++++++ package.json | 7 +- scripts/generate-summaries.mjs | 3 + scripts/present.mjs | 1 + scripts/release.mjs | 7 +- tests/platform-shell.test.mjs | 67 ++++++++++ tests/present-agent.test.mjs | 56 ++++++++- tests/present-instances.test.mjs | 2 +- tests/serve-built.test.mjs | 28 +++-- tests/test-lanes.test.mjs | 81 ++++++++++++ 13 files changed, 608 insertions(+), 19 deletions(-) create mode 100644 .c8rc.json create mode 100644 .github/workflows/test-lanes.yml create mode 100644 tests/platform-shell.test.mjs create mode 100644 tests/test-lanes.test.mjs diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 0000000..1f371e0 --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,21 @@ +{ + "all": true, + "include": [ + "scripts/cli-args.mjs", + "scripts/build-diff-data.mjs", + "scripts/generate-summaries.mjs", + "scripts/present.mjs" + ], + "reporter": [ + "text", + "json-summary", + "lcov" + ], + "reports-dir": "coverage", + "check-coverage": true, + "per-file": true, + "statements": 80, + "branches": 60, + "functions": 90, + "lines": 80 +} diff --git a/.github/workflows/test-lanes.yml b/.github/workflows/test-lanes.yml new file mode 100644 index 0000000..28f9d02 --- /dev/null +++ b/.github/workflows/test-lanes.yml @@ -0,0 +1,110 @@ +name: Test lanes + +on: + pull_request: + workflow_dispatch: + schedule: + - cron: "23 8 * * 1" + +permissions: + contents: read + +concurrency: + group: test-lanes-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit: + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: npm + - name: Install dependencies + run: npm ci + - name: Run fast unit tests + run: npm run test:unit + + integration: + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: npm + - name: Install dependencies + run: npm ci + - name: Run Git and server tests + run: npm run test:integration + + coverage: + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: npm + - name: Install dependencies + run: npm ci + - name: Check core coverage + run: npm run test:coverage + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v6 + with: + name: core-coverage + path: coverage + if-no-files-found: error + + browser: + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: npm + - name: Install dependencies + run: npm ci + - name: Install browser system packages + run: npx playwright install --with-deps chromium + - name: Run the browser journey + run: npm run test:browser + + platform: + if: github.event_name != 'pull_request' + strategy: + fail-fast: false + matrix: + os: + - macos-15 + - windows-2025 + runs-on: ${{ matrix.os }} + steps: + - name: Check out repository + uses: actions/checkout@v6 + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: npm + - name: Install dependencies + run: npm ci + - name: Check host shell assumptions + run: npm run test:platform diff --git a/docs/content/development.mdx b/docs/content/development.mdx index 8363074..12803b0 100644 --- a/docs/content/development.mdx +++ b/docs/content/development.mdx @@ -46,15 +46,46 @@ and fixture changes update the browser through the event stream. ## Run checks +Run one lane for quick feedback: + ```sh npm run lint npm run test:browser:install +npm run test:unit +npm run test:integration +npm run test:browser +``` + +Run the full test gate before you send a change: + +```sh npm test ``` -Install Chromium once after `npm ci`, and again when Playwright changes. -`npm test` builds the app, runs the Node tests, then runs the desktop and -mobile browser checks without downloading a browser. +The lanes have clear bounds: + +| Command | What it checks | +| --- | --- | +| `npm run test:unit` | Fast parser, provider, doctor, release, and path checks. It does not build the app. | +| `npm run test:integration` | The production build plus local Git, agent note, presenter, and server checks. | +| `npm run test:browser` | The review journey in a real Chromium browser. It does not download a browser. | +| `npm run test:coverage` | Per-file coverage for the CLI parser, target builder, agent note writer, and presenter. | +| `npm test` | Unit, integration, coverage, and browser lanes in order. | + +Install Chromium once after `npm ci`, and again when Playwright changes. The +pull request browser job installs Chromium before it runs the browser lane. + +The coverage lane writes text, JSON summary, and LCOV reports under +`coverage/`. Each covered file must keep at least 80% statement and line +coverage, 60% branch coverage, and 90% function coverage. The per-file check +applies the floor to each path, not just the total. + +The pull request jobs run each main lane from a clean checkout on Ubuntu with +local Git repos, fake coding agents, and checked-in browser data. They need no +agent account or remote repo. A weekly workflow also runs +`npm run test:platform` on macOS 15 and Windows 2025. That lane checks shell +command lookup and the npm launcher with local fake tools. You can also start +the **Test lanes** workflow by hand. ## Publish a release diff --git a/package-lock.json b/package-lock.json index 3e7fbf7..9aa6dea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.2", "blume": "^1.2.0", + "c8": "12.0.0", "eslint": "10.8.0", "fallow": "^3.10.0", "react": "19.2.6", @@ -839,6 +840,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", @@ -2501,6 +2512,16 @@ "node": ">=18.0.0" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -5561,6 +5582,13 @@ "@types/unist": "*" } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -8760,6 +8788,40 @@ "node": ">= 0.8" } }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -11120,6 +11182,36 @@ "node": ">=20" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -11389,6 +11481,16 @@ "dev": true, "license": "MIT" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -12081,6 +12183,52 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports/node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -12678,6 +12826,22 @@ "source-map-js": "^1.2.1" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", @@ -16097,6 +16261,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/svgo": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz", @@ -16285,6 +16462,21 @@ "node": ">=18" } }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -16932,6 +17124,21 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/vali-date": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", diff --git a/package.json b/package.json index 895bc6b..1af4a36 100644 --- a/package.json +++ b/package.json @@ -61,9 +61,13 @@ "build": "vite build && node scripts/clean-built-live-data.mjs", "start": "node scripts/serve-built.mjs", "prepack": "npm run build", - "test": "npm run build && node --test tests/*.test.mjs && npm run test:browser", + "test": "npm run test:unit && npm run test:integration && npm run test:coverage && npm run test:browser", + "test:unit": "node --test tests/cli-args.test.mjs tests/coding-agents.test.mjs tests/doctor.test.mjs tests/landing-demo.test.mjs tests/release.test.mjs tests/summary-path.test.mjs tests/test-lanes.test.mjs", + "test:integration": "npm run build && node --test --test-concurrency=1 tests/dev.test.mjs tests/generate-summaries.test.mjs tests/present-agent.test.mjs tests/present-help.test.mjs tests/present-instances.test.mjs tests/remote-targets.test.mjs tests/rendered-html.test.mjs tests/serve-built.test.mjs", + "test:coverage": "npm run build && c8 node --test --test-concurrency=1 tests/cli-args.test.mjs tests/generate-summaries.test.mjs tests/present-agent.test.mjs tests/present-help.test.mjs tests/present-instances.test.mjs tests/remote-targets.test.mjs", "test:browser": "node --test tests/browser/*.test.mjs", "test:browser:install": "playwright install chromium", + "test:platform": "node --test tests/platform-shell.test.mjs", "lint": "tsc --noEmit && eslint ." }, "devDependencies": { @@ -74,6 +78,7 @@ "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.2", "blume": "^1.2.0", + "c8": "12.0.0", "eslint": "10.8.0", "fallow": "^3.10.0", "react": "19.2.6", diff --git a/scripts/generate-summaries.mjs b/scripts/generate-summaries.mjs index 1eb3895..117afd5 100644 --- a/scripts/generate-summaries.mjs +++ b/scripts/generate-summaries.mjs @@ -622,6 +622,9 @@ function runAgent(invocation, input) { }; child.stdout.on('data', (chunk) => collect(stdout, chunk)); child.stderr.on('data', (chunk) => collect(stderr, chunk)); + child.stdin.on('error', (error) => { + if (error?.code !== 'EPIPE') rejectPromise(error); + }); child.once('error', (error) => { activeAgentProcesses.delete(child); rejectPromise(error); diff --git a/scripts/present.mjs b/scripts/present.mjs index a532e56..7844cea 100755 --- a/scripts/present.mjs +++ b/scripts/present.mjs @@ -310,6 +310,7 @@ function scheduleAgent(fingerprint) { selectedFingerprint === state.fingerprint ) { agentFingerprint = selectedFingerprint; + console.log('Reusing current agent notes.'); return; } if (!selectedFingerprint || selectedFingerprint === agentFingerprint) return; diff --git a/scripts/release.mjs b/scripts/release.mjs index fb0b7c8..c1268d6 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -5,10 +5,13 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +export function npmCommand(platform = process.platform) { + return platform === 'win32' ? 'npm.cmd' : 'npm'; +} function runNpm(args) { - const result = spawnSync(npm, args, { + const result = spawnSync(npmCommand(), args, { cwd: root, stdio: 'inherit', }); diff --git a/tests/platform-shell.test.mjs b/tests/platform-shell.test.mjs new file mode 100644 index 0000000..7a5a335 --- /dev/null +++ b/tests/platform-shell.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { chmod, link, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import test from 'node:test'; +import { findCommand } from '../scripts/coding-agents.mjs'; +import { doctorReport } from '../scripts/doctor.mjs'; +import { npmCommand } from '../scripts/release.mjs'; + +async function fakeCommand(directory, name) { + if (process.platform === 'win32') { + const path = join(directory, `${name}.EXE`); + await link(process.execPath, path); + return path; + } + + const path = join(directory, name); + await writeFile(path, '#!/bin/sh\nprintf "fake 1.0.0\\n"\n'); + await chmod(path, 0o755); + return path; +} + +test('finds deterministic fake tools with the host shell rules', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffsplain-platform-')); + try { + await Promise.all([ + fakeCommand(directory, 'git'), + fakeCommand(directory, 'gh'), + fakeCommand(directory, 'codex'), + ]); + const env = { + PATH: directory, + ...(process.platform === 'win32' ? { PATHEXT: '.EXE' } : {}), + }; + + for (const command of ['git', 'gh', 'codex']) { + const path = await findCommand(command, { + env, + platform: process.platform, + }); + assert.equal( + basename(path).toLowerCase(), + `${command}${process.platform === 'win32' ? '.exe' : ''}`, + ); + } + + const report = await doctorReport({ + env, + platform: process.platform, + architecture: 'test-arch', + nodeVersion: 'v22.13.0', + nodePath: process.execPath, + }); + assert.equal(report.ready, true); + assert.match(report.text, /Git\s+fake 1\.0\.0|Git\s+v22\./); + assert.match(report.text, /Codex\s+fake 1\.0\.0|Codex\s+v22\./); + assert.match(report.text, new RegExp(`Platform: ${process.platform} test-arch`)); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('uses the host npm launcher without a shell wrapper', () => { + assert.equal(npmCommand('linux'), 'npm'); + assert.equal(npmCommand('darwin'), 'npm'); + assert.equal(npmCommand('win32'), 'npm.cmd'); +}); diff --git a/tests/present-agent.test.mjs b/tests/present-agent.test.mjs index 9fc21d8..4817d86 100644 --- a/tests/present-agent.test.mjs +++ b/tests/present-agent.test.mjs @@ -14,7 +14,7 @@ function git(repo, ...args) { }).trim(); } -async function waitFor(read, timeout = 12_000) { +async function waitFor(read, timeout = 30_000) { const deadline = Date.now() + timeout; let lastError; while (Date.now() < deadline) { @@ -29,11 +29,41 @@ async function waitFor(read, timeout = 12_000) { throw lastError || new Error("Timed out waiting for presenter output"); } +function waitForOutput(child, pattern, timeout = 30_000) { + return new Promise((resolve, reject) => { + let output = ""; + const timer = setTimeout(() => { + reject(new Error(`Timed out waiting for presenter output: ${output}`)); + }, timeout); + const onOutput = (chunk) => { + output += chunk.toString(); + if (pattern.test(output)) { + clearTimeout(timer); + resolve(output); + } + }; + child.stdout.on("data", onOutput); + child.stderr.on("data", onOutput); + child.once("error", reject); + child.once("exit", (code, signal) => { + clearTimeout(timer); + reject( + new Error( + `Presenter stopped before it was ready (${code ?? signal}): ${output}`, + ), + ); + }); + }); +} + async function stop(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return { code: child.exitCode, signal: child.signalCode }; + } const closed = new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error("Presenter did not stop after SIGTERM")); - }, 5_000); + }, 15_000); child.once("exit", (code, signal) => { clearTimeout(timer); resolve({ code, signal }); @@ -44,6 +74,16 @@ async function stop(child) { return closed; } +function agentCallCount(value) { + return (value.match(/codex-after-feed/g) || []).length; +} + +async function stopIfRunning(child) { + if (child?.exitCode === null && child.signalCode === null) { + await stop(child); + } +} + test("starts the note agent after the watch snapshot and stops cleanly", async () => { const root = await mkdtemp(join(tmpdir(), "diffsplain-present-")); const repo = join(root, "repo"); @@ -157,6 +197,9 @@ test("starts the note agent after the watch snapshot and stops cleanly", async ( assert.equal(result.code, 0); assert.equal(result.signal, null); + const firstRunLog = await readFile(events, "utf8"); + const firstRunCalls = agentCallCount(firstRunLog); + assert.equal(firstRunCalls, 2); presenter = spawn( process.execPath, [ @@ -182,16 +225,19 @@ test("starts the note agent after the watch snapshot and stops cleanly", async ( stdio: "pipe", }, ); - await new Promise((resolve) => setTimeout(resolve, 3_500)); + await waitForOutput( + presenter, + /^Reusing current agent notes\.$/m, + ); const restartLog = await readFile(events, "utf8"); - assert.equal((restartLog.match(/codex-after-feed/g) || []).length, 2); + assert.equal(agentCallCount(restartLog), firstRunCalls); const restartResult = await stop(presenter); presenter = undefined; assert.equal(restartResult.code, 0); assert.equal(restartResult.signal, null); } finally { - if (presenter && !presenter.killed) await stop(presenter); + await stopIfRunning(presenter); await rm(root, { recursive: true, force: true }); } }); diff --git a/tests/present-instances.test.mjs b/tests/present-instances.test.mjs index 3a64306..d16e08a 100644 --- a/tests/present-instances.test.mjs +++ b/tests/present-instances.test.mjs @@ -280,7 +280,7 @@ test('reuses a matching project tab when it reconnects', async () => { const reused = new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error('Presenter did not reuse the open tab')), - 2_000, + 10_000, ); second.stdout.on('data', (chunk) => { if (chunk.toString().includes('Reusing the open Diffsplain tab.')) { diff --git a/tests/serve-built.test.mjs b/tests/serve-built.test.mjs index 7420e6d..3272197 100644 --- a/tests/serve-built.test.mjs +++ b/tests/serve-built.test.mjs @@ -38,6 +38,22 @@ function stop(child) { }); } +function within(promise, message, timeout = 10_000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeout); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + test('serves the built review page with live diff data', async () => { const directory = await mkdtemp(join(tmpdir(), 'diffsplain-server-')); const output = join(directory, 'diff-data.json'); @@ -97,7 +113,7 @@ test('reports a matching project tab connection', async () => { const connected = new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error('Server did not report the tab connection')), - 2_000, + 10_000, ); child.stdout.on('data', (chunk) => { if (chunk.toString().includes('Diffsplain tab: connected')) { @@ -141,17 +157,15 @@ test('pushes an event soon after live diff data changes', async () => { let buffered = decoder.decode((await reader.read()).value); assert.match(buffered, /event: ready/); - const started = performance.now(); await writeFile(output, JSON.stringify({ version: 'after' })); while (!buffered.includes('event: update')) { - const next = await reader.read(); + const next = await within( + reader.read(), + 'Server did not send an update event', + ); assert.equal(next.done, false); buffered += decoder.decode(next.value); } - assert.ok( - performance.now() - started < 500, - 'expected an update event within 500 ms', - ); } finally { await reader?.cancel(); if (child && child.exitCode === null) await stop(child); diff --git a/tests/test-lanes.test.mjs b/tests/test-lanes.test.mjs new file mode 100644 index 0000000..cbcd0f9 --- /dev/null +++ b/tests/test-lanes.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +async function json(path) { + return JSON.parse( + await readFile(new URL(`../${path}`, import.meta.url), 'utf8'), + ); +} + +test('keeps the test lanes separate and composes the complete test gate', async () => { + const packageJson = await json('package.json'); + const scripts = packageJson.scripts; + + for (const lane of [ + 'test:unit', + 'test:integration', + 'test:coverage', + 'test:browser', + 'test:platform', + ]) { + assert.ok(scripts[lane], `${lane} must be a named command`); + } + assert.equal( + scripts.test, + 'npm run test:unit && npm run test:integration && npm run test:coverage && npm run test:browser', + ); + assert.doesNotMatch(scripts['test:unit'], /browser|npm run build/); + assert.match(scripts['test:integration'], /npm run build/); + assert.match(scripts['test:integration'], /--test-concurrency=1/); + assert.match(scripts['test:coverage'], /npm run build/); + assert.match(scripts['test:coverage'], /--test-concurrency=1/); + assert.doesNotMatch(scripts['test:browser'], /playwright install/); + assert.match(scripts['test:browser:install'], /playwright install chromium/); +}); + +test('holds each core path to the documented coverage floor', async () => { + const config = await json('.c8rc.json'); + + assert.equal(config['check-coverage'], true); + assert.equal(config['per-file'], true); + assert.deepEqual(config.include, [ + 'scripts/cli-args.mjs', + 'scripts/build-diff-data.mjs', + 'scripts/generate-summaries.mjs', + 'scripts/present.mjs', + ]); + assert.deepEqual( + { + statements: config.statements, + branches: config.branches, + functions: config.functions, + lines: config.lines, + }, + { statements: 80, branches: 60, functions: 90, lines: 80 }, + ); + assert.ok(config.reporter.includes('json-summary')); + assert.ok(config.reporter.includes('lcov')); +}); + +test('runs pull request lanes on Linux and scheduled shell checks elsewhere', async () => { + const workflow = await readFile( + new URL('../.github/workflows/test-lanes.yml', import.meta.url), + 'utf8', + ); + + for (const command of [ + 'npm run test:unit', + 'npm run test:integration', + 'npm run test:coverage', + 'npm run test:browser', + 'npm run test:platform', + ]) { + assert.match(workflow, new RegExp(command.replaceAll(':', '\\:'))); + } + assert.match(workflow, /pull_request:/); + assert.match(workflow, /schedule:/); + assert.match(workflow, /macos-15/); + assert.match(workflow, /windows-2025/); + assert.match(workflow, /run: npm ci/g); +}); From a7005b0dfd224640b32130457eb420eaec33d9e6 Mon Sep 17 00:00:00 2001 From: Justin Ling Date: Fri, 31 Jul 2026 15:06:01 +0800 Subject: [PATCH 2/2] Build assets before browser test lane --- package.json | 2 +- tests/test-lanes.test.mjs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 11bb504..8685a1c 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "test:unit": "node --test tests/access-token.test.mjs tests/automation-trust.test.mjs tests/cache.test.mjs tests/cli-args.test.mjs tests/cli-docs.test.mjs tests/coding-agents.test.mjs tests/data-contract.test.mjs tests/doctor.test.mjs tests/landing-demo.test.mjs tests/package-manifest.test.mjs tests/plan-ledger.test.mjs tests/presenter-runtime.test.mjs tests/product-gate.test.mjs tests/release.test.mjs tests/summary-path.test.mjs tests/support-record.test.mjs tests/test-lanes.test.mjs tests/tool-profiles.test.mjs", "test:integration": "npm run build && node --test --test-concurrency=1 tests/dev.test.mjs tests/generate-summaries.test.mjs tests/live-update-speed.test.mjs tests/performance-gate.test.mjs tests/present-agent.test.mjs tests/present-help.test.mjs tests/present-instances.test.mjs tests/presenter-recovery.test.mjs tests/remote-targets.test.mjs tests/rendered-html.test.mjs tests/serve-built.test.mjs tests/setup-environments.test.mjs", "test:coverage": "npm run build && c8 node --test --test-concurrency=1 tests/cli-args.test.mjs tests/generate-summaries.test.mjs tests/present-agent.test.mjs tests/present-help.test.mjs tests/present-instances.test.mjs tests/remote-targets.test.mjs", - "test:browser": "node --test tests/browser/*.test.mjs", + "test:browser": "npm run build && node --test tests/browser/*.test.mjs", "test:browser:install": "playwright install chromium", "test:cloud": "node --test tests/generate-summaries.test.mjs tests/present-agent.test.mjs", "test:platform": "node --test tests/platform-shell.test.mjs", diff --git a/tests/test-lanes.test.mjs b/tests/test-lanes.test.mjs index e85c148..97bc982 100644 --- a/tests/test-lanes.test.mjs +++ b/tests/test-lanes.test.mjs @@ -34,7 +34,11 @@ test('keeps the test lanes separate and composes the complete test gate', async assert.match(scripts['test:integration'], /--test-concurrency=1/); assert.match(scripts['test:coverage'], /npm run build/); assert.match(scripts['test:coverage'], /--test-concurrency=1/); - assert.equal(scripts['test:browser'], 'node --test tests/browser/*.test.mjs'); + assert.equal( + scripts['test:browser'], + 'npm run build && node --test tests/browser/*.test.mjs', + ); + assert.match(scripts['test:browser'], /npm run build/); assert.doesNotMatch(scripts['test:browser'], /playwright install/); assert.match(scripts['test:browser:install'], /playwright install chromium/);