Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
104 commits
Select commit Hold shift + click to select a range
b382d70
Base for flow e2e
j-piasecki Jun 29, 2026
3a61279
Add `launch` block
j-piasecki Jun 29, 2026
4119d2f
Remove re-export
j-piasecki Jun 29, 2026
107d177
Add syntax sugar ocver assert/await
j-piasecki Jun 29, 2026
4d2013b
Instruct the agent to polish recorded flows
j-piasecki Jun 29, 2026
46d3e9c
Note distinction between tool calls and directives in the skill
j-piasecki Jun 29, 2026
df676b1
Ignore leading launch/restart calls in flows
j-piasecki Jun 29, 2026
483b55f
Wait after launching the app
j-piasecki Jun 29, 2026
dc32cfb
Add scroll-to directive
j-piasecki Jun 29, 2026
938ec35
Use native hierarchy on ios
j-piasecki Jun 30, 2026
a60fb9a
Print echo messages
j-piasecki Jun 30, 2026
abe05de
Rewrite flow execution to run directive
j-piasecki Jun 30, 2026
d3e6a35
Ignore echos in step numbering
j-piasecki Jun 30, 2026
297ace6
Pin status bar for entire flow
j-piasecki Jun 30, 2026
fd914c2
Update shorthand to look for ids first, then text
j-piasecki Jun 30, 2026
59757dd
Settle before taking screenshot
j-piasecki Jun 30, 2026
8d3f4a2
Fix swipe settle
j-piasecki Jul 1, 2026
91b972c
Fail flow if native devtools not connected
j-piasecki Jul 1, 2026
f2cd384
Ensure scroll target is visible
j-piasecki Jul 1, 2026
9cd3fad
Stop ignoring launch/restart commands, update skill
j-piasecki Jul 1, 2026
e94127a
Add wait directive
j-piasecki Jul 1, 2026
d1cd458
Submit text inputs after typing is finished
j-piasecki Jul 1, 2026
c510dce
Dump entire android hierarchy
j-piasecki Jul 1, 2026
e37f1c4
Fix text-based assertions
j-piasecki Jul 1, 2026
5532a28
Rename file
j-piasecki Jul 1, 2026
7d5e80a
Format
j-piasecki Jul 1, 2026
bcbffc2
Cleanup duplication
j-piasecki Jul 2, 2026
7ed80e6
Fix await, add shorthand syntax to other directives
j-piasecki Jul 2, 2026
b85f761
Remove unnecessary config
j-piasecki Jul 2, 2026
3112f0c
Launch as a step
j-piasecki Jul 2, 2026
78d8fad
Update await/assert timeouts
j-piasecki Jul 2, 2026
ca7f555
Update text matching algorithm
j-piasecki Jul 2, 2026
88d2dbd
Clear accessibility cache in flow
j-piasecki Jul 2, 2026
da55620
Warn on snapshot when no baseline exists
j-piasecki Jul 3, 2026
07c6327
Scroll by half the container not half the screen
j-piasecki Jul 3, 2026
5773368
Unify selector matching
j-piasecki Jul 3, 2026
65d8f44
Unify selectors
j-piasecki Jul 3, 2026
12042ec
Ignore text that's not visible in asserts
j-piasecki Jul 3, 2026
015ee17
Fix status bar staying potentially pinned
j-piasecki Jul 3, 2026
50e8964
Snapshot results as artifacts
j-piasecki Jul 3, 2026
2fd342e
Remove auto scroll
j-piasecki Jul 3, 2026
33c6658
Adapt chromium tree
j-piasecki Jul 3, 2026
9aeac9e
Preserve test ids on chromium
j-piasecki Jul 3, 2026
58abcd9
Handle Vega
j-piasecki Jul 6, 2026
987fb28
Wait to settle before typing
j-piasecki Jul 6, 2026
c8b604e
Fix failing test
j-piasecki Jul 6, 2026
ad33379
Format
j-piasecki Jul 6, 2026
8ac056f
Fix type error
j-piasecki Jul 6, 2026
c7961ec
Don't report screenshots on comparison success
j-piasecki Jul 6, 2026
a0bf514
Fix stale skill content
j-piasecki Jul 6, 2026
5f16fcb
Boot chromium device when running a flow
j-piasecki Jul 7, 2026
00a86ef
Fix chromium path being treated as a bundle id
j-piasecki Jul 7, 2026
0f84fa1
Validate maxMismatch values
j-piasecki Jul 7, 2026
3b837a2
Use full tree for recording
j-piasecki Jul 7, 2026
20b3ddd
Fail snapshot comparisons on no baseline
j-piasecki Jul 7, 2026
08542cb
Fail when devtools unavailable
j-piasecki Jul 7, 2026
15857b4
Report cancelled directives as skips
j-piasecki Jul 7, 2026
2f9446a
Fix hidden timeout reason after blank reads
j-piasecki Jul 7, 2026
bc4d5e8
Scope scroll end detection to the container region
j-piasecki Jul 7, 2026
1408dd2
Exclude the root from the chromium flow flatten
j-piasecki Jul 7, 2026
87b9790
Never surface a password input's value as its label
j-piasecki Jul 7, 2026
9c40c85
Flag iframe-held focus on the inner element only
j-piasecki Jul 7, 2026
e4ac8bc
Treat a rejected visibility probe as inconclusive
j-piasecki Jul 7, 2026
9ed9d50
Reject non-finite await timeouts
j-piasecki Jul 7, 2026
3b29a6e
Enforce project containment for explicit flow_file
j-piasecki Jul 7, 2026
969ab00
Clean up snapshot diff scratch dir
j-piasecki Jul 7, 2026
990a627
Fetch only the inlined diff artifact
j-piasecki Jul 7, 2026
769e5ff
Harden Android demo-mode undo on pin failure
j-piasecki Jul 7, 2026
88b9e25
Drop unreferenced flow failure codes
j-piasecki Jul 7, 2026
aa40f83
Document recorded taps as strict text selectors
j-piasecki Jul 7, 2026
10afac4
Error on missing --device/--platform values in flow run
j-piasecki Jul 7, 2026
6381a42
Hoist descendant text in the Vega flow tree
j-piasecki Jul 7, 2026
f98939a
Stub incidental shell-outs in unit tests
j-piasecki Jul 7, 2026
2c6b1c3
Attach cause to devtools unavailable errors
j-piasecki Jul 7, 2026
475c1aa
feat(flow): stream flow steps instead of waiting for it to finish
j-piasecki Jul 7, 2026
41abe03
feat(flow): include selectors in the logs
j-piasecki Jul 7, 2026
3edb401
feat(flow): add optional `output` parameter for storing failed flow info
j-piasecki Jul 7, 2026
4e7219a
Fix CI
j-piasecki Jul 10, 2026
d2559b9
Fix import
j-piasecki Jul 10, 2026
8b66323
Add `id` shorthand
j-piasecki Jul 10, 2026
ba684a5
Honor --help anywhere in flow run args
j-piasecki Jul 12, 2026
5e0a00d
Proceed with snapshot capture when the tree source is down
j-piasecki Jul 12, 2026
a1d370a
Reject a timeout on assert steps
j-piasecki Jul 12, 2026
eb6da50
Make flow serialization round-trip-safe
j-piasecki Jul 12, 2026
d88feac
Deduplicate the selector-to-string helpers
j-piasecki Jul 12, 2026
d569a67
Read the same element in text checks and their messages
j-piasecki Jul 12, 2026
2a78898
Derive recordable text selectors that can self-match
j-piasecki Jul 12, 2026
94843e7
Drop a container's own label from subtreeText when descendants render it
j-piasecki Jul 12, 2026
f622980
Report a cancelled launch as a skip
j-piasecki Jul 12, 2026
403e37c
Kill runner-booted Chromium through its process handle
j-piasecki Jul 12, 2026
0d871c7
Accept --flag=value and reject unknown flags in flow run
j-piasecki Jul 12, 2026
f7242d1
Fetch only the artifacts the flow CLI actually uses
j-piasecki Jul 12, 2026
a621d30
Contain artifact exports to the output directory
j-piasecki Jul 12, 2026
bb4dabb
Fail cancelled flow runs
j-piasecki Jul 13, 2026
b1d0e4a
Retain semantic role targets in flow trees
j-piasecki Jul 13, 2026
471a6d5
Update comment
j-piasecki Jul 13, 2026
ece0897
Scope end-of-scroll detection to scrollers under the swipe anchor
j-piasecki Jul 13, 2026
341b224
Flush stdio before exiting the flow CLI
j-piasecki Jul 13, 2026
2a4e963
Reject unknown keys in flow directive options
j-piasecki Jul 13, 2026
ace8ab7
Accept scroll-to targets that span the whole clip window
j-piasecki Jul 13, 2026
1897d99
Prune flow tree nodes scrolled out of their scroll containers
j-piasecki Jul 13, 2026
8e3c1f2
Re-check the abort signal after settle-completing tree reads
j-piasecki Jul 13, 2026
6f97323
Dedup container labels only on whole-word descendant matches
j-piasecki Jul 13, 2026
8451260
Format
j-piasecki Jul 13, 2026
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
481 changes: 481 additions & 0 deletions packages/argent-cli/src/flow.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/argent-cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { run, type RunCommandOptions } from "./run.js";
export { flow, type FlowCommandOptions } from "./flow.js";
export { tools, type ToolsCommandOptions } from "./tools.js";
export { server } from "./server.js";
export { lens, type LensCommandOptions } from "./lens.js";
Expand Down
320 changes: 320 additions & 0 deletions packages/argent-cli/test/flow-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { MaterializeContext } from "@argent/tools-client";
import { exportFailureArtifacts, type FlowReport, type StepReport } from "../src/flow.js";

let tmpDir: string;
let outDir: string;

// Legacy string-path artifacts contain no handles, so materialization walks
// them without touching the network — the URL never resolves.
const ctx: MaterializeContext = { toolsUrl: "http://tools.invalid" };

async function writeFile(name: string, content: string): Promise<string> {
const p = path.join(tmpDir, name);
await fs.writeFile(p, content);
return p;
}

/**
* A wire artifact handle whose hostPath is a real local file, sized/stamped so
* the materializer's co-location gate resolves it in place (no download).
*/
async function writeHandle(name: string, content: string): Promise<Record<string, unknown>> {
const p = await writeFile(name, content);
const st = await fs.stat(p);
return {
__argentArtifact: true,
id: `id-${name}`,
filename: name,
mimeType: "image/png",
size: st.size,
mtimeMs: st.mtimeMs,
hostPath: p,
};
}

function mkReport(steps: StepReport[]): FlowReport {
return {
flow: "checkout",
device: "UDID-1",
ok: false,
passed: 0,
failed: 1,
skipped: 0,
errored: 0,
steps,
};
}

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-output-"));
outDir = path.join(tmpDir, "out");
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});

describe("exportFailureArtifacts", () => {
it("copies every role of a failed snapshot to <output>/<flow>/<key>-<role>.png and rewrites the report", async () => {
const baseline = await writeFile("b.png", "baseline-bytes");
const current = await writeFile("c.png", "current-bytes");
const diff = await writeFile("d.png", "diff-bytes");
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { baseline, current, diff },
};

await exportFailureArtifacts(mkReport([step]), outDir, ctx);

const dir = path.join(outDir, "checkout");
for (const [role, content] of [
["baseline", "baseline-bytes"],
["current", "current-bytes"],
["diff", "diff-bytes"],
] as const) {
const dest = path.join(dir, `home__ios-390x844-${role}.png`);
expect(step.artifacts?.[role]).toBe(dest);
expect(await fs.readFile(dest, "utf8")).toBe(content);
}
});

it("leaves passed and baseline-seeded snapshots alone (failure-only)", async () => {
const baseline = await writeFile("b.png", "baseline-bytes");
const seeded: StepReport = {
index: 0,
kind: "snapshot",
status: "pass",
warning: "baseline created",
snapshotKey: "home__ios-390x844",
artifacts: { baseline },
};

await exportFailureArtifacts(mkReport([seeded]), outDir, ctx);

expect(seeded.artifacts?.baseline).toBe(baseline);
await expect(fs.access(outDir)).rejects.toThrow();
});

it("derives the key from the baseline path when the server sent no snapshotKey", async () => {
const baseline = await writeFile("home__android-1080x2400.png", "baseline-bytes");
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
artifacts: { baseline },
};

await exportFailureArtifacts(mkReport([step]), outDir, ctx);

expect(step.artifacts?.baseline).toBe(
path.join(outDir, "checkout", "home__android-1080x2400-baseline.png")
);
});

it("skips unmaterialized (null) roles and steps with no usable key", async () => {
const current = await writeFile("c.png", "current-bytes");
const withNull: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { baseline: null, current },
};
const keyless: StepReport = {
index: 1,
kind: "snapshot",
status: "fail",
artifacts: { current },
};

await exportFailureArtifacts(mkReport([withNull, keyless]), outDir, ctx);

expect(withNull.artifacts?.baseline).toBeNull();
expect(withNull.artifacts?.current).toBe(
path.join(outDir, "checkout", "home__ios-390x844-current.png")
);
// No snapshotKey and no baseline to derive one from — nothing written.
expect(keyless.artifacts?.current).toBe(current);
});

it("materializes a co-located snapshot's handles in place and copies them without fetching", async () => {
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: {
baseline: await writeHandle("b.png", "baseline-bytes"),
current: await writeHandle("c.png", "current-bytes"),
diff: await writeHandle("d.png", "diff-bytes"),
},
};
const fetchSpy = vi.fn(async () => {
throw new Error("unexpected network fetch");
});

await exportFailureArtifacts(mkReport([step]), outDir, {
toolsUrl: "http://tools.invalid",
fetchImpl: fetchSpy as unknown as typeof fetch,
});

// The handles' hostPaths are on this machine — resolved in place, no wire.
expect(fetchSpy).not.toHaveBeenCalled();
for (const [role, content] of [
["baseline", "baseline-bytes"],
["current", "current-bytes"],
["diff", "diff-bytes"],
] as const) {
const dest = path.join(outDir, "checkout", `home__ios-390x844-${role}.png`);
expect(step.artifacts?.[role]).toBe(dest);
expect(await fs.readFile(dest, "utf8")).toBe(content);
}
});

it("downloads a remote handle (no hostPath) and copies it under --output", async () => {
const prevCache = process.env.ARGENT_ARTIFACTS_DIR;
process.env.ARGENT_ARTIFACTS_DIR = path.join(tmpDir, "cache");
try {
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: {
diff: {
__argentArtifact: true,
id: "diff-1",
filename: "remote-diff.png",
mimeType: "image/png",
size: 10,
},
},
};
const fetchSpy = vi.fn(async () => new Response("diff-bytes"));

await exportFailureArtifacts(mkReport([step]), outDir, {
toolsUrl: "http://tools.invalid",
authToken: "tok",
fetchImpl: fetchSpy as unknown as typeof fetch,
});

expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith("http://tools.invalid/artifacts/diff-1", {
headers: { Authorization: "Bearer tok" },
});
const dest = path.join(outDir, "checkout", "home__ios-390x844-diff.png");
expect(step.artifacts?.diff).toBe(dest);
expect(await fs.readFile(dest, "utf8")).toBe("diff-bytes");
} finally {
if (prevCache === undefined) delete process.env.ARGENT_ARTIFACTS_DIR;
else process.env.ARGENT_ARTIFACTS_DIR = prevCache;
}
});

it("refuses a flow name with path traversal: warns, writes nothing, downloads nothing", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// A remote handle (no hostPath) would force a download — proving the
// guard fires before materialization, not just before the copy.
const handle = {
__argentArtifact: true,
id: "diff-1",
filename: "remote-diff.png",
mimeType: "image/png",
size: 10,
};
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { diff: handle },
};
const fetchSpy = vi.fn(async () => new Response("diff-bytes"));

await exportFailureArtifacts({ ...mkReport([step]), flow: "../escape" }, outDir, {
toolsUrl: "http://tools.invalid",
fetchImpl: fetchSpy as unknown as typeof fetch,
});

expect(fetchSpy).not.toHaveBeenCalled();
expect(step.artifacts?.diff).toBe(handle);
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("unsafe flow name"));
// The would-be destination outside --output must not exist.
await expect(fs.access(path.join(tmpDir, "escape"))).rejects.toThrow();
await expect(fs.access(outDir)).rejects.toThrow();
} finally {
errSpy.mockRestore();
}
});

it("skips a step whose snapshotKey contains path traversal, still exporting safe steps", async () => {
const evil = await writeFile("evil.png", "evil-bytes");
const good = await writeFile("good.png", "good-bytes");
const badStep: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "../../pwned",
artifacts: { current: evil },
};
const goodStep: StepReport = {
index: 1,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: good },
};

await exportFailureArtifacts(mkReport([badStep, goodStep]), outDir, ctx);

// Untouched — the join would have resolved to <tmpDir>/pwned-current.png.
expect(badStep.artifacts?.current).toBe(evil);
await expect(fs.access(path.join(tmpDir, "pwned-current.png"))).rejects.toThrow();
expect(goodStep.artifacts?.current).toBe(
path.join(outDir, "checkout", "home__ios-390x844-current.png")
);
});

it("skips a step whose baseline-derived key reduces to '..'", async () => {
// path.basename("<dir>/..") is ".." — the fallback alone can't contain it.
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
artifacts: { baseline: `${tmpDir}${path.sep}..` },
};

await exportFailureArtifacts(mkReport([step]), outDir, ctx);

expect(step.artifacts?.baseline).toBe(`${tmpDir}${path.sep}..`);
await expect(fs.access(outDir)).rejects.toThrow();
});

it("warns and keeps the temp path when a source file is unreadable", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const gone = path.join(tmpDir, "vanished.png");
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { diff: gone },
};

await exportFailureArtifacts(mkReport([step]), outDir, ctx);

expect(step.artifacts?.diff).toBe(gone);
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("warning: could not write"));
} finally {
errSpy.mockRestore();
}
});
});
Loading
Loading