Files
software-mansion__argent/packages/tool-server/test/flows/script/flow-script-step-run.test.ts
Hubert Gancarczyk 0f90d182b2 feat(flows): record a script step by running it (#940)
#864 (the executor) and #865 (the `script:` step) are merged. This
branch now
sits directly on `main` as a single commit, so the diff and the commit
list are
this change alone.

A `script:` step could only enter a flow by being typed into the YAML
after the
recording ended. The authoring skill said so in as many words, and
listed it
among the legal unrecorded insertions. That made it the one directive
exempt
from every rule the recorder exists to enforce, and it cost two things:

- **The script never ran while the flow was authored.** It first
executed during
the closing replay, where a failure reads as a flow that was never right
rather than a script that needs one more attempt. Worse, the device
steps
recorded after it were walked against backend state the author had
established
by hand — so the recording proved nothing about whether the script
establishes
  that same state.
- **The author never saw what the script returned.** A later release
reads
`{{output:user.id}}`; the only way to know that path exists is to have
seen the
  document.

This adds one MCP tool, `flow-add-script`, that runs a local `.mjs` and
records
the step it ran. **No new flow syntax** — the `script:` step is
unchanged, and
this is a second way for one to enter a flow file.

```text
flow-add-script { name: "checkout", project_root: "/Users/dev/shop",
                  path: "../../scripts/seed-order.mjs", timeout: 60000 }
```

## What it adds

- **One shared execution path.** Path resolution, the on-disk casing
guard, the
executor call and the verdict were private to `flow-run.ts`. They are
now
`runFlowScriptStep` in `flow-script-step.ts`, with the file-ref helpers
in
`flow-file-refs.ts`, and `runScriptStep` is that call plus its
`ExecState`
plumbing. This is the load-bearing change: "the recording runs the
script
exactly as replay will" is one function, not two code paths that agree
today.
- **A failure records nothing.** The opposite of `flow-add-step`,
deliberately.
A failed script did not establish the state the rest of the walkthrough
would
then be recorded against, so an appended red step costs every step after
it,
not just itself. The `message` also says whether a child actually *ran*
— a
path that resolved to no file forked nothing, while a script that threw
halfway did not roll back — because the author's next move is a retry or
a
  cleanup and they cannot pick without knowing.
- **The returned document, as JSON text.** Text rather than an object
because
this is the first tool result carrying bytes the server did not author:
every
  result is deep-walked for `__argentClientFile` and `__argentArtifact`
directives, which match on shape alone and write or fetch files on the
agent's
  machine. As a string it inherits none of that. Bounded at 64 KiB with
  `outputTruncated`, for the reason `log` is bounded.
- **`{{output:` refused by `parseFlow`.** Surfacing the document invites
an
author to write a reference that has no meaning yet — it would be
printed
literally by a step that passes, green and wrong. The scan runs over
exactly
the field list the output release declares supported, so it is a seam
that
release converts from reject to resolve rather than scaffolding it
deletes.
Patterns are excluded: a `{{` in a regular expression is a quantifier
the
  author meant.
- **Two boundaries.** A `client`-mode recording is refused — its `.mjs`
never
left the agent's machine, the same anchor reason `run:`, `snapshot` and
  uploaded flows are refused. And the tool is `longRunning`, so the MCP
adapter's 30s fetch abort cannot retry a call whose whole purpose is a
side
effect; the comment records what that flag does *not* close, and where
the
  rest belongs.
- **`flow-add-step` says when the call already ran.** The `{{output:`
refusal is
the one refusal this release adds on a path where the tool call has
already
fired, and its message asks for an edit-and-retry — which repeats the
device
  action. Verified: a `keyboard` step recorded twice left
`{{output:code}}{{output:code}}` in the field. That one refusal now
carries
  what already happened.

## Documentation

Rule 5 of `argent-create-flow` no longer lists `script:` as a legal
unrecorded
insertion — leaving it there would tell agents to keep doing the thing
this PR
removes. `live-authoring.md` gains the recorder contract, the return
fields that
are not self-evident, and the ordering rule: a setup script is recorded
*before*
the `restart-app` it prepares state for, because that is where it runs
at
replay. A leading script does not make the flow a fragment. `qa-flows`
and
`reliability-and-recovery` follow the count and the wording.

## Verification

`npm run build`, `npx eslint . --max-warnings 0`, `npx prettier --check`
over
tracked files, and `npm run typecheck:tests -w @argent/tool-server` are
green.
`npm test -w @argent/tool-server`: 358 files, 4657 passed, 1 skipped.

Exercised end to end against a live tool server built from this branch,
not
only in tests — recorded a flow, then replayed the saved YAML through
`argent flow run` with no hand editing:

```
flow-add-script → status "pass", durationMs 124
  log: "seeding order for the flow\nwarn: using the fake backend\n"
  outputJson: {"order":{"id":"ord_4417","total":39.5},
               "user":{"id":"usr_9","email":"buyer@example.com"}}
  recorded: 1. script: ../../scripts/seed-order.mjs (timeout 30000ms)
```

```yaml
steps:
  - script:
      path: ../../scripts/seed-order.mjs
      timeout: 30000
  - echo: order seeded; expect Home
```

```
Flow "checkout"
  ✓  1 script ../../scripts/seed-order.mjs
       │ seeding order for the flow
       │ warn: using the fake backend
  › order seeded; expect Home

PASS — 1 passed, 0 failed, 0 errored, 0 skipped
```

The three refusals, live on the same recording:

- A script that threw after creating records: `status "fail"`,
`stepCount`
unchanged at 1, and the message naming the side effects that were not
rolled
  back.
- A path resolving to no file: refused before any fork, no `durationMs`,
and
  "Nothing ran, so there is nothing to clean up".
- `flow-add-echo` holding `{{output:user.id}}`: refused with the
author-facing
  message naming the step, the field and the later release.

No device is involved at any point — a recording may open with a seeding
script
before it boots anything.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added the `flow-add-script` tool to run local `.mjs` scripts and
record successful executions in flows.
* Added script status, output, duration, logs, and failure details to
recording results.
* Added clearer handling for script cancellation, timeouts, missing
files, and path casing.

* **Documentation**
* Updated flow guidance to clarify end-to-end classification and script
setup or cleanup usage.
* Documented script path requirements, failure conditions, and recording
workflows.

* **Bug Fixes**
* Flows now reject unsupported output-reference templates with more
precise diagnostics.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-07 12:59:29 +02:00

772 lines
30 KiB
TypeScript

import { describe, it, expect, vi, 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";
/**
* The `script:` step in a run; the executor's own behaviour is covered beside
* it, in flow-script-run.test.ts. Real child processes, hence the generous
* timeout.
*/
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 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}`);
}
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);
});
});
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); // echo is narration and is not counted
});
it("names the file, the line and the frames a bare node run would print", async () => {
// Without them the step's whole diagnostic is one sentence: a throw writes
// nothing to stderr, so there is no log either, and CI has nothing to
// re-run against.
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");
// Project-relative, so the frames fit the step line the reason rides on.
expect(reason).toContain("at inner (scripts/deep.mjs:1:30)");
expect(reason).toContain("at outer (scripts/deep.mjs:2:27)");
// Still one line: the frames are escaped like any other break in a reason.
expect(reason).not.toMatch(/[\n\r]/);
});
it("leaves the host's own frames out of the reason", async () => {
// A stack ends in the ESM loader and the runner that preloaded the script.
// Those name no line the author can open, and they would crowd out the
// frames that do.
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 () => {
// The ordinary shape of a rethrown API error. Raw, the JSON body lands at
// column 0 under the `✗` line, outside the step framing every renderer is
// built on — one line per step, reason interpolated straight in.
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]/);
// Escaped, not stripped: the message is still readable and the breaks are
// still recoverable.
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 () => {
// The step's reason is the only text on a report line that the script
// itself writes. Given a raw newline it can put a whole summary line of its
// own below a failed step and above the real one.
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 () => {
// The kernel answers ENOTDIR, not ENOENT, when a directory component of the
// path is a regular file. Nothing is there either way, so both read alike.
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 () => {
// The one authoring error a local run cannot find: APFS and NTFS open
// `CreateUser.mjs` for a file really named `createUser.mjs`, and the same
// tree then fails with ENOENT on Linux CI.
//
// Ungated, because the VERDICT is not the filesystem's: classifyOnDiskSpelling
// compares the supplied basename against readdir's own entries, lowercased,
// so the refusal reproduces on a case-sensitive host too.
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 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", () => {
/**
* `flow-add-script` runs the script with the RECORDING's `project_root`; the
* runner uses the ROOT run's. A fragment recorded in one project and composed
* by a flow in another therefore runs its script somewhere else than where it
* was recorded — which is what the tool's "it ran here as a replay of this
* flow will" is qualified against.
*/
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 });
// `run:` is always relative to the flow file that names it.
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"));
// A flow that uses `run:` resolves a device even when every leaf is a
// script — see "still resolves a device when the same flow uses run:".
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 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 () => {
// The script's `error` stops the run, so the steps below it take the
// hard-stop path rather than the abort guard every other cancelled step
// uses. They still have to say why they did not run: a cancellation is not
// collateral of a step that failed.
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"],
]);
});
});