mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
904 lines
33 KiB
TypeScript
904 lines
33 KiB
TypeScript
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
|
import * as fs from "node:fs/promises";
|
|
import * as fsSync from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { ArtifactStore, type Registry, type ToolContext } from "@argent/registry";
|
|
import { createRunFlowTool, type FlowRunResult } from "../../../src/tools/flows/flow-run";
|
|
import { stepRequiresDevice } from "../../../src/tools/flows/flow-device";
|
|
import { resolveHostBash } from "../../helpers/host-bash";
|
|
|
|
vi.setConfig({ testTimeout: 30_000 });
|
|
|
|
const DEVICE = "00000000-0000-0000-0000-0000000000ab";
|
|
|
|
let root: string;
|
|
|
|
function mockRegistry(opts: { booted?: string[] } = {}) {
|
|
const invokeTool = vi.fn(async (id: string) => {
|
|
if (id === "list-devices") {
|
|
return {
|
|
devices: (opts.booted ?? []).map((udid) => ({ platform: "ios", udid, state: "Booted" })),
|
|
};
|
|
}
|
|
return { ok: true };
|
|
});
|
|
const registry = {
|
|
invokeTool,
|
|
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
|
|
resolveService: vi.fn(async () => ({
|
|
isConnected: () => true,
|
|
listConnectedBundleIds: () => [],
|
|
})),
|
|
} as unknown as Registry;
|
|
return { registry, invokeTool };
|
|
}
|
|
|
|
async function write(relative: string, contents: string): Promise<string> {
|
|
const file = path.join(root, relative);
|
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
await fs.writeFile(file, contents, "utf8");
|
|
return file;
|
|
}
|
|
|
|
function flow(name: string, yaml: string): Promise<string> {
|
|
return write(path.join(".argent", "flows", `${name}.yaml`), yaml);
|
|
}
|
|
|
|
function markPath(mark: string): string {
|
|
return path.join(root, `${mark}.mark`);
|
|
}
|
|
|
|
function markingScript(relative: string, mark: string, expression?: string): Promise<string> {
|
|
return write(
|
|
relative,
|
|
`import fs from "node:fs";\n` +
|
|
`fs.writeFileSync(${JSON.stringify(markPath(mark))}, ` +
|
|
`String(${expression ?? JSON.stringify(mark)}));`
|
|
);
|
|
}
|
|
|
|
function readMark(mark: string): string | undefined {
|
|
try {
|
|
return fsSync.readFileSync(markPath(mark), "utf8");
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function boundaryCtx(flowPath: string): ToolContext {
|
|
return {
|
|
artifacts: new ArtifactStore(),
|
|
fileInputs: {
|
|
flow_path: {
|
|
clientPath: flowPath,
|
|
presentOnHost: true,
|
|
viaUpload: false,
|
|
statVerified: true,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function listedDevices(invokeTool: { mock: { calls: unknown[][] } }): boolean {
|
|
return invokeTool.mock.calls.some((call) => call[0] === "list-devices");
|
|
}
|
|
|
|
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
|
|
if (!("steps" in r)) throw new Error(`expected a run result, got notice: ${r.notice}`);
|
|
return r;
|
|
}
|
|
|
|
async function run(
|
|
registry: Registry,
|
|
params: Record<string, unknown>,
|
|
ctx?: ToolContext
|
|
): Promise<FlowRunResult> {
|
|
return asRun(
|
|
await createRunFlowTool(registry).execute({}, { project_root: root, ...params } as never, ctx)
|
|
);
|
|
}
|
|
|
|
async function runFlow(name: string, opts: { booted?: string[]; device?: string } = {}) {
|
|
const { registry, invokeTool } = mockRegistry(opts);
|
|
const result = await run(registry, { name, ...(opts.device ? { device: opts.device } : {}) });
|
|
return { result, invokeTool };
|
|
}
|
|
|
|
async function until(predicate: () => boolean, label: string, timeoutMs = 15_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
throw new Error(`timed out waiting for ${label}`);
|
|
}
|
|
|
|
let noBash: string | undefined;
|
|
|
|
beforeAll(async () => {
|
|
const found = await resolveHostBash();
|
|
if (!("path" in found)) noBash = found.problem;
|
|
});
|
|
|
|
function skipWithoutBash(ctx: { skip: (note?: string) => void }): void {
|
|
if (noBash) ctx.skip(`this host has no bash to run a .sh step with: ${noBash}`);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
root = await fs.mkdtemp(path.join(os.tmpdir(), "flow-script-step-"));
|
|
});
|
|
afterEach(async () => {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("a script step in a run", () => {
|
|
it("runs the file, passes, and carries its output into the report", async () => {
|
|
await write("scripts/seed.mjs", `console.log("seeded order 4711");`);
|
|
await flow("seed", "steps:\n - script: { path: ../../scripts/seed.mjs }\n");
|
|
|
|
const { result, invokeTool } = await runFlow("seed");
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.steps).toHaveLength(1);
|
|
expect(result.steps[0]).toMatchObject({
|
|
kind: "script",
|
|
status: "pass",
|
|
target: "../../scripts/seed.mjs",
|
|
});
|
|
expect(result.steps[0]!.scriptLog).toContain("seeded order 4711");
|
|
expect(result.device).toBe("");
|
|
expect(listedDevices(invokeTool)).toBe(false);
|
|
});
|
|
|
|
it("carries stderr into the same log as stdout", async () => {
|
|
// Cross-STREAM interleaving is the kernel's to decide (stdout and stderr
|
|
// are separate pipes), so only each stream's own order is pinned.
|
|
await write(
|
|
"scripts/noisy.mjs",
|
|
`console.log("one");\nconsole.error("problem");\nconsole.log("three");`
|
|
);
|
|
await flow("noisy", "steps:\n - script: { path: ../../scripts/noisy.mjs }\n");
|
|
|
|
const { result } = await runFlow("noisy");
|
|
|
|
const log = result.steps[0]!.scriptLog!;
|
|
expect(log).toContain("problem");
|
|
expect(log.indexOf("one")).toBeGreaterThanOrEqual(0);
|
|
expect(log.indexOf("three")).toBeGreaterThan(log.indexOf("one"));
|
|
expect(result.steps[0]!.scriptLogTruncated).toBeUndefined();
|
|
});
|
|
|
|
it("flags a log a limit cut short, because the text carries no marker", async () => {
|
|
await write(
|
|
"scripts/chatty.mjs",
|
|
`for (let i = 0; i < 4000; i++) console.log("line " + i + " " + "x".repeat(40));`
|
|
);
|
|
await flow("chatty", "steps:\n - script: { path: ../../scripts/chatty.mjs }\n");
|
|
|
|
const { result } = await runFlow("chatty");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "pass", scriptLogTruncated: true });
|
|
expect(result.steps[0]!.scriptLog).toContain("line 0 ");
|
|
});
|
|
|
|
it("spends one run-wide budget across the run's steps through the runner", async () => {
|
|
await write("scripts/chatty.mjs", `process.stdout.write("z".repeat(64 * 1024));`);
|
|
await write("scripts/quiet.mjs", `console.log("the quiet one ran");`);
|
|
const chattyStep = " - script: { path: ../../scripts/chatty.mjs }\n";
|
|
await flow(
|
|
"budget",
|
|
"steps:\n" + chattyStep.repeat(5) + " - script: { path: ../../scripts/quiet.mjs }\n"
|
|
);
|
|
|
|
const { result } = await runFlow("budget");
|
|
|
|
expect(result.ok).toBe(true);
|
|
for (const starved of result.steps.slice(4)) {
|
|
expect(starved.scriptLog).toBeUndefined();
|
|
expect(starved.scriptLogTruncated).toBe(true);
|
|
}
|
|
expect(result.steps[0]!.scriptLog).not.toBe("");
|
|
});
|
|
|
|
it("classifies as needing no device", () => {
|
|
const registry = mockRegistry().registry;
|
|
expect(stepRequiresDevice(registry, { kind: "script", path: "seed.mjs" })).toBe(false);
|
|
expect(stepRequiresDevice(registry, { kind: "script", path: "seed.sh" })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("a bash step in a run", () => {
|
|
it("runs the .sh, passes, and carries what it printed into the report", async (ctx) => {
|
|
skipWithoutBash(ctx);
|
|
await write(
|
|
"scripts/seed.sh",
|
|
`set -euo pipefail\n` +
|
|
`echo "seeded order 4711"\n` +
|
|
`echo "and a warning" >&2\n` +
|
|
`printf 'ran' > ${JSON.stringify(markPath("seed-sh"))}\n`
|
|
);
|
|
await flow("seed-sh", "steps:\n - script: { path: ../../scripts/seed.sh }\n");
|
|
|
|
const { result, invokeTool } = await runFlow("seed-sh");
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.steps[0]).toMatchObject({
|
|
kind: "script",
|
|
status: "pass",
|
|
target: "../../scripts/seed.sh",
|
|
});
|
|
expect(result.steps[0]!.scriptLog).toContain("seeded order 4711");
|
|
expect(result.steps[0]!.scriptLog).toContain("and a warning");
|
|
expect(result.steps[0]!.scriptLogTruncated).toBeUndefined();
|
|
expect(readMark("seed-sh")).toBe("ran");
|
|
expect(result.device).toBe("");
|
|
expect(listedDevices(invokeTool)).toBe(false);
|
|
});
|
|
|
|
it("stops the flow on a failing .sh and carries its reason into the report", async (ctx) => {
|
|
skipWithoutBash(ctx);
|
|
await write("scripts/boom.sh", `echo "seed API returned 500" >&2\nexit 1\n`);
|
|
await flow(
|
|
"boom-sh",
|
|
"steps:\n" +
|
|
" - script: { path: ../../scripts/boom.sh }\n" +
|
|
" - echo: never reached\n" +
|
|
" - wait: 1\n"
|
|
);
|
|
|
|
const { result } = await runFlow("boom-sh");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.steps[0]).toMatchObject({ kind: "script", status: "fail" });
|
|
expect(result.steps[0]!.reason).toContain("seed API returned 500");
|
|
expect(result.steps[0]!.scriptLog).toContain("seed API returned 500");
|
|
expect(result.steps.slice(1).map((s) => [s.kind, s.status])).toEqual([
|
|
["echo", "skip"],
|
|
["wait", "skip"],
|
|
]);
|
|
});
|
|
|
|
it("runs a .mjs and a .sh in one flow", async (ctx) => {
|
|
skipWithoutBash(ctx);
|
|
await markingScript("scripts/first.mjs", "mixed-mjs");
|
|
await write("scripts/second.sh", `printf 'ran' > ${JSON.stringify(markPath("mixed-sh"))}\n`);
|
|
await flow(
|
|
"mixed",
|
|
"steps:\n" +
|
|
" - script: { path: ../../scripts/first.mjs }\n" +
|
|
" - script: { path: ../../scripts/second.sh }\n"
|
|
);
|
|
|
|
const { result } = await runFlow("mixed");
|
|
|
|
expect(result.steps.map((s) => s.status)).toEqual(["pass", "pass"]);
|
|
expect(readMark("mixed-mjs")).toBe("mixed-mjs");
|
|
expect(readMark("mixed-sh")).toBe("ran");
|
|
});
|
|
|
|
it("picks the interpreter from the file a symlink resolves to", async () => {
|
|
await markingScript("scripts/real.mjs", "aliased");
|
|
fsSync.symlinkSync("real.mjs", path.join(root, "scripts", "aliased.sh"));
|
|
await flow("aliased", "steps:\n - script: { path: ../../scripts/aliased.sh }\n");
|
|
|
|
const { result } = await runFlow("aliased");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "pass" });
|
|
expect(readMark("aliased")).toBe("aliased");
|
|
});
|
|
|
|
it("reports a looping .sh stopped at its time limit as an error", async (ctx) => {
|
|
skipWithoutBash(ctx);
|
|
await write("scripts/slow.sh", `while true; do sleep 1; done\n`);
|
|
await flow("slow-sh", "steps:\n - script: { path: ../../scripts/slow.sh, timeout: 800 }\n");
|
|
|
|
const { result } = await runFlow("slow-sh");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "error" });
|
|
expect(result.steps[0]!.reason).toMatch(/did not finish within its 800ms time limit/);
|
|
});
|
|
});
|
|
|
|
describe("a script step that fails", () => {
|
|
it("reports the script's own error as a fail and stops the flow", async () => {
|
|
await write("scripts/boom.mjs", `throw new Error("seed API returned 500");`);
|
|
await flow(
|
|
"boom",
|
|
"steps:\n" +
|
|
" - script: { path: ../../scripts/boom.mjs }\n" +
|
|
" - echo: never reached\n" +
|
|
" - wait: 1\n"
|
|
);
|
|
|
|
const { result } = await runFlow("boom");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.steps[0]).toMatchObject({ kind: "script", status: "fail" });
|
|
expect(result.steps[0]!.reason).toContain("seed API returned 500");
|
|
expect(result.steps.slice(1).map((s) => [s.kind, s.status])).toEqual([
|
|
["echo", "skip"],
|
|
["wait", "skip"],
|
|
]);
|
|
expect(result.failed).toBe(1);
|
|
expect(result.skipped).toBe(1);
|
|
});
|
|
|
|
it("names the file, the line and the frames a bare node run would print", async () => {
|
|
await write(
|
|
"scripts/deep.mjs",
|
|
"function inner(o) { return o.id; }\nfunction outer() { return inner(undefined); }\nouter();\n"
|
|
);
|
|
await flow("deep", "steps:\n - script: { path: ../../scripts/deep.mjs }\n");
|
|
|
|
const { result } = await runFlow("deep");
|
|
|
|
const reason = result.steps[0]!.reason!;
|
|
expect(result.steps[0]).toMatchObject({ kind: "script", status: "fail" });
|
|
expect(reason).toContain("Cannot read properties of undefined");
|
|
expect(reason).toContain("at inner (scripts/deep.mjs:1:30)");
|
|
expect(reason).toContain("at outer (scripts/deep.mjs:2:27)");
|
|
expect(reason).not.toMatch(/[\n\r]/);
|
|
});
|
|
|
|
it("leaves the host's own frames out of the reason", async () => {
|
|
await write("scripts/boom2.mjs", `throw new Error("seed API returned 500");`);
|
|
await flow("boom2", "steps:\n - script: { path: ../../scripts/boom2.mjs }\n");
|
|
|
|
const { result } = await runFlow("boom2");
|
|
|
|
const reason = result.steps[0]!.reason!;
|
|
expect(reason).toContain("at scripts/boom2.mjs:1:7");
|
|
expect(reason).not.toContain("node:internal");
|
|
expect(reason).not.toContain("flow-script-runner");
|
|
});
|
|
|
|
it("caps the frames it folds in and says how many it left out", async () => {
|
|
await write(
|
|
"scripts/recurse.mjs",
|
|
"function down(n) { if (n === 0) throw new Error('bottom'); return down(n - 1); }\ndown(20);\n"
|
|
);
|
|
await flow("recurse", "steps:\n - script: { path: ../../scripts/recurse.mjs }\n");
|
|
|
|
const { result } = await runFlow("recurse");
|
|
|
|
const reason = result.steps[0]!.reason!;
|
|
expect(reason).toContain("bottom");
|
|
expect(reason.match(/ at down \(/g) ?? []).toHaveLength(6);
|
|
expect(reason).toMatch(/… \d+ more frames/);
|
|
});
|
|
|
|
it("keeps a multi-line throw message on the step's own line", async () => {
|
|
await write(
|
|
"scripts/rethrow.mjs",
|
|
"throw new Error(`POST /orders returned 409\n" +
|
|
'${JSON.stringify({ error: "duplicate_order" }, null, 2)}`);'
|
|
);
|
|
await flow("rethrow", "steps:\n - script: { path: ../../scripts/rethrow.mjs }\n");
|
|
|
|
const { result } = await runFlow("rethrow");
|
|
|
|
const reason = result.steps[0]!.reason!;
|
|
expect(result.steps[0]).toMatchObject({ kind: "script", status: "fail" });
|
|
expect(reason).not.toMatch(/[\n\r\t]/);
|
|
expect(reason).toContain("POST /orders returned 409\\n");
|
|
expect(reason).toContain("duplicate_order");
|
|
});
|
|
|
|
it("denies a script the framing needed to forge a run verdict", async () => {
|
|
await write(
|
|
"scripts/forge.mjs",
|
|
"throw new Error(`seed failed\\n\\nPASS — 3 passed, 0 failed, 0 errored, 0 skipped`);"
|
|
);
|
|
await flow("forge", "steps:\n - script: { path: ../../scripts/forge.mjs }\n");
|
|
|
|
const { result } = await runFlow("forge");
|
|
|
|
const reason = result.steps[0]!.reason!;
|
|
expect(result.ok).toBe(false);
|
|
expect(reason.split("\n")).toHaveLength(1);
|
|
expect(reason).toContain("PASS — 3 passed");
|
|
});
|
|
|
|
it("reports a script that stops its own process as a fail", async () => {
|
|
await write("scripts/exit.mjs", `console.log("about to bail");\nprocess.exit(3);`);
|
|
await flow("exit", "steps:\n - script: { path: ../../scripts/exit.mjs }\n");
|
|
|
|
const { result } = await runFlow("exit");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "fail" });
|
|
expect(result.steps[0]!.reason).toContain("exit code 3");
|
|
expect(result.steps[0]!.scriptLog).toContain("about to bail");
|
|
});
|
|
|
|
it("reports a script the host stopped at its time limit as an error", async () => {
|
|
await write("scripts/slow.mjs", `await new Promise((r) => setTimeout(r, 30000));`);
|
|
await flow("slow", "steps:\n - script: { path: ../../scripts/slow.mjs, timeout: 800 }\n");
|
|
|
|
const { result } = await runFlow("slow");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "error" });
|
|
expect(result.steps[0]!.reason).toMatch(/did not finish within its 800ms time limit/);
|
|
expect(result.errored).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("a script path is checked at its own step", () => {
|
|
it("reports a missing file with the path it resolved to", async () => {
|
|
await flow("gone", "steps:\n - script: { path: ../../scripts/gone.mjs }\n");
|
|
|
|
const { result } = await runFlow("gone");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "fail", kind: "script" });
|
|
expect(result.steps[0]!.reason).toContain('Script "../../scripts/gone.mjs" does not exist');
|
|
expect(result.steps[0]!.reason).toMatch(
|
|
/Resolved path: \S*[/\\]\.argent[/\\]flows[/\\]\.\.[/\\]\.\.[/\\]scripts[/\\]gone\.mjs\.$/
|
|
);
|
|
});
|
|
|
|
it("reports a path that names a directory rather than a file", async () => {
|
|
await fs.mkdir(path.join(root, "scripts", "seed.mjs"), { recursive: true });
|
|
await flow("dir", "steps:\n - script: { path: ../../scripts/seed.mjs }\n");
|
|
|
|
const { result } = await runFlow("dir");
|
|
|
|
expect(result.steps[0]!.reason).toContain("is not a file");
|
|
});
|
|
|
|
it("reports a path that walks THROUGH a file as an ordinary missing file", async () => {
|
|
await write("scripts/seed.mjs", `console.log("ok");`);
|
|
await flow("through", "steps:\n - script: { path: ../../scripts/seed.mjs/inner.mjs }\n");
|
|
|
|
const { result } = await runFlow("through");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "fail", kind: "script" });
|
|
expect(result.steps[0]!.reason).toContain("does not exist");
|
|
expect(result.steps[0]!.reason).not.toContain("cannot be read");
|
|
});
|
|
|
|
it("reports a stat failure that is neither as its own text, not as absence", async () => {
|
|
const tooLong = `${"n".repeat(300)}.mjs`;
|
|
// The directory has to exist, or the missing component answers ENOENT
|
|
// first and this never reaches the arm under test.
|
|
await write("scripts/seed.mjs", `console.log("ok");`);
|
|
await flow("unreadable", `steps:\n - script: { path: ../../scripts/${tooLong} }\n`);
|
|
|
|
const { result } = await runFlow("unreadable");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "fail", kind: "script" });
|
|
expect(result.steps[0]!.reason).toContain("cannot be read: ");
|
|
expect(result.steps[0]!.reason).toContain("ENAMETOOLONG");
|
|
});
|
|
|
|
it("never checks a path behind a guard that does not fire", async () => {
|
|
await flow(
|
|
"guarded",
|
|
"steps:\n" +
|
|
" - when:\n" +
|
|
" platform: android\n" +
|
|
" steps:\n" +
|
|
" - script: { path: ../../scripts/nowhere.mjs }\n" +
|
|
" - echo: reached the end\n"
|
|
);
|
|
|
|
const { registry } = mockRegistry({ booted: [DEVICE] });
|
|
const result = await run(registry, { name: "guarded", device: DEVICE });
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.steps.map((s) => [s.kind, s.status])).toEqual([
|
|
["when", "skip"],
|
|
["script", "skip"],
|
|
["echo", "pass"],
|
|
]);
|
|
});
|
|
|
|
it("refuses a mis-cased path, quoting the spelling on disk", async () => {
|
|
await write("scripts/createUser.mjs", `console.log("ok");`);
|
|
await flow("cased", "steps:\n - script: { path: ../../scripts/CreateUser.mjs }\n");
|
|
|
|
const { result } = await runFlow("cased");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "error" });
|
|
expect(result.steps[0]!.reason).toContain(
|
|
'Script path "../../scripts/CreateUser.mjs" has the wrong letter case'
|
|
);
|
|
expect(result.steps[0]!.reason).toContain('Use "../../scripts/createUser.mjs"');
|
|
});
|
|
|
|
it("refuses a mis-cased .sh the same way", async () => {
|
|
await write("scripts/createUser.sh", `exit 0\n`);
|
|
await flow("cased-sh", "steps:\n - script: { path: ../../scripts/CreateUser.sh }\n");
|
|
|
|
const { result } = await runFlow("cased-sh");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "error" });
|
|
expect(result.steps[0]!.reason).toContain(
|
|
'Script path "../../scripts/CreateUser.sh" has the wrong letter case'
|
|
);
|
|
expect(result.steps[0]!.reason).toContain('Use "../../scripts/createUser.sh"');
|
|
});
|
|
|
|
it("asks for a rename when the spelling on disk is one no `script` path may name", async () => {
|
|
await write("scripts/ALT.SH", `exit 0\n`);
|
|
await flow("noncase-sh", "steps:\n - script: { path: ../../scripts/alt.sh }\n");
|
|
|
|
const { result } = await runFlow("noncase-sh");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "error" });
|
|
expect(result.steps[0]!.reason).toContain(
|
|
'Script path "../../scripts/alt.sh" has the wrong letter case'
|
|
);
|
|
expect(result.steps[0]!.reason).toContain('Rename "ALT.SH" to "alt.sh"');
|
|
});
|
|
|
|
it("refuses a mis-cased spelling of a script reached through a cross-directory symlink", async () => {
|
|
await fs.mkdir(path.join(root, "lib"), { recursive: true });
|
|
await fs.writeFile(path.join(root, "lib", "real.mjs"), `console.log("ok");`);
|
|
await fs.mkdir(path.join(root, "scripts"), { recursive: true });
|
|
await fs.symlink(
|
|
path.join(root, "lib", "real.mjs"),
|
|
path.join(root, "scripts", "CreateUser.mjs")
|
|
);
|
|
await flow("cased-link", "steps:\n - script: { path: ../../scripts/createUser.mjs }\n");
|
|
|
|
const { result } = await runFlow("cased-link");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "error" });
|
|
expect(result.steps[0]!.reason).toContain(
|
|
'Script path "../../scripts/createUser.mjs" has the wrong letter case'
|
|
);
|
|
});
|
|
|
|
it("asks for a rename when the spelling on disk is one no path could name", async () => {
|
|
await write("scripts/ALT.MJS", `console.log("ok");`);
|
|
await flow("noncase", "steps:\n - script: { path: ../../scripts/alt.mjs }\n");
|
|
|
|
const { result } = await runFlow("noncase");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "error" });
|
|
expect(result.steps[0]!.reason).toContain('Rename "ALT.MJS" to "alt.mjs"');
|
|
expect(result.steps[0]!.reason).not.toContain("Use");
|
|
});
|
|
|
|
it("treats a hyphen difference as an ordinary missing file, not a casing problem", async () => {
|
|
await write("scripts/createUser.mjs", `console.log("ok");`);
|
|
await flow("hyphen", "steps:\n - script: { path: ../../scripts/create-user.mjs }\n");
|
|
|
|
const { result } = await runFlow("hyphen");
|
|
|
|
expect(result.steps[0]!.reason).toContain("does not exist");
|
|
expect(result.steps[0]!.reason).not.toContain("wrong letter case");
|
|
});
|
|
});
|
|
|
|
describe("where a script path resolves", () => {
|
|
it("anchors at the flow file that names the step, not the root flow", async () => {
|
|
await write("scripts/shared.mjs", `console.log("shared script ran");`);
|
|
await write(
|
|
path.join(".argent", "flows", "frag", "seed.yaml"),
|
|
"steps:\n - script: { path: ../../../scripts/shared.mjs }\n"
|
|
);
|
|
await flow("root-a", "steps:\n - run: frag/seed.yaml\n");
|
|
await write(
|
|
path.join(".argent", "flows", "deep", "root-b.yaml"),
|
|
"steps:\n - run: ../frag/seed.yaml\n"
|
|
);
|
|
|
|
const a = await runFlow("root-a", { booted: [DEVICE], device: DEVICE });
|
|
const bPath = path.join(root, ".argent", "flows", "deep", "root-b.yaml");
|
|
const b = await run(
|
|
mockRegistry({ booted: [DEVICE] }).registry,
|
|
{ flow_path: bPath, device: DEVICE },
|
|
boundaryCtx(bPath)
|
|
);
|
|
|
|
for (const result of [a.result, b]) {
|
|
const script = result.steps.find((s) => s.kind === "script");
|
|
expect(script, JSON.stringify(result.steps)).toMatchObject({ status: "pass" });
|
|
expect(script!.scriptLog).toContain("shared script ran");
|
|
}
|
|
});
|
|
|
|
it("reaches a sibling directory's script through `..`", async () => {
|
|
await write("shared/scripts/seed.mjs", `console.log("sideways");`);
|
|
await write(
|
|
path.join(".argent", "flows", "onboarding", "login.yaml"),
|
|
"steps:\n - script: { path: ../../../shared/scripts/seed.mjs }\n"
|
|
);
|
|
await flow("compose", "steps:\n - run: onboarding/login.yaml\n");
|
|
|
|
const { result } = await runFlow("compose", { booted: [DEVICE], device: DEVICE });
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.steps.find((s) => s.kind === "script")!.scriptLog).toContain("sideways");
|
|
});
|
|
|
|
it("resolves a `..` after a symlinked component with kernel semantics", async () => {
|
|
await fs.mkdir(path.join(root, "lex", "other"), { recursive: true });
|
|
await fs.writeFile(path.join(root, "lex", "seed.mjs"), `console.log("kernel-resolved");`);
|
|
await write(path.join(".argent", "flows", "seed.mjs"), `console.log("lexical decoy");`);
|
|
await flow("linked", "steps:\n - script: { path: link/../seed.mjs }\n");
|
|
await fs.symlink(path.join(root, "lex", "other"), path.join(root, ".argent", "flows", "link"));
|
|
|
|
const { result } = await runFlow("linked");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "pass", kind: "script" });
|
|
expect(result.steps[0]!.scriptLog).toContain("kernel-resolved");
|
|
expect(result.steps[0]!.scriptLog).not.toContain("lexical decoy");
|
|
});
|
|
|
|
it("runs a script reached through a symlink under the name the flow spells", async () => {
|
|
await write("scripts/real.mjs", `console.log("through the alias");`);
|
|
await fs.symlink(
|
|
path.join(root, "scripts", "real.mjs"),
|
|
path.join(root, "scripts", "alias.mjs")
|
|
);
|
|
await flow("alias", "steps:\n - script: { path: ../../scripts/alias.mjs }\n");
|
|
|
|
const { result } = await runFlow("alias");
|
|
|
|
expect(result.steps[0]).toMatchObject({ status: "pass", kind: "script" });
|
|
expect(result.steps[0]!.scriptLog).toContain("through the alias");
|
|
});
|
|
|
|
it("anchors an explicit flow_path at that YAML's own directory", async () => {
|
|
await write("elsewhere/scripts/seed.mjs", `console.log("from flow_path");`);
|
|
await write("elsewhere/standalone.yaml", "steps:\n - script: { path: scripts/seed.mjs }\n");
|
|
|
|
const flowPath = path.join(root, "elsewhere", "standalone.yaml");
|
|
const result = await run(
|
|
mockRegistry().registry,
|
|
{ flow_path: flowPath },
|
|
boundaryCtx(flowPath)
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.steps[0]!.scriptLog).toContain("from flow_path");
|
|
});
|
|
|
|
it("runs the script in project_root, not in the flow file's directory", async () => {
|
|
await write("scripts/cwd.mjs", `console.log(process.cwd());`);
|
|
await write(
|
|
path.join("elsewhere", "standalone.yaml"),
|
|
"steps:\n - script: { path: ../scripts/cwd.mjs }\n"
|
|
);
|
|
const flowPath = path.join(root, "elsewhere", "standalone.yaml");
|
|
const result = await run(
|
|
mockRegistry().registry,
|
|
{ flow_path: flowPath },
|
|
boundaryCtx(flowPath)
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
const reported = fsSync.realpathSync(result.steps[0]!.scriptLog!.trim());
|
|
expect(reported).toBe(fsSync.realpathSync(root));
|
|
expect(reported).not.toBe(fsSync.realpathSync(path.join(root, "elsewhere")));
|
|
});
|
|
});
|
|
|
|
describe("which project root a script runs from", () => {
|
|
it("gives a composed fragment's script the ROOT run's project root", async () => {
|
|
const composer = await fs.mkdtemp(path.join(os.tmpdir(), "flow-script-composer-"));
|
|
try {
|
|
await write(
|
|
"scripts/where.mjs",
|
|
`import { writeFileSync } from "node:fs";\n` +
|
|
`writeFileSync("./where.txt", process.cwd());`
|
|
);
|
|
await flow("frag", "steps:\n - script: { path: ../../scripts/where.mjs }\n");
|
|
const composed = path.join(composer, ".argent", "flows", "main.yaml");
|
|
await fs.mkdir(path.dirname(composed), { recursive: true });
|
|
const target = path
|
|
.relative(path.dirname(composed), path.join(root, ".argent", "flows", "frag.yaml"))
|
|
.split(path.sep)
|
|
.join("/");
|
|
await fs.writeFile(composed, `steps:\n - run: ${target}\n`, "utf8");
|
|
|
|
const direct = await runFlow("frag");
|
|
expect(direct.result.ok).toBe(true);
|
|
expect(fsSync.existsSync(path.join(root, "where.txt"))).toBe(true);
|
|
await fs.rm(path.join(root, "where.txt"));
|
|
|
|
const { registry } = mockRegistry({ booted: [DEVICE] });
|
|
const composedRun = await run(registry, {
|
|
project_root: composer,
|
|
name: "main",
|
|
device: DEVICE,
|
|
});
|
|
|
|
expect(composedRun.ok).toBe(true);
|
|
expect(fsSync.existsSync(path.join(composer, "where.txt"))).toBe(true);
|
|
expect(fsSync.existsSync(path.join(root, "where.txt"))).toBe(false);
|
|
} finally {
|
|
await fs.rm(composer, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("a script step and the run's device", () => {
|
|
it("lets a script-only flow run with nothing booted", async () => {
|
|
await write("scripts/seed.mjs", `console.log("no device needed");`);
|
|
await flow(
|
|
"deviceless",
|
|
"steps:\n - echo: seeding\n - script: { path: ../../scripts/seed.mjs }\n"
|
|
);
|
|
|
|
const { result, invokeTool } = await runFlow("deviceless", { booted: [] });
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.device).toBe("");
|
|
expect(listedDevices(invokeTool)).toBe(false);
|
|
});
|
|
|
|
it("still resolves a device when the same flow uses run:", async () => {
|
|
await write("scripts/seed.mjs", `console.log("seeded");`);
|
|
await write(
|
|
path.join(".argent", "flows", "narrate.yaml"),
|
|
"steps:\n - echo: nothing but narration\n"
|
|
);
|
|
await flow(
|
|
"with-run",
|
|
"steps:\n - script: { path: ../../scripts/seed.mjs }\n - run: narrate.yaml\n"
|
|
);
|
|
|
|
const { result, invokeTool } = await runFlow("with-run", { booted: [DEVICE] });
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.device).toBe(DEVICE);
|
|
expect(listedDevices(invokeTool)).toBe(true);
|
|
expect(result.steps[0]).toMatchObject({ kind: "script", status: "pass" });
|
|
});
|
|
});
|
|
|
|
describe("a script step in an uploaded flow", () => {
|
|
it("is rejected before anything executes", async () => {
|
|
const uploaded = await write(
|
|
"materialized-upload.yaml",
|
|
"steps:\n - script: { path: seed.mjs }\n"
|
|
);
|
|
const { registry } = mockRegistry({ booted: [DEVICE] });
|
|
|
|
await expect(
|
|
createRunFlowTool(registry).execute(
|
|
{},
|
|
{ name: "main", project_root: root, flow_file: uploaded, device: DEVICE },
|
|
{
|
|
artifacts: new ArtifactStore(),
|
|
fileInputs: {
|
|
flow_file: {
|
|
clientPath: "/client/.argent/flows/main.yaml",
|
|
presentOnHost: false,
|
|
viaUpload: true,
|
|
},
|
|
},
|
|
}
|
|
)
|
|
).rejects.toThrow(/script is not on this host/i);
|
|
});
|
|
|
|
it("is rejected naming a .sh step just as it names a .mjs one", async () => {
|
|
const uploaded = await write(
|
|
"materialized-upload.yaml",
|
|
"steps:\n - script: { path: seed.sh }\n"
|
|
);
|
|
const { registry } = mockRegistry({ booted: [DEVICE] });
|
|
|
|
await expect(
|
|
createRunFlowTool(registry).execute(
|
|
{},
|
|
{ name: "main", project_root: root, flow_file: uploaded, device: DEVICE },
|
|
{
|
|
artifacts: new ArtifactStore(),
|
|
fileInputs: {
|
|
flow_file: {
|
|
clientPath: "/client/.argent/flows/main.yaml",
|
|
presentOnHost: false,
|
|
viaUpload: true,
|
|
},
|
|
},
|
|
}
|
|
)
|
|
).rejects.toThrow(/script: \{ path: seed\.sh \}/);
|
|
});
|
|
|
|
it("is rejected from inside a when: block that would not fire", async () => {
|
|
const uploaded = await write(
|
|
"materialized-upload.yaml",
|
|
"steps:\n - when:\n platform: android\n steps:\n - script: { path: seed.mjs }\n"
|
|
);
|
|
const { registry } = mockRegistry({ booted: [DEVICE] });
|
|
|
|
await expect(
|
|
createRunFlowTool(registry).execute(
|
|
{},
|
|
{ name: "main", project_root: root, flow_file: uploaded, device: DEVICE },
|
|
{
|
|
artifacts: new ArtifactStore(),
|
|
fileInputs: {
|
|
flow_file: {
|
|
clientPath: "/client/.argent/flows/main.yaml",
|
|
presentOnHost: false,
|
|
viaUpload: true,
|
|
},
|
|
},
|
|
}
|
|
)
|
|
).rejects.toThrow(/script is not on this host/i);
|
|
});
|
|
});
|
|
|
|
describe("cancelling a run that contains a script step", () => {
|
|
it("skips a script that had not started", async () => {
|
|
await write("scripts/seed.mjs", `console.log("must not run");`);
|
|
await flow("pre-cancel", "steps:\n - script: { path: ../../scripts/seed.mjs }\n");
|
|
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const { registry } = mockRegistry();
|
|
const result = await run(registry, { name: "pre-cancel" }, {
|
|
signal: controller.signal,
|
|
} as ToolContext);
|
|
|
|
expect(result.steps[0]).toMatchObject({
|
|
kind: "script",
|
|
status: "skip",
|
|
reason: "run aborted",
|
|
});
|
|
expect(result.aborted).toBe(true);
|
|
expect(result.skipped).toBe(1);
|
|
});
|
|
|
|
it("errors a script that HAD started, because what it did is still done", async () => {
|
|
const marker = path.join(root, "started.txt");
|
|
await write(
|
|
"scripts/slow.mjs",
|
|
`import { writeFileSync } from "node:fs";\n` +
|
|
`writeFileSync(${JSON.stringify(marker)}, "go");\n` +
|
|
`await new Promise((r) => setTimeout(r, 30000));`
|
|
);
|
|
await flow("mid-cancel", "steps:\n - script: { path: ../../scripts/slow.mjs }\n - wait: 1\n");
|
|
|
|
const controller = new AbortController();
|
|
const { registry } = mockRegistry();
|
|
const pending = run(registry, { name: "mid-cancel" }, {
|
|
signal: controller.signal,
|
|
} as ToolContext);
|
|
await until(() => fsSync.existsSync(marker), "the script to start");
|
|
controller.abort();
|
|
|
|
const result = await pending;
|
|
expect(result.steps[0]).toMatchObject({ kind: "script", status: "error" });
|
|
expect(result.steps[0]!.reason).toMatch(/cancelled/i);
|
|
expect(result.aborted).toBe(true);
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("says the run was cancelled on the steps after the script, not just under it", async () => {
|
|
const marker = path.join(root, "started-2.txt");
|
|
await write(
|
|
"scripts/slow.mjs",
|
|
`import { writeFileSync } from "node:fs";\n` +
|
|
`writeFileSync(${JSON.stringify(marker)}, "go");\n` +
|
|
`await new Promise((r) => setTimeout(r, 30000));`
|
|
);
|
|
await flow(
|
|
"mid-cancel-tail",
|
|
"steps:\n - script: { path: ../../scripts/slow.mjs }\n - wait: 1\n - wait: 1\n"
|
|
);
|
|
|
|
const controller = new AbortController();
|
|
const { registry } = mockRegistry();
|
|
const pending = run(registry, { name: "mid-cancel-tail" }, {
|
|
signal: controller.signal,
|
|
} as ToolContext);
|
|
await until(() => fsSync.existsSync(marker), "the script to start");
|
|
controller.abort();
|
|
|
|
const result = await pending;
|
|
expect(result.steps.slice(1).map((s) => [s.kind, s.status, s.reason])).toEqual([
|
|
["wait", "skip", "run aborted"],
|
|
["wait", "skip", "run aborted"],
|
|
]);
|
|
});
|
|
});
|