Files
Jakub Piasecki a0d1bbb1e7 feat(flow): indent nested steps in reports by block depth (#524)
Flow reports rendered every step flat, hiding which steps ran inside a
`when:` block or a `run:` fragment. The tool-server now stamps
`StepReport.depth` (+1 per block directive, omitted at top level) and
the CLI/MCP renderers indent step labels and under-step lines by it:

```
  ✓  1 launch
  ✓  2 when visible "What's new" — condition met
  ✓  3   tap "Skip"
  ✓  4   run dismiss [dismiss]
  ✓  5     tap "OK" [dismiss]
```

Invariants:

- **Wire-compatible both ways** — a flow with no block directives
reports byte-identical to the pre-depth shape; a pre-depth server
renders flat as before.
- **Skipped ≡ entered** — skipped/errored/aborted blocks report the same
depths as entered ones, keeping reports comparable run-to-run.
- **Untrusted wire data** — renderers clamp depth (display cap, not a
producer bound): hostile values render flat or at the cap, never throw
or allocate huge strings.

Also fixes a pre-existing bug: under-step warning/artifact lines now
stay aligned past 99 steps.

Tests pin depth on every producer path (entered/skipped/errored/aborted
blocks, `run:` success/failure markers, `run:`-in-`when:`) and renderer
alignment incl. the clamp boundary.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 07:28:48 +02:00

690 lines
24 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import {
toMcpContent,
screenshotDiffToMcpContent,
isScreenshotDiffResult,
flowRunToMcpContent,
type FlowExecuteResult,
} from "../src/content.js";
import { ARTIFACT_MARKER, type ArtifactHandle } from "@argent/tools-client";
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
function artifactHandle(id: string, filename: string, mimeType: string): ArtifactHandle {
return { [ARTIFACT_MARKER]: true, id, filename, mimeType, size: 0 };
}
function fetchReturning(bytes: number[]): typeof fetch {
return (async () => ({
ok: true,
arrayBuffer: async () => new Uint8Array(bytes).buffer,
})) as unknown as typeof fetch;
}
const mockOk = (bytes: number[]) =>
vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new Uint8Array(bytes).buffer });
// ── toMcpContent ─────────────────────────────────────────────────────
describe("toMcpContent", () => {
it("returns JSON text block for plain results", async () => {
const result = await toMcpContent({ foo: "bar" });
expect(result).toEqual([{ type: "text", text: JSON.stringify({ foo: "bar" }, null, 2) }]);
});
it("returns JSON text block when outputHint is not image", async () => {
const result = await toMcpContent({ url: "http://x" }, "other");
expect(result).toEqual([
{
type: "text",
text: JSON.stringify({ url: "http://x" }, null, 2),
},
]);
});
it("fetches and base64-encodes image when outputHint is image", async () => {
const pngBytes = [...PNG_SIGNATURE, 0xde, 0xad];
vi.stubGlobal("fetch", mockOk(pngBytes));
const result = await toMcpContent(
{ url: "http://localhost/img.png", path: "/tmp/img.png" },
"image"
);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: "image",
data: Buffer.from(pngBytes).toString("base64"),
mimeType: "image/png",
});
expect(result[1]).toEqual({ type: "text", text: "Saved: /tmp/img.png" });
vi.unstubAllGlobals();
});
it("returns text only and does not fetch when args.includeImageInContext is false", async () => {
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
const result = await toMcpContent(
{
url: "http://localhost/img.png",
path: "/tmp/img.png",
},
"image",
undefined,
{ udid: "ABC", includeImageInContext: false }
);
expect(mockFetch).not.toHaveBeenCalled();
expect(result).toEqual([{ type: "text", text: "Saved: /tmp/img.png" }]);
vi.unstubAllGlobals();
});
it("attaches the image when args.includeImageInContext is undefined or true", async () => {
const pngBytes = new Uint8Array(PNG_SIGNATURE);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => pngBytes.buffer,
})
);
const result = await toMcpContent(
{ url: "http://localhost/img.png", path: "/tmp/img.png" },
"image",
undefined,
{ udid: "ABC" }
);
expect(result[0]?.type).toBe("image");
expect(result[1]).toEqual({ type: "text", text: "Saved: /tmp/img.png" });
vi.unstubAllGlobals();
});
it("uses empty string for path when not present", async () => {
vi.stubGlobal("fetch", mockOk(PNG_SIGNATURE));
const result = await toMcpContent({ url: "http://x" }, "image");
expect(result[1]).toEqual({ type: "text", text: "Saved: " });
vi.unstubAllGlobals();
});
it("falls back to text when outputHint is image but no url", async () => {
const result = await toMcpContent({ foo: 1 }, "image");
expect(result).toEqual([{ type: "text", text: JSON.stringify({ foo: 1 }, null, 2) }]);
});
// Regression for #255 — fetched bytes that aren't a PNG must NOT be shipped
// labelled as image/png. The three cases below cover what `fetch(url)` can
// realistically return when the simulator-server's `/media/...` URL goes
// sideways: a 404 with an empty body, a 200 with a non-PNG body (any
// upstream error page), and the network throwing.
it("returns a placeholder when fetch returns 404", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: false, arrayBuffer: async () => new ArrayBuffer(0) })
);
const result = await toMcpContent({ url: "http://x/missing.png" }, "image");
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("text");
expect(result.find((b) => b.type === "image")).toBeUndefined();
vi.unstubAllGlobals();
});
it("returns a placeholder when fetched bytes are not a PNG", async () => {
vi.stubGlobal("fetch", mockOk(Array.from(Buffer.from("<!doctype html>"))));
const result = await toMcpContent({ url: "http://x/wrong.png" }, "image");
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("text");
expect(result.find((b) => b.type === "image")).toBeUndefined();
vi.unstubAllGlobals();
});
it("returns a placeholder when fetch throws", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
const result = await toMcpContent({ url: "http://127.0.0.1:1/x.png" }, "image");
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("text");
expect(result.find((b) => b.type === "image")).toBeUndefined();
vi.unstubAllGlobals();
});
});
describe("screenshotDiffToMcpContent", () => {
it("returns a context image followed by the summary text", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "argent-mcp-content-"));
const contextDiffPath = path.join(dir, "context.diff.png");
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
await fs.writeFile(contextDiffPath, pngBytes);
const summary = [
"Screenshot diff summary",
"",
"Overall:",
"- status: unchanged",
"- pixel_mismatch: 0% - no pixel change",
].join("\n");
const content = await screenshotDiffToMcpContent({
summary,
diffPath: path.join(dir, "full.diff.png"),
contextDiffPath,
});
expect(content).toEqual([
{
type: "image",
data: pngBytes.toString("base64"),
mimeType: "image/png",
},
{ type: "text", text: summary },
]);
});
it("returns only the summary text when no context image is present", async () => {
const summary = [
"Screenshot diff summary",
"",
"Overall:",
"- status: dimension_mismatch",
"- dimension_mismatch: expected=2x1 actual=1x2",
].join("\n");
const content = await screenshotDiffToMcpContent({ summary });
expect(content).toEqual([{ type: "text", text: summary }]);
});
});
// ── isScreenshotDiffResult ───────────────────────────────────────────
describe("isScreenshotDiffResult", () => {
it("returns true for values carrying a string summary", () => {
expect(isScreenshotDiffResult({ summary: "hello" })).toBe(true);
expect(isScreenshotDiffResult({ summary: "hello", contextDiffPath: "/tmp/x.png" })).toBe(true);
});
it("returns false for non-object values or missing summary", () => {
expect(isScreenshotDiffResult(null)).toBe(false);
expect(isScreenshotDiffResult("string")).toBe(false);
expect(isScreenshotDiffResult({})).toBe(false);
expect(isScreenshotDiffResult({ summary: 123 })).toBe(false);
});
});
// ── toMcpContent with artifact context (remote-aware path) ───────────
describe("toMcpContent with artifact ctx", () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "content-artifacts-"));
process.env.ARGENT_ARTIFACTS_DIR = root;
});
afterEach(async () => {
delete process.env.ARGENT_ARTIFACTS_DIR;
await rm(root, { recursive: true, force: true });
});
it("materializes an image artifact and renders image + local Saved path", async () => {
const pngBytes = [...PNG_SIGNATURE, 0x42];
const result = await toMcpContent(
{ image: artifactHandle("img1", "shot.png", "image/png") },
"image",
{ toolsUrl: "http://remote:3001", deviceId: "DEV-1", fetchImpl: fetchReturning(pngBytes) }
);
expect(result[0]).toEqual({
type: "image",
data: Buffer.from(pngBytes).toString("base64"),
mimeType: "image/png",
});
expect(result[1]?.type).toBe("text");
expect((result[1] as { text: string }).text).toMatch(/^Saved: .*shot\.png$/);
});
it("rewrites non-image artifacts to local paths inside the JSON result", async () => {
const result = await toMcpContent(
{ exportedFiles: { cpu: artifactHandle("cpu1", "cpu.xml", "application/xml") } },
undefined,
{ toolsUrl: "http://remote:3001", fetchImpl: fetchReturning([1, 2, 3]) }
);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("text");
expect((result[0] as { text: string }).text).toContain("cpu.xml");
});
});
// ── flowRunToMcpContent ──────────────────────────────────────────────
describe("flowRunToMcpContent", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("produces header and footer text blocks", async () => {
const input: FlowExecuteResult = { flow: "test", steps: [] };
const blocks = await flowRunToMcpContent(input);
expect(blocks[0]).toEqual({
type: "text",
text: 'Running flow "test" (0 steps)',
});
expect(blocks[blocks.length - 1]).toEqual({
type: "text",
text: 'Flow "test" complete.',
});
});
it("renders echo steps as text", async () => {
const input: FlowExecuteResult = {
flow: "f",
steps: [{ kind: "echo", message: "Hello" }],
};
const blocks = await flowRunToMcpContent(input);
expect(blocks[1]).toEqual({ type: "text", text: "[1] Hello" });
});
it("renders legacy tool error steps (status-less)", async () => {
const input: FlowExecuteResult = {
flow: "f",
steps: [{ kind: "tool", tool: "gesture-tap", error: "connection lost" }],
};
const blocks = await flowRunToMcpContent(input);
expect(blocks[1]).toEqual({
type: "text",
text: "[1] gesture-tap — connection lost",
});
});
it("indents step labels by nesting depth, clamping hostile wire values", async () => {
const input: FlowExecuteResult = {
flow: "f",
steps: [
{ index: 0, kind: "when", status: "pass", target: 'visible "Promo"' },
{ index: 1, kind: "tap", status: "pass", target: '"Dismiss"', depth: 1 },
{ index: 2, kind: "echo", status: "pass", message: "deep note", depth: 2 },
// Wire data is untrusted: a negative depth must not throw and a huge
// one must not allocate a huge line.
{ index: 3, kind: "tap", status: "pass", target: '"A"', depth: -2 },
{ index: 4, kind: "tap", status: "pass", target: '"B"', depth: 1e9 },
// The cap clamps, it does not discard: legitimate depth can exceed it
// (the producer's run-chain and when-nesting limits accumulate), so a
// too-deep step keeps the maximum indent rather than snapping flat.
{ index: 5, kind: "tap", status: "pass", target: '"C"', depth: 20 },
{ index: 6, kind: "tap", status: "pass", target: '"D"', depth: 21 },
],
};
const blocks = await flowRunToMcpContent(input);
const texts = blocks
.filter((b): b is { type: "text"; text: string } => b.type === "text")
.map((b) => b.text);
expect(texts).toContain('[1] ✓ when visible "Promo"');
expect(texts).toContain('[2] ✓ tap "Dismiss"');
expect(texts).toContain("[3] ✓ deep note");
expect(texts).toContain('[4] ✓ tap "A"');
const cap = " ".repeat(20);
expect(texts).toContain(`[5] ✓ ${cap}tap "B"`);
expect(texts).toContain(`[6] ✓ ${cap}tap "C"`);
expect(texts).toContain(`[7] ✓ ${cap}tap "D"`);
});
it("shifts snapshot artifact lines with the step's depth, matching the CLI renderer", async () => {
const input: FlowExecuteResult = {
flow: "f",
steps: [
{
index: 0,
kind: "snapshot",
status: "fail",
reason: "diff 2.10% > 1%",
target: '"home"',
depth: 1,
artifacts: { baseline: "/tmp/b.png" },
},
],
};
const blocks = await flowRunToMcpContent(input);
const artifactText = blocks.find(
(b): b is { type: "text"; text: string } => b.type === "text" && b.text.includes("baseline:")
);
// Two-space prefix, then the step's indent — under the indented label,
// not left of it.
expect(artifactText?.text).toBe(" baseline: /tmp/b.png");
});
it("renders the new report shape: status glyphs, reasons, directive kinds, and summary", async () => {
const input: FlowExecuteResult = {
flow: "checkout",
device: "SIM",
ok: false,
passed: 2,
failed: 1,
errored: 0,
skipped: 1,
steps: [
{ index: 0, kind: "tap", status: "pass" },
{ index: 1, kind: "assert", status: "pass" },
{ index: 2, kind: "snapshot", status: "fail", reason: "diff 3.10% > 0.5% (home)" },
{ index: 3, kind: "echo", status: "skip", message: "done" },
],
};
const blocks = await flowRunToMcpContent(input);
const texts = blocks
.filter((b): b is { type: "text"; text: string } => b.type === "text")
.map((b) => b.text);
expect(texts[0]).toBe('Running flow "checkout" on SIM (4 steps)');
expect(texts[1]).toBe("[1] ✓ tap");
expect(texts[2]).toBe("[2] ✓ assert");
expect(texts[3]).toBe("[3] ✗ snapshot — diff 3.10% > 0.5% (home)");
expect(texts[4]).toBe("[4] · done");
expect(texts[texts.length - 1]).toBe("FAIL — 2 passed, 1 failed, 0 errored, 1 skipped");
// No invalid (text: undefined) blocks even though directive steps carry no result.
expect(blocks.every((b) => b.type !== "text" || typeof b.text === "string")).toBe(true);
});
it("surfaces a legacy passed step's warning on its status line (older tool-servers adopted missing baselines)", async () => {
const input: FlowExecuteResult = {
flow: "f",
steps: [
{
index: 0,
kind: "snapshot",
status: "pass",
reason: "baseline created (home__ios-390x844.png)",
warning: 'no baseline existed for "home" — nothing was compared',
},
],
};
const blocks = await flowRunToMcpContent(input);
expect(blocks[1]).toEqual({
type: "text",
text: '[1] ✓ snapshot — baseline created (home__ios-390x844.png) ⚠ no baseline existed for "home" — nothing was compared',
});
});
it("materializes only the diff and inlines it on failure", async () => {
const pngBytes = [...PNG_SIGNATURE, 0x02];
const fetchImpl = vi.fn(fetchReturning(pngBytes));
const input: FlowExecuteResult = {
flow: "checkout",
steps: [
{
index: 0,
kind: "snapshot",
status: "fail",
reason: "diff 3.10% > 0.5% (home)",
artifacts: {
baseline: artifactHandle("b1", "home-baseline.png", "image/png"),
current: artifactHandle("c1", "home-current.png", "image/png"),
diff: artifactHandle("d1", "home-diff.png", "image/png"),
},
},
],
};
const blocks = await flowRunToMcpContent(input, {
toolsUrl: "http://remote:3001",
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const artifactText = blocks.find(
(b): b is { type: "text"; text: string } => b.type === "text" && b.text.includes("baseline:")
);
expect(artifactText?.text).toContain("home-baseline.png");
expect(artifactText?.text).toContain("home-current.png");
expect(artifactText?.text).toMatch(/diff: .*home-diff\.png/);
// Exactly one inline image — the diff, not the full-res baseline/current.
const images = blocks.filter((b) => b.type === "image");
expect(images).toHaveLength(1);
expect(images[0]).toMatchObject({ data: Buffer.from(pngBytes).toString("base64") });
// And exactly one download: baseline/current are referenced by name only,
// never pulled over the wire just to print their paths.
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(String(fetchImpl.mock.calls[0]?.[0])).toContain("/artifacts/d1");
});
it("lists snapshot artifact paths without fetching anything when the step passed", async () => {
const fetchImpl = vi.fn(fetchReturning([...PNG_SIGNATURE, 0x03]));
const input: FlowExecuteResult = {
flow: "checkout",
steps: [
{
index: 0,
kind: "snapshot",
status: "pass",
reason: "diff 0.00% ≤ 0.5% (home)",
artifacts: {
baseline: artifactHandle("b1", "home-baseline.png", "image/png"),
current: artifactHandle("c1", "home-current.png", "image/png"),
},
},
],
};
const blocks = await flowRunToMcpContent(input, {
toolsUrl: "http://remote:3001",
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(blocks.find((b) => b.type === "image")).toBeUndefined();
const artifactText = blocks.find(
(b): b is { type: "text"; text: string } => b.type === "text" && b.text.includes("baseline:")
);
expect(artifactText?.text).toContain("home-baseline.png");
expect(fetchImpl).not.toHaveBeenCalled();
});
it("falls back to artifact host paths when no materialize context is given", async () => {
const input: FlowExecuteResult = {
flow: "checkout",
steps: [
{
index: 0,
kind: "snapshot",
status: "fail",
reason: "diff 3.10% > 0.5% (home)",
artifacts: {
baseline: {
...artifactHandle("b1", "base.png", "image/png"),
hostPath: "/srv/base.png",
},
diff: { ...artifactHandle("d1", "diff.png", "image/png"), hostPath: "/srv/diff.png" },
},
},
],
};
const blocks = await flowRunToMcpContent(input);
expect(blocks.find((b) => b.type === "image")).toBeUndefined();
const artifactText = blocks.find(
(b): b is { type: "text"; text: string } => b.type === "text" && b.text.includes("baseline:")
);
expect(artifactText?.text).toContain("baseline: /srv/base.png");
expect(artifactText?.text).toContain("diff: /srv/diff.png");
});
it("renders legacy string[] artifacts as plain path lines", async () => {
const input: FlowExecuteResult = {
flow: "checkout",
steps: [
{
index: 0,
kind: "snapshot",
status: "fail",
reason: "diff 3.10% > 0.5% (home)",
artifacts: ["/srv/baseline.png", "/srv/current.png"] as unknown as Record<
string,
unknown
>,
},
],
};
const blocks = await flowRunToMcpContent(input);
const artifactText = blocks.find(
(b): b is { type: "text"; text: string } =>
b.type === "text" && b.text.includes("/srv/baseline.png")
);
expect(artifactText).toBeDefined();
expect(blocks.find((b) => b.type === "image")).toBeUndefined();
});
it("renders tool success as JSON text", async () => {
const input: FlowExecuteResult = {
flow: "f",
steps: [{ kind: "tool", tool: "gesture-tap", result: { ok: true } }],
};
const blocks = await flowRunToMcpContent(input);
// [0] header, [1] tool name, [2] JSON result, [3] footer
expect(blocks[1]).toEqual({ type: "text", text: "[1] gesture-tap" });
expect(blocks[2]).toEqual({
type: "text",
text: JSON.stringify({ ok: true }, null, 2),
});
});
it("renders image tool results as image blocks", async () => {
const pngBytes = [...PNG_SIGNATURE, 0x01];
vi.stubGlobal("fetch", mockOk(pngBytes));
const input: FlowExecuteResult = {
flow: "f",
steps: [
{
kind: "tool",
tool: "screenshot",
result: { url: "http://localhost/img.png", path: "/tmp/s.png" },
outputHint: "image",
},
],
};
const blocks = await flowRunToMcpContent(input);
// [0] header, [1] "screenshot", [2] image, [3] "Saved: ...", [4] footer
expect(blocks[1]).toEqual({ type: "text", text: "[1] screenshot" });
expect(blocks[2]).toEqual({
type: "image",
data: Buffer.from(pngBytes).toString("base64"),
mimeType: "image/png",
});
expect(blocks[3]).toEqual({ type: "text", text: "Saved: /tmp/s.png" });
vi.unstubAllGlobals();
});
it("renders a text placeholder when an image step's fetch fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: false, arrayBuffer: async () => new ArrayBuffer(0) })
);
const blocks = await flowRunToMcpContent({
flow: "f",
steps: [
{
kind: "tool",
tool: "screenshot",
result: { url: "http://x/gone.png", path: "/tmp/s.png" },
outputHint: "image",
},
],
});
expect(blocks[1]).toEqual({ type: "text", text: "[1] screenshot" });
expect(blocks[2]?.type).toBe("text");
expect(blocks.find((b) => b.type === "image")).toBeUndefined();
vi.unstubAllGlobals();
});
it("suppresses image attach when step.args.includeImageInContext is false", async () => {
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
const input: FlowExecuteResult = {
flow: "f",
steps: [
{
kind: "tool",
tool: "screenshot",
result: { url: "http://localhost/img.png", path: "/tmp/s.png" },
outputHint: "image",
args: { udid: "ABC", includeImageInContext: false, scale: 1.0 },
},
],
};
const blocks = await flowRunToMcpContent(input);
expect(mockFetch).not.toHaveBeenCalled();
// [0] header, [1] "screenshot", [2] "Saved: ...", [3] footer
expect(blocks[1]).toEqual({ type: "text", text: "[1] screenshot" });
expect(blocks[2]).toEqual({ type: "text", text: "Saved: /tmp/s.png" });
vi.unstubAllGlobals();
});
it("handles mixed steps in order", async () => {
const input: FlowExecuteResult = {
flow: "mixed",
steps: [
{ kind: "echo", message: "Start" },
{ kind: "tool", tool: "gesture-tap", result: { x: 1 } },
{ kind: "echo", message: "End" },
],
};
const blocks = await flowRunToMcpContent(input);
const texts = blocks
.filter((b): b is { type: "text"; text: string } => b.type === "text")
.map((b) => b.text);
expect(texts[0]).toContain("Running flow");
expect(texts[1]).toBe("[1] Start");
expect(texts[2]).toBe("[2] gesture-tap");
// [3] is JSON result
expect(texts[4]).toBe("[3] End");
expect(texts[5]).toContain("complete");
});
it("numbers steps sequentially", async () => {
const input: FlowExecuteResult = {
flow: "num",
steps: [
{ kind: "echo", message: "A" },
{ kind: "echo", message: "B" },
{ kind: "echo", message: "C" },
],
};
const blocks = await flowRunToMcpContent(input);
expect(blocks[1]).toEqual({ type: "text", text: "[1] A" });
expect(blocks[2]).toEqual({ type: "text", text: "[2] B" });
expect(blocks[3]).toEqual({ type: "text", text: "[3] C" });
});
});