Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,14 @@ npx @expo/explore compare:flows --before explore-app-1 --after explore-app-2 --o
```

Each checkpoint comes back `unchanged`, `changed` (with where the screenshots differ, the status bar and home indicator set aside, as in a review), `missing` (no picture on one side, with why), `different-size` (another device or orientation, so not compared), or `added` / `removed` (in only one run's flows file). The result also lists how the two runs were captured differently (the app, device, OS version, locale, orientation, or appearance), since a change can come from those.

Add `--report DIRECTORY` to write a report that opens on the Viewer's Flows view, and host it with `report:upload DIRECTORY`. It is the after run's report with the comparison beside it and both builds' checkpoint screenshots:

- The list shows each flow with what happened to it ("Stopped · 2 not reached", "3 changed"). Changed keeps every flow with a change and every run that stopped.
- A flow is a filmstrip of its checkpoints, as in Screens, and opens at its first problem. Above the checkpoint in the middle are the steps that led to it, and, for a change too small to read in the picture, the text that changed. ← and → step through the checkpoints, J and K through the changed areas of one.
- Before | After (X) flips the pictures to the other build in place.

A run records each flow's steps for the review; runs made before Explore recorded them show none.
- A `{{secret:NAME}}` or a described step limited by `platforms` to another platform needs no value, and no model, on this one.

</details>
Expand Down
2 changes: 1 addition & 1 deletion skills/explore-binary/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ To compare runs, or an iPhone with an Android phone, screen for screen, pass `--
When the user describes a flow, or a set of flows, to look at ("sign in, then rename a pattern"), write them as a flows file and pass `--flows flows.json`, not `--replay`. The format is in the README ("Follow named flows, step by step"): `{ "flows": [{ "name": "Sign in", "steps": [{ "checkpoint": "Welcome" }, { "do": "Sign in with {{secret:TEST_EMAIL}}" }, { "checkpoint": "Home" }] }] }`.
- Each step is an exact action (the `--replay` format), a step described in words (`do`) that a model carries out, or a named `checkpoint`. Use exact actions where you know the labels, and `do` where you don't.
- Each flow starts from a relaunched app, and its checkpoints are photographed under their names. `report/exploration.json` lists each flow under `exploration.flows`, with its status and its checkpoints in order, so two runs pair up by flow and checkpoint name. A checkpoint on a screen that withholds its picture (a secret on it) is listed with why it has none, and its flow goes on.
- To compare two runs of the same flows file, run `npx @expo/explore compare:flows --before DIR --after DIR` with the two output directories. Each checkpoint is `unchanged`, `changed` (with regions), `missing`, `different-size`, `added` or `removed`, and `conditions` lists what differed between the runs (device, OS, locale, appearance).
- To compare two runs of the same flows file, run `npx @expo/explore compare:flows --before DIR --after DIR` with the two output directories. Each checkpoint is `unchanged`, `changed` (with regions), `missing`, `different-size`, `added` or `removed`, and `conditions` lists what differed between the runs (device, OS, locale, appearance). Add `--report DIR` to write a report that opens on the Viewer's Flows view (each flow's checkpoints, the steps between them and the text that changed), and host it with `report:upload DIR`.
- The file is checked before a device starts (`FLOWS_PLAN_INVALID`, `FLOWS_PLAN_EMPTY`, `FLOWS_PLAN_OVER_BUDGET`). `--provider`, `--model` and `--hints` steer the model that carries out `do` steps, and are refused when there are none (`FLOWS_OPTIONS_IGNORED`). `--flows` and `--replay` cannot be combined.

## Sign-in and other app state
Expand Down
11 changes: 11 additions & 0 deletions src/binary/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CiError } from "../shared/ci-errors.js";
import type { CiAgentSession, CiAgentSessionRunRequest, CiAgentSessionRunResult, CiAgentToolResult } from "../capture/agent/session.js";
import type { CiAgentTool } from "../capture/agent/provider.js";
import type { CaptureSetupAction } from "../config/explore-config.js";
import type { ExplorationFlowStep } from "../contracts/standalone-report.js";
import { explorationSecretNames } from "../config/exploration-inputs.js";
import { TOOL_LIMITS } from "./explorer.js";
import { actInput, describeAction, listed, parseReplayStep, type ReplayAction, type ReplayPlatform } from "./replay.js";
Expand All @@ -22,6 +23,8 @@ export type FlowSession = {
budgetNote(): string | undefined;
/** Each flow's checkpoints the run has not attempted, in order: the explorer records them as missing. */
notReached(): Array<{ flow: string; checkpoints: string[] }>;
/** Each flow's steps on the platform, and the one it failed at, for the report. */
steps(): Array<{ flow: string; steps: ExplorationFlowStep[]; failedStep?: number }>;
};

const MAX_FLOWS = 50;
Expand Down Expand Up @@ -228,11 +231,16 @@ export function createFlowSession(plan: FlowPlan, platform: ReplayPlatform, opti
const checkpoints = steps.flatMap(({ step }) => "checkpoint" in step && !attempted.get(flow.name)?.has(step.checkpoint) ? [step.checkpoint] : []);
return checkpoints.length ? [{ flow: flow.name, checkpoints }] : [];
});
/** The step each failed flow stopped at, counted from 1 among its steps on the platform. */
const failedAt = new Map<string, number>();
return {
provider: "flows",
model: "flows file",
async preflight() {},
notReached,
steps: () => flows.map(({ flow, steps }) => ({ flow: flow.name,
steps: steps.map(({ step }): ExplorationFlowStep => "checkpoint" in step ? { checkpoint: step.checkpoint } : "do" in step ? { do: step.do } : { action: describeAction(step) }),
...(failedAt.has(flow.name) ? { failedStep: failedAt.get(flow.name)! } : {}) })),
budgetNote() {
const names = notReached().flatMap(({ flow, checkpoints }) => checkpoints.map(name => `${flow} › ${name}`));
return names.length ? `Not reached: ${listed(names, TOOL_LIMITS.reason, "checkpoints")}.` : undefined;
Expand Down Expand Up @@ -273,6 +281,7 @@ export function createFlowSession(plan: FlowPlan, platform: ReplayPlatform, opti
continue;
}
failure = `Checkpoint "${step.checkpoint}" could not be taken: ${why(taken.value)}`;
failedAt.set(flow.name, index + 1);
break;
}
if ("do" in step) {
Expand All @@ -284,12 +293,14 @@ export function createFlowSession(plan: FlowPlan, platform: ReplayPlatform, opti
usage.inputTokens += goal.usage.inputTokens; usage.outputTokens += goal.usage.outputTokens;
if (goal.reached) continue;
failure = `Step ${number} ("${step.do.slice(0, 120)}") was not done: ${goal.reason}`;
failedAt.set(flow.name, index + 1);
break;
}
const acted = await call("act", actInput(step));
if (!acted.failed) continue;
if (step.optional) { skipped.push(`${flow.name} step ${number} (${describeAction(step)})`); continue; }
failure = `Step ${number} (${describeAction(step)}) failed: ${why(acted.value)}`;
failedAt.set(flow.name, index + 1);
break;
}
await call("end_flow", failure ? { status: "failed", reason: failure.slice(0, TOOL_LIMITS.reason) } : { status: "completed" });
Expand Down
6 changes: 6 additions & 0 deletions src/binary/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,12 @@ export async function exploreConnectedSession(ctx: ConnectedSessionExploration):
throw new Error(failureText(error));
}
} });
for (const planned of strategy.flowSteps?.() ?? []) {
const record = exploration.flows?.find(flow => flow.name.toLowerCase() === planned.flow.toLowerCase());
if (!record) continue;
record.steps = planned.steps;
if (planned.failedStep !== undefined && record.status === "failed") record.failedStep = planned.failedStep;
}
// "complete" is reserved for a run with no pending frontier or open gaps; a model that finishes early is "partial".
// The map schema allows stopReason only on partial/failed runs.
map.run.status = exploration.stopReason === "model-error" ? "failed"
Expand Down
5 changes: 4 additions & 1 deletion src/binary/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createCiAgentSession, type CiAgentSession } from "../capture/agent/sess
import { createLocalCiAgentSession } from "../capture/agent/session-local.js";
import type { CiAgentProviderName } from "../capture/agent/provider.js";
import { createExplorationInputs } from "../config/exploration-inputs.js";
import type { ExplorationFlowStep } from "../contracts/standalone-report.js";
import { CiError } from "../shared/ci-errors.js";
import type { Platform } from "../types.js";
import { assertAppUrl } from "./app-url.js";
Expand Down Expand Up @@ -33,6 +34,8 @@ export type ExplorationStrategy = {
* and names each flow's checkpoints it never attempted, for the explorer to record as missing.
*/
flows?: () => Array<{ flow: string; checkpoints: string[] }>;
/** Each flow's steps and the one it failed at, which the report keeps beside the flow. */
flowSteps?: () => Array<{ flow: string; steps: ExplorationFlowStep[]; failedStep?: number }>;
};

export const DEFAULT_MODELS: Record<CiAgentProviderName, string> = { anthropic: "claude-sonnet-4-5", openai: "gpt-6.1-sol" };
Expand Down Expand Up @@ -127,7 +130,7 @@ export async function startStrategy(choice: StrategyChoice, context: { platform:
assertFlowsFit(choice.plan, platform, { ...limits, maxTurns: turnBudget(limits) });
const goals = flowsNeedModel(choice.plan, platform) ? await preflightModelSession(options, environment, say) : undefined;
const session = createFlowSession(choice.plan, platform, { app, ...(goals ? { model: goals.session } : {}), ...(options.hints ? { hints: options.hints } : {}) });
return { session, budgetNote: session.budgetNote, flows: session.notReached, provider: "flows", model: goals ? `${goals.provider} ${goals.model}` : session.model, auth: goals?.auth ?? "none" };
return { session, budgetNote: session.budgetNote, flows: session.notReached, flowSteps: session.steps, provider: "flows", model: goals ? `${goals.provider} ${goals.model}` : session.model, auth: goals?.auth ?? "none" };
}
return preflightModelSession(options, environment, say);
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const flagKinds: Record<string, Record<string, "value" | "boolean">> = {
"ci:find-baseline": { sha:"value", target:"value", platform:"value" },
"ci:compare-refs": { project:"value", base:"value", head:"value", title:"value", output:"value" },
"ci:after-capture": { output:"value", checkout:"value" },
"compare:flows": { before: "value", after: "value", output: "value" },
"compare:flows": { before: "value", after: "value", output: "value", report: "value" },
compare: {
baseline: "value",
current: "value",
Expand Down
11 changes: 8 additions & 3 deletions src/cli/binary-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { readReplayPlan } from "../binary/replay.js";
import { readFlowPlan } from "../binary/flows.js";
import { EXPLORATION_REPORT_KIND, type ExplorationFlow, type ExplorationReport } from "../contracts/standalone-report.js";
import { compareFlowRuns, type FlowRun } from "../review/flow-comparison.js";
import { writeFlowReviewReport } from "../review/flow-review.js";
import { diffScreenshots } from "../review/visual-evidence.js";
import { agentDeviceLauncher, ensureController } from "../shared/controller-cache.js";
import { CiError } from "../shared/ci-errors.js";
Expand Down Expand Up @@ -177,15 +178,19 @@ async function flowRun(path: string, name: "before" | "after"): Promise<FlowRun>
return refuse(`No exploration.json was found in ${resolve(path)} or its report folder.`);
}

/** `compare:flows --before DIR --after DIR [--output FILE]`: two runs of one flows file, checkpoint by checkpoint. */
/**
* `compare:flows --before DIR --after DIR [--output FILE] [--report DIR]`: two runs of one flows file, checkpoint by
* checkpoint. `--report` writes a report the Viewer opens on its Flows view.
*/
export async function compareFlows(): Promise<void> {
const [before, after] = await Promise.all([flowRun(required("before"), "before"), flowRun(required("after"), "after")]);
let command: string | undefined;
const comparison = await compareFlowRuns(before, after, async (beforePath, afterPath) => {
if (!command) { await ensureController("agent-device", { environment: process.env }); command = agentDeviceLauncher(process.env); }
return diffScreenshots({ baselinePath: beforePath, currentPath: afterPath, command });
});
const path = flag("output");
const path = flag("output"), reportPath = flag("report");
if (path) await writeFile(resolve(path), JSON.stringify(comparison, null, 2));
output({ ok: true, ...(path ? { output: resolve(path) } : {}), comparison });
const report = reportPath ? await writeFlowReviewReport(comparison, before, after, reportPath) : undefined;
output({ ok: true, ...(path ? { output: resolve(path) } : {}), ...(report ? { report } : {}), comparison });
}
5 changes: 3 additions & 2 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ const CI = `CI (the installed workflows run these; they manage remote EAS Simula
capture:baseline --project DIRECTORY --sha COMMIT --output TEMP_DIRECTORY [--config FILE] [--target ID | --platform ios|android] [--controller agent-device|argent] [--simulator eas|local] [--repack eas|local]
source:verify --base-project DIRECTORY --head-project DIRECTORY
--base-sha COMMIT --head-sha COMMIT --output DIRECTORY [--config FILE] [--scope all|changes] [--target ID | --platform ios|android]
compare:flows --before DIRECTORY --after DIRECTORY [--output FILE]
(two runs of one --flows file, checkpoint by checkpoint: unchanged, changed with where, or why not compared)
compare:flows --before DIRECTORY --after DIRECTORY [--output FILE] [--report DIRECTORY]
(two runs of one --flows file, checkpoint by checkpoint: unchanged, changed with where, or why not compared;
--report writes a report the Viewer opens on its Flows view)
compare --baseline DIRECTORY --current DIRECTORY --output FILE
[--review-output FILE] [--catalog-output FILE] [--catalog-name NAME]
[--base-surface-coverage FILE] [--head-surface-coverage FILE]
Expand Down
30 changes: 29 additions & 1 deletion src/contracts/flow-comparison.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
import type { ExplorationReport } from "./standalone-report.js";
import type { ExplorationFlow, ExplorationFlowStep, ExplorationReport } from "./standalone-report.js";

export const FLOW_COMPARISON_KIND = "explore.flow-comparison" as const;

/**
* `changed` and `unchanged` compare the two screenshots; `different-size` could not (another device or orientation);
* `missing` has no screenshot on one side; `added` and `removed` are in only one run's flows file.
*/
export type CheckpointVerdict = "unchanged" | "changed" | "different-size" | "missing" | "added" | "removed";
/** Where two screenshots differ, in percent of the image (0–100). */
export type ChangedRegion = { x: number; y: number; width: number; height: number };

/** One build's side of a checkpoint in a flow review: its screenshot (an asset of the review's report) and the text its screen showed, or why it has none. */
export type FlowReviewSide = { screenshot: string; text?: string[] } | { missing: string };
export type FlowReviewCheckpoint = { name: string; verdict: CheckpointVerdict; before?: FlowReviewSide; after?: FlowReviewSide; regions?: ChangedRegion[] };
/** How one build's run took the flow. */
export type FlowReviewRun = Pick<ExplorationFlow, "status" | "reason" | "failedStep"> & { steps?: ExplorationFlowStep[] };
export type FlowReviewFlow = { name: string; before?: FlowReviewRun; after?: FlowReviewRun; checkpoints: FlowReviewCheckpoint[] };

/**
* Two runs of one flows file, compared checkpoint by checkpoint, as the Viewer shows them: carried in the after run's
* exploration report, whose assets hold both builds' checkpoint screenshots.
*/
export type FlowReview = {
before: RunConditions;
after: RunConditions;
/** How the runs were captured differently; a checkpoint that changed may owe its change to these. */
conditions: RunConditionDifference[];
notes: string[];
flows: FlowReviewFlow[];
};

/** One run's side of a checkpoint: its screenshot (a path inside that run's report directory), or why it has none. */
export type CheckpointSide = { observationId: string; screenshot: string } | { missing: string };

Expand Down
10 changes: 10 additions & 0 deletions src/contracts/standalone-report.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PublicReview } from "./capture.js";
import type { FlowReview } from "./flow-comparison.js";
import type { BuildWorkspacePayload, Coverage, ExploreMap } from "../types.js";

export const STANDALONE_REPORT_CONTRACT = "expo.explore.standalone-report.v1" as const;
Expand All @@ -24,8 +25,15 @@ export type ExplorationFlow = {
/** The map's journeys for the flow: its walk from the screen it started on, in parts where the app moved without a transition. */
journeyIds?: string[];
checkpoints: Array<{ name: string; observationId: string; missing?: never } | { name: string; missing: string; observationId?: never }>;
/** The flow's steps on this run's platform, as the flows file names them. */
steps?: ExplorationFlowStep[];
/** The step the flow failed at, counted from 1 in `steps`. */
failedStep?: number;
};

/** An exact action (`tap "Settings"`), a step described in words for a model, or a checkpoint. */
export type ExplorationFlowStep = { action: string } | { do: string } | { checkpoint: string };

/** What `explore:binary` learned about one prebuilt app: provenance plus the map it explored, with no review or pull request. */
export type ExplorationReport = {
kind: typeof EXPLORATION_REPORT_KIND;
Expand All @@ -47,6 +55,8 @@ export type ExplorationReport = {
exploreVersion: string;
};
assets: Array<{ sha256: string; path: string }>;
/** Present when compare:flows wrote this report: the run compared with an earlier run of the same flows file. */
flowReview?: FlowReview;
};

export type EmbeddedReport = {
Expand Down
14 changes: 6 additions & 8 deletions src/review/flow-comparison.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
import { open } from "node:fs/promises";
import { resolve } from "node:path";
import { isFoldable, systemRegions } from "../contracts/capture.js";
import { FLOW_COMPARISON_KIND, conditionDifferences, pairCheckpoints, runConditions, type CheckpointPair, type RunConditionDifference, type RunConditions } from "../contracts/flow-comparison.js";
import {
FLOW_COMPARISON_KIND, conditionDifferences, pairCheckpoints, runConditions,
type ChangedRegion, type CheckpointPair, type CheckpointVerdict, type RunConditionDifference, type RunConditions,
} from "../contracts/flow-comparison.js";
import type { ExplorationReport } from "../contracts/standalone-report.js";
import { ignoreSystemRegions, systemBandFrameLoader } from "./evidence-assessment.js";
import type { ScreenshotDiffMeasurement } from "./visual-evidence.js";

/**
* `changed` and `unchanged` compare the two screenshots; `different-size` could not (another device or orientation);
* `missing` has no screenshot on one side; `added` and `removed` are in only one run's flows file.
*/
export type CheckpointVerdict = "unchanged" | "changed" | "different-size" | "missing" | "added" | "removed";
export type { CheckpointVerdict } from "../contracts/flow-comparison.js";
export type ComparedCheckpoint = CheckpointPair & {
verdict: CheckpointVerdict;
/** Share of the screenshot that differs once the status bar and home indicator are set aside. */
mismatchPercentage?: number;
/** Where the screenshots differ, in percent of the image (0–100). */
regions?: Array<{ x: number; y: number; width: number; height: number }>;
regions?: ChangedRegion[];
};

export type FlowComparison = {
Expand Down
Loading
Loading