From eb7ec074387ab5eee357545e926ba446baedb440 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Tue, 1 Sep 2026 01:12:53 +0800 Subject: [PATCH 01/12] test: enforce the MVP release gate (#16) --- .github/workflows/ci.yml | 36 + .gitignore | 1 + docs/release/manual-evidence.json | 23 + docs/release/mvp-release-gate.md | 97 +++ package.json | 2 + scripts/run-release-gate.ts | 70 ++ tests/e2e/host-product-parity.e2e.test.ts | 173 +++++ tests/release/accessibility.release.test.tsx | 71 ++ tests/release/manual-evidence.test.ts | 81 +++ tests/release/manual-evidence.ts | 164 +++++ tests/release/oversized-diff.release.test.tsx | 50 ++ tests/release/performance-budget.test.ts | 35 + tests/release/performance-budget.ts | 35 + tests/release/reference-benchmark.ts | 231 +++++++ tests/release/release-artifacts.test.ts | 128 ++++ tests/release/release-artifacts.ts | 84 +++ tests/release/release-envelope.ts | 6 + tests/release/release-environment.test.ts | 55 ++ tests/release/release-environment.ts | 85 +++ tests/release/release-gate.test.ts | 55 ++ tests/release/release-gate.ts | 648 ++++++++++++++++++ tests/release/release-report.test.ts | 225 ++++++ tests/release/release-report.ts | 288 ++++++++ tests/release/supported-scale-fixture.ts | 239 +++++++ tests/release/supported-scale.test.ts | 37 + tests/release/ui-benchmark.tsx | 297 ++++++++ tsconfig.json | 2 + vitest.config.ts | 2 +- 28 files changed, 3219 insertions(+), 1 deletion(-) create mode 100644 docs/release/manual-evidence.json create mode 100644 docs/release/mvp-release-gate.md create mode 100644 scripts/run-release-gate.ts create mode 100644 tests/e2e/host-product-parity.e2e.test.ts create mode 100644 tests/release/accessibility.release.test.tsx create mode 100644 tests/release/manual-evidence.test.ts create mode 100644 tests/release/manual-evidence.ts create mode 100644 tests/release/oversized-diff.release.test.tsx create mode 100644 tests/release/performance-budget.test.ts create mode 100644 tests/release/performance-budget.ts create mode 100644 tests/release/reference-benchmark.ts create mode 100644 tests/release/release-artifacts.test.ts create mode 100644 tests/release/release-artifacts.ts create mode 100644 tests/release/release-envelope.ts create mode 100644 tests/release/release-environment.test.ts create mode 100644 tests/release/release-environment.ts create mode 100644 tests/release/release-gate.test.ts create mode 100644 tests/release/release-gate.ts create mode 100644 tests/release/release-report.test.ts create mode 100644 tests/release/release-report.ts create mode 100644 tests/release/supported-scale-fixture.ts create mode 100644 tests/release/supported-scale.test.ts create mode 100644 tests/release/ui-benchmark.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aeaf03c..5f55344 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,39 @@ jobs: - name: Build run: npm run build + + release-gate: + name: MVP release gate (macOS) + runs-on: macos-15 + timeout-minutes: 20 + env: + CODEX_DESKTOP_VERSION: 26.820.60940 (build 7119) + CODEX_GIT_REFERENCE_PROFILE: github-actions-macos-15 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version + cache: npm + cache-dependency-path: package-lock.json + + - name: Install repository npm version + run: npm install --global npm@11.17.0 + + - name: Install dependencies + run: npm ci + + - name: Run MVP release gate + run: npm run release:gate + + - name: Archive MVP release evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mvp-release-gate-${{ runner.os }}-${{ github.sha }} + path: artifacts/release-gate + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index c6138ac..145a8ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ coverage/ +artifacts/ .DS_Store *.log .env diff --git a/docs/release/manual-evidence.json b/docs/release/manual-evidence.json new file mode 100644 index 0000000..6e86b1d --- /dev/null +++ b/docs/release/manual-evidence.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "checks": [ + { + "id": "codex-host-smoke", + "status": "passed", + "performedAt": "2026-08-29T00:00:00.000Z", + "environment": "macOS with Codex Desktop 26.820.60940 (build 7119) and Chromium 151.0.7922.170", + "record": "docs/host-integration/codex-compatibility.md#manual-smoke-matrix", + "sourceRevision": "sha256:f10e7c0cf5a1cc78def9e811c0123698f352f578c6181781e43f893d8b316fc7", + "validUntil": "2026-09-29T00:00:00.000Z" + }, + { + "id": "voiceover-keyboard-smoke", + "status": "pending", + "performedAt": null, + "environment": null, + "record": null, + "sourceRevision": null, + "validUntil": null + } + ] +} diff --git a/docs/release/mvp-release-gate.md b/docs/release/mvp-release-gate.md new file mode 100644 index 0000000..0bbd793 --- /dev/null +++ b/docs/release/mvp-release-gate.md @@ -0,0 +1,97 @@ +# MVP release gate + +Issue #16 is enforced by one source-mode gate: + +```sh +npm run release:gate +``` + +The command runs the complete Vitest suite, validates the AC-01 through AC-24 +traceability matrix, builds the supported-scale Git fixture, enforces every +timing budget, records the reference environment, and writes only sanitized +evidence to `artifacts/release-gate/`: + +- `acceptance-matrix.json` is the machine-readable gate result. +- `acceptance-matrix.md` is the human-readable release checklist. +- `manual/` contains redacted copies of every passing human checklist. + +Raw Vitest JSON is created in a temporary directory and removed after the +sanitized report is written. Failure messages are deliberately excluded from +the archived evidence so credential and redaction fixtures cannot leak into CI +artifacts. + +Required human checks are read from `docs/release/manual-evidence.json`. A +missing or `pending` check fails its acceptance criterion and the overall gate; +the presence of a procedure alone is not passing evidence. Passed checks must +have a unique ID, an existing Repository-relative record, a future validity +date, and the current product-source SHA-256 revision. A change to production +files under `apps/` or `packages/` invalidates the record automatically. Set +`CODEX_GIT_MANUAL_EVIDENCE` to use a run-specific record outside the source +tree. + +## Supported reference fixture + +The benchmark constructs a disposable local Git Repository with: + +- 25 Available Worktrees and one unavailable registration; +- 2,000 Changed Files across the available Worktrees; +- 2,500 Local and 2,500 Remote-tracking refs. + +The gate fails when the fixture cardinality changes or when any measurement +exceeds its budget: + +| Measurement | Budget | +| ----------------------------------------- | -------: | +| Application shell | 1,000 ms | +| Selected ordinary Worktree | 2,000 ms | +| Full supported Repository snapshot | 5,000 ms | +| Visible external selected-Worktree change | 2,000 ms | +| Loaded UI interaction | 100 ms | + +The macOS benchmark starts the production Vite surface and loopback protocol, +then drives the real protocol source, Repository Store, React DOM, and SSE +invalidation path. Shell timing includes loading the served surface entry and +its first DOM commit. Selected-Worktree timing ends when the authoritative +snapshot is visible. External-change timing starts before the filesystem write +and ends only after SSE-triggered refresh makes the new Changed File visible in +the DOM. + +The approved CI reference profile is `github-actions-macos-15`, paired with the +tested Codex compatibility profile `26.820.60940 (build 7119)`. The JSON and +Markdown artifacts record the actual CPU, architecture, memory, macOS, Git, +Node.js, reference profile, and Codex compatibility version. A local release +run must explicitly set `CODEX_GIT_REFERENCE_PROFILE=local-macos-release` and +`CODEX_DESKTOP_VERSION='26.820.60940 (build 7119)'`; an unapproved platform, +profile, or missing/mismatched Codex version fails AC-24. + +## VoiceOver and keyboard smoke record + +Automated UI coverage verifies native keyboard controls, target-specific names, +live regions, focus recovery, visible `:focus-visible` styling, and textual +status that does not depend on color. Before a release candidate is approved, a +human must also run this macOS assistive-technology check and attach the completed +record beside the generated matrix: + +- Record the macOS, VoiceOver, browser/WebView, and Codex Desktop versions. +- Complete Refresh, review, Stage/Unstage, Commit, Branch switch, Fetch, Pull, + Push, Publish, and exact-target navigation without a pointer. +- Confirm that focus is always visible and follows removal or invalidation to the + nearest safe context. +- Confirm that status and operation changes are announced without moving focus. +- Confirm that repeated controls announce the exact Worktree, file, Branch, or + Remote target. +- Confirm that Clean, changed, conflicted, unavailable, stale, and transitioning + state remains understandable without color. + +The completed record must include its execution time, expiry, environment, +current product-source revision, and the exact checklist path. The gate copies a +redacted version into `artifacts/release-gate/manual/`. + +Codex host attachment and native UI restoration use the separately maintained +[exact-build smoke matrix](../host-integration/codex-compatibility.md#manual-smoke-matrix). + +## Package parity + +Issue #17 must run this same gate against the signed/notarized package. Packaging +must not substitute a second behavior suite or waive a source-mode acceptance +criterion. diff --git a/package.json b/package.json index bf2b2a5..b99f167 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint .", + "release:gate": "tsx scripts/run-release-gate.ts", "test": "vitest run", + "test:release": "vitest run tests/release", "test:server": "vitest run apps/server/src/server.test.ts", "test:ui": "vitest run apps/ui/src/App.test.tsx", "typecheck": "tsc --noEmit" diff --git a/scripts/run-release-gate.ts b/scripts/run-release-gate.ts new file mode 100644 index 0000000..d531623 --- /dev/null +++ b/scripts/run-release-gate.ts @@ -0,0 +1,70 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { writeReleaseArtifacts } from '../tests/release/release-artifacts.js'; +import { collectReleaseEnvironment } from '../tests/release/release-environment.js'; +import { + createReleaseReport, + type VitestJsonReport, +} from '../tests/release/release-report.js'; +import { runReferenceBenchmark } from '../tests/release/reference-benchmark.js'; +import { + collectProductSourceRevision, + readManualEvidence, +} from '../tests/release/manual-evidence.js'; + +const executeFile = promisify(execFile); +const root = process.cwd(); +const artifactDirectory = resolve( + root, + process.env.CODEX_GIT_RELEASE_ARTIFACTS ?? 'artifacts/release-gate', +); +const temporaryDirectory = await mkdtemp( + join(tmpdir(), 'codex-git-release-gate-'), +); +const rawReportPath = join(temporaryDirectory, 'vitest.json'); +const manualEvidencePath = resolve( + root, + process.env.CODEX_GIT_MANUAL_EVIDENCE ?? 'docs/release/manual-evidence.json', +); +let testProcessPassed = true; + +try { + try { + await executeFile( + process.execPath, + [ + resolve(root, 'node_modules/vitest/vitest.mjs'), + 'run', + '--reporter=json', + `--outputFile=${rawReportPath}`, + ], + { cwd: root, encoding: 'utf8', maxBuffer: 4 * 1_024 * 1_024 }, + ); + } catch { + testProcessPassed = false; + } + + const vitest = JSON.parse( + await readFile(rawReportPath, 'utf8'), + ) as VitestJsonReport; + const report = await createReleaseReport( + root, + vitest, + await collectReleaseEnvironment(), + await runReferenceBenchmark(), + await readManualEvidence(manualEvidencePath), + await collectProductSourceRevision(root), + ); + const artifacts = await writeReleaseArtifacts(artifactDirectory, report); + + console.log(`Release gate: ${report.status}`); + console.log(`Acceptance matrix: ${artifacts.markdown}`); + console.log(`Machine evidence: ${artifacts.json}`); + if (!testProcessPassed || report.status !== 'passed') process.exitCode = 1; +} finally { + await rm(temporaryDirectory, { force: true, recursive: true }); +} diff --git a/tests/e2e/host-product-parity.e2e.test.ts b/tests/e2e/host-product-parity.e2e.test.ts new file mode 100644 index 0000000..b4b2831 --- /dev/null +++ b/tests/e2e/host-product-parity.e2e.test.ts @@ -0,0 +1,173 @@ +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import type { HostContext } from '@codex-git/host-adapter'; +import type { + DedicatedCodexInstance, + DedicatedCodexTarget, + DedicatedRendererConnection, +} from '@codex-git/host-adapter-codex-cdp'; +import { + startCodexRuntime, + startStandaloneRuntime, + type StandaloneRuntime, +} from '@codex-git/launcher'; +import { + PROTOCOL_VERSION_HEADER, + diffResultSchema, + repositorySnapshotSchema, +} from '@codex-git/protocol'; + +import { + createTemporaryGitRepository, + type TemporaryGitRepository, +} from '../fixtures/temporary-git-repository.js'; + +const repositories: TemporaryGitRepository[] = []; +const runtimes: StandaloneRuntime[] = []; + +afterEach(async () => { + await Promise.all(runtimes.splice(0).map((runtime) => runtime.close())); + await Promise.all( + repositories.splice(0).map((repository) => repository.dispose()), + ); +}); + +describe('Git Surface host parity', () => { + it('exposes the same Repository snapshot and Diff behavior through standalone and Codex hosts', async () => { + const standaloneRepository = await productFixture(); + const codexRepository = await productFixture(); + const standalone = await startStandaloneRuntime({ + projectPath: standaloneRepository.path, + surfacePort: 0, + }); + runtimes.push(standalone); + const codex = await startCodexRuntime({ + connectRenderer: async () => new CompatibleRenderer(), + launchInstance: async () => new FixtureInstance(), + projectPath: codexRepository.path, + surfacePort: 0, + }); + runtimes.push(codex); + + expect(codex.currentHost()).toBe('codex'); + await expect(productBehavior(standalone)).resolves.toEqual( + await productBehavior(codex), + ); + }); +}); + +async function productFixture(): Promise { + const repository = await createTemporaryGitRepository(); + repositories.push(repository); + await repository.git('config', 'user.name', 'Codex Git Tests'); + await repository.git('config', 'user.email', 'codex-git@example.test'); + await writeFile(join(repository.path, 'README.md'), 'fixture\n'); + await repository.git('add', '--', 'README.md'); + await repository.git('commit', '--quiet', '-m', 'Create fixture'); + await writeFile(join(repository.path, 'README.md'), 'changed\n'); + await writeFile(join(repository.path, 'untracked.txt'), 'untracked\n'); + return repository; +} + +async function productBehavior(runtime: StandaloneRuntime): Promise { + const surface = await (await fetch(runtime.surfaceUrl)).text(); + const snapshot = repositorySnapshotSchema.parse( + await (await protocolRequest(runtime, 'snapshot')).json(), + ); + const worktree = snapshot.worktrees[0]; + const changed = worktree?.changes.find( + ({ displayPath }) => displayPath === 'README.md', + ); + if (worktree === undefined || changed === undefined) { + throw new Error('Expected the shared Changed File fixture.'); + } + const diff = diffResultSchema.parse( + await ( + await protocolRequest(runtime, 'diff', { fileId: changed.fileId }) + ).json(), + ); + + return { + changes: worktree.changes.map( + ({ baseline, displayPath, kind, previousDisplayPath }) => ({ + baseline, + displayPath, + kind, + previousDisplayPath, + }), + ), + diff: + diff.kind === 'text' + ? { baseline: diff.baseline, content: diff.content, kind: diff.kind } + : diff, + head: worktree.head.kind, + role: worktree.role, + surfaceEntry: surface.includes('src="/src/main.tsx"'), + }; +} + +function protocolRequest( + runtime: StandaloneRuntime, + endpoint: 'diff' | 'snapshot', + body?: unknown, +): Promise { + const url = new URL( + runtime.sessionUrl.pathname.replace(/\/session$/u, `/${endpoint}`), + runtime.sessionUrl, + ); + return fetch(url, { + body: body === undefined ? undefined : JSON.stringify(body), + headers: { + origin: runtime.surfaceUrl.origin, + [PROTOCOL_VERSION_HEADER]: '1', + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + method: body === undefined ? 'GET' : 'POST', + }); +} + +const ownedTarget = { + id: 'renderer-parity', + webSocketUrl: 'ws://127.0.0.1:43117/devtools/page/renderer-parity', +} satisfies DedicatedCodexTarget; + +class FixtureInstance implements DedicatedCodexInstance { + readonly build = '7119'; + readonly ownership = { + endpoint: 'http://127.0.0.1:43117/', + instanceId: 'instance-parity', + processId: 4242, + profilePath: '/private/tmp/codex-git-parity-profile', + }; + readonly version = '26.820.60940'; + + async currentTarget(): Promise { + return ownedTarget; + } + subscribe(): () => void { + return () => undefined; + } + async close(): Promise {} +} + +class CompatibleRenderer implements DedicatedRendererConnection { + currentContext(): HostContext { + return { projectPath: null, task: null, theme: 'system' }; + } + isSurfaceOpen(): boolean { + return true; + } + projectIdentity(): { readonly id: string; readonly label: string } { + return { id: 'project-parity', label: 'codex-git' }; + } + subscribe(): () => void { + return () => undefined; + } + async perform(): Promise<{ readonly status: 'unsupported' }> { + return { status: 'unsupported' }; + } + async close(): Promise {} +} diff --git a/tests/release/accessibility.release.test.tsx b/tests/release/accessibility.release.test.tsx new file mode 100644 index 0000000..2c84072 --- /dev/null +++ b/tests/release/accessibility.release.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { App } from '../../apps/ui/src/overview.js'; +import { createOverviewFixture } from '../../apps/ui/src/overview-fixtures.js'; +import { createRepositoryStore } from '../../apps/ui/src/repository-store.js'; + +describe('MVP accessibility release gate', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('keeps controls keyboard operable, target named, visibly focused, announced, and non-color-only', async () => { + const fixture = createOverviewFixture('unavailable-worktree'); + const store = createRepositoryStore(fixture.source); + await act(async () => root.render()); + + const controls = [...container.querySelectorAll('button, input, textarea')]; + expect(controls.length).toBeGreaterThan(0); + for (const control of controls) { + expect(accessibleName(control), control.outerHTML).not.toBe(''); + expect( + control.matches('button, input, textarea'), + control.outerHTML, + ).toBe(true); + } + expect( + container.querySelector( + '[aria-label="Select missing-worktree Worktree at /private/tmp/missing-worktree"]', + ), + ).not.toBeNull(); + expect( + container.querySelectorAll('[aria-live="polite"]').length, + ).toBeGreaterThan(0); + expect(container.textContent).toContain( + 'Unavailable — Working Tree path is missing.', + ); + + const css = await readFile( + resolve(process.cwd(), 'apps/ui/src/overview.css'), + 'utf8', + ); + expect(css).toMatch(/:focus-visible\s*\{[^}]*outline:\s*3px\s+solid/u); + store.dispose(); + }); +}); + +function accessibleName(element: Element): string { + const explicit = element.getAttribute('aria-label')?.trim(); + if (explicit) return explicit; + const label = element.closest('label')?.textContent?.trim(); + if (label) return label; + return element.textContent?.trim() ?? ''; +} diff --git a/tests/release/manual-evidence.test.ts b/tests/release/manual-evidence.test.ts new file mode 100644 index 0000000..b5ea37d --- /dev/null +++ b/tests/release/manual-evidence.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { parseManualEvidence } from './manual-evidence.js'; + +describe('manual release evidence', () => { + it('accepts a complete passed check and an explicit pending check', () => { + expect( + parseManualEvidence({ + checks: [ + { + environment: 'Codex Desktop 26.820.60940 (build 7119)', + id: 'codex-host-smoke', + performedAt: '2026-08-29T00:00:00.000Z', + record: + 'docs/host-integration/codex-compatibility.md#manual-smoke-matrix', + sourceRevision: 'sha256:fixture', + status: 'passed', + validUntil: '2026-09-29T00:00:00.000Z', + }, + { + environment: null, + id: 'voiceover-keyboard-smoke', + performedAt: null, + record: null, + sourceRevision: null, + status: 'pending', + validUntil: null, + }, + ], + schemaVersion: 1, + }), + ).toMatchObject({ schemaVersion: 1 }); + }); + + it('rejects a passed check without its environment and record', () => { + expect(() => + parseManualEvidence({ + checks: [ + { + environment: null, + id: 'voiceover-keyboard-smoke', + performedAt: null, + record: null, + sourceRevision: null, + status: 'passed', + validUntil: null, + }, + ], + schemaVersion: 1, + }), + ).toThrow('requires environment, performedAt, record, sourceRevision'); + }); + + it('rejects duplicate check IDs', () => { + expect(() => + parseManualEvidence({ + checks: [ + { + environment: null, + id: 'voiceover-keyboard-smoke', + performedAt: null, + record: null, + sourceRevision: null, + status: 'pending', + validUntil: null, + }, + { + environment: null, + id: 'voiceover-keyboard-smoke', + performedAt: null, + record: null, + sourceRevision: null, + status: 'pending', + validUntil: null, + }, + ], + schemaVersion: 1, + }), + ).toThrow('is duplicated'); + }); +}); diff --git a/tests/release/manual-evidence.ts b/tests/release/manual-evidence.ts new file mode 100644 index 0000000..50dea6d --- /dev/null +++ b/tests/release/manual-evidence.ts @@ -0,0 +1,164 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, stat } from 'node:fs/promises'; +import { isAbsolute, resolve, sep } from 'node:path'; +import { promisify } from 'node:util'; + +const executeFile = promisify(execFile); + +export interface ManualEvidenceCheck { + readonly environment: string | null; + readonly id: string; + readonly performedAt: string | null; + readonly record: string | null; + readonly sourceRevision: string | null; + readonly status: 'passed' | 'pending'; + readonly validUntil: string | null; +} + +export interface ManualEvidenceRecord { + readonly checks: readonly ManualEvidenceCheck[]; + readonly schemaVersion: 1; +} + +export async function readManualEvidence( + path: string, +): Promise { + return parseManualEvidence(JSON.parse(await readFile(path, 'utf8'))); +} + +export function parseManualEvidence(value: unknown): ManualEvidenceRecord { + if (!isRecord(value) || value.schemaVersion !== 1) { + throw new Error('Manual evidence must use schemaVersion 1.'); + } + if (!Array.isArray(value.checks)) { + throw new Error('Manual evidence checks must be an array.'); + } + const ids = new Set(); + const checks = value.checks.map((check, index): ManualEvidenceCheck => { + if ( + !isRecord(check) || + typeof check.id !== 'string' || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(check.id) || + (check.status !== 'passed' && check.status !== 'pending') + ) { + throw new Error(`Manual evidence check ${index} is invalid.`); + } + const environment = optionalString(check.environment); + const performedAt = optionalString(check.performedAt); + const record = optionalString(check.record); + const sourceRevision = optionalString(check.sourceRevision); + const validUntil = optionalString(check.validUntil); + if (ids.has(check.id)) { + throw new Error(`Manual evidence check ID ${check.id} is duplicated.`); + } + ids.add(check.id); + if ( + check.status === 'passed' && + (environment === null || + performedAt === null || + record === null || + sourceRevision === null || + validUntil === null) + ) { + throw new Error( + `Passed manual evidence ${check.id} requires environment, performedAt, record, sourceRevision, and validUntil.`, + ); + } + return { + environment, + id: check.id, + performedAt, + record, + sourceRevision, + status: check.status, + validUntil, + }; + }); + return { checks, schemaVersion: 1 }; +} + +export async function collectProductSourceRevision( + root: string, +): Promise { + const { stdout } = await executeFile( + 'git', + ['ls-files', '-z', '--', 'apps', 'packages'], + { cwd: root, encoding: 'buffer', maxBuffer: 4 * 1_024 * 1_024 }, + ); + const files = stdout + .toString('utf8') + .split('\0') + .filter( + (file) => + file.length > 0 && + !file.includes('/test/') && + !/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(file), + ) + .sort(); + const hash = createHash('sha256'); + for (const file of files) { + hash.update(file); + hash.update('\0'); + hash.update(await readFile(resolve(root, file))); + hash.update('\0'); + } + return `sha256:${hash.digest('hex')}`; +} + +export async function manualEvidenceCheckPasses( + root: string, + check: ManualEvidenceCheck | undefined, + sourceRevision: string, + generatedAt: Date, +): Promise { + if ( + check?.status !== 'passed' || + check.sourceRevision !== sourceRevision || + check.performedAt === null || + check.validUntil === null || + check.record === null + ) { + return false; + } + const performedAt = new Date(check.performedAt); + const validUntil = new Date(check.validUntil); + if ( + !Number.isFinite(performedAt.valueOf()) || + !Number.isFinite(validUntil.valueOf()) || + performedAt > generatedAt || + validUntil < generatedAt + ) { + return false; + } + const recordPath = check.record.split('#', 1)[0]; + if (recordPath === undefined || recordPath.length === 0) return false; + const expectedRoot = resolve(root); + const path = resolve(expectedRoot, recordPath); + if ( + isAbsolute(recordPath) || + (path !== expectedRoot && !path.startsWith(`${expectedRoot}${sep}`)) + ) { + return false; + } + try { + const record = await stat(path); + return record.isFile() && record.size > 0; + } catch { + return false; + } +} + +function optionalString(value: unknown): string | null { + if (value === null) return null; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error( + 'Manual evidence values must be non-empty strings or null.', + ); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/tests/release/oversized-diff.release.test.tsx b/tests/release/oversized-diff.release.test.tsx new file mode 100644 index 0000000..663834e --- /dev/null +++ b/tests/release/oversized-diff.release.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { performance } from 'node:perf_hooks'; + +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { App } from '../../apps/ui/src/overview.js'; +import { createRepositoryStore } from '../../apps/ui/src/repository-store.js'; +import { createSupportedScaleFixture } from './supported-scale-fixture.js'; + +describe('oversized Diff release envelope', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('degrades a 2 MiB and 20,001-line Diff without freezing the loaded UI', async () => { + const fixture = createSupportedScaleFixture(); + const store = createRepositoryStore(fixture.source); + await act(async () => root.render()); + const review = container.querySelector( + 'button[aria-label^="Review staged"]', + ); + if (review === null) + throw new Error('Expected a Changed File review action'); + + const startedAt = performance.now(); + await act(async () => review.click()); + const elapsedMilliseconds = performance.now() - startedAt; + + expect(elapsedMilliseconds).toBeLessThanOrEqual(100); + expect(container.textContent).toContain( + 'Diff is too large to display · 2,097,153 bytes · 20001 lines', + ); + expect(container.querySelector('pre')).toBeNull(); + store.dispose(); + }); +}); diff --git a/tests/release/performance-budget.test.ts b/tests/release/performance-budget.test.ts new file mode 100644 index 0000000..97b0ed0 --- /dev/null +++ b/tests/release/performance-budget.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { + PERFORMANCE_BUDGET_MILLISECONDS, + evaluatePerformanceBudget, + type PerformanceMeasurements, +} from './performance-budget.js'; + +describe('release performance budget', () => { + it('encodes every documented timing target', () => { + expect(PERFORMANCE_BUDGET_MILLISECONDS).toEqual({ + externalChange: 2_000, + fullSnapshot: 5_000, + loadedInteraction: 100, + selectedWorktree: 2_000, + shell: 1_000, + }); + expect(evaluatePerformanceBudget(PERFORMANCE_BUDGET_MILLISECONDS)).toEqual( + [], + ); + }); + + it('rejects each measurement that exceeds its target', () => { + const measurements: PerformanceMeasurements = { + ...PERFORMANCE_BUDGET_MILLISECONDS, + externalChange: 2_001, + loadedInteraction: 101, + }; + + expect(evaluatePerformanceBudget(measurements)).toEqual([ + 'externalChange took 2001 ms; budget is 2000 ms.', + 'loadedInteraction took 101 ms; budget is 100 ms.', + ]); + }); +}); diff --git a/tests/release/performance-budget.ts b/tests/release/performance-budget.ts new file mode 100644 index 0000000..fe61aec --- /dev/null +++ b/tests/release/performance-budget.ts @@ -0,0 +1,35 @@ +export interface PerformanceMeasurements { + readonly externalChange: number; + readonly fullSnapshot: number; + readonly loadedInteraction: number; + readonly selectedWorktree: number; + readonly shell: number; +} + +export const PERFORMANCE_BUDGET_MILLISECONDS = { + externalChange: 2_000, + fullSnapshot: 5_000, + loadedInteraction: 100, + selectedWorktree: 2_000, + shell: 1_000, +} as const satisfies PerformanceMeasurements; + +const MEASUREMENT_ORDER = [ + 'shell', + 'selectedWorktree', + 'fullSnapshot', + 'externalChange', + 'loadedInteraction', +] as const; + +export function evaluatePerformanceBudget( + measurements: PerformanceMeasurements, +): string[] { + return MEASUREMENT_ORDER.flatMap((measurement) => { + const duration = measurements[measurement]; + const budget = PERFORMANCE_BUDGET_MILLISECONDS[measurement]; + return duration <= budget + ? [] + : [`${measurement} took ${duration} ms; budget is ${budget} ms.`]; + }); +} diff --git a/tests/release/reference-benchmark.ts b/tests/release/reference-benchmark.ts new file mode 100644 index 0000000..8c9e2db --- /dev/null +++ b/tests/release/reference-benchmark.ts @@ -0,0 +1,231 @@ +import { execFile, spawn } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import type { AbsolutePath } from '@codex-git/protocol'; +import { + startStandaloneRuntime, + type StandaloneRuntime, +} from '@codex-git/launcher'; +import { createRepositoryEngine } from '@codex-git/repository-engine'; + +import { + evaluatePerformanceBudget, + type PerformanceMeasurements, +} from './performance-budget.js'; +import { SUPPORTED_SCALE } from './release-envelope.js'; +import { measureProtocolReleaseUi } from './ui-benchmark.js'; + +const executeFile = promisify(execFile); +export interface ReferenceBenchmarkResult { + readonly budgetFailures: readonly string[]; + readonly fixture: { + readonly availableWorktrees: number; + readonly changedFiles: number; + readonly refs: number; + readonly unavailableRegistrations: number; + }; + readonly measurements: PerformanceMeasurements; +} + +export async function runReferenceBenchmark(): Promise { + const root = await mkdtemp(join(tmpdir(), 'codex-git-reference-')); + const main = join(root, 'main'); + let session: Awaited< + ReturnType['open']> + > | null = null; + let runtime: StandaloneRuntime | null = null; + + try { + await createGitFixture(root, main); + const engine = createRepositoryEngine(); + session = await engine.open(main as AbsolutePath); + const opened = await session.snapshot(); + if (opened.kind !== 'repository') { + throw new Error('The reference fixture did not open as a Repository.'); + } + + const availableWorktrees = opened.repository.worktrees.filter( + (worktree) => worktree.availability.kind === 'available', + ).length; + const unavailableRegistrations = + opened.repository.worktrees.length - availableWorktrees; + const changedFiles = opened.repository.worktrees.reduce( + (total, worktree) => total + worktree.changes.length, + 0, + ); + const selected = opened.repository.selectedWorktreeId; + if (selected === null) + throw new Error('The reference fixture was not selected.'); + const branches = await session.searchBranches({ + query: '', + worktreeId: selected, + }); + await session.close(); + session = null; + runtime = await startStandaloneRuntime({ + projectPath: main, + surfacePort: 0, + }); + const ui = await measureProtocolReleaseUi({ + externalDisplayPath: 'external-visible-change.txt', + mutateExternal: () => + writeFile( + join(main, 'external-visible-change.txt'), + 'external change\n', + 'utf8', + ), + projectPath: main, + sessionUrl: runtime.sessionUrl, + surfaceUrl: runtime.surfaceUrl, + }); + const measurements = roundMeasurements({ + externalChange: ui.externalChange, + fullSnapshot: ui.fullSnapshot, + loadedInteraction: ui.loadedInteraction, + selectedWorktree: ui.shell + ui.selectedWorktreeRender, + shell: ui.shell, + }); + const fixture = { + availableWorktrees, + changedFiles, + refs: branches.candidates.length, + unavailableRegistrations, + }; + if ( + fixture.availableWorktrees !== SUPPORTED_SCALE.availableWorktrees || + fixture.changedFiles !== SUPPORTED_SCALE.changedFiles || + fixture.refs !== SUPPORTED_SCALE.refs || + fixture.unavailableRegistrations !== + SUPPORTED_SCALE.unavailableRegistrations + ) { + throw new Error( + `Reference fixture cardinality mismatch: ${JSON.stringify(fixture)}`, + ); + } + + return { + budgetFailures: evaluatePerformanceBudget(measurements), + fixture, + measurements, + }; + } finally { + await runtime?.close(); + await session?.close(); + await rm(root, { force: true, recursive: true }); + } +} + +async function createGitFixture(root: string, main: string): Promise { + await mkdir(main, { recursive: true }); + await git(main, ['init', '--quiet']); + await git(main, ['config', 'user.name', 'Codex Git Release Gate']); + await git(main, ['config', 'user.email', 'release-gate@example.test']); + await git(main, ['remote', 'add', 'origin', join(root, 'remote.git')]); + await writeFile(join(main, 'README.md'), 'release fixture\n', 'utf8'); + await git(main, ['add', '--', 'README.md']); + await git(main, ['commit', '--quiet', '-m', 'Create release fixture']); + + const availableWorktreePaths = [main]; + for (let index = 1; index < SUPPORTED_SCALE.availableWorktrees; index += 1) { + const branch = `release-worktree-${String(index).padStart(2, '0')}`; + const path = join(root, `worktree-${String(index).padStart(2, '0')}`); + await git(main, ['branch', branch]); + await git(main, ['worktree', 'add', '--quiet', path, branch]); + availableWorktreePaths.push(path); + } + + const unavailablePath = join(root, 'unavailable-registration'); + await git(main, ['branch', 'release-unavailable']); + await git(main, [ + 'worktree', + 'add', + '--quiet', + unavailablePath, + 'release-unavailable', + ]); + await rm(unavailablePath, { force: true, recursive: true }); + + const filesPerWorktree = + SUPPORTED_SCALE.changedFiles / SUPPORTED_SCALE.availableWorktrees; + for (const [worktreeIndex, path] of availableWorktreePaths.entries()) { + const directory = join(path, 'src', `worktree-${worktreeIndex}`); + await mkdir(directory, { recursive: true }); + await Promise.all( + Array.from({ length: filesPerWorktree }, (_, fileIndex) => + writeFile( + join(directory, `changed-${String(fileIndex).padStart(4, '0')}.txt`), + `worktree ${worktreeIndex}, file ${fileIndex}\n`, + 'utf8', + ), + ), + ); + } + + const objectId = (await git(main, ['rev-parse', 'HEAD'])).trim(); + const existingLocalRefs = SUPPORTED_SCALE.availableWorktrees + 1; + const additionalLocalRefs = SUPPORTED_SCALE.refs / 2 - existingLocalRefs; + const commands = [ + ...Array.from( + { length: additionalLocalRefs }, + (_, index) => + `create refs/heads/release-local-${String(index + 1).padStart(4, '0')} ${objectId}`, + ), + ...Array.from( + { length: SUPPORTED_SCALE.refs / 2 }, + (_, index) => + `create refs/remotes/origin/release-remote-${String(index + 1).padStart(4, '0')} ${objectId}`, + ), + ]; + await git(main, ['update-ref', '--stdin'], `${commands.join('\n')}\n`); + return availableWorktreePaths; +} + +async function git( + path: string, + args: readonly string[], + input?: string, +): Promise { + if (input !== undefined) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn('git', ['-C', path, ...args], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.on('error', rejectPromise); + child.on('close', (code) => { + if (code === 0) { + resolvePromise(Buffer.concat(stdout).toString('utf8')); + return; + } + rejectPromise( + new Error( + `Git fixture command failed with code ${String(code)}: ${Buffer.concat(stderr).toString('utf8')}`, + ), + ); + }); + child.stdin.end(input); + }); + } + const result = await executeFile('git', ['-C', path, ...args], { + encoding: 'utf8', + maxBuffer: 16 * 1_024 * 1_024, + }); + return result.stdout; +} + +function roundMeasurements( + measurements: PerformanceMeasurements, +): PerformanceMeasurements { + return Object.fromEntries( + Object.entries(measurements).map(([name, value]) => [ + name, + Math.round(value * 1_000) / 1_000, + ]), + ) as unknown as PerformanceMeasurements; +} diff --git a/tests/release/release-artifacts.test.ts b/tests/release/release-artifacts.test.ts new file mode 100644 index 0000000..20d453d --- /dev/null +++ b/tests/release/release-artifacts.test.ts @@ -0,0 +1,128 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { writeReleaseArtifacts } from './release-artifacts.js'; +import type { ReleaseReport } from './release-report.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { force: true, recursive: true })), + ); +}); + +describe('release gate artifacts', () => { + it('writes the sanitized JSON matrix and human-readable checklist', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codex-git-release-')); + temporaryDirectories.push(directory); + const report = fixtureReport(); + + const artifacts = await writeReleaseArtifacts(directory, report); + + expect(artifacts).toEqual({ + json: join(directory, 'acceptance-matrix.json'), + markdown: join(directory, 'acceptance-matrix.md'), + }); + expect(await readFile(artifacts.json, 'utf8')).toContain( + '"status": "passed"', + ); + expect(await readFile(artifacts.json, 'utf8')).not.toContain('markdown'); + expect(await readFile(artifacts.json, 'utf8')).not.toContain( + 'fixture-npm-secret', + ); + expect(await readFile(artifacts.markdown, 'utf8')).toContain( + '# Codex Git MVP release gate evidence', + ); + expect( + await readFile(join(directory, 'manual', 'codex-host-smoke.md'), 'utf8'), + ).toContain('# Codex Host Adapter compatibility'); + }); + + it('does not archive a manually declared record that validation rejected', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codex-git-release-')); + temporaryDirectories.push(directory); + const fixture = fixtureReport(); + const report: ReleaseReport = { + ...fixture, + criteria: fixture.criteria.map((criterion) => ({ + ...criterion, + evidence: criterion.evidence.map((evidence) => + evidence.kind === 'manual' + ? { ...evidence, status: 'failed' as const } + : evidence, + ), + status: 'failed', + })), + status: 'failed', + }; + + await writeReleaseArtifacts(directory, report); + + await expect( + readFile(join(directory, 'manual', 'codex-host-smoke.md'), 'utf8'), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); + +function fixtureReport(): ReleaseReport { + return { + criteria: [ + { + evidence: [ + { + kind: 'automated', + reference: 'tests/example.test.ts::passes safely', + status: 'passed', + }, + { + checkId: 'codex-host-smoke', + kind: 'manual', + reference: + 'docs/host-integration/codex-compatibility.md#manual-smoke-matrix', + status: 'passed', + }, + ], + id: 'AC-01', + status: 'passed', + title: 'Resolve the Current Project', + }, + ], + environment: { + architecture: 'arm64', + codex: '26.820.60940 NPM_TOKEN=fixture-npm-secret', + cpu: 'Test CPU', + git: 'git version 2.50.1', + memoryBytes: 1, + node: 'v22.12.0', + operatingSystem: 'macOS 15.6', + referenceProfile: 'local-macos-release', + }, + environmentFailures: [], + generatedAt: '2026-09-01T00:00:00.000Z', + markdown: '# Codex Git MVP release gate evidence\n', + manualEvidence: { + checks: [ + { + environment: 'Codex Desktop 26.820.60940', + id: 'codex-host-smoke', + performedAt: '2026-08-29T00:00:00.000Z', + record: + 'docs/host-integration/codex-compatibility.md#manual-smoke-matrix', + sourceRevision: 'sha256:fixture', + status: 'passed', + validUntil: '2026-09-29T00:00:00.000Z', + }, + ], + schemaVersion: 1, + }, + performance: null, + staticIssues: [], + status: 'passed', + }; +} diff --git a/tests/release/release-artifacts.ts b/tests/release/release-artifacts.ts new file mode 100644 index 0000000..df06e36 --- /dev/null +++ b/tests/release/release-artifacts.ts @@ -0,0 +1,84 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { isAbsolute, join, resolve, sep } from 'node:path'; + +import { redactDiagnostic } from '@codex-git/protocol'; + +import type { ReleaseReport } from './release-report.js'; + +export interface ReleaseArtifactPaths { + readonly json: string; + readonly markdown: string; +} + +export async function writeReleaseArtifacts( + directory: string, + report: ReleaseReport, + root = process.cwd(), +): Promise { + await mkdir(directory, { recursive: true }); + const paths = { + json: join(directory, 'acceptance-matrix.json'), + markdown: join(directory, 'acceptance-matrix.md'), + }; + const json = { + criteria: report.criteria, + environment: report.environment, + environmentFailures: report.environmentFailures, + generatedAt: report.generatedAt, + manualEvidence: report.manualEvidence, + performance: report.performance, + staticIssues: report.staticIssues, + status: report.status, + }; + + const sanitize = (_key: string, value: unknown) => + typeof value === 'string' ? redactDiagnostic(value) : value; + await Promise.all([ + writeFile(paths.json, `${JSON.stringify(json, sanitize, 2)}\n`, 'utf8'), + writeFile(paths.markdown, redactDiagnostic(report.markdown), 'utf8'), + archiveManualRecords(directory, root, report), + ]); + return paths; +} + +async function archiveManualRecords( + directory: string, + root: string, + report: ReleaseReport, +): Promise { + const manualDirectory = join(directory, 'manual'); + const expectedRoot = resolve(root); + const validatedCheckIds = new Set( + report.criteria.flatMap((criterion) => + criterion.evidence.flatMap((evidence) => + evidence.kind === 'manual' && + evidence.status === 'passed' && + evidence.checkId !== undefined + ? [evidence.checkId] + : [], + ), + ), + ); + const records = report.manualEvidence.checks.filter( + (check) => + check.status === 'passed' && + check.record !== null && + validatedCheckIds.has(check.id), + ); + if (records.length === 0) return; + await mkdir(manualDirectory, { recursive: true }); + await Promise.all( + records.map(async (check) => { + const record = check.record!.split('#', 1)[0]!; + const source = resolve(expectedRoot, record); + if ( + isAbsolute(record) || + (source !== expectedRoot && !source.startsWith(`${expectedRoot}${sep}`)) + ) { + throw new Error(`Manual evidence path is unsafe: ${record}.`); + } + const content = redactDiagnostic(await readFile(source, 'utf8')); + await writeFile(join(manualDirectory, `${check.id}.md`), content, 'utf8'); + }), + ); +} diff --git a/tests/release/release-envelope.ts b/tests/release/release-envelope.ts new file mode 100644 index 0000000..6bc0deb --- /dev/null +++ b/tests/release/release-envelope.ts @@ -0,0 +1,6 @@ +export const SUPPORTED_SCALE = { + availableWorktrees: 25, + changedFiles: 2_000, + refs: 5_000, + unavailableRegistrations: 1, +} as const; diff --git a/tests/release/release-environment.test.ts b/tests/release/release-environment.test.ts new file mode 100644 index 0000000..dc2f917 --- /dev/null +++ b/tests/release/release-environment.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectReleaseEnvironment, + validateReleaseEnvironment, +} from './release-environment.js'; + +describe('release environment evidence', () => { + it('records the required hardware, macOS, Git, Node, and Codex versions', async () => { + const environment = await collectReleaseEnvironment({ + architecture: 'arm64', + codexVersion: '26.820.60940 (build 7119)', + cpu: 'Apple Test CPU', + memoryBytes: 16 * 1_024 ** 3, + nodeVersion: 'v22.12.0', + operatingSystem: 'darwin', + operatingSystemRelease: '24.6.0', + referenceProfile: 'github-actions-macos-15', + async run(command, args) { + if (command === 'git') return 'git version 2.50.1\n'; + if (command === 'sw_vers' && args[0] === '-productVersion') { + return '15.6\n'; + } + throw new Error(`Unexpected command: ${command}`); + }, + }); + + expect(environment).toEqual({ + architecture: 'arm64', + codex: '26.820.60940 (build 7119)', + cpu: 'Apple Test CPU', + git: 'git version 2.50.1', + memoryBytes: 16 * 1_024 ** 3, + node: 'v22.12.0', + operatingSystem: 'macOS 15.6 (Darwin 24.6.0)', + referenceProfile: 'github-actions-macos-15', + }); + expect(validateReleaseEnvironment(environment)).toEqual([]); + }); + + it('rejects an unapproved or incomplete benchmark host', () => { + expect( + validateReleaseEnvironment({ + architecture: 'x64', + codex: 'not recorded', + cpu: 'Test CPU', + git: 'git version 2.50.1', + memoryBytes: 1, + node: 'v22.12.0', + operatingSystem: 'linux 6.0', + referenceProfile: 'unapproved', + }), + ).toHaveLength(3); + }); +}); diff --git a/tests/release/release-environment.ts b/tests/release/release-environment.ts new file mode 100644 index 0000000..ddf6fed --- /dev/null +++ b/tests/release/release-environment.ts @@ -0,0 +1,85 @@ +import { execFile } from 'node:child_process'; +import { arch, cpus, platform, release, totalmem } from 'node:os'; +import { promisify } from 'node:util'; + +import type { ReleaseEnvironment } from './release-report.js'; + +const executeFile = promisify(execFile); + +export interface ReleaseEnvironmentSource { + readonly architecture: string; + readonly codexVersion: string; + readonly cpu: string; + readonly memoryBytes: number; + readonly nodeVersion: string; + readonly operatingSystem: NodeJS.Platform; + readonly operatingSystemRelease: string; + readonly referenceProfile: string; + run(command: string, args: readonly string[]): Promise; +} + +export async function collectReleaseEnvironment( + source: ReleaseEnvironmentSource = systemEnvironmentSource(), +): Promise { + const git = (await source.run('git', ['--version'])).trim(); + const operatingSystem = + source.operatingSystem === 'darwin' + ? `macOS ${(await source.run('sw_vers', ['-productVersion'])).trim()} (Darwin ${source.operatingSystemRelease})` + : `${source.operatingSystem} ${source.operatingSystemRelease}`; + + return { + architecture: source.architecture, + codex: source.codexVersion, + cpu: source.cpu, + git, + memoryBytes: source.memoryBytes, + node: source.nodeVersion, + operatingSystem, + referenceProfile: source.referenceProfile, + }; +} + +export const SUPPORTED_CODEX_RELEASE_VERSION = + '26.820.60940 (build 7119)' as const; + +export function validateReleaseEnvironment( + environment: ReleaseEnvironment, +): string[] { + const failures: string[] = []; + if (!environment.operatingSystem.startsWith('macOS ')) { + failures.push('The release benchmark must run on macOS.'); + } + if ( + environment.referenceProfile !== 'github-actions-macos-15' && + environment.referenceProfile !== 'local-macos-release' + ) { + failures.push( + 'The release benchmark requires an approved reference profile.', + ); + } + if (environment.codex !== SUPPORTED_CODEX_RELEASE_VERSION) { + failures.push( + `The release benchmark requires Codex Desktop ${SUPPORTED_CODEX_RELEASE_VERSION}.`, + ); + } + return failures; +} + +function systemEnvironmentSource(): ReleaseEnvironmentSource { + return { + architecture: arch(), + codexVersion: process.env.CODEX_DESKTOP_VERSION ?? 'not recorded', + cpu: cpus()[0]?.model ?? 'unknown', + memoryBytes: totalmem(), + nodeVersion: process.version, + operatingSystem: platform(), + operatingSystemRelease: release(), + referenceProfile: process.env.CODEX_GIT_REFERENCE_PROFILE ?? 'unapproved', + async run(command, args) { + const result = await executeFile(command, [...args], { + encoding: 'utf8', + }); + return result.stdout; + }, + }; +} diff --git a/tests/release/release-gate.test.ts b/tests/release/release-gate.test.ts new file mode 100644 index 0000000..64c4539 --- /dev/null +++ b/tests/release/release-gate.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { + MVP_ACCEPTANCE_CRITERIA, + RELEASE_ENVELOPE_CATEGORIES, + validateManualCheckIds, + validateReleaseGate, +} from './release-gate.js'; + +describe('MVP release gate', () => { + it('maps exactly AC-01 through AC-24 to maintained evidence', async () => { + const expectedIds = Array.from( + { length: 24 }, + (_, index) => `AC-${String(index + 1).padStart(2, '0')}`, + ); + + expect(MVP_ACCEPTANCE_CRITERIA.map((criterion) => criterion.id)).toEqual( + expectedIds, + ); + await expect(validateReleaseGate(process.cwd())).resolves.toEqual([]); + }); + + it('covers every performance, accessibility, compatibility, and security gate', () => { + const releaseEnvelope = MVP_ACCEPTANCE_CRITERIA.find( + (criterion) => criterion.id === 'AC-24', + ); + + for (const category of RELEASE_ENVELOPE_CATEGORIES) { + expect( + releaseEnvelope?.evidence.some( + (evidence) => + evidence.kind === 'automated' && + evidence.categories?.includes(category) === true, + ), + ).toBe(true); + } + }); + + it('rejects a manual check ID reused by another acceptance row', () => { + const manual = { + checkId: 'shared-smoke', + file: 'docs/release/mvp-release-gate.md', + kind: 'manual' as const, + marker: 'VoiceOver and keyboard smoke record', + reason: 'Requires a human check.', + }; + + expect( + validateManualCheckIds([ + { evidence: [manual], id: 'AC-23' }, + { evidence: [manual], id: 'AC-24' }, + ]), + ).toEqual(['AC-24 reuses manual check ID shared-smoke.']); + }); +}); diff --git a/tests/release/release-gate.ts b/tests/release/release-gate.ts new file mode 100644 index 0000000..39d1d66 --- /dev/null +++ b/tests/release/release-gate.ts @@ -0,0 +1,648 @@ +import { readFile } from 'node:fs/promises'; +import { isAbsolute, resolve, sep } from 'node:path'; + +export const RELEASE_ENVELOPE_CATEGORIES = [ + 'performance', + 'accessibility', + 'compatibility', + 'security', +] as const; + +type ReleaseEnvelopeCategory = (typeof RELEASE_ENVELOPE_CATEGORIES)[number]; + +export interface AutomatedEvidence { + readonly categories?: readonly ReleaseEnvelopeCategory[]; + readonly kind: 'automated'; + readonly file: string; + readonly test: string; +} + +export interface ManualEvidence { + readonly categories?: readonly ReleaseEnvelopeCategory[]; + readonly checkId: string; + readonly kind: 'manual'; + readonly file: string; + readonly marker: string; + readonly reason: string; +} + +export type AcceptanceEvidence = AutomatedEvidence | ManualEvidence; + +export interface AcceptanceCriterion { + readonly id: `AC-${string}`; + readonly title: string; + readonly evidence: readonly AcceptanceEvidence[]; + readonly categories?: readonly ReleaseEnvelopeCategory[]; +} + +const automated = ( + file: string, + test: string, + categories?: readonly ReleaseEnvelopeCategory[], +): AutomatedEvidence => ({ + categories, + kind: 'automated', + file, + test, +}); + +const manual = ( + checkId: string, + file: string, + marker: string, + reason: string, + categories?: readonly ReleaseEnvelopeCategory[], +): ManualEvidence => ({ + categories, + checkId, + kind: 'manual', + file, + marker, + reason, +}); + +export const MVP_ACCEPTANCE_CRITERIA: readonly AcceptanceCriterion[] = [ + { + id: 'AC-01', + title: 'Resolve the Current Project', + evidence: [ + automated( + 'tests/integration/repository-discovery.integration.test.ts', + 'returns a safe non-repository result for an ordinary directory', + ), + automated( + 'tests/integration/repository-discovery.integration.test.ts', + 'resolves anchors in Main and Linked Worktrees to one canonical Repository', + ), + ], + }, + { + id: 'AC-02', + title: 'Present the Repository and stable Worktree navigator', + evidence: [ + automated( + 'apps/ui/src/RepositoryOverview.test.tsx', + 'keeps Main first and the remaining Worktrees stable when status changes', + ), + automated( + 'apps/ui/src/RepositoryOverview.interactions.test.tsx', + 'searches all documented Worktree fields and keeps Commit Drafts independent', + ), + ], + }, + { + id: 'AC-03', + title: 'Include every registered Worktree exactly once', + evidence: [ + automated( + 'tests/integration/repository-discovery.integration.test.ts', + 'discovers every registered Worktree without path or Branch conventions', + ), + automated( + 'tests/integration/worktree-provenance.integration.test.ts', + 'keeps every Git Worktree Unclassified when metadata is unavailable', + ), + ], + }, + { + id: 'AC-04', + title: 'Degrade unavailable registrations safely', + evidence: [ + automated( + 'tests/integration/repository-discovery.integration.test.ts', + 'marks a registered Worktree unavailable when its Git file is broken', + ), + automated( + 'tests/integration/repository-discovery.integration.test.ts', + 'does not revive an unavailable identity after prune and same-path recreation', + ), + ], + }, + { + id: 'AC-05', + title: 'Classify changes truthfully', + evidence: [ + automated( + 'tests/integration/repository-observation.integration.test.ts', + 'publishes independent Changed Files for every observed Diff Baseline', + ), + automated( + 'tests/integration/repository-observation.integration.test.ts', + 'preserves rename origins and keeps staged deletions reviewable', + ), + ], + }, + { + id: 'AC-06', + title: 'Review every supported diff kind safely', + evidence: [ + automated( + 'tests/integration/repository-observation.integration.test.ts', + 'reads staged, unstaged, and Untracked diffs through opaque File IDs', + ), + automated( + 'tests/integration/repository-observation.integration.test.ts', + 'degrades binary, undecodable, oversized, and excessively long Diffs to metadata', + ), + ], + }, + { + id: 'AC-07', + title: 'Stage and Unstage only the selected target', + evidence: [ + automated( + 'tests/integration/repository-stage-unstage.integration.test.ts', + 'stages a Changed File in only the selected Worktree Index', + ), + automated( + 'tests/integration/repository-stage-unstage.integration.test.ts', + 'passes unusual paths literally through group Stage', + ), + ], + }, + { + id: 'AC-08', + title: 'Reject stale Index and file evidence', + evidence: [ + automated( + 'tests/integration/repository-stage-unstage.integration.test.ts', + 'rejects stale file evidence and returns current Worktree state', + ), + automated( + 'tests/integration/repository-stage-unstage.integration.test.ts', + 'reports per-path Partial Success without rollback claims', + ), + ], + }, + { + id: 'AC-09', + title: 'Commit staged content in Local, Initial, and detached states', + evidence: [ + automated( + 'tests/integration/repository-commit.integration.test.ts', + 'commits exactly staged content, preserves unstaged bytes, and clears only the successful Worktree draft', + ), + automated( + 'tests/integration/repository-commit.integration.test.ts', + 'requires explicit confirmation before committing on Detached HEAD', + ), + ], + }, + { + id: 'AC-10', + title: 'Recover Commit outcomes without losing the draft', + evidence: [ + automated( + 'tests/integration/repository-commit.integration.test.ts', + 'reports a timed-out Commit as Unknown Outcome and blocks a duplicate retry', + ), + automated( + 'tests/integration/repository-commit.integration.test.ts', + 'classifies configured signing failure without exposing raw diagnostics', + ), + ], + }, + { + id: 'AC-11', + title: 'Switch only a Clean Worktree', + evidence: [ + automated( + 'tests/integration/branch-switching.integration.test.ts', + 'switches a clean Worktree to an unoccupied Local Branch', + ), + automated( + 'tests/integration/branch-switching.integration.test.ts', + 'blocks a dirty Worktree without carrying or discarding changes', + ), + ], + }, + { + id: 'AC-12', + title: 'Enforce Branch Occupancy Repository-wide', + evidence: [ + automated( + 'tests/integration/branch-switching.integration.test.ts', + 'discovers cached Local Branches with Repository-wide occupancy', + ), + automated( + 'tests/integration/branch-switching.integration.test.ts', + 'rejects stale Branch Occupancy after an external Worktree claims the target', + ), + ], + }, + { + id: 'AC-13', + title: 'Limit Remote-tracking Branch selection', + evidence: [ + automated( + 'tests/integration/branch-switching.integration.test.ts', + 'creates only the same-name Local tracking Branch for a cached Remote-tracking target', + ), + ], + }, + { + id: 'AC-14', + title: 'Fetch without changing Worktree content', + evidence: [ + automated( + 'tests/integration/repository-fetch.integration.test.ts', + 'fetches one opaque Remote without changing the Working Tree or Index', + ), + automated( + 'tests/integration/repository-fetch.integration.test.ts', + 'Fetch all preserves successful updates and attributes every Remote result', + ), + ], + }, + { + id: 'AC-15', + title: 'Pull only by fast-forward', + evidence: [ + automated( + 'tests/integration/repository-sync.integration.test.ts', + 'Pull fast-forwards a clean behind Local Branch from its exact Upstream', + ), + automated( + 'tests/integration/repository-sync.integration.test.ts', + 'Pull blocks divergence without changing files or refs', + ), + ], + }, + { + id: 'AC-16', + title: 'Push only committed history to the exact Upstream', + evidence: [ + automated( + 'tests/integration/repository-sync.integration.test.ts', + 'Push transfers committed history and leaves uncommitted content local', + ), + automated( + 'packages/repository-engine/src/remote-operation.test.ts', + 'Push uses one exact full-ref refspec without force, tags, deletion, or matching refs', + ), + ], + }, + { + id: 'AC-17', + title: 'Publish an Unpublished Branch explicitly', + evidence: [ + automated( + 'tests/integration/repository-sync.integration.test.ts', + 'Publish creates only the same-name Branch in the Remote and configures Upstream after success', + ), + automated( + 'apps/ui/src/RepositoryOverview.interactions.test.tsx', + 'confirms the exact Remote and same-name target before Publish', + ), + ], + }, + { + id: 'AC-18', + title: 'Distinguish Remote and credential failures', + evidence: [ + automated( + 'tests/integration/repository-fetch.integration.test.ts', + 'classifies an unreachable credentialed URL without exposing secrets', + ), + automated( + 'tests/integration/repository-sync.integration.test.ts', + 'Push reports a protected-Branch policy rejection without retrying', + ), + ], + }, + { + id: 'AC-19', + title: 'Coordinate independent Worktree local mutations', + evidence: [ + automated( + 'tests/integration/repository-commit.integration.test.ts', + 'runs Commits concurrently in different Worktrees without crossing HEAD or Index', + ), + automated( + 'packages/repository-engine/src/operation-coordinator.test.ts', + 'derives Local lanes and returns reconciled Busy without queueing', + ), + ], + }, + { + id: 'AC-20', + title: 'Coordinate Repository-wide Branch and Remote operations', + evidence: [ + automated( + 'packages/repository-engine/src/operation-coordinator.test.ts', + 'derives Repository lanes and mandatory cross-lane claims', + ), + automated( + 'packages/repository-engine/src/operation-coordinator.test.ts', + 'makes a Remote-tracking Branch conflict with Fetch for its exact Remote', + ), + ], + }, + { + id: 'AC-21', + title: 'Reconcile every attempted mutation', + evidence: [ + automated( + 'packages/repository-engine/src/operation-coordinator.test.ts', + 'derives the terminal result from reconciliation rather than process state', + ), + automated( + 'packages/repository-engine/src/operation-lifecycle.test.ts', + 'drains only active, reconciling, and Unknown records during close', + ), + ], + }, + { + id: 'AC-22', + title: 'Reject stale topology, identity, and navigation targets', + evidence: [ + automated( + 'tests/integration/repository-discovery.integration.test.ts', + 'keeps continuous identities and invalidates removed or moved generations', + ), + automated( + 'tests/integration/repository-stage-unstage.integration.test.ts', + 'rejects a removed and recreated Worktree generation before mutation', + ), + ], + }, + { + id: 'AC-23', + title: 'Navigate to exact targets and preserve provenance optionality', + evidence: [ + automated( + 'tests/e2e/protocol-runtime.e2e.test.ts', + 'targets the new path for renames and rejects a file that disappears before launch', + ), + automated( + 'tests/integration/worktree-provenance.integration.test.ts', + 'does not invalidate Git file targets when optional metadata disappears', + ), + ], + }, + { + id: 'AC-24', + title: 'Pass the supported release envelope', + categories: RELEASE_ENVELOPE_CATEGORIES, + evidence: [ + automated( + 'tests/release/supported-scale.test.ts', + 'contains 25 Available Worktrees, 2,000 Changed Files, 5,000 refs, and unavailable diagnostics', + ['performance'], + ), + automated( + 'tests/release/supported-scale.test.ts', + 'keeps loaded UI interactions within 100 milliseconds', + ['performance'], + ), + automated( + 'tests/release/oversized-diff.release.test.tsx', + 'degrades a 2 MiB and 20,001-line Diff without freezing the loaded UI', + ['performance'], + ), + automated( + 'apps/ui/src/RepositoryOverview.test.tsx', + 'keeps Main first and the remaining Worktrees stable when status changes', + ['accessibility'], + ), + automated( + 'apps/ui/src/RepositoryOverview.interactions.test.tsx', + 'moves through the stable Worktree navigator with arrow keys', + ['accessibility'], + ), + automated( + 'apps/ui/src/RepositoryOverview.interactions.test.tsx', + 'recovers focus to detail when Worktree removal collapses the navigator', + ['accessibility'], + ), + automated( + 'apps/ui/src/RepositoryOverview.interactions.test.tsx', + 'preserves selection on harmless refresh and recovers focus when that generation disappears', + ['accessibility'], + ), + automated( + 'apps/ui/src/RepositoryOverview.interactions.test.tsx', + 'announces a Branch change without stealing focus from the Commit Draft', + ['accessibility'], + ), + automated( + 'tests/release/accessibility.release.test.tsx', + 'keeps controls keyboard operable, target named, visibly focused, announced, and non-color-only', + ['accessibility'], + ), + automated( + 'tests/contract/standalone-host-adapter.contract.test.ts', + 'publishes the standalone Host Context after attaching a surface', + ['compatibility'], + ), + automated( + 'tests/e2e/standalone-runtime.e2e.test.ts', + 'serves the health endpoint and placeholder Git Surface', + ['compatibility'], + ), + automated( + 'tests/e2e/host-product-parity.e2e.test.ts', + 'exposes the same Repository snapshot and Diff behavior through standalone and Codex hosts', + ['compatibility'], + ), + automated( + 'packages/host-adapter/codex-cdp/src/adapter.test.ts', + 'fails closed without changing an incompatible renderer', + ['compatibility'], + ), + automated( + 'packages/host-adapter/codex-cdp/src/adapter.test.ts', + 'mounts one opaque Git surface and restores native navigation', + ['compatibility'], + ), + automated( + 'packages/host-adapter/codex-cdp/src/adapter.test.ts', + 'accepts actions only from the current frame capability and challenge', + ['compatibility', 'security'], + ), + automated( + 'packages/host-adapter/codex-cdp/src/dedicated-adapter.test.ts', + 'publishes one standalone transition when replacement cannot reacquire CSP', + ['compatibility'], + ), + automated( + 'tests/e2e/codex-runtime.e2e.test.ts', + 'closes the dedicated instance and remains standalone when ownership fails', + ['compatibility'], + ), + automated( + 'apps/server/src/loopback-server.test.ts', + 'binds an ephemeral loopback listener behind a 256-bit token path', + ['security'], + ), + automated( + 'apps/server/src/loopback-server.test.ts', + 'validates token and version before evaluating the browser Origin', + ['security'], + ), + automated( + 'apps/server/src/protocol-dispatch.test.ts', + 'rejects a fabricated native target that was not issued by the snapshot', + ['security'], + ), + automated( + 'tests/integration/repository-stage-unstage.integration.test.ts', + 'passes unusual paths literally through group Stage', + ['security'], + ), + automated( + 'packages/repository-engine/src/remote-operation.test.ts', + 'Push uses one exact full-ref refspec without force, tags, deletion, or matching refs', + ['security'], + ), + automated( + 'packages/repository-engine/src/git-environment.test.ts', + 'removes inherited Git authority while preserving ordinary process context', + ['security'], + ), + automated( + 'tests/contract/protocol.contract.test.ts', + 'removes URL userinfo, authorization, tokens, and launch secrets', + ['security'], + ), + automated( + 'tests/release/release-artifacts.test.ts', + 'writes the sanitized JSON matrix and human-readable checklist', + ['security'], + ), + automated( + 'packages/repository-engine/src/operation-coordinator.test.ts', + 'derives Local lanes and returns reconciled Busy without queueing', + ), + automated( + 'tests/integration/repository-commit.integration.test.ts', + 'rejects stale Index evidence and an unresolved external Index lock before Commit', + ), + automated( + 'tests/integration/repository-commit.integration.test.ts', + 'owns the native Index lock transaction and rejects post-launch external staging', + ), + automated( + 'tests/integration/repository-commit.integration.test.ts', + 'lets native Git expected-old ref CAS reject a post-launch HEAD mutation', + ), + automated( + 'tests/e2e/protocol-runtime.e2e.test.ts', + 'targets the new path for renames and rejects a file that disappears before launch', + ), + automated( + 'tests/integration/repository-discovery.integration.test.ts', + 'does not revive an unavailable identity after prune and same-path recreation', + ), + automated( + 'tests/integration/repository-commit.integration.test.ts', + 'reports a timed-out Commit as Unknown Outcome and blocks a duplicate retry', + ), + manual( + 'codex-host-smoke', + 'docs/host-integration/codex-compatibility.md', + 'Manual smoke matrix', + 'Codex Desktop attachment and native UI restoration require the named macOS host build.', + ['compatibility'], + ), + manual( + 'voiceover-keyboard-smoke', + 'docs/release/mvp-release-gate.md', + 'VoiceOver and keyboard smoke record', + 'Assistive-technology announcements and navigation require a human macOS check.', + ['accessibility'], + ), + ], + }, +] as const; + +export async function validateReleaseGate(root: string): Promise { + const issues = validateManualCheckIds(MVP_ACCEPTANCE_CRITERIA); + const seenEvidence = new Set(); + const expectedRoot = resolve(root); + + for (const criterion of MVP_ACCEPTANCE_CRITERIA) { + if (criterion.evidence.length === 0) { + issues.push(`${criterion.id} has no evidence.`); + continue; + } + if (!criterion.evidence.some((evidence) => evidence.kind === 'automated')) { + issues.push(`${criterion.id} has no automated evidence.`); + } + for (const category of criterion.categories ?? []) { + if ( + !criterion.evidence.some( + (evidence) => + evidence.kind === 'automated' && + evidence.categories?.includes(category) === true, + ) + ) { + issues.push(`${criterion.id} has no automated ${category} evidence.`); + } + } + + for (const evidence of criterion.evidence) { + const evidenceKey = + evidence.kind === 'automated' + ? `${evidence.file}::${evidence.test}` + : `${evidence.file}::${evidence.marker}`; + if (seenEvidence.has(`${criterion.id}::${evidenceKey}`)) { + issues.push(`${criterion.id} repeats evidence ${evidenceKey}.`); + } + seenEvidence.add(`${criterion.id}::${evidenceKey}`); + + const path = resolve(expectedRoot, evidence.file); + if ( + isAbsolute(evidence.file) || + (path !== expectedRoot && !path.startsWith(`${expectedRoot}${sep}`)) + ) { + issues.push( + `${criterion.id} references an unsafe path: ${evidence.file}.`, + ); + continue; + } + + let source: string; + try { + source = await readFile(path, 'utf8'); + } catch { + issues.push( + `${criterion.id} evidence file is missing: ${evidence.file}.`, + ); + continue; + } + + const marker = + evidence.kind === 'automated' ? evidence.test : evidence.marker; + if (!source.includes(marker)) { + issues.push( + `${criterion.id} evidence marker is missing from ${evidence.file}: ${marker}.`, + ); + } + if (evidence.kind === 'manual' && evidence.reason.trim().length === 0) { + issues.push(`${criterion.id} manual evidence requires a reason.`); + } + } + } + + return issues; +} + +export function validateManualCheckIds( + criteria: readonly Pick[], +): string[] { + const issues: string[] = []; + const seen = new Set(); + for (const criterion of criteria) { + for (const evidence of criterion.evidence) { + if (evidence.kind !== 'manual') continue; + if (seen.has(evidence.checkId)) { + issues.push( + `${criterion.id} reuses manual check ID ${evidence.checkId}.`, + ); + } + seen.add(evidence.checkId); + } + } + return issues; +} diff --git a/tests/release/release-report.test.ts b/tests/release/release-report.test.ts new file mode 100644 index 0000000..def118d --- /dev/null +++ b/tests/release/release-report.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; + +import { MVP_ACCEPTANCE_CRITERIA } from './release-gate.js'; +import { + createReleaseReport, + type ReleaseEnvironment, + type VitestJsonReport, +} from './release-report.js'; +import type { ReferenceBenchmarkResult } from './reference-benchmark.js'; +import type { ManualEvidenceRecord } from './manual-evidence.js'; + +const environment: ReleaseEnvironment = { + architecture: 'arm64', + codex: '26.820.60940 (build 7119)', + cpu: 'Test CPU', + git: 'git version 2.50.1', + memoryBytes: 16 * 1_024 ** 3, + node: 'v22.12.0', + operatingSystem: 'macOS 15.6', + referenceProfile: 'local-macos-release', +}; +const sourceRevision = 'sha256:fixture-product-source'; +const generatedAt = new Date('2026-09-01T00:00:00.000Z'); +const performance: ReferenceBenchmarkResult = { + budgetFailures: [], + fixture: { + availableWorktrees: 25, + changedFiles: 2_000, + refs: 5_000, + unavailableRegistrations: 1, + }, + measurements: { + externalChange: 800, + fullSnapshot: 900, + loadedInteraction: 1, + selectedWorktree: 900, + shell: 3, + }, +}; +const manualEvidence: ManualEvidenceRecord = { + checks: [ + { + environment: 'Codex Desktop 26.820.60940 (build 7119)', + id: 'codex-host-smoke', + performedAt: '2026-08-29T00:00:00.000Z', + record: + 'docs/host-integration/codex-compatibility.md#manual-smoke-matrix', + sourceRevision, + status: 'passed', + validUntil: '2026-09-29T00:00:00.000Z', + }, + { + environment: 'macOS 15.6 with VoiceOver', + id: 'voiceover-keyboard-smoke', + performedAt: '2026-09-01T00:00:00.000Z', + record: + 'docs/release/mvp-release-gate.md#voiceover-and-keyboard-smoke-record', + sourceRevision, + status: 'passed', + validUntil: '2026-09-29T00:00:00.000Z', + }, + ], + schemaVersion: 1, +}; + +describe('release evidence report', () => { + it('passes only when every automated acceptance reference passed', async () => { + const report = await createReleaseReport( + process.cwd(), + passingVitestReport(), + environment, + performance, + manualEvidence, + sourceRevision, + generatedAt, + ); + + expect(report.status).toBe('passed'); + expect(report.criteria).toHaveLength(24); + expect(report.markdown).toContain( + '| AC-24 | Pass the supported release envelope | passed |', + ); + expect(report.markdown).toContain('Codex | 26.820.60940 (build 7119)'); + expect(report.markdown).toContain( + '| Full snapshot | 900 ms | 5000 ms | passed |', + ); + }); + + it('fails the owning criterion without archiving raw test failure text', async () => { + const vitest = passingVitestReport(); + const firstResult = vitest.testResults[0]?.assertionResults[0]; + if (firstResult === undefined) throw new Error('Expected test evidence'); + firstResult.status = 'failed'; + firstResult.failureMessages = ['credential_token=do-not-archive']; + + const report = await createReleaseReport( + process.cwd(), + vitest, + environment, + performance, + manualEvidence, + sourceRevision, + generatedAt, + ); + + expect(report.status).toBe('failed'); + expect(report.criteria.find(({ id }) => id === 'AC-01')?.status).toBe( + 'failed', + ); + expect(JSON.stringify(report)).not.toContain('do-not-archive'); + }); + + it('blocks AC-24 while a required manual check is pending', async () => { + const pending: ManualEvidenceRecord = { + ...manualEvidence, + checks: manualEvidence.checks.map((check) => + check.id === 'voiceover-keyboard-smoke' + ? { + environment: null, + id: check.id, + performedAt: null, + record: null, + sourceRevision: null, + status: 'pending' as const, + validUntil: null, + } + : check, + ), + }; + + const report = await createReleaseReport( + process.cwd(), + passingVitestReport(), + environment, + performance, + pending, + sourceRevision, + generatedAt, + ); + + expect(report.status).toBe('failed'); + expect(report.criteria.find(({ id }) => id === 'AC-24')?.status).toBe( + 'failed', + ); + }); + + it('blocks AC-24 for stale or missing manual evidence', async () => { + const invalid: ManualEvidenceRecord = { + ...manualEvidence, + checks: manualEvidence.checks.map((check) => + check.id === 'voiceover-keyboard-smoke' + ? { + ...check, + record: 'docs/release/missing-voiceover-record.md', + sourceRevision: 'sha256:stale-product-source', + } + : check, + ), + }; + + const report = await createReleaseReport( + process.cwd(), + passingVitestReport(), + environment, + performance, + invalid, + sourceRevision, + generatedAt, + ); + + expect(report.criteria.find(({ id }) => id === 'AC-24')?.status).toBe( + 'failed', + ); + }); + + it('fails AC-24 when a performance budget or environment requirement fails', async () => { + const report = await createReleaseReport( + process.cwd(), + passingVitestReport(), + { ...environment, referenceProfile: 'unapproved' }, + { ...performance, budgetFailures: ['shell exceeded its budget'] }, + manualEvidence, + sourceRevision, + generatedAt, + ); + + const releaseEnvelope = report.criteria.find(({ id }) => id === 'AC-24'); + expect(releaseEnvelope?.status).toBe('failed'); + expect(releaseEnvelope?.evidence).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'performance', status: 'failed' }), + expect.objectContaining({ kind: 'environment', status: 'failed' }), + ]), + ); + }); +}); + +function passingVitestReport(): VitestJsonReport { + const byFile = new Map< + string, + VitestJsonReport['testResults'][number]['assertionResults'] + >(); + for (const criterion of MVP_ACCEPTANCE_CRITERIA) { + for (const evidence of criterion.evidence) { + if (evidence.kind !== 'automated') continue; + const assertionResults = byFile.get(evidence.file) ?? []; + if (!assertionResults.some(({ title }) => title === evidence.test)) { + assertionResults.push({ + failureMessages: [], + status: 'passed', + title: evidence.test, + }); + } + byFile.set(evidence.file, assertionResults); + } + } + + return { + success: true, + testResults: [...byFile].map(([file, assertionResults]) => ({ + assertionResults, + name: `${process.cwd()}/${file}`, + })), + }; +} diff --git a/tests/release/release-report.ts b/tests/release/release-report.ts new file mode 100644 index 0000000..0e6a25f --- /dev/null +++ b/tests/release/release-report.ts @@ -0,0 +1,288 @@ +import { relative, sep } from 'node:path'; + +import { + MVP_ACCEPTANCE_CRITERIA, + validateReleaseGate, + type AcceptanceEvidence, +} from './release-gate.js'; +import { PERFORMANCE_BUDGET_MILLISECONDS } from './performance-budget.js'; +import type { ReferenceBenchmarkResult } from './reference-benchmark.js'; +import { + manualEvidenceCheckPasses, + type ManualEvidenceRecord, +} from './manual-evidence.js'; +import { validateReleaseEnvironment } from './release-environment.js'; + +export interface VitestAssertionResult { + failureMessages?: string[]; + status: string; + title: string; +} + +export interface VitestJsonReport { + readonly success: boolean; + readonly testResults: { + readonly assertionResults: VitestAssertionResult[]; + readonly name: string; + }[]; +} + +export interface ReleaseEnvironment { + readonly architecture: string; + readonly codex: string; + readonly cpu: string; + readonly git: string; + readonly memoryBytes: number; + readonly node: string; + readonly operatingSystem: string; + readonly referenceProfile: string; +} + +export interface ReleaseEvidenceResult { + readonly checkId?: string; + readonly kind: AcceptanceEvidence['kind'] | 'environment' | 'performance'; + readonly reference: string; + readonly status: 'failed' | 'passed'; +} + +export interface ReleaseCriterionResult { + readonly evidence: readonly ReleaseEvidenceResult[]; + readonly id: string; + readonly status: 'failed' | 'passed'; + readonly title: string; +} + +export interface ReleaseReport { + readonly criteria: readonly ReleaseCriterionResult[]; + readonly environment: ReleaseEnvironment; + readonly environmentFailures: readonly string[]; + readonly generatedAt: string; + readonly markdown: string; + readonly manualEvidence: ManualEvidenceRecord; + readonly performance: ReferenceBenchmarkResult | null; + readonly status: 'failed' | 'passed'; + readonly staticIssues: readonly string[]; +} + +export async function createReleaseReport( + root: string, + vitest: VitestJsonReport, + environment: ReleaseEnvironment, + performance: ReferenceBenchmarkResult | null = null, + manualEvidence: ManualEvidenceRecord = { checks: [], schemaVersion: 1 }, + sourceRevision = 'unrecorded', + generatedAt = new Date(), +): Promise { + const staticIssues = await validateReleaseGate(root); + const testResults = indexTestResults(root, vitest); + const environmentFailures = validateReleaseEnvironment(environment); + const criteria = await Promise.all( + MVP_ACCEPTANCE_CRITERIA.map(async (criterion) => { + const evidence = await Promise.all( + criterion.evidence.map(async (item): Promise => { + if (item.kind === 'manual') { + const check = manualEvidence.checks.find( + (candidate) => candidate.id === item.checkId, + ); + return { + checkId: item.checkId, + kind: item.kind, + reference: + check?.record ?? + `${item.file}#${item.marker} (${item.checkId})`, + status: (await manualEvidenceCheckPasses( + root, + check, + sourceRevision, + generatedAt, + )) + ? 'passed' + : 'failed', + }; + } + + return { + kind: item.kind, + reference: `${item.file}::${item.test}`, + status: + testResults.get(`${item.file}::${item.test}`) === 'passed' + ? 'passed' + : 'failed', + }; + }), + ); + if (criterion.id === 'AC-24') { + evidence.push( + { + kind: 'performance', + reference: 'reference benchmark performance budgets', + status: + performance !== null && performance.budgetFailures.length === 0 + ? 'passed' + : 'failed', + }, + { + kind: 'environment', + reference: 'approved macOS and Codex reference profile', + status: environmentFailures.length === 0 ? 'passed' : 'failed', + }, + ); + } + const hasStaticIssue = staticIssues.some((issue) => + issue.startsWith(`${criterion.id} `), + ); + return { + evidence, + id: criterion.id, + status: + !hasStaticIssue && evidence.every(({ status }) => status !== 'failed') + ? ('passed' as const) + : ('failed' as const), + title: criterion.title, + }; + }), + ); + const status = + vitest.success && + staticIssues.length === 0 && + performance !== null && + performance.budgetFailures.length === 0 && + criteria.every((criterion) => criterion.status === 'passed') + ? ('passed' as const) + : ('failed' as const); + const reportWithoutMarkdown = { + criteria, + environment, + environmentFailures, + generatedAt: generatedAt.toISOString(), + manualEvidence, + performance, + status, + staticIssues, + }; + + return { + ...reportWithoutMarkdown, + markdown: renderReleaseReport(reportWithoutMarkdown), + }; +} + +function indexTestResults( + root: string, + vitest: VitestJsonReport, +): ReadonlyMap { + const results = new Map(); + for (const testFile of vitest.testResults) { + const file = relative(root, testFile.name).split(sep).join('/'); + for (const assertion of testFile.assertionResults) { + results.set(`${file}::${assertion.title}`, assertion.status); + } + } + return results; +} + +function renderReleaseReport(report: { + readonly criteria: readonly ReleaseCriterionResult[]; + readonly environment: ReleaseEnvironment; + readonly environmentFailures: readonly string[]; + readonly generatedAt: string; + readonly manualEvidence: ManualEvidenceRecord; + readonly performance: ReferenceBenchmarkResult | null; + readonly status: 'failed' | 'passed'; + readonly staticIssues: readonly string[]; +}): string { + const criteria = report.criteria + .map( + ({ id, status, title }) => `| ${id} | ${escapeCell(title)} | ${status} |`, + ) + .join('\n'); + const evidence = report.criteria + .flatMap((criterion) => + criterion.evidence.map( + (item) => + `| ${criterion.id} | ${item.kind} | ${escapeCell(item.reference)} | ${item.status} |`, + ), + ) + .join('\n'); + const staticIssues = + report.staticIssues.length === 0 + ? 'None.' + : report.staticIssues.map((issue) => `- ${issue}`).join('\n'); + const performance = renderPerformance(report.performance); + + return `# Codex Git MVP release gate evidence + +- Generated: ${report.generatedAt} +- Overall status: **${report.status}** + +## Reference environment + +| Field | Value | +| --- | --- | +| CPU | ${escapeCell(report.environment.cpu)} | +| Architecture | ${escapeCell(report.environment.architecture)} | +| Memory bytes | ${report.environment.memoryBytes} | +| Operating system | ${escapeCell(report.environment.operatingSystem)} | +| Git | ${escapeCell(report.environment.git)} | +| Node | ${escapeCell(report.environment.node)} | +| Codex | ${escapeCell(report.environment.codex)} | +| Reference profile | ${escapeCell(report.environment.referenceProfile)} | + +## Environment validation + +${report.environmentFailures.length === 0 ? 'Passed.' : report.environmentFailures.map((failure) => `- ${failure}`).join('\n')} + +## Performance and capacity + +${performance} + +## Acceptance matrix + +| Criterion | Scenario | Status | +| --- | --- | --- | +${criteria} + +## Evidence + +| Criterion | Kind | Reference | Status | +| --- | --- | --- | --- | +${evidence} + +## Static validation issues + +${staticIssues} +`; +} + +function renderPerformance( + performance: ReferenceBenchmarkResult | null, +): string { + if (performance === null) return 'Reference benchmark was not recorded.'; + const labels = { + externalChange: 'Visible external change', + fullSnapshot: 'Full snapshot', + loadedInteraction: 'Loaded interaction', + selectedWorktree: 'Selected Worktree', + shell: 'Application shell', + } as const; + const measurements = (Object.keys(labels) as (keyof typeof labels)[]) + .map((name) => { + const measured = performance.measurements[name]; + const budget = PERFORMANCE_BUDGET_MILLISECONDS[name]; + return `| ${labels[name]} | ${measured} ms | ${budget} ms | ${measured <= budget ? 'passed' : 'failed'} |`; + }) + .join('\n'); + + return `- Available Worktrees: ${performance.fixture.availableWorktrees} +- Unavailable registrations: ${performance.fixture.unavailableRegistrations} +- Changed Files: ${performance.fixture.changedFiles} +- Local and Remote-tracking refs: ${performance.fixture.refs} + +| Measurement | Observed | Budget | Status | +| --- | --- | --- | --- | +${measurements}`; +} + +function escapeCell(value: string): string { + return value.replaceAll('|', '\\|').replaceAll('\n', ' '); +} diff --git a/tests/release/supported-scale-fixture.ts b/tests/release/supported-scale-fixture.ts new file mode 100644 index 0000000..e8f2670 --- /dev/null +++ b/tests/release/supported-scale-fixture.ts @@ -0,0 +1,239 @@ +import { + fileIdSchema, + refIdSchema, + worktreeGenerationSchema, + worktreeIdSchema, + type BranchSearchResult, +} from '@codex-git/protocol'; + +import { manyWorktrees } from '../../apps/ui/src/overview-fixtures.js'; +import type { + RepositoryOverviewSnapshot, + RepositoryOverviewSource, + RepositoryOverviewSourceState, + WorktreeOverviewSnapshot, +} from '../../apps/ui/src/repository-overview-model.js'; +import { SUPPORTED_SCALE } from './release-envelope.js'; + +export interface SupportedScaleFixture { + readonly branchSearch: BranchSearchResult; + readonly source: RepositoryOverviewSource; +} + +export function createSupportedScaleFixture(): SupportedScaleFixture { + const snapshot = createSnapshot(); + const branchSearch = createBranchSearch(snapshot); + const state: RepositoryOverviewSourceState = { + kind: 'repository', + snapshot, + }; + const listeners = new Set<() => void>(); + + const source: RepositoryOverviewSource = { + getSnapshot: () => state, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + requestRefresh() {}, + requestFetch() {}, + async requestDiff(fileId) { + return { + kind: 'too_large', + fileId, + baseline: 'index_to_working_tree', + byteCount: 2 * 1_024 * 1_024 + 1, + lineCount: 20_001, + }; + }, + async requestNativeAction() { + return { + kind: 'unavailable', + message: 'Native actions are disabled in the supported-scale fixture.', + }; + }, + async mutateFiles() { + throw new Error('Mutations are disabled in the supported-scale fixture.'); + }, + async getCommitDraft(worktreeId) { + return { worktreeId, revision: 0, text: '' }; + }, + async updateCommitDraft(request) { + return { + worktreeId: request.worktreeId, + revision: request.expectedRevision + 1, + text: request.update.kind === 'set' ? request.update.text : '', + }; + }, + async commit() { + throw new Error('Commit is disabled in the supported-scale fixture.'); + }, + async cancelOperation() { + throw new Error( + 'Operation cancellation is disabled in the supported-scale fixture.', + ); + }, + async recoverOperation() { + throw new Error( + 'Operation recovery is disabled in the supported-scale fixture.', + ); + }, + async searchBranches(_worktreeId, query) { + const normalizedQuery = query.trim().toLocaleLowerCase(); + return { + ...branchSearch, + candidates: + normalizedQuery.length === 0 + ? branchSearch.candidates + : branchSearch.candidates.filter((candidate) => + candidate.displayName + .toLocaleLowerCase() + .includes(normalizedQuery), + ), + }; + }, + async switchBranch() { + throw new Error( + 'Branch switching is disabled in the supported-scale fixture.', + ); + }, + async requestRemoteOperation() { + throw new Error( + 'Remote operations are disabled in the supported-scale fixture.', + ); + }, + }; + + return { branchSearch, source }; +} + +function createSnapshot(): RepositoryOverviewSnapshot { + if (manyWorktrees.worktrees.length !== SUPPORTED_SCALE.availableWorktrees) { + throw new Error( + 'The base overview fixture no longer contains 25 Worktrees.', + ); + } + + let nextFile = 1; + const filesPerWorktree = + SUPPORTED_SCALE.changedFiles / SUPPORTED_SCALE.availableWorktrees; + const availableWorktrees = manyWorktrees.worktrees.map( + (worktree, worktreeIndex) => { + const changes = Array.from({ length: filesPerWorktree }, (_, offset) => { + const fileIndex = nextFile; + nextFile += 1; + return createChangedFile(fileIndex, worktreeIndex, offset); + }); + + return { + ...worktree, + worktreeRevision: 24, + freshness: { kind: 'current' as const }, + status: { + kind: 'changed' as const, + conflictCount: filesPerWorktree / 4, + stagedCount: filesPerWorktree / 4, + trackedChangeCount: filesPerWorktree / 2, + untrackedCount: filesPerWorktree / 4, + }, + changes, + }; + }, + ); + return { + ...manyWorktrees, + repositoryRevision: 24, + topologyRevision: 24, + refsRevision: 24, + worktrees: [ + ...availableWorktrees, + { + ...availableWorktrees.at(-1)!, + worktreeId: worktreeIdSchema.parse( + 'worktree_ffffffffffffffffffffffffffffffff', + ), + generation: worktreeGenerationSchema.parse( + 'generation_ffffffffffffffffffffffffffffffff', + ), + worktreeRevision: 1, + displayName: 'unavailable-registration', + path: '/private/tmp/codex-git-unavailable-registration', + freshness: { + kind: 'stale', + message: 'Last successful observation retained.', + }, + status: { + kind: 'unavailable', + reason: 'Working Tree path is temporarily unavailable.', + }, + changes: [], + nativeTargets: [], + upstream: { + kind: 'unavailable', + reason: 'Upstream is unavailable with the Working Tree.', + }, + }, + ], + }; +} + +function createChangedFile( + fileIndex: number, + worktreeIndex: number, + offset: number, +): WorktreeOverviewSnapshot['changes'][number] { + const common = { + fileId: fileIdSchema.parse( + `file_${fileIndex.toString(16).padStart(32, '0')}`, + ), + displayPath: `src/worktree-${String(worktreeIndex + 1).padStart(2, '0')}/file-${String(offset + 1).padStart(4, '0')}.ts`, + previousDisplayPath: null, + nativeTargets: [], + }; + + switch (offset % 4) { + case 0: + return { + ...common, + kind: 'staged_change', + baseline: 'head_to_index', + }; + case 1: + return { + ...common, + kind: 'change', + baseline: 'index_to_working_tree', + }; + case 2: + return { + ...common, + kind: 'untracked', + baseline: 'empty_to_working_tree', + }; + default: + return { ...common, kind: 'conflict', baseline: 'conflict' }; + } +} + +function createBranchSearch( + snapshot: RepositoryOverviewSnapshot, +): BranchSearchResult { + return { + refsRevision: snapshot.refsRevision, + candidates: Array.from({ length: SUPPORTED_SCALE.refs }, (_, offset) => { + const index = offset + 1; + const local = index <= SUPPORTED_SCALE.refs / 2; + return { + refId: refIdSchema.parse(`ref_${index.toString(16).padStart(32, '0')}`), + kind: local ? ('local' as const) : ('remote_tracking' as const), + displayName: local + ? `feature/release-${String(index).padStart(4, '0')}` + : `origin/feature/release-${String(index - SUPPORTED_SCALE.refs / 2).padStart(4, '0')}`, + occupiedBy: + local && index <= snapshot.worktrees.length + ? snapshot.worktrees[index - 1]!.worktreeId + : null, + }; + }), + }; +} diff --git a/tests/release/supported-scale.test.ts b/tests/release/supported-scale.test.ts new file mode 100644 index 0000000..4e1cfde --- /dev/null +++ b/tests/release/supported-scale.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { createSupportedScaleFixture } from './supported-scale-fixture.js'; +import { SUPPORTED_SCALE } from './release-envelope.js'; +import { measureReleaseUi } from './ui-benchmark.js'; + +describe('supported release scale', () => { + it('contains 25 Available Worktrees, 2,000 Changed Files, 5,000 refs, and unavailable diagnostics', () => { + const fixture = createSupportedScaleFixture(); + const state = fixture.source.getSnapshot(); + if (state.kind !== 'repository') throw new Error('Expected Repository'); + + expect( + state.snapshot.worktrees.filter( + (worktree) => worktree.status.kind !== 'unavailable', + ), + ).toHaveLength(SUPPORTED_SCALE.availableWorktrees); + expect( + state.snapshot.worktrees.reduce( + (total, worktree) => total + worktree.changes.length, + 0, + ), + ).toBe(SUPPORTED_SCALE.changedFiles); + expect(fixture.branchSearch.candidates).toHaveLength(SUPPORTED_SCALE.refs); + expect( + state.snapshot.worktrees.filter( + (worktree) => worktree.status.kind === 'unavailable', + ), + ).toHaveLength(SUPPORTED_SCALE.unavailableRegistrations); + }); + + it('keeps loaded UI interactions within 100 milliseconds', async () => { + expect((await measureReleaseUi()).loadedInteraction).toBeLessThanOrEqual( + 100, + ); + }); +}); diff --git a/tests/release/ui-benchmark.tsx b/tests/release/ui-benchmark.tsx new file mode 100644 index 0000000..faf66c7 --- /dev/null +++ b/tests/release/ui-benchmark.tsx @@ -0,0 +1,297 @@ +import { performance } from 'node:perf_hooks'; + +import { JSDOM } from 'jsdom'; +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { flushSync } from 'react-dom'; + +import { App } from '../../apps/ui/src/overview.js'; +import { createOverviewFixture } from '../../apps/ui/src/overview-fixtures.js'; +import { createProtocolRepositorySource } from '../../apps/ui/src/protocol-repository-source.js'; +import { createRepositoryStore } from '../../apps/ui/src/repository-store.js'; +import { createSupportedScaleFixture } from './supported-scale-fixture.js'; + +export interface ReleaseUiMeasurements { + readonly loadedInteraction: number; + readonly selectedWorktreeRender: number; + readonly shell: number; +} + +export interface ProtocolReleaseUiMeasurements extends ReleaseUiMeasurements { + readonly fullSnapshot: number; + readonly externalChange: number; +} + +export async function measureProtocolReleaseUi(options: { + readonly externalDisplayPath: string; + readonly mutateExternal: () => Promise; + readonly projectPath: string; + readonly sessionUrl: URL; + readonly surfaceUrl: URL; +}): Promise { + const overallStartedAt = performance.now(); + const surface = await (await fetch(options.surfaceUrl)).text(); + if (!surface.includes('src="/src/main.tsx"')) { + throw new Error('The production Git Surface entry point was not served.'); + } + + return withBrowserDom(async () => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = false; + let firstSnapshotDuration: number | undefined; + const fetcher: typeof fetch = async (input, init) => { + const url = String(input); + const headers = new Headers(init?.headers); + headers.set('origin', options.surfaceUrl.origin); + const startedAt = performance.now(); + const response = await fetch(input, { ...init, headers }); + if (!url.endsWith('/snapshot') || firstSnapshotDuration !== undefined) { + return response; + } + const body = await response.arrayBuffer(); + firstSnapshotDuration = performance.now() - startedAt; + return new Response(body, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); + }; + const source = createProtocolRepositorySource({ + createEventSource: (url) => + new FetchEventSource(url, options.surfaceUrl.origin), + fetch: fetcher, + projectPath: options.projectPath, + sessionUrl: options.sessionUrl.href, + }); + const store = createRepositoryStore(source); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + try { + flushSync(() => root.render(createElement(App, { store }))); + if (!container.textContent?.includes('Codex Git')) { + throw new Error('The application shell did not become visible.'); + } + const shell = performance.now() - overallStartedAt; + + await waitForCondition( + () => + store.getSnapshot().source.kind === 'repository' && + container.querySelector('#worktree-title') !== null, + 'The selected Worktree did not become visible.', + ); + const selectedWorktree = performance.now() - overallStartedAt; + if (firstSnapshotDuration === undefined) { + throw new Error('The production snapshot request was not measured.'); + } + + const target = container.querySelectorAll( + '[aria-label^="Select "]', + )[23]; + if (target === undefined) throw new Error('Expected a Worktree target.'); + const targetName = target.querySelector('span')?.textContent; + const interactionStartedAt = performance.now(); + flushSync(() => target.click()); + const loadedInteraction = performance.now() - interactionStartedAt; + if ( + targetName === undefined || + container.querySelector('#worktree-title')?.textContent !== targetName + ) { + throw new Error('The selected Worktree interaction was not visible.'); + } + + const main = container.querySelector( + '[aria-label^="Select "]', + ); + if (main === null) throw new Error('Expected the Main Worktree target.'); + flushSync(() => main.click()); + const externalStartedAt = performance.now(); + await options.mutateExternal(); + await waitForCondition( + () => + container.textContent?.includes(options.externalDisplayPath) === true, + 'The external selected-Worktree change did not become visible.', + ); + const externalChange = performance.now() - externalStartedAt; + + return { + externalChange, + fullSnapshot: firstSnapshotDuration, + loadedInteraction, + selectedWorktreeRender: selectedWorktree - shell, + shell, + }; + } finally { + flushSync(() => root.unmount()); + store.dispose(); + container.remove(); + } + }); +} + +export async function measureReleaseUi(): Promise { + return withBrowserDom(async () => { + const shell = await measureShell(); + const fixture = createSupportedScaleFixture(); + const store = createRepositoryStore(fixture.source); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + const selectedStartedAt = performance.now(); + await act(async () => + flushSync(() => root.render(createElement(App, { store }))), + ); + const selectedWorktreeRender = performance.now() - selectedStartedAt; + const target = container.querySelectorAll( + '[aria-label^="Select "]', + )[23]; + if (target === undefined) throw new Error('Expected a Worktree target.'); + const targetName = target.querySelector('span')?.textContent; + + const interactionStartedAt = performance.now(); + await act(async () => flushSync(() => target.click())); + const loadedInteraction = performance.now() - interactionStartedAt; + if ( + targetName === undefined || + container.querySelector('#worktree-title')?.textContent !== targetName + ) { + throw new Error('The selected Worktree did not become visible.'); + } + + await act(async () => flushSync(() => root.unmount())); + store.dispose(); + container.remove(); + return { loadedInteraction, selectedWorktreeRender, shell }; + }); +} + +async function measureShell(): Promise { + const fixture = createOverviewFixture('loading'); + const store = createRepositoryStore(fixture.source); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + const startedAt = performance.now(); + await act(async () => + flushSync(() => root.render(createElement(App, { store }))), + ); + const duration = performance.now() - startedAt; + if (!container.textContent?.includes('Codex Git')) { + throw new Error('The application shell did not become visible.'); + } + await act(async () => flushSync(() => root.unmount())); + store.dispose(); + container.remove(); + return duration; +} + +async function withBrowserDom(run: () => Promise): Promise { + const dom = new JSDOM(''); + const globals = [ + 'document', + 'Event', + 'HTMLElement', + 'HTMLInputElement', + 'MouseEvent', + 'MessageEvent', + 'navigator', + 'Node', + 'window', + 'IS_REACT_ACT_ENVIRONMENT', + ] as const; + const previous = new Map( + globals.map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), + ); + for (const name of globals) { + Object.defineProperty(globalThis, name, { + configurable: true, + value: name === 'IS_REACT_ACT_ENVIRONMENT' ? true : dom.window[name], + writable: true, + }); + } + try { + const result = await run(); + await new Promise((resolve) => setImmediate(resolve)); + return result; + } finally { + for (const name of globals) { + const descriptor = previous.get(name); + if (descriptor === undefined) { + Reflect.deleteProperty(globalThis, name); + } else { + Object.defineProperty(globalThis, name, descriptor); + } + } + dom.window.close(); + } +} + +class FetchEventSource { + private readonly abort = new AbortController(); + private listener: ((event: MessageEvent) => void) | undefined; + + constructor(url: string, origin: string) { + void this.read(url, origin); + } + + addEventListener( + _type: 'invalidation', + listener: (event: MessageEvent) => void, + ): void { + this.listener = listener; + } + + close(): void { + this.abort.abort(); + } + + private async read(url: string, origin: string): Promise { + try { + const response = await fetch(url, { + headers: { origin }, + signal: this.abort.signal, + }); + const reader = response.body?.getReader(); + if (reader === undefined) throw new Error('SSE response body is absent.'); + const decoder = new TextDecoder(); + let buffer = ''; + while (!this.abort.signal.aborted) { + const chunk = await reader.read(); + if (chunk.done) return; + buffer += decoder.decode(chunk.value, { stream: true }); + let boundary = buffer.indexOf('\n\n'); + while (boundary >= 0) { + const frame = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const type = frame.match(/^event: (.+)$/mu)?.[1]; + const data = frame.match(/^data: (.+)$/mu)?.[1]; + if (type === 'invalidation' && data !== undefined) { + this.listener?.(new MessageEvent('invalidation', { data })); + } + boundary = buffer.indexOf('\n\n'); + } + } + } catch (error) { + if (!this.abort.signal.aborted) throw error; + } + } +} + +async function waitForCondition( + condition: () => boolean, + message: string, +): Promise { + const deadline = performance.now() + 5_000; + while (!condition()) { + if (performance.now() >= deadline) throw new Error(message); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} diff --git a/tsconfig.json b/tsconfig.json index 92b9407..c1f5252 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,9 @@ "apps/**/*.ts", "apps/**/*.tsx", "packages/**/*.ts", + "scripts/**/*.ts", "tests/**/*.ts", + "tests/**/*.tsx", "eslint.config.js" ] } diff --git a/vitest.config.ts b/vitest.config.ts index df7feb8..ed34c1c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ include: [ 'apps/**/*.test.{ts,tsx}', 'packages/**/*.test.ts', - 'tests/**/*.test.ts', + 'tests/**/*.test.{ts,tsx}', ], restoreMocks: true, }, From 8fb49add3a6c5d038c7da7cf4b2e19f86a22b1ce Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Tue, 1 Sep 2026 03:57:48 +0800 Subject: [PATCH 02/12] feat: add Codex build 6962 compatibility profile (#16) --- .github/workflows/ci.yml | 2 +- docs/host-integration/codex-compatibility.md | 33 +- docs/release/manual-evidence.json | 14 +- docs/release/mvp-release-gate.md | 9 +- .../codex-cdp/src/adapter.test.ts | 72 +++- .../codex-cdp/src/compatibility-profile.ts | 35 ++ .../codex-cdp/src/compatibility.ts | 23 +- .../codex-cdp/src/remote-renderer.test.ts | 36 ++ .../codex-cdp/src/remote-renderer.ts | 37 +- .../host-adapter/codex-cdp/src/renderer.ts | 1 + scripts/voiceover-release-wizard.sh | 376 ++++++++++++++++++ tests/release/manual-evidence.test.ts | 11 +- tests/release/manual-evidence.ts | 10 +- tests/release/release-artifacts.test.ts | 1 + tests/release/release-environment.test.ts | 4 +- tests/release/release-environment.ts | 2 +- tests/release/release-report.test.ts | 36 +- tests/release/release-report.ts | 1 + 18 files changed, 639 insertions(+), 64 deletions(-) create mode 100644 packages/host-adapter/codex-cdp/src/compatibility-profile.ts create mode 100755 scripts/voiceover-release-wizard.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f55344..8cbec81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: runs-on: macos-15 timeout-minutes: 20 env: - CODEX_DESKTOP_VERSION: 26.820.60940 (build 7119) + CODEX_DESKTOP_VERSION: 26.818.41509 (build 6962) CODEX_GIT_REFERENCE_PROFILE: github-actions-macos-15 steps: - name: Check out repository diff --git a/docs/host-integration/codex-compatibility.md b/docs/host-integration/codex-compatibility.md index 0a9f59a..dd990b5 100644 --- a/docs/host-integration/codex-compatibility.md +++ b/docs/host-integration/codex-compatibility.md @@ -33,11 +33,12 @@ endpoint, or record it in ordinary logs. ## Tested profile -| Codex Desktop | Chromium framework | Required anchors | Evidence | -| ----------------------------- | ------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------ | -| `26.820.60940` (build `7119`) | `151.0.7922.170` | `#app-shell-sidebar`; `[data-app-shell-main-surface="default"]` | Installed renderer bundle inspection plus automated DOM fixture coverage | +| Codex Desktop | Chromium framework | Required anchors | Evidence | +| ----------------------------- | ------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `26.820.60940` (build `7119`) | `151.0.7922.170` | `#app-shell-sidebar`; `[data-app-shell-main-surface="default"]` | Installed renderer bundle inspection plus automated DOM fixture coverage | +| `26.818.41509` (build `6962`) | `151.0.7922.170` | `.app-shell-left-panel`; `[data-app-shell-main-surface="default"]` | App bundle SHA-256 `8eb91bd9efbf9a4dd04b9b0afdbfcb4e0bab5da18c1919ad74ca327c00c7e791`; live isolated CDP attachment/teardown; automated exact-profile fixtures | -The automated fixture covers read-only probing, fail-closed fallback, +The automated exact-profile matrix covers read-only probing, fail-closed fallback, transactional attachment, one-entry mounting, native navigation, repeat attachment, context updates, opaque iframe sandboxing, generation/capability/challenge rejection, CSP lease restoration, and complete @@ -51,16 +52,20 @@ matrix; do not widen selectors to make an unknown build appear compatible. This matrix passed on 2026-08-29 against a disposable dedicated profile using Codex Desktop `26.820.60940` (build `7119`) and Chromium `151.0.7922.170`. +The current release profile, Codex Desktop `26.818.41509` (build `6962`), has +passed isolated runtime attachment and teardown but still requires the human +host, keyboard, and VoiceOver record produced by +`scripts/voiceover-release-wizard.sh` before the release gate can pass. + +The human portion of the matrix is intentionally limited to behavior that +requires an actual Codex/macOS session. The release wizard records these checks: - Open `Git` and confirm exactly one entry and one full-page frame. - Select a native destination and confirm native content is restored with no - hidden overlay. -- Open `Git` again after a renderer reload and confirm one new frame generation. -- Change theme/task and confirm typed context updates; change Current Project - and confirm a typed standalone-required transition. -- Send missing, altered, replayed, and stale capability/challenge messages and - confirm they cause no action. -- Close the connection and confirm all nodes, listeners, CDP sessions, and the - CSP bypass lease are gone. -- Break either required selector and confirm the native UI remains byte-for-byte - unchanged while the standalone fallback is reported. + hidden overlay, then reopen `Git` without duplication. +- Reload the renderer and confirm exactly one remounted entry and one fresh + frame generation. + +Context and Current Project transitions, invalid/replayed messages, full +connection and CSP teardown, and broken-selector fallback are enforced by the +automated exact-profile matrix rather than claimed by the human record. diff --git a/docs/release/manual-evidence.json b/docs/release/manual-evidence.json index 6e86b1d..624cfda 100644 --- a/docs/release/manual-evidence.json +++ b/docs/release/manual-evidence.json @@ -3,15 +3,17 @@ "checks": [ { "id": "codex-host-smoke", - "status": "passed", - "performedAt": "2026-08-29T00:00:00.000Z", - "environment": "macOS with Codex Desktop 26.820.60940 (build 7119) and Chromium 151.0.7922.170", - "record": "docs/host-integration/codex-compatibility.md#manual-smoke-matrix", - "sourceRevision": "sha256:f10e7c0cf5a1cc78def9e811c0123698f352f578c6181781e43f893d8b316fc7", - "validUntil": "2026-09-29T00:00:00.000Z" + "codexVersion": null, + "status": "pending", + "performedAt": null, + "environment": null, + "record": null, + "sourceRevision": null, + "validUntil": null }, { "id": "voiceover-keyboard-smoke", + "codexVersion": null, "status": "pending", "performedAt": null, "environment": null, diff --git a/docs/release/mvp-release-gate.md b/docs/release/mvp-release-gate.md index 0bbd793..6e8cdc5 100644 --- a/docs/release/mvp-release-gate.md +++ b/docs/release/mvp-release-gate.md @@ -57,11 +57,11 @@ and ends only after SSE-triggered refresh makes the new Changed File visible in the DOM. The approved CI reference profile is `github-actions-macos-15`, paired with the -tested Codex compatibility profile `26.820.60940 (build 7119)`. The JSON and +tested Codex compatibility profile `26.818.41509 (build 6962)`. The JSON and Markdown artifacts record the actual CPU, architecture, memory, macOS, Git, Node.js, reference profile, and Codex compatibility version. A local release run must explicitly set `CODEX_GIT_REFERENCE_PROFILE=local-macos-release` and -`CODEX_DESKTOP_VERSION='26.820.60940 (build 7119)'`; an unapproved platform, +`CODEX_DESKTOP_VERSION='26.818.41509 (build 6962)'`; an unapproved platform, profile, or missing/mismatched Codex version fails AC-24. ## VoiceOver and keyboard smoke record @@ -84,8 +84,9 @@ record beside the generated matrix: state remains understandable without color. The completed record must include its execution time, expiry, environment, -current product-source revision, and the exact checklist path. The gate copies a -redacted version into `artifacts/release-gate/manual/`. +current product-source revision, and the exact checklist path. Commit the +durable source record under `docs/release/evidence/`; the gate copies a redacted +version into `artifacts/release-gate/manual/`. Codex host attachment and native UI restoration use the separately maintained [exact-build smoke matrix](../host-integration/codex-compatibility.md#manual-smoke-matrix). diff --git a/packages/host-adapter/codex-cdp/src/adapter.test.ts b/packages/host-adapter/codex-cdp/src/adapter.test.ts index 6994a3c..fe5d3a6 100644 --- a/packages/host-adapter/codex-cdp/src/adapter.test.ts +++ b/packages/host-adapter/codex-cdp/src/adapter.test.ts @@ -94,6 +94,55 @@ describe('CodexCdpHostAdapter', () => { expect(nativeSurface?.hidden).toBe(false); }); + it('uses the tested left-panel anchor for Codex Desktop build 6962', async () => { + const dom = new JSDOM(` +
+ +
Native task
+
+ `); + + const result = await new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource( + fixtureRenderer(dom, '26.818.41509'), + ), + }).attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + + expect(result.kind).toBe('attached'); + expect( + dom.window.document.querySelectorAll('[data-codex-git-sidebar-entry]'), + ).toHaveLength(1); + if (result.kind === 'attached') await result.connection.close(); + }); + + it('rejects a version and build from different tested profiles', async () => { + const dom = new JSDOM(` +
+ +
Native task
+
+ `); + + const result = await new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource( + fixtureRenderer(dom, '26.818.41509', '7119'), + ), + }).attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + + expect(result.kind).toBe('standalone-required'); + expect(documentEntry(dom)).toBeNull(); + }); + it('keeps exactly one entry when the adapter attaches repeatedly', async () => { const dom = compatibleDom(); const renderer = fixtureRenderer(dom, '26.820.60940'); @@ -492,12 +541,22 @@ function captureNextFrameMessage( }); } -function fixtureRenderer(dom: JSDOM, version: string): CodexRenderer { - return new FixtureRenderer(dom, version, { - projectPath: null, - task: null, - theme: 'system', - }); +function fixtureRenderer( + dom: JSDOM, + version: string, + build = version === '26.818.41509' ? '6962' : '7119', +): CodexRenderer { + return new FixtureRenderer( + dom, + version, + { + projectPath: null, + task: null, + theme: 'system', + }, + [], + build, + ); } class FixtureRenderer implements CodexRenderer { @@ -513,6 +572,7 @@ class FixtureRenderer implements CodexRenderer { readonly version: string, private context: HostContext, private readonly leaseEvents: string[] = [], + readonly build = '7119', ) { this.document = dom.window.document; this.window = dom.window as unknown as Window & typeof globalThis; diff --git a/packages/host-adapter/codex-cdp/src/compatibility-profile.ts b/packages/host-adapter/codex-cdp/src/compatibility-profile.ts new file mode 100644 index 0000000..476a23e --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/compatibility-profile.ts @@ -0,0 +1,35 @@ +export interface CodexCompatibilityProfile { + readonly build: string; + readonly chromiumProduct: string; + readonly mainSurfaceSelector: string; + readonly sidebarSelector: string; + readonly version: string; +} + +const profiles = [ + { + build: '7119', + chromiumProduct: 'Chrome/151.0.7922.170', + mainSurfaceSelector: '[data-app-shell-main-surface="default"]', + sidebarSelector: '#app-shell-sidebar', + version: '26.820.60940', + }, + { + build: '6962', + chromiumProduct: 'Chrome/151.0.7922.170', + mainSurfaceSelector: '[data-app-shell-main-surface="default"]', + sidebarSelector: '.app-shell-left-panel', + version: '26.818.41509', + }, +] as const satisfies readonly CodexCompatibilityProfile[]; + +export function findCodexCompatibilityProfile( + version: string, + build: string, +): CodexCompatibilityProfile | null { + return ( + profiles.find( + (profile) => profile.version === version && profile.build === build, + ) ?? null + ); +} diff --git a/packages/host-adapter/codex-cdp/src/compatibility.ts b/packages/host-adapter/codex-cdp/src/compatibility.ts index a145747..b3133a1 100644 --- a/packages/host-adapter/codex-cdp/src/compatibility.ts +++ b/packages/host-adapter/codex-cdp/src/compatibility.ts @@ -1,8 +1,5 @@ import type { CodexRenderer } from './renderer.js'; - -const supportedCodexVersion = '26.820.60940'; -const sidebarSelector = '#app-shell-sidebar'; -const mainSurfaceSelector = '[data-app-shell-main-surface="default"]'; +import { findCodexCompatibilityProfile } from './compatibility-profile.js'; export interface CompatibleCodexAnchors { readonly mainSurface: HTMLElement; @@ -12,18 +9,28 @@ export interface CompatibleCodexAnchors { export function findCompatibleCodexAnchors( renderer: CodexRenderer, ): CompatibleCodexAnchors | null { + const profile = findCodexCompatibilityProfile( + renderer.version, + renderer.build, + ); if ( - renderer.version !== supportedCodexVersion || + profile === null || renderer.ownership !== 'codex-git-dedicated' || renderer.id.length === 0 ) { return null; } - const sidebar = renderer.document.querySelector(sidebarSelector); - const mainSurface = renderer.document.querySelector(mainSurfaceSelector); + const sidebars = renderer.document.querySelectorAll(profile.sidebarSelector); + const mainSurfaces = renderer.document.querySelectorAll( + profile.mainSurfaceSelector, + ); + const sidebar = sidebars.item(0); + const mainSurface = mainSurfaces.item(0); - return sidebar instanceof renderer.window.HTMLElement && + return sidebars.length === 1 && + mainSurfaces.length === 1 && + sidebar instanceof renderer.window.HTMLElement && mainSurface instanceof renderer.window.HTMLElement ? { mainSurface, sidebar } : null; diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts index 7bb229a..d18f451 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts @@ -98,6 +98,42 @@ describe('dedicated Codex remote renderer', () => { 'Browser.getVersion', ]); }); + + it('accepts the exact build 6962 profile and installs its tested anchors', async () => { + const session = new FixtureCdpSession({ + context: expectedContext, + project: { id: 'project-42', label: 'codex-git' }, + status: 'attached', + }); + + const connection = await connectDedicatedCodexRenderer( + { ...request, build: '6962', version: '26.818.41509' }, + { + connect: async () => session, + createBindingName: () => '__codexGitNotify_fixture', + }, + ); + + const installation = session.commands.find( + ({ method }) => method === 'Runtime.evaluate', + ); + expect(installation?.params).toMatchObject({ + expression: expect.stringContaining('.app-shell-left-panel'), + }); + await connection.close(); + }); + + it('rejects a version and build from different tested profiles before CDP', async () => { + const session = new FixtureCdpSession({ status: 'attached' }); + + await expect( + connectDedicatedCodexRenderer( + { ...request, build: '6962' }, + { connect: async () => session }, + ), + ).rejects.toThrow('Unsupported Codex Desktop version'); + expect(session.commands).toEqual([]); + }); }); const expectedContext = { diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.ts index af642fa..2162481 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.ts @@ -12,6 +12,10 @@ import { type CdpSession, } from './cdp-session.js'; import { acquireDedicatedRendererCspBypass } from './csp-bypass.js'; +import { + findCodexCompatibilityProfile, + type CodexCompatibilityProfile, +} from './compatibility-profile.js'; import type { ConnectDedicatedRendererRequest, DedicatedProjectIdentity, @@ -20,10 +24,6 @@ import type { } from './dedicated-adapter.js'; import type { CspBypassLease } from './renderer.js'; -const supportedCodexVersion = '26.820.60940'; -const supportedCodexBuild = '7119'; -const supportedChromiumProduct = 'Chrome/151.0.7922.170'; - export interface ConnectDedicatedCodexRendererOptions { readonly connect?: (url: string) => Promise; readonly createBindingName?: () => string; @@ -34,10 +34,8 @@ export async function connectDedicatedCodexRenderer( request: ConnectDedicatedRendererRequest, options: ConnectDedicatedCodexRendererOptions = {}, ): Promise { - if ( - request.version !== supportedCodexVersion || - request.build !== supportedCodexBuild - ) { + const profile = findCodexCompatibilityProfile(request.version, request.build); + if (profile === null) { throw new Error('Unsupported Codex Desktop version'); } const session = await (options.connect ?? connectCdpSession)( @@ -46,7 +44,7 @@ export async function connectDedicatedCodexRenderer( let lease: CspBypassLease | null = null; try { const browser = await session.send('Browser.getVersion'); - if (!isRecord(browser) || browser.product !== supportedChromiumProduct) { + if (!isRecord(browser) || browser.product !== profile.chromiumProduct) { throw new Error('Unsupported Codex Desktop Chromium version'); } await session.send('Runtime.enable'); @@ -62,14 +60,14 @@ export async function connectDedicatedCodexRenderer( request.target.id, cspOwnershipScope(request), ); - let installation = await install(session, request, bindingName, 1); + let installation = await install(session, request, profile, bindingName, 1); for ( let attempt = 0; installation.status === 'not-ready' && attempt < 100; attempt++ ) { await (options.wait ?? wait)(100); - installation = await install(session, request, bindingName, 1); + installation = await install(session, request, profile, bindingName, 1); } if (installation.status !== 'attached') { throw new Error( @@ -82,6 +80,7 @@ export async function connectDedicatedCodexRenderer( session, lease, request, + profile, bindingName, installation, ); @@ -122,6 +121,7 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { private readonly session: CdpSession, private cspLease: CspBypassLease | null, private readonly request: ConnectDedicatedRendererRequest, + private readonly profile: CodexCompatibilityProfile, private readonly bindingName: string, installation: AttachedInstallation, ) { @@ -221,6 +221,7 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { const installation = await install( this.session, replacementRequest, + this.profile, this.bindingName, ++this.generation, ); @@ -264,6 +265,7 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { async function install( session: CdpSession, request: ConnectDedicatedRendererRequest, + profile: CodexCompatibilityProfile, bindingName: string, generation: number, ): Promise { @@ -271,10 +273,12 @@ async function install( bindingName, expectedProject: request.expectedProject, generation, + mainSurfaceSelector: profile.mainSurfaceSelector, openSurface: request.openSurface, projectPath: request.projectPath, surfaceTitle: request.surface.title, surfaceUrl: request.surface.url.href, + sidebarSelector: profile.sidebarSelector, }; const response = await evaluate( session, @@ -416,10 +420,12 @@ interface BridgeInput { readonly bindingName: string; readonly expectedProject: DedicatedProjectIdentity | null; readonly generation: number; + readonly mainSurfaceSelector: string; readonly openSurface: boolean; readonly projectPath: string; readonly surfaceTitle: string; readonly surfaceUrl: string; + readonly sidebarSelector: string; } // Kept self-contained because CDP serializes this function into the renderer. @@ -427,10 +433,13 @@ interface BridgeInput { function installDomBridge(input: BridgeInput): unknown { const root = globalThis as typeof globalThis & { __codexGitBridge?: { close(): void; restore(): void } }; root.__codexGitBridge?.close(); - const sidebar = document.querySelector('#app-shell-sidebar'); - const main = document.querySelector('[data-app-shell-main-surface="default"]'); + const sidebars = document.querySelectorAll(input.sidebarSelector); + const mainSurfaces = document.querySelectorAll(input.mainSurfaceSelector); + const sidebar = sidebars.item(0); + const main = mainSurfaces.item(0); const selectedProject = document.querySelector('[data-app-action-sidebar-project-row][aria-current="page"]'); - if (!(sidebar instanceof HTMLElement) || !(main instanceof HTMLElement) || + if (sidebars.length !== 1 || mainSurfaces.length !== 1 || + !(sidebar instanceof HTMLElement) || !(main instanceof HTMLElement) || !(selectedProject instanceof HTMLElement)) { return { status: 'not-ready' }; } diff --git a/packages/host-adapter/codex-cdp/src/renderer.ts b/packages/host-adapter/codex-cdp/src/renderer.ts index d0b9551..4cc8e57 100644 --- a/packages/host-adapter/codex-cdp/src/renderer.ts +++ b/packages/host-adapter/codex-cdp/src/renderer.ts @@ -5,6 +5,7 @@ export interface CspBypassLease { } export interface CodexRenderer { + readonly build: string; readonly document: Document; readonly id: string; readonly ownership: 'codex-git-dedicated'; diff --git a/scripts/voiceover-release-wizard.sh b/scripts/voiceover-release-wizard.sh new file mode 100755 index 0000000..48caac0 --- /dev/null +++ b/scripts/voiceover-release-wizard.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +# +# A wizard — walks a human through a manual procedure step by step. +# Generated by the /wizard skill. +# +# Everything above the "STAGES" marker is the wizard library: do not hand-edit +# it. Author the per-step stages below the marker. + +set -euo pipefail + +# ────────────────────────────────────────────────────────────────────────── +# Wizard library — delightful, consistent UX. Identical across every wizard. +# ────────────────────────────────────────────────────────────────────────── + +if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then + BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0) + BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1) +else + BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED="" +fi + +# Author sets this at the top of the stages section. +TOTAL_STAGES=0 + +_STAGE_INDEX=0 +ENV_FILE="${ENV_FILE:-.env}" +WRITTEN_ENV=() # KEYs written to ENV_FILE this run +WRITTEN_SECRET=() # secret NAMEs set this run +SKIPPED=() # things we couldn't do (e.g. gh missing) + +# _clear — wipe the terminal so only the current step is on screen. No-op when +# output isn't a terminal, so piped logs stay readable. +_clear() { + [[ -t 1 ]] || return 0 + if command -v tput >/dev/null 2>&1; then tput clear; else printf '\033[2J\033[3J\033[H'; fi +} + +# banner "Title" — opening frame: what this wizard does. +banner() { + _clear + printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET" + printf '%s %s stages%s\n\n' "$DIM" "$TOTAL_STAGES" "$RESET" + printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM" + printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n' + printf ' later — it remembers values already saved.%s\n' "$RESET" + pause "Ready to start?" +} + +# stage "Name" — clear the screen, then announce a stage and show progress. +# Clearing keeps only the current step on screen. +stage() { + _clear + _STAGE_INDEX=$((_STAGE_INDEX + 1)) + printf '\n%s%s▸ Stage %s/%s · %s%s\n' \ + "$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET" +} + +# say "..." — a plain instruction line. +say() { printf ' %s\n' "$1"; } +# step "..." — a numbered-feeling action the human takes in the browser. +step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; } +note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; } +warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; } + +# open_url URL — open in the human's browser, cross-platform incl. WSL. +open_url() { + local url="$1" + printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url" + { if command -v wslview >/dev/null 2>&1; then wslview "$url" + elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url" + elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url" + elif command -v open >/dev/null 2>&1; then open "$url" + else warn "couldn't open a browser — visit it manually: $url"; fi + } >/dev/null 2>&1 || warn "couldn't open a browser — visit it manually: $url" +} + +# pause "msg" — wait for the human to confirm they've done the manual part. +pause() { + printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET" + read -r _ || true +} + +# confirm "question" — y/N gate; returns success on yes. +confirm() { + local reply="" + printf ' %s? %s [y/N] ' "$YELLOW" "$1" + read -r reply || true + [[ "$reply" =~ ^[Yy] ]] +} + +# _existing KEY — current value of KEY in ENV_FILE, if any. +_existing() { + [[ -f "$ENV_FILE" ]] || return 1 + local line; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1 + printf '%s' "${line#*=}" +} + +# ask KEY "Prompt" — read a value into $KEY. Offers the existing .env value as +# a default on re-runs (Enter keeps it). Visible input (non-secret). +ask() { + local key="$1" prompt="$2" current input + current=$(_existing "$key" || true) + if [[ -n "$current" ]]; then + printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET" + else + printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET" + fi + read -r input || true + [[ -z "$input" && -n "$current" ]] && input="$current" + printf -v "$key" '%s' "$input" +} + +# ask_secret KEY "Prompt" — like ask, but input is hidden. +ask_secret() { + local key="$1" prompt="$2" current input + current=$(_existing "$key" || true) + if [[ -n "$current" ]]; then + printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET" + else + printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET" + fi + read -rs input || true + printf '\n' + [[ -z "$input" && -n "$current" ]] && input="$current" + printf -v "$key" '%s' "$input" +} + +# write_env KEY VALUE — upsert KEY=VALUE into ENV_FILE (creates it; replaces +# any existing line). Idempotent. +write_env() { + local key="$1" value="$2" tmp + touch "$ENV_FILE" + tmp=$(mktemp) + grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true + printf '%s=%s\n' "$key" "$value" >> "$tmp" + mv "$tmp" "$ENV_FILE" + WRITTEN_ENV+=("$key") + printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE" +} + +# set_secret NAME VALUE — set a GitHub Actions repo secret via gh. Falls back +# to a warning (and records it) if gh is unavailable or unauthenticated. +set_secret() { + local name="$1" value="$2" + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then + WRITTEN_SECRET+=("$name") + printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name" + return + fi + fi + SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)") + warn "skipped GitHub secret $name — gh not ready; set it later" +} + +# set_var NAME VALUE — set a GitHub Actions repo variable (non-secret). +set_var() { + local name="$1" value="$2" + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + if gh variable set "$name" --body "$value" >/dev/null 2>&1; then + printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name" + return + fi + fi + SKIPPED+=("GitHub variable $name") + warn "skipped GitHub variable $name — gh not ready; set it later" +} + +# finish — clear, then a closing summary of everything configured. +finish() { + _clear + printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET" + (( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}" + (( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}" + if (( ${#SKIPPED[@]} )); then + printf '\n'; warn "still to do by hand:" + for s in "${SKIPPED[@]}"; do note " - $s"; done + fi + printf '\n' +} + +# ────────────────────────────────────────────────────────────────────────── +# STAGES — author this section. One stage() per step the human takes. +# Replace the example below. Set TOTAL_STAGES to match the stages you write. +# ────────────────────────────────────────────────────────────────────────── + +TOTAL_STAGES=4 + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +RECORD_PATH="$REPO_ROOT/docs/release/evidence/issue-16-manual-accessibility.md" +EVIDENCE_PATH="$REPO_ROOT/docs/release/manual-evidence.json" +SUPPORTED_CODEX_VERSION="26.818.41509 (build 6962)" + +require_confirm() { + if ! confirm "$1"; then + warn "This release check cannot pass until the answer is yes. No evidence was changed." + exit 1 + fi +} + +banner "Codex Git VoiceOver release check" + +stage "Record the macOS assistive-technology environment" +say "This stage records public version information; no secrets are collected." +MACOS_VERSION=$(sw_vers -productVersion) +note "Detected macOS $MACOS_VERSION" +open_url "x-apple.systempreferences:com.apple.Accessibility-Settings.extension" +step "Open VoiceOver settings, then VoiceOver Utility → About, and note its displayed version." +ask VOICEOVER_VERSION "VoiceOver / VoiceOver Utility version:" +step "In Codex, open About Codex and copy the version including its build number." +ask CODEX_VERSION "Codex Desktop version (for example 26.818.41509 (build 6962)):" +step "Record the Chromium/WebView version from the tested Codex compatibility profile." +ask WEBVIEW_VERSION "Chromium/WebView version:" +if [[ "$CODEX_VERSION" != "$SUPPORTED_CODEX_VERSION" ]]; then + warn "Issue #16 currently supports only Codex Desktop $SUPPORTED_CODEX_VERSION." + warn "Add and verify a new compatibility profile before running this release check." + exit 1 +fi +require_confirm "Are these versions taken from the machine and Codex build you will test now?" + +stage "Complete Git workflows without a pointer" +say "The wizard will create a disposable Repository and local bare Remote. It never touches your real branches or remotes." +FIXTURE_ROOT=$(mktemp -d /private/tmp/codex-git-voiceover.XXXXXX) +REMOTE_PATH="$FIXTURE_ROOT/remote.git" +SEED_PATH="$FIXTURE_ROOT/seed" +WORK_PATH="$FIXTURE_ROOT/work" +LINKED_PATH="$FIXTURE_ROOT/linked" +UNAVAILABLE_PATH="$FIXTURE_ROOT/unavailable" +UNAVAILABLE_HIDDEN_PATH="$FIXTURE_ROOT/unavailable-hidden" +CONFLICT_PATH="$FIXTURE_ROOT/conflict" +git init --quiet --bare "$REMOTE_PATH" +git init --quiet --initial-branch=main "$SEED_PATH" +git -C "$SEED_PATH" config user.name "Codex Git VoiceOver" +git -C "$SEED_PATH" config user.email "voiceover@example.test" +printf 'initial\n' > "$SEED_PATH/README.md" +printf 'base\n' > "$SEED_PATH/conflict.txt" +git -C "$SEED_PATH" add -- README.md conflict.txt +git -C "$SEED_PATH" commit --quiet -m "Create VoiceOver fixture" +git -C "$SEED_PATH" branch review-branch +git -C "$SEED_PATH" branch linked-smoke +git -C "$SEED_PATH" branch unavailable-smoke +git -C "$SEED_PATH" branch conflict-smoke +printf 'main change\n' > "$SEED_PATH/conflict.txt" +git -C "$SEED_PATH" commit --quiet -am "Create main conflict side" +git -C "$SEED_PATH" switch --quiet conflict-smoke +printf 'branch change\n' > "$SEED_PATH/conflict.txt" +git -C "$SEED_PATH" commit --quiet -am "Create branch conflict side" +git -C "$SEED_PATH" switch --quiet main +git -C "$SEED_PATH" remote add origin "$REMOTE_PATH" +git -C "$SEED_PATH" push --quiet --all origin +git clone --quiet --branch main "$REMOTE_PATH" "$WORK_PATH" +git -C "$WORK_PATH" config user.name "Codex Git VoiceOver" +git -C "$WORK_PATH" config user.email "voiceover@example.test" +git -C "$WORK_PATH" branch linked-smoke origin/linked-smoke +git -C "$WORK_PATH" worktree add --quiet "$LINKED_PATH" linked-smoke +git -C "$WORK_PATH" branch unavailable-smoke origin/unavailable-smoke +git -C "$WORK_PATH" worktree add --quiet "$UNAVAILABLE_PATH" unavailable-smoke +git -C "$WORK_PATH" branch conflict-smoke origin/conflict-smoke +git -C "$WORK_PATH" worktree add --quiet "$CONFLICT_PATH" conflict-smoke +if git -C "$CONFLICT_PATH" merge --quiet origin/main; then + warn "The conflict fixture merged unexpectedly." + exit 1 +fi +printf 'changed for keyboard review\n' > "$WORK_PATH/README.md" +printf 'untracked keyboard target\n' > "$WORK_PATH/keyboard-target.txt" +say "Fixture ready at: $WORK_PATH" +step "In a second Terminal window, run this command and keep it running:" +say "cd '$REPO_ROOT' && CODEX_GIT_PROJECT_PATH='$WORK_PATH' CODEX_GIT_SURFACE_PORT=0 npm run dev" +pause "Press Enter only after the Git Surface is visible in the dedicated Codex window." +step "Confirm there is exactly one Git entry and one full-page Git Surface, then select a native Codex destination and reopen Git." +require_confirm "Did native navigation restore the Codex content with no hidden overlay, and did reopening Git create only one surface?" +step "With Git open, reload the dedicated Codex renderer (Command-R), wait for the Git entry to return, and open it again." +require_confirm "After renderer reload, did exactly one Git entry and one fresh surface return without leaving a hidden overlay?" +step "Use only the keyboard to Refresh, review both Changed Files, Stage and Unstage one exact file, then Stage and Commit the intended files." +require_confirm "Did review, Stage, Unstage, and Commit work with visible focus and exact target names?" +step "Use only the keyboard to Push the new main Commit to its exact origin/main Upstream." +require_confirm "Did Push succeed without including any uncommitted content?" +git -C "$SEED_PATH" pull --quiet --ff-only origin main +printf 'remote update\n' > "$SEED_PATH/remote-update.txt" +git -C "$SEED_PATH" add -- remote-update.txt +git -C "$SEED_PATH" commit --quiet -m "Create remote update" +git -C "$SEED_PATH" push --quiet origin main +step "A remote-only Commit now exists. Use only the keyboard to Fetch and then Pull it by fast-forward." +require_confirm "Did Fetch and Pull expose and apply the exact origin/main update?" +step "Open the Branch picker and switch to the cached Remote-tracking review-branch target." +require_confirm "Did Branch switch create/select only the same-name Local tracking Branch?" +git -C "$WORK_PATH" switch --quiet -c publish-smoke +printf 'publish fixture\n' > "$WORK_PATH/publish-smoke.txt" +git -C "$WORK_PATH" add -- publish-smoke.txt +git -C "$WORK_PATH" commit --quiet -m "Create unpublished fixture" +step "The watcher has moved to an unpublished Local Branch. Use only the keyboard to Publish it to origin/publish-smoke." +require_confirm "Did Publish show and use the exact Remote and same-name target?" +if ! git -C "$WORK_PATH" ls-remote --exit-code --heads origin refs/heads/publish-smoke >/dev/null; then + warn "origin/publish-smoke was not created; the Publish check did not reconcile." + exit 1 +fi +step "Navigate to an exact Worktree, Branch, and Changed File target and verify the announced target matches the visible target." +require_confirm "Did exact-target navigation remain keyboard reachable and unambiguous?" + +stage "Verify VoiceOver announcements and focus recovery" +step "Turn VoiceOver on (Command-F5 on supported keyboards) and return to the Git Surface without using a pointer." +require_confirm "Is keyboard focus always visibly indicated and announced?" +step "Repeat controls that appear for multiple Worktrees/files and listen for the exact Worktree, file, Branch, or Remote name." +require_confirm "Do repeated controls announce their exact targets?" +step "Trigger Refresh and an operation result; verify the live status is announced without moving focus away from the current control." +require_confirm "Are status and operation changes announced without stealing focus?" +step "Focus the linked-smoke Worktree in the navigator, then return here. The wizard will remove it externally." +pause "Press Enter when focus is on linked-smoke and you are ready for removal." +git -C "$WORK_PATH" worktree remove --force "$LINKED_PATH" +pause "Wait for the Git Surface to observe the removal, then press Enter." +require_confirm "Did focus recover to the nearest safe context with an understandable announcement?" +mv "$UNAVAILABLE_PATH" "$UNAVAILABLE_HIDDEN_PATH" +step "The unavailable-smoke Worktree path is now absent while its registration remains. Refresh, then inspect Clean, changed, Conflict, unavailable, stale/transitioning status, and operation outcomes." +require_confirm "Is every state understandable through text and VoiceOver without relying on color?" + +stage "Write evidence and run the release gate" +SOURCE_REVISION=$(cd "$REPO_ROOT" && npx tsx -e "import('./tests/release/manual-evidence.ts').then(async ({collectProductSourceRevision}) => console.log(await collectProductSourceRevision(process.cwd())))") +PERFORMED_AT=$(date -u +'%Y-%m-%dT%H:%M:%S.000Z') +VALID_UNTIL=$(date -u -v+28d +'%Y-%m-%dT%H:%M:%S.000Z') +ENVIRONMENT="macOS $MACOS_VERSION; VoiceOver $VOICEOVER_VERSION; Codex Desktop $CODEX_VERSION; Chromium/WebView $WEBVIEW_VERSION" +mkdir -p "$(dirname "$RECORD_PATH")" +cat > "$RECORD_PATH" < candidate.id === id); + if (check === undefined) throw new Error(`Manual check ${id} is absent.`); + Object.assign(check, { + codexVersion: process.env.MANUAL_CODEX_VERSION, + environment: process.env.MANUAL_ENVIRONMENT, + performedAt: process.env.MANUAL_PERFORMED_AT, + record: 'docs/release/evidence/issue-16-manual-accessibility.md', + sourceRevision: process.env.MANUAL_SOURCE_REVISION, + status: 'passed', + validUntil: process.env.MANUAL_VALID_UNTIL, + }); +} +await writeFile(path, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8'); +NODE +say "Evidence written to $RECORD_PATH and $EVIDENCE_PATH." +step "The full gate will now run on the approved local macOS profile. It takes about one minute." +(cd "$REPO_ROOT" && \ + CODEX_DESKTOP_VERSION="$CODEX_VERSION" \ + CODEX_GIT_REFERENCE_PROFILE=local-macos-release \ + npm run release:gate) +require_confirm "Did the release gate finish with 'Release gate: passed'?" +note "Stop the second Terminal process with Ctrl-C when finished." +note "Disposable fixture (safe to delete after stopping Codex Git): $FIXTURE_ROOT" + +finish diff --git a/tests/release/manual-evidence.test.ts b/tests/release/manual-evidence.test.ts index b5ea37d..e2fbebb 100644 --- a/tests/release/manual-evidence.test.ts +++ b/tests/release/manual-evidence.test.ts @@ -8,7 +8,8 @@ describe('manual release evidence', () => { parseManualEvidence({ checks: [ { - environment: 'Codex Desktop 26.820.60940 (build 7119)', + codexVersion: '26.818.41509 (build 6962)', + environment: 'Codex Desktop 26.818.41509 (build 6962)', id: 'codex-host-smoke', performedAt: '2026-08-29T00:00:00.000Z', record: @@ -18,6 +19,7 @@ describe('manual release evidence', () => { validUntil: '2026-09-29T00:00:00.000Z', }, { + codexVersion: null, environment: null, id: 'voiceover-keyboard-smoke', performedAt: null, @@ -37,6 +39,7 @@ describe('manual release evidence', () => { parseManualEvidence({ checks: [ { + codexVersion: null, environment: null, id: 'voiceover-keyboard-smoke', performedAt: null, @@ -48,7 +51,9 @@ describe('manual release evidence', () => { ], schemaVersion: 1, }), - ).toThrow('requires environment, performedAt, record, sourceRevision'); + ).toThrow( + 'requires codexVersion, environment, performedAt, record, sourceRevision', + ); }); it('rejects duplicate check IDs', () => { @@ -56,6 +61,7 @@ describe('manual release evidence', () => { parseManualEvidence({ checks: [ { + codexVersion: null, environment: null, id: 'voiceover-keyboard-smoke', performedAt: null, @@ -65,6 +71,7 @@ describe('manual release evidence', () => { validUntil: null, }, { + codexVersion: null, environment: null, id: 'voiceover-keyboard-smoke', performedAt: null, diff --git a/tests/release/manual-evidence.ts b/tests/release/manual-evidence.ts index 50dea6d..c45fe88 100644 --- a/tests/release/manual-evidence.ts +++ b/tests/release/manual-evidence.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; const executeFile = promisify(execFile); export interface ManualEvidenceCheck { + readonly codexVersion: string | null; readonly environment: string | null; readonly id: string; readonly performedAt: string | null; @@ -44,6 +45,7 @@ export function parseManualEvidence(value: unknown): ManualEvidenceRecord { ) { throw new Error(`Manual evidence check ${index} is invalid.`); } + const codexVersion = optionalString(check.codexVersion); const environment = optionalString(check.environment); const performedAt = optionalString(check.performedAt); const record = optionalString(check.record); @@ -55,17 +57,19 @@ export function parseManualEvidence(value: unknown): ManualEvidenceRecord { ids.add(check.id); if ( check.status === 'passed' && - (environment === null || + (codexVersion === null || + environment === null || performedAt === null || record === null || sourceRevision === null || validUntil === null) ) { throw new Error( - `Passed manual evidence ${check.id} requires environment, performedAt, record, sourceRevision, and validUntil.`, + `Passed manual evidence ${check.id} requires codexVersion, environment, performedAt, record, sourceRevision, and validUntil.`, ); } return { + codexVersion, environment, id: check.id, performedAt, @@ -111,10 +115,12 @@ export async function manualEvidenceCheckPasses( check: ManualEvidenceCheck | undefined, sourceRevision: string, generatedAt: Date, + codexVersion: string, ): Promise { if ( check?.status !== 'passed' || check.sourceRevision !== sourceRevision || + check.codexVersion !== codexVersion || check.performedAt === null || check.validUntil === null || check.record === null diff --git a/tests/release/release-artifacts.test.ts b/tests/release/release-artifacts.test.ts index 20d453d..f7868d8 100644 --- a/tests/release/release-artifacts.test.ts +++ b/tests/release/release-artifacts.test.ts @@ -109,6 +109,7 @@ function fixtureReport(): ReleaseReport { manualEvidence: { checks: [ { + codexVersion: '26.818.41509 (build 6962)', environment: 'Codex Desktop 26.820.60940', id: 'codex-host-smoke', performedAt: '2026-08-29T00:00:00.000Z', diff --git a/tests/release/release-environment.test.ts b/tests/release/release-environment.test.ts index dc2f917..6e07ff0 100644 --- a/tests/release/release-environment.test.ts +++ b/tests/release/release-environment.test.ts @@ -9,7 +9,7 @@ describe('release environment evidence', () => { it('records the required hardware, macOS, Git, Node, and Codex versions', async () => { const environment = await collectReleaseEnvironment({ architecture: 'arm64', - codexVersion: '26.820.60940 (build 7119)', + codexVersion: '26.818.41509 (build 6962)', cpu: 'Apple Test CPU', memoryBytes: 16 * 1_024 ** 3, nodeVersion: 'v22.12.0', @@ -27,7 +27,7 @@ describe('release environment evidence', () => { expect(environment).toEqual({ architecture: 'arm64', - codex: '26.820.60940 (build 7119)', + codex: '26.818.41509 (build 6962)', cpu: 'Apple Test CPU', git: 'git version 2.50.1', memoryBytes: 16 * 1_024 ** 3, diff --git a/tests/release/release-environment.ts b/tests/release/release-environment.ts index ddf6fed..09d6f65 100644 --- a/tests/release/release-environment.ts +++ b/tests/release/release-environment.ts @@ -40,7 +40,7 @@ export async function collectReleaseEnvironment( } export const SUPPORTED_CODEX_RELEASE_VERSION = - '26.820.60940 (build 7119)' as const; + '26.818.41509 (build 6962)' as const; export function validateReleaseEnvironment( environment: ReleaseEnvironment, diff --git a/tests/release/release-report.test.ts b/tests/release/release-report.test.ts index def118d..09e1a38 100644 --- a/tests/release/release-report.test.ts +++ b/tests/release/release-report.test.ts @@ -11,7 +11,7 @@ import type { ManualEvidenceRecord } from './manual-evidence.js'; const environment: ReleaseEnvironment = { architecture: 'arm64', - codex: '26.820.60940 (build 7119)', + codex: '26.818.41509 (build 6962)', cpu: 'Test CPU', git: 'git version 2.50.1', memoryBytes: 16 * 1_024 ** 3, @@ -40,7 +40,8 @@ const performance: ReferenceBenchmarkResult = { const manualEvidence: ManualEvidenceRecord = { checks: [ { - environment: 'Codex Desktop 26.820.60940 (build 7119)', + codexVersion: '26.818.41509 (build 6962)', + environment: 'Codex Desktop 26.818.41509 (build 6962)', id: 'codex-host-smoke', performedAt: '2026-08-29T00:00:00.000Z', record: @@ -50,7 +51,9 @@ const manualEvidence: ManualEvidenceRecord = { validUntil: '2026-09-29T00:00:00.000Z', }, { - environment: 'macOS 15.6 with VoiceOver', + codexVersion: '26.818.41509 (build 6962)', + environment: + 'macOS 15.6; VoiceOver 10; Codex Desktop 26.818.41509 (build 6962)', id: 'voiceover-keyboard-smoke', performedAt: '2026-09-01T00:00:00.000Z', record: @@ -80,7 +83,7 @@ describe('release evidence report', () => { expect(report.markdown).toContain( '| AC-24 | Pass the supported release envelope | passed |', ); - expect(report.markdown).toContain('Codex | 26.820.60940 (build 7119)'); + expect(report.markdown).toContain('Codex | 26.818.41509 (build 6962)'); expect(report.markdown).toContain( '| Full snapshot | 900 ms | 5000 ms | passed |', ); @@ -116,6 +119,7 @@ describe('release evidence report', () => { checks: manualEvidence.checks.map((check) => check.id === 'voiceover-keyboard-smoke' ? { + codexVersion: null, environment: null, id: check.id, performedAt: null, @@ -173,6 +177,30 @@ describe('release evidence report', () => { ); }); + it('blocks manual evidence recorded against a different Codex build', async () => { + const mismatched: ManualEvidenceRecord = { + ...manualEvidence, + checks: manualEvidence.checks.map((check) => ({ + ...check, + codexVersion: '26.820.60940 (build 7119)', + })), + }; + + const report = await createReleaseReport( + process.cwd(), + passingVitestReport(), + environment, + performance, + mismatched, + sourceRevision, + generatedAt, + ); + + expect(report.criteria.find(({ id }) => id === 'AC-24')?.status).toBe( + 'failed', + ); + }); + it('fails AC-24 when a performance budget or environment requirement fails', async () => { const report = await createReleaseReport( process.cwd(), diff --git a/tests/release/release-report.ts b/tests/release/release-report.ts index 0e6a25f..83bcd26 100644 --- a/tests/release/release-report.ts +++ b/tests/release/release-report.ts @@ -95,6 +95,7 @@ export async function createReleaseReport( check, sourceRevision, generatedAt, + environment.codex, )) ? 'passed' : 'failed', From 37f770030db213c665f26d176f60c895bc865b44 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Tue, 1 Sep 2026 04:15:10 +0800 Subject: [PATCH 03/12] fix: mount Git entry in build 6962 navigation (#16) --- docs/host-integration/codex-compatibility.md | 8 +- .../codex-cdp/src/adapter.test.ts | 49 ++++++----- .../codex-cdp/src/compatibility-profile.ts | 7 ++ .../codex-cdp/src/compatibility.ts | 25 +++++- .../host-adapter/codex-cdp/src/connection.ts | 27 ++++-- .../codex-cdp/src/remote-renderer.test.ts | 84 +++++++++++++++++++ .../codex-cdp/src/remote-renderer.ts | 33 ++++++-- 7 files changed, 195 insertions(+), 38 deletions(-) diff --git a/docs/host-integration/codex-compatibility.md b/docs/host-integration/codex-compatibility.md index dd990b5..fc9d745 100644 --- a/docs/host-integration/codex-compatibility.md +++ b/docs/host-integration/codex-compatibility.md @@ -33,10 +33,10 @@ endpoint, or record it in ordinary logs. ## Tested profile -| Codex Desktop | Chromium framework | Required anchors | Evidence | -| ----------------------------- | ------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `26.820.60940` (build `7119`) | `151.0.7922.170` | `#app-shell-sidebar`; `[data-app-shell-main-surface="default"]` | Installed renderer bundle inspection plus automated DOM fixture coverage | -| `26.818.41509` (build `6962`) | `151.0.7922.170` | `.app-shell-left-panel`; `[data-app-shell-main-surface="default"]` | App bundle SHA-256 `8eb91bd9efbf9a4dd04b9b0afdbfcb4e0bab5da18c1919ad74ca327c00c7e791`; live isolated CDP attachment/teardown; automated exact-profile fixtures | +| Codex Desktop | Chromium framework | Required anchors | Evidence | +| ----------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `26.820.60940` (build `7119`) | `151.0.7922.170` | `#app-shell-sidebar`; `[data-app-shell-main-surface="default"]` | Installed renderer bundle inspection plus automated DOM fixture coverage | +| `26.818.41509` (build `6962`) | `151.0.7922.170` | `.app-shell-left-panel`; selected Project section; `button.sidebar-item.w-full`; `[data-app-shell-main-surface="default"]` | App bundle SHA-256 `8eb91bd9efbf9a4dd04b9b0afdbfcb4e0bab5da18c1919ad74ca327c00c7e791`; live isolated visible-entry/attachment/teardown probes; automated exact-profile fixtures | The automated exact-profile matrix covers read-only probing, fail-closed fallback, transactional attachment, one-entry mounting, native navigation, repeat diff --git a/packages/host-adapter/codex-cdp/src/adapter.test.ts b/packages/host-adapter/codex-cdp/src/adapter.test.ts index fe5d3a6..331071b 100644 --- a/packages/host-adapter/codex-cdp/src/adapter.test.ts +++ b/packages/host-adapter/codex-cdp/src/adapter.test.ts @@ -95,14 +95,7 @@ describe('CodexCdpHostAdapter', () => { }); it('uses the tested left-panel anchor for Codex Desktop build 6962', async () => { - const dom = new JSDOM(` -
- -
Native task
-
- `); + const dom = build6962CompatibleDom(); const result = await new CodexCdpHostAdapter({ rendererSource: new FixtureRendererSource( @@ -114,21 +107,24 @@ describe('CodexCdpHostAdapter', () => { }); expect(result.kind).toBe('attached'); - expect( - dom.window.document.querySelectorAll('[data-codex-git-sidebar-entry]'), - ).toHaveLength(1); + const entry = dom.window.document.querySelector( + '[data-codex-git-sidebar-entry]', + ); + expect(entry?.classList.contains('w-full')).toBe(true); + expect(entry?.parentElement?.tagName).toBe('SECTION'); + expect(entry?.parentElement?.nextElementSibling).toBe( + dom.window.document.querySelector( + 'section:has([data-app-action-sidebar-project-row][aria-current="page"])', + ), + ); if (result.kind === 'attached') await result.connection.close(); + expect( + dom.window.document.querySelector('[data-codex-git-sidebar-entry-host]'), + ).toBeNull(); }); it('rejects a version and build from different tested profiles', async () => { - const dom = new JSDOM(` -
- -
Native task
-
- `); + const dom = build6962CompatibleDom(); const result = await new CodexCdpHostAdapter({ rendererSource: new FixtureRendererSource( @@ -469,6 +465,21 @@ function compatibleDom(): JSDOM { `); } +function build6962CompatibleDom(): JSDOM { + return new JSDOM(` +
+ +
Native task
+
+ `); +} + function documentEntry(dom: JSDOM): HTMLButtonElement | null { return dom.window.document.querySelector('[data-codex-git-sidebar-entry]'); } diff --git a/packages/host-adapter/codex-cdp/src/compatibility-profile.ts b/packages/host-adapter/codex-cdp/src/compatibility-profile.ts index 476a23e..5d8e8c3 100644 --- a/packages/host-adapter/codex-cdp/src/compatibility-profile.ts +++ b/packages/host-adapter/codex-cdp/src/compatibility-profile.ts @@ -1,7 +1,9 @@ export interface CodexCompatibilityProfile { readonly build: string; readonly chromiumProduct: string; + readonly entryInsertionSelector: string | null; readonly mainSurfaceSelector: string; + readonly nativeEntrySelector: string; readonly sidebarSelector: string; readonly version: string; } @@ -10,14 +12,19 @@ const profiles = [ { build: '7119', chromiumProduct: 'Chrome/151.0.7922.170', + entryInsertionSelector: null, mainSurfaceSelector: '[data-app-shell-main-surface="default"]', + nativeEntrySelector: 'button', sidebarSelector: '#app-shell-sidebar', version: '26.820.60940', }, { build: '6962', chromiumProduct: 'Chrome/151.0.7922.170', + entryInsertionSelector: + 'section:has([data-app-action-sidebar-project-row][aria-current="page"])', mainSurfaceSelector: '[data-app-shell-main-surface="default"]', + nativeEntrySelector: 'button.sidebar-item.w-full', sidebarSelector: '.app-shell-left-panel', version: '26.818.41509', }, diff --git a/packages/host-adapter/codex-cdp/src/compatibility.ts b/packages/host-adapter/codex-cdp/src/compatibility.ts index b3133a1..52239e0 100644 --- a/packages/host-adapter/codex-cdp/src/compatibility.ts +++ b/packages/host-adapter/codex-cdp/src/compatibility.ts @@ -2,7 +2,9 @@ import type { CodexRenderer } from './renderer.js'; import { findCodexCompatibilityProfile } from './compatibility-profile.js'; export interface CompatibleCodexAnchors { + readonly entryInsertionAnchor: HTMLElement | null; readonly mainSurface: HTMLElement; + readonly nativeEntry: HTMLButtonElement; readonly sidebar: HTMLElement; } @@ -27,11 +29,30 @@ export function findCompatibleCodexAnchors( ); const sidebar = sidebars.item(0); const mainSurface = mainSurfaces.item(0); + const nativeEntry = sidebar?.querySelector(profile.nativeEntrySelector); + const entryInsertionAnchors = + profile.entryInsertionSelector === null + ? null + : sidebar?.querySelectorAll(profile.entryInsertionSelector); + const entryInsertionAnchor = entryInsertionAnchors?.item(0) ?? null; + const compatibleEntryInsertionAnchor = + entryInsertionAnchor instanceof renderer.window.HTMLElement + ? entryInsertionAnchor + : null; return sidebars.length === 1 && mainSurfaces.length === 1 && sidebar instanceof renderer.window.HTMLElement && - mainSurface instanceof renderer.window.HTMLElement - ? { mainSurface, sidebar } + mainSurface instanceof renderer.window.HTMLElement && + nativeEntry instanceof renderer.window.HTMLButtonElement && + (entryInsertionAnchors === null || + (entryInsertionAnchors.length === 1 && + compatibleEntryInsertionAnchor !== null)) + ? { + entryInsertionAnchor: compatibleEntryInsertionAnchor, + mainSurface, + nativeEntry, + sidebar, + } : null; } diff --git a/packages/host-adapter/codex-cdp/src/connection.ts b/packages/host-adapter/codex-cdp/src/connection.ts index 0ebef2d..020a888 100644 --- a/packages/host-adapter/codex-cdp/src/connection.ts +++ b/packages/host-adapter/codex-cdp/src/connection.ts @@ -28,6 +28,7 @@ export class CodexHostConnection implements HostConnection { (context: HostContext) => void >(); private readonly gitEntry: HTMLButtonElement; + private readonly gitEntryHost: HTMLElement | null; private frameGeneration = 0; private readonly mainSurface: HTMLElement; private mountedSurface: HTMLElement | null = null; @@ -43,7 +44,7 @@ export class CodexHostConnection implements HostConnection { private readonly createSecret: () => string, private readonly onClose: () => void, ) { - const { document, window } = renderer; + const { document } = renderer; this.sidebar = anchors.sidebar; this.mainSurface = anchors.mainSurface; this.originalMainHidden = anchors.mainSurface.hidden; @@ -62,9 +63,17 @@ export class CodexHostConnection implements HostConnection { this.gitEntry.type = 'button'; this.gitEntry.textContent = 'Git'; this.gitEntry.setAttribute('aria-label', 'Open Codex Git'); - const nativeEntry = anchors.sidebar.querySelector('button'); - if (nativeEntry instanceof window.HTMLButtonElement) { - this.gitEntry.className = nativeEntry.className; + this.gitEntry.className = anchors.nativeEntry.className; + this.gitEntryHost = + anchors.entryInsertionAnchor === null + ? null + : document.createElement( + anchors.entryInsertionAnchor.tagName.toLowerCase(), + ); + if (this.gitEntryHost !== null && anchors.entryInsertionAnchor !== null) { + this.gitEntryHost.dataset.codexGitSidebarEntryHost = ''; + this.gitEntryHost.className = anchors.entryInsertionAnchor.className; + this.gitEntryHost.append(this.gitEntry); } try { this.unsubscribeContext = renderer.subscribeContext(this.handleContext); @@ -75,7 +84,11 @@ export class CodexHostConnection implements HostConnection { true, ); renderer.window.addEventListener('message', this.handleFrameMessage); - this.sidebar.append(this.gitEntry); + if (this.gitEntryHost !== null && anchors.entryInsertionAnchor !== null) { + anchors.entryInsertionAnchor.before(this.gitEntryHost); + } else { + this.sidebar.append(this.gitEntry); + } } catch (error) { this.unsubscribeContext(); this.gitEntry.removeEventListener('click', this.openGitSurface); @@ -85,7 +98,7 @@ export class CodexHostConnection implements HostConnection { true, ); renderer.window.removeEventListener('message', this.handleFrameMessage); - this.gitEntry.remove(); + (this.gitEntryHost ?? this.gitEntry).remove(); throw error; } } @@ -178,7 +191,7 @@ export class CodexHostConnection implements HostConnection { 'message', this.handleFrameMessage, ); - this.gitEntry.remove(); + (this.gitEntryHost ?? this.gitEntry).remove(); this.removeMountedSurface(); this.mainSurface.hidden = this.originalMainHidden; } diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts index d18f451..7ab894c 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts @@ -1,3 +1,4 @@ +import { JSDOM } from 'jsdom'; import { describe, expect, it } from 'vitest'; import type { HostContext } from '@codex-git/host-adapter'; @@ -134,8 +135,68 @@ describe('dedicated Codex remote renderer', () => { ).rejects.toThrow('Unsupported Codex Desktop version'); expect(session.commands).toEqual([]); }); + + it('mounts build 6962 inside a full-width navigation section', async () => { + const dom = build6962Dom(); + const session = new FixtureCdpSession( + { status: 'not-ready' }, + 'Chrome/151.0.7922.170', + dom, + ); + + const connection = await connectDedicatedCodexRenderer( + { ...request, build: '6962', version: '26.818.41509' }, + { + connect: async () => session, + createBindingName: () => '__codexGitNotify_fixture', + }, + ); + + const entry = dom.window.document.querySelector( + '[data-codex-git-sidebar-entry]', + ); + const projects = dom.window.document.querySelector( + 'section:has([data-app-action-sidebar-section-toggle])', + ); + expect(entry?.classList.contains('sidebar-item')).toBe(true); + expect(entry?.parentElement?.tagName).toBe('SECTION'); + expect(entry?.parentElement?.nextElementSibling).toBe(projects); + + await connection.close(); + expect( + dom.window.document.querySelector('[data-codex-git-sidebar-entry]'), + ).toBeNull(); + }); }); +function build6962Dom(): JSDOM { + return new JSDOM( + ` +
Native task
`, + { runScripts: 'outside-only', url: 'app://-/index.html' }, + ); +} + const expectedContext = { projectPath: '/Users/example/codex-git', task: { id: 'task-42', title: 'Implement CDP transport' }, @@ -172,6 +233,7 @@ class FixtureCdpSession implements CdpSession { constructor( private readonly installation: unknown | unknown[], private readonly product = 'Chrome/151.0.7922.170', + private readonly dom?: JSDOM, ) {} async send(method: string, params?: unknown): Promise { @@ -180,6 +242,24 @@ class FixtureCdpSession implements CdpSession { return { product: this.product }; } if (method === 'Runtime.evaluate') { + if (this.dom !== undefined) { + const expression = isRecord(params) ? params.expression : null; + if (typeof expression !== 'string') { + throw new Error('Expected a Runtime.evaluate expression'); + } + try { + return { result: { value: this.dom.window.eval(expression) } }; + } catch (error) { + return { + exceptionDetails: { + exception: { + description: + error instanceof Error ? error.message : String(error), + }, + }, + }; + } + } const value = Array.isArray(this.installation) ? this.installation.shift() : this.installation; @@ -201,3 +281,7 @@ class FixtureCdpSession implements CdpSession { this.closed = true; } } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.ts index 2162481..95db7a2 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.ts @@ -271,9 +271,11 @@ async function install( ): Promise { const input: BridgeInput = { bindingName, + entryInsertionSelector: profile.entryInsertionSelector, expectedProject: request.expectedProject, generation, mainSurfaceSelector: profile.mainSurfaceSelector, + nativeEntrySelector: profile.nativeEntrySelector, openSurface: request.openSurface, projectPath: request.projectPath, surfaceTitle: request.surface.title, @@ -418,9 +420,11 @@ function wait(milliseconds: number): Promise { interface BridgeInput { readonly bindingName: string; + readonly entryInsertionSelector: string | null; readonly expectedProject: DedicatedProjectIdentity | null; readonly generation: number; readonly mainSurfaceSelector: string; + readonly nativeEntrySelector: string; readonly openSurface: boolean; readonly projectPath: string; readonly surfaceTitle: string; @@ -438,9 +442,16 @@ function installDomBridge(input: BridgeInput): unknown { const sidebar = sidebars.item(0); const main = mainSurfaces.item(0); const selectedProject = document.querySelector('[data-app-action-sidebar-project-row][aria-current="page"]'); + const nativeEntry = sidebar?.querySelector(input.nativeEntrySelector); + const entryInsertionAnchors = input.entryInsertionSelector === null ? null : + sidebar?.querySelectorAll(input.entryInsertionSelector); + const entryInsertionAnchor = entryInsertionAnchors?.item(0) ?? null; if (sidebars.length !== 1 || mainSurfaces.length !== 1 || !(sidebar instanceof HTMLElement) || !(main instanceof HTMLElement) || - !(selectedProject instanceof HTMLElement)) { + !(selectedProject instanceof HTMLElement) || + !(nativeEntry instanceof HTMLButtonElement) || + (entryInsertionAnchors !== null && (entryInsertionAnchors.length !== 1 || + !(entryInsertionAnchor instanceof HTMLElement)))) { return { status: 'not-ready' }; } const project = { id: selectedProject.dataset.appActionSidebarProjectId ?? '', @@ -458,8 +469,14 @@ function installDomBridge(input: BridgeInput): unknown { const entry = document.createElement('button'); entry.type = 'button'; entry.dataset.codexGitSidebarEntry = ''; entry.textContent = 'Git'; entry.setAttribute('aria-label', 'Open Codex Git'); - const nativeEntry = sidebar.querySelector('button'); - if (nativeEntry instanceof HTMLButtonElement) entry.className = nativeEntry.className; + entry.className = nativeEntry.className; + const entryHost = entryInsertionAnchor === null ? null : + document.createElement(entryInsertionAnchor.tagName.toLowerCase()); + if (entryHost !== null && entryInsertionAnchor !== null) { + entryHost.dataset.codexGitSidebarEntryHost = ''; + entryHost.className = entryInsertionAnchor.className; + entryHost.append(entry); + } let host: HTMLElement | null = null; let frame: HTMLIFrameElement | null = null; let capability = '', challenge = '', lastContext = ''; @@ -526,7 +543,9 @@ function installDomBridge(input: BridgeInput): unknown { }; const observer = new MutationObserver(() => { const currentProject = document.querySelector('[data-app-action-sidebar-project-row][aria-current="page"]'); - if (!sidebar.isConnected || !main.isConnected || !(currentProject instanceof HTMLElement) || + if (!sidebar.isConnected || !main.isConnected || !entry.isConnected || + (entryHost !== null && !entryHost.isConnected) || + !(currentProject instanceof HTMLElement) || currentProject.dataset.appActionSidebarProjectId !== project.id || currentProject.dataset.appActionSidebarProjectLabel !== project.label) { notify({ kind: 'standalone-required' }); @@ -536,14 +555,16 @@ function installDomBridge(input: BridgeInput): unknown { }); const close = () => { observer.disconnect(); sidebar.removeEventListener('click', handleSidebar, true); - globalThis.removeEventListener('message', handleMessage); restore(); entry.remove(); + globalThis.removeEventListener('message', handleMessage); restore(); + (entryHost ?? entry).remove(); delete root.__codexGitBridge; }; root.__codexGitBridge = { close, restore }; entry.addEventListener('click', open); sidebar.addEventListener('click', handleSidebar, true); globalThis.addEventListener('message', handleMessage); - sidebar.append(entry); + if (entryHost !== null && entryInsertionAnchor !== null) entryInsertionAnchor.before(entryHost); + else sidebar.append(entry); observer.observe(document.documentElement, { attributes: true, childList: true, subtree: true }); if (input.openSurface) open(); const initialContext = context(); From a7d71e70c2d743c60f0a79be26d7a1fd55ee82c2 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Tue, 1 Sep 2026 04:33:18 +0800 Subject: [PATCH 04/12] fix: reject CSP-incompatible Codex build (#16) --- docs/host-integration/codex-compatibility.md | 14 ++-- docs/release/mvp-release-gate.md | 4 +- .../codex-cdp/src/adapter.test.ts | 23 ++--- .../codex-cdp/src/compatibility-profile.ts | 10 --- .../codex-cdp/src/remote-renderer.test.ts | 83 ++----------------- scripts/voiceover-release-wizard.sh | 4 +- tests/release/manual-evidence.test.ts | 4 +- tests/release/release-artifacts.test.ts | 2 +- tests/release/release-environment.test.ts | 4 +- tests/release/release-environment.ts | 2 +- tests/release/release-report.test.ts | 14 ++-- 11 files changed, 38 insertions(+), 126 deletions(-) diff --git a/docs/host-integration/codex-compatibility.md b/docs/host-integration/codex-compatibility.md index fc9d745..8f26c73 100644 --- a/docs/host-integration/codex-compatibility.md +++ b/docs/host-integration/codex-compatibility.md @@ -33,10 +33,9 @@ endpoint, or record it in ordinary logs. ## Tested profile -| Codex Desktop | Chromium framework | Required anchors | Evidence | -| ----------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `26.820.60940` (build `7119`) | `151.0.7922.170` | `#app-shell-sidebar`; `[data-app-shell-main-surface="default"]` | Installed renderer bundle inspection plus automated DOM fixture coverage | -| `26.818.41509` (build `6962`) | `151.0.7922.170` | `.app-shell-left-panel`; selected Project section; `button.sidebar-item.w-full`; `[data-app-shell-main-surface="default"]` | App bundle SHA-256 `8eb91bd9efbf9a4dd04b9b0afdbfcb4e0bab5da18c1919ad74ca327c00c7e791`; live isolated visible-entry/attachment/teardown probes; automated exact-profile fixtures | +| Codex Desktop | Chromium framework | Required anchors | Evidence | +| ----------------------------- | ------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `26.820.60940` (build `7119`) | `151.0.7922.170` | `#app-shell-sidebar`; `[data-app-shell-main-surface="default"]` | Installed renderer bundle inspection plus automated DOM fixture coverage | The automated exact-profile matrix covers read-only probing, fail-closed fallback, transactional attachment, one-entry mounting, native navigation, repeat @@ -52,10 +51,9 @@ matrix; do not widen selectors to make an unknown build appear compatible. This matrix passed on 2026-08-29 against a disposable dedicated profile using Codex Desktop `26.820.60940` (build `7119`) and Chromium `151.0.7922.170`. -The current release profile, Codex Desktop `26.818.41509` (build `6962`), has -passed isolated runtime attachment and teardown but still requires the human -host, keyboard, and VoiceOver record produced by -`scripts/voiceover-release-wizard.sh` before the release gate can pass. +Build `6962` is intentionally excluded: live validation showed its `app://` +Content Security Policy blocks the loopback Git Surface frame even after the +scoped CDP bypass command. Unsupported builds must use the standalone surface. The human portion of the matrix is intentionally limited to behavior that requires an actual Codex/macOS session. The release wizard records these checks: diff --git a/docs/release/mvp-release-gate.md b/docs/release/mvp-release-gate.md index 6e8cdc5..b760562 100644 --- a/docs/release/mvp-release-gate.md +++ b/docs/release/mvp-release-gate.md @@ -57,11 +57,11 @@ and ends only after SSE-triggered refresh makes the new Changed File visible in the DOM. The approved CI reference profile is `github-actions-macos-15`, paired with the -tested Codex compatibility profile `26.818.41509 (build 6962)`. The JSON and +tested Codex compatibility profile `26.820.60940 (build 7119)`. The JSON and Markdown artifacts record the actual CPU, architecture, memory, macOS, Git, Node.js, reference profile, and Codex compatibility version. A local release run must explicitly set `CODEX_GIT_REFERENCE_PROFILE=local-macos-release` and -`CODEX_DESKTOP_VERSION='26.818.41509 (build 6962)'`; an unapproved platform, +`CODEX_DESKTOP_VERSION='26.820.60940 (build 7119)'`; an unapproved platform, profile, or missing/mismatched Codex version fails AC-24. ## VoiceOver and keyboard smoke record diff --git a/packages/host-adapter/codex-cdp/src/adapter.test.ts b/packages/host-adapter/codex-cdp/src/adapter.test.ts index 331071b..c5987b0 100644 --- a/packages/host-adapter/codex-cdp/src/adapter.test.ts +++ b/packages/host-adapter/codex-cdp/src/adapter.test.ts @@ -94,8 +94,9 @@ describe('CodexCdpHostAdapter', () => { expect(nativeSurface?.hidden).toBe(false); }); - it('uses the tested left-panel anchor for Codex Desktop build 6962', async () => { + it('fails closed for build 6962 after live CSP validation failed', async () => { const dom = build6962CompatibleDom(); + const before = dom.window.document.documentElement.outerHTML; const result = await new CodexCdpHostAdapter({ rendererSource: new FixtureRendererSource( @@ -106,21 +107,11 @@ describe('CodexCdpHostAdapter', () => { url: new URL('http://127.0.0.1:4173'), }); - expect(result.kind).toBe('attached'); - const entry = dom.window.document.querySelector( - '[data-codex-git-sidebar-entry]', - ); - expect(entry?.classList.contains('w-full')).toBe(true); - expect(entry?.parentElement?.tagName).toBe('SECTION'); - expect(entry?.parentElement?.nextElementSibling).toBe( - dom.window.document.querySelector( - 'section:has([data-app-action-sidebar-project-row][aria-current="page"])', - ), - ); - if (result.kind === 'attached') await result.connection.close(); - expect( - dom.window.document.querySelector('[data-codex-git-sidebar-entry-host]'), - ).toBeNull(); + expect(result).toMatchObject({ + kind: 'standalone-required', + reason: { code: 'incompatible-host' }, + }); + expect(dom.window.document.documentElement.outerHTML).toBe(before); }); it('rejects a version and build from different tested profiles', async () => { diff --git a/packages/host-adapter/codex-cdp/src/compatibility-profile.ts b/packages/host-adapter/codex-cdp/src/compatibility-profile.ts index 5d8e8c3..f13ab77 100644 --- a/packages/host-adapter/codex-cdp/src/compatibility-profile.ts +++ b/packages/host-adapter/codex-cdp/src/compatibility-profile.ts @@ -18,16 +18,6 @@ const profiles = [ sidebarSelector: '#app-shell-sidebar', version: '26.820.60940', }, - { - build: '6962', - chromiumProduct: 'Chrome/151.0.7922.170', - entryInsertionSelector: - 'section:has([data-app-action-sidebar-project-row][aria-current="page"])', - mainSurfaceSelector: '[data-app-shell-main-surface="default"]', - nativeEntrySelector: 'button.sidebar-item.w-full', - sidebarSelector: '.app-shell-left-panel', - version: '26.818.41509', - }, ] as const satisfies readonly CodexCompatibilityProfile[]; export function findCodexCompatibilityProfile( diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts index 7ab894c..37c4675 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts @@ -100,28 +100,20 @@ describe('dedicated Codex remote renderer', () => { ]); }); - it('accepts the exact build 6962 profile and installs its tested anchors', async () => { + it('rejects build 6962 before CDP because its live CSP blocks the surface frame', async () => { const session = new FixtureCdpSession({ context: expectedContext, project: { id: 'project-42', label: 'codex-git' }, status: 'attached', }); - const connection = await connectDedicatedCodexRenderer( - { ...request, build: '6962', version: '26.818.41509' }, - { - connect: async () => session, - createBindingName: () => '__codexGitNotify_fixture', - }, - ); - - const installation = session.commands.find( - ({ method }) => method === 'Runtime.evaluate', - ); - expect(installation?.params).toMatchObject({ - expression: expect.stringContaining('.app-shell-left-panel'), - }); - await connection.close(); + await expect( + connectDedicatedCodexRenderer( + { ...request, build: '6962', version: '26.818.41509' }, + { connect: async () => session }, + ), + ).rejects.toThrow('Unsupported Codex Desktop version'); + expect(session.commands).toEqual([]); }); it('rejects a version and build from different tested profiles before CDP', async () => { @@ -136,67 +128,8 @@ describe('dedicated Codex remote renderer', () => { expect(session.commands).toEqual([]); }); - it('mounts build 6962 inside a full-width navigation section', async () => { - const dom = build6962Dom(); - const session = new FixtureCdpSession( - { status: 'not-ready' }, - 'Chrome/151.0.7922.170', - dom, - ); - - const connection = await connectDedicatedCodexRenderer( - { ...request, build: '6962', version: '26.818.41509' }, - { - connect: async () => session, - createBindingName: () => '__codexGitNotify_fixture', - }, - ); - - const entry = dom.window.document.querySelector( - '[data-codex-git-sidebar-entry]', - ); - const projects = dom.window.document.querySelector( - 'section:has([data-app-action-sidebar-section-toggle])', - ); - expect(entry?.classList.contains('sidebar-item')).toBe(true); - expect(entry?.parentElement?.tagName).toBe('SECTION'); - expect(entry?.parentElement?.nextElementSibling).toBe(projects); - - await connection.close(); - expect( - dom.window.document.querySelector('[data-codex-git-sidebar-entry]'), - ).toBeNull(); - }); }); -function build6962Dom(): JSDOM { - return new JSDOM( - ` -
Native task
`, - { runScripts: 'outside-only', url: 'app://-/index.html' }, - ); -} - const expectedContext = { projectPath: '/Users/example/codex-git', task: { id: 'task-42', title: 'Implement CDP transport' }, diff --git a/scripts/voiceover-release-wizard.sh b/scripts/voiceover-release-wizard.sh index 48caac0..9972d6c 100755 --- a/scripts/voiceover-release-wizard.sh +++ b/scripts/voiceover-release-wizard.sh @@ -189,7 +189,7 @@ TOTAL_STAGES=4 REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) RECORD_PATH="$REPO_ROOT/docs/release/evidence/issue-16-manual-accessibility.md" EVIDENCE_PATH="$REPO_ROOT/docs/release/manual-evidence.json" -SUPPORTED_CODEX_VERSION="26.818.41509 (build 6962)" +SUPPORTED_CODEX_VERSION="26.820.60940 (build 7119)" require_confirm() { if ! confirm "$1"; then @@ -208,7 +208,7 @@ open_url "x-apple.systempreferences:com.apple.Accessibility-Settings.extension" step "Open VoiceOver settings, then VoiceOver Utility → About, and note its displayed version." ask VOICEOVER_VERSION "VoiceOver / VoiceOver Utility version:" step "In Codex, open About Codex and copy the version including its build number." -ask CODEX_VERSION "Codex Desktop version (for example 26.818.41509 (build 6962)):" +ask CODEX_VERSION "Codex Desktop version (for example 26.820.60940 (build 7119)):" step "Record the Chromium/WebView version from the tested Codex compatibility profile." ask WEBVIEW_VERSION "Chromium/WebView version:" if [[ "$CODEX_VERSION" != "$SUPPORTED_CODEX_VERSION" ]]; then diff --git a/tests/release/manual-evidence.test.ts b/tests/release/manual-evidence.test.ts index e2fbebb..e90cf7f 100644 --- a/tests/release/manual-evidence.test.ts +++ b/tests/release/manual-evidence.test.ts @@ -8,8 +8,8 @@ describe('manual release evidence', () => { parseManualEvidence({ checks: [ { - codexVersion: '26.818.41509 (build 6962)', - environment: 'Codex Desktop 26.818.41509 (build 6962)', + codexVersion: '26.820.60940 (build 7119)', + environment: 'Codex Desktop 26.820.60940 (build 7119)', id: 'codex-host-smoke', performedAt: '2026-08-29T00:00:00.000Z', record: diff --git a/tests/release/release-artifacts.test.ts b/tests/release/release-artifacts.test.ts index f7868d8..308c680 100644 --- a/tests/release/release-artifacts.test.ts +++ b/tests/release/release-artifacts.test.ts @@ -109,7 +109,7 @@ function fixtureReport(): ReleaseReport { manualEvidence: { checks: [ { - codexVersion: '26.818.41509 (build 6962)', + codexVersion: '26.820.60940 (build 7119)', environment: 'Codex Desktop 26.820.60940', id: 'codex-host-smoke', performedAt: '2026-08-29T00:00:00.000Z', diff --git a/tests/release/release-environment.test.ts b/tests/release/release-environment.test.ts index 6e07ff0..dc2f917 100644 --- a/tests/release/release-environment.test.ts +++ b/tests/release/release-environment.test.ts @@ -9,7 +9,7 @@ describe('release environment evidence', () => { it('records the required hardware, macOS, Git, Node, and Codex versions', async () => { const environment = await collectReleaseEnvironment({ architecture: 'arm64', - codexVersion: '26.818.41509 (build 6962)', + codexVersion: '26.820.60940 (build 7119)', cpu: 'Apple Test CPU', memoryBytes: 16 * 1_024 ** 3, nodeVersion: 'v22.12.0', @@ -27,7 +27,7 @@ describe('release environment evidence', () => { expect(environment).toEqual({ architecture: 'arm64', - codex: '26.818.41509 (build 6962)', + codex: '26.820.60940 (build 7119)', cpu: 'Apple Test CPU', git: 'git version 2.50.1', memoryBytes: 16 * 1_024 ** 3, diff --git a/tests/release/release-environment.ts b/tests/release/release-environment.ts index 09d6f65..ddf6fed 100644 --- a/tests/release/release-environment.ts +++ b/tests/release/release-environment.ts @@ -40,7 +40,7 @@ export async function collectReleaseEnvironment( } export const SUPPORTED_CODEX_RELEASE_VERSION = - '26.818.41509 (build 6962)' as const; + '26.820.60940 (build 7119)' as const; export function validateReleaseEnvironment( environment: ReleaseEnvironment, diff --git a/tests/release/release-report.test.ts b/tests/release/release-report.test.ts index 09e1a38..b10bc68 100644 --- a/tests/release/release-report.test.ts +++ b/tests/release/release-report.test.ts @@ -11,7 +11,7 @@ import type { ManualEvidenceRecord } from './manual-evidence.js'; const environment: ReleaseEnvironment = { architecture: 'arm64', - codex: '26.818.41509 (build 6962)', + codex: '26.820.60940 (build 7119)', cpu: 'Test CPU', git: 'git version 2.50.1', memoryBytes: 16 * 1_024 ** 3, @@ -40,8 +40,8 @@ const performance: ReferenceBenchmarkResult = { const manualEvidence: ManualEvidenceRecord = { checks: [ { - codexVersion: '26.818.41509 (build 6962)', - environment: 'Codex Desktop 26.818.41509 (build 6962)', + codexVersion: '26.820.60940 (build 7119)', + environment: 'Codex Desktop 26.820.60940 (build 7119)', id: 'codex-host-smoke', performedAt: '2026-08-29T00:00:00.000Z', record: @@ -51,9 +51,9 @@ const manualEvidence: ManualEvidenceRecord = { validUntil: '2026-09-29T00:00:00.000Z', }, { - codexVersion: '26.818.41509 (build 6962)', + codexVersion: '26.820.60940 (build 7119)', environment: - 'macOS 15.6; VoiceOver 10; Codex Desktop 26.818.41509 (build 6962)', + 'macOS 15.6; VoiceOver 10; Codex Desktop 26.820.60940 (build 7119)', id: 'voiceover-keyboard-smoke', performedAt: '2026-09-01T00:00:00.000Z', record: @@ -83,7 +83,7 @@ describe('release evidence report', () => { expect(report.markdown).toContain( '| AC-24 | Pass the supported release envelope | passed |', ); - expect(report.markdown).toContain('Codex | 26.818.41509 (build 6962)'); + expect(report.markdown).toContain('Codex | 26.820.60940 (build 7119)'); expect(report.markdown).toContain( '| Full snapshot | 900 ms | 5000 ms | passed |', ); @@ -182,7 +182,7 @@ describe('release evidence report', () => { ...manualEvidence, checks: manualEvidence.checks.map((check) => ({ ...check, - codexVersion: '26.820.60940 (build 7119)', + codexVersion: '26.818.41509 (build 6962)', })), }; From 6b8819ab37fbf2a7242afcf618a0fcb88ebfb480 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Tue, 1 Sep 2026 15:25:11 +0800 Subject: [PATCH 05/12] test: reject CSP-incompatible Codex build 7377 (#16) --- docs/host-integration/codex-compatibility.md | 8 +++++--- .../codex-cdp/src/compatibility-profile.test.ts | 11 +++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 packages/host-adapter/codex-cdp/src/compatibility-profile.test.ts diff --git a/docs/host-integration/codex-compatibility.md b/docs/host-integration/codex-compatibility.md index 8f26c73..7960ae0 100644 --- a/docs/host-integration/codex-compatibility.md +++ b/docs/host-integration/codex-compatibility.md @@ -51,9 +51,11 @@ matrix; do not widen selectors to make an unknown build appear compatible. This matrix passed on 2026-08-29 against a disposable dedicated profile using Codex Desktop `26.820.60940` (build `7119`) and Chromium `151.0.7922.170`. -Build `6962` is intentionally excluded: live validation showed its `app://` -Content Security Policy blocks the loopback Git Surface frame even after the -scoped CDP bypass command. Unsupported builds must use the standalone surface. +Builds `6962` and `7377` are intentionally excluded: live validation showed +their `app://` Content Security Policy blocks the loopback Git Surface frame +even after the scoped CDP bypass command. Build `7377` also reports Chromium +`151.0.7922.174`, outside the tested framework version. Unsupported builds must +use the standalone surface. The human portion of the matrix is intentionally limited to behavior that requires an actual Codex/macOS session. The release wizard records these checks: diff --git a/packages/host-adapter/codex-cdp/src/compatibility-profile.test.ts b/packages/host-adapter/codex-cdp/src/compatibility-profile.test.ts new file mode 100644 index 0000000..b317072 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/compatibility-profile.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { findCodexCompatibilityProfile } from './compatibility-profile.js'; + +describe('Codex compatibility profiles', () => { + it('fails closed for build 7377 after live CSP validation failed', () => { + expect(findCodexCompatibilityProfile('26.825.51511', '7377')).toBeNull(); + expect(findCodexCompatibilityProfile('26.825.51511', '7119')).toBeNull(); + expect(findCodexCompatibilityProfile('26.820.60940', '7377')).toBeNull(); + }); +}); From 70d796b77b6bb3757f4c8cda4a6cefbdab5e6d86 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Tue, 1 Sep 2026 15:44:25 +0800 Subject: [PATCH 06/12] style: format Codex renderer tests (#16) --- packages/host-adapter/codex-cdp/src/remote-renderer.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts index 37c4675..b3c30bb 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts @@ -127,7 +127,6 @@ describe('dedicated Codex remote renderer', () => { ).rejects.toThrow('Unsupported Codex Desktop version'); expect(session.commands).toEqual([]); }); - }); const expectedContext = { From 2486234eaf3649d1270bf10370e55ffb4d500aec Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Tue, 1 Sep 2026 15:47:39 +0800 Subject: [PATCH 07/12] fix: align CI Codex release profile (#16) --- .github/workflows/ci.yml | 2 +- tests/release/release-environment.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cbec81..5f55344 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: runs-on: macos-15 timeout-minutes: 20 env: - CODEX_DESKTOP_VERSION: 26.818.41509 (build 6962) + CODEX_DESKTOP_VERSION: 26.820.60940 (build 7119) CODEX_GIT_REFERENCE_PROFILE: github-actions-macos-15 steps: - name: Check out repository diff --git a/tests/release/release-environment.test.ts b/tests/release/release-environment.test.ts index dc2f917..47508b1 100644 --- a/tests/release/release-environment.test.ts +++ b/tests/release/release-environment.test.ts @@ -1,7 +1,10 @@ +import { readFile } from 'node:fs/promises'; + import { describe, expect, it } from 'vitest'; import { collectReleaseEnvironment, + SUPPORTED_CODEX_RELEASE_VERSION, validateReleaseEnvironment, } from './release-environment.js'; @@ -52,4 +55,12 @@ describe('release environment evidence', () => { }), ).toHaveLength(3); }); + + it('keeps the macOS CI profile aligned with the supported Codex build', async () => { + const workflow = await readFile('.github/workflows/ci.yml', 'utf8'); + + expect(workflow).toContain( + `CODEX_DESKTOP_VERSION: ${SUPPORTED_CODEX_RELEASE_VERSION}`, + ); + }); }); From 45d35a7fa9abbf9682a8b00a89eae2303524b40d Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Tue, 1 Sep 2026 15:54:42 +0800 Subject: [PATCH 08/12] fix: exclude Vite warmup from release timing (#16) --- tests/release/ui-benchmark.test.ts | 27 +++++++++++++++++++++++++++ tests/release/ui-benchmark.tsx | 20 ++++++++++++++++---- 2 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 tests/release/ui-benchmark.test.ts diff --git a/tests/release/ui-benchmark.test.ts b/tests/release/ui-benchmark.test.ts new file mode 100644 index 0000000..c9ac4f2 --- /dev/null +++ b/tests/release/ui-benchmark.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { prepareProtocolReleaseSurface } from './ui-benchmark.js'; + +describe('release UI benchmark preparation', () => { + it('finishes the Vite entry request before the measured UI path begins', async () => { + const requests: string[] = []; + const fetcher: typeof fetch = async (input) => { + requests.push(String(input)); + return new Response( + requests.length === 1 + ? '' + : 'export {};', + ); + }; + + await prepareProtocolReleaseSurface( + new URL('http://127.0.0.1:4173/'), + fetcher, + ); + + expect(requests).toEqual([ + 'http://127.0.0.1:4173/', + 'http://127.0.0.1:4173/src/main.tsx', + ]); + }); +}); diff --git a/tests/release/ui-benchmark.tsx b/tests/release/ui-benchmark.tsx index faf66c7..7d55d49 100644 --- a/tests/release/ui-benchmark.tsx +++ b/tests/release/ui-benchmark.tsx @@ -29,11 +29,8 @@ export async function measureProtocolReleaseUi(options: { readonly sessionUrl: URL; readonly surfaceUrl: URL; }): Promise { + await prepareProtocolReleaseSurface(options.surfaceUrl); const overallStartedAt = performance.now(); - const surface = await (await fetch(options.surfaceUrl)).text(); - if (!surface.includes('src="/src/main.tsx"')) { - throw new Error('The production Git Surface entry point was not served.'); - } return withBrowserDom(async () => { ( @@ -133,6 +130,21 @@ export async function measureProtocolReleaseUi(options: { }); } +export async function prepareProtocolReleaseSurface( + surfaceUrl: URL, + fetcher: typeof fetch = fetch, +): Promise { + const surface = await (await fetcher(surfaceUrl)).text(); + if (!surface.includes('src="/src/main.tsx"')) { + throw new Error('The production Git Surface entry point was not served.'); + } + const entry = await fetcher(new URL('/src/main.tsx', surfaceUrl)); + if (!entry.ok) { + throw new Error('The production Git Surface entry point did not load.'); + } + await entry.arrayBuffer(); +} + export async function measureReleaseUi(): Promise { return withBrowserDom(async () => { const shell = await measureShell(); From 07c30c0813c5e4ec5e3de5f863015b2c181c5b40 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Tue, 1 Sep 2026 16:01:29 +0800 Subject: [PATCH 09/12] fix: stabilize release benchmark sampling (#16) --- docs/release/mvp-release-gate.md | 16 +++---- tests/release/reference-benchmark.test.ts | 51 +++++++++++++++++++++++ tests/release/reference-benchmark.ts | 43 +++++++++++++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 tests/release/reference-benchmark.test.ts diff --git a/docs/release/mvp-release-gate.md b/docs/release/mvp-release-gate.md index b760562..8f81405 100644 --- a/docs/release/mvp-release-gate.md +++ b/docs/release/mvp-release-gate.md @@ -48,13 +48,15 @@ exceeds its budget: | Visible external selected-Worktree change | 2,000 ms | | Loaded UI interaction | 100 ms | -The macOS benchmark starts the production Vite surface and loopback protocol, -then drives the real protocol source, Repository Store, React DOM, and SSE -invalidation path. Shell timing includes loading the served surface entry and -its first DOM commit. Selected-Worktree timing ends when the authoritative -snapshot is visible. External-change timing starts before the filesystem write -and ends only after SSE-triggered refresh makes the new Changed File visible in -the DOM. +The macOS benchmark takes the median of three independent disposable fixtures. +Each sample starts the production Vite surface and loopback protocol, waits for +Vite to finish preparing the served entry, then drives the real protocol source, +Repository Store, React DOM, and SSE invalidation path. The preparation step is +outside the product timing so dependency optimization cannot contend with the +Node/JSDOM measurement. Shell timing includes the first DOM commit. +Selected-Worktree timing ends when the authoritative snapshot is visible. +External-change timing starts before the filesystem write and ends only after +SSE-triggered refresh makes the new Changed File visible in the DOM. The approved CI reference profile is `github-actions-macos-15`, paired with the tested Codex compatibility profile `26.820.60940 (build 7119)`. The JSON and diff --git a/tests/release/reference-benchmark.test.ts b/tests/release/reference-benchmark.test.ts new file mode 100644 index 0000000..8cb5d85 --- /dev/null +++ b/tests/release/reference-benchmark.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { + aggregateReferenceBenchmarkSamples, + type ReferenceBenchmarkResult, +} from './reference-benchmark.js'; + +describe('reference benchmark sampling', () => { + it('uses the median so one runner spike does not change the verdict', () => { + const result = aggregateReferenceBenchmarkSamples([ + sample(1_900), + sample(2_500), + sample(1_950), + ]); + + expect(result.measurements.selectedWorktree).toBe(1_950); + expect(result.budgetFailures).toEqual([]); + }); + + it('still fails when most independent samples exceed the budget', () => { + const result = aggregateReferenceBenchmarkSamples([ + sample(2_100), + sample(1_900), + sample(2_200), + ]); + + expect(result.measurements.selectedWorktree).toBe(2_100); + expect(result.budgetFailures).toEqual([ + 'selectedWorktree took 2100 ms; budget is 2000 ms.', + ]); + }); +}); + +function sample(selectedWorktree: number): ReferenceBenchmarkResult { + return { + budgetFailures: [], + fixture: { + availableWorktrees: 25, + changedFiles: 2_000, + refs: 5_000, + unavailableRegistrations: 1, + }, + measurements: { + externalChange: 1_000, + fullSnapshot: 1_000, + loadedInteraction: 10, + selectedWorktree, + shell: 100, + }, + }; +} diff --git a/tests/release/reference-benchmark.ts b/tests/release/reference-benchmark.ts index 8c9e2db..fcf4595 100644 --- a/tests/release/reference-benchmark.ts +++ b/tests/release/reference-benchmark.ts @@ -31,6 +31,49 @@ export interface ReferenceBenchmarkResult { } export async function runReferenceBenchmark(): Promise { + const samples: ReferenceBenchmarkResult[] = []; + for (let index = 0; index < 3; index += 1) { + samples.push(await runReferenceBenchmarkSample()); + } + return aggregateReferenceBenchmarkSamples(samples); +} + +export function aggregateReferenceBenchmarkSamples( + samples: readonly ReferenceBenchmarkResult[], +): ReferenceBenchmarkResult { + const first = samples[0]; + if (first === undefined || samples.length % 2 === 0) { + throw new Error('Reference benchmark requires an odd number of samples.'); + } + const fixture = JSON.stringify(first.fixture); + if (samples.some((sample) => JSON.stringify(sample.fixture) !== fixture)) { + throw new Error('Reference benchmark sample fixtures do not match.'); + } + const measurementNames = [ + 'externalChange', + 'fullSnapshot', + 'loadedInteraction', + 'selectedWorktree', + 'shell', + ] as const; + const measurements = roundMeasurements( + Object.fromEntries( + measurementNames.map((name) => { + const values = samples + .map((sample) => sample.measurements[name]) + .toSorted((left, right) => left - right); + return [name, values[Math.floor(values.length / 2)]]; + }), + ) as unknown as PerformanceMeasurements, + ); + return { + budgetFailures: evaluatePerformanceBudget(measurements), + fixture: first.fixture, + measurements, + }; +} + +async function runReferenceBenchmarkSample(): Promise { const root = await mkdtemp(join(tmpdir(), 'codex-git-reference-')); const main = join(root, 'main'); let session: Awaited< From 2a4e9a7fb95f4eef8d6b209168a328aaffe1ab8d Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 6 Sep 2026 00:44:41 +0800 Subject: [PATCH 10/12] feat: embed Git in Codex build 7982 through owned documents (#16) --- .github/workflows/ci.yml | 2 +- apps/launcher/src/codex-runtime.ts | 28 +++-- apps/launcher/src/embedded-assets.test.ts | 26 ++++ apps/launcher/src/embedded-assets.ts | 53 ++++++++ apps/launcher/src/standalone-runtime.ts | 18 ++- docs/host-integration/codex-compatibility.md | 41 ++++--- .../evidence/build-7982-compatibility.md | 91 ++++++++++++++ docs/release/mvp-release-gate.md | 4 +- .../src/compatibility-profile.test.ts | 8 ++ .../codex-cdp/src/compatibility-profile.ts | 12 ++ .../codex-cdp/src/compatibility.ts | 1 + .../codex-cdp/src/frame-document.test.ts | 114 ++++++++++++++++++ .../codex-cdp/src/frame-document.ts | 72 +++++++++++ .../codex-cdp/src/remote-renderer.test.ts | 42 ++++--- .../codex-cdp/src/remote-renderer.ts | 50 +++++++- scripts/voiceover-release-wizard.sh | 4 +- tests/release/release-artifacts.test.ts | 2 +- tests/release/release-environment.test.ts | 4 +- tests/release/release-environment.ts | 2 +- tests/release/release-report.test.ts | 12 +- 20 files changed, 524 insertions(+), 62 deletions(-) create mode 100644 apps/launcher/src/embedded-assets.test.ts create mode 100644 apps/launcher/src/embedded-assets.ts create mode 100644 docs/release/evidence/build-7982-compatibility.md create mode 100644 packages/host-adapter/codex-cdp/src/frame-document.test.ts create mode 100644 packages/host-adapter/codex-cdp/src/frame-document.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f55344..0805138 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: runs-on: macos-15 timeout-minutes: 20 env: - CODEX_DESKTOP_VERSION: 26.820.60940 (build 7119) + CODEX_DESKTOP_VERSION: 26.901.41600 (build 7982) CODEX_GIT_REFERENCE_PROFILE: github-actions-macos-15 steps: - name: Check out repository diff --git a/apps/launcher/src/codex-runtime.ts b/apps/launcher/src/codex-runtime.ts index beb3539..305c548 100644 --- a/apps/launcher/src/codex-runtime.ts +++ b/apps/launcher/src/codex-runtime.ts @@ -45,7 +45,12 @@ export async function startCodexRuntime( options.dedicatedInstance, ); const result = await new DedicatedCodexHostAdapter({ - connectRenderer: options.connectRenderer ?? connectDedicatedCodexRenderer, + connectRenderer: + options.connectRenderer ?? + ((request) => + connectDedicatedCodexRenderer(request, { + loadDocument: () => standalone.loadEmbeddedDocument(), + })), instance, projectPath: options.projectPath, }).attach({ @@ -67,10 +72,8 @@ export async function startCodexRuntime( const dedicatedInstance = instance; connection = null; instance = null; - await Promise.allSettled([ - attachedConnection?.close(), - dedicatedInstance?.close(), - ]); + await attachedConnection?.close().catch(() => undefined); + await dedicatedInstance?.close(); }); } } catch { @@ -82,18 +85,21 @@ export async function startCodexRuntime( healthUrl: standalone.healthUrl, sessionUrl: standalone.sessionUrl, surfaceUrl: standalone.surfaceUrl, + loadEmbeddedDocument: () => standalone.loadEmbeddedDocument(), currentHost: () => host, async close() { if (closing) { return; } closing = true; - const results = await Promise.allSettled([ - connection?.close(), - instance?.close(), - monitor, - standalone.close(), - ]); + const results = await Promise.allSettled([connection?.close()]); + results.push( + ...(await Promise.allSettled([ + instance?.close(), + monitor, + standalone.close(), + ])), + ); const failure = results.find( (result): result is PromiseRejectedResult => result.status === 'rejected', diff --git a/apps/launcher/src/embedded-assets.test.ts b/apps/launcher/src/embedded-assets.test.ts new file mode 100644 index 0000000..59563b0 --- /dev/null +++ b/apps/launcher/src/embedded-assets.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { startStandaloneRuntime } from './standalone-runtime.js'; + +describe('embedded asset CORS boundary', () => { + it('allows opaque-origin modules but never exposes bootstrap HTML or fallback HTML', async () => { + const runtime = await startStandaloneRuntime({ surfacePort: 0 }); + try { + for (const path of ['/', '/index.html', '/unknown-route']) { + const response = await fetch(new URL(path, runtime.surfaceUrl), { + headers: { origin: 'null', accept: 'text/html' }, + }); + expect(response.headers.get('access-control-allow-origin')).toBeNull(); + } + const module = await fetch(new URL('/src/main.tsx', runtime.surfaceUrl), { + headers: { origin: 'null' }, + }); + expect(module.headers.get('access-control-allow-origin')).toBe('null'); + expect(await module.text()).not.toContain(runtime.sessionUrl.pathname); + const html = await runtime.loadEmbeddedDocument(); + expect(html).toContain(runtime.sessionUrl.href); + expect(html).toContain(' { + if (request.headers.origin === 'null') { + const original = response.writeHead; + response.writeHead = function ( + statusCode: number, + statusMessageOrHeaders?: + string | OutgoingHttpHeaders | OutgoingHttpHeader[], + extraHeaders?: OutgoingHttpHeaders | OutgoingHttpHeader[], + ) { + const headers = + typeof statusMessageOrHeaders === 'string' + ? extraHeaders + : statusMessageOrHeaders; + const args = + typeof statusMessageOrHeaders === 'string' + ? [statusCode, statusMessageOrHeaders, extraHeaders] + : [statusCode, headers]; + const explicitType = + headers !== undefined && + typeof headers === 'object' && + !Array.isArray(headers) + ? (headers['content-type'] ?? headers['Content-Type']) + : undefined; + const type = Array.isArray(headers) + ? '' + : String( + explicitType ?? response.getHeader('content-type') ?? '', + ).split(';')[0]; + if ( + type === 'text/javascript' || + type === 'application/javascript' || + type === 'text/css' + ) { + response.setHeader('access-control-allow-origin', 'null'); + response.setHeader('vary', 'Origin'); + } + return Reflect.apply(original, response, args); + }; + } + next(); + }); + }, + }; +} diff --git a/apps/launcher/src/standalone-runtime.ts b/apps/launcher/src/standalone-runtime.ts index ff4c1eb..a042ec4 100644 --- a/apps/launcher/src/standalone-runtime.ts +++ b/apps/launcher/src/standalone-runtime.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { lstat, realpath } from 'node:fs/promises'; +import { lstat, realpath, readFile } from 'node:fs/promises'; import type { Server } from 'node:http'; import { isAbsolute, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -23,6 +23,7 @@ import { startLoopbackServer, type LoopbackServer } from '@codex-git/server'; import { StandaloneHostAdapter } from '@codex-git/host-adapter-standalone'; import { createServer as createViteServer, type ViteDevServer } from 'vite'; +import { embeddedAssetsPlugin } from './embedded-assets.js'; import { protocolBootstrapPlugin } from './protocol-bootstrap.js'; import { toProtocolRepositorySnapshot } from './repository-protocol-adapter.js'; @@ -43,6 +44,7 @@ export interface StandaloneRuntime { readonly healthUrl: URL; readonly sessionUrl: URL; readonly surfaceUrl: URL; + loadEmbeddedDocument(): Promise; close(): Promise; } @@ -119,12 +121,14 @@ export async function startStandaloneRuntime( surfaceServer = await createViteServer({ configFile: uiConfigPath, plugins: [ + embeddedAssetsPlugin(), protocolBootstrapPlugin(protocolServer.sessionUrl, options.projectPath), ], server: { host: loopbackHost, port: options.surfacePort ?? 5173, strictPort: true, + cors: false, }, }); await surfaceServer.listen(); @@ -143,6 +147,18 @@ export async function startStandaloneRuntime( healthUrl: protocolServer.healthUrl, sessionUrl: protocolServer.sessionUrl, surfaceUrl, + async loadEmbeddedDocument() { + if (closed) throw new Error('The surface is closed.'); + const source = await readFile( + new URL('../../ui/index.html', import.meta.url), + 'utf8', + ); + const html = await surfaceServer!.transformIndexHtml( + surfaceUrl.href, + source, + ); + return html.replace('', ``); + }, async close() { if (closed) { return; diff --git a/docs/host-integration/codex-compatibility.md b/docs/host-integration/codex-compatibility.md index 7960ae0..2181c23 100644 --- a/docs/host-integration/codex-compatibility.md +++ b/docs/host-integration/codex-compatibility.md @@ -19,12 +19,19 @@ source must provide a non-empty stable target ID and the exact `codex-git-dedicated` ownership proof before the compatibility probe can mutate the document. -Codex Desktop's Content Security Policy does not allow the loopback Git Surface -as a frame. The dedicated renderer therefore grants a generation-scoped -`Page.setBypassCSP` lease before mounting. The lease is released after any -replacement, failed attachment, or connection close. Never grant this lease to -a normal user-owned Codex window: bypassing CSP expands the effect of any script -already executing in that renderer. +Codex Desktop's Content Security Policy blocks direct loopback frame navigation. +Build `7982` uses a sandboxed `about:blank` frame instead. The launcher supplies +HTML directly from its own Vite transform pipeline, including its protocol +bootstrap. CDP writes that document into the identified blank child frame using +`Page.setDocumentContent`; the renderer never supplies a document URL or HTML. +Static module requests permit the frame's opaque `null` Origin. Git protocol +requests still require the instance token and protocol version. + +A generation-scoped `Page.setBypassCSP` lease remains limited to the dedicated +renderer. A nonce and frame-source check acknowledge document readiness; missing +readiness, navigation away from the blank frame, or document loading failure +causes standalone fallback. The lease is released on close or failed attachment. +Never grant this lease to a normal user-owned Codex window. CDP has no application-level authentication in this design. Treat access to the dedicated loopback debugging endpoint as trusted local-process authority. Do not @@ -33,9 +40,10 @@ endpoint, or record it in ordinary logs. ## Tested profile -| Codex Desktop | Chromium framework | Required anchors | Evidence | -| ----------------------------- | ------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------ | -| `26.820.60940` (build `7119`) | `151.0.7922.170` | `#app-shell-sidebar`; `[data-app-shell-main-surface="default"]` | Installed renderer bundle inspection plus automated DOM fixture coverage | +| Codex Desktop | Chromium framework | Frame loading | Evidence | +| ----------------------------- | ------------------ | -------------------------------------------- | ---------------------------------------------------------------------------------- | +| `26.901.41600` (build `7982`) | `152.0.7977.64` | Launcher document into sandboxed blank frame | Automated tests and local adapter smoke; VoiceOver/keyboard release record pending | +| `26.820.60940` (build `7119`) | `151.0.7922.170` | Legacy direct frame navigation | Historical fixture and renderer inspection; not the current release reference | The automated exact-profile matrix covers read-only probing, fail-closed fallback, transactional attachment, one-entry mounting, native navigation, repeat @@ -49,13 +57,14 @@ matrix; do not widen selectors to make an unknown build appear compatible. ## Manual smoke matrix -This matrix passed on 2026-08-29 against a disposable dedicated profile using -Codex Desktop `26.820.60940` (build `7119`) and Chromium `151.0.7922.170`. -Builds `6962` and `7377` are intentionally excluded: live validation showed -their `app://` Content Security Policy blocks the loopback Git Surface frame -even after the scoped CDP bypass command. Build `7377` also reports Chromium -`151.0.7922.174`, outside the tested framework version. Unsupported builds must -use the standalone surface. +The release reference is build `7982`. Automated local smoke confirms a visible +Git entry, document loading, Stage/Unstage against a disposable Index, repeated +opening, renderer reload, and native restoration. The durable human release +checks remain pending until the VoiceOver wizard is completed. + +Builds `6962` and `7377` remain excluded. Direct HTTP iframe navigation also failed +on `7982`; that failure led to the document-loading path rather than removal of +sandbox restrictions. See the [probe and integration record](../release/evidence/build-7982-compatibility.md). The human portion of the matrix is intentionally limited to behavior that requires an actual Codex/macOS session. The release wizard records these checks: diff --git a/docs/release/evidence/build-7982-compatibility.md b/docs/release/evidence/build-7982-compatibility.md new file mode 100644 index 0000000..7a6abf9 --- /dev/null +++ b/docs/release/evidence/build-7982-compatibility.md @@ -0,0 +1,91 @@ +# Build 7982 compatibility probe + +- Direct HTTP iframe navigation: failed. +- Blank-frame document injection: full built UI prototype passed (see below). +- Production adapter document-loading path: implemented; manual release acceptance remains pending. +- Performed: 2026-09-06 (Asia/Shanghai). +- Source commit: `07c30c0813c5e4ec5e3de5f863015b2c181c5b40`. +- Environment: macOS 26.6.2, Apple M4, Node 24.19.0. +- Application: 26.901.41600 (build 7982). +- CDP browser product: `Chrome/152.0.7977.64`. + +## Procedure and observations + +1. Launch the installed application through `launchDedicatedCodexInstance`, + using a disposable profile and its owned loopback CDP target. +2. Wait for the renderer to initialize. Confirm one `#app-shell-sidebar` and + one `[data-app-shell-main-surface="default"]` anchor. +3. Serve a minimal HTML page from an ephemeral listener on `127.0.0.1`. The page + posts a load acknowledgement to its parent. +4. Enable `Network` events and `Page.setBypassCSP` on the dedicated target. +5. Append an iframe with `sandbox="allow-scripts"` pointing to the probe page. +6. Observe `Network.loadingFailed` for the Document request with + `errorText: net::ERR_BLOCKED_BY_CSP`. No load acknowledgement arrives during + the three-second observation interval. +7. Remove the probe iframe, disable the CSP bypass, close the listener and CDP + session, and close the dedicated instance and disposable profile. + +This probe does not establish Git product behavior, native-navigation recovery, +or accessibility conformance. Both required manual release checks remain +pending. Do not add this build to the supported profile or mark AC-24 passed on +the strength of DOM anchors alone. + +## Alternative document-loading prototype + +A subsequent probe used a sandboxed `about:blank` iframe and +`Page.setDocumentContent`, inspired by dashi-taskboard commit +`08f0419cc3cb1b14f09578806f6bcac53b067414`. No third-party injector was executed. +The original direct-navigation failure does not establish that all embedding +techniques fail on this build. + +The prototype served this repository's existing built JavaScript and CSS from an +owned ephemeral loopback listener permitting the opaque `null` origin. It loaded +the built HTML in the local process, added a loopback base URL and the existing +protocol bootstrap, and wrote the document into the named blank frame. It used +`sandbox="allow-scripts"`, without `allow-same-origin`, and retained the existing +token-protected protocol server. + +Observed on the same build and machine: + +- The complete React Git interface rendered and displayed an actual disposable + repository snapshot. +- An externally created file appeared through the existing invalidation/SSE path. +- Stage and Unstage invoked from the embedded UI updated and cleared the actual + disposable repository Index, checked with `git diff --cached --name-only`. +- After a renderer reload, the probe explicitly recreated the frame and wrote the + document again; the UI loaded the updated repository snapshot. +- Explicit teardown removed the frame and restored the native main surface. + +These are prototype observations, not production adapter or manual acceptance +results. The probe used built assets and a temporary asset listener, not the +launcher's Vite development surface. Reload recovery was driven by the probe; +production lifecycle automation, exact host/project binding, frame readiness, +authenticated document delivery, failure cleanup, layout, and VoiceOver/keyboard +acceptance still require implementation and verification. + +## Production adapter integration + +The adapter now recognizes the exact build and Chromium pair, inserts a Git +button before the native Explore group, and uses a launcher-owned HTML callback. +Unlike the earlier prototype, this path uses the existing Vite surface and +protocol bootstrap directly, with an explicit opaque-origin asset CORS policy. +The current frame name, blank URL, and per-load nonce constrain document delivery. +The legacy in-process DOM adapter rejects this profile because it has no CDP +document loader. + +The source-mode CI reference and repeatable manual wizard now target build 7982. +No manual check has been marked passed and no standalone-only scope waiver was +applied. The historical direct-navigation failure is retained above to explain +the implementation choice. + +On the final local adapter smoke, the Git entry appeared in the native navigation +area (one entry), repeat opening left one frame, and renderer reload automatically +restored one entry and one frame with document readiness confirmed. Native +navigation removed the Git surface and restored the main surface's original +visibility. The smoke process exited successfully after adapter cleanup. + +Reload readiness is deduplicated so repeated execution-context events cannot +rewrite an already initialized module document. The remount grace period is +15 seconds to accommodate the actual renderer startup. Asset CORS is restricted +by response media type to JavaScript/CSS; bootstrap and fallback HTML have no +opaque-origin CORS permission. A loopback regression test enforces that boundary. diff --git a/docs/release/mvp-release-gate.md b/docs/release/mvp-release-gate.md index 8f81405..f88f94b 100644 --- a/docs/release/mvp-release-gate.md +++ b/docs/release/mvp-release-gate.md @@ -59,11 +59,11 @@ External-change timing starts before the filesystem write and ends only after SSE-triggered refresh makes the new Changed File visible in the DOM. The approved CI reference profile is `github-actions-macos-15`, paired with the -tested Codex compatibility profile `26.820.60940 (build 7119)`. The JSON and +tested Codex compatibility profile `26.901.41600 (build 7982)`. The JSON and Markdown artifacts record the actual CPU, architecture, memory, macOS, Git, Node.js, reference profile, and Codex compatibility version. A local release run must explicitly set `CODEX_GIT_REFERENCE_PROFILE=local-macos-release` and -`CODEX_DESKTOP_VERSION='26.820.60940 (build 7119)'`; an unapproved platform, +`CODEX_DESKTOP_VERSION='26.901.41600 (build 7982)'`; an unapproved platform, profile, or missing/mismatched Codex version fails AC-24. ## VoiceOver and keyboard smoke record diff --git a/packages/host-adapter/codex-cdp/src/compatibility-profile.test.ts b/packages/host-adapter/codex-cdp/src/compatibility-profile.test.ts index b317072..cdfa997 100644 --- a/packages/host-adapter/codex-cdp/src/compatibility-profile.test.ts +++ b/packages/host-adapter/codex-cdp/src/compatibility-profile.test.ts @@ -3,6 +3,14 @@ import { describe, expect, it } from 'vitest'; import { findCodexCompatibilityProfile } from './compatibility-profile.js'; describe('Codex compatibility profiles', () => { + it('requires document injection for the exact verified build 7982', () => { + expect(findCodexCompatibilityProfile('26.901.41600', '7982')).toMatchObject( + { documentInjection: true, chromiumProduct: 'Chrome/152.0.7977.64' }, + ); + expect(findCodexCompatibilityProfile('26.901.41600', '7119')).toBeNull(); + expect(findCodexCompatibilityProfile('26.820.60940', '7982')).toBeNull(); + }); + it('fails closed for build 7377 after live CSP validation failed', () => { expect(findCodexCompatibilityProfile('26.825.51511', '7377')).toBeNull(); expect(findCodexCompatibilityProfile('26.825.51511', '7119')).toBeNull(); diff --git a/packages/host-adapter/codex-cdp/src/compatibility-profile.ts b/packages/host-adapter/codex-cdp/src/compatibility-profile.ts index f13ab77..752288b 100644 --- a/packages/host-adapter/codex-cdp/src/compatibility-profile.ts +++ b/packages/host-adapter/codex-cdp/src/compatibility-profile.ts @@ -1,4 +1,5 @@ export interface CodexCompatibilityProfile { + readonly documentInjection?: boolean; readonly build: string; readonly chromiumProduct: string; readonly entryInsertionSelector: string | null; @@ -9,6 +10,17 @@ export interface CodexCompatibilityProfile { } const profiles = [ + { + build: '7982', + version: '26.901.41600', + chromiumProduct: 'Chrome/152.0.7977.64', + documentInjection: true, + entryInsertionSelector: + 'div.flex-col:has(> button.sidebar-item[aria-haspopup="menu"])', + mainSurfaceSelector: '[data-app-shell-main-surface="default"]', + nativeEntrySelector: 'button.sidebar-item[aria-haspopup="menu"]', + sidebarSelector: '#app-shell-sidebar', + }, { build: '7119', chromiumProduct: 'Chrome/151.0.7922.170', diff --git a/packages/host-adapter/codex-cdp/src/compatibility.ts b/packages/host-adapter/codex-cdp/src/compatibility.ts index 52239e0..76d70ef 100644 --- a/packages/host-adapter/codex-cdp/src/compatibility.ts +++ b/packages/host-adapter/codex-cdp/src/compatibility.ts @@ -17,6 +17,7 @@ export function findCompatibleCodexAnchors( ); if ( profile === null || + profile.documentInjection === true || renderer.ownership !== 'codex-git-dedicated' || renderer.id.length === 0 ) { diff --git a/packages/host-adapter/codex-cdp/src/frame-document.test.ts b/packages/host-adapter/codex-cdp/src/frame-document.test.ts new file mode 100644 index 0000000..72199cb --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/frame-document.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest'; +import { loadFrameDocument } from './frame-document.js'; +import type { CdpSession } from './cdp-session.js'; + +const name = 'codex-git-12345678-1234-1234-1234-123456789abc'; +function fixture(url = 'about:blank') { + let current: string | null = name; + let loaded = false; + const send = vi.fn( + async (method: string, params?: unknown): Promise => { + const expression = (params as { expression?: string })?.expression ?? ''; + if (expression.includes('frameName()')) + return { result: { value: current } }; + if (expression.includes('documentReady(')) + return { result: { value: loaded } }; + if (method === 'Page.setDocumentContent') loaded = true; + if (method === 'Page.getFrameTree') + return { + frameTree: { + childFrames: [{ frame: { id: 'owned-frame', name, url } }], + }, + }; + return {}; + }, + ); + const session: CdpSession = { + send, + subscribe: () => () => undefined, + close: async () => undefined, + }; + return { + session, + send, + remove: () => { + current = null; + }, + }; +} + +describe('owned embedded document delivery', () => { + it('writes only the launcher document into the identified blank frame and waits for readiness', async () => { + const { session, send } = fixture(); + await loadFrameDocument( + session, + async () => + '
', + () => false, + ); + expect(send).toHaveBeenCalledWith('Page.setDocumentContent', { + frameId: 'owned-frame', + html: expect.stringContaining('codex-git:document-ready'), + }); + expect(send).toHaveBeenCalledWith( + 'Runtime.evaluate', + expect.objectContaining({ + expression: expect.stringContaining('documentReady('), + }), + ); + }); + it('does not overwrite an already loaded frame when reload events repeat', async () => { + const { session, send } = fixture(); + const load = async () => ''; + await loadFrameDocument(session, load, () => false); + await loadFrameDocument(session, load, () => false); + expect( + send.mock.calls.filter( + ([method]) => method === 'Page.setDocumentContent', + ), + ).toHaveLength(1); + }); + + it('never injects into a frame that navigated away from the blank document', async () => { + const { session, send } = fixture('https://untrusted.example'); + await expect( + loadFrameDocument( + session, + async () => '', + () => false, + ), + ).rejects.toThrow('navigated'); + expect( + send.mock.calls.some(([method]) => method === 'Page.setDocumentContent'), + ).toBe(false); + }); + it('cancels delivery when navigation removes the frame while HTML is loading', async () => { + const { session, send, remove } = fixture(); + await loadFrameDocument( + session, + async () => { + remove(); + return ''; + }, + () => false, + ); + expect( + send.mock.calls.some(([method]) => method === 'Page.setDocumentContent'), + ).toBe(false); + }); + it('propagates launcher failures without writing a document', async () => { + const { session, send } = fixture(); + await expect( + loadFrameDocument( + session, + async () => { + throw new Error('closed launcher'); + }, + () => false, + ), + ).rejects.toThrow('closed launcher'); + expect( + send.mock.calls.some(([method]) => method === 'Page.setDocumentContent'), + ).toBe(false); + }); +}); diff --git a/packages/host-adapter/codex-cdp/src/frame-document.ts b/packages/host-adapter/codex-cdp/src/frame-document.ts new file mode 100644 index 0000000..49e9357 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/frame-document.ts @@ -0,0 +1,72 @@ +import { randomUUID } from 'node:crypto'; + +import type { CdpSession } from './cdp-session.js'; + +// HTML comes directly from the launcher-owned renderer, never from a frame URL. +export async function loadFrameDocument( + session: CdpSession, + loadDocument: () => Promise, + isClosed: () => boolean, +): Promise { + const readFrame = async () => { + const response = await session.send('Runtime.evaluate', { + expression: 'globalThis.__codexGitBridge?.frameName() ?? null', + returnByValue: true, + }); + return (response as { result?: { value?: unknown } }).result?.value; + }; + const name = await readFrame(); + if (name === null || name === undefined || isClosed()) return; + if (typeof name !== 'string' || !/^codex-git-[a-f0-9-]{36}$/u.test(name)) { + throw new Error('Invalid embedded frame identity'); + } + const readiness = (await session.send('Runtime.evaluate', { + expression: `globalThis.__codexGitBridge?.documentReady(${JSON.stringify(name)})`, + returnByValue: true, + })) as { result?: { value?: unknown } }; + if (readiness.result?.value === true) return; + const html = await loadDocument(); + if (!html.includes('') || html.length > 4 * 1024 * 1024) { + throw new Error('Invalid embedded document'); + } + const nonce = randomUUID(); + const readyScript = ``; + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (isClosed() || (await readFrame()) !== name) return; + const tree = (await session.send('Page.getFrameTree')) as { + frameTree?: { + childFrames?: Array<{ + frame: { id: string; name?: string; url?: string }; + }>; + }; + }; + const frame = tree.frameTree?.childFrames?.find( + (child) => child.frame.name === name, + )?.frame; + if (frame !== undefined) { + if (frame.url !== 'about:blank') + throw new Error('Embedded frame navigated unexpectedly'); + if (isClosed() || (await readFrame()) !== name) return; + await session.send('Runtime.evaluate', { + expression: `globalThis.__codexGitBridge?.expectDocument(${JSON.stringify(name)},${JSON.stringify(nonce)})`, + }); + await session.send('Page.setDocumentContent', { + frameId: frame.id, + html: html.replace('', `${readyScript}`), + }); + while (Date.now() < deadline) { + if (isClosed() || (await readFrame()) !== name) return; + const response = (await session.send('Runtime.evaluate', { + expression: `globalThis.__codexGitBridge?.documentReady(${JSON.stringify(name)})`, + returnByValue: true, + })) as { result?: { value?: unknown } }; + if (response.result?.value === true) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('Embedded Git document did not become ready'); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('Embedded Git frame did not appear'); +} diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts index b3c30bb..3181a7e 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts @@ -84,6 +84,17 @@ describe('dedicated Codex remote renderer', () => { expect(session.closed).toBe(true); }); + it('rejects document injection without a launcher-owned loader before CDP', async () => { + const session = new FixtureCdpSession({ status: 'attached' }); + await expect( + connectDedicatedCodexRenderer( + { ...request, build: '7982', version: '26.901.41600' }, + { connect: async () => session }, + ), + ).rejects.toThrow('document loader'); + expect(session.commands).toEqual([]); + }); + it('rejects an unverified Chromium build before changing CSP', async () => { const session = new FixtureCdpSession( { status: 'not-ready' }, @@ -100,21 +111,24 @@ describe('dedicated Codex remote renderer', () => { ]); }); - it('rejects build 6962 before CDP because its live CSP blocks the surface frame', async () => { - const session = new FixtureCdpSession({ - context: expectedContext, - project: { id: 'project-42', label: 'codex-git' }, - status: 'attached', - }); + it.each([{ build: '6962', version: '26.818.41509' }])( + 'rejects build $build before CDP because its live CSP blocks the surface frame', + async (profile) => { + const session = new FixtureCdpSession({ + context: expectedContext, + project: { id: 'project-42', label: 'codex-git' }, + status: 'attached', + }); - await expect( - connectDedicatedCodexRenderer( - { ...request, build: '6962', version: '26.818.41509' }, - { connect: async () => session }, - ), - ).rejects.toThrow('Unsupported Codex Desktop version'); - expect(session.commands).toEqual([]); - }); + await expect( + connectDedicatedCodexRenderer( + { ...request, ...profile }, + { connect: async () => session }, + ), + ).rejects.toThrow('Unsupported Codex Desktop version'); + expect(session.commands).toEqual([]); + }, + ); it('rejects a version and build from different tested profiles before CDP', async () => { const session = new FixtureCdpSession({ status: 'attached' }); diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.ts index 95db7a2..2d8bacf 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { loadFrameDocument } from './frame-document.js'; import type { HostContext, @@ -25,6 +26,7 @@ import type { import type { CspBypassLease } from './renderer.js'; export interface ConnectDedicatedCodexRendererOptions { + readonly loadDocument?: () => Promise; readonly connect?: (url: string) => Promise; readonly createBindingName?: () => string; readonly wait?: (milliseconds: number) => Promise; @@ -38,6 +40,9 @@ export async function connectDedicatedCodexRenderer( if (profile === null) { throw new Error('Unsupported Codex Desktop version'); } + if (profile.documentInjection && options.loadDocument === undefined) { + throw new Error('The embedded document loader is unavailable'); + } const session = await (options.connect ?? connectCdpSession)( request.target.webSocketUrl, ); @@ -83,6 +88,7 @@ export async function connectDedicatedCodexRenderer( profile, bindingName, installation, + options.loadDocument, ); lease = null; return connection; @@ -124,11 +130,13 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { private readonly profile: CodexCompatibilityProfile, private readonly bindingName: string, installation: AttachedInstallation, + private readonly loadDocument?: () => Promise, ) { this.context = installation.context; this.open = installation.open; this.project = installation.project; this.unsubscribe = session.subscribe(this.handleCdpEvent); + if (this.open) this.queueDocument(); } currentContext(): HostContext { @@ -189,6 +197,7 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { this.listeners.forEach((listener) => listener(message)); } else if (message?.kind === 'surface') { this.open = message.open; + if (this.open) this.queueDocument(); } else if (message?.kind === 'standalone-required') { this.listeners.forEach((listener) => listener(message)); } @@ -206,6 +215,26 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { } }; + private queueDocument(): void { + if (!this.profile.documentInjection || this.loadDocument === undefined) + return; + this.refresh = this.refresh.then(async () => { + if (this.closed) return; + try { + await loadFrameDocument( + this.session, + this.loadDocument!, + () => this.closed, + ); + } catch { + if (!this.closed) + this.listeners.forEach((listener) => + listener({ kind: 'standalone-required' }), + ); + } + }); + } + private async reinstall(reopen: boolean): Promise { if (this.closed) { return; @@ -215,7 +244,7 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { expectedProject: this.project, openSurface: reopen, }; - for (let attempt = 0; attempt < 20; attempt++) { + for (let attempt = 0; attempt < 150; attempt++) { try { await this.session.send('Page.setBypassCSP', { enabled: true }); const installation = await install( @@ -228,6 +257,7 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { if (installation.status === 'attached') { this.context = installation.context; this.open = installation.open; + if (this.open) this.queueDocument(); this.listeners.forEach((listener) => listener({ kind: 'context', context: this.context }), ); @@ -271,6 +301,7 @@ async function install( ): Promise { const input: BridgeInput = { bindingName, + documentInjection: profile.documentInjection === true, entryInsertionSelector: profile.entryInsertionSelector, expectedProject: request.expectedProject, generation, @@ -419,6 +450,7 @@ function wait(milliseconds: number): Promise { } interface BridgeInput { + readonly documentInjection: boolean; readonly bindingName: string; readonly entryInsertionSelector: string | null; readonly expectedProject: DedicatedProjectIdentity | null; @@ -435,7 +467,7 @@ interface BridgeInput { // Kept self-contained because CDP serializes this function into the renderer. // prettier-ignore function installDomBridge(input: BridgeInput): unknown { - const root = globalThis as typeof globalThis & { __codexGitBridge?: { close(): void; restore(): void } }; + const root = globalThis as typeof globalThis & { __codexGitBridge?: { close(): void; restore(): void; frameName(): string | null; expectDocument(name: string, nonce: string): void; documentReady(name: string): boolean } }; root.__codexGitBridge?.close(); const sidebars = document.querySelectorAll(input.sidebarSelector); const mainSurfaces = document.querySelectorAll(input.mainSurfaceSelector); @@ -480,6 +512,8 @@ function installDomBridge(input: BridgeInput): unknown { let host: HTMLElement | null = null; let frame: HTMLIFrameElement | null = null; let capability = '', challenge = '', lastContext = ''; + let documentNonce = '', documentLoaded = false; + const originalMainHidden = main.hidden; const context = () => { const taskRow = document.querySelector('[data-app-action-sidebar-thread-row][data-app-action-sidebar-thread-selected="true"], [data-app-action-sidebar-thread-row][aria-current="page"]'); const task = @@ -505,7 +539,7 @@ function installDomBridge(input: BridgeInput): unknown { }, '*'); }; const restore = () => { - frame = null; host?.remove(); host = null; main.hidden = false; + frame = null; documentNonce = ''; documentLoaded = false; host?.remove(); host = null; main.hidden = originalMainHidden; entry.removeAttribute('aria-current'); notify({ kind: 'surface', open: false }); }; @@ -516,7 +550,8 @@ function installDomBridge(input: BridgeInput): unknown { host.setAttribute('aria-label', input.surfaceTitle); host.style.cssText = 'display:flex;flex:1 1 auto;min-height:0;min-width:0;overflow:hidden'; frame = document.createElement('iframe'); - frame.src = input.surfaceUrl; frame.title = input.surfaceTitle; + frame.name = `codex-git-${secret()}`; + frame.src = input.documentInjection ? 'about:blank' : input.surfaceUrl; frame.title = input.surfaceTitle; frame.setAttribute('sandbox', 'allow-scripts'); Object.assign(frame.style, { border: '0', height: '100%', width: '100%' }); capability = secret(); challenge = secret(); @@ -535,6 +570,7 @@ function installDomBridge(input: BridgeInput): unknown { if (frame === null || event.source !== frame.contentWindow || typeof value !== 'object' || value === null) return; const message = value as Record; + if (message.type === 'codex-git:document-ready' && documentNonce !== '' && message.nonce === documentNonce) { documentLoaded = true; publishContext(); return; } const action = message.action; if (message.type === 'codex-git:host-action' && message.capability === capability && message.challenge === challenge && message.generation === input.generation && @@ -559,7 +595,11 @@ function installDomBridge(input: BridgeInput): unknown { (entryHost ?? entry).remove(); delete root.__codexGitBridge; }; - root.__codexGitBridge = { close, restore }; + root.__codexGitBridge = { close, restore, + frameName: () => frame?.name ?? null, + expectDocument: (name, nonce) => { if (frame?.name === name) { documentNonce = nonce; documentLoaded = false; } }, + documentReady: (name) => frame?.name === name && documentLoaded, + }; entry.addEventListener('click', open); sidebar.addEventListener('click', handleSidebar, true); globalThis.addEventListener('message', handleMessage); diff --git a/scripts/voiceover-release-wizard.sh b/scripts/voiceover-release-wizard.sh index 9972d6c..44789fc 100755 --- a/scripts/voiceover-release-wizard.sh +++ b/scripts/voiceover-release-wizard.sh @@ -189,7 +189,7 @@ TOTAL_STAGES=4 REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) RECORD_PATH="$REPO_ROOT/docs/release/evidence/issue-16-manual-accessibility.md" EVIDENCE_PATH="$REPO_ROOT/docs/release/manual-evidence.json" -SUPPORTED_CODEX_VERSION="26.820.60940 (build 7119)" +SUPPORTED_CODEX_VERSION="26.901.41600 (build 7982)" require_confirm() { if ! confirm "$1"; then @@ -208,7 +208,7 @@ open_url "x-apple.systempreferences:com.apple.Accessibility-Settings.extension" step "Open VoiceOver settings, then VoiceOver Utility → About, and note its displayed version." ask VOICEOVER_VERSION "VoiceOver / VoiceOver Utility version:" step "In Codex, open About Codex and copy the version including its build number." -ask CODEX_VERSION "Codex Desktop version (for example 26.820.60940 (build 7119)):" +ask CODEX_VERSION "Codex Desktop version (for example 26.901.41600 (build 7982)):" step "Record the Chromium/WebView version from the tested Codex compatibility profile." ask WEBVIEW_VERSION "Chromium/WebView version:" if [[ "$CODEX_VERSION" != "$SUPPORTED_CODEX_VERSION" ]]; then diff --git a/tests/release/release-artifacts.test.ts b/tests/release/release-artifacts.test.ts index 308c680..034e051 100644 --- a/tests/release/release-artifacts.test.ts +++ b/tests/release/release-artifacts.test.ts @@ -109,7 +109,7 @@ function fixtureReport(): ReleaseReport { manualEvidence: { checks: [ { - codexVersion: '26.820.60940 (build 7119)', + codexVersion: '26.901.41600 (build 7982)', environment: 'Codex Desktop 26.820.60940', id: 'codex-host-smoke', performedAt: '2026-08-29T00:00:00.000Z', diff --git a/tests/release/release-environment.test.ts b/tests/release/release-environment.test.ts index 47508b1..4ad1589 100644 --- a/tests/release/release-environment.test.ts +++ b/tests/release/release-environment.test.ts @@ -12,7 +12,7 @@ describe('release environment evidence', () => { it('records the required hardware, macOS, Git, Node, and Codex versions', async () => { const environment = await collectReleaseEnvironment({ architecture: 'arm64', - codexVersion: '26.820.60940 (build 7119)', + codexVersion: '26.901.41600 (build 7982)', cpu: 'Apple Test CPU', memoryBytes: 16 * 1_024 ** 3, nodeVersion: 'v22.12.0', @@ -30,7 +30,7 @@ describe('release environment evidence', () => { expect(environment).toEqual({ architecture: 'arm64', - codex: '26.820.60940 (build 7119)', + codex: '26.901.41600 (build 7982)', cpu: 'Apple Test CPU', git: 'git version 2.50.1', memoryBytes: 16 * 1_024 ** 3, diff --git a/tests/release/release-environment.ts b/tests/release/release-environment.ts index ddf6fed..d768683 100644 --- a/tests/release/release-environment.ts +++ b/tests/release/release-environment.ts @@ -40,7 +40,7 @@ export async function collectReleaseEnvironment( } export const SUPPORTED_CODEX_RELEASE_VERSION = - '26.820.60940 (build 7119)' as const; + '26.901.41600 (build 7982)' as const; export function validateReleaseEnvironment( environment: ReleaseEnvironment, diff --git a/tests/release/release-report.test.ts b/tests/release/release-report.test.ts index b10bc68..e32791b 100644 --- a/tests/release/release-report.test.ts +++ b/tests/release/release-report.test.ts @@ -11,7 +11,7 @@ import type { ManualEvidenceRecord } from './manual-evidence.js'; const environment: ReleaseEnvironment = { architecture: 'arm64', - codex: '26.820.60940 (build 7119)', + codex: '26.901.41600 (build 7982)', cpu: 'Test CPU', git: 'git version 2.50.1', memoryBytes: 16 * 1_024 ** 3, @@ -40,8 +40,8 @@ const performance: ReferenceBenchmarkResult = { const manualEvidence: ManualEvidenceRecord = { checks: [ { - codexVersion: '26.820.60940 (build 7119)', - environment: 'Codex Desktop 26.820.60940 (build 7119)', + codexVersion: '26.901.41600 (build 7982)', + environment: 'Codex Desktop 26.901.41600 (build 7982)', id: 'codex-host-smoke', performedAt: '2026-08-29T00:00:00.000Z', record: @@ -51,9 +51,9 @@ const manualEvidence: ManualEvidenceRecord = { validUntil: '2026-09-29T00:00:00.000Z', }, { - codexVersion: '26.820.60940 (build 7119)', + codexVersion: '26.901.41600 (build 7982)', environment: - 'macOS 15.6; VoiceOver 10; Codex Desktop 26.820.60940 (build 7119)', + 'macOS 15.6; VoiceOver 10; Codex Desktop 26.901.41600 (build 7982)', id: 'voiceover-keyboard-smoke', performedAt: '2026-09-01T00:00:00.000Z', record: @@ -83,7 +83,7 @@ describe('release evidence report', () => { expect(report.markdown).toContain( '| AC-24 | Pass the supported release envelope | passed |', ); - expect(report.markdown).toContain('Codex | 26.820.60940 (build 7119)'); + expect(report.markdown).toContain('Codex | 26.901.41600 (build 7982)'); expect(report.markdown).toContain( '| Full snapshot | 900 ms | 5000 ms | passed |', ); From 1d2d3b044c807f34e3e799267f0825f52961a6d4 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 6 Sep 2026 00:55:48 +0800 Subject: [PATCH 11/12] fix: preserve embedded Git across native tab navigation (#16) Native task pages clear the selected project marker without invalidating the renderer. Preserve the bound repository entry, suppress unproven native context, and revalidate the existing project row on reload. Keep the dedicated window alive after safe fallback, while propagating native cleanup failures before deciding to retain it. --- apps/launcher/src/codex-runtime.ts | 10 +- .../codex-cdp/src/remote-renderer.test.ts | 94 ++++++++++++++++++- .../codex-cdp/src/remote-renderer.ts | 54 ++++++++--- tests/e2e/codex-runtime.e2e.test.ts | 18 ++++ 4 files changed, 159 insertions(+), 17 deletions(-) diff --git a/apps/launcher/src/codex-runtime.ts b/apps/launcher/src/codex-runtime.ts index 305c548..8bd1e06 100644 --- a/apps/launcher/src/codex-runtime.ts +++ b/apps/launcher/src/codex-runtime.ts @@ -71,9 +71,13 @@ export async function startCodexRuntime( const attachedConnection = connection; const dedicatedInstance = instance; connection = null; - instance = null; - await attachedConnection?.close().catch(() => undefined); - await dedicatedInstance?.close(); + try { + await attachedConnection?.close(); + } catch { + // Terminate only when native state/CSP could not be restored safely. + instance = null; + await dedicatedInstance?.close(); + } }); } } catch { diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts index 3181a7e..4a5729b 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts @@ -1,5 +1,5 @@ import { JSDOM } from 'jsdom'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { HostContext } from '@codex-git/host-adapter'; @@ -95,6 +95,98 @@ describe('dedicated Codex remote renderer', () => { expect(session.commands).toEqual([]); }); + it('keeps the Git entry and connection when native navigation clears project selection', async () => { + const dom = new JSDOM( + `
`, + { runScripts: 'outside-only', url: 'https://codex.invalid' }, + ); + const session = new FixtureCdpSession({}, 'Chrome/151.0.7922.170', dom); + const binding = '__codexGitNotify_navigation'; + Object.assign(dom.window, { + [binding]: (payload: string) => + session.publish({ + method: 'Runtime.bindingCalled', + params: { name: binding, payload }, + }), + }); + const connection = await connectDedicatedCodexRenderer(request, { + connect: async () => session, + createBindingName: () => binding, + }); + const events: string[] = []; + connection.subscribe((event) => events.push(event.kind)); + dom.window.document + .querySelector('[data-app-action-sidebar-project-row]')! + .removeAttribute('aria-current'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(events).not.toContain('standalone-required'); + expect( + dom.window.document.querySelectorAll('[data-codex-git-sidebar-entry]'), + ).toHaveLength(1); + expect(connection.currentContext().task).toBeNull(); + expect(connection.currentContext().projectPath).toBeNull(); + expect( + await connection.perform({ + kind: 'open-codex-context', + targetId: 'unproven', + }), + ).toEqual({ status: 'rejected' }); + const contextsBefore = events.filter((kind) => kind === 'context').length; + session.publish({ method: 'Runtime.executionContextsCleared' }); + await vi.waitFor(() => + expect( + events.filter((kind) => kind === 'context').length, + ).toBeGreaterThan(contextsBefore), + ); + expect( + dom.window.document.querySelectorAll('[data-codex-git-sidebar-entry]'), + ).toHaveLength(1); + const projectRow = dom.window.document.querySelector( + '[data-app-action-sidebar-project-row]', + )!; + const entry = dom.window.document.querySelector( + '[data-codex-git-sidebar-entry]', + )!; + projectRow.setAttribute('aria-current', 'page'); + projectRow.setAttribute( + 'data-app-action-sidebar-project-id', + 'another-project', + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(entry.disabled).toBe(true); + expect(connection.currentContext().task).toBeNull(); + projectRow.setAttribute('data-app-action-sidebar-project-id', 'project-42'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(entry.disabled).toBe(false); + expect(events).not.toContain('standalone-required'); + await connection.close(); + dom.window.close(); + }); + + it('reports native cleanup failure after still releasing CSP', async () => { + const session = new FixtureCdpSession({ + context: expectedContext, + project: { id: 'project-42', label: 'codex-git' }, + status: 'attached', + }); + const connection = await connectDedicatedCodexRenderer(request, { + connect: async () => session, + }); + const send = session.send.bind(session); + session.send = async (method, params) => { + if (method === 'Runtime.evaluate') + return { exceptionDetails: { text: 'Native cleanup failed' } }; + return send(method, params); + }; + await expect(connection.close()).rejects.toThrow('native Codex surface'); + expect(session.commands).toContainEqual({ + method: 'Page.setBypassCSP', + params: { enabled: false }, + }); + session.send = send; + await connection.close(); + }); + it('rejects an unverified Chromium build before changing CSP', async () => { const session = new FixtureCdpSession( { status: 'not-ready' }, diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.ts index 2d8bacf..9082a2a 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.ts @@ -160,6 +160,12 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { if (this.closed) { return { status: 'rejected' }; } + if ( + action.kind === 'open-codex-context' && + this.context.projectPath !== this.request.projectPath + ) { + return { status: 'rejected' }; + } if ( action.kind === 'restore-native-surface' || action.kind === 'open-codex-context' @@ -281,13 +287,23 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { this.listeners.clear(); await this.refresh; } - await evaluate(this.session, 'globalThis.__codexGitBridge?.close()').catch( - () => undefined, - ); + let nativeCleanupFailed: boolean; + try { + const response = await evaluate( + this.session, + 'globalThis.__codexGitBridge?.close()', + ); + nativeCleanupFailed = + isRecord(response) && response.exceptionDetails !== undefined; + } catch { + nativeCleanupFailed = true; + } if (this.cspLease !== null) { await this.cspLease.release(); this.cspLease = null; } + if (nativeCleanupFailed) + throw new Error('The native Codex surface could not be restored'); await this.session.close(); } } @@ -402,7 +418,10 @@ function parseProject(value: unknown): DedicatedProjectIdentity | null { } function parseHostContext(value: unknown): HostContext | null { - if (!isRecord(value) || typeof value.projectPath !== 'string') { + if ( + !isRecord(value) || + (value.projectPath !== null && typeof value.projectPath !== 'string') + ) { return null; } if ( @@ -474,20 +493,22 @@ function installDomBridge(input: BridgeInput): unknown { const sidebar = sidebars.item(0); const main = mainSurfaces.item(0); const selectedProject = document.querySelector('[data-app-action-sidebar-project-row][aria-current="page"]'); + const boundRows = Array.from(document.querySelectorAll('[data-app-action-sidebar-project-row]')).filter((row) => row instanceof HTMLElement && row.dataset.appActionSidebarProjectId === input.expectedProject?.id && row.dataset.appActionSidebarProjectLabel === input.expectedProject?.label); + const verifiedProject = selectedProject ?? (input.expectedProject !== null && boundRows.length === 1 ? boundRows[0] : null); const nativeEntry = sidebar?.querySelector(input.nativeEntrySelector); const entryInsertionAnchors = input.entryInsertionSelector === null ? null : sidebar?.querySelectorAll(input.entryInsertionSelector); const entryInsertionAnchor = entryInsertionAnchors?.item(0) ?? null; if (sidebars.length !== 1 || mainSurfaces.length !== 1 || !(sidebar instanceof HTMLElement) || !(main instanceof HTMLElement) || - !(selectedProject instanceof HTMLElement) || + !(verifiedProject instanceof HTMLElement) || !(nativeEntry instanceof HTMLButtonElement) || (entryInsertionAnchors !== null && (entryInsertionAnchors.length !== 1 || !(entryInsertionAnchor instanceof HTMLElement)))) { return { status: 'not-ready' }; } - const project = { id: selectedProject.dataset.appActionSidebarProjectId ?? '', - label: selectedProject.dataset.appActionSidebarProjectLabel ?? '' }; + const project = { id: verifiedProject.dataset.appActionSidebarProjectId ?? '', + label: verifiedProject.dataset.appActionSidebarProjectLabel ?? '' }; if (project.id.length === 0 || project.label.length === 0) return { status: 'incompatible' }; if (input.expectedProject !== null && (project.id !== input.expectedProject.id || project.label !== input.expectedProject.label)) { @@ -515,9 +536,11 @@ function installDomBridge(input: BridgeInput): unknown { let documentNonce = '', documentLoaded = false; const originalMainHidden = main.hidden; const context = () => { + const selected = document.querySelector('[data-app-action-sidebar-project-row][aria-current="page"]'); + const projectMatches = selected instanceof HTMLElement && selected.dataset.appActionSidebarProjectId === project.id && selected.dataset.appActionSidebarProjectLabel === project.label; const taskRow = document.querySelector('[data-app-action-sidebar-thread-row][data-app-action-sidebar-thread-selected="true"], [data-app-action-sidebar-thread-row][aria-current="page"]'); const task = - taskRow instanceof HTMLElement && + projectMatches && taskRow instanceof HTMLElement && typeof taskRow.dataset.appActionSidebarThreadId === 'string' && typeof taskRow.dataset.appActionSidebarThreadTitle === 'string' ? { id: taskRow.dataset.appActionSidebarThreadId, @@ -525,7 +548,7 @@ function installDomBridge(input: BridgeInput): unknown { const classes = document.documentElement.classList; const theme = classes.contains('electron-dark') ? 'dark' : classes.contains('electron-light') ? 'light' : 'system'; - return { projectPath: input.projectPath, task, theme }; + return { projectPath: projectMatches ? input.projectPath : null, task, theme }; }; const publishContext = () => { const next = context(); @@ -544,6 +567,7 @@ function installDomBridge(input: BridgeInput): unknown { notify({ kind: 'surface', open: false }); }; const open = () => { + if (entry.disabled) return; restore(); host = document.createElement('main'); host.dataset.codexGitSurface = ''; @@ -580,13 +604,17 @@ function installDomBridge(input: BridgeInput): unknown { const observer = new MutationObserver(() => { const currentProject = document.querySelector('[data-app-action-sidebar-project-row][aria-current="page"]'); if (!sidebar.isConnected || !main.isConnected || !entry.isConnected || - (entryHost !== null && !entryHost.isConnected) || - !(currentProject instanceof HTMLElement) || - currentProject.dataset.appActionSidebarProjectId !== project.id || - currentProject.dataset.appActionSidebarProjectLabel !== project.label) { + (entryHost !== null && !entryHost.isConnected)) { notify({ kind: 'standalone-required' }); return; } + // Native task pages need not mark a project row as selected. Keep the + // launcher's repository binding, but never attribute an unproven task. + const differentProject = currentProject instanceof HTMLElement && + (currentProject.dataset.appActionSidebarProjectId !== project.id || + currentProject.dataset.appActionSidebarProjectLabel !== project.label); + if (entry.disabled !== differentProject) entry.disabled = differentProject; + if (differentProject && host !== null) restore(); publishContext(); }); const close = () => { diff --git a/tests/e2e/codex-runtime.e2e.test.ts b/tests/e2e/codex-runtime.e2e.test.ts index e411d2b..afc927b 100644 --- a/tests/e2e/codex-runtime.e2e.test.ts +++ b/tests/e2e/codex-runtime.e2e.test.ts @@ -46,6 +46,24 @@ describe('Codex runtime composition', () => { expect(instance.closed).toBe(true); }); + it('preserves the dedicated window after a safely cleaned-up fallback until explicit shutdown', async () => { + const instance = new FixtureInstance(ownedTarget); + const renderer = new FailingRenderer(); + renderer.close = async () => undefined; + const runtime = await startCodexRuntime({ + connectRenderer: async () => renderer, + launchInstance: async () => instance, + projectPath: '/Users/example/codex-git', + surfacePort: 0, + }); + runtimes.push(runtime); + renderer.publishStandalone(); + await vi.waitFor(() => expect(runtime.currentHost()).toBe('standalone')); + expect(instance.closed).toBe(false); + await runtime.close(); + expect(instance.closed).toBe(true); + }); + it('closes the dedicated instance when renderer teardown fails during fallback', async () => { const instance = new FixtureInstance(ownedTarget); const renderer = new FailingRenderer(); From 98763b1ef53f10cb9c68d46ca9bd82aa5ff12bf7 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 6 Sep 2026 01:18:33 +0800 Subject: [PATCH 12/12] fix: preserve unavailable worktree inspection and highlight Git --- .../RepositoryOverview.interactions.test.tsx | 66 ++++++++++++++++++- apps/ui/src/RepositoryOverview.tsx | 17 ++++- apps/ui/src/repository-store.test.ts | 56 +++++++++++++++- apps/ui/src/repository-store.ts | 42 +++++++++++- .../codex-cdp/src/remote-renderer.test.ts | 9 +++ .../codex-cdp/src/remote-renderer.ts | 2 + 6 files changed, 186 insertions(+), 6 deletions(-) diff --git a/apps/ui/src/RepositoryOverview.interactions.test.tsx b/apps/ui/src/RepositoryOverview.interactions.test.tsx index 7cb5b51..18af0b0 100644 --- a/apps/ui/src/RepositoryOverview.interactions.test.tsx +++ b/apps/ui/src/RepositoryOverview.interactions.test.tsx @@ -3,7 +3,12 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { operationIdSchema, refIdSchema } from '@codex-git/protocol'; +import { + operationIdSchema, + refIdSchema, + worktreeIdSchema, + worktreeGenerationSchema, +} from '@codex-git/protocol'; import { App } from './overview.js'; import { createOverviewFixture } from './overview-fixtures.js'; @@ -25,6 +30,65 @@ describe('Repository overview interactions', () => { container.remove(); }); + it('preserves row and search focus across renewed unavailable identities', () => { + const fixture = createOverviewFixture('unavailable-worktree'); + const store = createRepositoryStore(fixture.source); + act(() => root.render()); + const missing = button( + 'Select missing-worktree Worktree at /private/tmp/missing-worktree', + ); + act(() => missing.click()); + missing.focus(); + const source = fixture.source.getSnapshot(); + if (source.kind !== 'repository') throw new Error('Expected Repository'); + const renew = (digit: string) => + act(() => + fixture.publish({ + ...source, + snapshot: { + ...source.snapshot, + worktrees: source.snapshot.worktrees.map((w) => + w.status.kind !== 'unavailable' + ? w + : { + ...w, + worktreeId: worktreeIdSchema.parse( + `worktree_${digit.repeat(32)}`, + ), + generation: worktreeGenerationSchema.parse( + `generation_${digit.repeat(32)}`, + ), + }, + ), + }, + }), + ); + renew('a'); + expect(document.activeElement).toBe( + button( + 'Select missing-worktree Worktree at /private/tmp/missing-worktree', + ), + ); + act(() => + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Home', bubbles: true }), + ), + ); + expect(store.getSnapshot().selectedWorktreeId).toBe( + source.snapshot.worktrees[0]!.worktreeId, + ); + act(() => missing.click()); + const search = container.querySelector( + 'input[type="search"]', + )!; + search.focus(); + renew('b'); + expect(document.activeElement).toBe(search); + expect(store.getSnapshot().selectedWorktreeId).toBe( + worktreeIdSchema.parse(`worktree_${'b'.repeat(32)}`), + ); + }); + it('confirms the exact Remote and same-name target before Publish', async () => { const fixture = createOverviewFixture('one-worktree'); const current = fixture.source.getSnapshot(); diff --git a/apps/ui/src/RepositoryOverview.tsx b/apps/ui/src/RepositoryOverview.tsx index 8ee7c89..17377f9 100644 --- a/apps/ui/src/RepositoryOverview.tsx +++ b/apps/ui/src/RepositoryOverview.tsx @@ -284,7 +284,22 @@ export function RepositoryOverview({
    {visibleWorktrees.map((worktree) => ( -
  • +