Files
explore/test/node/runtime-adapter.test.mjs
Brent Vatne 078902710b Capture iPhone Duo on EAS Simulator with eas simulator:control, for both controllers (#9)
Capture iPhone Duo on EAS Simulator with eas simulator:control, for both controllers

- eas-cli 25.2.0: app.approveUrlScheme after install, appearance.set for Argent on
  iOS EAS sessions, and springboard.restart as the last fold recovery step. The
  controller's own tools come first; these only fill what neither controller has.
- iPhone Duo on EAS: both controllers' folds are verified (Argent by its tree,
  agent-device by its screenshot) and recovered by restarting SpringBoard and
  folding again, once; never by stopping the session's tool-server.
- agent-device opens the app before folding on EAS, as it does locally.
- agent-device pinned to 0.21.29-nightly.20261018.74 for callstack/agent-device#3776,
  which stops @expo/ui sliders on a regular-width display from wedging its runner.
- A session EAS fails to start is replaced, up to five times within 30 minutes,
  with a growing wait, only when the failure is provably EAS's.
- doctor warns about Argent's single appearance only for Android EAS sessions.
2026-10-17 20:44:41 -07:00

300 lines
11 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
parseRuntimeExecutionContext,
runtimeEvidenceInputFromMap,
} from "../../dist/review/runtime-adapter.js";
import { projectRuntimeEvidence } from "../../dist/review/runtime-evidence.js";
import { AGENT_DEVICE_VERSION } from "../../dist/capture/run.js";
import { ARGENT_VERSION } from "../../dist/shared/argent-version.js";
const sha = "a".repeat(40);
function execution(overrides = {}) {
return {
provider: "eas-simulator",
controller: "agent-device",
controllerPackageVersion: AGENT_DEVICE_VERSION,
easCliVersion: "25.2.0",
platform: "ios",
deviceModel: "iPhone 17",
osVersion: "26.0",
locale: "en-US",
orientation: "portrait",
colorScheme: "light",
sessionLifecycle: "stopped",
...overrides,
};
}
function map() {
const observations = ["obs-1", "obs-2", "obs-3"].map((id, index) => ({
id,
screenId: "cats",
variantId: "cats-ready",
fingerprints: { combined: "same", visual: "visual", accessibility: "a11y" },
capturedAt: `2026-09-01T00:00:0${index + 1}Z`,
surfaceScope: "app",
elementIds: ["care-guide"],
elements: [
{
elementId: "care-guide",
identifier: "cats-care-guide-cta",
role: "button",
label: "Care Guide",
occurrence: 1,
controller: "agent-device",
actions: ["press"],
risk: "safe",
actionable: true,
},
{
elementId: "surface-provenance",
identifier: "expo-explore-route",
role: "text",
label: "internal capture provenance",
occurrence: 1,
controller: "agent-device",
actions: [],
risk: "safe",
actionable: false,
},
],
screenshotArtifactId: `shot-${index + 1}`,
}));
return {
contract: "expo.explore.local-map.v1",
revision: 1,
run: {
id: "run",
name: "Example",
platform: "ios",
controller: "agent-device",
bundleId: "com.example.app",
status: "complete",
createdAt: "2026-09-01T00:00:00Z",
updatedAt: "2026-09-01T00:00:04Z",
completedAt: "2026-09-01T00:00:04Z",
build: { id: "head", version: sha, gitCommit: sha, source: "eas" },
environment: {
deviceModel: "iPhone 17",
osVersion: "26.0",
locale: "en-US",
orientation: "portrait",
colorScheme: "light",
authState: "logged-out",
authPersona: "local-fixture",
},
limits: { maxScreens: 120, maxActions: 500, maxDurationMinutes: 120 },
},
artifacts: observations.map((observation, index) => ({
id: `shot-${index + 1}`,
kind: "screenshot",
path: `artifacts/shot-${index + 1}.png`,
sha256: String(index + 1).repeat(64),
bytes: 100,
mimeType: "image/png",
width: 1179,
height: 2556,
createdAt: observation.capturedAt,
})),
screens: [{
id: "cats",
title: "Cats",
route: "/cats",
state: "ready",
start: true,
terminal: false,
depth: 0,
firstSeenAt: "2026-09-01T00:00:01Z",
lastSeenAt: "2026-09-01T00:00:03Z",
visits: 3,
variantIds: ["cats-ready"],
observationIds: observations.map((value) => value.id),
elementIds: ["care-guide"],
}],
variants: [{
id: "cats-ready",
screenId: "cats",
key: "ready",
firstSeenAt: "2026-09-01T00:00:01Z",
lastSeenAt: "2026-09-01T00:00:03Z",
observationIds: observations.map((value) => value.id),
}],
observations,
elements: [],
actionAttempts: [{
id: "attempt-1",
sourceScreenId: "cats",
sourceObservationId: "obs-1",
targetScreenId: "cats",
targetObservationId: "obs-2",
action: { kind: "press", label: "Open Care Guide" },
outcome: "transition",
risk: "safe",
startedAt: "2026-09-01T00:00:01Z",
completedAt: "2026-09-01T00:00:01.250Z",
}],
transitions: [],
journeys: [],
frontier: [],
gaps: [],
};
}
test("parses only the pinned, stopped, non-secret runtime context", () => {
assert.deepEqual(parseRuntimeExecutionContext(execution()), execution());
assert.throws(
() => parseRuntimeExecutionContext(execution({ sessionId: "private-session" })),
/unsupported field sessionId/,
);
assert.throws(
() => parseRuntimeExecutionContext(execution({ sessionLifecycle: "capturing" })),
/session lifecycle.*invalid/i,
);
assert.throws(
() => parseRuntimeExecutionContext(execution({ easCliVersion: "latest" })),
/EAS CLI version.*invalid/i,
);
});
test("a dark+light capture's execution context lists its appearances", () => {
const both = execution({ colorSchemes: ["light", "dark"] });
assert.deepEqual(parseRuntimeExecutionContext(both), both);
assert.throws(() => parseRuntimeExecutionContext(execution({ colorSchemes: [] })), /at least one appearance/);
assert.throws(() => parseRuntimeExecutionContext(execution({ colorSchemes: ["light", "sepia"] })), /color schemes.*invalid/i);
});
test("projects one map surface into the five runtime evidence groups", () => {
const input = runtimeEvidenceInputFromMap({
side: "head",
map: map(),
screenId: "cats",
representativeObservationId: "obs-1",
sampleObservationIds: ["obs-1", "obs-2", "obs-3"],
expectedSampleCount: 3,
profile: "iphone-17-review",
execution: parseRuntimeExecutionContext(execution()),
});
const result = projectRuntimeEvidence(input);
assert.deepEqual(
input.runtime.observation.accessibility.elements.map((element) => element.label),
["Care Guide"],
);
assert.equal(result.groups.screen.route.value, "/cats");
assert.equal(result.groups.capture.sampleCount.value, 3);
assert.equal(result.groups.capture.stability.value, "stable");
assert.deepEqual(result.groups.capture.pixelDensities.value, []);
assert.equal(result.groups.interaction.transitionCount.value, 1);
assert.equal(result.groups.ui.landmarks.value.length, 0);
assert.equal(result.groups.environment.controllerPackageVersion?.value, AGENT_DEVICE_VERSION);
assert.equal(result.groups.environment.easCliVersion?.value, "25.2.0");
});
test("normalizes multi-line labels and treats only open parser gaps as truncation", () => {
const fixture = map();
for (const observation of fixture.observations) {
observation.elements[0].label = "Get closer to\nyour cats.\tNow";
}
fixture.gaps = [
{ id: "gap-1", kind: "controller-parse", status: "accepted", message: "Collapsed 1 exact duplicate JSON element(s)", observationId: "obs-1", createdAt: "2026-09-01T00:00:00Z" },
];
const build = () => runtimeEvidenceInputFromMap({
side: "head",
map: fixture,
screenId: "cats",
representativeObservationId: "obs-1",
sampleObservationIds: ["obs-1", "obs-2", "obs-3"],
expectedSampleCount: 3,
profile: "iphone-17-review",
execution: parseRuntimeExecutionContext(execution()),
});
const accepted = build();
assert.deepEqual(
accepted.runtime.observation.accessibility.elements.map((element) => element.label),
["Get closer to your cats. Now"],
);
assert.equal(accepted.runtime.observation.accessibility.truncated, false);
assert.equal(projectRuntimeEvidence(accepted).groups.ui.snapshotQuality.value, "complete");
fixture.gaps[0].status = "open";
assert.equal(build().runtime.observation.accessibility.truncated, true);
});
test("route observers and legacy markers are verification metadata, not runtime UI evidence", () => {
const fixture = map();
for (const observation of fixture.observations) {
observation.elements.push({ elementId: `${observation.id}-observer`, identifier: 'expo-explore-observer',
role: 'text', label: JSON.stringify({ provider: 'expo-router', pathname: '/cats', parameters: { sample: observation.id } }),
occurrence: 1, controller: 'agent-device', actions: [], risk: 'safe', actionable: false });
}
const input = runtimeEvidenceInputFromMap({ side: 'head', map: fixture, screenId: 'cats', representativeObservationId: 'obs-1',
sampleObservationIds: ['obs-1', 'obs-2', 'obs-3'], expectedSampleCount: 3, profile: 'iphone-17-review', execution: execution() });
assert.deepEqual(input.runtime.observation.accessibility.elements.map(element => element.id), ['care-guide']);
assert.deepEqual(input.runtime.observation.accessibility.elements.map(element => element.label), ['Care Guide']);
assert.doesNotMatch(JSON.stringify(projectRuntimeEvidence(input).groups.ui), /expo-explore|pathname|sample/);
assert.equal(fixture.observations[0].elements.length, 3, 'the original capture retains diagnostic metadata');
});
test("a captured route in a partial run retains controller evidence without an app marker", () => {
const fixture = map();
fixture.run.status = "partial";
for (const observation of fixture.observations) {
observation.elements = observation.elements.filter(element => element.identifier !== "expo-explore-route");
}
const input = runtimeEvidenceInputFromMap({
side: "head", map: fixture, screenId: "cats", representativeObservationId: "obs-1",
sampleObservationIds: ["obs-1"], expectedSampleCount: 1, profile: "iphone-17-review",
execution: parseRuntimeExecutionContext(execution()),
});
assert.equal(input.runtime.environment.controller, "agent-device");
assert.equal(input.runtime.environment.sessionLifecycle, "stopped");
assert.equal(input.runtime.observation.accessibility.elements[0].label, "Care Guide");
});
test("fails closed on provenance, environment, surface, and capture mismatches", () => {
const base = {
side: "base",
map: map(),
screenId: "cats",
representativeObservationId: "obs-1",
sampleObservationIds: ["obs-1"],
expectedSampleCount: 1,
profile: "iphone-17-review",
execution: parseRuntimeExecutionContext(execution()),
};
const wrongEnvironment = map();
wrongEnvironment.run.environment.deviceModel = "iPhone 16";
assert.throws(
() => runtimeEvidenceInputFromMap({ ...base, map: wrongEnvironment }),
/does not match EAS Simulator context/,
);
assert.throws(
() => runtimeEvidenceInputFromMap({ ...base, sampleObservationIds: ["missing"] }),
/observation is missing/,
);
assert.throws(
() => runtimeEvidenceInputFromMap({
...base,
expectedSampleCount: 3,
sampleObservationIds: ["obs-1"],
}),
/expected 3 capture samples, received 1/,
);
const noVisual = map();
delete noVisual.observations[0].fingerprints.visual;
assert.throws(
() => runtimeEvidenceInputFromMap({ ...base, map: noVisual }),
/visual fingerprint is missing/,
);
});
test("an Argent capture's execution context names Argent and its pinned version, never another pairing", () => {
const argent = execution({ controller: "argent", controllerPackageVersion: ARGENT_VERSION });
assert.deepEqual(parseRuntimeExecutionContext(argent), argent);
assert.throws(() => parseRuntimeExecutionContext(execution({ controller: "argent" })), /Controller package version.*invalid/i);
assert.throws(() => parseRuntimeExecutionContext(execution({ controllerPackageVersion: ARGENT_VERSION })), /Controller package version.*invalid/i);
assert.throws(() => parseRuntimeExecutionContext(execution({ controller: "appium" })), /Runtime controller.*invalid/i);
});