diff --git a/mobile/src/test-support/rpc-recording/README.md b/mobile/src/test-support/rpc-recording/README.md index 7dd7d87b602..1558bcef7f4 100644 --- a/mobile/src/test-support/rpc-recording/README.md +++ b/mobile/src/test-support/rpc-recording/README.md @@ -1,8 +1,10 @@ # Main RPC recordings Test infrastructure only. `pilot-scenarios.json` binds logical operations/actions to small -mount adapters. The adapters execute the actual product modules from the selected source -root, using React's test renderer; they do not reconstruct acceptance or lifecycle logic. +mount adapters, which live one module per domain under `adapters/` and are registered in +`adapters/mounted-operation-modules.ts`. The adapters execute the actual product modules from the +selected source root, using React's test renderer; they do not reconstruct acceptance or lifecycle +logic. The module loader transpiles the real source with TypeScript and resolves task barrels lazily so unused native views do not need a device. Accessing an unspecified native import fails. The history metadata function is exposed to its adapter without rewriting its body. @@ -58,14 +60,31 @@ visible, it does not make the reduction itself observable. ## Golden schema Each file records `runnerVersion`, `baseline`, `lockfileSha256` (mobile's lockfile), -`recorderSha256`, `scenarioSha256`, `platform`, `scenarioVersion`, `projectionVersion`, +`recorderSha256`, `adapterSha256`, `scenarioSha256`, `platform`, `scenarioVersion`, `projectionVersion`, `goldenFormatVersion`, `operation`, `family`, and `namedDeltas`. `platform` and `lockfileSha256` are provenance and are not compared: a dependency or OS that changes behaviour changes the trace itself, so comparing them would only fail candidates on unrelated bumps. The rest are pinned. -`recorderSha256` covers every non-markdown file under this directory, so the runner that produced a -golden is as pinned as the product baseline: editing an adapter projection or a fixture fails -candidate mode on the header and forces a deliberate re-record of everything. +`recorderSha256` covers every non-markdown file under this directory **except `adapters/`**, so the +engine that produced a golden is as pinned as the product baseline: editing the runner, the +transport, the projection or a fixture fails candidate mode on the header of every golden and +forces a deliberate re-record of all of them. + +`adapterSha256` covers the source of the mount adapter module _that golden_ was recorded through — +the file under `adapters/` that mounts each operation its scenarios drive, read off the same +`mounts` calls that build the table the recording runs against, so the pin cannot name a file the +runner did not use. Adding a domain's module re-digests nothing that was already recorded, and +editing one fails exactly the goldens mounted through it. The adapters used to sit in +`recorderSha256` with the engine, which made every golden's header a function of every other +family's adapter: #20568 added two task modules and put a conflict on that one line in 153 files, +against every domain branch in flight. + +The seam is the directory, not a filename convention, because a convention is a rule nobody +enforces. `adapter-seam.test.ts` enforces this one: every file under `adapters/` is a registered +module, every registered module is declared in the file it is registered under, no adapter module +imports a sibling (which would leave a golden pinned to one module and driven by two), and +`pilotMountAdapters` mounts nothing of its own — an adapter defined in an engine file would be +pinned by `recorderSha256` on all 153 goldens instead of by `adapterSha256` on its own. `scenarioSha256` covers the scenario input _that golden_ was recorded from — one manifest scenario for a pilot golden, the generated variants and any hoisted prelude for a matrix or schedule golden, @@ -75,8 +94,7 @@ it. The manifest used to be an input to `recorderSha256` instead, which made eve a function of every other family's scenarios: adding one domain's family re-digested all 153 files and put a conflict on that line in every domain branch in flight. Which goldens a manifest derives lives in `derived-goldens.ts`, so the digest is a function of the same derivation that records the -file rather than of a restatement of it; `golden-header-digest.test.ts` pins the four properties -that separation buys. +file rather than of a restatement of it; `golden-header-digest.test.ts` pins what both separations buy. Checkpoints contain ordered sender calls and serialized physical application payloads, action and request settlements, projected state, and ordered external effects. Sender args have three @@ -86,10 +104,10 @@ times and errors stay observable. Errors contain category, message and `isRpcDel stack paths, plus `code` and a recursively captured `cause` when the thrown error carries them. Platform is provenance; candidate comparison does not require the same operating system. -Format version 4 adds `scenarioSha256`. A version-3 golden would already fail this reader's byte -compare, so the bump buys the diagnosis rather than the rejection: `readGolden` names the stale -format and says to re-record, instead of reporting an opaque `(encoding)` difference. The bump moved -no observation. +Format version 4 added `scenarioSha256` and version 5 adds `adapterSha256`. A stale golden would +already fail this reader's byte compare, so each bump buys the diagnosis rather than the rejection: +`readGolden` names the stale format and says to re-record, instead of reporting an opaque +`(encoding)` difference. Neither bump moved an observation. ### Value pool @@ -192,16 +210,17 @@ Task-model projections record setter invocations and resulting model values, not ## Commands and checker contract Record only from unchanged pinned product sources and lockfile. The fence exempts only -`mobile/src/test-support/rpc-recording`, which `recorderSha256` pins instead; every other -test-support path is compared against the baseline like product code: +`mobile/src/test-support/rpc-recording`, which `recorderSha256` and `adapterSha256` pin between +them; every other test-support path is compared against the baseline like product code: ```sh ORCA_BACKGROUND_LAUNCH=1 RPC_FOUNDATION_RECORD=1 pnpm --dir mobile exec tsx scripts/rpc-recording.mts --record ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test src/test-support/rpc-recording ``` -Mutants are the defect evidence. `operation-mutations.ts` holds one anchored source edit per -adapter family, and every family's recording must change visible state when its mutant is applied, +Mutants are the defect evidence. `operation-mutations.ts` is an engine file and holds one anchored +source edit per adapter family — it is read only when a mutant runs, never while recording, so a new +entry cannot change a recording but does still move `recorderSha256` on every golden, and every family's recording must change visible state when its mutant is applied, which is what shows that family's `state()` projection observes the operation's real output. Anchors are asserted to match exactly one site, because a repeated anchor would half-apply while still counting as applied. Mutants replace the expression in memory, then run the same real hook. @@ -286,14 +305,16 @@ ORCA_BACKGROUND_LAUNCH=1 RPC_FOUNDATION_RECORD=1 \ A re-record is a claim about behaviour. State the cause in the commit; every golden the refresh moves should have one. -Editing the recorder itself on a migration branch is the awkward case: `recorderSha256` moves, so +Editing the recorder engine on a migration branch is the awkward case: `recorderSha256` moves, so every golden needs rewriting, but the product tree no longer matches `baseline`, and bumping `baseline` to the branch would record the migrated source and make the parity claim circular. Record from the pinned commit instead, with this branch's recorder laid over it — a detached checkout or a `git archive` extraction of `baseline`, this tree's `rpc-recording/` and `pilot-scenarios.json` copied in, `node_modules` symlinked, `RPC_FOUNDATION_GOLDENS` pointed at a scratch directory — then copy the result back and run the candidate suite here. Format the recorder before recording: an -`oxfmt` pass afterwards moves `recorderSha256` again. +`oxfmt` pass afterwards moves `recorderSha256` again. Adding or editing one domain's module under +`adapters/` no longer needs any of this: only that domain's goldens move, and they re-record from +its own branch like any other behaviour change. If your call site carries a mutation anchor in `operation-mutations.ts`, rewriting it will make the anchor match zero sites. Re-anchor the same defect at its new home rather than deleting the mutant: diff --git a/mobile/src/test-support/rpc-recording/adapter-digest.ts b/mobile/src/test-support/rpc-recording/adapter-digest.ts new file mode 100644 index 00000000000..81966d4b35c --- /dev/null +++ b/mobile/src/test-support/rpc-recording/adapter-digest.ts @@ -0,0 +1,73 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join, posix } from 'node:path' +import { MOUNTED_OPERATION_MODULES } from './adapters/mounted-operation-modules' +import { operationModuleLoader } from './operation-module-loader' +import { ADAPTER_DIRECTORY } from './recorder-digest' +import type { MountedOperationModule } from './mounted-operation-module' +import type { RecordingScenario } from './recording-scenario' + +const digests = new Map() + +/** + * Which module mounts each operation, read off the same `mounts` calls that build the table a + * recording runs against. A restated map could agree with itself while naming the wrong source. + */ +export function adapterSourceByOperation( + root: string, + registered: readonly MountedOperationModule[] = MOUNTED_OPERATION_MODULES +): Map { + const modules = operationModuleLoader(root) + const owners = new Map() + for (const module of registered) { + for (const operation of Object.keys(module.mounts(modules, {}))) { + owners.set(operation, module.source) + } + } + return owners +} + +/** + * The adapter source one golden was recorded through: the module mounting each operation its + * scenarios drive, deduplicated and ordered by file name. + * + * Pinned per golden rather than over the whole `adapters/` directory so a domain that adds its + * module moves nothing that was already recorded, and editing a module fails exactly the goldens + * that mounted it. A golden whose operation no module mounts has no runner to be attributed to, + * so it throws rather than digesting an empty set. + */ +export function adapterSha256( + root: string, + scenarios: readonly RecordingScenario[], + registered: readonly MountedOperationModule[] = MOUNTED_OPERATION_MODULES +): string { + const owners = adapterSourceByOperation(root, registered) + const sources = [ + ...new Set( + scenarios.map((scenario) => { + const source = owners.get(scenario.operation) + if (source === undefined) { + throw new Error(`No adapter module mounts ${scenario.operation}`) + } + return source + }) + ) + ].sort() + const key = `${root}\0${sources.join('\0')}` + const cached = digests.get(key) + if (cached !== undefined) { + return cached + } + const digest = createHash('sha256') + .update( + sources + .map( + (source) => + `${source}:${readFileSync(join(root, ...ADAPTER_DIRECTORY.split(posix.sep), source))}` + ) + .join('\n') + ) + .digest('hex') + digests.set(key, digest) + return digest +} diff --git a/mobile/src/test-support/rpc-recording/adapter-seam.test.ts b/mobile/src/test-support/rpc-recording/adapter-seam.test.ts new file mode 100644 index 00000000000..d3aa8010113 --- /dev/null +++ b/mobile/src/test-support/rpc-recording/adapter-seam.test.ts @@ -0,0 +1,69 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { adapterSourceByOperation } from './adapter-digest' +import { MOUNTED_OPERATION_MODULES } from './adapters/mounted-operation-modules' +import { operationModuleLoader } from './operation-module-loader' +import { pilotMountAdapters } from './pilot-mount-adapters' +import { ADAPTER_DIRECTORY } from './recorder-digest' +import { readScenarios } from './scenario-input' + +const root = resolve(import.meta.dirname, '../../../..') +const manifest = readScenarios( + process.env.RPC_FOUNDATION_SCENARIOS ?? + resolve(root, 'mobile/rpc-foundation/pilot-scenarios.json') +).scenarios +const directory = join(root, ADAPTER_DIRECTORY) +/** The register is the seam's own index, not an adapter: no golden is recorded through it. */ +const REGISTER = 'mounted-operation-modules.ts' +const sources = MOUNTED_OPERATION_MODULES.map((module) => module.source) + +function read(source: string): string { + return readFileSync(join(directory, source), 'utf8') +} + +/** + * `recorderSha256` covers the engine and `adapterSha256` covers one module per golden, so a file on + * the wrong side of this directory is pinned by the wrong thing — an engine file here escapes every + * golden, and an adapter outside re-digests all of them. Both fail here on the move instead. + */ +describe('the engine/adapter seam', () => { + it('registers every file in the adapter directory', () => { + const present = readdirSync(directory).filter((file) => file !== REGISTER) + expect(present.sort()).toEqual([...sources].sort()) + }) + + it('pairs each registered module with the file that declares it', () => { + expect(new Set(sources).size).toBe(sources.length) + const unpaired = MOUNTED_OPERATION_MODULES.filter( + ({ source, mounts }) => !read(source).includes(`export function ${mounts.name}(`) + ).map(({ source, mounts }) => `${mounts.name} is not declared in ${source}`) + expect(unpaired).toEqual([]) + }) + + // An adapter reaching sideways would leave a golden pinned to one module and driven by two. + it('leaves the seam for every import an adapter module makes', () => { + const inward = sources.flatMap((source) => + [...read(source).matchAll(/(?:from|import\()\s*'(\.[^']*)'/g)] + .filter((match) => !match[1]!.startsWith('../')) + .map((match) => `${source} imports ${match[1]}`) + ) + expect(inward).toEqual([]) + }) + + it('mounts nothing outside a registered module', () => { + const modules = operationModuleLoader(root) + const registered = MOUNTED_OPERATION_MODULES.flatMap((module) => + Object.keys(module.mounts(modules, {})) + ) + expect(Object.keys(pilotMountAdapters(root).adapters).sort()).toEqual([...registered].sort()) + }) + + it('attributes every recorded operation to a registered module', () => { + const owners = adapterSourceByOperation(root) + const orphans = [...new Set(manifest.map((scenario) => scenario.operation))] + .filter((operation) => !owners.has(operation)) + .sort() + expect(orphans).toEqual([]) + }) +}) diff --git a/mobile/src/test-support/rpc-recording/adapters/file-inventory-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/file-inventory-mount-adapters.ts new file mode 100644 index 00000000000..898fbc92716 --- /dev/null +++ b/mobile/src/test-support/rpc-recording/adapters/file-inventory-mount-adapters.ts @@ -0,0 +1,61 @@ +import { hookMount, performHookAction } from '../hook-mount' +import type { MountOptions } from '../mounted-operation-module' +import { operationModuleLoader } from '../operation-module-loader' +import type { MountAdapter } from '../recording-scenario' + +/** The native-chat file search behind the legacy inventory seeds. */ +export function fileInventoryMountAdapters( + modules: ReturnType, + options: MountOptions +): Record { + return { + 'workspace.file-inventory': ({ client }) => { + const useSearch = modules.load< + typeof import('../../../session/use-mobile-native-chat-file-search') + >('mobile/src/session/use-mobile-native-chat-file-search.ts').useMobileNativeChatFileSearch + const operations = options.reference + ? modules + .load('mobile/src/session/native-host-session-native-chat-operations.ts') + .nativeHostSessionNativeChatOperations(client) + : undefined + let workspace = 'A' + let state: ReturnType + const hook = hookMount(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. + state = useSearch({ client, operations, worktreeId: workspace } as Parameters< + typeof useSearch + >[0]) + }) + return { + action(name, args) { + if (name === 'mount' || name === 'remount') { + return hook.mount() + } + if (name === 'unmount') { + return hook.unmount() + } + if (name === 'select') { + workspace = String(args.workspace) + return hook.update() + } + if (name === 'reset') { + const previous = workspace + workspace = `${workspace}-reset` + hook.update() + workspace = previous + return hook.update() + } + if (name === 'query') { + return performHookAction(() => state.loadNativeChatFiles(String(args.query))) + } + if (name === 'blur') { + return + } + throw new Error(`Unknown inventory action: ${name}`) + }, + state: () => ({ files: state?.nativeChatFilePaths ?? [] }), + dispose: hook.unmount + } + } + } +} diff --git a/mobile/src/test-support/rpc-recording/hosted-review-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/hosted-review-mount-adapters.ts similarity index 90% rename from mobile/src/test-support/rpc-recording/hosted-review-mount-adapters.ts rename to mobile/src/test-support/rpc-recording/adapters/hosted-review-mount-adapters.ts index df836e7bc13..2528923ce4c 100644 --- a/mobile/src/test-support/rpc-recording/hosted-review-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/hosted-review-mount-adapters.ts @@ -1,5 +1,5 @@ -import type { MountAdapter } from './recording-scenario' -import { operationModuleLoader } from './operation-module-loader' +import type { MountAdapter } from '../recording-scenario' +import { operationModuleLoader } from '../operation-module-loader' const WORKTREE = 'repo42::/p' @@ -17,7 +17,7 @@ export function hostedReviewMountAdapters( return { 'source-control.review-git-preparation': ({ client }) => { const preparation = modules.load< - typeof import('../../source-control/mobile-hosted-review-git-preparation') + typeof import('../../../source-control/mobile-hosted-review-git-preparation') >('mobile/src/source-control/mobile-hosted-review-git-preparation.ts') let status: unknown = 'unread' let committed: unknown = 'uncommitted' @@ -42,7 +42,7 @@ export function hostedReviewMountAdapters( }, 'source-control.remote-prerequisite': (context) => { const apply = modules.load< - typeof import('../../source-control/mobile-hosted-review-remote-prerequisite') + typeof import('../../../source-control/mobile-hosted-review-remote-prerequisite') >( 'mobile/src/source-control/mobile-hosted-review-remote-prerequisite.ts' ).applyMobileHostedReviewRemotePrerequisite @@ -74,7 +74,7 @@ export function hostedReviewMountAdapters( }, 'source-control.hosted-review-eligibility': ({ client }) => { const service = modules.load< - typeof import('../../source-control/mobile-hosted-review-service') + typeof import('../../../source-control/mobile-hosted-review-service') >('mobile/src/source-control/mobile-hosted-review-service.ts') let eligibility: unknown = 'unfetched' let prefill: unknown = 'unresolved' @@ -104,7 +104,7 @@ export function hostedReviewMountAdapters( }, 'source-control.hosted-review-create': ({ client }) => { const create = modules.load< - typeof import('../../source-control/mobile-hosted-review-service') + typeof import('../../../source-control/mobile-hosted-review-service') >('mobile/src/source-control/mobile-hosted-review-service.ts').createMobileHostedReview let outcome: unknown = 'uncreated' return { @@ -127,7 +127,7 @@ export function hostedReviewMountAdapters( }, 'source-control.create-intent': (context) => { const run = modules.load< - typeof import('../../source-control/mobile-hosted-review-create-intent-runner') + typeof import('../../../source-control/mobile-hosted-review-create-intent-runner') >( 'mobile/src/source-control/mobile-hosted-review-create-intent-runner.ts' ).runMobileHostedReviewCreateIntent diff --git a/mobile/src/test-support/rpc-recording/adapters/mounted-operation-modules.ts b/mobile/src/test-support/rpc-recording/adapters/mounted-operation-modules.ts new file mode 100644 index 00000000000..4c52003e3fd --- /dev/null +++ b/mobile/src/test-support/rpc-recording/adapters/mounted-operation-modules.ts @@ -0,0 +1,23 @@ +import { fileInventoryMountAdapters } from './file-inventory-mount-adapters' +import { hostedReviewMountAdapters } from './hosted-review-mount-adapters' +import { newTabAgentMountAdapters } from './new-tab-agent-mount-adapters' +import { settingsMountAdapters } from './settings-mount-adapters' +import { sourceControlMountAdapters } from './source-control-mount-adapters' +import { taskMountAdapters } from './task-mount-adapters' +import { workspaceSettingsMounts } from './workspace-settings-mounts' +import type { MountedOperationModule } from '../mounted-operation-module' + +/** + * Every domain's mount adapters, paired with the file each one lives in. The register lives inside + * the seam it registers, so adding a domain edits no engine file and moves no existing golden; + * `adapter-seam.test.ts` checks each pairing names the file that declares it. + */ +export const MOUNTED_OPERATION_MODULES: readonly MountedOperationModule[] = [ + { source: 'file-inventory-mount-adapters.ts', mounts: fileInventoryMountAdapters }, + { source: 'hosted-review-mount-adapters.ts', mounts: hostedReviewMountAdapters }, + { source: 'new-tab-agent-mount-adapters.ts', mounts: newTabAgentMountAdapters }, + { source: 'settings-mount-adapters.ts', mounts: settingsMountAdapters }, + { source: 'source-control-mount-adapters.ts', mounts: sourceControlMountAdapters }, + { source: 'task-mount-adapters.ts', mounts: taskMountAdapters }, + { source: 'workspace-settings-mounts.ts', mounts: workspaceSettingsMounts } +] diff --git a/mobile/src/test-support/rpc-recording/adapters/new-tab-agent-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/new-tab-agent-mount-adapters.ts new file mode 100644 index 00000000000..7dec78b53e3 --- /dev/null +++ b/mobile/src/test-support/rpc-recording/adapters/new-tab-agent-mount-adapters.ts @@ -0,0 +1,21 @@ +import { operationModuleLoader } from '../operation-module-loader' +import type { MountAdapter } from '../recording-scenario' + +/** New-tab agent options: a plain loader call, so no React host is needed. */ +export function newTabAgentMountAdapters( + modules: ReturnType +): Record { + return { + 'settings.new-tab-agents': ({ client }) => { + const load = modules.load( + 'mobile/src/session/mobile-new-tab-agent-loader.ts' + ).loadMobileNewTabAgentOptions + return { + action: (_name, args) => + load({ client, worktreeId: String(args.workspace ?? 'repo-1::/folder') }), + state: () => ({}), + dispose: () => {} + } + } + } +} diff --git a/mobile/src/test-support/rpc-recording/settings-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts similarity index 88% rename from mobile/src/test-support/rpc-recording/settings-mount-adapters.ts rename to mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts index f453c16ef13..84ebcf93c58 100644 --- a/mobile/src/test-support/rpc-recording/settings-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts @@ -1,16 +1,16 @@ -import type { MountAdapter } from './recording-scenario' -import { hookMount } from './hook-mount' -import { observableModel, projectObservable } from './observable-model' -import { operationModuleLoader } from './operation-module-loader' +import type { MountAdapter } from '../recording-scenario' +import { hookMount } from '../hook-mount' +import { observableModel, projectObservable } from '../observable-model' +import { operationModuleLoader } from '../operation-module-loader' export function settingsMountAdapters( modules: ReturnType ): Record { return { 'settings.bot-overrides': ({ client }) => { - const useOverrides = modules.load( - 'mobile/src/session/use-pr-bot-author-overrides.ts' - ).usePRBotAuthorOverrides + const useOverrides = modules.load< + typeof import('../../../session/use-pr-bot-author-overrides') + >('mobile/src/session/use-pr-bot-author-overrides.ts').usePRBotAuthorOverrides let state: ReadonlySet = new Set() let revision = 1 const hook = hookMount(() => { @@ -39,7 +39,7 @@ export function settingsMountAdapters( }, 'settings.workspace-context': ({ client }) => { const useContext = modules.load< - typeof import('../../components/use-new-workspace-runtime-context') + typeof import('../../../components/use-new-workspace-runtime-context') >('mobile/src/components/use-new-workspace-runtime-context.ts').useNewWorkspaceRuntimeContext let state: ReturnType let visible = true @@ -107,9 +107,9 @@ export function settingsMountAdapters( return { action: () => load(client), state: () => ({}), dispose: () => {} } }, 'settings.repo-metadata': (context) => { - const useMetadata = modules.load( - 'mobile/src/host-screen/use-host-repo-metadata.ts' - ).useHostRepoMetadata + const useMetadata = modules.load< + typeof import('../../../host-screen/use-host-repo-metadata') + >('mobile/src/host-screen/use-host-repo-metadata.ts').useHostRepoMetadata const state = observableModel(context, { clientRef: { current: context.client }, fetchRepoMetadataInFlightRef: { current: new Set() }, @@ -142,7 +142,7 @@ export function settingsMountAdapters( }, 'settings.task-hydration': (context) => { const useHydration = modules.load< - typeof import('../../tasks/use-mobile-tasks-runtime-hydration') + typeof import('../../../tasks/use-mobile-tasks-runtime-hydration') >('mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx').useMobileTasksRuntimeHydration const model = observableModel(context, { client: context.client, diff --git a/mobile/src/test-support/rpc-recording/source-control-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/source-control-mount-adapters.ts similarity index 86% rename from mobile/src/test-support/rpc-recording/source-control-mount-adapters.ts rename to mobile/src/test-support/rpc-recording/adapters/source-control-mount-adapters.ts index 81d2db75eb1..5602b916f70 100644 --- a/mobile/src/test-support/rpc-recording/source-control-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/source-control-mount-adapters.ts @@ -1,5 +1,5 @@ -import type { MountAdapter } from './recording-scenario' -import { operationModuleLoader } from './operation-module-loader' +import type { MountAdapter } from '../recording-scenario' +import { operationModuleLoader } from '../operation-module-loader' const WORKTREE = 'repo42::/p' @@ -12,7 +12,7 @@ export function sourceControlMountAdapters( ): Record { return { 'source-control.branch-base-ref': ({ client }) => { - const resolve = modules.load( + const resolve = modules.load( 'mobile/src/source-control/mobile-branch-base-ref.ts' ).resolveMobileBranchCompareBaseRef let baseRef: unknown = 'unresolved' @@ -27,7 +27,7 @@ export function sourceControlMountAdapters( } }, 'source-control.git-history': ({ client }) => { - const history = modules.load( + const history = modules.load( 'mobile/src/source-control/mobile-git-history.ts' ) let rows: unknown = 'unloaded' @@ -42,7 +42,7 @@ export function sourceControlMountAdapters( } }, 'source-control.commit-message': ({ client }) => { - const ai = modules.load( + const ai = modules.load( 'mobile/src/source-control/mobile-commit-message-ai.ts' ) let generated: unknown = 'ungenerated' @@ -61,7 +61,7 @@ export function sourceControlMountAdapters( } }, 'source-control.pr-link': ({ client }) => { - const link = modules.load( + const link = modules.load( 'mobile/src/source-control/mobile-pr-link.ts' ) let outcome: unknown = 'unlinked' @@ -93,7 +93,7 @@ export function sourceControlMountAdapters( }, 'source-control.session-diff-reveal': ({ client }) => { const reveal = modules.load< - typeof import('../../source-control/reveal-mobile-source-control-session-diff') + typeof import('../../../source-control/reveal-mobile-source-control-session-diff') >( 'mobile/src/source-control/reveal-mobile-source-control-session-diff.ts' ).revealMobileSourceControlSessionDiff diff --git a/mobile/src/test-support/rpc-recording/adapters/task-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/task-mount-adapters.ts new file mode 100644 index 00000000000..21abb51ee27 --- /dev/null +++ b/mobile/src/test-support/rpc-recording/adapters/task-mount-adapters.ts @@ -0,0 +1,164 @@ +import { hookMount } from '../hook-mount' +import type { MountOptions } from '../mounted-operation-module' +import { observableModel } from '../observable-model' +import { operationModuleLoader } from '../operation-module-loader' +import type { MountAdapter } from '../recording-scenario' + +/** Task-model mounts: GitHub project metadata, Linear issue detail and task client settings. */ +export function taskMountAdapters( + modules: ReturnType, + options: MountOptions +): Record { + return { + 'project.update-metadata': (context) => { + const useMetadata = modules.load< + typeof import('../../../tasks/use-mobile-tasks-project-metadata-actions') + >( + 'mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx' + ).useMobileTasksProjectMetadataActions + const row = { + id: 'item-1', + itemType: 'ISSUE', + content: { repository: 'owner/repo', number: 1, labels: [], assignees: [] } + } + const model = observableModel(context, { + projectMutating: false, + projectRowDetailError: '', + projectRowItem: row, + githubProjectTable: { rows: [row] }, + projectRowDetail: null, + projectFieldDrafts: {} + }) + Object.assign(model, { + client: context.client, + activeGitHubProjectHost: 'github.enterprise.test' + }) + if (options.reference) { + model.taskOperations = { + projectMutation: modules + .load('mobile/src/tasks/native-host-task-project-mutation-operations.ts') + .nativeHostTaskProjectMutationOperations(context.client) + } + } + let actions: ReturnType + const hook = hookMount(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. + actions = useMetadata(model as unknown as Parameters[0]) + }) + return { + action(name) { + if (name === 'mount') { + return hook.mount() + } + if (name === 'submit') { + return actions.mutateProjectRowMetadata( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scenario supplies the row as JSON, not as a typed model. + row as unknown as Parameters[0], + { addLabels: ['recorded'] } + ) + } + throw new Error(`Unknown project action: ${name}`) + }, + state: () => ({ + mutating: model.projectMutating, + error: model.projectRowDetailError, + row: model.projectRowItem + }), + dispose: hook.unmount + } + }, + 'linear.issue-detail': (context) => { + const useDetail = modules.load< + typeof import('../../../tasks/use-mobile-tasks-item-detail-loading') + >('mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx').useMobileTasksItemDetailLoading + const model = observableModel(context, { + actionItem: { + provider: 'linear', + source: { id: 'issue-1', workspaceId: 'linear-workspace' } + }, + detailLoading: false, + detailError: '', + detailPayload: null, + items: [] + }) + Object.assign(model, { client: context.client, tasksSupported: true, detailRefreshSeq: 0 }) + if (options.reference) { + model.taskOperations = { + detail: modules + .load('mobile/src/tasks/native-host-task-detail-operations.ts') + .nativeHostTaskDetailOperations(context.client) + } + } + const hook = hookMount(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. + useDetail(model as unknown as Parameters[0]) + }) + return { + action(name) { + if (name === 'mount' || name === 'remount') { + return hook.mount() + } + if (name === 'unmount') { + return hook.unmount() + } + if (name === 'reset') { + model.detailRefreshSeq = Number(model.detailRefreshSeq) + 1 + return hook.update() + } + if (name === 'blur') { + return + } + throw new Error(`Unknown detail action: ${name}`) + }, + state: () => ({ + loading: model.detailLoading, + error: model.detailError, + payload: model.detailPayload + }), + dispose: hook.unmount + } + }, + 'settings.task-preferences': (context) => { + const usePreferences = modules.load< + typeof import('../../../tasks/use-mobile-tasks-client-settings-actions') + >( + 'mobile/src/tasks/use-mobile-tasks-client-settings-actions.tsx' + ).useMobileTasksClientSettingsActions + const model = observableModel(context, { + defaultGitHubPreset: 'all', + githubProjectSettings: {} + }) + Object.assign(model, { + client: context.client, + clientRef: { current: context.client }, + repoSelectionHydratedRef: { current: false }, + defaultRepoSelectionRef: { current: null }, + taskUiReady: true, + githubProjectFieldVisibilityScope: null, + taskResumeRef: { current: {} }, + trustedOrcaHooks: {} + }) + let actions: ReturnType + const hook = hookMount(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. + actions = usePreferences(model as unknown as Parameters[0]) + }) + return { + action(name, args) { + if (name === 'mount') { + return hook.mount() + } + if (name === 'write') { + return actions.persistDefaultGitHubPreset( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the preset arrives from the scenario JSON as a string. + args.preset as Parameters[0] + ) + } + throw new Error(`Unknown preferences action: ${name}`) + }, + state: () => ({ preset: model.defaultGitHubPreset }), + dispose: hook.unmount + } + } + } +} diff --git a/mobile/src/test-support/rpc-recording/workspace-settings-mounts.ts b/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts similarity index 91% rename from mobile/src/test-support/rpc-recording/workspace-settings-mounts.ts rename to mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts index d37c3d40935..db966c5c196 100644 --- a/mobile/src/test-support/rpc-recording/workspace-settings-mounts.ts +++ b/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts @@ -1,7 +1,7 @@ -import type { MountAdapter } from './recording-scenario' -import { hookMount, performHookAction } from './hook-mount' -import { observableModel, projectObservable } from './observable-model' -import { operationModuleLoader } from './operation-module-loader' +import type { MountAdapter } from '../recording-scenario' +import { hookMount, performHookAction } from '../hook-mount' +import { observableModel, projectObservable } from '../observable-model' +import { operationModuleLoader } from '../operation-module-loader' export function workspaceSettingsMounts( modules: ReturnType @@ -9,7 +9,7 @@ export function workspaceSettingsMounts( return { 'settings.workspace-submit': (context) => { const useSubmit = modules.load< - typeof import('../../components/use-new-workspace-create-submit') + typeof import('../../../components/use-new-workspace-create-submit') >('mobile/src/components/use-new-workspace-create-submit.ts').useNewWorkspaceCreateSubmit const model = observableModel(context, { client: context.client, @@ -58,7 +58,7 @@ export function workspaceSettingsMounts( }, 'settings.task-workspace': (context) => { const useCreate = modules.load< - typeof import('../../tasks/use-mobile-tasks-workspace-create-actions') + typeof import('../../../tasks/use-mobile-tasks-workspace-create-actions') >( 'mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx' ).useMobileTasksWorkspaceCreateActions diff --git a/mobile/src/test-support/rpc-recording/golden-header-digest.test.ts b/mobile/src/test-support/rpc-recording/golden-header-digest.test.ts index cf05b38cfca..7f9e4d85b49 100644 --- a/mobile/src/test-support/rpc-recording/golden-header-digest.test.ts +++ b/mobile/src/test-support/rpc-recording/golden-header-digest.test.ts @@ -4,8 +4,10 @@ import { join, resolve } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { derivedGoldens } from './derived-goldens' import { goldenRecording, type GoldenRecording } from './golden-recording' -import { RECORDER_DIRECTORY } from './recorder-digest' +import { MOUNTED_OPERATION_MODULES } from './adapters/mounted-operation-modules' +import { ADAPTER_DIRECTORY, RECORDER_DIRECTORY } from './recorder-digest' import { readScenarios } from './scenario-input' +import type { MountedOperationModule } from './mounted-operation-module' import type { RecordingScenario, ScenarioStep } from './recording-scenario' const root = resolve(import.meta.dirname, '../../../..') @@ -40,12 +42,27 @@ afterAll(() => { } }) -/** The two files a root contributes to a header, plus the manifest the old digest also read. */ -function stubRoot(recorder: string, scenarioFile: string): string { +/** + * One state of the recorder tree: the engine, the registered adapter modules, and the source each + * one holds. A module left out of `sources` gets identical stub source, so only what a revision + * names is different between two of them. + */ +type Revision = { + engine: string + registered?: readonly MountedOperationModule[] + sources?: Record +} + +/** The files a root contributes to a header, plus the manifest the oldest digest also read. */ +function stubRoot(revision: Revision, scenarioFile: string): string { const directory = mkdtempSync(join(tmpdir(), 'rpc-header-')) created.push(directory) - mkdirSync(join(directory, RECORDER_DIRECTORY), { recursive: true }) - writeFileSync(join(directory, RECORDER_DIRECTORY, 'runner.ts'), recorder) + mkdirSync(join(directory, ADAPTER_DIRECTORY), { recursive: true }) + writeFileSync(join(directory, RECORDER_DIRECTORY, 'runner.ts'), revision.engine) + for (const { source } of revision.registered ?? MOUNTED_OPERATION_MODULES) { + const stub = revision.sources?.[source] ?? 'export const adapter = 1' + writeFileSync(join(directory, ADAPTER_DIRECTORY, source), stub) + } writeFileSync(join(directory, 'mobile/pnpm-lock.yaml'), 'lockfile: stub\n') mkdirSync(join(directory, 'mobile/rpc-foundation'), { recursive: true }) writeFileSync(join(directory, 'mobile/rpc-foundation/pilot-scenarios.json'), scenarioFile) @@ -53,15 +70,16 @@ function stubRoot(recorder: string, scenarioFile: string): string { } /** Every golden's header for one recorder revision and one manifest, both written to a stub root. */ -function headers(recorder: string, scenarios: readonly RecordingScenario[]): Map { - const stub = stubRoot(recorder, JSON.stringify({ baseline: BASELINE, scenarios })) +function headers(revision: Revision, scenarios: readonly RecordingScenario[]): Map { + const stub = stubRoot(revision, JSON.stringify({ baseline: BASELINE, scenarios })) return new Map( derivedGoldens(scenarios).map((golden) => { const { recording: _recording, ...header } = goldenRecording( stub, BASELINE, golden.scenarios(), - { scenario: golden.id, checkpoints: [] } + { scenario: golden.id, checkpoints: [] }, + revision.registered ?? MOUNTED_OPERATION_MODULES ) return [golden.id, header] }) @@ -75,6 +93,30 @@ function moved(before: Map, after: Map): string[ .sort() } +const ADDED_MODULE: MountedOperationModule = { + source: 'digest-probe-mount-adapters.ts', + mounts: () => ({ + 'digest.probe': () => { + throw new Error('The digest reads adapter source; it never mounts one') + } + }) +} + +/** Every golden recorded through `new-tab-agent-mount-adapters.ts`, the module with one operation. */ +const NEW_TAB_GOLDENS = [ + 'matrix-settings-agent-read-preflight.detectremoteagents-1', + 'matrix-settings-agent-read-repo.list-1', + 'matrix-settings-agent-read-settings.get-1', + 'probe-new-tab-both-refused', + 'probe-new-tab-null-sibling-refused', + 'probe-new-tab-refused-sibling-rejects', + 'probe-new-tab-rejects-sibling-refused', + 'schedules-settings-new-tab-ssh', + 'settings-new-tab-refused', + 'settings-new-tab-ssh', + 'settings-new-tab-transport-error' +] + /** A family no other golden consumes, with one reply the matrix can replay as its success. */ const ADDED_FAMILY: RecordingScenario = { id: 'digest-probe', @@ -124,18 +166,42 @@ function editCompletion( } describe('golden header digests', () => { - it('re-digests nothing when the manifest gains a family', () => { - const before = headers('export const runner = 1', manifest) - const after = headers('export const runner = 1', [...manifest, ADDED_FAMILY]) + const ENGINE = 'export const runner = 1' + + // A domain PR lands a family and the module that mounts it together. Neither is an input to any + // other golden's header, so no existing golden re-records and no branch conflicts on that line. + it('re-digests nothing when a domain adds a family and its adapter module', () => { + const before = headers({ engine: ENGINE }, manifest) + const after = headers( + { engine: ENGINE, registered: [...MOUNTED_OPERATION_MODULES, ADDED_MODULE] }, + [...manifest, ADDED_FAMILY] + ) expect(moved(before, after)).toEqual([]) // The added family did derive goldens of its own: a pilot golden and one matrix site. expect(after.size).toBe(before.size + 2) }) - it('re-digests exactly the goldens derived from an edited scenario', () => { - const before = headers('export const runner = 1', manifest) + it('re-digests exactly the goldens recorded through an edited adapter module', () => { + const before = headers({ engine: ENGINE }, manifest) const after = headers( - 'export const runner = 1', + { + engine: ENGINE, + sources: { 'new-tab-agent-mount-adapters.ts': 'export const adapter = 2' } + }, + manifest + ) + expect(moved(before, after)).toEqual([...NEW_TAB_GOLDENS].sort()) + for (const id of NEW_TAB_GOLDENS) { + expect(after.get(id)?.recorderSha256).toBe(before.get(id)?.recorderSha256) + expect(after.get(id)?.scenarioSha256).toBe(before.get(id)?.scenarioSha256) + expect(after.get(id)?.adapterSha256).not.toBe(before.get(id)?.adapterSha256) + } + }) + + it('re-digests exactly the goldens derived from an edited scenario', () => { + const before = headers({ engine: ENGINE }, manifest) + const after = headers( + { engine: ENGINE }, editCompletion(manifest, EDITED_SCENARIO, EDITED_SITE, (step) => ({ ...step, params: { worktree: 'id:A', query: 'old', limit: 17 } @@ -144,6 +210,7 @@ describe('golden header digests', () => { expect(moved(before, after)).toEqual([...EDITED_GOLDENS].sort()) for (const id of EDITED_GOLDENS) { expect(after.get(id)?.recorderSha256).toBe(before.get(id)?.recorderSha256) + expect(after.get(id)?.adapterSha256).toBe(before.get(id)?.adapterSha256) expect(after.get(id)?.scenarioSha256).not.toBe(before.get(id)?.scenarioSha256) } }) @@ -152,9 +219,9 @@ describe('golden header digests', () => { // the `normal` partition replays a sibling's recorded reply, so the sibling is a real input to a // matrix golden that its own scenario never appears in. it('re-digests a matrix golden whose replayed success comes from an edited sibling', () => { - const before = headers('export const runner = 1', manifest) + const before = headers({ engine: ENGINE }, manifest) const after = headers( - 'export const runner = 1', + { engine: ENGINE }, editCompletion(manifest, REPLAYED_SCENARIO, EDITED_SITE, (step) => ({ ...step, reply: { ok: true, result: { files: [{ relativePath: 'edited.ts' }] } } @@ -163,12 +230,13 @@ describe('golden header digests', () => { expect(moved(before, after)).toEqual([REPLAYED_SCENARIO, REPLAYED_GOLDEN].sort()) }) - it('re-digests every golden when a recorder file changes', () => { - const before = headers('export const runner = 1', manifest) - const after = headers('export const runner = 2', manifest) + it('re-digests every golden when an engine file changes', () => { + const before = headers({ engine: ENGINE }, manifest) + const after = headers({ engine: 'export const runner = 2' }, manifest) expect(moved(before, after)).toEqual([...before.keys()].sort()) for (const [id, header] of before) { expect(after.get(id)?.recorderSha256).not.toBe(header.recorderSha256) + expect(after.get(id)?.adapterSha256).toBe(header.adapterSha256) expect(after.get(id)?.scenarioSha256).toBe(header.scenarioSha256) } }) diff --git a/mobile/src/test-support/rpc-recording/golden-recording.ts b/mobile/src/test-support/rpc-recording/golden-recording.ts index 911da5997b0..ec8bba57063 100644 --- a/mobile/src/test-support/rpc-recording/golden-recording.ts +++ b/mobile/src/test-support/rpc-recording/golden-recording.ts @@ -10,17 +10,20 @@ import { type InternedRecording, type ValuePool } from './golden-value-pool' +import { adapterSha256 } from './adapter-digest' +import { MOUNTED_OPERATION_MODULES } from './adapters/mounted-operation-modules' import { recorderSha256 } from './recorder-digest' import { scenarioSha256 } from './scenario-digest' +import type { MountedOperationModule } from './mounted-operation-module' import type { Recording, RecordingScenario } from './recording-scenario' import type { RecordedValue } from './recording-values' export const RUNNER_VERSION = 1 // 2 stamps every settlement with startedAt/settledAt on the pinned virtual clock. export const PROJECTION_VERSION = 2 -// 4 pins scenarioSha256 per golden. The byte compare would fail a version-3 golden anyway; the bump -// buys the diagnosis, reporting the stale format instead of an opaque `(encoding)` difference. -export const GOLDEN_FORMAT_VERSION = 4 +// 5 splits the mount adapters out of recorderSha256 into adapterSha256. As with 4, the byte compare +// would fail a stale golden anyway; the bump buys the diagnosis instead of an opaque `(encoding)`. +export const GOLDEN_FORMAT_VERSION = 5 export type GoldenRecording = { operation: string family: string @@ -29,6 +32,7 @@ export type GoldenRecording = { baseline: string lockfileSha256: string recorderSha256: string + adapterSha256: string scenarioSha256: string platform: string scenarioVersion: number @@ -44,7 +48,8 @@ export function goldenRecording( root: string, baseline: string, scenarios: readonly RecordingScenario[], - recording: Recording + recording: Recording, + registered: readonly MountedOperationModule[] = MOUNTED_OPERATION_MODULES ): GoldenRecording { const [scenario] = scenarios if (!scenario) { @@ -60,6 +65,7 @@ export function goldenRecording( .update(readFileSync(join(root, 'mobile/pnpm-lock.yaml'))) .digest('hex'), recorderSha256: recorderSha256(root), + adapterSha256: adapterSha256(root, scenarios, registered), scenarioSha256: scenarioSha256(scenarios), platform: process.platform, scenarioVersion: scenario.version, diff --git a/mobile/src/test-support/rpc-recording/mounted-operation-module.ts b/mobile/src/test-support/rpc-recording/mounted-operation-module.ts new file mode 100644 index 00000000000..c68d17587e8 --- /dev/null +++ b/mobile/src/test-support/rpc-recording/mounted-operation-module.ts @@ -0,0 +1,17 @@ +import { operationModuleLoader } from './operation-module-loader' +import type { MountAdapter } from './recording-scenario' + +/** Reference mode wires an archived tree's operations instead of main's. */ +export type MountOptions = { reference?: boolean } + +/** + * One domain's mount adapters and the `adapters/` file they live in. The file is what each golden + * recorded through them pins, so a domain edit moves those goldens and no others. + */ +export type MountedOperationModule = { + source: string + mounts: ( + modules: ReturnType, + options: MountOptions + ) => Record +} diff --git a/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts b/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts index a1dd2b61174..582930c4fcd 100644 --- a/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts @@ -1,230 +1,26 @@ -import { observableModel } from './observable-model' -import { hostedReviewMountAdapters } from './hosted-review-mount-adapters' -import { settingsMountAdapters } from './settings-mount-adapters' -import { sourceControlMountAdapters } from './source-control-mount-adapters' -import { workspaceSettingsMounts } from './workspace-settings-mounts' -import type { MountAdapter } from './recording-scenario' -import { hookMount, performHookAction } from './hook-mount' +import { MOUNTED_OPERATION_MODULES } from './adapters/mounted-operation-modules' import { operationModuleLoader, type Mutation } from './operation-module-loader' +import type { MountedOperationModule, MountOptions } from './mounted-operation-module' +import type { MountAdapter } from './recording-scenario' +/** + * The mount table one recording runs against: every registered domain module, merged. Nothing is + * mounted here, because an adapter defined in this file would be pinned by `recorderSha256` on + * every golden rather than by `adapterSha256` on the goldens that mount it. + */ export function pilotMountAdapters( root: string, - options: { reference?: boolean; mutation?: Mutation } = {} + options: MountOptions & { mutation?: Mutation } = {}, + registered: readonly MountedOperationModule[] = MOUNTED_OPERATION_MODULES ) { const modules = operationModuleLoader(root, options.mutation) - const adapters: Record = { - ...settingsMountAdapters(modules), - ...workspaceSettingsMounts(modules), - ...sourceControlMountAdapters(modules), - ...hostedReviewMountAdapters(modules), - 'workspace.file-inventory': ({ client }) => { - const useSearch = modules.load< - typeof import('../../session/use-mobile-native-chat-file-search') - >('mobile/src/session/use-mobile-native-chat-file-search.ts').useMobileNativeChatFileSearch - const operations = options.reference - ? modules - .load('mobile/src/session/native-host-session-native-chat-operations.ts') - .nativeHostSessionNativeChatOperations(client) - : undefined - let workspace = 'A' - let state: ReturnType - const hook = hookMount(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. - state = useSearch({ client, operations, worktreeId: workspace } as Parameters< - typeof useSearch - >[0]) - }) - return { - action(name, args) { - if (name === 'mount' || name === 'remount') { - return hook.mount() - } - if (name === 'unmount') { - return hook.unmount() - } - if (name === 'select') { - workspace = String(args.workspace) - return hook.update() - } - if (name === 'reset') { - const previous = workspace - workspace = `${workspace}-reset` - hook.update() - workspace = previous - return hook.update() - } - if (name === 'query') { - return performHookAction(() => state.loadNativeChatFiles(String(args.query))) - } - if (name === 'blur') { - return - } - throw new Error(`Unknown inventory action: ${name}`) - }, - state: () => ({ files: state?.nativeChatFilePaths ?? [] }), - dispose: hook.unmount - } - }, - 'project.update-metadata': (context) => { - const useMetadata = modules.load< - typeof import('../../tasks/use-mobile-tasks-project-metadata-actions') - >( - 'mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx' - ).useMobileTasksProjectMetadataActions - const row = { - id: 'item-1', - itemType: 'ISSUE', - content: { repository: 'owner/repo', number: 1, labels: [], assignees: [] } - } - const model = observableModel(context, { - projectMutating: false, - projectRowDetailError: '', - projectRowItem: row, - githubProjectTable: { rows: [row] }, - projectRowDetail: null, - projectFieldDrafts: {} - }) - Object.assign(model, { - client: context.client, - activeGitHubProjectHost: 'github.enterprise.test' - }) - if (options.reference) { - model.taskOperations = { - projectMutation: modules - .load('mobile/src/tasks/native-host-task-project-mutation-operations.ts') - .nativeHostTaskProjectMutationOperations(context.client) - } - } - let actions: ReturnType - const hook = hookMount(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. - actions = useMetadata(model as unknown as Parameters[0]) - }) - return { - action(name) { - if (name === 'mount') { - return hook.mount() - } - if (name === 'submit') { - return actions.mutateProjectRowMetadata( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scenario supplies the row as JSON, not as a typed model. - row as unknown as Parameters[0], - { addLabels: ['recorded'] } - ) - } - throw new Error(`Unknown project action: ${name}`) - }, - state: () => ({ - mutating: model.projectMutating, - error: model.projectRowDetailError, - row: model.projectRowItem - }), - dispose: hook.unmount - } - }, - 'linear.issue-detail': (context) => { - const useDetail = modules.load< - typeof import('../../tasks/use-mobile-tasks-item-detail-loading') - >('mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx').useMobileTasksItemDetailLoading - const model = observableModel(context, { - actionItem: { - provider: 'linear', - source: { id: 'issue-1', workspaceId: 'linear-workspace' } - }, - detailLoading: false, - detailError: '', - detailPayload: null, - items: [] - }) - Object.assign(model, { client: context.client, tasksSupported: true, detailRefreshSeq: 0 }) - if (options.reference) { - model.taskOperations = { - detail: modules - .load('mobile/src/tasks/native-host-task-detail-operations.ts') - .nativeHostTaskDetailOperations(context.client) - } - } - const hook = hookMount(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. - useDetail(model as unknown as Parameters[0]) - }) - return { - action(name) { - if (name === 'mount' || name === 'remount') { - return hook.mount() - } - if (name === 'unmount') { - return hook.unmount() - } - if (name === 'reset') { - model.detailRefreshSeq = Number(model.detailRefreshSeq) + 1 - return hook.update() - } - if (name === 'blur') { - return - } - throw new Error(`Unknown detail action: ${name}`) - }, - state: () => ({ - loading: model.detailLoading, - error: model.detailError, - payload: model.detailPayload - }), - dispose: hook.unmount - } - }, - 'settings.new-tab-agents': ({ client }) => { - const load = modules.load( - 'mobile/src/session/mobile-new-tab-agent-loader.ts' - ).loadMobileNewTabAgentOptions - return { - action: (_name, args) => - load({ client, worktreeId: String(args.workspace ?? 'repo-1::/folder') }), - state: () => ({}), - dispose: () => {} - } - }, - 'settings.task-preferences': (context) => { - const usePreferences = modules.load< - typeof import('../../tasks/use-mobile-tasks-client-settings-actions') - >( - 'mobile/src/tasks/use-mobile-tasks-client-settings-actions.tsx' - ).useMobileTasksClientSettingsActions - const model = observableModel(context, { - defaultGitHubPreset: 'all', - githubProjectSettings: {} - }) - Object.assign(model, { - client: context.client, - clientRef: { current: context.client }, - repoSelectionHydratedRef: { current: false }, - defaultRepoSelectionRef: { current: null }, - taskUiReady: true, - githubProjectFieldVisibilityScope: null, - taskResumeRef: { current: {} }, - trustedOrcaHooks: {} - }) - let actions: ReturnType - const hook = hookMount(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. - actions = usePreferences(model as unknown as Parameters[0]) - }) - return { - action(name, args) { - if (name === 'mount') { - return hook.mount() - } - if (name === 'write') { - return actions.persistDefaultGitHubPreset( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the preset arrives from the scenario JSON as a string. - args.preset as Parameters[0] - ) - } - throw new Error(`Unknown preferences action: ${name}`) - }, - state: () => ({ preset: model.defaultGitHubPreset }), - dispose: hook.unmount + const adapters: Record = {} + for (const module of registered) { + for (const [operation, adapter] of Object.entries(module.mounts(modules, options))) { + if (operation in adapters) { + throw new Error(`Two adapter modules mount ${operation}`) } + adapters[operation] = adapter } } return { adapters, assertMutationApplied: modules.assertMutationApplied } diff --git a/mobile/src/test-support/rpc-recording/recorder-digest.ts b/mobile/src/test-support/rpc-recording/recorder-digest.ts index b2a5e893a70..9f131dba895 100644 --- a/mobile/src/test-support/rpc-recording/recorder-digest.ts +++ b/mobile/src/test-support/rpc-recording/recorder-digest.ts @@ -3,6 +3,8 @@ import { readFileSync, readdirSync } from 'node:fs' import { join, posix } from 'node:path' export const RECORDER_DIRECTORY = 'mobile/src/test-support/rpc-recording' +/** The per-domain mount adapters. Excluded below and pinned per golden by `adapterSha256` instead. */ +export const ADAPTER_DIRECTORY = `${RECORDER_DIRECTORY}/adapters` const digests = new Map() function collect(root: string, relative: string, files: string[]): void { @@ -11,7 +13,9 @@ function collect(root: string, relative: string, files: string[]): void { )) { const child = `${relative}/${entry.name}` if (entry.isDirectory()) { - collect(root, child, files) + if (child !== ADAPTER_DIRECTORY) { + collect(root, child, files) + } } else if (!entry.name.endsWith('.md')) { files.push(child) } @@ -19,14 +23,16 @@ function collect(root: string, relative: string, files: string[]): void { } /** - * Every executable recorder input, so a golden is attributable to one runner. Prose is excluded - * because it cannot change a recording; a candidate run recomputes this and `compareGolden` fails - * the header, which forces a recorder edit to re-record deliberately. + * Every executable recorder input a golden shares with every other golden: the engine, and nothing + * domain-specific. Prose is excluded because it cannot change a recording; a candidate run + * recomputes this and `compareGolden` fails the header, which forces an engine edit to re-record + * deliberately. * - * The scenario manifest is deliberately not an input. It used to be, which made every golden's - * header a function of every other family's scenarios: adding one family re-digested all 153 files - * and put a conflict on that line in every domain branch. `scenarioSha256` pins each golden to the - * scenarios it was actually recorded from instead. + * Two inputs are deliberately absent, each for the same reason. The scenario manifest used to be + * here, which made every golden's header a function of every other family's scenarios. The mount + * adapters used to be here too, which made it a function of every other family's adapter: adding + * one domain's module re-digested all 153 files and put a conflict on that line in every domain + * branch in flight. `scenarioSha256` and `adapterSha256` pin each golden to its own instead. */ export function recorderSha256(root: string): string { const cached = digests.get(root)