mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
296447707e
* refactor: migrate back/home/orientation/tv-remote/keyboard to the request-bound device runtime Continues the ADR 0019 platform-runtime migration (Wave 5 generic leaves): five generic-route commands move off dispatchKnownCommand/Interactor legacy dispatch onto fact-owned admission, one bind per handler. keyboard uses the R35 action-selected single-bind pattern (status/dismiss/enter each admit and bind independently). All 8 owner runtime packages gained fact-cell tests for the new operations; six smoke-coverage integration oracles and nine daemon/capability unit test files were updated for the retired capability- catalog admission these commands no longer carry. * refactor: extract shared interactor-resolution prelude in keyboard-runtime bindKeyboardStatus/Dismiss/Enter repeated the same signal-check + resolveInteractor call; factor it into resolveKeyboardInteractor so each binder is a two-line call instead of a six-line copy. No behavior change — the three contract-module mutants planted earlier in review still kill on this shape. * fix: refuse watchOS admission for back/home/orientation/keyboard; pin tv-remote non-TV parity P1: watchOS has no constructible Apple interactor (XCUITest cannot drive its UI, ADR-0009), matching the existing captureScreenshot/captureSnapshot/readTextAtPoint/findSelector pattern in this same file. appleBackFact/appleHomeFact/appleMobileInputEligible admitted every Apple OS but tvOS/macOS, wrongly including watchOS. Facts now refuse watchOS explicitly for back, home, orientation, and keyboard dismiss/enter, with a fact-cell test asserting no binding for every one of them. P2: verified the daemon's generic-route capability gate already reproduced the retired per-platform tv-remote hint text (message stays the generic "<command> is not supported on this device", hint carries the owner-specific text) for every device that could reach dispatch in the old system -- the retired handleTvRemoteCommand's own "supported only on TV targets" check was unreachable there and only exercised by a test calling dispatchCommand directly. Added a daemon-level test pinning the exact iOS and Android-mobile hint strings to make that parity explicit instead of implicit. Also fixes a fallow complexity finding the P1 test edit introduced by splitting the fact-cell assertions into five small named helpers instead of one large function. * fix: stop orientation-runtime.test.ts's router-join test from hitting real adb Root-caused the CI-only Coverage failure (unreproducible locally in isolation, reproducible 2/2 in the full CI run): every generic-route leaf this migration touches carries `androidBlockingDialogGuard: true`, and `dispatchGenericCommand` calls `ensureNoAndroidBlockingDialogReady` unconditionally for any `platform: 'android'` session reaching the real request router -- regardless of whether admission is fact-based or capability-based. That check calls `getAndroidBlockingDialogFocus`, which shells out to the real `adb` binary. orientation-runtime.test.ts's "request router joins..." test used a synthetic `platform: 'android'` device through `createRequestHandler` (the real router), without stubbing the platform ADB layer -- only the runtime gateway was mocked. On a host with a real `adb` binary (my machine) the subprocess fails fast and `allowFailure` tolerates it, costing ~800ms-1.1s but still succeeding. On a host with no `adb` binary at all (CI's Coverage job, a plain unit-test lane with no Android SDK) the spawn itself throws, which isn't something `allowFailure` catches, producing exactly the observed `ok: false` unsupported-operation response. back/home/tv-remote's equivalent router-join tests already use Apple/Vega devices, so they never reached this path. Switched orientation's fixture to match -- Apple, since the fixture's facts/execution are fully synthetic and platform-agnostic regardless. Also: renamed the widely-shared 'emulator-5554'/'ios-simulator' device-id literals in back/orientation/tv-remote/keyboard-runtime.test.ts to file-scoped ids. Device claims for a `local-family` owner binding hit the real on-disk `require-owner` claim file (keyed only by canonical device id), and 27+ pre-existing test files already share 'emulator-5554'; this migration added three more consumers of it under a `require-owner` policy that reaches real admission, which was worth eliminating as a source of doubt even though it wasn't the actual root cause here. * refactor: extract navigation/keyboard concepts into sibling modules; test the real Android dialog-guard path packages/platform-apple/src/runtime.ts and packages/provider-limrun/src/app-log-runtime.ts grew past the repo's 500-line extraction threshold. Move the new back/home/orientation/ tv-remote/keyboard facts and bindings into packages/platform-apple/src/navigation/runtime.ts (new sibling module, matching deployment/runtime.ts's existing pattern), and the new keyboard facts/bindings for limrun into the existing packages/provider-limrun/src/interaction-operations.ts (which already held the sibling navigation logic). Also fix orientation-runtime.test.ts's router-join test: it previously swapped its device fixture from Android to Apple to dodge the real adb-backed blocking-dialog guard, which masked the Android route that was actually failing in CI. Keep the Android fixture and stub getAndroidBlockingDialogFocus instead, the same seam request-router-android-modal.test.ts already uses. * refactor: adopt granular contracts subpaths for back/home/orientation/tv-remote/keyboard Following main's #1969 (facade granularization), give each of this branch's five new contract modules their own package.json entry subpath and move every value-importer (owner runtime packages, the daemon binders, and their tests) off the wide @agent-device/contracts/platform facade onto the specific module that owns the symbol — the same convention #1969 established for the rest of the vocabulary. Keeps this migration's files out of the contracts-entry-closure gate and out of the eager-evaluation cost #1969 measured for the daemon's permanent hubs (registry.ts, dispatch.ts). * refactor: shared navigation/keyboard binder table; dedupe keyboard admission; drop restated types Addresses the review's finding 1 (seven per-owner copies of the same "fact-keyed table of interactor binders" pattern) by extracting bindAdmittedLocalInteractorOperations/bindAdmittedProviderInteractorOperations into packages/contracts/src/interactor-operation-catalog.ts. Each owner now requests the subset of back/home/setOrientation/tvRemote/keyboard{Status, Dismiss,Enter} it admits, instead of hand-writing `facts.operations.<key>.available ? bind…(resolver) : {}` per operation. Applied across all seven call sites (apple, android, harmonyos, vega, linux, webdriver, limrun) and collapsed limrun's two separate bind functions (navigation, keyboard) into one shared call. Finding 3 (resolveBoundKeyboardRuntime copy-pastes admit-then-wrap three times): extracted a local admitKeyboardAction<...> helper mirroring resolveBoundGenericRuntime's admit-then-defer shape, so the three action branches (status/dismiss/enter) share one admission path. Finding 6 (execute* helpers hand-restate a contract that can drift): back/ home/orientation/tv-remote/keyboard's execute functions are now typed off `BoundDeviceRuntime<typeof xRuntimeUse>` (derived from the actual bind-use value) instead of a hand-written `Readonly<{ operations: Readonly<{...}> }>` shape. Also fixed provider-limrun's `RuntimeOperationUnavailability | { available: true }` restating RuntimeOperationFact by hand — folded away entirely once the bind functions it typed were removed. Finding 7 (naming/placement): platform-apple/runtime.ts's misleadingly-named `captureOperations` bucket (held deployment/network/recording/find, not just capture) collapsed into one flat `operations` object now that the navigation bucket is a single function call instead of six ternaries. Exported RuntimeAdmissionRequest from runtime-admission.ts (needed by the new keyboard admission helper). Added packages/contracts/src/ interactor-operation-catalog.test.ts for the new shared binder table. pnpm typecheck, check:fallow, check:layering, and the full unit-core suite (1010 files / 7513 tests, one known contention-flake excluded) are green. * refactor: split generic-mutating command traits from the legacy dispatch pair Addresses the review's finding 5: GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS bundled two orthogonal things (daemon/recording traits, and the legacy capability+dispatch pair migration strips), forcing every migrated descriptor to hand-expand the constant minus two fields plus an explanatory comment. Split into GENERIC_MUTATING_COMMAND_TRAITS (the shared daemon/recording traits) and LEGACY_LINUX_DEVICE_EXECUTION (the dispatch/capability pair). back/home/orientation/tv-remote (this migration) and focus (an earlier one, same pattern, previously a stale reference to the retired constant name) now spread the trait constant directly instead of hand-expanding it; the still-legacy `scroll` descriptor spreads both pieces, equivalent to the retired constant. pnpm typecheck, check:fallow, check:layering, and the registry/daemon test suites are green. * refactor: table-ify packages/contracts/src/keyboard-runtime.ts's three-way duplication Finding 3's second half: the three bindKeyboardX functions and six bindLocal/ProviderKeyboardXInteractor entry points differed only by method name and label string. Replaced with one generic bindKeyboardAction<Key> dispatching off the operation key (interactor[key], resolved from a small label table) plus two shared local/provider dispatch helpers the six named exports each call with their own key — collapsing three copies of the bind logic into one and six near-duplicate entry-point bodies into one line each, while keeping every exported name and type signature unchanged. pnpm typecheck, check:fallow, check:layering, and pnpm check:affected --run are green. * refactor: parameterize runSessionOrSelectorDispatch with an execute strategy Addresses the review's finding 2: handleKeyboardCommand re-implemented runSessionOrSelectorDispatch's orchestration step for step (session/selector guard, device resolve, ref-frame expiry, record) instead of reusing it, because the shared function had no seam for keyboard's bind-and-execute admission — only the legacy requireCommandSupported + dispatchCommand path. That left the shared orchestrator with one caller instead of two, and set a precedent that would fork a new copy for each of the 28 remaining session-route migrations. Gave runSessionOrSelectorDispatch an `execute` parameter: the orchestration (guard, resolve device, admit-then-execute, expire ref frame if mutating, derive and record next session) stays in one place, and callers supply their own admission/execution strategy. Extracted `legacySessionDispatchExecute` for the still-legacy capability-gate-then-dispatchCommand shape `handleTriggerAppEventCommand` (the remaining legacy caller) now passes explicitly, and `keyboardSessionExecute` for keyboard's bind-and-execute shape. Deleted the now-fully-redundant `executeBoundKeyboardCommand` — its result recording duplicated what the shared orchestrator's tail already does. pnpm typecheck, check:fallow, check:layering, the full daemon test suite (321 files / 2271 tests), and pnpm check:affected --run are green. * refactor: extract limrun facts-runtime.ts; discriminate KeyboardDismissResult by owner app-log-runtime.ts was still 589 lines after the shared-abstraction fixes; moves fact assembly (limrunAppLogFacts/limrunAppLogRecoveryFacts/limrunLifecycleFacts/deploymentOptions) to a new facts-runtime.ts and the shared device-identity predicate to device.ts, the leaf both files already depend on. app-log-runtime.ts is now 336 lines. KeyboardDismissResult was an 11-field optional bag with executeKeyboardDismiss separately re-deriving platform from the device and projecting subsets by hand. Each owner (android, apple, harmonyos) now tags its own result with a `kind` discriminant, so an owner can only ever produce its own shape, and the daemon derives the wire `platform` label from `kind` instead of guessing from the device a second time. Wire output is unchanged. * fix: expire ref frame before the mutating call, not after; extract session/selector dispatch; derive catalog operations from facts runSessionOrSelectorDispatch awaited execute(device, session) — which bundled admission and the mutating invocation together — before expiring the ref frame, so a rejecting or timed-out invocation left a stale frame active (ADR 0014 requires expiry immediately before the mutating call, with no success-only rollback). Split the execute thunk into `prepare` (admission only) + a deferred `execute` invocation, so the orchestrator can expire between them regardless of how the invocation resolves. Added a regression test proving the frame still expires when the invocation rejects. Extracted runSessionOrSelectorDispatch and its keyboard/trigger-app-event callers into a new session-selector-dispatch.ts, matching this file's own convention of one file per command-group (session.ts shrinks from 571 to well under its 500-line budget). bindAdmittedLocalInteractorOperations/bindAdmittedProviderInteractorOperations accepted both a facts object and a separately hand-maintained `operations` array naming the same keys — a second source of truth that could drift from what the facts actually admit. Removed the array; the binder now walks the fixed set of navigation operations and lets each owner's own facts decide what binds, exactly as before but with one source of truth. * style: reformat legacySessionDispatchExecute call in session-selector-dispatch.ts * refactor: derive catalog operation list from one canonical tuple; move keyboard orchestration tests NAVIGATION_INTERACTOR_OPERATIONS was declared as a plain readonly array independently of the NavigationInteractorOperation union it walked, so a future union member could compile without ever being added to the walk list, silently preventing an admitted fact from binding. Made the tuple the single canonical value: the union type is now derived from it via `(typeof TUPLE)[number]`, so LOCAL_BINDERS/PROVIDER_BINDERS' Record<NavigationInteractorOperation, ...> completeness is checked against the same tuple, not a separately hand-kept list. Added a regression test binding all seven operations at once to pin the runtime walk, independent of the type-level guarantee. Moved the four keyboard-orchestration tests (the two ADR 0014 ref-frame seam tests plus the two session/selector-guard tests) out of the mixed appstate/perf test file into a new session-selector-dispatch.test.ts, colocated with the file they exercise. Strengthened the rejection regression test to assert the frame is already expired from inside the rejecting keyboardDismiss callback itself, pinning the exact pre-invocation seam rather than only checking the end state after the dispatch settles. * fix: restore back/home/orientation/tv-remote/keyboard-runtime exports lost in rebase Rebasing onto origin/main dropped these five package.json export entries during conflict resolution (the granular-subpath commit's package.json changes silently lost during merge). Restored, confirmed by pnpm typecheck across all 17 workspace packages and the full unit-core suite (1023 files / 7581 tests). * test: pin the exact point the live iOS email field value goes missing Two prior CI runs on this PR saw the seeded email field ("ada@example") end up containing only a typed suffix (".test") by the time the flow reads it back at the end — after fill, keyboard dismiss, coordinate refocus, and type. Since this PR touches executeKeyboardDismiss's response shaping, the reviewer asked to disprove keyboard dismiss as the cause rather than assume the pre-existing dropped-keystroke flake pattern applies. Added two read-back checkpoints: right after seeding (before dismiss runs at all) and right after dismiss (before the coordinate refocus + type steps that follow). If both hold "ada@example", the loss happens during refocus/type, not dismiss — matching the documented flake, not a regression in this PR's diff. * refactor: make keyboard status/enter owner-discriminated too; trim review-round prose KeyboardStatusResult and KeyboardEnterResult were bare objects; executeKeyboardStatus and executeKeyboardEnter derived the wire platform label from device.platform via keyboardPlatformLabel, the same re-derivation already fixed for dismiss. Each owner now tags its own result with a kind (android's status/enter as 'ime-probe' and 'android-acknowledged', harmonyos's enter as 'harmonyos-acknowledged', apple's enter as 'visibility-echo'), and the daemon derives platform from a kind-keyed lookup table for all three actions. keyboardPlatformLabel and its isIosFamily import are gone — nothing derives platform from the device anymore. Android and HarmonyOS's enter acknowledgments are structurally identical (empty besides kind), so the discriminant alone — not result shape — is what tells the daemon which owner actually ran. Added a harmonyos enter test alongside the existing ios/android ones so all three owners are covered for both dismiss and enter's kind-to-platform mapping. Also trimmed several comments that narrated which PR review round motivated them down to just the durable invariant or rationale — the type shape, test names, and assertions already carry the proof.
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
buildGesturePlan,
|
|
gesturePayloadFromPositionals,
|
|
normalizePublicGesture,
|
|
normalizePublicSwipeMotion,
|
|
swipePayloadFromPositionals,
|
|
} from '@agent-device/contracts/interaction';
|
|
import { PUBLIC_COMMANDS } from '../../src/command-catalog.ts';
|
|
import { isCommandSupportedOnDevice } from '../../src/core/capabilities.ts';
|
|
import { parseReplayScriptDetailed } from '@agent-device/ad-script';
|
|
import { isValidSelectorExpression } from '@agent-device/selectors';
|
|
import { IOS_SIMULATOR_BEHAVIOR_COVERAGE } from './ios-simulator-e2e/behavior-coverage.ts';
|
|
import {
|
|
IOS_SIMULATOR_E2E_COVERAGE,
|
|
liveCommandsForScenario,
|
|
} from './ios-simulator-e2e/coverage-manifest.ts';
|
|
import { collectPagedEventTimeline } from './live-device-e2e/event-timeline.ts';
|
|
import { findMissingFixtureIdentifiers } from './ios-simulator-e2e/fixture-identifier-coverage.ts';
|
|
import { IOS_SIMULATOR_LIVE_SCENARIOS } from './ios-simulator-e2e/scenarios.ts';
|
|
|
|
const IOS_SIMULATOR = {
|
|
appleOs: 'ios' as const,
|
|
id: 'ci-ios-simulator',
|
|
kind: 'simulator' as const,
|
|
name: 'CI iPhone',
|
|
platform: 'apple' as const,
|
|
target: 'mobile' as const,
|
|
};
|
|
|
|
test('iOS simulator coverage exhaustively classifies the public catalog', () => {
|
|
const publicCommands = Object.values(PUBLIC_COMMANDS).sort();
|
|
assert.deepEqual(Object.keys(IOS_SIMULATOR_E2E_COVERAGE).sort(), publicCommands);
|
|
|
|
for (const command of publicCommands) {
|
|
const entry = IOS_SIMULATOR_E2E_COVERAGE[command];
|
|
assert.ok(entry.assertion.trim().length > 0, `${command} needs an observable assertion`);
|
|
if (typeof entry.owner === 'string') {
|
|
assert.ok(entry.owner.trim().length > 0, `${command} needs a concrete scenario owner`);
|
|
} else {
|
|
assert.ok(entry.owner.path.trim().length > 0, `${command} needs an evidence path`);
|
|
assert.ok(entry.owner.test.trim().length > 0, `${command} needs named evidence`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('live command claims are owned by executable scenarios', () => {
|
|
const scenariosById = new Map(
|
|
IOS_SIMULATOR_LIVE_SCENARIOS.map((scenario) => [scenario.id, scenario]),
|
|
);
|
|
|
|
for (const [command, entry] of Object.entries(IOS_SIMULATOR_E2E_COVERAGE)) {
|
|
if (entry.level !== 'live') continue;
|
|
const scenario = scenariosById.get(entry.owner);
|
|
assert.ok(scenario, `${command} references missing scenario ${entry.owner}`);
|
|
assert.ok(
|
|
liveCommandsForScenario(scenario.id).some((candidate) => candidate === command),
|
|
`${entry.owner} does not execute ${command}`,
|
|
);
|
|
}
|
|
|
|
const scenarioIds = IOS_SIMULATOR_LIVE_SCENARIOS.map((scenario) => scenario.id);
|
|
assert.equal(new Set(scenarioIds).size, scenarioIds.length, 'scenario ids must be unique');
|
|
const claimedCommands = IOS_SIMULATOR_LIVE_SCENARIOS.flatMap((scenario) =>
|
|
liveCommandsForScenario(scenario.id),
|
|
);
|
|
assert.equal(
|
|
new Set(claimedCommands).size,
|
|
claimedCommands.length,
|
|
'runtime command claims must have one primary scenario',
|
|
);
|
|
});
|
|
|
|
test('mobile behavior patterns are owned by live scenarios or executable workflows', () => {
|
|
const scenarioIds = new Set(IOS_SIMULATOR_LIVE_SCENARIOS.map((scenario) => scenario.id));
|
|
for (const [behavior, entry] of Object.entries(IOS_SIMULATOR_BEHAVIOR_COVERAGE)) {
|
|
assert.ok(entry.assertion.trim().length > 0, `${behavior} needs an observable assertion`);
|
|
if (entry.level === 'live') {
|
|
assert.ok(scenarioIds.has(entry.owner), `${behavior} references missing ${entry.owner}`);
|
|
} else {
|
|
const ownerPath = path.resolve(entry.owner.path);
|
|
assert.ok(fs.existsSync(ownerPath), `${behavior} owner does not exist`);
|
|
assert.ok(
|
|
fs.readFileSync(ownerPath, 'utf8').includes(entry.owner.test),
|
|
`${behavior} owner does not contain ${entry.owner.test}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('non-live owners name concrete executable repository evidence', () => {
|
|
for (const [command, entry] of Object.entries(IOS_SIMULATOR_E2E_COVERAGE)) {
|
|
if (entry.level === 'live') continue;
|
|
const ownerPath = path.resolve(entry.owner.path);
|
|
assert.ok(fs.existsSync(ownerPath), `${command} owner does not exist: ${entry.owner.path}`);
|
|
assert.ok(
|
|
fs.readFileSync(ownerPath, 'utf8').includes(entry.owner.test),
|
|
`${command} owner does not contain named evidence: ${entry.owner.test}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('fixture identifier coverage rejects direct-selector drift', () => {
|
|
assert.deepEqual(
|
|
findMissingFixtureIdentifiers(
|
|
['<Text testID="field-name" />'],
|
|
[
|
|
`await runStep(context, 'read field', ['get', 'text', 'id="field-typo"']);
|
|
nodes.find((node) => node.identifier === 'field-snapshot-typo');`,
|
|
],
|
|
),
|
|
['field-snapshot-typo', 'field-typo'],
|
|
);
|
|
});
|
|
|
|
test('live iOS scenarios reference fixture identifiers that exist', () => {
|
|
const readSources = (directory: string, includes: (fileName: string) => boolean) =>
|
|
fs
|
|
.readdirSync(directory)
|
|
.filter(includes)
|
|
.map((fileName) => fs.readFileSync(path.join(directory, fileName), 'utf8'));
|
|
const fixtureSources = readSources('examples/test-app/src/screens', (fileName) =>
|
|
fileName.endsWith('.tsx'),
|
|
);
|
|
const liveScenarioSources = readSources(
|
|
'test/integration/ios-simulator-e2e',
|
|
(fileName) => fileName.startsWith('live-') && fileName.endsWith('.ts'),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
findMissingFixtureIdentifiers(fixtureSources, liveScenarioSources),
|
|
[],
|
|
'live iOS scenarios must use test IDs defined by the fixture app',
|
|
);
|
|
});
|
|
|
|
test('capability classifications match executable simulator behavior', () => {
|
|
const factOwnedCommands: readonly string[] = [PUBLIC_COMMANDS.viewport, PUBLIC_COMMANDS.tvRemote];
|
|
for (const [command, entry] of Object.entries(IOS_SIMULATOR_E2E_COVERAGE)) {
|
|
if (factOwnedCommands.includes(command)) {
|
|
assert.equal(
|
|
entry.level,
|
|
'command-contract',
|
|
`${command} admission belongs to the exact-owner runtime fact, not the capability catalog`,
|
|
);
|
|
continue;
|
|
}
|
|
const supported = isCommandSupportedOnDevice(command, IOS_SIMULATOR);
|
|
if (command === PUBLIC_COMMANDS.audio) {
|
|
assert.equal(
|
|
supported,
|
|
process.platform === 'darwin',
|
|
'simulator audio admission follows host ScreenCaptureKit availability',
|
|
);
|
|
continue;
|
|
}
|
|
if (entry.level === 'capability-denial') {
|
|
assert.equal(supported, false, `${command} denial must match capability admission`);
|
|
} else {
|
|
assert.equal(supported, true, `${command} evidence requires simulator capability admission`);
|
|
}
|
|
}
|
|
|
|
assert.equal(
|
|
IOS_SIMULATOR_E2E_COVERAGE[PUBLIC_COMMANDS.tvRemote].level,
|
|
'command-contract',
|
|
'tv-remote denial is owned by exact platform runtime facts',
|
|
);
|
|
assert.equal(
|
|
IOS_SIMULATOR_E2E_COVERAGE[PUBLIC_COMMANDS.viewport].level,
|
|
'command-contract',
|
|
'viewport denial is owned by exact platform runtime facts',
|
|
);
|
|
});
|
|
|
|
type ReplayAction = ReturnType<typeof parseReplayScriptDetailed>['actions'][number];
|
|
type Viewport = { height: number; width: number; x: number; y: number };
|
|
|
|
function assertReplayActionFitsViewport(action: ReplayAction, viewport: Viewport): void {
|
|
const positionals = action.positionals ?? [];
|
|
if (action.command === 'swipe') {
|
|
const payload = swipePayloadFromPositionals(positionals);
|
|
buildGesturePlan(normalizePublicSwipeMotion(payload).gesture, viewport, 'ios');
|
|
}
|
|
if (action.command === 'gesture') {
|
|
const pointerCount =
|
|
typeof action.flags.pointerCount === 'number' ? action.flags.pointerCount : undefined;
|
|
const payload = gesturePayloadFromPositionals(positionals, pointerCount);
|
|
buildGesturePlan(normalizePublicGesture(payload).gesture, viewport, 'ios');
|
|
}
|
|
}
|
|
|
|
function assertReplayFitsViewports(replayPath: string, viewports: readonly Viewport[]): void {
|
|
const actions = parseReplayScriptDetailed(fs.readFileSync(replayPath, 'utf8')).actions;
|
|
for (const action of actions) {
|
|
for (const viewport of viewports) assertReplayActionFitsViewport(action, viewport);
|
|
}
|
|
}
|
|
|
|
test('fixture replay gestures fit the smallest supported iPhone viewport', () => {
|
|
const compactViewports = [
|
|
{ x: 0, y: 0, width: 320, height: 568 },
|
|
{ x: 0, y: 0, width: 375, height: 667 },
|
|
];
|
|
const replayPaths = [
|
|
'test/integration/replays/ios/fixture/01-navigation-scroll.ad',
|
|
'test/integration/replays/ios/fixture/02-checkout-release.ad',
|
|
'examples/test-app/replays/gesture-lab.ad',
|
|
];
|
|
for (const replayPath of replayPaths) assertReplayFitsViewports(replayPath, compactViewports);
|
|
});
|
|
|
|
test('drag replay fixtures use parseable source and destination selectors', () => {
|
|
const replayPaths = [
|
|
'examples/test-app/replays/drag.ad',
|
|
'examples/test-app/replays/drag-android.ad',
|
|
];
|
|
|
|
for (const replayPath of replayPaths) {
|
|
const drag = parseReplayScriptDetailed(fs.readFileSync(replayPath, 'utf8')).actions.find(
|
|
(action) => action.command === 'gesture' && action.positionals?.[0] === 'drag',
|
|
);
|
|
assert.ok(drag, `${replayPath} must contain a drag gesture`);
|
|
|
|
const source = drag.positionals?.[1];
|
|
const destination = drag.positionals?.[2];
|
|
assert.ok(
|
|
source && isValidSelectorExpression(source),
|
|
`${replayPath} source must be a selector`,
|
|
);
|
|
assert.ok(
|
|
destination && isValidSelectorExpression(destination),
|
|
`${replayPath} destination must be a selector`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('fixture navigation uses edge-aware traversal without losing direct swipe evidence', () => {
|
|
const actions = parseReplayScriptDetailed(
|
|
fs.readFileSync('test/integration/replays/ios/fixture/01-navigation-scroll.ad', 'utf8'),
|
|
).actions;
|
|
assert.equal(
|
|
actions.filter((action) => action.command === 'swipe').length,
|
|
2,
|
|
'one directional swipe per edge proves replay swipe execution',
|
|
);
|
|
assert.deepEqual(
|
|
actions
|
|
.filter((action) => action.command === 'scroll')
|
|
.map((action) => action.positionals?.[0]),
|
|
['bottom', 'top'],
|
|
'edge traversal must terminate from observed scroll state rather than viewport-tuned swipes',
|
|
);
|
|
});
|
|
|
|
test('fixture navigation establishes the home route before selecting Catalog', () => {
|
|
const actions = parseReplayScriptDetailed(
|
|
fs.readFileSync('test/integration/replays/ios/fixture/01-navigation-scroll.ad', 'utf8'),
|
|
).actions;
|
|
const open = actions[0];
|
|
assert.equal(open?.command, 'open');
|
|
assert.equal(open?.flags.relaunch, true);
|
|
assert.equal(open?.runtime?.launchUrl, 'agent-device-test-app:///');
|
|
assert.equal(actions[2]?.command, 'click');
|
|
assert.equal(actions[2]?.positionals?.[0], 'label="Catalog"');
|
|
});
|
|
|
|
test('event timeline coverage follows cursors beyond the first page', async () => {
|
|
const requestedCursors: Array<string | undefined> = [];
|
|
const timeline = await collectPagedEventTimeline(async (cursor) => {
|
|
requestedCursors.push(cursor);
|
|
if (cursor === undefined) {
|
|
return {
|
|
events: [{ command: PUBLIC_COMMANDS.open }, { command: PUBLIC_COMMANDS.snapshot }],
|
|
nextCursor: '100',
|
|
};
|
|
}
|
|
return { events: [{ command: PUBLIC_COMMANDS.press }] };
|
|
});
|
|
|
|
assert.deepEqual(requestedCursors, [undefined, '100']);
|
|
assert.deepEqual(timeline.commands, [
|
|
PUBLIC_COMMANDS.open,
|
|
PUBLIC_COMMANDS.snapshot,
|
|
PUBLIC_COMMANDS.press,
|
|
]);
|
|
assert.deepEqual(timeline.pages, [
|
|
{ cursor: '0', eventCount: 2, nextCursor: '100' },
|
|
{ cursor: '100', eventCount: 1 },
|
|
]);
|
|
});
|
|
|
|
test('event timeline coverage rejects malformed and non-advancing pages', async () => {
|
|
await assert.rejects(
|
|
collectPagedEventTimeline(async () => ({})),
|
|
/events page must contain an events array/,
|
|
);
|
|
await assert.rejects(
|
|
collectPagedEventTimeline(async () => ({ events: [{ command: 42 }] })),
|
|
/must name a command/,
|
|
);
|
|
await assert.rejects(
|
|
collectPagedEventTimeline(async () => ({ events: [], nextCursor: { offset: 100 } })),
|
|
/nextCursor must be a canonical non-negative integer string/,
|
|
);
|
|
await assert.rejects(
|
|
collectPagedEventTimeline(async () => ({ events: [], nextCursor: '1e2' })),
|
|
/nextCursor must be a canonical non-negative integer string/,
|
|
);
|
|
await assert.rejects(
|
|
collectPagedEventTimeline(async () => ({ events: [], nextCursor: '100' })),
|
|
/did not advance beyond cursor 100/,
|
|
);
|
|
let page = 0;
|
|
await assert.rejects(
|
|
collectPagedEventTimeline(async () => ({
|
|
events: [],
|
|
nextCursor: page++ === 0 ? '100' : '50',
|
|
})),
|
|
/did not advance beyond cursor 100/,
|
|
);
|
|
});
|
|
|
|
test('event timeline coverage fails fast on runaway pagination', async () => {
|
|
let nextCursor = 0;
|
|
await assert.rejects(
|
|
collectPagedEventTimeline(async () => ({
|
|
events: [],
|
|
nextCursor: String(++nextCursor),
|
|
})),
|
|
/events pagination exceeded 100 pages/,
|
|
);
|
|
});
|