Files
callstack__agent-device/test/integration/smoke-ios-snapshot-backend-conformance.test.ts
Michał Pierzchała 80997b6bf1 fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal (#2315)
* fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal

Two CI flake families on main and PRs since 2026-09-03.

iOS Smoke, `is absent ... capture was truncated` (7 of 13 failures): the
runner's stampedSnapshotPayload set `truncated: true` on every non-healthy
capture, so a complete private-AX tree taken while the XCTest channel was
penalized as slow (the normal state on a loaded CI host) was reported as
truncated. Nothing consumed that until the strict absence assertion (#2245)
refused truncated captures. `truncated` now tracks completeness only:
payload truncation, a depth-limited capture, or a sparse terminal payload.
The E2E conformance helper asserted the old conflation and now asserts
`truncated === false`; a runner unit test pins the new contract and joins
the targeted list in ios.yml.

Android Smoke, `get text id="automation-alert-result"` selector miss (5 of
5 failures): #2260 replaced a polling wait with a one-shot read right after
`alert dismiss`, and Android's `alert accept|dismiss` returned as soon as
the button was pressed, while the dialog window was still the only thing in
the accessibility tree. They now poll until the same dialog is gone (a
different alert taking its place counts as dismissed), bounded by the
existing action budget, else fail with "did not dismiss the visible alert"
like the iOS runner already does.

* test(provider): model Android dialogs that leave the tree after the alert action

The scripted Android alert scenarios served the same dialog to every
capture, which encoded the old return-after-press behavior; alert
accept/dismiss now confirm the dialog is gone, so a dialog that never
leaves is the failure it should be (covered by a new scenario). The
fixtures now hide the dialog once its button is tapped or Back is sent,
the way the ANR recovery scenario already did.

* test(e2e): wait for the alert outcome before reading it; dump evidence for any failed step

The Android smoke still missed `id="automation-alert-result"` on CI right
after a confirmed dismissal: the daemon opened a fresh helper session for
that read and its 2s capture had no such node, while the same one-shot
read passes locally in 150ms. The fixture's re-render after the button
callback is app timing, so the scenario waits for the outcome text (the
polling landmark #2260 removed) and then pins it to the canary element.

The harness kept only a screenshot, and only for wait timeouts, so the
tree that produced a selector miss was never in the artifacts. Every
unexpected step failure now writes failed-step-N.png and
failed-step-N-snapshot.json next to failed-step.txt.

* test(provider): move the Android alert scenarios and dialog fixtures out of android-lifecycle

The test-file size ratchet rejects growth in android-lifecycle.test.ts
(1,597 lines at the merge-base), and the dialog re-check work added a
scenario there. The alert scenarios now live in android-alert.test.ts
and the scripted dialog surfaces they share with the ANR scenarios in
android-dialog-fixtures.ts; the lifecycle file drops to 1,260 lines.
2026-09-05 23:15:21 +02:00

122 lines
4.1 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
assertSnapshotBackendConformance,
createSnapshotBackendConformanceTransport,
loadSnapshotBackendConformanceFixture,
} from './ios-simulator-e2e/snapshot-backend-conformance.ts';
import {
buildSnapshotBackendConformanceBase,
type SnapshotBackendConformanceInput,
} from './ios-simulator-e2e/snapshot-backend-conformance-fixtures.ts';
const fixture = loadSnapshotBackendConformanceFixture();
test('snapshot backend conformance checks each backend contract independently', () => {
const base = buildSnapshotBackendConformanceBase();
assert.doesNotThrow(() => assertSnapshotBackendConformance(base, 'tree', fixture));
assert.throws(
() =>
assertSnapshotBackendConformance(
{ ...base, snapshotQuality: { state: 'healthy', backend: 'tree' } },
'private-ax',
fixture,
),
/private-ax capture must prove its backend/,
);
});
test('snapshot backend conformance rejects every promised control invariant', () => {
const base = buildSnapshotBackendConformanceBase();
const updateControl = (identifier: string, update: Record<string, unknown>) =>
base.nodes.map((node) => (node.identifier === identifier ? { ...node, ...update } : node));
const expectFailure = (
name: string,
snapshot: SnapshotBackendConformanceInput,
message: RegExp,
) =>
assert.throws(() => assertSnapshotBackendConformance(snapshot, 'tree', fixture), message, name);
expectFailure(
'sparse quality',
{ ...base, snapshotQuality: { state: 'sparse', backend: 'tree' } },
/must have a non-sparse quality verdict/,
);
expectFailure(
'truncated fixture capture',
{ ...base, truncated: true },
/reported a truncated capture/,
);
expectFailure('minimum node count', { ...base, nodes: base.nodes.slice(0, 2) }, /too few nodes/);
expectFailure(
'seeded control presence',
{
...base,
nodes: [
...base.nodes.filter((node) => node.identifier !== 'field-email'),
{ index: 3, ref: 'e4', type: 'Other' },
],
},
/missing field-email/,
);
expectFailure(
'label identity',
{ ...base, nodes: updateControl('field-name', { label: 'Name' }) },
/label drift/,
);
expectFailure(
'canonical role identity',
{ ...base, nodes: updateControl('field-name', { type: 'StaticText' }) },
/role drift/,
);
expectFailure(
'enabled interactivity',
{ ...base, nodes: updateControl('field-name', { enabled: false }) },
/did not mark field-name enabled/,
);
expectFailure(
'semantic interactivity',
{ ...base, nodes: updateControl('field-name', { type: 'StaticText', role: 'text-field' }) },
/did not expose field-name as a semantic control/,
);
expectFailure(
'positive interaction geometry',
{ ...base, nodes: updateControl('field-name', { rect: { x: 0, y: 0, width: 0, height: 20 } }) },
/did not expose positive interaction geometry/,
);
expectFailure(
'structured hittable evidence',
{ ...base, nodes: updateControl('field-name', { hittable: undefined }) },
/omitted its structured hittable result/,
);
expectFailure(
'geometric actionability',
{ ...base, nodes: updateControl('field-name', { hittable: false }) },
/did not expose field-name as geometrically actionable/,
);
expectFailure(
'seeded field value',
{ ...base, nodes: updateControl('field-email', { value: 'wrong@example.test' }) },
/value drift/,
);
});
test('backend forcing stays in the test-owned daemon transport seam', async () => {
type Request = Parameters<import('@agent-device/contracts/client').AgentDeviceDaemonTransport>[0];
let received: Request | undefined;
const transport = createSnapshotBackendConformanceTransport('tree', async (request) => {
received = request;
return { ok: true, data: {} };
});
await transport({ command: 'snapshot', positionals: [], session: 'default', flags: {} });
assert.equal(received?.flags?.snapshotPreferredBackend, 'tree');
assert.equal(
(received?.flags as Record<string, unknown> | undefined)?.preferredBackend,
undefined,
);
});