Files
software-mansion__argent/packages/argent-cli/test/flow-output.test.ts
Jakub Piasecki a644d52d39 feat(flows): run CLI flows from explicit paths (#568)
`argent flow run` now takes an explicit YAML path instead of a
saved-flow name:

```
argent flow run .argent/flows/checkout.yaml --platform ios
```

The path may point anywhere on the local filesystem; the filename stem
names the run's report/artifacts, so it must be `[A-Za-z0-9_-]+` with a
lowercase `.yaml` extension. A bare name fails with the exact
replacement command, and `argent flow list` prints paths instead of
names.

Because sibling `run:` files and `__baselines__` resolve beside the
YAML, the CLI now requires the auto-started local tool server and
refuses `ARGENT_TOOLS_URL` / `argent link` routing. The `flow-execute`
tool stays remotely callable via `name` + `project_root`, and gains a
`flow_path` parameter (exactly one of `name`/`flow_path`) that is only
honored when the file-input boundary verified it as a real host path —
uploads and boundary bypasses are rejected, so it can't execute
arbitrary server YAML.

Also: the flow-name pattern is now a single shared export from
`@argent/registry`, and the recorder composes a `flow_path` call into a
`run:` step when it targets a sibling of the recording.
2026-08-05 14:51:42 +02:00

1175 lines
49 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createHash } from "node:crypto";
import * as fs from "node:fs/promises";
import { readFileSync, writeFileSync } from "node:fs";
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";
// Spies over the real implementations, not stubs: every test here runs against
// a real temp filesystem. Only the three marker-window tests override anything,
// and only the marker's own read or exclusive create — reproducing the
// one-syscall windows around it: an O_EXCL create publishing the marker's path
// before its bytes land, and that same create losing to a marker that is gone
// again by the time the loser reads it. Racing real calls cannot schedule
// either deterministically. Node builtins can't be spied on in place (their
// ESM namespace is frozen), so the module is mocked.
vi.mock("node:fs/promises", { spy: true });
/**
* Whether mode bits actually deny this process a write. `chmod 0555` means
* nothing to root (some CI containers run as root) and nothing on Windows, and
* there the refused-claim fixtures would be writable — the assertions would
* pass for the wrong reason, so skip them instead.
*/
const canDenyWrite = process.platform !== "win32" && process.getuid?.() !== 0;
let tmpDir: string;
let outDir: string;
/** The CLI-resolved YAML path the export derives its subdirectory from. */
let flowFile: string;
/** The deterministic disambiguation suffix for a flow path — mirrors the export's derivation. */
function pathHash(p: string, len = 8): string {
return createHash("sha256").update(p).digest("hex").slice(0, len);
}
// 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");
// Only the path string matters to the export — the YAML is never read here.
flowFile = path.join(tmpDir, "checkout.yaml");
});
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, flowFile, 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, flowFile, 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, flowFile, 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, flowFile, 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, flowFile, {
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, flowFile, {
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 path whose stem is 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"));
// The CLI validates the stem before ever calling the export, but the
// function must stay safe in isolation: a path ending in "/.." has the
// basename "..", which would resolve the subdirectory to outDir's parent.
await exportFailureArtifacts(mkReport([step]), outDir, `${tmpDir}${path.sep}..`, {
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 filename"));
await expect(fs.access(outDir)).rejects.toThrow();
} finally {
errSpy.mockRestore();
}
});
it("ignores the wire report's flow field when picking the destination", async () => {
// The export dir comes from the CLI-resolved path only — a server-sent
// report.flow (even a hostile one) must not steer the copy anywhere else.
const current = await writeFile("c.png", "current-bytes");
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current },
};
await exportFailureArtifacts({ ...mkReport([step]), flow: "../escape" }, outDir, flowFile, ctx);
expect(step.artifacts?.current).toBe(
path.join(outDir, "checkout", "home__ios-390x844-current.png")
);
await expect(fs.access(path.join(tmpDir, "escape"))).rejects.toThrow();
});
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, flowFile, 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, flowFile, 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, flowFile, ctx);
expect(step.artifacts?.diff).toBe(gone);
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("warning: could not write"));
} finally {
errSpy.mockRestore();
}
});
it("keeps both flows' evidence when two different files share a filename stem", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// "Paths anywhere" makes suiteA/checks.yaml + suiteB/checks.yaml the
// natural CI layout, both exporting into one --output dir.
const flowA = path.join(tmpDir, "suiteA", "checks.yaml");
const flowB = path.join(tmpDir, "suiteB", "checks.yaml");
const stepA: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "shot__ios-390x844",
artifacts: { current: await writeFile("a.png", "suiteA-bytes") },
};
const stepB: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "shot__ios-390x844",
artifacts: { current: await writeFile("b.png", "suiteB-bytes") },
};
await exportFailureArtifacts(mkReport([stepA]), outDir, flowA, ctx);
await exportFailureArtifacts(mkReport([stepB]), outDir, flowB, ctx);
// The first file keeps the documented <output>/<stem>/ path…
const aDest = path.join(outDir, "checks", "shot__ios-390x844-current.png");
expect(stepA.artifacts?.current).toBe(aDest);
expect(await fs.readFile(aDest, "utf8")).toBe("suiteA-bytes");
// …and the second lands in the deterministic hash-suffixed sibling, so
// neither run's evidence replaced the other's — with a warning saying so.
const bDest = path.join(outDir, `checks-${pathHash(flowB)}`, "shot__ios-390x844-current.png");
expect(stepB.artifacts?.current).toBe(bDest);
expect(await fs.readFile(bDest, "utf8")).toBe("suiteB-bytes");
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining(`checks-${pathHash(flowB)}`));
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining(flowA));
} finally {
errSpy.mockRestore();
}
});
it("re-exports the same flow file in place: same paths, fresh bytes, no extra directories", async () => {
const mkStep = async (content: string): Promise<StepReport> => ({
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile("c.png", content) },
});
const dest = path.join(outDir, "checkout", "home__ios-390x844-current.png");
// A CI re-run of one flow into a reused --output dir must keep the stable
// documented path and overwrite, never accumulate hash-suffixed siblings.
const first = await mkStep("run1-bytes");
await exportFailureArtifacts(mkReport([first]), outDir, flowFile, ctx);
expect(first.artifacts?.current).toBe(dest);
const second = await mkStep("run2-bytes");
await exportFailureArtifacts(mkReport([second]), outDir, flowFile, ctx);
expect(second.artifacts?.current).toBe(dest);
expect(await fs.readFile(dest, "utf8")).toBe("run2-bytes");
expect(await fs.readdir(outDir)).toEqual(["checkout"]);
// The claim marker names the producing YAML so uploaded artifacts stay
// auditable and the next run can recognize its own directory.
expect(await fs.readFile(path.join(outDir, "checkout", ".argent-flow-source"), "utf8")).toBe(
`${flowFile}\n`
);
});
it("redirects away from a same-named directory it cannot prove it owns", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// No marker → operator files or a pre-marker export; either way the
// export cannot tell it is its own, so it must not overwrite into it.
await fs.mkdir(path.join(outDir, "checkout"), { recursive: true });
await fs.writeFile(path.join(outDir, "checkout", "keep.txt"), "operator data");
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile("c.png", "current-bytes") },
};
await exportFailureArtifacts(mkReport([step]), outDir, flowFile, ctx);
expect(await fs.readFile(path.join(outDir, "checkout", "keep.txt"), "utf8")).toBe(
"operator data"
);
expect(step.artifacts?.current).toBe(
path.join(outDir, `checkout-${pathHash(flowFile)}`, "home__ios-390x844-current.png")
);
// "files", not "artifacts" — the export can't know what a markerless
// directory's contents are, only that they exist.
expect(errSpy).toHaveBeenCalledWith(
expect.stringContaining("already holds files from an unknown source")
);
} finally {
errSpy.mockRestore();
}
});
it("escalates past an occupied redirect target instead of overwriting its evidence", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// The reviewer's collision: <stem>/ is taken by suiteA, and the hash8
// redirect target is ALREADY claimed by another flow (in the wild, one
// whose stem is literally "checks-<hash8>"). Reproduced deterministically
// by pre-creating <stem>-<hash8> — computed from the real flow path the
// same way the source does — with a marker naming that other owner.
const flowA = path.join(tmpDir, "suiteA", "checks.yaml");
const flowB = path.join(tmpDir, "suiteB", "checks.yaml");
const h8 = pathHash(flowB);
const suiteD = path.join(tmpDir, "suiteD", `checks-${h8}.yaml`);
await fs.mkdir(path.join(outDir, "checks"), { recursive: true });
await fs.writeFile(path.join(outDir, "checks", ".argent-flow-source"), `${flowA}\n`);
const occupied = path.join(outDir, `checks-${h8}`);
await fs.mkdir(occupied, { recursive: true });
await fs.writeFile(path.join(occupied, ".argent-flow-source"), `${suiteD}\n`);
await fs.writeFile(path.join(occupied, "shot__ios-390x844-current.png"), "suiteD-bytes");
const stepB: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "shot__ios-390x844",
artifacts: { current: await writeFile("b.png", "suiteB-bytes") },
};
await exportFailureArtifacts(mkReport([stepB]), outDir, flowB, ctx);
// suiteD's evidence survives byte-for-byte, marker included.
expect(await fs.readFile(path.join(occupied, ".argent-flow-source"), "utf8")).toBe(
`${suiteD}\n`
);
expect(await fs.readFile(path.join(occupied, "shot__ios-390x844-current.png"), "utf8")).toBe(
"suiteD-bytes"
);
// suiteB lands one rung up the deterministic ladder: the 16-char prefix.
const dest = path.join(outDir, `checks-${pathHash(flowB, 16)}`);
expect(stepB.artifacts?.current).toBe(path.join(dest, "shot__ios-390x844-current.png"));
expect(await fs.readFile(stepB.artifacts?.current as string, "utf8")).toBe("suiteB-bytes");
// One warning per avoided directory, each naming its owner — and every
// warning names the directory the artifacts actually land in.
expect(errSpy).toHaveBeenCalledTimes(2);
expect(errSpy).toHaveBeenCalledWith(
expect.stringContaining(
`${path.join(outDir, "checks")} already holds artifacts from ${flowA}`
)
);
expect(errSpy).toHaveBeenCalledWith(
expect.stringContaining(`${occupied} already holds artifacts from ${suiteD}`)
);
for (const call of errSpy.mock.calls) {
expect(call[0]).toContain(`writing this flow's artifacts to ${dest}`);
}
} finally {
errSpy.mockRestore();
}
});
it("escalates past a markerless non-empty redirect target the same as a markerless stem", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// Both the stem and the hash8 fallback hold unowned files — the export
// can prove neither is its own, so it must step past both.
await fs.mkdir(path.join(outDir, "checkout"), { recursive: true });
await fs.writeFile(path.join(outDir, "checkout", "keep.txt"), "operator data");
const occupied = path.join(outDir, `checkout-${pathHash(flowFile)}`);
await fs.mkdir(occupied, { recursive: true });
await fs.writeFile(path.join(occupied, "keep.txt"), "more operator data");
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile("c.png", "current-bytes") },
};
await exportFailureArtifacts(mkReport([step]), outDir, flowFile, ctx);
expect(await fs.readFile(path.join(occupied, "keep.txt"), "utf8")).toBe("more operator data");
expect(step.artifacts?.current).toBe(
path.join(outDir, `checkout-${pathHash(flowFile, 16)}`, "home__ios-390x844-current.png")
);
expect(errSpy).toHaveBeenCalledWith(
expect.stringContaining(`${occupied} already holds files from an unknown source`)
);
} finally {
errSpy.mockRestore();
}
});
it("claims a pre-created empty redirect target, consistent with the stem-dir rule", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await fs.mkdir(path.join(outDir, "checkout"), { recursive: true });
await fs.writeFile(path.join(outDir, "checkout", "keep.txt"), "operator data");
const target = path.join(outDir, `checkout-${pathHash(flowFile)}`);
await fs.mkdir(target, { recursive: true }); // empty — nothing to protect
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile("c.png", "current-bytes") },
};
await exportFailureArtifacts(mkReport([step]), outDir, flowFile, ctx);
const dest = path.join(target, "home__ios-390x844-current.png");
expect(step.artifacts?.current).toBe(dest);
expect(await fs.readFile(dest, "utf8")).toBe("current-bytes");
// Claimed with the usual marker — no escalation to a longer prefix.
expect(await fs.readFile(path.join(target, ".argent-flow-source"), "utf8")).toBe(
`${flowFile}\n`
);
expect((await fs.readdir(outDir)).sort()).toEqual([
"checkout",
`checkout-${pathHash(flowFile)}`,
]);
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining(target));
} finally {
errSpy.mockRestore();
}
});
it("re-runs a redirected flow into its established hash directory, overwriting in place", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// The stem stays foreign across runs; the flow's own marker in the
// hash8 directory is what lets a later invocation overwrite in place —
// the stable redirect name CI references depend on.
await fs.mkdir(path.join(outDir, "checkout"), { recursive: true });
await fs.writeFile(path.join(outDir, "checkout", "keep.txt"), "operator data");
const mkStep = async (content: string): Promise<StepReport> => ({
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile("c.png", content) },
});
const dest = path.join(
outDir,
`checkout-${pathHash(flowFile)}`,
"home__ios-390x844-current.png"
);
const first = await mkStep("run1-bytes");
await exportFailureArtifacts(mkReport([first]), outDir, flowFile, ctx);
expect(first.artifacts?.current).toBe(dest);
const second = await mkStep("run2-bytes");
await exportFailureArtifacts(mkReport([second]), outDir, flowFile, ctx);
expect(second.artifacts?.current).toBe(dest);
expect(await fs.readFile(dest, "utf8")).toBe("run2-bytes");
// No longer-prefix siblings accumulate across re-runs.
expect((await fs.readdir(outDir)).sort()).toEqual([
"checkout",
`checkout-${pathHash(flowFile)}`,
]);
} finally {
errSpy.mockRestore();
}
});
it("skips the export entirely when every candidate directory is foreign", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// Squat on the stem and the whole prefix ladder, full hash included —
// only then may the export give up, and it must warn instead of write.
const fullHash = pathHash(flowFile, 64);
const names = ["checkout"];
for (let len = 8; len <= 64; len += 8) names.push(`checkout-${fullHash.slice(0, len)}`);
for (const name of names) {
await fs.mkdir(path.join(outDir, name), { recursive: true });
await fs.writeFile(path.join(outDir, name, "keep.txt"), `squatting ${name}`);
}
const source = await writeFile("c.png", "current-bytes");
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: source },
};
await exportFailureArtifacts(mkReport([step]), outDir, flowFile, ctx);
// Source path left in place, nothing created, every squatter untouched.
expect(step.artifacts?.current).toBe(source);
expect((await fs.readdir(outDir)).sort()).toEqual([...names].sort());
for (const name of names) {
expect(await fs.readFile(path.join(outDir, name, "keep.txt"), "utf8")).toBe(
`squatting ${name}`
);
}
expect(errSpy).toHaveBeenCalledWith(
expect.stringContaining(`not exporting artifacts for ${flowFile}`)
);
} finally {
errSpy.mockRestore();
}
});
it("claims a pre-created empty directory: documented path, marker written, no warning", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// `mkdir -p out/checkout` before the run is an ordinary CI step — an
// empty directory holds nothing to protect, so the export must keep the
// stable <output>/<flow>/ path instead of redirecting forever.
await fs.mkdir(path.join(outDir, "checkout"), { recursive: true });
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile("c.png", "current-bytes") },
};
await exportFailureArtifacts(mkReport([step]), outDir, flowFile, ctx);
const dest = path.join(outDir, "checkout", "home__ios-390x844-current.png");
expect(step.artifacts?.current).toBe(dest);
expect(await fs.readFile(dest, "utf8")).toBe("current-bytes");
expect(await fs.readdir(outDir)).toEqual(["checkout"]); // no hash-suffixed sibling
// The claim is completed with the usual marker, so the next run of this
// same file recognizes the directory and overwrites in place.
expect(await fs.readFile(path.join(outDir, "checkout", ".argent-flow-source"), "utf8")).toBe(
`${flowFile}\n`
);
expect(errSpy).not.toHaveBeenCalled();
} finally {
errSpy.mockRestore();
}
});
it("leaves --output untouched when a colliding flow has nothing to export", async () => {
// A clean pass must neither warn about a collision nor create directories
// or markers — the no-write invariant holds even when the stem is taken.
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const owned: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile("c.png", "current-bytes") },
};
await exportFailureArtifacts(mkReport([owned]), outDir, flowFile, ctx);
errSpy.mockClear();
const passing: StepReport = { index: 0, kind: "snapshot", status: "pass" };
await exportFailureArtifacts(
mkReport([passing]),
outDir,
path.join(tmpDir, "other", "checkout.yaml"),
ctx
);
expect(errSpy).not.toHaveBeenCalled();
expect(await fs.readdir(outDir)).toEqual(["checkout"]);
} finally {
errSpy.mockRestore();
}
});
it("gives two concurrent runs of different files sharing a stem their own directories", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// The CI shape the marker exists for: two suites' checks.yaml exporting
// into one --output at the same time. Both classify <output>/checks
// before either has written anything into it, so nothing but the marker's
// exclusive create can separate them — a classify-then-claim protocol
// lets both write there, leaving a directory whose marker names one flow
// while holding the other's bytes.
const flowA = path.join(tmpDir, "suiteA", "checks.yaml");
const flowB = path.join(tmpDir, "suiteB", "checks.yaml");
const stepA: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "shot__ios-390x844",
artifacts: { current: await writeFile("a.png", "suiteA-bytes") },
};
const stepB: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "shot__ios-390x844",
artifacts: { current: await writeFile("b.png", "suiteB-bytes") },
};
await Promise.all([
exportFailureArtifacts(mkReport([stepA]), outDir, flowA, ctx),
exportFailureArtifacts(mkReport([stepB]), outDir, flowB, ctx),
]);
// Two directories, never one — whichever run won <output>/checks, the
// other took its own deterministic hash rung.
expect(await fs.readdir(outDir)).toHaveLength(2);
for (const [flow, step, bytes] of [
[flowA, stepA, "suiteA-bytes"],
[flowB, stepB, "suiteB-bytes"],
] as const) {
const dest = step.artifacts?.current as string;
const dir = path.dirname(dest);
expect([
path.join(outDir, "checks"),
path.join(outDir, `checks-${pathHash(flow)}`),
]).toContain(dir);
// The marker names the flow whose bytes are actually in the directory:
// the property a lost race silently breaks, and the one a later run of
// the named flow then relies on to overwrite in place.
expect(await fs.readFile(path.join(dir, ".argent-flow-source"), "utf8")).toBe(`${flow}\n`);
expect(await fs.readFile(dest, "utf8")).toBe(bytes);
// Marker + this run's one PNG, so nothing of the other run leaked in.
expect((await fs.readdir(dir)).sort()).toEqual([
".argent-flow-source",
"shot__ios-390x844-current.png",
]);
}
expect(path.dirname(stepA.artifacts?.current as string)).not.toBe(
path.dirname(stepB.artifacts?.current as string)
);
// Exactly one redirect warning: the run that lost the stem directory,
// whether it lost by classification or by the claim.
expect(errSpy).toHaveBeenCalledTimes(1);
} finally {
errSpy.mockRestore();
}
});
it("escalates to its hash directory when the marker's exclusive create loses the race", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// Pre-created and empty, so both runs classify <output>/checks as free
// ("nothing to protect") and neither can be redirected by the read alone.
// Exactly one wins the create; the loser must read the winner's marker
// and treat it like any other foreign owner — same escalation, same
// warning as a classify-time verdict.
await fs.mkdir(path.join(outDir, "checks"), { recursive: true });
const flowA = path.join(tmpDir, "suiteA", "checks.yaml");
const flowB = path.join(tmpDir, "suiteB", "checks.yaml");
const mkStep = async (name: string, content: string): Promise<StepReport> => ({
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "shot__ios-390x844",
artifacts: { current: await writeFile(name, content) },
});
const stepA = await mkStep("a.png", "suiteA-bytes");
const stepB = await mkStep("b.png", "suiteB-bytes");
await Promise.all([
exportFailureArtifacts(mkReport([stepA]), outDir, flowA, ctx),
exportFailureArtifacts(mkReport([stepB]), outDir, flowB, ctx),
]);
// Winner-agnostic: whoever holds the pre-created stem directory won.
const stemDir = path.join(outDir, "checks");
const a = { flow: flowA, dir: path.dirname(stepA.artifacts?.current as string) };
const b = { flow: flowB, dir: path.dirname(stepB.artifacts?.current as string) };
const [winner, loser] = a.dir === stemDir ? [a, b] : [b, a];
expect(winner.dir).toBe(stemDir);
expect(loser.dir).toBe(path.join(outDir, `checks-${pathHash(loser.flow)}`));
expect(await fs.readFile(path.join(stemDir, ".argent-flow-source"), "utf8")).toBe(
`${winner.flow}\n`
);
expect(errSpy).toHaveBeenCalledWith(
`warning: ${stemDir} already holds artifacts from ${winner.flow}; ` +
`writing this flow's artifacts to ${loser.dir} so neither set is overwritten`
);
} finally {
errSpy.mockRestore();
}
});
it("keeps two concurrent runs of the same flow file in one directory", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// Two runs of one file are indistinguishable to a separate process, so
// the loser of the claim must recognize its own path in the winner's
// marker and share the directory. Redirecting here would scatter one
// flow's evidence across hash siblings on every parallel CI shard.
const mkStep = async (name: string, content: string): Promise<StepReport> => ({
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile(name, content) },
});
const first = await mkStep("c1.png", "run1-bytes");
const second = await mkStep("c2.png", "run2-bytes");
await Promise.all([
exportFailureArtifacts(mkReport([first]), outDir, flowFile, ctx),
exportFailureArtifacts(mkReport([second]), outDir, flowFile, ctx),
]);
const dest = path.join(outDir, "checkout", "home__ios-390x844-current.png");
expect(first.artifacts?.current).toBe(dest);
expect(second.artifacts?.current).toBe(dest);
expect(await fs.readdir(outDir)).toEqual(["checkout"]); // no spurious redirect
expect(await fs.readFile(path.join(outDir, "checkout", ".argent-flow-source"), "utf8")).toBe(
`${flowFile}\n`
);
expect(errSpy).not.toHaveBeenCalled();
// Deliberately NOT fixed: both runs write the same key, so one set of
// bytes wins — exactly as before any ownership machinery existed. The
// marker is truthful either way, which is what makes it survivable.
expect(["run1-bytes", "run2-bytes"]).toContain(await fs.readFile(dest, "utf8"));
} finally {
errSpy.mockRestore();
}
});
it("escalates when the marker that beat its exclusive create is gone by the time it reads it", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const stemDir = path.join(outDir, "checkout");
const marker = path.join(stemDir, ".argent-flow-source");
try {
// The third way the exclusive create can lose: the marker that beat it is
// already gone when the loser goes to read it — a racing claim that was
// cleaned up, or died and was swept, inside that window. Nothing then
// proves anything about the directory, and an unprovable claim must never
// be written into: whoever created that marker may still be filling the
// directory, so copying in would leave unowned bytes in a shared one. So
// it reads as an occupant with no name and escalates, exactly like an
// illegible marker does at classify time. Only the race reaches here —
// `mkdir -p` does not EEXIST on a directory that already exists, and the
// plain file that would make it EEXIST is diverted to "foreign" by
// classifyExportDir before takeExportDir is ever called.
let served = false;
vi.mocked(fs.writeFile).mockImplementation((async (
file: unknown,
data: unknown,
options: unknown
) => {
if (!served && String(file) === marker) {
served = true; // only the stem's claim loses; the sibling's really lands
throw Object.assign(new Error(`EEXIST: file already exists, open '${marker}'`), {
code: "EEXIST",
});
}
return writeFileSync(file as string, data as never, options as never);
}) as unknown as typeof fs.writeFile);
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile("c.png", "current-bytes") },
};
await exportFailureArtifacts(mkReport([step]), outDir, flowFile, ctx);
expect(served).toBe(true); // the create really did lose
const target = path.join(outDir, `checkout-${pathHash(flowFile)}`);
expect(step.artifacts?.current).toBe(path.join(target, "home__ios-390x844-current.png"));
expect(await fs.readFile(step.artifacts?.current as string, "utf8")).toBe("current-bytes");
expect(await fs.readFile(path.join(target, ".argent-flow-source"), "utf8")).toBe(
`${flowFile}\n`
);
// The stem is bare: created by the mkdir that preceded the lost create,
// then stepped past — no marker of ours, and none of this run's bytes.
expect(await fs.readdir(stemDir)).toEqual([]);
expect(errSpy).toHaveBeenCalledTimes(1);
expect(errSpy).toHaveBeenCalledWith(
`warning: ${stemDir} already holds files from an unknown source; ` +
`writing this flow's artifacts to ${target} so neither set is overwritten`
);
} finally {
vi.mocked(fs.writeFile).mockRestore();
errSpy.mockRestore();
}
});
it("claims nothing for failed snapshots that turn out to copy no bytes", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// Three shapes that reach the copy loop with a perfectly usable key and
// still write nothing: an empty artifacts object, roles that are already
// null, and — the realistic one — a remote handle whose download fails,
// which the materializer rewrites to null. Claiming before the first byte
// would leave <output>/checkout and its marker behind for a run holding
// no artifacts, and that marker is not inert: it makes the stem foreign
// to every other flow file from then on, so each such run adds another
// hash-suffixed directory to a reused --output.
const noRoles: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: {},
};
const nulled: StepReport = {
index: 1,
kind: "snapshot",
status: "fail",
snapshotKey: "list__ios-390x844",
artifacts: { baseline: null, current: null, diff: null },
};
const remote: StepReport = {
index: 2,
kind: "snapshot",
status: "fail",
snapshotKey: "detail__ios-390x844",
artifacts: {
diff: {
__argentArtifact: true,
id: "diff-1",
filename: "remote-diff.png",
mimeType: "image/png",
size: 10,
},
},
};
// The server evicted the artifact: the fetch happens, the bytes don't.
const fetchSpy = vi.fn(async () => new Response("gone", { status: 404 }));
await exportFailureArtifacts(mkReport([noRoles, nulled, remote]), outDir, flowFile, {
toolsUrl: "http://tools.invalid",
fetchImpl: fetchSpy as unknown as typeof fetch,
});
expect(fetchSpy).toHaveBeenCalledTimes(1); // the download really was tried
expect(remote.artifacts?.diff).toBeNull();
// Not a directory, not a marker, not a warning — --output as it was.
await expect(fs.access(outDir)).rejects.toThrow();
expect(errSpy).not.toHaveBeenCalled();
} finally {
errSpy.mockRestore();
}
});
it("rides out an empty marker read instead of scattering to a hash sibling", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// <output>/checkout is this flow's own directory. A concurrent run of the
// SAME file can catch the marker mid-create: the O_EXCL open publishes the
// path one syscall before the bytes, so a read landing in between returns
// "". Reading that as "names nobody" would classify the flow's own
// directory foreign and split its evidence across a hash sibling for no
// reason, so an empty read is retried before the marker is written off.
await fs.mkdir(path.join(outDir, "checkout"), { recursive: true });
await fs.writeFile(path.join(outDir, "checkout", ".argent-flow-source"), `${flowFile}\n`);
let served = false;
vi.mocked(fs.readFile).mockImplementation((async (file: unknown, options: unknown) => {
if (!served && String(file).endsWith(".argent-flow-source")) {
served = true; // exactly one read falls inside the window
return "";
}
return readFileSync(file as string, options as never);
}) as unknown as typeof fs.readFile);
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile("c.png", "current-bytes") },
};
await exportFailureArtifacts(mkReport([step]), outDir, flowFile, ctx);
expect(served).toBe(true); // the window was actually exercised
expect(step.artifacts?.current).toBe(
path.join(outDir, "checkout", "home__ios-390x844-current.png")
);
expect(await fs.readdir(outDir)).toEqual(["checkout"]); // no hash sibling
expect(errSpy).not.toHaveBeenCalled();
} finally {
vi.mocked(fs.readFile).mockRestore();
errSpy.mockRestore();
}
});
it("treats a marker that never fills in as an unknown owner, not a free directory", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const stemDir = path.join(outDir, "checkout");
try {
// The far end of that same window: a marker whose bytes never arrive,
// because whatever created it died between the O_EXCL open and the write.
// No number of retries rescues that, and once they run out the marker is
// illegible — which must mean "someone else's", never "free". Something
// did claim this directory and may still be filling it, so claiming it
// again would overwrite exactly the run the marker was created for. The
// fixture leaves nothing else standing in the way: the directory reads
// empty, so if the exhausted read reported "no marker" rather than "names
// nobody" the emptiness probe would hand the directory straight over.
await fs.mkdir(stemDir, { recursive: true });
const marker = path.join(stemDir, ".argent-flow-source");
let reads = 0;
vi.mocked(fs.readFile).mockImplementation((async (file: unknown, options: unknown) => {
if (String(file) === marker) {
reads++; // every attempt falls inside the window, not just the first
return "";
}
return readFileSync(file as string, options as never);
}) as unknown as typeof fs.readFile);
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile("c.png", "current-bytes") },
};
await exportFailureArtifacts(mkReport([step]), outDir, flowFile, ctx);
expect(reads).toBe(5); // MARKER_READ_RETRIES + 1: the retries really ran out
const target = path.join(outDir, `checkout-${pathHash(flowFile)}`);
expect(step.artifacts?.current).toBe(path.join(target, "home__ios-390x844-current.png"));
expect(await fs.readFile(step.artifacts?.current as string, "utf8")).toBe("current-bytes");
expect(await fs.readdir(stemDir)).toEqual([]); // not claimed, not written into
expect(errSpy).toHaveBeenCalledTimes(1);
expect(errSpy).toHaveBeenCalledWith(
`warning: ${stemDir} already holds files from an unknown source; ` +
`writing this flow's artifacts to ${target} so neither set is overwritten`
);
} finally {
vi.mocked(fs.readFile).mockRestore();
errSpy.mockRestore();
}
});
it.skipIf(!canDenyWrite)(
"steps past a directory the filesystem refuses to let it claim",
async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const stemDir = path.join(outDir, "checkout");
await fs.mkdir(stemDir, { recursive: true });
await fs.chmod(stemDir, 0o555); // listable and empty, but unwritable
try {
// Empty, so the read says "free" — and then the marker create fails with
// EACCES, leaving the claim unproven. An unproven claim must never be
// written into (the pre-claim code wrote the marker best-effort and copied
// in anyway, which would drop unowned bytes into a shared directory), so
// this escalates exactly like a foreign occupant and says which it is.
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: await writeFile("c.png", "current-bytes") },
};
await exportFailureArtifacts(mkReport([step]), outDir, flowFile, ctx);
const target = path.join(outDir, `checkout-${pathHash(flowFile)}`);
expect(step.artifacts?.current).toBe(path.join(target, "home__ios-390x844-current.png"));
expect(await fs.readFile(step.artifacts?.current as string, "utf8")).toBe("current-bytes");
expect(await fs.readdir(stemDir)).toEqual([]); // nothing was forced in
expect(errSpy).toHaveBeenCalledTimes(1);
expect(errSpy).toHaveBeenCalledWith(
`warning: ${stemDir} could not be claimed (EACCES: permission denied, ` +
`open '${path.join(stemDir, ".argent-flow-source")}'); ` +
`writing this flow's artifacts to ${target} so neither set is overwritten`
);
} finally {
await fs.chmod(stemDir, 0o755);
errSpy.mockRestore();
}
}
);
it.skipIf(!canDenyWrite)(
"skips the export when no candidate directory can be claimed at all",
async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await fs.mkdir(outDir, { recursive: true });
await fs.chmod(outDir, 0o555); // --output itself refuses every mkdir
try {
const source = await writeFile("c.png", "current-bytes");
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: source },
};
await exportFailureArtifacts(mkReport([step]), outDir, flowFile, ctx);
// Every rung unclaimable is the give-up case: source path left in place,
// verdict unchanged, one warning naming the whole span it tried.
expect(step.artifacts?.current).toBe(source);
expect(await fs.readdir(outDir)).toEqual([]);
expect(errSpy).toHaveBeenCalledTimes(1);
expect(errSpy).toHaveBeenCalledWith(
`warning: not exporting artifacts for ${flowFile}: no candidate directory from ` +
`${path.join(outDir, "checkout")} through ` +
`${path.join(outDir, `checkout-${pathHash(flowFile, 64)}`)} could be claimed without ` +
`overwriting other files; leaving this run's artifact paths in place so nothing is ` +
`overwritten`
);
} finally {
await fs.chmod(outDir, 0o755);
errSpy.mockRestore();
}
}
);
});