Files
software-mansion__argent/packages/tool-server/test/flows/flow-path-baseline-escape.test.ts
Jakub Piasecki cc2f907ce9 feat(flows): resolve run: directives from explicit YAML paths (#567)
A `run:` value is now a YAML path resolved against the directory of the
flow file that contains the step, matching the CLI cutover:

```yaml
- run: login.yaml
- run: ../shared/login.yaml   # cross-directory composition now works
```

Bare names fail at parse with a migration hint (`run: login` → `run:
login.yaml`). The filename stem stays the fragment's report attribution;
reports also carry the as-written path as `target`. Cycle detection
compares realpath-canonical paths (same stem in two dirs ≠ cycle; same
file via symlink/spelling = cycle). Snapshot baselines are unchanged —
keyed by and stored beside the root flow.

An uploaded root flow with `run:` steps is now rejected up front with a
contract error instead of a mid-run temp-dir ENOENT (its referenced
files stayed on the client, and relative resolution in the temp dir
could reach host files); same for a relative chromium app path. The
recorder emits the sibling form (`run: <name>.yaml`).

**Breaking:** existing `run: <name>` directives fail at parse until
renamed to `run: <name>.yaml`. Snapshot baselines for a saved flow whose
YAML is a symlink now anchor beside the real file (the symlink target's
directory) — move any committed `.argent/flows/__baselines__/<flow>/`
set beside the real file once, or the next run fails with a missing
baseline. A `run:` target must also resolve inside the project root or
beside/below the flow file that references it (arbitrary host YAML is no
longer reachable), and an uploaded flow with a `snapshot` step is
rejected up front (its baselines cannot anchor to the per-call temp
dir).
2026-08-05 14:51:42 +02:00

330 lines
13 KiB
TypeScript

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 { Registry, ToolContext } from "@argent/registry";
import { ArtifactStore } from "@argent/registry";
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
/**
* The flow_path branch derives its logical flow name from the YAML basename,
* and that name is the only thing keeping one flow's baselines out of another
* directory: flow-visual joins it as `<flowsDir>/__baselines__/<flowName>`.
* A dotted basename clears the extension arm — path.extname("...yaml") really
* is ".yaml" — and then path.basename strips the extension down to a relative
* segment (".." or "."), which path.join collapses back out of the joined
* path. assertSafeFlowName is the whole of what stops that here: unlike the
* `name` branch, where getFlowPath re-runs the same assert, nothing downstream
* re-checks the derived name. So these run end-to-end with updateBaselines and
* pin the write the assert prevents, not the message it prints.
*/
// Stub only the settle: the run has to reach the REAL runSnapshot and its REAL
// baseline copy — that copy is the consequence under test — but the mock
// registry below serves no describe tree, so an unstubbed settle would poll to
// its own deadline before the capture.
vi.mock("../../src/tools/flows/flow-actions", async (importOriginal) => ({
...(await importOriginal<typeof import("../../src/tools/flows/flow-actions")>()),
settleTree: vi.fn(async () => ({})),
}));
const IOS_DEVICE = "00000000-0000-0000-0000-0000000000ab";
let flowDir: string;
let captureDir: string;
let capture: string;
beforeEach(async () => {
flowDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-path-baseline-"));
// The capture lives outside flowDir so the directory assertion below sees
// only what the run itself put next to the flow.
captureDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-path-capture-"));
capture = path.join(captureDir, "capture.png");
// runSnapshot reads only the IHDR width/height bytes off the capture.
const ihdr = Buffer.alloc(24);
ihdr.writeUInt32BE(390, 16);
ihdr.writeUInt32BE(844, 20);
await fs.writeFile(capture, ihdr);
});
afterEach(async () => {
await fs.rm(flowDir, { recursive: true, force: true });
await fs.rm(captureDir, { recursive: true, force: true });
});
function mockRegistry(): Registry {
return {
invokeTool: vi.fn(async (id: string) => {
if (id === "list-devices") return { devices: [] };
if (id === "screenshot") return { image: { hostPath: capture } };
return { ok: true };
}),
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
} as unknown as Registry;
}
/** The ctx the flow_path boundary produces for a co-located, stat-matched path. */
function boundaryCtx(flowPath: string): ToolContext {
return {
artifacts: new ArtifactStore(),
fileInputs: {
flow_path: {
clientPath: flowPath,
presentOnHost: true,
viaUpload: false,
statVerified: true,
},
},
};
}
describe("flow_path stems that would collapse the baseline directory", () => {
it.each([
// path.join(flowsDir, "__baselines__", "..") IS flowsDir, so an adopted
// baseline lands beside the flow file itself rather than under a
// per-flow subdirectory — the escape.
["..", "...yaml"],
// One level less, but still not a directory of its own: every such flow's
// baselines would pile into the shared __baselines__ root.
[".", "..yaml"],
])('refuses the "%s" stem a "%s" flow_path derives', async (stem, basename) => {
const flowPath = path.join(flowDir, basename);
await fs.writeFile(
flowPath,
["executionPrerequisite: ''", "steps:", " - snapshot: shot", ""].join("\n"),
"utf8"
);
const runFlow = createRunFlowTool(mockRegistry());
const failure = await runFlow
.execute(
{},
{
project_root: flowDir,
flow_path: flowPath,
device: IOS_DEVICE,
updateBaselines: true,
},
boundaryCtx(flowPath)
)
.then(
() => null,
(err: unknown) => err as Error
);
// Asserted before the message: updateBaselines adopts the capture by
// copying it into the joined baseline dir, so a run that got this far
// leaves the PNG in the collapsed location. Nothing may have been written.
expect((await fs.readdir(flowDir, { recursive: true })).sort()).toEqual([basename]);
expect(failure?.message).toContain(`Invalid flow name "${stem}"`);
});
});
function asRun(result: FlowRunResult | { notice: string }): FlowRunResult {
if (!("steps" in result)) throw new Error(`expected a run result, got notice: ${result.notice}`);
return result;
}
/**
* The happy-path complement: a valid stem has to ARRIVE, not just an invalid
* one be refused. The derived name is threaded three separate ways —
* state.baselineKey into runSnapshot's baseline directory (for a real file the
* canonical stem it derives from IS this one), the step scope's
* flow into every report, and runStack[0] into run: cycle detection — and only
* a flow_path run can tell any of them apart from params.name (undefined
* here), so each thread is pinned end-to-end through the same boundary the
* escapes use.
*/
describe("the stem a valid flow_path derives", () => {
it("keys the adopted baseline under __baselines__/<stem> and attributes the report to it", async () => {
const flowPath = path.join(flowDir, "checkout.yaml");
await fs.writeFile(
flowPath,
["executionPrerequisite: ''", "steps:", " - snapshot: shot", ""].join("\n"),
"utf8"
);
const runFlow = createRunFlowTool(mockRegistry());
const result = asRun(
await runFlow.execute(
{},
{
project_root: flowDir,
flow_path: flowPath,
device: IOS_DEVICE,
updateBaselines: true,
},
boundaryCtx(flowPath)
)
);
// The stem lands on both report levels: summarize's top `flow`, and —
// seeded separately, through the step scope — each step's own.
expect(result.ok).toBe(true);
expect(result.flow).toBe("checkout");
expect(result.steps).toEqual([
expect.objectContaining({
kind: "snapshot",
status: "pass",
flow: "checkout",
reason: "baseline written (shot__ios-390x844.png)",
}),
]);
// The write landed at exactly <dirname(flow_path)>/__baselines__/<stem>/
// <name>__<platform>-WxH.png, byte-for-byte the capture; the full listing
// proves nothing landed anywhere else beside the flow.
const baseline = path.join(flowDir, "__baselines__", "checkout", "shot__ios-390x844.png");
expect(await fs.readFile(baseline)).toEqual(await fs.readFile(capture));
expect((await fs.readdir(flowDir, { recursive: true })).sort()).toEqual([
"__baselines__",
path.join("__baselines__", "checkout"),
path.join("__baselines__", "checkout", "shot__ios-390x844.png"),
"checkout.yaml",
]);
});
it("seeds run: cycle detection, so a sibling cycling back to the top flow is caught", async () => {
const flowPath = path.join(flowDir, "checkout.yaml");
await fs.writeFile(
flowPath,
["executionPrerequisite: ''", "steps:", " - run: helper.yaml", ""].join("\n"),
"utf8"
);
await fs.writeFile(
path.join(flowDir, "helper.yaml"),
["executionPrerequisite: ''", "steps:", " - run: checkout.yaml", ""].join("\n"),
"utf8"
);
const runFlow = createRunFlowTool(mockRegistry());
const result = asRun(
await runFlow.execute(
{},
{ project_root: flowDir, flow_path: flowPath, device: IOS_DEVICE },
boundaryCtx(flowPath)
)
);
// Exactly one hop: helper's reference back to "checkout" is already on the
// seeded stack. A stack seeded with anything but the stem would instead
// re-enter checkout.yaml as a fragment and only trip a hop later, on
// "helper" — so the step sequence and the cycle spelling are both
// load-bearing here. The offending target shares the root's stem, so it
// renders as "./checkout" (runDisplayName's same-stem disambiguation).
expect(result.ok).toBe(false);
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["run:pass", "run:error"]);
expect(result.steps[1].reason).toBe("cyclic flow reference: checkout → helper → ./checkout");
});
});
/**
* The baseline key follows the CANONICAL root flow (`baselineKeyFor`), because
* the directory it joins onto — flowsDir — is canonical: two projects
* symlinking their own `smoke.yaml` into one shared vault would otherwise both
* key `vault/__baselines__/smoke/` and overwrite each other's committed PNGs.
* That canonical basename is the symlink TARGET's, and it is validated by
* nothing: assertSafeFlowName and classifyOnDiskSpelling above only ever saw
* the as-written spelling, so a link to a file named "...yaml" re-opens exactly
* the collapse this file exists for — `path.join(flowsDir, "__baselines__",
* "..")` IS flowsDir — with no upstream arm left to refuse it. Like the tests
* above, these run end-to-end with updateBaselines and assert the file the
* adoption copy actually left on disk, not a message.
*/
describe("the baseline key a symlinked flow_path derives from its target", () => {
/**
* Run `flowDir/safe.yaml` — a symlink to `flowDir/vault/<targetBase>`, the
* one-project spelling of the vault layout. The as-written name is always
* "safe" and always valid, so the target's name is the only variable.
*/
async function runVaultedFlow(targetBase: string): Promise<FlowRunResult> {
const vault = path.join(flowDir, "vault");
await fs.mkdir(vault);
await fs.writeFile(
path.join(vault, targetBase),
["executionPrerequisite: ''", "steps:", " - snapshot: shot", ""].join("\n"),
"utf8"
);
const flowPath = path.join(flowDir, "safe.yaml");
await fs.symlink(path.join(vault, targetBase), flowPath);
const runFlow = createRunFlowTool(mockRegistry());
return asRun(
await runFlow.execute(
{},
{
project_root: flowDir,
flow_path: flowPath,
device: IOS_DEVICE,
updateBaselines: true,
},
boundaryCtx(flowPath)
)
);
}
/** Every file the run left anywhere under the flow directory. */
async function tree(): Promise<string[]> {
return (await fs.readdir(flowDir, { recursive: true })).sort();
}
it("keys the store by the target's stem, so vault-sharing links can't share one", async () => {
// The fix itself, on the write path: the adopted PNG lands under the REAL
// file's name. A second project whose own smoke.yaml points at a different
// vault file therefore writes a different directory, instead of replacing
// this baseline in place.
const result = await runVaultedFlow("real-name.yaml");
expect(result.ok).toBe(true);
expect(result.steps[0]).toMatchObject({
status: "pass",
reason: "baseline written (shot__ios-390x844.png)",
});
// The report keeps the as-written identity; only the store moved.
expect(result.flow).toBe("safe");
expect(await tree()).toEqual(
[
"safe.yaml",
"vault",
path.join("vault", "__baselines__"),
path.join("vault", "__baselines__", "real-name"),
path.join("vault", "__baselines__", "real-name", "shot__ios-390x844.png"),
path.join("vault", "real-name.yaml"),
].sort()
);
});
it.each([
// The escape: stem "..", which path.join collapses back out — every
// baseline would land in the vault itself, beside the flow files.
["...yaml", ".."],
// Not an escape, but not a flow name either: a dotted vault filename is
// perfectly legal on disk and must not become a path segment unchecked.
["my.smoke.yaml", "my.smoke"],
])('falls back to the as-written key when the target "%s" stems to "%s"', async (targetBase) => {
const result = await runVaultedFlow(targetBase);
// A run must not FAIL over an unusually named vault file — the fallback
// key is the as-written one, which every source validated.
expect(result.ok).toBe(true);
expect(result.steps[0]).toMatchObject({
status: "pass",
reason: "baseline written (shot__ios-390x844.png)",
});
// The whole tree, so the assertion catches a write anywhere: under
// __baselines__/safe/ and nowhere else — in particular nothing beside the
// real file in vault/, which is where the collapsed ".." key would land it.
expect(await tree()).toEqual(
[
"safe.yaml",
"vault",
path.join("vault", "__baselines__"),
path.join("vault", "__baselines__", "safe"),
path.join("vault", "__baselines__", "safe", "shot__ios-390x844.png"),
path.join("vault", targetBase),
].sort()
);
});
});