diff --git a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts index c02bb62ddb..89e56adbf4 100644 --- a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts @@ -24,7 +24,13 @@ import { storeSessionForTest } from '../../../__tests__/support/store-factory.ts * `caution`/`manual` divergence is no longer a dead end. */ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + bottomTabsRealCaptureFixture, + buildNodes, + mkdtempForTestSync, + recordArticleEvidence, +} from '@agent-device/test-kit'; +import { computeTargetEvidence } from '@agent-device/selectors/target-evidence'; vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { const actual = @@ -52,11 +58,6 @@ import { writeReplayFile, } from '../replay-runtime/session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; -import { - bottomTabsRealCaptureFixture, - recordArticleEvidence, - toSnapshotNodes, -} from '@agent-device/replay-port/target-classification-fixtures'; /** Repair-transaction status, or `undefined` outside a repair publication. */ function sessionRepairStatus(session: SessionState | undefined) { @@ -90,7 +91,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' }), ); - const evidence = await recordArticleEvidence(); + const evidence = recordArticleEvidence(computeTargetEvidence); const filePath = writeReplayFile(root, [ 'open "Demo" --relaunch', `# agent-device:target-v1 ${JSON.stringify(evidence)}`, @@ -561,7 +562,7 @@ test('an unauthorized --from one past the plan end is rejected on an ARMED sessi sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' }), ); - const evidence = await recordArticleEvidence(); + const evidence = recordArticleEvidence(computeTargetEvidence); const filePath = writeReplayFile(root, [ 'open "Demo" --relaunch', `# agent-device:target-v1 ${JSON.stringify(evidence)}`, @@ -574,7 +575,7 @@ test('an unauthorized --from one past the plan end is rejected on an ARMED sessi // `state-repair` (the app-state sub-flow), never `record-and-heal`. No // `pendingRecordAndHeal` watermark is ever stamped for this session. mockDispatchCommand.mockResolvedValue({ - nodes: toSnapshotNodes([ + nodes: buildNodes([ { index: 0, type: 'Application', @@ -647,7 +648,7 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' }), ); - const evidence = await recordArticleEvidence(); + const evidence = recordArticleEvidence(computeTargetEvidence); const filePath = writeReplayFile(root, [ 'open "Demo" --relaunch', `# agent-device:target-v1 ${JSON.stringify(evidence)}`, diff --git a/packages/daemon/src/sessions/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts b/packages/daemon/src/sessions/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts index 9843768577..7468a0cbfc 100644 --- a/packages/daemon/src/sessions/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts @@ -20,7 +20,12 @@ * zero-match selector) instead of returning it. */ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + bottomTabsRealCaptureFixture, + mkdtempForTestSync, + recordArticleEvidence, +} from '@agent-device/test-kit'; +import { computeTargetEvidence } from '@agent-device/selectors/target-evidence'; vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { const actual = @@ -42,10 +47,6 @@ import { baseReplayRequest as baseReq, writeReplayFile, } from '../replay-runtime/session-replay-runtime.fixtures.ts'; -import { - bottomTabsRealCaptureFixture, - recordArticleEvidence, -} from '@agent-device/replay-port/target-classification-fixtures'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, @@ -111,7 +112,7 @@ function assertDivergenceShape( test('(a) an ANNOTATED press whose dispatch throws a selector-miss yields REPLAY_DIVERGENCE, not COMMAND_FAILED', async () => { const root = mkdtempForTestSync('agent-device-replay-dispatch-miss-annotated-'); const { sessionStore, sessionName } = setupSession(root); - const evidence = await recordArticleEvidence(); + const evidence = recordArticleEvidence(computeTargetEvidence); const filePath = writeReplayFile(root, [ `# agent-device:target-v1 ${JSON.stringify(evidence)}`, 'click id="article"', diff --git a/packages/replay-port/package.json b/packages/replay-port/package.json index da330c5e2d..e5322c7016 100644 --- a/packages/replay-port/package.json +++ b/packages/replay-port/package.json @@ -124,10 +124,6 @@ "types": "./src/daemon-port/target-classification.ts", "default": "./src/daemon-port/target-classification.ts" }, - "./target-classification-fixtures": { - "types": "./src/target-classification.fixtures.ts", - "default": "./src/target-classification.fixtures.ts" - }, "./target-token": { "types": "./src/daemon-port/target-token.ts", "default": "./src/daemon-port/target-token.ts" @@ -144,5 +140,8 @@ "types": "./src/replay-script-source.fixtures.ts", "default": "./src/replay-script-source.fixtures.ts" } + }, + "devDependencies": { + "@agent-device/test-kit": "workspace:*" } } diff --git a/packages/replay-port/src/daemon-port/__tests__/target-classification.test.ts b/packages/replay-port/src/daemon-port/__tests__/target-classification.test.ts index fa5aa68d92..992a9f1b51 100644 --- a/packages/replay-port/src/daemon-port/__tests__/target-classification.test.ts +++ b/packages/replay-port/src/daemon-port/__tests__/target-classification.test.ts @@ -16,8 +16,8 @@ import { import { bottomTabsRealCaptureFixture, recordArticleEvidence, - toSnapshotNodes, -} from '@agent-device/replay-port/target-classification-fixtures'; + buildNodes, +} from '@agent-device/test-kit'; /** Verified outcomes carry the verified member + matchCount (for the post-resolution guard). */ function assertVerified( @@ -33,7 +33,7 @@ function assertVerified( const PLATFORM = 'ios' as const; test('classifyReplayTarget: real-capture fixture verifies by @ref when the tree is unchanged', async () => { - const recorded = await recordArticleEvidence(); + const recorded = recordArticleEvidence(computeTargetEvidence); const replayNodes = bottomTabsRealCaptureFixture(); const winner = replayNodes.find((node) => node.label === 'Article, unselected'); assert.ok(winner); @@ -50,7 +50,7 @@ test('classifyReplayTarget: real-capture fixture verifies by @ref when the tree }); test('classifyReplayTarget: real-capture fixture — a relabeled node is identity-mismatch (path 3)', async () => { - const recorded = await recordArticleEvidence(); + const recorded = recordArticleEvidence(computeTargetEvidence); const replayNodes = bottomTabsRealCaptureFixture(); const winner = replayNodes.find((node) => node.label === 'Article, unselected'); assert.ok(winner); @@ -99,7 +99,7 @@ function saveButtonRecorded(overrides: Partial = {}): Target } function saveButtonTree(): SnapshotNode[] { - return toSnapshotNodes([ + return buildNodes([ { index: 0, type: 'Window', depth: 0 }, { index: 1, type: 'Toolbar', label: 'Editor', depth: 1, parentIndex: 0 }, { @@ -116,7 +116,7 @@ function saveButtonTree(): SnapshotNode[] { test('classifyReplayTarget path 2: selector-miss when the recorded target is gone', () => { const recorded = saveButtonRecorded(); - const nodes = toSnapshotNodes([ + const nodes = buildNodes([ { index: 0, type: 'Window', depth: 0 }, { index: 1, type: 'Toolbar', label: 'Editor', depth: 1, parentIndex: 0 }, ]); @@ -152,7 +152,7 @@ test('classifyReplayTarget path 4: verified via @ref on an unchanged tree', () = }); test('classifyReplayTarget uses the later chain alternative that resolution selected after an earlier tie', () => { - const nodes = toSnapshotNodes([ + const nodes = buildNodes([ { index: 0, type: 'Button', @@ -231,7 +231,7 @@ test('classifyReplayTarget path 5: a unique-but-wrong rebind is caught even when // Two "Go back" buttons at different depths: the label-only selector // matches both, but only one carries the recorded id. The disambiguation // heuristic (deepest-then-smallest-area) prefers the decoy. - const nodes = toSnapshotNodes([ + const nodes = buildNodes([ { index: 0, type: 'Window', rect: { x: 0, y: 0, width: 400, height: 800 }, depth: 0 }, { index: 1, @@ -279,7 +279,7 @@ test('classifyReplayTarget path 5: a unique-but-wrong rebind is caught even when // --------------------------------------------------------------------------- function duplicateRowTree(): SnapshotNode[] { - return toSnapshotNodes([ + return buildNodes([ { index: 0, type: 'Window', depth: 0 }, { index: 1, type: 'ScrollView', identifier: 'list', depth: 1, parentIndex: 0 }, // Two anonymous section wrappers (role only, no label) — a real @@ -439,7 +439,7 @@ test('classifyReplayTarget: document-order determinism for equal rect centers', // tie-break were nondeterministic or reversed, the winner (e3) would not // match `orderedRegion[0]` and this would report a mismatch instead of // verified. - const nodes = toSnapshotNodes([ + const nodes = buildNodes([ { index: 0, type: 'ScrollView', identifier: 'list', depth: 0 }, { index: 1, type: 'Other', depth: 1, parentIndex: 0 }, { index: 2, type: 'Other', depth: 1, parentIndex: 0 }, @@ -508,7 +508,7 @@ function androidSharedIdListFixture( parentIndex: wrapperIndex, }); }); - return toSnapshotNodes(raw); + return buildNodes(raw); } test('#1269 e2e: a demoted shared-id row rebinds by role+label after the shared-id rows reorder on replay', () => { @@ -663,7 +663,7 @@ test('#1280 e2e: a retargeted press on a row container rebinds its labeled desce // --------------------------------------------------------------------------- test('#2498 e2e: a read recorded on a collapsed wrapper chain verifies on the same tree', () => { - const nodes = toSnapshotNodes(UNVERIFIED_HITTABILITY_WRAPPER_CHAIN_NODES); + const nodes = buildNodes(UNVERIFIED_HITTABILITY_WRAPPER_CHAIN_NODES); const reports = nodes.filter((node) => node.identifier === 'scoring_home_button'); const control = resolveElementReportedTwice(nodes, reports); assert.ok(control, 'dispatch resolves the toolbar button, not the item host that reports it'); @@ -684,7 +684,7 @@ test('#2498 e2e: a read recorded on a collapsed wrapper chain verifies on the sa }); test('#2498 e2e: matches in distinct subtrees stay a refusal, not a collapse to one of them', () => { - const nodes = toSnapshotNodes(ELEMENT14_DISTINCT_SUBTREE_NODES); + const nodes = buildNodes(ELEMENT14_DISTINCT_SUBTREE_NODES); const recordedWinner = nodes.find((node) => node.type === 'XCUIElementTypeButton'); assert.ok(recordedWinner); const recorded = computeTargetEvidence({ node: recordedWinner, preActionNodes: nodes }); diff --git a/packages/replay-port/tsconfig.json b/packages/replay-port/tsconfig.json index 1d50b78f96..92d1911f45 100644 --- a/packages/replay-port/tsconfig.json +++ b/packages/replay-port/tsconfig.json @@ -22,6 +22,7 @@ { "path": "../maestro" }, { "path": "../replay-test" }, { "path": "../selectors" }, - { "path": "../session-journal" } + { "path": "../session-journal" }, + { "path": "../test-kit" } ] } diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 6075087256..1ace2c4f17 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -77,6 +77,10 @@ export { unavailableDeploymentSnapshotAndShutdownOperationFacts, } from './runtime-operation-facts.ts'; export { buildNodes } from './snapshot-builders.ts'; +export { + bottomTabsRealCaptureFixture, + recordArticleEvidence, +} from './target-classification-fixtures.ts'; export { mkdtempForTest, mkdtempForTestSync } from './tmp-dir.ts'; export { likelyPlayableWebmContainer } from './video-fixtures.ts'; export { diff --git a/packages/replay-port/src/target-classification.fixtures.ts b/packages/test-kit/src/target-classification-fixtures.ts similarity index 82% rename from packages/replay-port/src/target-classification.fixtures.ts rename to packages/test-kit/src/target-classification-fixtures.ts index 1eead75663..01836c9068 100644 --- a/packages/replay-port/src/target-classification.fixtures.ts +++ b/packages/test-kit/src/target-classification-fixtures.ts @@ -1,13 +1,10 @@ import assert from 'node:assert/strict'; -import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; - -export function toSnapshotNodes(raw: RawSnapshotNode[]): SnapshotNode[] { - return raw.map((node, position) => ({ ...node, ref: `e${position + 1}` })); -} +import { buildNodes } from './snapshot-builders.ts'; export function bottomTabsRealCaptureFixture(): SnapshotNode[] { - return toSnapshotNodes([ + return buildNodes([ { index: 0, type: 'Application', @@ -80,8 +77,12 @@ export function bottomTabsRealCaptureFixture(): SnapshotNode[] { ]); } -export async function recordArticleEvidence(): Promise { - const { computeTargetEvidence } = await import('@agent-device/selectors/target-evidence'); +export function recordArticleEvidence( + computeTargetEvidence: (capture: { + node: SnapshotNode; + preActionNodes: SnapshotNode[]; + }) => TargetAnnotationV1 | undefined, +): TargetAnnotationV1 { const nodes = bottomTabsRealCaptureFixture(); const winner = nodes.find((node) => node.label === 'Article, unselected'); if (!winner) throw new Error('fixture missing Article tab'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aadb93b9c0..7b1687f0ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -681,6 +681,10 @@ importers: '@agent-device/session-journal': specifier: workspace:* version: link:../session-journal + devDependencies: + '@agent-device/test-kit': + specifier: workspace:* + version: link:../test-kit packages/replay-test: dependencies: