Files
Brent Vatne d26e65db47 Add a README quick start, and run local reviews with no setup
- README: a Quick start of two steps (make the app buildable on EAS, then
  `npx @expo/explore capture`), with a prompt to paste into Claude Code or Codex.
- `capture` is a one-word name for `capture:review`.
- A local review reviews the working tree by default, untracked files included,
  as `eas build` uploads it. `--committed` reviews HEAD alone, and an eas.json
  with `cli.requireCommit: true` leaves uncommitted changes out, as eas build does.
- Without `.expo-explore/config.yaml`, a local review uses the config `setup`
  would write (or an uncommitted one in the checkout), so it works before setup.
- setup prefers a simulator profile named `explore` when there are several.
2026-10-16 12:36:38 -07:00

478 lines
22 KiB
JavaScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { addCoverageGap, addScreen, createMapStore } from "../helpers/map-store-fixture.mjs";
const root = resolve(import.meta.dirname, "../..");
const cli = join(root, "dist", "cli.js");
const fixtures = join(import.meta.dirname, "../fixtures");
const { parseControllerOutput } = await import(join(root, "dist", "capture", "controller-output.js"));
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
function raw(cwd, args) {
return spawnSync(process.execPath, [cli, ...args, "--json"], {
cwd,
encoding: "utf8",
});
}
function run(cwd, args) {
const result = raw(cwd, args);
assert.equal(result.status, 0, result.stderr);
return JSON.parse(result.stdout);
}
function runFailure(cwd, args) {
const result = raw(cwd, args);
assert.notEqual(result.status, 0, result.stdout);
return JSON.parse(result.stderr || result.stdout);
}
async function writeFixture(cwd, name, contents) {
const path = join(cwd, name);
await writeFile(path, contents);
return path;
}
// Capture parses controller output with the same parser (src/capture/run.ts).
function parseFile(cwd, controller, path) {
return parseControllerOutput(controller, readFileSync(resolve(cwd, path), "utf8"));
}
test("map query commands read a captured map without writing to it", async (t) => {
const cwd = await mkdtemp(join(tmpdir(), "expo-explore-query-"));
t.after(() => rm(cwd, { recursive: true, force: true }));
const store = await createMapStore(cwd, "Query fixture");
const screenshotPath = await writeFixture(cwd, "screen.png", png);
await addScreen(store, { id: "home", title: "Home", start: true, screenshotPath,
snapshotPath: join(fixtures, "agent-device-snapshot.txt") });
await addScreen(store, { id: "details", title: "Details", screenshotPath });
const captured = await store.read();
const [home, details] = captured.screens;
const timestamp = captured.run.updatedAt;
captured.actionAttempts.push({ id: "open-details", sourceScreenId: home.id,
sourceObservationId: home.observationIds[0], targetScreenId: details.id,
targetObservationId: details.observationIds[0], action: { kind: "press", label: "Open details" },
outcome: "transition", risk: "safe", startedAt: timestamp, completedAt: timestamp });
captured.transitions.push({ id: "home-details", fromScreenId: home.id, toScreenId: details.id,
actionKind: "press", firstObservedAt: timestamp, lastObservedAt: timestamp, attemptIds: ["open-details"] });
captured.journeys.push({ id: "browse", title: "Browse details", screenIds: [home.id, details.id],
transitionIds: ["home-details"], attemptIds: ["open-details"] });
await store.write(captured);
const exampleMap = store.root;
const map = JSON.parse(await readFile(join(exampleMap, "screen-map.json"), "utf8"));
const before = (await readdir(exampleMap)).sort();
const query = (args) => {
const result = run(root, [...args, "--dir", exampleMap]);
assert.equal(result.contract, "expo.explore.query.v1");
assert.equal(result.ok, true, args.join(" "));
return result;
};
const start = map.screens.find((screen) => screen.start);
const transition = map.transitions.find((entry) => entry.fromScreenId === start.id);
const observation = map.observations.find((entry) => entry.screenId === start.id);
const attempt = map.actionAttempts[0];
assert.equal(query(["summary"]).coverage.screens, map.screens.length);
assert.equal(query(["status"]).revision, map.revision);
assert.deepEqual(query(["validate"]).errors, []);
const screens = query(["screen:list", "--limit", "2"]);
assert.equal(screens.total, map.screens.length);
assert.equal(screens.items.length, 2);
assert.equal(query(["screen:get", "--id", start.id]).screen.id, start.id);
assert.ok(query(["element:list", "--screen", start.id]).total > 0);
assert.ok(query(["observation:list", "--screen", start.id]).items.some((entry) => entry.id === observation.id));
assert.equal(query(["observation:get", "--id", observation.id]).observation.screenId, start.id);
assert.equal(query(["attempt:list"]).total, map.actionAttempts.length);
assert.equal(query(["attempt:get", "--id", attempt.id]).attempt.id, attempt.id);
assert.ok(query(["transition:list", "--screen", start.id]).items.some((entry) => entry.id === transition.id));
assert.equal(query(["transition:get", "--id", transition.id]).transition.toScreenId, transition.toScreenId);
assert.ok(query(["neighbors", "--screen", start.id]).outgoing.length > 0);
assert.equal(query(["path", "--from", start.id, "--to", transition.toScreenId]).found, true);
assert.equal(query(["journey:list"]).total, map.journeys.length);
assert.equal(query(["journey:get", "--id", "browse"]).journey.id, "browse");
assert.equal(query(["frontier:list"]).total, map.frontier.length);
assert.equal(typeof query(["frontier:next"]).done, "boolean");
assert.equal(query(["gap:list"]).total, map.gaps.length);
assert.ok(query(["search", "--query", start.title]).total > 0);
assert.match(runFailure(root, ["journey:get", "--id", "missing", "--dir", exampleMap]).error, /journey/i);
const exported = join(await mkdtemp(join(tmpdir(), "expo-explore-query-export-")), "map");
query(["export", "--output", exported]);
assert.equal(JSON.parse(await readFile(join(exported, "screen-map.json"), "utf8")).revision, map.revision);
assert.deepEqual((await readdir(exampleMap)).sort(), before);
assert.equal(JSON.parse(await readFile(join(exampleMap, "screen-map.json"), "utf8")).revision, map.revision);
});
test("version prints the package version", () => {
const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
for (const command of ["version", "--version", "-v"]) {
const result = spawnSync(process.execPath, [cli, command], { cwd: root, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, `${version}\n`);
}
});
test("capture is the local review, capture:review by a shorter name", async (t) => {
const outside = await mkdtemp(join(tmpdir(), "explore-capture-"));
t.after(() => rm(outside, { recursive: true, force: true }));
const failures = ["capture", "capture:review"].map(command => [
runFailure(outside, [command, "--bogus", "--json"]),
runFailure(outside, [command, "--committed", "--json"]),
]);
assert.deepEqual(failures[0], failures[1], "the same flags, errors and result contract");
assert.equal(failures[0][0].command, "capture:review");
assert.equal(failures[0][0].diagnostic.code, "CLI_ARGUMENT_INVALID");
assert.equal(failures[0][1].diagnostic.code, "LOCAL_REVIEW_GIT_FAILED", "--committed is a known flag; outside a repository the review stops");
});
test("removed map-writing commands fail as unknown commands", () => {
for (const command of ["init", "screen:add", "attempt:commit", "frontier:claim", "gap:add", "finish"]) {
const result = spawnSync(process.execPath, [cli, command], { cwd: root, encoding: "utf8" });
assert.notEqual(result.status, 0);
assert.match(result.stderr, new RegExp(`^Unknown command: ${command}\n→ Run expo-explore help`));
assert.match(runFailure(root, [command]).error, new RegExp(`Unknown command: ${command}`));
}
});
test("parses representative agent-device text and Argent JSON", async () => {
const cwd = await mkdtemp(join(tmpdir(), "expo-explore-parsers-"));
const agent = parseFile(cwd, "agent-device", join(fixtures, "agent-device-snapshot.txt"));
assert.equal(agent.format, "text");
assert.equal(agent.warnings.length, 0);
assert.equal(agent.elements.length, 5);
const agentContinue = agent.elements.find((element) => element.label === "Continue");
assert.equal(agentContinue.role, "button");
assert.deepEqual(agentContinue.bounds, {
x: 16,
y: 80,
width: 358,
height: 48,
unit: "points",
});
assert.ok(agent.elements.every((element) => !element.id?.startsWith("@e")));
assert.ok(agent.elements.every((element) => !element.selector?.includes("@e")));
const argent = parseFile(cwd, "argent", join(fixtures, "argent-describe.json"));
assert.equal(argent.format, "json");
const continueButton = argent.elements.find((element) => element.identifier === "continue-button");
assert.equal(continueButton.role, "button");
assert.equal(continueButton.selector, "id=continue-button");
assert.equal(continueButton.bounds.unit, "normalized");
const email = argent.elements.find((element) => element.label === "Email address");
assert.deepEqual(email.actions, ["fill"]);
const tree = parseFile(cwd, "argent", join(fixtures, "argent-tree.json"));
assert.equal(tree.format, "json");
assert.equal(tree.elements.length, 4);
assert.equal(
tree.elements.find((element) => element.identifier === "continue-button").actionable,
true,
);
assert.equal(
tree.elements.find((element) => element.label === "Hidden purchase").actionable,
false,
);
assert.equal(tree.elements.find((element) => element.label === "Nested content").role, "text");
});
test("parses current agent-device and Argent controller output", async () => {
const cwd = await mkdtemp(join(tmpdir(), "expo-explore-current-parsers-"));
const agent = parseFile(cwd, "agent-device", join(fixtures, "agent-device-current.txt"));
assert.equal(agent.format, "text");
assert.equal(agent.elements.length, 8);
assert.match(agent.warnings.join(" "), /Collapsed 2 adjacent duplicate/);
assert.equal(agent.elements.filter((element) => element.label === "Activity log").length, 1);
assert.equal(agent.elements.filter((element) => element.label === "Mute").length, 2);
assert.equal(agent.elements.some((element) => element.label === "button"), false);
assert.deepEqual(
agent.elements.find((element) => element.label === "Search").actions,
["fill"],
);
const tabBar = agent.elements.find((element) => element.label === "Tab Bar");
assert.equal(tabBar.role, "other");
assert.equal(tabBar.actionable, false);
assert.equal(agent.elements.find((element) => element.label === "Patterns").role, "tab");
const argent = parseFile(cwd, "argent", join(fixtures, "argent-describe-current.json"));
assert.equal(argent.format, "json");
assert.equal(argent.elements.length, 6);
const recordMode = argent.elements.find(
(element) => element.label === "Switch to record mode",
);
assert.equal(recordMode.role, "button");
assert.deepEqual(recordMode.bounds, {
x: 0.828,
y: 0.846,
width: 0.128,
height: 0.031,
unit: "normalized",
});
const waveform = argent.elements.find((element) => element.identifier === "waveform");
assert.equal(waveform.role, "field");
assert.equal(waveform.selector, "id=waveform");
assert.equal(argent.elements.find((element) => element.label === "Patterns").role, "tab");
});
test("host AX bridge nodes take their role from the element type, not the native view class", async () => {
// Node shape from agent-device 0.21.15's host AX bridge (a Settings screen).
const parsed = parseControllerOutput("agent-device", JSON.stringify({ success: true, data: { nodes: [
{ type: "StaticText", role: "RCTParagraphComponentView", subrole: "UIView", pid: 42, label: "YOUR ACCOUNT", rect: { x: 26, y: 129, width: 90, height: 14 } },
{ type: "Button", role: "RCTViewComponentView", subrole: "UIView", pid: 42, label: "Account", rect: { x: 12, y: 150, width: 378, height: 54 } },
{ type: "Other", role: "RCTViewComponentView", subrole: "UIView", pid: 42, label: "Settings rows", rect: { x: 0, y: 120, width: 402, height: 600 } },
] } }));
const role = (label) => parsed.elements.find((element) => element.label === label)?.role;
assert.equal(role("YOUR ACCOUNT"), "text");
assert.equal(role("Account"), "button");
assert.equal(role("Settings rows"), "other");
// A known role in an earlier field still wins over the element type.
const explicit = parseControllerOutput("argent", JSON.stringify([{ role: "link", type: "Button", label: "Terms" }]));
assert.equal(explicit.elements[0].role, "link");
});
test("drops Android system UI nodes (status bar, keyboard) from snapshots", () => {
const parsed = parseControllerOutput("agent-device", JSON.stringify({ success: true, data: { nodes: [
{ index: 0, type: "android.widget.FrameLayout", bundleId: "com.android.systemui", label: "Phone signal full.", rect: { x: 600, y: 10, width: 40, height: 30 } },
{ index: 1, type: "android.widget.TextView", bundleId: "com.android.systemui", label: "4:20 PM", rect: { x: 20, y: 10, width: 80, height: 30 } },
{ index: 2, type: "android.widget.TextView", bundleId: "dev.brent.euxy", label: "Activity Log", rect: { x: 100, y: 200, width: 200, height: 40 } },
{ index: 3, type: "android.widget.Button", bundleId: "com.google.android.inputmethod.latin", label: "q", rect: { x: 10, y: 1400, width: 60, height: 60 } },
] } }));
assert.deepEqual(parsed.elements.map((element) => element.label), ["Activity Log"]);
// iOS nodes carry no package and are untouched.
const ios = parseControllerOutput("agent-device", JSON.stringify({ success: true, data: { nodes: [
{ type: "StaticText", label: "Phone signal full.", rect: { x: 0, y: 0, width: 40, height: 20 } },
] } }));
assert.equal(ios.elements.length, 1);
});
test("preserves controller actionability and reports lossy parser heuristics", async () => {
const cwd = await mkdtemp(join(tmpdir(), "expo-explore-parser-safety-"));
await writeFixture(
cwd,
"same-parent.txt",
'@e1 [other] "Pattern row"\n@e2 [button] [same label as parent]\n',
);
const inherited = parseFile(cwd, "agent-device", "same-parent.txt");
const inheritedButton = inherited.elements.find(
(element) => element.role === "button" && element.label === "Pattern row",
);
assert.equal(inheritedButton.actionable, true);
await writeFixture(
cwd,
"visibility.json",
JSON.stringify({ role: "button", label: "Hidden Buy", visible: false }),
);
const hidden = parseFile(cwd, "argent", "visibility.json").elements[0];
assert.equal(hidden.visible, false);
assert.equal(hidden.actionable, false);
await writeFixture(
cwd,
"tab-scope.txt",
'@e1 [tab-bar] "Tab Bar"\n' +
'@e2 [button] "Home" [selected]\n' +
'@e3 [other] "Account section"\n' +
'@e4 [button] "Delete account"\n',
);
const scoped = parseFile(cwd, "agent-device", "tab-scope.txt");
assert.equal(scoped.elements.find((element) => element.label === "Home").role, "tab");
assert.equal(
scoped.elements.find((element) => element.label === "Delete account").role,
"button",
);
await writeFixture(
cwd,
"boundless-tab-scope.txt",
'@e1 [tab-bar] "Tab Bar"\n' +
'@e2 [button] "Home" [selected]\n' +
'@e3 [button] "Delete account"\n',
);
const boundless = parseFile(cwd, "agent-device", "boundless-tab-scope.txt");
assert.equal(boundless.elements.find((element) => element.label === "Home").role, "tab");
assert.equal(
boundless.elements.find((element) => element.label === "Delete account").role,
"button",
);
assert.match(boundless.warnings.join(" "), /tab membership was ambiguous/);
await writeFixture(
cwd,
"duplicates.json",
JSON.stringify([
{ role: "button", label: "Delete" },
{ role: "button", label: "Delete" },
]),
);
const duplicates = parseFile(cwd, "argent", "duplicates.json");
assert.equal(duplicates.elements.length, 1);
assert.match(duplicates.warnings.join(" "), /Collapsed 1 exact duplicate JSON element/);
await writeFixture(
cwd,
"hidden-scroll.txt",
'@e1 [scroll-area] "Feed" [scrollable]\n [content below scroll-area hidden]\n',
);
const hiddenScroll = parseFile(cwd, "agent-device", "hidden-scroll.txt");
assert.match(hiddenScroll.warnings.join(" "), /add a swipe frontier/);
});
test("reclaims an abandoned writer lock immediately", async () => {
const cwd = await mkdtemp(join(tmpdir(), "expo-explore-lock-"));
const store = await createMapStore(cwd, "Lock recovery");
await writeFile(
store.lockPath,
`${JSON.stringify({ pid: 999_999_999, token: "abandoned", createdAt: new Date().toISOString() })}\n`,
);
const added = await addCoverageGap(store, {
kind: "connection",
message: "Recovered after the previous writer exited",
});
assert.equal(added.gap.status, "open");
await assert.rejects(readFile(store.lockPath), { code: "ENOENT" });
});
test("uses revision compare-and-swap and validates artifact integrity", async () => {
const cwd = await mkdtemp(join(tmpdir(), "expo-explore-cas-"));
await writeFixture(cwd, "root.png", png);
await writeFixture(cwd, "root.txt", '@e1 [text] "Root"\n');
const store = await createMapStore(cwd, "CAS");
await addScreen(store, {
id: "root",
title: "Root",
screenshotPath: join(cwd, "root.png"),
snapshotPath: join(cwd, "root.txt"),
start: true,
});
const first = await store.read();
const stale = structuredClone(first);
first.run.environment.instructions = "first writer";
await store.write(first);
stale.run.environment.instructions = "stale writer";
await assert.rejects(store.write(stale), /changed concurrently/);
const map = await store.read();
const screenshot = map.artifacts.find((artifact) => artifact.kind === "screenshot");
await writeFile(join(cwd, ".explore", screenshot.path), "corrupted");
const validation = runFailure(cwd, ["validate"]);
assert.match(validation.errors.join(" "), /integrity validation/);
});
test("refuses cross-screen references even when the JSON schema is valid", async () => {
const cwd = await mkdtemp(join(tmpdir(), "expo-explore-references-"));
const store = await createMapStore(cwd, "References");
for (const id of ["first", "second"]) {
await addScreen(store, { id, title: id, fingerprint: id, start: id === "first" });
}
const map = await store.read();
const firstObservation = map.observations.find((observation) => observation.screenId === "first");
const secondVariant = map.variants.find((variant) => variant.screenId === "second");
firstObservation.variantId = secondVariant.id;
await assert.rejects(store.write(map), /variant from another screen/);
assert.equal(run(cwd, ["validate"]).ok, true);
});
test("npm package contains the action, workflow templates, compiled artifacts, and setup skill", async () => {
const cache = await mkdtemp(join(tmpdir(), "expo-explore-npm-cache-"));
const result = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], {
cwd: root,
encoding: "utf8",
env: { ...process.env, npm_config_cache: cache },
});
assert.equal(result.status, 0, result.stderr);
const pack = JSON.parse(result.stdout)[0];
const files = new Set(pack.files.map((file) => file.path));
assert.ok(files.has("LICENSE"));
assert.ok(files.has("action.yml"));
assert.ok(files.has("templates/explore-baseline.yml"));
assert.ok(files.has("templates/explore-review.yml"));
assert.ok(!files.has("templates/explore-pr.yml"));
assert.ok(!files.has("templates/explore-public-review.yml"));
assert.ok(files.has("dist/cli.js"));
assert.ok(files.has("dist/config/setup.js"));
assert.ok(files.has("dist/viewer/index.html"));
assert.ok(files.has("dist/viewer/fonts/OFL-1.1.txt"));
assert.ok(files.has("dist/viewer/fonts/NOTICE.txt"));
assert.ok(files.has("schema/screen-map.v1.schema.json"));
assert.ok(files.has("schema/build-catalog.v1.schema.json"));
assert.ok(files.has("schema/build-comparison.v1.schema.json"));
assert.ok([...files].every((file) => !file.startsWith(".agents/")));
assert.ok(files.has("skills/explore-setup/SKILL.md"));
assert.ok([...files].every((file) => !file.startsWith("examples/")));
assert.match(
import.meta.resolve("@expo/explore/schema/build-catalog.v1"),
/schema\/build-catalog\.v1\.schema\.json$/,
);
assert.match(
import.meta.resolve("@expo/explore/schema/build-comparison.v1"),
/schema\/build-comparison\.v1\.schema\.json$/,
);
});
test("compiled CLI remains directly executable after a build", async () => {
assert.notEqual((await stat(cli)).mode & 0o111, 0);
const result = spawnSync(cli, ["help", "--json"], {
cwd: root,
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Expo Explore/);
});
test("help lists the terminal and CI commands and keeps the map queries behind --all", () => {
const short = spawnSync(cli, ["help"], { cwd: root, encoding: "utf8" });
assert.equal(short.status, 0, short.stderr);
assert.match(short.stdout, /Get started/);
assert.match(short.stdout, /capture:review/);
assert.match(short.stdout, /expo-explore help --all/);
assert.doesNotMatch(short.stdout, /screen:get/);
const all = spawnSync(cli, ["help", "--all"], { cwd: root, encoding: "utf8" });
assert.equal(all.status, 0, all.stderr);
assert.match(all.stdout, /Map queries/);
assert.match(all.stdout, /screen:get --id ID/);
});
test("export:web rejects the removed visual diff command override", () => {
const result = raw(root, [
"export:web",
"--agent-device-command",
"/tmp/agent-device",
]);
assert.notEqual(result.status, 0);
const failure = JSON.parse(result.stderr || result.stdout);
assert.match(failure.error, /Unknown flag for export:web/);
});
test("doctor --capture accepts --platform, as its help says", async () => {
const cwd = await mkdtemp(join(tmpdir(), "expo-explore-doctor-platform-"));
try {
const result = raw(root, ["doctor", "--capture", "--platform", "android", "--offline", "--project", cwd]);
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Unknown flag for doctor/);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("setup --workflows accepts --force", async () => {
const cwd = await mkdtemp(join(tmpdir(), "expo-explore-setup-force-"));
try {
const result = raw(root, ["setup", "--workflows", "--force", "--project", cwd]);
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Unknown flag for setup/);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});