mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's
`recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its merge with main conflicted on that one line in 153 files; every future domain PR would collide with every other in flight the same way. Split the directory at a real seam instead of a filename convention: `adapters/` holds one module per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers the engine only. A new `adapterSha256` covers the source of the module that mounts each operation a golden's 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 now re-digests nothing already recorded; editing one fails exactly the goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file, and an adapter importing a sibling each fail. The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new header field; the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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<string, string>()
|
||||
|
||||
/**
|
||||
* 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<string, string> {
|
||||
const modules = operationModuleLoader(root)
|
||||
const owners = new Map<string, string>()
|
||||
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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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<typeof operationModuleLoader>,
|
||||
options: MountOptions
|
||||
): Record<string, MountAdapter> {
|
||||
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<typeof useSearch>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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
|
||||
@@ -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 }
|
||||
]
|
||||
@@ -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<typeof operationModuleLoader>
|
||||
): Record<string, MountAdapter> {
|
||||
return {
|
||||
'settings.new-tab-agents': ({ client }) => {
|
||||
const load = modules.load<typeof import('../../../session/mobile-new-tab-agent-loader')>(
|
||||
'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: () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -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<typeof operationModuleLoader>
|
||||
): Record<string, MountAdapter> {
|
||||
return {
|
||||
'settings.bot-overrides': ({ client }) => {
|
||||
const useOverrides = modules.load<typeof import('../../session/use-pr-bot-author-overrides')>(
|
||||
'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<string> = 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<typeof useContext>
|
||||
let visible = true
|
||||
@@ -107,9 +107,9 @@ export function settingsMountAdapters(
|
||||
return { action: () => load(client), state: () => ({}), dispose: () => {} }
|
||||
},
|
||||
'settings.repo-metadata': (context) => {
|
||||
const useMetadata = modules.load<typeof import('../../host-screen/use-host-repo-metadata')>(
|
||||
'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,
|
||||
+7
-7
@@ -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<string, MountAdapter> {
|
||||
return {
|
||||
'source-control.branch-base-ref': ({ client }) => {
|
||||
const resolve = modules.load<typeof import('../../source-control/mobile-branch-base-ref')>(
|
||||
const resolve = modules.load<typeof import('../../../source-control/mobile-branch-base-ref')>(
|
||||
'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<typeof import('../../source-control/mobile-git-history')>(
|
||||
const history = modules.load<typeof import('../../../source-control/mobile-git-history')>(
|
||||
'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<typeof import('../../source-control/mobile-commit-message-ai')>(
|
||||
const ai = modules.load<typeof import('../../../source-control/mobile-commit-message-ai')>(
|
||||
'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<typeof import('../../source-control/mobile-pr-link')>(
|
||||
const link = modules.load<typeof import('../../../source-control/mobile-pr-link')>(
|
||||
'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
|
||||
@@ -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<typeof operationModuleLoader>,
|
||||
options: MountOptions
|
||||
): Record<string, MountAdapter> {
|
||||
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<typeof useMetadata>
|
||||
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<typeof useMetadata>[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<typeof actions.mutateProjectRowMetadata>[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<typeof useDetail>[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<typeof usePreferences>
|
||||
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<typeof usePreferences>[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<typeof actions.persistDefaultGitHubPreset>[0]
|
||||
)
|
||||
}
|
||||
throw new Error(`Unknown preferences action: ${name}`)
|
||||
},
|
||||
state: () => ({ preset: model.defaultGitHubPreset }),
|
||||
dispose: hook.unmount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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<typeof operationModuleLoader>
|
||||
@@ -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
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
/** 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<string, Header> {
|
||||
const stub = stubRoot(recorder, JSON.stringify({ baseline: BASELINE, scenarios }))
|
||||
function headers(revision: Revision, scenarios: readonly RecordingScenario[]): Map<string, Header> {
|
||||
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<string, Header>, after: Map<string, Header>): 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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof operationModuleLoader>,
|
||||
options: MountOptions
|
||||
) => Record<string, MountAdapter>
|
||||
}
|
||||
@@ -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<string, MountAdapter> = {
|
||||
...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<typeof useSearch>
|
||||
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<typeof useMetadata>
|
||||
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<typeof useMetadata>[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<typeof actions.mutateProjectRowMetadata>[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<typeof useDetail>[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<typeof import('../../session/mobile-new-tab-agent-loader')>(
|
||||
'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<typeof usePreferences>
|
||||
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<typeof usePreferences>[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<typeof actions.persistDefaultGitHubPreset>[0]
|
||||
)
|
||||
}
|
||||
throw new Error(`Unknown preferences action: ${name}`)
|
||||
},
|
||||
state: () => ({ preset: model.defaultGitHubPreset }),
|
||||
dispose: hook.unmount
|
||||
const adapters: Record<string, MountAdapter> = {}
|
||||
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 }
|
||||
|
||||
@@ -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<string, string>()
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user