Files
callstack__agent-device/scripts/__tests__/help-conformance-sample-producers.ts
Michał Pierzchała 1f9d940bff refactor(capture-kit): complete ADR 0019 end state — relocate snapshot and recording zones (#2385)
* refactor(capture-kit): relocate snapshot and recording zones into capture-kit

Move the ADR 0019 end-state capture zones into @agent-device/capture-kit:

- src/snapshot/** -> packages/capture-kit/src/snapshot/** (presentation,
  freshness, scroll-edge-state, ios-snapshot-runtime, android occlusion)
- src/recording/** -> packages/capture-kit/src/recording/**
- src/core/snapshot-{chrome,state,tree-ingestion,node-lookup}.ts ->
  packages/capture-kit/src/
- src/snapshot-quality/ test -> capture-kit presentation tree (directory
  retires with its last file)

Pure renames: import re-pointing and gate updates follow in the next commit.
The snapshot-desktop-surface test parks in src/__tests__/ because it pins
the root eager-import-closure walker.

* refactor(capture-kit): re-point capture and recording consumers to the new subpaths

Rewires every consumer of the relocated snapshot/recording modules to the new @agent-device/capture-kit subpath exports, adds the 23 subpath entries to the capture-kit exports map, fixes the moved recording-scripts test's __dirname-relative paths for the deeper location, and records the completed migration in ADR 0019's end state.

* chore(gates): align layering, mutation, fallow and CI gates with the capture-kit relocation

Moves the executable-policy roots, presentation-owner constant, zone ranks, authority fixture, mutation sharding globs, stryker aliases, fallow baselines and the iOS workflow's android-owned paths-ignore entry onto the new packages/capture-kit paths, and extends the planted-red coverage to the new presentation-owner subpath.

* chore: point capture-domain source-of-truth comments at the relocated capture-kit modules

* test: point shutdown recording mock at capture-kit and cover interactor acquisition presentation

* test(capture-kit): update upstream presentation test imports

* chore(gates): follow relocated snapshot assembly in R74

* test(daemon): freeze prewarm deadline assertion clocks
2026-09-08 12:41:39 +02:00

503 lines
17 KiB
TypeScript

import type { CapturedSample } from '../help-conformance-sample-outputs.mjs';
import {
AMBIGUOUS_MATCH_SAMPLE,
APP_NOT_INSTALLED_SAMPLE,
BROWSERSTACK_CONNECT_SAMPLE,
DEVICE_IN_USE_SAMPLE,
DEVICE_CLAIM_IN_USE_SAMPLE,
FOREGROUND_SNAPSHOT_FAILURE_SAMPLE,
MERGED_CARD_ACTIONS_SAMPLE,
NOT_SETTLED_SAMPLE,
OFFSCREEN_TARGET_SNAPSHOT_SAMPLE,
PRIVATE_AX_RECOVERY_SAMPLE,
SETTLE_DIFF_SAMPLE,
SETTLE_DIFF_SAMPLE_NOTES,
SETTLE_TAIL_SAMPLE,
STALE_REF_SAMPLE,
} from '../help-conformance-sample-outputs.mjs';
import { interactionCliOutputFormatters } from '../../src/commands/interaction/output.ts';
import { snapshotCliOutput } from '../../src/commands/capture/output.ts';
import { openCliOutput } from '../../src/commands/management/output.ts';
import { NEVER_SETTLED_HINT } from '../../src/commands/interaction/runtime/settle.ts';
import { buildAmbiguousMatchError } from '../../src/daemon/selector-match-errors.ts';
import { refMutationAdmissionResponse } from '../../src/daemon/interaction/index.ts';
import { buildDeviceInUseBySessionError } from '../../src/daemon/session-recovery-hints.ts';
import { buildDeviceClaimConflictError } from '../../src/daemon/device-claim-conflict.ts';
import { activateCompleteRefFrame, readRefMutationFrame } from '../../src/daemon/ref-frame.ts';
import { resolveRefStalenessWarning } from '../../src/daemon/session-snapshot.ts';
import type { SessionState } from '../../src/daemon/session-state.ts';
import { buildAppNotInstalledError } from '@agent-device/platform-apple/app-resolution';
import {
presentConnectReadiness,
renderConnectSuccess,
} from '../../src/cli/commands/connection-presentation.ts';
import type { ConnectVerification } from '../../src/cli/connection/connect-provider-adapters.ts';
import type { RemoteConnectionState } from '../../src/remote/remote-connection-state.ts';
import { AppError, normalizeError } from '@agent-device/kernel/errors';
import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot';
import { renderSnapshotQualityWarnings } from '@agent-device/capture-kit/quality-warnings';
import { printHumanError } from '../../src/commands/output/error.ts';
import { formatSnapshotText } from '../../src/commands/output/snapshot.ts';
// The production renderer behind each captured sample in
// scripts/help-conformance-sample-outputs.mjs, as data rather than as one test
// per sample. help-conformance-sample-outputs.test.ts asserts every entry
// rebuilds its sample's exact text, and
// help-conformance-error-recovery-coverage.test.ts asserts every exported
// sample HAS an entry here — so a new sample cannot ship pinned to nothing but
// its own transcription, which is the drift class this registry exists to
// prevent.
export type SampleProducer = {
/** Export name in help-conformance-sample-outputs.mjs, for test titles. */
name: string;
/** What the sample is pinned to, read as "<name> matches <producer>". */
producer: string;
sample: CapturedSample;
/** The sample's text rebuilt from production code, ready to compare. */
render: () => string | Promise<string>;
};
const formatPress = (result: Record<string, unknown>) =>
interactionCliOutputFormatters.press({ input: {}, result }).text;
const formatFill = (result: Record<string, unknown>) =>
interactionCliOutputFormatters.fill({ input: {}, result }).text;
/** The `Error (CODE): …` + `Hint: …` text printHumanError writes to stderr. */
async function renderHumanError(error: AppError): Promise<string> {
const lines: string[] = [];
const originalWrite = process.stderr.write;
process.stderr.write = ((chunk: string | Uint8Array) => {
lines.push(String(chunk));
return true;
}) as typeof process.stderr.write;
try {
await printHumanError(normalizeError(error));
} finally {
process.stderr.write = originalWrite;
}
return lines.join('').trimEnd();
}
type ErrorResponse = {
ok: false;
error: {
code: string;
message: string;
hint?: string;
retriable?: boolean;
details?: Record<string, unknown>;
};
};
/** Renders a daemon error response the way the CLI prints it for a human. */
async function renderErrorResponse(response: ErrorResponse): Promise<string> {
return await renderHumanError(
new AppError(
response.error.code as ConstructorParameters<typeof AppError>[0],
response.error.message,
{
...response.error.details,
...(response.error.hint ? { hint: response.error.hint } : {}),
...(response.error.retriable === undefined ? {} : { retriable: response.error.retriable }),
} as ConstructorParameters<typeof AppError>[2],
),
);
}
function assertErrorResponse(
response: { ok: boolean } | undefined,
what: string,
): asserts response is ErrorResponse {
if (!response || response.ok) throw new Error(`${what} must be an error response`);
}
export const SAMPLE_PRODUCERS: SampleProducer[] = [
{
name: 'SETTLE_TAIL_SAMPLE',
producer: 'the press renderer, including ADR 0014 ref pinning',
sample: SETTLE_TAIL_SAMPLE,
render: () =>
formatPress({
ref: 'e37',
x: 203,
y: 88,
settle: {
settled: true,
waitedMs: 540,
refsGeneration: 5,
diff: {
summary: { additions: 0, removals: 1, unchanged: 15 },
lines: [{ kind: 'removed', text: '@e50 [text] "Suggested for you"' }],
},
tail: [
{ ref: 'e64', role: 'text-field', label: 'Search' },
{ ref: 'e65', role: 'text', label: 'Recent searches' },
{ ref: 'e12', role: 'tab', label: 'Home' },
{ ref: 'e40', role: 'tab', label: 'Profile' },
],
},
}),
},
{
name: 'SETTLE_DIFF_SAMPLE',
producer: 'the fill renderer',
sample: SETTLE_DIFF_SAMPLE,
render: () =>
formatFill({
text: 'callstack',
message: 'Filled 9 chars',
settle: {
settled: true,
waitedMs: 610,
refsGeneration: 6,
diff: {
summary: { additions: 2, removals: 0, unchanged: 18 },
lines: [
{ kind: 'added', text: '@e64 [button] "@callstack.com"' },
{ kind: 'added', text: '@e65 [text] "Callstack"' },
],
},
},
}),
},
{
name: 'SETTLE_DIFF_SAMPLE_NOTES',
producer: 'the fill renderer (metamorphic twin)',
sample: SETTLE_DIFF_SAMPLE_NOTES,
render: () =>
formatFill({
text: 'groceries',
message: 'Filled 9 chars',
settle: {
settled: true,
waitedMs: 480,
refsGeneration: 3,
diff: {
summary: { additions: 2, removals: 0, unchanged: 11 },
lines: [
{ kind: 'added', text: '@e21 [button] "Groceries list"' },
{ kind: 'added', text: '@e22 [text] "3 items"' },
],
},
},
}),
},
{
name: 'NOT_SETTLED_SAMPLE',
producer: 'the press renderer and the production NEVER_SETTLED_HINT',
sample: NOT_SETTLED_SAMPLE,
render: () =>
formatPress({
ref: 'e12',
x: 166,
y: 240,
settle: { settled: false, waitedMs: 10_000, hint: NEVER_SETTLED_HINT },
}),
},
{
name: 'PRIVATE_AX_RECOVERY_SAMPLE',
producer: 'the snapshot renderer and the snapshot-quality warning',
sample: PRIVATE_AX_RECOVERY_SAMPLE,
render: async () => {
const nodes = [
{
index: 1,
ref: 'e5',
type: 'Button',
label: 'Search',
interactive: true,
rect: { x: 20, y: 120, width: 200, height: 44 },
},
{
index: 2,
ref: 'e8',
type: 'Tab',
label: 'Home',
selected: true,
interactive: true,
rect: { x: 0, y: 780, width: 100, height: 60 },
},
];
const verdict = { state: 'recovered', backend: 'private-ax' } as SnapshotQualityVerdict;
return formatSnapshotText({
nodes,
backend: 'private-ax',
snapshotQuality: verdict,
warnings: renderSnapshotQualityWarnings(verdict, nodes),
interactiveOnly: true,
}).trimEnd();
},
},
{
name: 'OFFSCREEN_TARGET_SNAPSHOT_SAMPLE',
producer: 'the visible-first snapshot renderer with off-screen rows summarized',
sample: OFFSCREEN_TARGET_SNAPSHOT_SAMPLE,
render: () => {
// A settings-style scrollable list in an 800pt viewport: five rows fit,
// four more (Privacy & Security, Notifications, Wallpaper, Developer) are
// laid out below it, so visible-first presentation summarizes them and
// none of their refs reach the output.
const row = (index: number, ref: string, label: string, y: number) => ({
index,
ref,
parentIndex: 2,
type: 'Cell',
label,
interactive: true,
hittable: y < 800,
rect: { x: 0, y, width: 390, height: 120 },
});
const nodes = [
{
index: 0,
ref: 'e1',
type: 'Application',
label: 'Preferences',
rect: { x: 0, y: 0, width: 390, height: 800 },
},
{
index: 1,
ref: 'e2',
parentIndex: 0,
type: 'Window',
rect: { x: 0, y: 0, width: 390, height: 800 },
},
{
index: 2,
ref: 'e3',
parentIndex: 1,
type: 'CollectionView',
interactive: true,
rect: { x: 0, y: 60, width: 390, height: 740 },
},
row(3, 'e4', 'General', 60),
row(4, 'e5', 'Display', 190),
row(5, 'e6', 'Sounds', 320),
row(6, 'e7', 'Focus', 450),
row(7, 'e8', 'Screen Time', 580),
row(8, 'e9', 'Privacy & Security', 900),
row(9, 'e10', 'Notifications', 1030),
row(10, 'e11', 'Wallpaper', 1160),
row(11, 'e12', 'Developer', 1290),
];
return formatSnapshotText(
{ nodes, backend: 'xctest', truncated: false },
{ interactiveOnly: true },
).trimEnd();
},
},
{
name: 'FOREGROUND_SNAPSHOT_FAILURE_SAMPLE',
producer: 'the open success renderer with a failed composed snapshot',
sample: FOREGROUND_SNAPSHOT_FAILURE_SAMPLE,
render: async () => {
const warning =
'The session is open, but the initial interactive snapshot failed (COMMAND_FAILED: capture failed). Run: agent-device snapshot -i';
return (
(
await openCliOutput({
session: 'default',
warnings: [warning],
initialSnapshotError: {
code: 'COMMAND_FAILED',
message: 'capture failed',
},
identifiers: { session: 'default' },
})
).text ?? ''
);
},
},
{
name: 'MERGED_CARD_ACTIONS_SAMPLE',
producer: "the snapshot renderer with --actions naming a merged element's custom actions",
sample: MERGED_CARD_ACTIONS_SAMPLE,
render: async () => {
// A Bluesky-style feed item merged into one Link node: its Reply/Repost/
// menu controls are AX custom actions, not child nodes, so they only
// surface when --actions is passed through to the renderer.
const nodes = [
{
index: 0,
ref: 'e1',
depth: 0,
type: 'Application',
label: 'Bluesky',
rect: { x: 0, y: 0, width: 390, height: 844 },
},
{
index: 1,
ref: 'e2',
parentIndex: 0,
depth: 1,
type: 'Window',
rect: { x: 0, y: 0, width: 390, height: 844 },
},
{
index: 2,
ref: 'e3',
parentIndex: 1,
depth: 2,
type: 'CollectionView',
interactive: true,
rect: { x: 0, y: 60, width: 390, height: 700 },
},
{
index: 3,
ref: 'e72',
parentIndex: 2,
depth: 3,
type: 'Link',
label: 'feedItem-by-whiskers.test',
interactive: true,
rect: { x: 0, y: 60, width: 390, height: 140 },
actions: ['Reply', 'Repost', 'Open post options menu'],
},
];
const output = await snapshotCliOutput({
result: { nodes, backend: 'xctest', truncated: false },
interactiveOnly: true,
});
return (output.text ?? '').trimEnd();
},
},
{
name: 'DEVICE_IN_USE_SAMPLE',
producer: 'the real session-open by-session conflict producer',
sample: DEVICE_IN_USE_SAMPLE,
render: () => {
// An explicitly named session is stored under its own name, so address === name here.
const owningSession = { address: 'checkout', session: { name: 'checkout' } as SessionState };
const device = { id: 'SIM-001', name: 'iPhone 17 Pro' } as Parameters<
typeof buildDeviceInUseBySessionError
>[1];
const response = buildDeviceInUseBySessionError(owningSession, device);
assertErrorResponse(response, 'the by-session conflict');
return renderErrorResponse(response);
},
},
{
name: 'DEVICE_CLAIM_IN_USE_SAMPLE',
producer: 'the enforced cross-daemon device-claim conflict producer',
sample: DEVICE_CLAIM_IN_USE_SAMPLE,
render: () => {
const response = buildDeviceClaimConflictError(
{
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
},
{
fileName: 'claim.json',
deviceKey: 'local:android:none:emulator-5554',
classification: 'live',
claim: {
schemaVersion: 2,
deviceKey: 'local:android:none:emulator-5554',
device: {
family: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
},
session: 'checkout',
workspace: '/worktrees/checkout',
stateDir: '/state/checkout',
ownerPid: 4242,
ownerStartTime: 'start',
ownerToken: 'token',
createdAtMs: 1,
updatedAtMs: 1,
},
},
);
assertErrorResponse(response, 'the enforced device-claim conflict');
return renderErrorResponse(response);
},
},
{
name: 'STALE_REF_SAMPLE',
producer: 'the real ADR 0014 admission rejection and staleness hint',
sample: STALE_REF_SAMPLE,
render: () => {
const session = { snapshotGeneration: 7 } as SessionState;
// Pin the epoch in the frame itself, as a real published namespace does, so the
// sample exercises the frame epoch rather than the pre-frame fallback.
activateCompleteRefFrame(session);
const response = refMutationAdmissionResponse({
session,
ref: '@e12',
mintedGeneration: 5,
staleRefsWarning: resolveRefStalenessWarning({ session, ref: '@e12', mintedGeneration: 5 }),
frame: readRefMutationFrame({ session, ref: '@e12', mintedGeneration: 5 }),
});
assertErrorResponse(response, 'a superseded pin');
return renderErrorResponse(response);
},
},
{
name: 'AMBIGUOUS_MATCH_SAMPLE',
producer: 'the real find producer and the default hint',
sample: AMBIGUOUS_MATCH_SAMPLE,
render: () => {
const matches = [
{ ref: 'e2', type: 'Button', label: 'Follow' },
{ ref: 'e5', type: 'Button', label: 'Follow' },
{ ref: 'e9', type: 'Button', label: 'Follow' },
] as Parameters<typeof buildAmbiguousMatchError>[0];
const response = buildAmbiguousMatchError(matches, 'text', 'Follow');
assertErrorResponse(response, 'an ambiguous find');
return renderErrorResponse(response);
},
},
{
name: 'APP_NOT_INSTALLED_SAMPLE',
producer: 'the real app-resolution producer and the default hint',
sample: APP_NOT_INSTALLED_SAMPLE,
render: () => renderHumanError(buildAppNotInstalledError('Shoply')),
},
{
name: 'BROWSERSTACK_CONNECT_SAMPLE',
producer: 'the connect success renderer with verified BrowserStack readiness',
sample: BROWSERSTACK_CONNECT_SAMPLE,
render: () => {
const state = {
version: 1,
session: 'adc-browserstack',
tenant: 'direct-browserstack',
runId: 'run-browserstack',
leaseProvider: 'browserstack',
platform: 'android',
remoteConfigPath: '/tmp/browserstack.json',
remoteConfigHash: 'fixture',
connectedAt: '2026-08-03T00:00:00.000Z',
updatedAt: '2026-08-03T00:00:00.000Z',
} satisfies RemoteConnectionState;
const facts = {
provider: 'browserstack',
service: 'BrowserStack',
verificationMessage: 'Credentials, device, and uploaded app verified.',
device: {
status: 'verified',
name: 'Google Pixel 8',
platform: 'android',
osVersion: '14.0',
},
app: {
status: 'verified',
name: 'sample.apk',
reference: 'bs://app-id',
version: '1.2.3',
},
} satisfies ConnectVerification;
return renderConnectSuccess({
state,
readiness: presentConnectReadiness(state, facts),
});
},
},
];