diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aeaf03c..0805138 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.901.41600 (build 7982) + 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/apps/launcher/src/codex-runtime.ts b/apps/launcher/src/codex-runtime.ts index beb3539..8bd1e06 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({ @@ -66,11 +71,13 @@ export async function startCodexRuntime( const attachedConnection = connection; const dedicatedInstance = instance; connection = null; - instance = null; - await Promise.allSettled([ - attachedConnection?.close(), - dedicatedInstance?.close(), - ]); + try { + await attachedConnection?.close(); + } catch { + // Terminate only when native state/CSP could not be restored safely. + instance = null; + await dedicatedInstance?.close(); + } }); } } catch { @@ -82,18 +89,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/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) => ( -
  • +
  • +
    + +
    +
    + +
    Native task
    + + `); +} + function documentEntry(dom: JSDOM): HTMLButtonElement | null { return dom.window.document.querySelector('[data-codex-git-sidebar-entry]'); } @@ -492,12 +543,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 +574,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.test.ts b/packages/host-adapter/codex-cdp/src/compatibility-profile.test.ts new file mode 100644 index 0000000..cdfa997 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/compatibility-profile.test.ts @@ -0,0 +1,19 @@ +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(); + expect(findCodexCompatibilityProfile('26.820.60940', '7377')).toBeNull(); + }); +}); 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..752288b --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/compatibility-profile.ts @@ -0,0 +1,44 @@ +export interface CodexCompatibilityProfile { + readonly documentInjection?: boolean; + readonly build: string; + readonly chromiumProduct: string; + readonly entryInsertionSelector: string | null; + readonly mainSurfaceSelector: string; + readonly nativeEntrySelector: string; + readonly sidebarSelector: string; + readonly version: string; +} + +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', + entryInsertionSelector: null, + mainSurfaceSelector: '[data-app-shell-main-surface="default"]', + nativeEntrySelector: 'button', + sidebarSelector: '#app-shell-sidebar', + version: '26.820.60940', + }, +] 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..76d70ef 100644 --- a/packages/host-adapter/codex-cdp/src/compatibility.ts +++ b/packages/host-adapter/codex-cdp/src/compatibility.ts @@ -1,30 +1,59 @@ 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 entryInsertionAnchor: HTMLElement | null; readonly mainSurface: HTMLElement; + readonly nativeEntry: HTMLButtonElement; readonly sidebar: HTMLElement; } export function findCompatibleCodexAnchors( renderer: CodexRenderer, ): CompatibleCodexAnchors | null { + const profile = findCodexCompatibilityProfile( + renderer.version, + renderer.build, + ); if ( - renderer.version !== supportedCodexVersion || + profile === null || + profile.documentInjection === true || 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); + 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 sidebar instanceof renderer.window.HTMLElement && - mainSurface instanceof renderer.window.HTMLElement - ? { mainSurface, sidebar } + return sidebars.length === 1 && + mainSurfaces.length === 1 && + sidebar instanceof renderer.window.HTMLElement && + 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/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 7bb229a..6af173e 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { describe, expect, it, vi } from 'vitest'; import type { HostContext } from '@codex-git/host-adapter'; @@ -83,6 +84,118 @@ 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('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 gitEntry = dom.window.document.querySelector( + '[data-codex-git-sidebar-entry]', + )!; + gitEntry.click(); + expect(gitEntry.getAttribute('aria-current')).toBe('page'); + expect(gitEntry.style.backgroundColor).not.toBe(''); + await connection.perform({ kind: 'restore-native-surface' }); + expect(gitEntry.hasAttribute('aria-current')).toBe(false); + expect(gitEntry.style.backgroundColor).toBe(''); + 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' }, @@ -98,6 +211,37 @@ describe('dedicated Codex remote renderer', () => { 'Browser.getVersion', ]); }); + + 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, ...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' }); + + await expect( + connectDedicatedCodexRenderer( + { ...request, build: '6962' }, + { connect: async () => session }, + ), + ).rejects.toThrow('Unsupported Codex Desktop version'); + expect(session.commands).toEqual([]); + }); }); const expectedContext = { @@ -136,6 +280,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 { @@ -144,6 +289,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; @@ -165,3 +328,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 af642fa..a04c5d1 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, @@ -12,6 +13,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,11 +25,8 @@ 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 loadDocument?: () => Promise; readonly connect?: (url: string) => Promise; readonly createBindingName?: () => string; readonly wait?: (milliseconds: number) => Promise; @@ -34,19 +36,20 @@ 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'); } + if (profile.documentInjection && options.loadDocument === undefined) { + throw new Error('The embedded document loader is unavailable'); + } const session = await (options.connect ?? connectCdpSession)( request.target.webSocketUrl, ); 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 +65,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,8 +85,10 @@ export async function connectDedicatedCodexRenderer( session, lease, request, + profile, bindingName, installation, + options.loadDocument, ); lease = null; return connection; @@ -122,13 +127,16 @@ 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, + 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 { @@ -152,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' @@ -189,6 +203,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 +221,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,18 +250,20 @@ 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( this.session, replacementRequest, + this.profile, this.bindingName, ++this.generation, ); 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 }), ); @@ -250,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(); } } @@ -264,17 +311,23 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { async function install( session: CdpSession, request: ConnectDedicatedRendererRequest, + profile: CodexCompatibilityProfile, bindingName: string, generation: number, ): Promise { const input: BridgeInput = { bindingName, + documentInjection: profile.documentInjection === true, + entryInsertionSelector: profile.entryInsertionSelector, expectedProject: request.expectedProject, generation, + mainSurfaceSelector: profile.mainSurfaceSelector, + nativeEntrySelector: profile.nativeEntrySelector, openSurface: request.openSurface, projectPath: request.projectPath, surfaceTitle: request.surface.title, surfaceUrl: request.surface.url.href, + sidebarSelector: profile.sidebarSelector, }; const response = await evaluate( session, @@ -365,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 ( @@ -413,29 +469,46 @@ function wait(milliseconds: number): Promise { } interface BridgeInput { + readonly documentInjection: boolean; 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; readonly surfaceUrl: string; + readonly sidebarSelector: string; } // 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 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) || - !(selectedProject instanceof HTMLElement)) { + 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) || + !(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)) { @@ -449,15 +522,25 @@ 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 = ''; + 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, @@ -465,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(); @@ -479,18 +562,21 @@ 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'); + entry.style.removeProperty('background-color'); notify({ kind: 'surface', open: false }); }; const open = () => { + if (entry.disabled) return; restore(); host = document.createElement('main'); host.dataset.codexGitSurface = ''; 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(); @@ -498,6 +584,7 @@ function installDomBridge(input: BridgeInput): unknown { host.append(frame); main.after(host); main.hidden = true; entry.setAttribute('aria-current', 'page'); + entry.style.backgroundColor = 'var(--color-primary-ghost-hover, rgba(127, 127, 127, 0.18))'; notify({ kind: 'surface', open: true }); }; const handleSidebar = (event: Event) => { @@ -509,6 +596,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 && @@ -517,24 +605,36 @@ 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) || - currentProject.dataset.appActionSidebarProjectId !== project.id || - currentProject.dataset.appActionSidebarProjectLabel !== project.label) { + if (!sidebar.isConnected || !main.isConnected || !entry.isConnected || + (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 = () => { 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 }; + 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); - 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(); 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/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/scripts/voiceover-release-wizard.sh b/scripts/voiceover-release-wizard.sh new file mode 100755 index 0000000..44789fc --- /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.901.41600 (build 7982)" + +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.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 + 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/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(); 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..e90cf7f --- /dev/null +++ b/tests/release/manual-evidence.test.ts @@ -0,0 +1,88 @@ +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: [ + { + 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: + 'docs/host-integration/codex-compatibility.md#manual-smoke-matrix', + sourceRevision: 'sha256:fixture', + status: 'passed', + validUntil: '2026-09-29T00:00:00.000Z', + }, + { + codexVersion: null, + 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: [ + { + codexVersion: null, + environment: null, + id: 'voiceover-keyboard-smoke', + performedAt: null, + record: null, + sourceRevision: null, + status: 'passed', + validUntil: null, + }, + ], + schemaVersion: 1, + }), + ).toThrow( + 'requires codexVersion, environment, performedAt, record, sourceRevision', + ); + }); + + it('rejects duplicate check IDs', () => { + expect(() => + parseManualEvidence({ + checks: [ + { + codexVersion: null, + environment: null, + id: 'voiceover-keyboard-smoke', + performedAt: null, + record: null, + sourceRevision: null, + status: 'pending', + validUntil: null, + }, + { + codexVersion: 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..c45fe88 --- /dev/null +++ b/tests/release/manual-evidence.ts @@ -0,0 +1,170 @@ +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 codexVersion: string | null; + 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 codexVersion = optionalString(check.codexVersion); + 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' && + (codexVersion === null || + environment === null || + performedAt === null || + record === null || + sourceRevision === null || + validUntil === null) + ) { + throw new Error( + `Passed manual evidence ${check.id} requires codexVersion, environment, performedAt, record, sourceRevision, and validUntil.`, + ); + } + return { + codexVersion, + 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, + codexVersion: string, +): Promise { + if ( + check?.status !== 'passed' || + check.sourceRevision !== sourceRevision || + check.codexVersion !== codexVersion || + 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.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 new file mode 100644 index 0000000..fcf4595 --- /dev/null +++ b/tests/release/reference-benchmark.ts @@ -0,0 +1,274 @@ +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 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< + 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..034e051 --- /dev/null +++ b/tests/release/release-artifacts.test.ts @@ -0,0 +1,129 @@ +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: [ + { + codexVersion: '26.901.41600 (build 7982)', + 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..4ad1589 --- /dev/null +++ b/tests/release/release-environment.test.ts @@ -0,0 +1,66 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +import { + collectReleaseEnvironment, + SUPPORTED_CODEX_RELEASE_VERSION, + 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.901.41600 (build 7982)', + 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.901.41600 (build 7982)', + 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); + }); + + 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}`, + ); + }); +}); diff --git a/tests/release/release-environment.ts b/tests/release/release-environment.ts new file mode 100644 index 0000000..d768683 --- /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.901.41600 (build 7982)' 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..e32791b --- /dev/null +++ b/tests/release/release-report.test.ts @@ -0,0 +1,253 @@ +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.901.41600 (build 7982)', + 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: [ + { + 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: + 'docs/host-integration/codex-compatibility.md#manual-smoke-matrix', + sourceRevision, + status: 'passed', + validUntil: '2026-09-29T00:00:00.000Z', + }, + { + codexVersion: '26.901.41600 (build 7982)', + environment: + 'macOS 15.6; VoiceOver 10; Codex Desktop 26.901.41600 (build 7982)', + 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.901.41600 (build 7982)'); + 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' + ? { + codexVersion: null, + 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('blocks manual evidence recorded against a different Codex build', async () => { + const mismatched: ManualEvidenceRecord = { + ...manualEvidence, + checks: manualEvidence.checks.map((check) => ({ + ...check, + codexVersion: '26.818.41509 (build 6962)', + })), + }; + + 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(), + 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..83bcd26 --- /dev/null +++ b/tests/release/release-report.ts @@ -0,0 +1,289 @@ +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, + environment.codex, + )) + ? '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.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 new file mode 100644 index 0000000..7d55d49 --- /dev/null +++ b/tests/release/ui-benchmark.tsx @@ -0,0 +1,309 @@ +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 { + await prepareProtocolReleaseSurface(options.surfaceUrl); + const overallStartedAt = performance.now(); + + 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 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(); + 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, },