From bdd997f71f40b2c511e982a4082caa3543c58ebd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:19:25 -0800 Subject: [PATCH] test(cross-version): load moved modules from wherever each release defines them (#37297) * test(cross-version): load moved modules from wherever each release defines them v1.4.246 is the first release cut after #36909, #36979, #37036 and #37070 moved the agent-session modules, so suites that imported the old paths from the newest release failed with ERR_MODULE_NOT_FOUND for every PR running cross-version wire. importReleaseCheckoutModule now resolves a moved module to the path the checked-out release ships, from one table of current and earlier locations, so both the rolling baseline and older pinned refs load. The released-chat writers drop their own per-module existsSync fallbacks for it. v1.4.246 also ships outcome storage, so it reads chats this build writes and closes its own rows with the outcomes marker. The downgrade test pins its refusing window to the two releases before outcome storage, and the upgrade test leaves the marker to the outcome reader, which restoreJournal checks. * test(cross-version): find a moved module by name in the release instead of a path table A table of earlier paths is a list of stale src literals the path-literal ratchet rightly refuses, and needs an entry for every future move. The release checkout itself says where it ships each module: the one file with that name under the same source root. Prunes the three released-chat-writers entries the ratchet baseline grandfathered. * test(cross-version): pin the chat-upgrade writers to the releases before outcome storage v1.4.246 keeps a chat in outcome tables, so its rows hold only the marker and there is no row upgrade to carry on; the newest release stays the reader this build must match. --- config/path-literal-baseline.txt | 13 ++--- ...ession-outcome-chat-downgrade.unit.test.ts | 17 +++--- .../cross-version-wire/release-checkout.ts | 6 ++- .../release-module-locations.ts | 53 +++++++++++++++++++ .../release-module-locations.unit.test.ts | 32 +++++++++++ .../released-chat-upgrade.unit.test.ts | 16 +++--- .../released-chat-writers.ts | 19 ++----- 7 files changed, 116 insertions(+), 40 deletions(-) create mode 100644 tests/e2e/cross-version-wire/release-module-locations.ts create mode 100644 tests/e2e/cross-version-wire/release-module-locations.unit.test.ts diff --git a/config/path-literal-baseline.txt b/config/path-literal-baseline.txt index 8967ec06c30..23eda0f2583 100644 --- a/config/path-literal-baseline.txt +++ b/config/path-literal-baseline.txt @@ -4,6 +4,11 @@ .github/workflows/agent-state-rules-publish.yml src/main/runtime/readiness-census .github/workflows/zcode-credential-contract.yml src/shared/windows-security-descriptor config/scripts/check-layout-writer-ratchets.mjs src/renderer/src/runtime/remote-workspace-frozen/ +config/scripts/pr-code-change-scope.mjs src/shared/agent-session-mutation-envelope +config/scripts/pr-code-change-scope.mjs src/shared/agent-session-provider-handle +config/scripts/pr-code-change-scope.mjs src/shared/agent-session-record +config/scripts/pr-code-change-scope.mjs src/shared/agent-session-resume-marker +config/scripts/pr-code-change-scope.mjs src/shared/agent-session-turn-record config/scripts/pr-layout-oracle-route.mjs src/main/runtime/old-session-tabs-stream/ config/scripts/pr-layout-oracle-route.mjs src/renderer/src/runtime/remote-workspace-frozen/ config/scripts/run-ssh-staged-upload-reliability.mjs src/main/ssh/ssh-relay-install-namespace.test.ts @@ -11,11 +16,3 @@ config/tsconfig.node.json src/renderer/src/components/native-chat/native-chat-co config/tsconfig.node.json src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts config/tsconfig.node.json src/renderer/src/components/native-chat/structured-agent-question-projection.ts tests/e2e/capture-opencode-tui-repro.mjs src/cli/cmd/tui/ui/spinner -tests/e2e/cross-version-wire/released-chat-writers.ts src/shared/agent-session-failure-words.ts -tests/e2e/cross-version-wire/released-chat-writers.ts src/shared/agent-session-failure.ts -config/scripts/pr-code-change-scope.mjs src/shared/agent-session-mutation-envelope -config/scripts/pr-code-change-scope.mjs src/shared/agent-session-provider-handle -config/scripts/pr-code-change-scope.mjs src/shared/agent-session-record -config/scripts/pr-code-change-scope.mjs src/shared/agent-session-resume-marker -config/scripts/pr-code-change-scope.mjs src/shared/agent-session-turn-record -tests/e2e/cross-version-wire/released-chat-writers.ts src/main/claude/claude-structured-journal-translation.ts diff --git a/tests/e2e/cross-version-wire/agent-session-outcome-chat-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-outcome-chat-downgrade.unit.test.ts index 695f60ebc81..e530943ce92 100644 --- a/tests/e2e/cross-version-wire/agent-session-outcome-chat-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-outcome-chat-downgrade.unit.test.ts @@ -26,17 +26,18 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout, releaseModuleExport, - resolveBaselineReleaseRef, - resolveRecentStableReleaseRefs + resolveBaselineReleaseRef } from './release-checkout' // A chat this build has written, new or an existing one whose input rows an older build wrote, is -// refused by each of the two newest releases (the downgrade window) as a newer Orca's, which leaves +// refused by each of the two last releases before outcome storage as a newer Orca's, which leaves // every byte as it was; a build from before that reason opens it read-only, as it does any newer // row, and changes no saved row (it only adds a table of its own). The existing chat's input rows // stay byte for byte throughout, and this build reads the whole chat again afterwards. const SUITE_TIMEOUT_MS = 180_000 // A main build sharing this database and schema; the releases in the window are probed as well. +// v1.4.246 shipped outcome storage and reads these chats itself, so the refusing window ends before it. +const REFUSING_RELEASE_REFS = ['v1.4.245', 'v1.4.244'] as const const WRITABLE_BASELINE_REF = '3727100cc9dbcea6201f8a3e506676a3c4b53b18' const JOURNAL = 'src/main/native-chat/agent-session-journal' @@ -142,8 +143,8 @@ function savedContent(): Map { describe('a chat this build wrote, opened by a shipped build', () => { it.each([ - ['new', 'the newest release'], - ['existing', 'the newest release'], + ['new', 'the last release before outcome storage'], + ['existing', 'the last release before outcome storage'], ['new', 'the release before it'], ['existing', 'the release before it'], ['new', 'the writable baseline'], @@ -157,10 +158,10 @@ describe('a chat this build wrote, opened by a shipped build', () => { expect(before['agent-session-journal.db']).toBeDefined() const inWindow = build !== 'the writable baseline' const checkout = await materializeReleaseCheckout( - build === 'the newest release' - ? resolveBaselineReleaseRef() + build === 'the last release before outcome storage' + ? REFUSING_RELEASE_REFS[0] : inWindow - ? resolveRecentStableReleaseRefs(2)[1]! + ? REFUSING_RELEASE_REFS[1] : WRITABLE_BASELINE_REF ) const support = await importReleaseCheckoutModule( diff --git a/tests/e2e/cross-version-wire/release-checkout.ts b/tests/e2e/cross-version-wire/release-checkout.ts index 759c68d96e9..293b00640cf 100644 --- a/tests/e2e/cross-version-wire/release-checkout.ts +++ b/tests/e2e/cross-version-wire/release-checkout.ts @@ -11,6 +11,7 @@ import { publishReleaseCheckout, type MaterializeReleaseCheckoutTestHooks } from './release-checkout-publication.ts' +import { releaseModuleLocation } from './release-module-locations.ts' export type { CheckoutLockOptions, CheckoutLifecycleContext, @@ -121,13 +122,16 @@ function checkoutModulePath(root: string, rootRelativePath: string): string { } // Pass raw absolute forward-slash paths: CI vite-node treats file:// URLs as root-relative ids (run 33049571360). +// A module main moved loads from wherever this release defines it. export function importReleaseCheckoutModule( checkout: ReleaseCheckout, rootRelativePath: string, importModule: (specifier: string) => Promise> = (specifier) => import(/* @vite-ignore */ specifier) as Promise> ): Promise> { - return importModule(checkoutModulePath(checkout.root, rootRelativePath)) + return importModule( + checkoutModulePath(checkout.root, releaseModuleLocation(checkout.root, rootRelativePath)) + ) } /** An export a release module must define; callers name the shape they use, so a changed one fails the test. */ diff --git a/tests/e2e/cross-version-wire/release-module-locations.ts b/tests/e2e/cross-version-wire/release-module-locations.ts new file mode 100644 index 00000000000..7aa5e5342b8 --- /dev/null +++ b/tests/e2e/cross-version-wire/release-module-locations.ts @@ -0,0 +1,53 @@ +import { existsSync, readdirSync } from 'node:fs' +import { join, posix } from 'node:path' + +export type ReleaseTree = { + exists: (rootRelativePath: string) => boolean + /** Root-relative forward-slash paths of every file under `directory`. */ + list: (directory: string) => readonly string[] +} + +const listings = new Map() + +function checkoutTree(root: string): ReleaseTree { + return { + exists: (path) => existsSync(join(root, path)), + list: (directory) => { + const key = join(root, directory) + let files = listings.get(key) + if (!files) { + files = existsSync(key) + ? readdirSync(key, { recursive: true, encoding: 'utf8' }).map((entry) => + posix.join(directory, entry.split('\\').join('/')) + ) + : [] + listings.set(key, files) + } + return files + } + } +} + +/** + * Where the release under `root` defines the module a suite names. Suites name main's path; a + * release cut before a folder move ships the module elsewhere under the same source root, so the + * one file there with its name is that module. Missing or ambiguous names pass through and fail + * at import, naming the path the suite asked for. + */ +export function releaseModuleLocation( + root: string, + rootRelativePath: string, + tree: ReleaseTree = checkoutTree(root) +): string { + const named = rootRelativePath + .replace(/^[/\\]+/, '') + .split('\\') + .join('/') + const sourceRoot = named.split('/').slice(0, 2).join('/') + if (tree.exists(named) || !sourceRoot.startsWith('src/')) { + return named + } + const name = posix.basename(named) + const moved = tree.list(sourceRoot).filter((path) => posix.basename(path) === name) + return moved.length === 1 ? moved[0]! : named +} diff --git a/tests/e2e/cross-version-wire/release-module-locations.unit.test.ts b/tests/e2e/cross-version-wire/release-module-locations.unit.test.ts new file mode 100644 index 00000000000..f0ec9d7f818 --- /dev/null +++ b/tests/e2e/cross-version-wire/release-module-locations.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { releaseModuleLocation, type ReleaseTree } from './release-module-locations' + +const CURRENT = 'src/shared/agent-session/lifecycle/agent-session-record.ts' +const SHIPPED = 'src/shared/agent-session-record.ts' +const release = (...files: string[]): ReleaseTree => ({ + exists: (path) => files.includes(path), + list: (directory) => files.filter((file) => file.startsWith(`${directory}/`)) +}) + +describe('release module locations', () => { + it('loads a moved module from wherever the release defines it, whichever path a suite names', () => { + for (const named of [CURRENT, SHIPPED, `/${SHIPPED}`]) { + expect(releaseModuleLocation('/release', named, release(SHIPPED))).toBe(SHIPPED) + expect(releaseModuleLocation('/release', named, release(CURRENT))).toBe(CURRENT) + } + }) + + it('stays inside the named source root and passes ambiguous or absent names through', () => { + expect( + releaseModuleLocation('/release', CURRENT, release('src/main/agent-session-record.ts')) + ).toBe(CURRENT) + expect( + releaseModuleLocation( + '/release', + CURRENT, + release(SHIPPED, 'src/shared/old/agent-session-record.ts') + ) + ).toBe(CURRENT) + expect(releaseModuleLocation('/release', CURRENT, release())).toBe(CURRENT) + }) +}) diff --git a/tests/e2e/cross-version-wire/released-chat-upgrade.unit.test.ts b/tests/e2e/cross-version-wire/released-chat-upgrade.unit.test.ts index bbd849f843f..adf4ccbe465 100644 --- a/tests/e2e/cross-version-wire/released-chat-upgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/released-chat-upgrade.unit.test.ts @@ -16,7 +16,7 @@ import type { AgentJournalSnapshot } from '../../../src/shared/agent-session/jou import { parseJournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import type { JournalStoredRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-table' import { JournalWriteProjection } from '../../../src/main/native-chat/agent-session-journal/journal-write-projection' -import { resolveRecentStableReleaseRefs } from './release-checkout' +import { resolveBaselineReleaseRef } from './release-checkout' import { editReleasedCard, rewindReleasedChat, @@ -30,16 +30,18 @@ import { type Agent } from './released-chat-writers' -// Each release in the downgrade window, and the oldest release whose chat database this build still +// The two releases before outcome storage, and the oldest release whose chat database this build still // reads, writes a Claude and a Codex chat through its own writers. This build admits every row as // written, opens the chat as that release had it, then edits a queued card (its first write, over // a key the old rows hold), sends, and rewinds, which saves the kept chat again in this build's // form. Every commit restores to the live chat. What this build does after the open is checked on // its own result, never against a release, so main stays free to change it. const SUITE_TIMEOUT_MS = 180_000 +// v1.4.246 shipped outcome storage, so its chats hold no rows to upgrade; the writers pin the two +// releases before it, and the newest release stays the reader this build must match. const RELEASES = { - 'the newest release': () => resolveRecentStableReleaseRefs(2)[0]!, - 'the release before it': () => resolveRecentStableReleaseRefs(2)[1]!, + 'the last release before outcome storage': () => 'v1.4.245', + 'the release before it': () => 'v1.4.244', // The first release with one chat database per host (schema 3, the oldest this build opens); // earlier releases kept a file per chat, which nothing reads any more. It never changes. 'the oldest release with this chat database': () => 'v1.4.219' @@ -145,8 +147,8 @@ async function carryOn( describe('a chat a shipped release wrote, carried on by this build', () => { it.each([ - ['the newest release', 'claude'], - ['the newest release', 'codex'], + ['the last release before outcome storage', 'claude'], + ['the last release before outcome storage', 'codex'], ['the release before it', 'claude'], ['the release before it', 'codex'], ['the oldest release with this chat database', 'claude'], @@ -197,7 +199,7 @@ describe('a chat a shipped release wrote, carried on by this build', () => { await closeTestJournalHostDatabase(directory) } // This build opens the chat as the newest release does. - const newest = await releasedJournalOpener(RELEASES['the newest release']()) + const newest = await releasedJournalOpener(resolveBaselineReleaseRef()) expect(releaseWritten(ours.opened)).toMatchObject( releaseWritten(await openedBy(newest, agent, shipped)) ) diff --git a/tests/e2e/cross-version-wire/released-chat-writers.ts b/tests/e2e/cross-version-wire/released-chat-writers.ts index 8a3fe900370..f9f7a2a9077 100644 --- a/tests/e2e/cross-version-wire/released-chat-writers.ts +++ b/tests/e2e/cross-version-wire/released-chat-writers.ts @@ -51,17 +51,11 @@ async function released(ref: string) { const load = async (path: string, name: string): Promise => releaseModuleExport(await importReleaseCheckoutModule(checkout, path), name) const fact = await load( - existsSync(join(checkout.root, 'src/shared/agent-session/failures/agent-session-failure.ts')) - ? 'src/shared/agent-session/failures/agent-session-failure.ts' - : 'src/shared/agent-session-failure.ts', + 'src/shared/agent-session/failures/agent-session-failure.ts', 'agentSessionFailureFact' ) const words = await load( - existsSync( - join(checkout.root, 'src/shared/agent-session/failures/agent-session-failure-words.ts') - ) - ? 'src/shared/agent-session/failures/agent-session-failure-words.ts' - : 'src/shared/agent-session-failure-words.ts', + 'src/shared/agent-session/failures/agent-session-failure-words.ts', 'agentSessionFailureWords' ) return { @@ -71,14 +65,7 @@ async function released(ref: string) { 'createTrackedJournalOpener' ), claude: await load( - existsSync( - join( - checkout.root, - 'src/main/claude/structured/journal/claude-structured-journal-translation.ts' - ) - ) - ? 'src/main/claude/structured/journal/claude-structured-journal-translation.ts' - : 'src/main/claude/claude-structured-journal-translation.ts', + 'src/main/claude/structured/journal/claude-structured-journal-translation.ts', 'createClaudeJournalTranslator' ), codex: await load(