mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
0f90d182b2
#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 -->
2576 lines
101 KiB
TypeScript
2576 lines
101 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||
import * as fs from "node:fs/promises";
|
||
import { constants as fsConstants } from "node:fs";
|
||
import * as os from "node:os";
|
||
import * as path from "node:path";
|
||
import { FAILURE_CODES, getFailureSignal } from "@argent/registry";
|
||
import {
|
||
countStepsOnDisk,
|
||
serializeFlow,
|
||
parseFlow,
|
||
describeSelector,
|
||
assertValidProjectRoot,
|
||
startRecordingSession,
|
||
getRecordingSession,
|
||
requireRecordingSession,
|
||
clearRecordingSession,
|
||
listActiveRecordings,
|
||
__resetRecordingsForTesting,
|
||
MAX_RECORDINGS,
|
||
getFlowPath,
|
||
appIdForPlatform,
|
||
chromiumLaunchSpec,
|
||
writeNewFlowFile,
|
||
blockSteps,
|
||
BLOCK_DIRECTIVE_KEYS,
|
||
type FlowFile,
|
||
} from "../../src/tools/flows/flow-utils";
|
||
|
||
// ── serializeFlow ────────────────────────────────────────────────────
|
||
|
||
describe("serializeFlow", () => {
|
||
it("serializes an empty flow with prerequisite", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "App on home screen",
|
||
steps: [],
|
||
};
|
||
const result = serializeFlow(flow);
|
||
expect(result).toContain("executionPrerequisite: App on home screen");
|
||
expect(result).toContain("steps: []");
|
||
});
|
||
|
||
it("serializes echo steps", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "Fresh reload",
|
||
steps: [{ kind: "echo", message: "Hello" }],
|
||
};
|
||
const result = serializeFlow(flow);
|
||
expect(result).toContain("- echo: Hello");
|
||
});
|
||
|
||
it("serializes tool steps with args", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } }],
|
||
};
|
||
const result = serializeFlow(flow);
|
||
expect(result).toContain("- tool: tap");
|
||
expect(result).toContain(" x: 0.5");
|
||
expect(result).toContain(" y: 0.3");
|
||
});
|
||
|
||
it("serializes tool steps with empty args (omits args key)", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [{ kind: "tool", name: "screenshot", args: {} }],
|
||
};
|
||
const result = serializeFlow(flow);
|
||
expect(result).toContain("- tool: screenshot");
|
||
expect(result).not.toContain("args:");
|
||
});
|
||
|
||
it("rejects gesture targets that cannot round-trip through the parser", async () => {
|
||
const serializeStep = (step: FlowFile["steps"][number]) =>
|
||
serializeFlow({ executionPrerequisite: "", steps: [step] });
|
||
|
||
expect(() => serializeStep({ kind: "tap", x: 1.5, y: 0.5 })).toThrow(
|
||
/normalized 0–1 fractions/i
|
||
);
|
||
expect(() => serializeStep({ kind: "long-press", x: Number.NaN, y: 0.5 })).toThrow(
|
||
/normalized 0–1 fractions/i
|
||
);
|
||
expect(() => serializeStep({ kind: "tap", x: 0.5 })).toThrow(/needs numeric x and y/i);
|
||
expect(() =>
|
||
serializeStep({ kind: "long-press", selector: { text: "Row" }, x: 0.5, y: 0.5 })
|
||
).toThrow(/selector or x\/y coordinates, not both/i);
|
||
});
|
||
});
|
||
|
||
// ── describeSelector ─────────────────────────────────────────────────
|
||
|
||
describe("describeSelector", () => {
|
||
it("spells identifier as id, the flow-YAML spelling", async () => {
|
||
expect(describeSelector({ identifier: "submit" })).toBe('id="submit"');
|
||
});
|
||
|
||
it("renders a text selector", async () => {
|
||
expect(describeSelector({ text: "Login" })).toBe('text="Login"');
|
||
});
|
||
|
||
it("drops the internal loose flag", async () => {
|
||
expect(describeSelector({ text: "Login", loose: true })).toBe('text="Login"');
|
||
});
|
||
|
||
it("joins multiple keys with spaces", async () => {
|
||
expect(describeSelector({ text: "Login", role: "button" })).toBe('text="Login" role="button"');
|
||
});
|
||
});
|
||
|
||
// ── parseFlow ────────────────────────────────────────────────────────
|
||
|
||
describe("parseFlow", () => {
|
||
it("parses a flow with executionPrerequisite and echo steps", async () => {
|
||
const content = "executionPrerequisite: App on home screen\nsteps:\n - echo: Hello\n";
|
||
const flow = parseFlow(content);
|
||
expect(flow.executionPrerequisite).toBe("App on home screen");
|
||
expect(flow.steps).toEqual([{ kind: "echo", message: "Hello" }]);
|
||
});
|
||
|
||
it("parses tool entries with args", async () => {
|
||
const content =
|
||
'executionPrerequisite: ""\nsteps:\n - tool: tap\n args:\n x: 0.5\n y: 0.3\n';
|
||
const flow = parseFlow(content);
|
||
expect(flow.steps).toEqual([{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } }]);
|
||
});
|
||
|
||
it("parses tool entries with no args", async () => {
|
||
const content = 'executionPrerequisite: ""\nsteps:\n - tool: screenshot\n';
|
||
const flow = parseFlow(content);
|
||
expect(flow.steps).toEqual([{ kind: "tool", name: "screenshot", args: {} }]);
|
||
});
|
||
|
||
it("parses a multi-step flow", async () => {
|
||
const content = [
|
||
"executionPrerequisite: Settings open",
|
||
"steps:",
|
||
" - echo: Step 1",
|
||
" - tool: tap",
|
||
" args:",
|
||
" x: 0.5",
|
||
" - echo: Step 2",
|
||
" - tool: screenshot",
|
||
" args:",
|
||
" udid: ABC",
|
||
].join("\n");
|
||
|
||
const flow = parseFlow(content);
|
||
expect(flow.executionPrerequisite).toBe("Settings open");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "echo", message: "Step 1" },
|
||
{ kind: "tool", name: "tap", args: { x: 0.5 } },
|
||
{ kind: "echo", message: "Step 2" },
|
||
{ kind: "tool", name: "screenshot", args: { udid: "ABC" } },
|
||
]);
|
||
});
|
||
|
||
it("returns empty steps for empty content", async () => {
|
||
const flow = parseFlow("");
|
||
expect(flow.executionPrerequisite).toBe("");
|
||
expect(flow.steps).toEqual([]);
|
||
});
|
||
|
||
it("defaults executionPrerequisite to empty string when missing", async () => {
|
||
const content = "steps:\n - echo: Hello\n";
|
||
const flow = parseFlow(content);
|
||
expect(flow.executionPrerequisite).toBe("");
|
||
expect(flow.steps).toEqual([{ kind: "echo", message: "Hello" }]);
|
||
});
|
||
|
||
it("throws on unrecognized entries", async () => {
|
||
const content = 'executionPrerequisite: ""\nsteps:\n - bogus: line\n';
|
||
expect(() => parseFlow(content)).toThrow("Unrecognized flow entry");
|
||
});
|
||
|
||
it("renders a small unrecognized entry in full", async () => {
|
||
// The common authoring error: a short mistyped step. The echo cap must
|
||
// leave it untouched — seeing the whole entry is what makes it fixable.
|
||
const content = 'executionPrerequisite: ""\nsteps:\n - bogus: line\n';
|
||
expect(() => parseFlow(content)).toThrow(': {"bogus":"line"}');
|
||
});
|
||
|
||
it("caps the echoed entry so an oversized value cannot ride the diagnostic", async () => {
|
||
// A mistyped run: path can point parseFlow at any in-project YAML file,
|
||
// and this message flows verbatim to stdout and into agent context — so
|
||
// the render must be bounded, and the tail of the value must not appear.
|
||
const content = `steps:\n - db_password: "hunter2-${"x".repeat(5000)}-SECRET-TAIL"\n`;
|
||
let message = "";
|
||
try {
|
||
parseFlow(content);
|
||
} catch (error) {
|
||
message = error instanceof Error ? error.message : String(error);
|
||
}
|
||
expect(message).toContain("Unrecognized flow entry");
|
||
expect(message).toContain("…(+");
|
||
expect(message).not.toContain("SECRET-TAIL");
|
||
expect(message.length).toBeLessThan(400);
|
||
});
|
||
|
||
it("throws when content is not an object with steps", async () => {
|
||
expect(() => parseFlow("- echo: Hello\n")).toThrow("expected an object with a steps array");
|
||
});
|
||
|
||
it("classifies a YAML syntax error as a validation failure with the parser's detail", async () => {
|
||
let thrown: unknown;
|
||
try {
|
||
parseFlow("steps: ][\n");
|
||
} catch (err) {
|
||
thrown = err;
|
||
}
|
||
expect(thrown).toBeInstanceOf(Error);
|
||
const signal = getFailureSignal(thrown);
|
||
expect(signal?.error_kind).toBe("validation");
|
||
expect(signal?.error_code).toBe(FAILURE_CODES.FLOW_FILE_INVALID);
|
||
// The yaml library's line/column detail must survive so the user can
|
||
// locate the syntax error.
|
||
expect((thrown as Error).message).toContain("Invalid flow file:");
|
||
expect((thrown as Error).message).toContain("line 1");
|
||
});
|
||
|
||
// `step must be an object` is spelled twice — here for a top-level entry, and
|
||
// in parseBlockSteps for a block directive's own steps: list. Nothing else
|
||
// asserts either copy, so the two are free to drift apart or be dropped with
|
||
// nothing noticing; the pair below pins each site independently.
|
||
const entryRejectionMessage = (content: string): string => {
|
||
try {
|
||
parseFlow(content);
|
||
} catch (err) {
|
||
// A raw TypeError would carry no signal — this is what makes it structured.
|
||
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_ENTRY_UNRECOGNIZED);
|
||
return err instanceof Error ? err.message : String(err);
|
||
}
|
||
throw new Error("expected the entry to be rejected");
|
||
};
|
||
|
||
it.each([
|
||
["a bare string", 'executionPrerequisite: ""\nsteps:\n - tap\n'],
|
||
["a primitive", 'executionPrerequisite: ""\nsteps:\n - 42\n'],
|
||
["a null", 'executionPrerequisite: ""\nsteps:\n - ~\n'],
|
||
])("names the step shape when %s sits in the top-level steps list", (_shape, content) => {
|
||
expect(entryRejectionMessage(content)).toContain(
|
||
"Unrecognized flow entry (step must be an object)"
|
||
);
|
||
});
|
||
|
||
it.each([
|
||
["a primitive", "steps:\n - when: { visible: Cart }\n steps: [42]\n"],
|
||
["a null", "steps:\n - when: { visible: Cart }\n steps: [~]\n"],
|
||
])("names the step shape when %s sits in a block's steps list", (_shape, content) => {
|
||
// `when:` is only the vehicle — it is today's sole block directive, and the
|
||
// guard it reaches is parseBlockSteps', shared by every block directive.
|
||
expect(entryRejectionMessage(content)).toContain(
|
||
"Unrecognized flow entry (step must be an object)"
|
||
);
|
||
});
|
||
|
||
it("sugars a bare-string selector into a loose { text } for tap", async () => {
|
||
const flow = parseFlow("steps:\n - tap: Settings\n");
|
||
// Bare string ⇒ loose: resolves identifier-first, then falls back to text.
|
||
expect(flow.steps).toEqual([{ kind: "tap", selector: { text: "Settings", loose: true } }]);
|
||
});
|
||
|
||
it("sugars a bare-string selector for type.into", async () => {
|
||
const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com" }\n');
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "type", into: { text: "email", loose: true }, text: "a@b.com" },
|
||
]);
|
||
});
|
||
|
||
it("defaults type.submit to on (no submit key in the parsed model)", async () => {
|
||
const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com" }\n');
|
||
expect(flow.steps[0]).not.toHaveProperty("submit");
|
||
});
|
||
|
||
it("parses and round-trips an explicit type.submit: false opt-out", async () => {
|
||
const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com", submit: false }\n');
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "type", into: { text: "email", loose: true }, text: "a@b.com", submit: false },
|
||
]);
|
||
expect(serializeFlow(flow)).toContain("submit: false");
|
||
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
|
||
});
|
||
|
||
it("rejects a non-boolean type.submit", async () => {
|
||
expect(() => parseFlow('steps:\n - type: { into: email, text: "x", submit: 3 }\n')).toThrow();
|
||
});
|
||
|
||
it("keeps an explicit { text } map strict (no loose fallback)", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { text: Settings }\n");
|
||
expect(flow.steps).toEqual([{ kind: "tap", selector: { text: "Settings" } }]);
|
||
});
|
||
|
||
it.each([
|
||
[
|
||
"selector",
|
||
"steps:\n - tap: { text: { matches: '^Order #\\d+$' } }\n",
|
||
{ kind: "tap", selector: { textMatches: "^Order #\\d+$" } },
|
||
],
|
||
[
|
||
"text condition",
|
||
"steps:\n - assert: { text: { in: total, matches: '^Total: \\$\\d+$' } }\n",
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { text: "total", loose: true },
|
||
expectedText: "^Total: \\$\\d+$",
|
||
textMatch: "matches",
|
||
},
|
||
],
|
||
] as const)("accepts a valid regex pattern at the %s ingress", (_ingress, yaml, expected) => {
|
||
expect(parseFlow(yaml).steps).toEqual([expected]);
|
||
});
|
||
|
||
it("accepts a regex selector combined with id and role", async () => {
|
||
expect(
|
||
parseFlow(
|
||
"steps:\n - tap: { text: { matches: '^Order #\\d+$' }, id: order-row, role: button }\n"
|
||
).steps
|
||
).toEqual([
|
||
{
|
||
kind: "tap",
|
||
selector: {
|
||
textMatches: "^Order #\\d+$",
|
||
identifier: "order-row",
|
||
role: "button",
|
||
},
|
||
},
|
||
]);
|
||
});
|
||
|
||
it.each([
|
||
["id", "''"],
|
||
["id", "42"],
|
||
["role", "''"],
|
||
["role", "42"],
|
||
])(
|
||
"validates an invalid regex-selector %s through the same schema as a literal selector (%s)",
|
||
(field, value) => {
|
||
const validationDetail = (text: string | { matches: string }): string => {
|
||
const yamlText = typeof text === "string" ? text : `{ matches: '${text.matches}' }`;
|
||
try {
|
||
parseFlow(`steps:\n - tap: { text: ${yamlText}, ${field}: ${value} }\n`);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
const detail = /^Unrecognized flow entry \((.*)\): /.exec(message)?.[1];
|
||
expect(detail).toBeDefined();
|
||
return detail!;
|
||
}
|
||
throw new Error("expected selector validation to fail");
|
||
};
|
||
|
||
const literalDetail = validationDetail("Order");
|
||
const regexDetail = validationDetail({ matches: "^Order #\\d+$" });
|
||
|
||
expect(literalDetail).toMatch(/^tap: /);
|
||
expect(regexDetail).toBe(literalDetail);
|
||
}
|
||
);
|
||
|
||
it.each([
|
||
[
|
||
"selector",
|
||
"steps:\n - assert: { visible: { text: { matches: '(' } } }\n",
|
||
"assert.visible: text",
|
||
],
|
||
[
|
||
"text condition",
|
||
"steps:\n - assert: { text: { in: total, matches: '(' } }\n",
|
||
"assert text",
|
||
],
|
||
])("reports invalid regex syntax consistently at the %s ingress", (_ingress, yaml, where) => {
|
||
expect(() => parseFlow(yaml)).toThrow(`${where} \`matches\` is not a valid regular expression`);
|
||
});
|
||
|
||
it("parses the map form's `id` as the internal identifier field (strict)", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { id: submit-btn }\n");
|
||
expect(flow.steps).toEqual([{ kind: "tap", selector: { identifier: "submit-btn" } }]);
|
||
});
|
||
|
||
it("accepts `identifier` as a parse-only alias for `id`", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { identifier: submit-btn }\n");
|
||
expect(flow.steps).toEqual([{ kind: "tap", selector: { identifier: "submit-btn" } }]);
|
||
});
|
||
|
||
it("rejects a selector map carrying both `id` and `identifier`", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { id: a, identifier: b }\n")).toThrow(
|
||
/`id` or `identifier`.*not both/
|
||
);
|
||
});
|
||
|
||
it("re-serializes an identifier-spelled flow with the `id` spelling", async () => {
|
||
// Old files parse via the alias; the next write (appendStep re-serializes
|
||
// the whole file) migrates them to the canonical `id` spelling.
|
||
const yaml = serializeFlow(parseFlow("steps:\n - tap: { identifier: submit-btn }\n"));
|
||
expect(yaml).toContain("id: submit-btn");
|
||
expect(yaml).not.toContain("identifier:");
|
||
});
|
||
|
||
it("parses condition-as-key await/assert sugar (visible/exists/hidden)", async () => {
|
||
const flow = parseFlow(
|
||
[
|
||
"steps:",
|
||
" - await: { visible: Account }",
|
||
" - assert: { exists: { id: row } }",
|
||
" - await: { hidden: spinner }",
|
||
].join("\n")
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "await", condition: "visible", selector: { text: "Account", loose: true } },
|
||
{ kind: "assert", condition: "exists", selector: { identifier: "row" } },
|
||
{ kind: "await", condition: "hidden", selector: { text: "spinner", loose: true } },
|
||
]);
|
||
});
|
||
|
||
it("parses the text sugar { in, contains } as a substring match", async () => {
|
||
const flow = parseFlow(
|
||
'steps:\n - assert: { text: { in: { id: counter }, contains: "Taps: 0" } }\n'
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "counter" },
|
||
expectedText: "Taps: 0",
|
||
textMatch: "contains",
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("parses the text sugar { in, equals } as an exact match", async () => {
|
||
const flow = parseFlow(
|
||
'steps:\n - assert: { text: { in: { id: counter }, equals: "Taps: 0" } }\n'
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "counter" },
|
||
expectedText: "Taps: 0",
|
||
textMatch: "equals",
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("rejects text sugar with both contains and equals", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - assert: { text: { in: counter, contains: a, equals: b } }\n")
|
||
).toThrow(/exactly one of `contains`, `equals`, or `matches`/);
|
||
});
|
||
|
||
it("rejects the explicit { condition, selector, expectedText } form (sugar only)", async () => {
|
||
expect(() =>
|
||
parseFlow(
|
||
[
|
||
"steps:",
|
||
" - assert:",
|
||
" condition: text",
|
||
" selector: { text: 'Taps:' }",
|
||
" expectedText: 'Taps: 0'",
|
||
].join("\n")
|
||
)
|
||
).toThrow(/exactly one condition key/);
|
||
});
|
||
|
||
it("rejects an await/assert body with no condition key", async () => {
|
||
expect(() => parseFlow("steps:\n - assert: { selector: foo }\n")).toThrow(
|
||
/exactly one condition key/
|
||
);
|
||
});
|
||
|
||
it("rejects text sugar with neither contains nor equals", async () => {
|
||
expect(() => parseFlow("steps:\n - assert: { text: { in: counter } }\n")).toThrow(
|
||
/exactly one of `contains`, `equals`, or `matches`/
|
||
);
|
||
});
|
||
|
||
it("rejects text sugar with an empty contains", async () => {
|
||
expect(() =>
|
||
parseFlow('steps:\n - assert: { text: { in: counter, contains: "" } }\n')
|
||
).toThrow(/non-empty `contains`/);
|
||
});
|
||
|
||
it("serializes await/assert with the condition-as-key sugar (no condition: field)", async () => {
|
||
const yaml = serializeFlow({
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
// loose ⇒ bare-string sugar; a strict { text } would keep the map form
|
||
{ kind: "assert", condition: "visible", selector: { text: "Welcome", loose: true } },
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "counter" },
|
||
expectedText: "Taps: 0",
|
||
textMatch: "contains",
|
||
},
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "total" },
|
||
expectedText: "1",
|
||
textMatch: "equals",
|
||
},
|
||
],
|
||
});
|
||
expect(yaml).toContain("visible: Welcome");
|
||
expect(yaml).not.toContain("condition:");
|
||
expect(yaml).toContain('contains: "Taps: 0"');
|
||
expect(yaml).toContain('equals: "1"');
|
||
expect(yaml).toContain("id: counter");
|
||
expect(yaml).not.toContain("identifier:");
|
||
// An assert never emits a `timeout` key (the parser would reject it back).
|
||
expect(yaml).not.toContain("timeout");
|
||
});
|
||
|
||
it.each([
|
||
["contains", "contains: Expected"],
|
||
["equals", "equals: Expected"],
|
||
["matches", "matches: Expected"],
|
||
] as const)("serializes and round-trips the %s text comparator", (textMatch, yamlComparator) => {
|
||
const step = {
|
||
kind: "assert" as const,
|
||
condition: "text" as const,
|
||
selector: { identifier: "status" },
|
||
expectedText: "Expected",
|
||
textMatch,
|
||
};
|
||
|
||
const yaml = serializeFlow({ executionPrerequisite: "", steps: [step] });
|
||
|
||
expect(yaml).toContain(yamlComparator);
|
||
expect(parseFlow(yaml).steps).toEqual([step]);
|
||
});
|
||
|
||
it("roundtrips the sugared step kinds through YAML", async () => {
|
||
// The spelling carries the loose bit exactly both ways: a LOOSE text-only
|
||
// selector serializes to a bare string (which parses back loose); a strict
|
||
// `{ text }` keeps the map form (which parses back strict). Identifier
|
||
// selectors keep the map form and stay strict.
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{ kind: "tap", selector: { text: "Login", loose: true } },
|
||
{ kind: "tap", selector: { text: "Save" } },
|
||
{ kind: "type", into: { text: "email", loose: true }, text: "a@b.com" },
|
||
{ kind: "type", into: { text: "Password" }, text: "hunter2" },
|
||
{ kind: "await", condition: "hidden", selector: { identifier: "spinner" } },
|
||
{ kind: "await", condition: "visible", selector: { text: "Welcome" } },
|
||
{ kind: "wait", ms: 500 },
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { text: "Taps:", loose: true },
|
||
expectedText: "Taps: 0",
|
||
textMatch: "contains",
|
||
},
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "total" },
|
||
expectedText: "1",
|
||
textMatch: "equals",
|
||
},
|
||
{ kind: "scroll-to", target: { text: "Order #1234", loose: true }, direction: "down" },
|
||
{
|
||
kind: "scroll-to",
|
||
target: { text: "Summer Sale", loose: true },
|
||
direction: "right",
|
||
within: { identifier: "promotions" },
|
||
},
|
||
{
|
||
kind: "scroll-to",
|
||
target: { text: "Checkout" },
|
||
direction: "down",
|
||
within: { text: "Cart items" },
|
||
},
|
||
],
|
||
};
|
||
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
|
||
});
|
||
|
||
it("keeps a strict { text } selector strict across repeated round-trips (never collapsed to a bare loose string)", async () => {
|
||
// The recorder derives strict `{ text }` selectors, and every recorded step
|
||
// re-reads and re-writes the whole file (appendStep) — so a single lossy
|
||
// serialization would silently promote them to loose, sending them through
|
||
// the identifier-first fallback they were never verified against.
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [{ kind: "tap", selector: { text: "Save" } }],
|
||
};
|
||
const once = serializeFlow(flow);
|
||
expect(once).toContain("text: Save");
|
||
expect(once).not.toContain("tap: Save");
|
||
const reparsed = parseFlow(once);
|
||
expect(reparsed.steps).toEqual(flow.steps); // no `loose` flag introduced
|
||
expect(parseFlow(serializeFlow(reparsed)).steps).toEqual(flow.steps);
|
||
});
|
||
|
||
it("sugars a bare-string scroll-to target and keeps the within map", async () => {
|
||
const flow = parseFlow(
|
||
["steps:", " - scroll-to: { target: Account, direction: down }"].join("\n")
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
|
||
]);
|
||
});
|
||
|
||
it("parses a bare-number wait as milliseconds", async () => {
|
||
const flow = parseFlow("steps:\n - wait: 750\n");
|
||
expect(flow.steps).toEqual([{ kind: "wait", ms: 750 }]);
|
||
});
|
||
|
||
it("rejects a wait that is not a non-negative number", async () => {
|
||
expect(() => parseFlow("steps:\n - wait: soon\n")).toThrow("wait needs a non-negative number");
|
||
expect(() => parseFlow("steps:\n - wait: -5\n")).toThrow("wait needs a non-negative number");
|
||
});
|
||
|
||
it("parses an await timeout in milliseconds", async () => {
|
||
const flow = parseFlow("steps:\n - await: { visible: Account, timeout: 10000 }\n");
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "await",
|
||
condition: "visible",
|
||
selector: { text: "Account", loose: true },
|
||
timeout: 10000,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("rejects an await timeout that is not a positive finite number", async () => {
|
||
// `.inf`, `.nan`, and an overflowing literal all parse to a typeof-number
|
||
// value; letting Infinity through would make the runner's poll deadline
|
||
// unreachable (an unbounded await).
|
||
for (const bad of ["soon", "0", "-5", ".inf", ".nan", "1e400"]) {
|
||
expect(() => parseFlow(`steps:\n - await: { visible: Account, timeout: ${bad} }\n`)).toThrow(
|
||
"await.timeout needs a positive number of milliseconds"
|
||
);
|
||
}
|
||
});
|
||
|
||
it("serializes an await timeout and round-trips it", () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{
|
||
kind: "await",
|
||
condition: "visible",
|
||
selector: { text: "Account", loose: true },
|
||
timeout: 10000,
|
||
},
|
||
],
|
||
};
|
||
const yaml = serializeFlow(flow);
|
||
expect(yaml).toContain("timeout: 10000");
|
||
expect(parseFlow(yaml).steps).toEqual(flow.steps);
|
||
});
|
||
|
||
it.each([
|
||
["zero", 0],
|
||
["infinite", Number.POSITIVE_INFINITY],
|
||
])("rejects a programmatic await timeout that is %s", (_description, timeout) => {
|
||
// A FlowStep built in code bypasses the YAML parser, so the serializer must
|
||
// apply the same positive-milliseconds guard the parser enforces — letting
|
||
// Infinity through would round-trip to `.inf`, an unbounded await.
|
||
expect(() =>
|
||
serializeFlow({
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{
|
||
kind: "await",
|
||
condition: "visible",
|
||
selector: { text: "Account", loose: true },
|
||
timeout,
|
||
},
|
||
],
|
||
})
|
||
).toThrow("Cannot serialize flow await.timeout: needs a positive number of milliseconds");
|
||
});
|
||
|
||
it("rejects a timeout on an assert step (an assert is an immediate check)", async () => {
|
||
// The internal assert step has no timeout field, so a YAML `timeout` used
|
||
// to be silently dropped; reject it loudly instead — a check that needs
|
||
// time to become true is a wait, spelled `await`.
|
||
expect(() => parseFlow("steps:\n - assert: { visible: Account, timeout: 9000 }\n")).toThrow(
|
||
/assert has no timeout/
|
||
);
|
||
expect(() =>
|
||
parseFlow('steps:\n - assert: { text: { in: counter, equals: "0" }, timeout: 5000 }\n')
|
||
).toThrow(/assert has no timeout/);
|
||
});
|
||
|
||
it("rejects a scroll-to with an invalid direction", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - scroll-to: { target: Account, direction: sideways }\n")
|
||
).toThrow("scroll-to direction must be one of");
|
||
});
|
||
|
||
it("defaults scroll-to direction to down", async () => {
|
||
const flow = parseFlow("steps:\n - scroll-to: { target: Account }\n");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
|
||
]);
|
||
});
|
||
|
||
it("parses a bare-string scroll-to as a down-scroll to that target", async () => {
|
||
const flow = parseFlow("steps:\n - scroll-to: Account\n");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
|
||
]);
|
||
});
|
||
|
||
it("serializes the default scroll-to back to the bare-string sugar", async () => {
|
||
const steps = [
|
||
{ kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" },
|
||
] as FlowFile["steps"];
|
||
const yaml = serializeFlow({ executionPrerequisite: "", steps });
|
||
expect(yaml).toContain("- scroll-to: Account");
|
||
expect(parseFlow(yaml).steps).toEqual(steps);
|
||
});
|
||
|
||
it("parses a bare-string snapshot as its name", async () => {
|
||
const flow = parseFlow("steps:\n - snapshot: home\n");
|
||
expect(flow.steps).toEqual([{ kind: "snapshot", name: "home" }]);
|
||
});
|
||
|
||
it("serializes a name-only snapshot as a bare string, keeps the map with maxMismatch", async () => {
|
||
const steps = [
|
||
{ kind: "snapshot", name: "home" },
|
||
{ kind: "snapshot", name: "cart", maxMismatch: 1.5 },
|
||
] as FlowFile["steps"];
|
||
const yaml = serializeFlow({ executionPrerequisite: "", steps });
|
||
expect(yaml).toContain("- snapshot: home");
|
||
expect(yaml).toContain("maxMismatch: 1.5");
|
||
expect(parseFlow(yaml).steps).toEqual(steps);
|
||
});
|
||
|
||
it("rejects a snapshot name that is not path-safe", async () => {
|
||
expect(() => parseFlow("steps:\n - snapshot: ../evil\n")).toThrow(/must match/);
|
||
});
|
||
|
||
it("accepts a string-number maxMismatch", async () => {
|
||
const flow = parseFlow('steps:\n - snapshot: { name: home, maxMismatch: "1.5" }\n');
|
||
expect(flow.steps).toEqual([{ kind: "snapshot", name: "home", maxMismatch: 1.5 }]);
|
||
});
|
||
|
||
it("rejects a non-numeric, negative, or out-of-range maxMismatch", async () => {
|
||
for (const bad of ['"5%"', "-1", "101", ".nan"]) {
|
||
expect(() =>
|
||
parseFlow(`steps:\n - snapshot: { name: home, maxMismatch: ${bad} }\n`)
|
||
).toThrow("snapshot maxMismatch must be a number between 0 and 100");
|
||
}
|
||
});
|
||
|
||
it("parses snapshot cropOn as a selector (bare-string loose, map strict)", async () => {
|
||
const flow = parseFlow(
|
||
"steps:\n" +
|
||
" - snapshot: { name: home, cropOn: Header }\n" +
|
||
" - snapshot: { name: cart, cropOn: { id: cart-total } }\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "snapshot", name: "home", cropOn: { text: "Header", loose: true } },
|
||
{ kind: "snapshot", name: "cart", cropOn: { identifier: "cart-total" } },
|
||
]);
|
||
});
|
||
|
||
it("serializes snapshot cropOn in the map form and round-trips", async () => {
|
||
const steps = [
|
||
{ kind: "snapshot", name: "home", cropOn: { text: "Header", loose: true } },
|
||
{ kind: "snapshot", name: "cart", maxMismatch: 1.5, cropOn: { identifier: "cart-total" } },
|
||
] as FlowFile["steps"];
|
||
const yaml = serializeFlow({ executionPrerequisite: "", steps });
|
||
expect(yaml).toContain("cropOn: Header");
|
||
expect(parseFlow(yaml).steps).toEqual(steps);
|
||
});
|
||
|
||
it("rejects a point-form cropOn — a point has no extent to crop to", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - snapshot: { name: home, cropOn: { x: 0.5, y: 0.5 } }\n")
|
||
).toThrow(/snapshot\.cropOn: selector has unknown keys `x`, `y`/);
|
||
});
|
||
|
||
it("rejects a tap body mixing a selector with coordinates", async () => {
|
||
for (const key of ["id", "identifier"]) {
|
||
expect(() => parseFlow(`steps:\n - tap: { ${key}: box, x: 0.5, y: 0.5 }\n`)).toThrow(
|
||
"tap takes a selector or x/y coordinates, not both"
|
||
);
|
||
}
|
||
});
|
||
|
||
it("rejects a coordinate tap with a missing or non-numeric x/y", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { x: 0.5 }\n")).toThrow(
|
||
"tap: a coordinate target needs numeric x and y"
|
||
);
|
||
expect(() => parseFlow('steps:\n - tap: { x: "0.5", y: 0.5 }\n')).toThrow(
|
||
"tap: a coordinate target needs numeric x and y"
|
||
);
|
||
});
|
||
|
||
it("round-trips free-text values exactly, including whitespace-only lines", async () => {
|
||
// The parser stores every free-text field verbatim — `type.text`, `echo`,
|
||
// await/assert `contains`/`equals`, and `executionPrerequisite` are never
|
||
// trimmed — so serialization must be byte-exact too. Default yamlStringify
|
||
// emits multi-line values as block scalars, whose chomping silently strips
|
||
// whitespace-only lines on re-parse (" \n" came back as "\n"); serializeFlow
|
||
// disables blockQuote so these values round-trip via double-quoted escapes.
|
||
const values = [
|
||
"line1\nline2",
|
||
"line1\nline2 ", // trailing space on the final content line
|
||
" \n", // whitespace-only line — silently corrupted by a block scalar
|
||
" \n \n",
|
||
"\t\n",
|
||
" hi ",
|
||
"plain single line",
|
||
];
|
||
for (const value of values) {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: value,
|
||
steps: [
|
||
{ kind: "echo", message: value },
|
||
{ kind: "type", into: { text: "email", loose: true }, text: value },
|
||
{
|
||
kind: "await",
|
||
condition: "text",
|
||
selector: { identifier: "counter" },
|
||
expectedText: value,
|
||
textMatch: "contains",
|
||
timeout: 5000,
|
||
},
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "total" },
|
||
expectedText: value,
|
||
textMatch: "equals",
|
||
},
|
||
],
|
||
};
|
||
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
|
||
}
|
||
});
|
||
|
||
it("never serializes a whitespace-only-line value as a block scalar", async () => {
|
||
const steps = [{ kind: "echo", message: "step one \n \ndone" }] as FlowFile["steps"];
|
||
const yaml = serializeFlow({ executionPrerequisite: "", steps });
|
||
// Block (|) and folded (>) scalars are not round-trip-safe for this shape;
|
||
// the value must be emitted as a double-quoted flow scalar instead.
|
||
expect(yaml).not.toContain("|");
|
||
expect(yaml).not.toContain(">");
|
||
expect(yaml).toContain("\\n"); // newlines spelled as escapes inside quotes
|
||
expect(parseFlow(yaml).steps).toEqual(steps);
|
||
});
|
||
|
||
// Flows are hand-authored YAML, so a misspelled option key must fail at
|
||
// parse time — silently dropping it would apply the default instead and
|
||
// surface later as a misleading runtime failure (wrong scroll direction,
|
||
// lost submit opt-out, lost timeout, lost snapshot tolerance).
|
||
describe("unknown option keys are rejected at parse time", () => {
|
||
it("rejects a misspelled scroll-to direction key with a suggestion", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - scroll-to: { target: Order-1234, directon: up }\n")
|
||
).toThrow(/scroll-to has unknown key `directon` \(did you mean `direction`\?\)/);
|
||
});
|
||
|
||
it("rejects a misspelled type.submit key with a suggestion", async () => {
|
||
expect(() =>
|
||
parseFlow('steps:\n - type: { into: email, text: "a@b.com", sumbit: false }\n')
|
||
).toThrow(/type has unknown key `sumbit` \(did you mean `submit`\?\)/);
|
||
});
|
||
|
||
it("rejects a misspelled await.timeout key with a suggestion", async () => {
|
||
expect(() => parseFlow("steps:\n - await: { visible: Account, timeut: 10000 }\n")).toThrow(
|
||
/await has unknown key `timeut` \(did you mean `timeout`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects a misspelled snapshot.maxMismatch key with a suggestion", async () => {
|
||
expect(() => parseFlow("steps:\n - snapshot: { name: home, maxMissmatch: 1.5 }\n")).toThrow(
|
||
/snapshot has unknown key `maxMissmatch` \(did you mean `maxMismatch`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects a miscased snapshot.cropOn key with a suggestion", async () => {
|
||
expect(() => parseFlow("steps:\n - snapshot: { name: home, cropon: Header }\n")).toThrow(
|
||
/snapshot has unknown key `cropon` \(did you mean `cropOn`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects an unknown key on a selector map", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { text: Save, roel: button }\n")).toThrow(
|
||
/tap: selector has unknown key `roel` \(did you mean `role`\?\)/
|
||
);
|
||
expect(() => parseFlow("steps:\n - await: { visible: { txt: Save } }\n")).toThrow(
|
||
/await.visible: selector has unknown key `txt` \(did you mean `text`\?\)/
|
||
);
|
||
expect(() =>
|
||
parseFlow("steps:\n - scroll-to: { target: { text: Row }, within: { identfier: list } }\n")
|
||
).toThrow(
|
||
/scroll-to.within: selector has unknown key `identfier` \(did you mean `identifier`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects an unknown key without a suggestion when nothing is close", async () => {
|
||
expect(() => parseFlow("steps:\n - scroll-to: { target: Row, sideways: true }\n")).toThrow(
|
||
/scroll-to has unknown key `sideways` — allowed keys: target, direction, within/
|
||
);
|
||
});
|
||
|
||
it("rejects an unknown key in an await/assert text body", async () => {
|
||
expect(() =>
|
||
parseFlow('steps:\n - assert: { text: { in: counter, contians: "Taps: 0" } }\n')
|
||
).toThrow(/assert.text has unknown key `contians` \(did you mean `contains`\?\)/);
|
||
});
|
||
|
||
it("rejects a stray key on a coordinate tap", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { x: 0.5, y: 0.5, why: 0.6 }\n")).toThrow(
|
||
/tap: a coordinate target takes only \{ x, y \}/
|
||
);
|
||
});
|
||
|
||
it("rejects an unknown key in a launch map and its chromium value", async () => {
|
||
expect(() => parseFlow("steps:\n - launch: { amdroid: com.acme.app }\n")).toThrow(
|
||
/launch has unknown key `amdroid` \(did you mean `android`\?\)/
|
||
);
|
||
expect(() =>
|
||
parseFlow("steps:\n - launch: { chromium: { path: ./app, arg: [--e2e] } }\n")
|
||
).toThrow(/launch.chromium has unknown key `arg` \(did you mean `args`\?\)/);
|
||
});
|
||
|
||
it("rejects a step-level sibling key (options belong inside the directive value)", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - await: { visible: Account }\n timeout: 5000\n")
|
||
).toThrow(
|
||
/a `await` step has unknown key `timeout` — step options go inside the `await:` value/
|
||
);
|
||
});
|
||
|
||
it("rejects a step carrying two directive keys", async () => {
|
||
expect(() => parseFlow("steps:\n - echo: hi\n tap: Save\n")).toThrow(
|
||
/a step takes exactly one directive key, found `echo`, `tap`/
|
||
);
|
||
});
|
||
|
||
it("suggests the directive key for a misspelled step kind", async () => {
|
||
expect(() => parseFlow("steps:\n - snapshoot: home\n")).toThrow(
|
||
/unrecognized step kind \(did you mean `snapshot`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects an unknown top-level flow file key", async () => {
|
||
expect(() =>
|
||
parseFlow("executionPrerequisit: Settings open\nsteps:\n - echo: hi\n")
|
||
).toThrow(/unknown key `executionPrerequisit` \(did you mean `executionPrerequisite`\?\)/);
|
||
});
|
||
});
|
||
|
||
it("roundtrips: serialize then parse", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "App freshly loaded on home screen",
|
||
steps: [
|
||
{ kind: "echo", message: "Launch app" },
|
||
{ kind: "tool", name: "launch-app", args: { bundleId: "com.test" } },
|
||
{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } },
|
||
{ kind: "echo", message: "Done" },
|
||
],
|
||
};
|
||
const serialized = serializeFlow(flow);
|
||
expect(parseFlow(serialized)).toEqual(flow);
|
||
});
|
||
});
|
||
|
||
// ── block directives ─────────────────────────────────────────────────
|
||
|
||
// A flow per block directive, with the children its block authors. Its key type
|
||
// derives from the registry, so registering a directive without a fixture here
|
||
// is a compile error. The fixture itself is deliberately not derived from the
|
||
// key: one that agreed with the parser by construction would test nothing.
|
||
const BLOCK_DIRECTIVE_FLOWS: Record<
|
||
(typeof BLOCK_DIRECTIVE_KEYS)[number],
|
||
{ yaml: string; children: FlowFile["steps"] }
|
||
> = {
|
||
when: {
|
||
yaml:
|
||
[
|
||
"steps:",
|
||
" - when: { visible: Cart }",
|
||
" steps:",
|
||
" - echo: Guarded",
|
||
" - tap: Buy",
|
||
].join("\n") + "\n",
|
||
children: [
|
||
{ kind: "echo", message: "Guarded" },
|
||
{ kind: "tap", selector: { text: "Buy", loose: true } },
|
||
],
|
||
},
|
||
};
|
||
|
||
// Registering a kind in BLOCK_DIRECTIVE_KEYS exempts it from fromYamlStep's
|
||
// generic unknown-sibling rejection, on the promise that its own parser
|
||
// rejects unknown siblings with a pointed message. This table is what holds a
|
||
// NEW entry to that promise: its key type derives from the registry, so
|
||
// registering a directive without a fixture here is a compile error, and the
|
||
// test proves the fixture's bogus sibling is rejected, not silently accepted.
|
||
const BLOCK_DIRECTIVE_SIBLING_REJECTIONS: Record<
|
||
(typeof BLOCK_DIRECTIVE_KEYS)[number],
|
||
{ yaml: string; message: string }
|
||
> = {
|
||
when: {
|
||
yaml:
|
||
["steps:", " - when: { exists: x }", " steps:", " - echo: hi", " bogus: 1"].join(
|
||
"\n"
|
||
) + "\n",
|
||
message: "a when step takes exactly { when: <condition>, steps: [...] }",
|
||
},
|
||
};
|
||
|
||
describe("output references", () => {
|
||
const REFUSALS: [label: string, yaml: string][] = [
|
||
["an echo message", 'steps:\n - echo: "created {{output:user.id}}"\n'],
|
||
["typed text", 'steps:\n - type: { into: { id: name }, text: "{{output:user.name}}" }\n'],
|
||
["a typed-into selector", 'steps:\n - type: { into: { id: "{{output:field}}" }, text: hi }\n'],
|
||
["selector text", 'steps:\n - tap: { text: "{{output:user.name}}" }\n'],
|
||
["a selector identifier", 'steps:\n - tap: { id: "{{output:row}}" }\n'],
|
||
["a selector role", 'steps:\n - tap: { id: row, role: "{{output:kind}}" }\n'],
|
||
[
|
||
"a nested relation's selector",
|
||
'steps:\n - tap: { id: row, within: { id: "{{output:card}}" } }\n',
|
||
],
|
||
["an await selector", 'steps:\n - await: { visible: { id: "{{output:row}}" } }\n'],
|
||
[
|
||
"an await expectation",
|
||
'steps:\n - await: { text: { in: total, equals: "{{output:sum}}" } }\n',
|
||
],
|
||
[
|
||
"the selector an await text condition locates",
|
||
'steps:\n - await: { text: { in: { id: "{{output:row}}" }, contains: Done } }\n',
|
||
],
|
||
[
|
||
"an assert expectation",
|
||
'steps:\n - assert: { text: { in: total, contains: "{{output:sum}}" } }\n',
|
||
],
|
||
[
|
||
"a when guard selector",
|
||
'steps:\n - when: { visible: { id: "{{output:row}}" } }\n steps:\n - echo: hi\n',
|
||
],
|
||
[
|
||
"a when guard expectation",
|
||
'steps:\n - when: { text: { in: total, equals: "{{output:sum}}" } }\n steps:\n - echo: hi\n',
|
||
],
|
||
[
|
||
"a scroll-to target",
|
||
'steps:\n - scroll-to: { target: { id: "{{output:row}}" }, direction: down }\n',
|
||
],
|
||
[
|
||
"a scroll-to container",
|
||
'steps:\n - scroll-to: { target: row, direction: down, within: { id: "{{output:list}}" } }\n',
|
||
],
|
||
["a long-press selector", 'steps:\n - long-press: { on: { id: "{{output:row}}" } }\n'],
|
||
["a pinch selector", 'steps:\n - pinch: { on: { id: "{{output:map}}" }, scale: 2 }\n'],
|
||
["a rotate selector", 'steps:\n - rotate: { on: { id: "{{output:map}}" }, by: 45 }\n'],
|
||
[
|
||
"a snapshot crop selector",
|
||
'steps:\n - snapshot: { name: home, cropOn: { id: "{{output:row}}" } }\n',
|
||
],
|
||
["a tool arg", 'steps:\n - tool: keyboard\n args: { text: "{{output:code}}" }\n'],
|
||
[
|
||
"a tool arg nested in an array",
|
||
'steps:\n - tool: run-sequence\n args: { steps: [{ args: { text: "{{output:code}}" } }] }\n',
|
||
],
|
||
[
|
||
"a step inside a when block",
|
||
'steps:\n - when: { visible: { id: row } }\n steps:\n - echo: "{{output:user.id}}"\n',
|
||
],
|
||
["an exists condition's selector", 'steps:\n - await: { exists: { id: "{{output:row}}" } }\n'],
|
||
["a hidden condition's selector", 'steps:\n - await: { hidden: { id: "{{output:row}}" } }\n'],
|
||
["an after relation", 'steps:\n - tap: { id: row, after: { id: "{{output:card}}" } }\n'],
|
||
["a next relation", 'steps:\n - tap: { id: row, next: { id: "{{output:card}}" } }\n'],
|
||
[
|
||
"a step inside a platform-guarded when block",
|
||
'steps:\n - when: { platform: ios }\n steps:\n - echo: "{{output:user.id}}"\n',
|
||
],
|
||
];
|
||
|
||
it.each(REFUSALS)("refuses one in %s", (_label, yaml) => {
|
||
expect(() => parseFlow(yaml)).toThrow(/unsupported template syntax/);
|
||
});
|
||
|
||
it("names the field and asks for a literal value", () => {
|
||
let message = "";
|
||
try {
|
||
parseFlow('steps:\n - type: { into: { id: name }, text: "{{output:user.name}}" }\n');
|
||
} catch (err) {
|
||
message = err instanceof Error ? err.message : String(err);
|
||
}
|
||
expect(message).toContain("`type.text`");
|
||
expect(message).toContain("{{output:");
|
||
expect(message).toContain("Replace it with the literal value the step needs");
|
||
});
|
||
|
||
it("addresses a condition's selector and its expectation apart", () => {
|
||
const fieldNamed = (yaml: string): string => {
|
||
try {
|
||
parseFlow(yaml);
|
||
} catch (err) {
|
||
return /`([^`]+)` uses unsupported template syntax/.exec(
|
||
err instanceof Error ? err.message : ""
|
||
)![1]!;
|
||
}
|
||
throw new Error(`expected parseFlow to reject: ${yaml}`);
|
||
};
|
||
|
||
expect(
|
||
fieldNamed('steps:\n - await: { text: { in: { id: "{{output:row}}" }, contains: Done } }\n')
|
||
).toBe("await.text.in.id");
|
||
expect(
|
||
fieldNamed('steps:\n - await: { text: { in: total, contains: "{{output:sum}}" } }\n')
|
||
).toBe("await.text.contains");
|
||
expect(fieldNamed('steps:\n - assert: { visible: { id: "{{output:row}}" } }\n')).toBe(
|
||
"assert.visible.id"
|
||
);
|
||
expect(
|
||
fieldNamed(
|
||
'steps:\n - when: { text: { in: total, equals: "{{output:sum}}" } }\n steps:\n - echo: hi\n'
|
||
)
|
||
).toBe("when.text.equals");
|
||
expect(
|
||
fieldNamed('steps:\n - scroll-to: { target: { id: "{{output:row}}" }, direction: down }\n')
|
||
).toBe("scroll-to.target.id");
|
||
});
|
||
|
||
it("addresses a relation scope apart from the target it narrows", () => {
|
||
const locatorNamed = (yaml: string): string => {
|
||
try {
|
||
parseFlow(yaml);
|
||
} catch (err) {
|
||
return /\): (.*?) uses unsupported template syntax/.exec(
|
||
err instanceof Error ? err.message : ""
|
||
)![1]!;
|
||
}
|
||
throw new Error(`expected parseFlow to reject: ${yaml}`);
|
||
};
|
||
|
||
expect(
|
||
locatorNamed(
|
||
'steps:\n - await: { visible: { text: Ok, within: { text: "{{output:x}}" } } }\n'
|
||
)
|
||
).toBe("`await.visible.within.text`");
|
||
expect(
|
||
locatorNamed('steps:\n - tap: { id: a, within: { id: b, after: { id: "{{output:x}}" } } }\n')
|
||
).toBe(
|
||
"`tap.within.after.id` (spelled `tap.on.within.after.id` if the target sits under `on:`)"
|
||
);
|
||
});
|
||
|
||
it("addresses a gesture target under `on:` whenever the step is known to spell it there", () => {
|
||
const locatorNamed = (yaml: string): string => {
|
||
try {
|
||
parseFlow(yaml);
|
||
} catch (err) {
|
||
return /\): (.*?) uses unsupported template syntax/.exec(
|
||
err instanceof Error ? err.message : ""
|
||
)![1]!;
|
||
}
|
||
throw new Error(`expected parseFlow to reject: ${yaml}`);
|
||
};
|
||
|
||
// An option beside the target proves the options form; `pinch` and
|
||
// `rotate` have no other form. Each of these names one path.
|
||
expect(locatorNamed('steps:\n - tap: { on: { id: "{{output:row}}" }, times: 2 }\n')).toBe(
|
||
"`tap.on.id`"
|
||
);
|
||
expect(
|
||
locatorNamed('steps:\n - long-press: { on: { id: "{{output:row}}" }, duration: 900 }\n')
|
||
).toBe("`long-press.on.id`");
|
||
expect(locatorNamed('steps:\n - pinch: { on: { id: "{{output:map}}" }, scale: 2 }\n')).toBe(
|
||
"`pinch.on.id`"
|
||
);
|
||
expect(locatorNamed('steps:\n - rotate: { on: { id: "{{output:map}}" }, by: 45 }\n')).toBe(
|
||
"`rotate.on.id`"
|
||
);
|
||
});
|
||
|
||
it("names both gesture spellings when the parse cannot tell them apart", () => {
|
||
// A `tap`/`long-press` step with no option beside its target parses the
|
||
// same from either form, so exactly one of these two paths is in the
|
||
// author's file and the parse cannot say which. `times: 1` and an omitted
|
||
// `duration` are the shapes that reach this: both normalize away.
|
||
const locatorNamed = (yaml: string): string => {
|
||
try {
|
||
parseFlow(yaml);
|
||
} catch (err) {
|
||
return /\): (.*?) uses unsupported template syntax/.exec(
|
||
err instanceof Error ? err.message : ""
|
||
)![1]!;
|
||
}
|
||
throw new Error(`expected parseFlow to reject: ${yaml}`);
|
||
};
|
||
|
||
expect(locatorNamed('steps:\n - tap: { id: "{{output:row}}" }\n')).toBe(
|
||
"`tap.id` (spelled `tap.on.id` if the target sits under `on:`)"
|
||
);
|
||
expect(locatorNamed('steps:\n - tap: { on: { id: "{{output:row}}" }, times: 1 }\n')).toBe(
|
||
"`tap.id` (spelled `tap.on.id` if the target sits under `on:`)"
|
||
);
|
||
expect(locatorNamed('steps:\n - long-press: { on: { id: "{{output:row}}" } }\n')).toBe(
|
||
"`long-press.id` (spelled `long-press.on.id` if the target sits under `on:`)"
|
||
);
|
||
});
|
||
|
||
it("names the constraint a bare-string target parses into", () => {
|
||
const locatorNamed = (yaml: string): string => {
|
||
try {
|
||
parseFlow(yaml);
|
||
} catch (err) {
|
||
return /\): (.*?) uses unsupported template syntax/.exec(
|
||
err instanceof Error ? err.message : ""
|
||
)![1]!;
|
||
}
|
||
throw new Error(`expected parseFlow to reject: ${yaml}`);
|
||
};
|
||
|
||
expect(locatorNamed('steps:\n - tap: "{{output:row}}"\n')).toBe(
|
||
"`tap.text` (spelled `tap.on.text` if the target sits under `on:`)"
|
||
);
|
||
expect(
|
||
locatorNamed('steps:\n - scroll-to: { target: "{{output:row}}", direction: down }\n')
|
||
).toBe("`scroll-to.target.text`");
|
||
});
|
||
|
||
it("names the step, so a reference inside a block says which one", () => {
|
||
let message = "";
|
||
try {
|
||
parseFlow(
|
||
'steps:\n - echo: first\n - when: { visible: { id: row } }\n steps:\n - echo: ok\n - echo: "{{output:user.id}}"\n'
|
||
);
|
||
} catch (err) {
|
||
message = err instanceof Error ? err.message : String(err);
|
||
}
|
||
expect(message).toContain("Step 2.2 (`echo`)");
|
||
});
|
||
|
||
it("addresses a tool arg by its own path through the args", () => {
|
||
let message = "";
|
||
try {
|
||
parseFlow(
|
||
'steps:\n - tool: run-sequence\n args: { steps: [{ args: { text: "{{output:code}}" } }] }\n'
|
||
);
|
||
} catch (err) {
|
||
message = err instanceof Error ? err.message : String(err);
|
||
}
|
||
expect(message).toContain("`args.steps[0].args.text`");
|
||
});
|
||
|
||
it("carries the parser's own entry failure code", () => {
|
||
let signal;
|
||
try {
|
||
parseFlow('steps:\n - echo: "{{output:user.id}}"\n');
|
||
} catch (err) {
|
||
signal = getFailureSignal(err);
|
||
}
|
||
expect(signal?.error_code).toBe(FAILURE_CODES.FLOW_ENTRY_UNRECOGNIZED);
|
||
});
|
||
|
||
it("survives a cyclic tool-args anchor rather than blowing the stack", () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - tool: keyboard\n args: &a\n self: *a\n")
|
||
).not.toThrow();
|
||
expect(() =>
|
||
parseFlow(
|
||
'steps:\n - tool: keyboard\n args: &a\n self: *a\n text: "{{output:code}}"\n'
|
||
)
|
||
).toThrow(/unsupported template syntax/);
|
||
});
|
||
|
||
it("reaches a leaf inside the two containers own properties do not show", () => {
|
||
expect(() =>
|
||
parseFlow(
|
||
'%YAML 1.1\n---\nsteps:\n - tool: t\n args:\n inner: !!set\n ? "{{output:x}}"\n'
|
||
)
|
||
).toThrow(/`args.inner` uses unsupported template syntax/);
|
||
expect(() =>
|
||
parseFlow(
|
||
'%YAML 1.1\n---\nsteps:\n - tool: t\n args: !!omap\n - k: "{{output:x}}"\n'
|
||
)
|
||
).toThrow(/`args.k` uses unsupported template syntax/);
|
||
});
|
||
|
||
it("leaves fields off the supported list alone", () => {
|
||
expect(parseFlow('steps:\n - launch: "com.acme.{{output:app}}"\n').steps[0]).toEqual({
|
||
kind: "launch",
|
||
app: "com.acme.{{output:app}}",
|
||
});
|
||
expect(
|
||
parseFlow(
|
||
'steps:\n - launch: { chromium: { path: ./app, args: ["--seed={{output:order.id}}"] } }\n'
|
||
).steps[0]
|
||
).toEqual({
|
||
kind: "launch",
|
||
app: { chromium: { path: "./app", args: ["--seed={{output:order.id}}"] } },
|
||
});
|
||
expect(parseFlow('steps:\n - run: "{{output:x}}/login.yaml"\n').steps[0]).toEqual({
|
||
kind: "run",
|
||
flow: "{{output:x}}/login.yaml",
|
||
});
|
||
});
|
||
|
||
it("refuses the three unscanned names whose own charset already forbids one", () => {
|
||
expect(() => parseFlow('steps:\n - run: "{{output:x}}"\n')).toThrow(/must end in .yaml/);
|
||
expect(() => parseFlow('steps:\n - script: { path: "{{output:x}}.mjs" }\n')).toThrow(
|
||
/filename must match/
|
||
);
|
||
expect(() => parseFlow('steps:\n - snapshot: "{{output:x}}"\n')).toThrow(
|
||
/snapshot name .* must match/
|
||
);
|
||
});
|
||
|
||
it("cuts a long offending value in the message rather than quoting all of it", () => {
|
||
const filler = "x".repeat(400);
|
||
let message = "";
|
||
try {
|
||
parseFlow(`steps:\n - echo: "{{output:user.id}}${filler}"\n`);
|
||
} catch (err) {
|
||
message = err instanceof Error ? err.message : String(err);
|
||
}
|
||
const quoted = /Replace it with the literal value the step needs: "(.*)"$/s.exec(message)?.[1];
|
||
expect(quoted).toBeDefined();
|
||
expect(quoted!.endsWith("…")).toBe(true);
|
||
// `MAX_ENTRY_RENDER_CHARS` (200) plus the ellipsis that replaces the rest.
|
||
expect(quoted!.length).toBe(201);
|
||
expect(message.length).toBeLessThan(1000);
|
||
});
|
||
|
||
it("leaves a pattern alone, at both levels that spell one", () => {
|
||
// A regular expression is not a literal — a `{{` in one is a
|
||
// quantifier-shaped sequence the author meant — so no regex is on the
|
||
// supported list. The marker is UNESCAPED in both patterns here: an escaped
|
||
// `\\{\\{output:` does not contain the marker at all, so it would pass
|
||
// whether or not the field were scanned, and pin nothing.
|
||
expect(parseFlow('steps:\n - tap: { text: { matches: "{{output:.*" } }\n').steps[0]).toEqual({
|
||
kind: "tap",
|
||
selector: { textMatches: "{{output:.*" },
|
||
});
|
||
expect(
|
||
parseFlow('steps:\n - assert: { text: { in: total, matches: "{{output:.*" } }\n').steps[0]
|
||
).toMatchObject({ kind: "assert", expectedText: "{{output:.*", textMatch: "matches" });
|
||
});
|
||
|
||
it("refuses a pattern in a `when` guard, the one context where it fails silently", () => {
|
||
// The exemption above holds where an unmatchable pattern is LOUD: a tap
|
||
// finds nothing, an assert fails. A `when` guard that matches nothing is
|
||
// simply not met — the block is skipped and the run is green — so the two
|
||
// regex spellings are scanned there, exactly as the `{{secret:` guard in
|
||
// parseWhenCondition scans them.
|
||
const fieldNamed = (yaml: string): string => {
|
||
try {
|
||
parseFlow(yaml);
|
||
} catch (err) {
|
||
return /`([^`]+)` uses unsupported template syntax/.exec(
|
||
err instanceof Error ? err.message : ""
|
||
)![1]!;
|
||
}
|
||
throw new Error(`expected parseFlow to reject: ${yaml}`);
|
||
};
|
||
|
||
expect(
|
||
fieldNamed(
|
||
'steps:\n - when: { visible: { text: { matches: "{{output:x}}" } } }\n steps:\n - echo: hi\n'
|
||
)
|
||
).toBe("when.visible.text.matches");
|
||
expect(
|
||
fieldNamed(
|
||
'steps:\n - when: { text: { in: { text: Total }, matches: "{{output:sum}}" } }\n steps:\n - echo: hi\n'
|
||
)
|
||
).toBe("when.text.matches");
|
||
// A pattern in a relational scope of the guard degrades it the same way.
|
||
expect(
|
||
fieldNamed(
|
||
'steps:\n - when: { visible: { text: Ok, within: { text: { matches: "{{output:x}}" } } } }\n steps:\n - echo: hi\n'
|
||
)
|
||
).toBe("when.visible.within.text.matches");
|
||
});
|
||
|
||
it("does not confuse a secret placeholder for one", () => {
|
||
expect(
|
||
parseFlow('steps:\n - type: { into: { id: pw }, text: "{{secret:APP_PASSWORD}}" }\n')
|
||
.steps[0]
|
||
).toMatchObject({ kind: "type", text: "{{secret:APP_PASSWORD}}" });
|
||
});
|
||
});
|
||
|
||
// The parser's block list and blockSteps' runtime answer are the same claim
|
||
// asked twice; a directive only one of them knows drops its whole block from
|
||
// every skip expansion and from the upload preflight, silently.
|
||
describe("block directives", () => {
|
||
it.each(BLOCK_DIRECTIVE_KEYS)("%s parses to its own kind and yields its children", (key) => {
|
||
const { yaml, children } = BLOCK_DIRECTIVE_FLOWS[key];
|
||
const step = parseFlow(yaml).steps[0]!;
|
||
expect(step.kind).toBe(key);
|
||
expect(blockSteps(step)).toEqual(children);
|
||
});
|
||
|
||
it.each(BLOCK_DIRECTIVE_KEYS)("%s rejects an unknown sibling key with its own message", (key) => {
|
||
const { yaml, message } = BLOCK_DIRECTIVE_SIBLING_REJECTIONS[key];
|
||
let thrown: unknown;
|
||
try {
|
||
parseFlow(yaml);
|
||
} catch (err) {
|
||
thrown = err;
|
||
}
|
||
expect(thrown, "the unknown sibling was silently accepted").toBeInstanceOf(Error);
|
||
expect((thrown as Error).message).toContain(message);
|
||
expect(getFailureSignal(thrown)?.error_code).toBe(FAILURE_CODES.FLOW_ENTRY_UNRECOGNIZED);
|
||
});
|
||
|
||
it("returns undefined for a leaf step", () => {
|
||
const step = parseFlow("steps:\n - tap: Buy\n").steps[0]!;
|
||
expect(blockSteps(step)).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ── chromium launch (app path) ───────────────────────────────────────
|
||
|
||
describe("chromium launch parsing", () => {
|
||
it("parses a chromium launch with a bare-string app path", async () => {
|
||
const flow = parseFlow("steps:\n - launch: { chromium: ./app }\n");
|
||
expect(flow.steps).toEqual([{ kind: "launch", app: { chromium: "./app" } }]);
|
||
});
|
||
|
||
it("parses a chromium launch with a { path, args } map", async () => {
|
||
const flow = parseFlow("steps:\n - launch: { chromium: { path: ./app, args: [--e2e] } }\n");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "launch", app: { chromium: { path: "./app", args: ["--e2e"] } } },
|
||
]);
|
||
});
|
||
|
||
it("parses a mixed per-platform launch (ios id + chromium path)", async () => {
|
||
const flow = parseFlow("steps:\n - launch: { ios: com.acme.app, chromium: ./app }\n");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "launch", app: { ios: "com.acme.app", chromium: "./app" } },
|
||
]);
|
||
});
|
||
|
||
it("round-trips a chromium { path, args } launch through YAML", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{ kind: "launch", app: { chromium: { path: "/abs/app", args: ["--foo", "--bar"] } } },
|
||
],
|
||
};
|
||
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
|
||
});
|
||
|
||
it("rejects a chromium map with no path", async () => {
|
||
expect(() => parseFlow("steps:\n - launch: { chromium: { args: [--e2e] } }\n")).toThrow(
|
||
/launch needs/
|
||
);
|
||
});
|
||
|
||
it("rejects a chromium map with non-string args", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - launch: { chromium: { path: ./app, args: [1, 2] } }\n")
|
||
).toThrow(/launch needs/);
|
||
});
|
||
});
|
||
|
||
describe("chromiumLaunchSpec", () => {
|
||
it("reads a bare-string launch as the app path", async () => {
|
||
expect(chromiumLaunchSpec("./app")).toEqual({ path: "./app" });
|
||
});
|
||
|
||
it("reads a chromium string value as the path", async () => {
|
||
expect(chromiumLaunchSpec({ chromium: "./app" })).toEqual({ path: "./app" });
|
||
});
|
||
|
||
it("reads a chromium { path, args } value", async () => {
|
||
expect(chromiumLaunchSpec({ chromium: { path: "./app", args: ["--e2e"] } })).toEqual({
|
||
path: "./app",
|
||
args: ["--e2e"],
|
||
});
|
||
});
|
||
|
||
it("returns null when no chromium target is declared", async () => {
|
||
expect(chromiumLaunchSpec({ ios: "com.acme.app" })).toBeNull();
|
||
expect(chromiumLaunchSpec(undefined)).toBeNull();
|
||
});
|
||
|
||
it("appIdForPlatform returns the chromium path (the runner's declared-target guard)", async () => {
|
||
expect(appIdForPlatform({ chromium: { path: "./app", args: ["--e2e"] } }, "chromium")).toBe(
|
||
"./app"
|
||
);
|
||
expect(appIdForPlatform({ chromium: "./app" }, "chromium")).toBe("./app");
|
||
expect(appIdForPlatform({ ios: "com.acme.app" }, "chromium")).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── native shorthand ─────────────────────────────────────────────────
|
||
|
||
describe("native launch shorthand", () => {
|
||
it("parses a native-only launch and round-trips it", async () => {
|
||
const flow = parseFlow("steps:\n - launch: { native: com.acme.app }\n");
|
||
expect(flow.steps).toEqual([{ kind: "launch", app: { native: "com.acme.app" } }]);
|
||
expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps);
|
||
});
|
||
|
||
it("parses native alongside a per-platform override and a chromium path", async () => {
|
||
const flow = parseFlow(
|
||
"steps:\n - launch: { native: com.acme.app, android: com.acme.app.debug, chromium: ./app }\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "launch",
|
||
app: { native: "com.acme.app", android: "com.acme.app.debug", chromium: "./app" },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("rejects an empty native id", async () => {
|
||
expect(() => parseFlow('steps:\n - launch: { native: "" }\n')).toThrow(/launch needs/);
|
||
});
|
||
|
||
it("appIdForPlatform falls back to native for installed platforms, override wins", async () => {
|
||
const app = { native: "com.acme.app", android: "com.acme.app.debug" };
|
||
// native fills in for platforms without a specific key…
|
||
expect(appIdForPlatform(app, "ios")).toBe("com.acme.app");
|
||
expect(appIdForPlatform(app, "vega")).toBe("com.acme.app");
|
||
// …and a specific key overrides it.
|
||
expect(appIdForPlatform(app, "android")).toBe("com.acme.app.debug");
|
||
});
|
||
|
||
it("native never applies to chromium (chromium takes a path, not an id)", async () => {
|
||
expect(appIdForPlatform({ native: "com.acme.app" }, "chromium")).toBeNull();
|
||
expect(chromiumLaunchSpec({ native: "com.acme.app" })).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── Recording sessions ───────────────────────────────────────────────
|
||
|
||
// Recordings live in a map keyed by the resolved flow file path, so a session
|
||
// has no identity beyond (project_root, name) — two agents recording at once
|
||
// must never write into each other's take. What is isolated is the artifact,
|
||
// not the fact that a recording exists: the not-found message deliberately
|
||
// names the other live flows in the caller's own project and counts the rest,
|
||
// and the two cases below pin that disclosure as bounded rather than absent.
|
||
describe("recording sessions", () => {
|
||
beforeEach(() => {
|
||
__resetRecordingsForTesting();
|
||
});
|
||
|
||
const emptyFlow = (): FlowFile => ({ executionPrerequisite: "", steps: [] });
|
||
|
||
const start = (projectRoot: string, name: string, flow: FlowFile = emptyFlow()) =>
|
||
startRecordingSession({
|
||
name,
|
||
projectRoot,
|
||
persist: "host",
|
||
filePath: getFlowPath(projectRoot, name),
|
||
flow,
|
||
});
|
||
|
||
it("throws when the key has no recording", async () => {
|
||
await expect(requireRecordingSession("/tmp/proj-a", "my-flow")).rejects.toThrow(
|
||
/No active recording for flow "my-flow"/
|
||
);
|
||
});
|
||
|
||
it("classifies the not-found throw as FLOW_NO_ACTIVE_RECORDING", async () => {
|
||
let caught: unknown;
|
||
try {
|
||
await requireRecordingSession("/tmp/proj-a", "my-flow");
|
||
} catch (err) {
|
||
caught = err;
|
||
}
|
||
expect(getFailureSignal(caught)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING);
|
||
});
|
||
|
||
it("names the asked-for key and this project's live recordings in the not-found message", async () => {
|
||
// With concurrent recordings the usual cause is a typo or the wrong
|
||
// project_root; the agent can only self-correct if it sees the live keys.
|
||
await start("/tmp/proj-a", "checkout");
|
||
await start("/tmp/proj-b", "login");
|
||
await expect(requireRecordingSession("/tmp/proj-a", "chekout")).rejects.toThrow(
|
||
/No active recording for flow "chekout" in \/tmp\/proj-a\./
|
||
);
|
||
await expect(requireRecordingSession("/tmp/proj-a", "chekout")).rejects.toThrow(
|
||
/Active recordings: "checkout" \(plus 1 in other projects\)\./
|
||
);
|
||
});
|
||
|
||
it("counts other projects' recordings without naming them", async () => {
|
||
// A tool-server bound beyond loopback serves unrelated callers; another
|
||
// project's flow names and absolute paths are not this caller's to see.
|
||
await start("/tmp/proj-b", "login");
|
||
await start("/tmp/proj-c", "secret-onboarding");
|
||
const message = await (async () => {
|
||
try {
|
||
await requireRecordingSession("/tmp/proj-a", "my-flow");
|
||
} catch (err) {
|
||
return (err as Error).message;
|
||
}
|
||
throw new Error("expected a throw");
|
||
})();
|
||
expect(message).toMatch(
|
||
/Active recordings: none in this project \(plus 2 in other projects\)\./
|
||
);
|
||
expect(message).not.toContain("login");
|
||
expect(message).not.toContain("secret-onboarding");
|
||
expect(message).not.toContain("/tmp/proj-b");
|
||
expect(message).not.toContain("/tmp/proj-c");
|
||
});
|
||
|
||
it("treats a differently-spelled but identical root as THIS project", async () => {
|
||
// The partition compares path.join-normalized flows dirs, not raw strings.
|
||
// A caller that spells its own root with a trailing slash must still be
|
||
// shown its own live recordings — a strict === would answer "none in this
|
||
// project (plus 1 in other projects)", degrading the message in exactly the
|
||
// wrong-project_root case it exists to diagnose. Every other test here
|
||
// spells both sides identically, so only this one separates the two.
|
||
await start("/tmp/proj-a", "checkout");
|
||
const message = await (async () => {
|
||
try {
|
||
await requireRecordingSession("/tmp/proj-a/", "chekout");
|
||
} catch (err) {
|
||
return (err as Error).message;
|
||
}
|
||
throw new Error("expected a throw");
|
||
})();
|
||
expect(message).toMatch(/Active recordings: "checkout"\./);
|
||
expect(message).not.toContain("other projects");
|
||
});
|
||
|
||
it("does not tell the agent to just call flow-start-recording", async () => {
|
||
// This message is reached for a key that was never started, but equally for
|
||
// one that was finished, superseded, or dropped by the concurrency cap —
|
||
// and in those cases the flow file on disk is fully populated. Naming
|
||
// flow-start-recording as the fix destroys it, because it truncates
|
||
// unconditionally and reports no `restarted` when no session was replaced.
|
||
const message = await (async () => {
|
||
try {
|
||
await requireRecordingSession("/tmp/proj-a", "finished-earlier");
|
||
} catch (err) {
|
||
return (err as Error).message;
|
||
}
|
||
throw new Error("expected a throw");
|
||
})();
|
||
expect(message).toContain("truncates");
|
||
expect(message).toMatch(/record under a fresh name|copy it aside/);
|
||
expect(message).not.toMatch(/Call flow-start-recording first/);
|
||
});
|
||
|
||
it('reports "none in this project" when nothing is being recorded', async () => {
|
||
await expect(requireRecordingSession("/tmp/proj-a", "my-flow")).rejects.toThrow(
|
||
/Active recordings: none in this project\./
|
||
);
|
||
});
|
||
|
||
it("returns the session that was started for that key", async () => {
|
||
await start("/tmp/proj-a", "my-flow");
|
||
const session = await requireRecordingSession("/tmp/proj-a", "my-flow");
|
||
expect(session.name).toBe("my-flow");
|
||
expect(session.projectRoot).toBe("/tmp/proj-a");
|
||
expect(session.persist).toBe("host");
|
||
expect(session.filePath).toBe(getFlowPath("/tmp/proj-a", "my-flow"));
|
||
});
|
||
|
||
it("getRecordingSession returns undefined for a key with no recording", async () => {
|
||
expect(await getRecordingSession("/tmp/proj-a", "my-flow")).toBeUndefined();
|
||
});
|
||
|
||
it("getRecordingSession returns the live session", async () => {
|
||
await start("/tmp/proj-a", "my-flow");
|
||
expect((await getRecordingSession("/tmp/proj-a", "my-flow"))?.name).toBe("my-flow");
|
||
});
|
||
|
||
it("clearRecordingSession removes only that key", async () => {
|
||
await start("/tmp/proj-a", "my-flow");
|
||
await start("/tmp/proj-a", "other-flow");
|
||
clearRecordingSession(await requireRecordingSession("/tmp/proj-a", "my-flow"));
|
||
expect(await getRecordingSession("/tmp/proj-a", "my-flow")).toBeUndefined();
|
||
await expect(requireRecordingSession("/tmp/proj-a", "my-flow")).rejects.toThrow(
|
||
/No active recording for flow "my-flow"/
|
||
);
|
||
// The unrelated recording is untouched.
|
||
expect((await requireRecordingSession("/tmp/proj-a", "other-flow")).name).toBe("other-flow");
|
||
});
|
||
|
||
it("clearRecordingSession deletes by the key the session HOLDS, not a fresh resolution", async () => {
|
||
// The same choice appendStepToFlow documents. Re-resolving the spelling
|
||
// looks up a key the map may no longer hold once the flow file's identity
|
||
// has moved under the session — a symlink repointed mid-recording — so the
|
||
// delete missed silently and the finish reported success while the session
|
||
// stayed live, unfinishable, and holding the key against its own restart.
|
||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clear-moved-key-"));
|
||
try {
|
||
const root = path.join(dir, "proj");
|
||
const flows = path.join(root, ".argent", "flows");
|
||
await fs.mkdir(flows, { recursive: true });
|
||
const first = path.join(dir, "first.yaml");
|
||
const second = path.join(dir, "second.yaml");
|
||
for (const f of [first, second]) await fs.writeFile(f, "steps: []\n", "utf8");
|
||
const link = path.join(flows, "shared.yaml");
|
||
await fs.symlink(first, link);
|
||
|
||
await startRecordingSession({
|
||
name: "shared",
|
||
projectRoot: root,
|
||
persist: "host",
|
||
filePath: link,
|
||
flow: emptyFlow(),
|
||
});
|
||
const session = (await getRecordingSession(root, "shared"))!;
|
||
|
||
await fs.rm(link);
|
||
await fs.symlink(second, link);
|
||
// The spelling now resolves to a different file entirely.
|
||
expect(await getRecordingSession(root, "shared")).toBeUndefined();
|
||
|
||
clearRecordingSession(session);
|
||
expect(listActiveRecordings()).toEqual([]);
|
||
} finally {
|
||
await fs.rm(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("keeps same-named recordings under different project roots independent", async () => {
|
||
await start("/tmp/proj-a", "my-flow", { executionPrerequisite: "A", steps: [] });
|
||
await start("/tmp/proj-b", "my-flow", { executionPrerequisite: "B", steps: [] });
|
||
expect(
|
||
(await requireRecordingSession("/tmp/proj-a", "my-flow")).flow.executionPrerequisite
|
||
).toBe("A");
|
||
expect(
|
||
(await requireRecordingSession("/tmp/proj-b", "my-flow")).flow.executionPrerequisite
|
||
).toBe("B");
|
||
// Finishing one leaves the other recording.
|
||
clearRecordingSession(await requireRecordingSession("/tmp/proj-a", "my-flow"));
|
||
expect(await getRecordingSession("/tmp/proj-a", "my-flow")).toBeUndefined();
|
||
expect(
|
||
(await requireRecordingSession("/tmp/proj-b", "my-flow")).flow.executionPrerequisite
|
||
).toBe("B");
|
||
});
|
||
|
||
it("returns null when starting a recording on a free key", async () => {
|
||
expect(await start("/tmp/proj-a", "my-flow")).toBeNull();
|
||
// A second, unrelated recording is the common concurrent case — not a replace.
|
||
expect(await start("/tmp/proj-a", "other-flow")).toBeNull();
|
||
expect(await start("/tmp/proj-b", "my-flow")).toBeNull();
|
||
});
|
||
|
||
it("returns the replaced session when re-recording the same key", async () => {
|
||
await start("/tmp/proj-a", "my-flow", { executionPrerequisite: "first take", steps: [] });
|
||
const replaced = await start("/tmp/proj-a", "my-flow", {
|
||
executionPrerequisite: "second take",
|
||
steps: [],
|
||
});
|
||
expect(replaced?.flow.executionPrerequisite).toBe("first take");
|
||
// The later take wins — one key, one writer.
|
||
expect(
|
||
(await requireRecordingSession("/tmp/proj-a", "my-flow")).flow.executionPrerequisite
|
||
).toBe("second take");
|
||
});
|
||
|
||
it("evicts the least recently USED recording, not the oldest one", async () => {
|
||
// The cap is a leak backstop, but which entry it drops matters: evicting a
|
||
// recording an agent is actively using would strand its steps. Fill past
|
||
// the cap, touching the first-registered key just before the overflow — it
|
||
// must survive and the untouched next-oldest must go. A FIFO eviction fails
|
||
// this deterministically.
|
||
//
|
||
// It does NOT reliably catch an LRU keyed on a millisecond clock: that only
|
||
// ties when the whole fill and the touch land inside one millisecond, which
|
||
// holds when this file runs alone but not under full-suite load. The
|
||
// counter's tie-freedom is argued at `touch()` rather than pinned here.
|
||
const cap = MAX_RECORDINGS;
|
||
for (let i = 0; i < cap; i++) await start("/tmp/proj-a", `flow-${i}`);
|
||
expect(listActiveRecordings()).toHaveLength(cap);
|
||
|
||
await requireRecordingSession("/tmp/proj-a", "flow-0"); // now most-recently-used
|
||
await start("/tmp/proj-a", "overflow");
|
||
|
||
const live = new Set(listActiveRecordings().map((r) => r.name));
|
||
expect(live.size).toBe(cap);
|
||
expect(live.has("flow-0")).toBe(true); // touched, so kept
|
||
expect(live.has("flow-1")).toBe(false); // untouched and now the oldest use
|
||
expect(live.has("overflow")).toBe(true);
|
||
});
|
||
|
||
it("listActiveRecordings reflects what is live", async () => {
|
||
expect(listActiveRecordings()).toEqual([]);
|
||
await start("/tmp/proj-a", "my-flow", {
|
||
executionPrerequisite: "",
|
||
steps: [{ kind: "echo", message: "hi" }],
|
||
});
|
||
await start("/tmp/proj-b", "my-flow");
|
||
expect(listActiveRecordings()).toEqual([
|
||
{ name: "my-flow", projectRoot: "/tmp/proj-a", steps: 1 },
|
||
{ name: "my-flow", projectRoot: "/tmp/proj-b", steps: 0 },
|
||
]);
|
||
clearRecordingSession(await requireRecordingSession("/tmp/proj-a", "my-flow"));
|
||
expect(listActiveRecordings()).toEqual([
|
||
{ name: "my-flow", projectRoot: "/tmp/proj-b", steps: 0 },
|
||
]);
|
||
__resetRecordingsForTesting();
|
||
expect(listActiveRecordings()).toEqual([]);
|
||
});
|
||
|
||
it("keys a session by the normalized flow path, so a trailing slash rejoins it", async () => {
|
||
await start("/tmp/proj-a", "my-flow");
|
||
expect((await requireRecordingSession("/tmp/proj-a/", "my-flow")).name).toBe("my-flow");
|
||
expect(await start("/tmp/proj-a/", "my-flow")).not.toBeNull();
|
||
expect(listActiveRecordings()).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
// ── getFlowPath name validation ──────────────────────────────────────
|
||
|
||
describe("getFlowPath name validation", () => {
|
||
// Pure path math over two explicit inputs — the root is a parameter, never
|
||
// shared state, so two callers naming two projects can never collide.
|
||
const root = "/tmp/argent-flow-name-test";
|
||
|
||
it("accepts plain alphanumeric names", async () => {
|
||
expect(getFlowPath(root, "my-flow_1")).toBe(
|
||
path.join(root, ".argent", "flows", "my-flow_1.yaml")
|
||
);
|
||
});
|
||
|
||
it("normalizes a trailing slash on the project root", async () => {
|
||
// The flow path doubles as the recording-session key: a trailing slash must
|
||
// not mint a second identity for the same file.
|
||
expect(getFlowPath("/tmp/x/", "f")).toBe(getFlowPath("/tmp/x", "f"));
|
||
});
|
||
|
||
it("rejects path-traversal segments", async () => {
|
||
expect(() => getFlowPath(root, "../../etc/passwd")).toThrow(/Invalid flow name/);
|
||
expect(() => getFlowPath(root, "../foo")).toThrow(/Invalid flow name/);
|
||
});
|
||
|
||
it("rejects path separators", async () => {
|
||
expect(() => getFlowPath(root, "foo/bar")).toThrow(/Invalid flow name/);
|
||
expect(() => getFlowPath(root, "/abs/path")).toThrow(/Invalid flow name/);
|
||
});
|
||
|
||
it("rejects names with spaces or shell metacharacters", async () => {
|
||
expect(() => getFlowPath(root, "foo bar")).toThrow(/Invalid flow name/);
|
||
expect(() => getFlowPath(root, "foo;bar")).toThrow(/Invalid flow name/);
|
||
expect(() => getFlowPath(root, "foo$(id)")).toThrow(/Invalid flow name/);
|
||
});
|
||
|
||
it("rejects empty names", async () => {
|
||
expect(() => getFlowPath(root, "")).toThrow(/Invalid flow name/);
|
||
});
|
||
});
|
||
|
||
// PR #194 follow-up C: project_root must be absolute AND free of ".."
|
||
// segments (path.join collapses ".." and would relocate the flows dir).
|
||
describe("assertValidProjectRoot validation", () => {
|
||
it("rejects a relative project_root", async () => {
|
||
expect(() => assertValidProjectRoot("relative/path")).toThrow(/absolute path/);
|
||
});
|
||
|
||
it('rejects an absolute project_root containing ".." segments', async () => {
|
||
expect(() => assertValidProjectRoot("/a/../../../etc")).toThrow(/must not contain "\.\."/);
|
||
expect(() => assertValidProjectRoot("/home/user/../../root")).toThrow(
|
||
/must not contain "\.\."/
|
||
);
|
||
});
|
||
|
||
it("accepts a clean absolute project_root", async () => {
|
||
expect(() => assertValidProjectRoot("/tmp/argent-pr194-c-test")).not.toThrow();
|
||
});
|
||
});
|
||
|
||
// ── within (descendant) selector scoping ─────────────────────────────
|
||
|
||
describe("within selector scoping", () => {
|
||
it("parses a within scope on a tap selector", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { text: Delete, within: { id: profile-card } }\n");
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "tap",
|
||
selector: { text: "Delete", within: { identifier: "profile-card" } },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("a bare-string within stays loose (identifier-first, then text)", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { text: Delete, within: profile-card }\n");
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "tap",
|
||
selector: { text: "Delete", within: { text: "profile-card", loose: true } },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("within chains outward and round-trips exactly", async () => {
|
||
const flow: FlowFile = {
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{
|
||
kind: "tap",
|
||
selector: {
|
||
text: "Delete",
|
||
within: { identifier: "cards", within: { text: "Settings" } },
|
||
},
|
||
},
|
||
{
|
||
kind: "await",
|
||
condition: "visible",
|
||
selector: { text: "Saved", within: { text: "toast-area", loose: true } },
|
||
},
|
||
{
|
||
kind: "assert",
|
||
condition: "text",
|
||
selector: { identifier: "total", within: { role: "AXTable" } },
|
||
expectedText: "Total: 3",
|
||
textMatch: "equals",
|
||
},
|
||
],
|
||
};
|
||
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
|
||
});
|
||
|
||
it("serializes a loose within back to its bare-string spelling", async () => {
|
||
const yaml = serializeFlow({
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{
|
||
kind: "tap",
|
||
selector: { text: "Delete", within: { text: "profile-card", loose: true } },
|
||
},
|
||
],
|
||
});
|
||
expect(yaml).toContain("within: profile-card");
|
||
});
|
||
|
||
it("accepts the regex text matcher inside a within scope", async () => {
|
||
const flow = parseFlow(
|
||
"steps:\n - assert: { visible: { text: Delete, within: { text: { matches: '^Card \\d+$' } } } }\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "assert",
|
||
condition: "visible",
|
||
selector: { text: "Delete", within: { textMatches: "^Card \\d+$" } },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("rejects a selector that is ONLY a within scope", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { within: { id: card } }\n")).toThrow(
|
||
/still needs its own text\/id\/role/
|
||
);
|
||
});
|
||
|
||
it("rejects unknown keys inside a within scope, naming the nested slot", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { text: Delete, within: { idd: card } }\n")).toThrow(
|
||
/tap\.within: selector has unknown key `idd` \(did you mean `id`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects id+identifier both set inside a within scope", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - tap: { text: A, within: { id: x, identifier: x } }\n")
|
||
).toThrow(/`id` or `identifier` \(its alias\), not both/);
|
||
});
|
||
|
||
it("rejects a cyclic within alias via the depth cap", async () => {
|
||
const yaml = "steps:\n - tap: &s { text: Delete, within: *s }\n";
|
||
expect(() => parseFlow(yaml)).toThrow(/nest deeper than|cyclic YAML alias/);
|
||
});
|
||
|
||
it("rejects a within selector mixed with coordinates", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { within: { id: card }, x: 0.5, y: 0.5 }\n")).toThrow(
|
||
/takes a selector or x\/y coordinates, not both/
|
||
);
|
||
});
|
||
|
||
it("rejects a within key beside the tap options form", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - tap: { on: Photo, times: 2, within: { id: card } }\n")
|
||
).toThrow(/the tap options form takes a nested selector/);
|
||
});
|
||
|
||
it("within works in scroll-to's target while scroll-to's own within stays the container anchor", async () => {
|
||
const flow = parseFlow(
|
||
[
|
||
"steps:",
|
||
" - scroll-to:",
|
||
" target: { text: Delete, within: { id: cards } }",
|
||
" direction: down",
|
||
" within: { id: settings-list }",
|
||
].join("\n") + "\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "scroll-to",
|
||
target: { text: "Delete", within: { identifier: "cards" } },
|
||
direction: "down",
|
||
within: { identifier: "settings-list" },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("describeSelector renders the scope chain in parentheses", async () => {
|
||
expect(
|
||
describeSelector({
|
||
text: "Delete",
|
||
within: { identifier: "cards", within: { text: "Settings" } },
|
||
})
|
||
).toBe('text="Delete" within (id="cards" within (text="Settings"))');
|
||
});
|
||
|
||
it("when guards reject a {{secret:…}} placeholder hidden in a within scope", async () => {
|
||
expect(() =>
|
||
parseFlow(
|
||
[
|
||
"steps:",
|
||
" - when: { visible: { text: Delete, within: { text: '{{secret:TOKEN}}' } } }",
|
||
" steps:",
|
||
" - echo: hi",
|
||
].join("\n") + "\n"
|
||
)
|
||
).toThrow(/secret/);
|
||
});
|
||
});
|
||
|
||
// ── sibling scopes (`after`/`next`) and the `any` universal selector ──
|
||
|
||
describe("sibling selector scopes and the universal selector", () => {
|
||
it("parses `after` (CSS ~) and `next` (CSS +) scopes", async () => {
|
||
const flow = parseFlow(
|
||
[
|
||
"steps:",
|
||
" - tap: { role: Switch, next: { text: Wi-Fi } }",
|
||
" - assert: { visible: { role: Button, after: { text: Danger zone } } }",
|
||
].join("\n") + "\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "tap", selector: { role: "Switch", next: { text: "Wi-Fi" } } },
|
||
{
|
||
kind: "assert",
|
||
condition: "visible",
|
||
selector: { role: "Button", after: { text: "Danger zone" } },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("parses `any: true` paired with a scope and round-trips exactly", async () => {
|
||
const yaml =
|
||
[
|
||
"steps:",
|
||
" - tap: { any: true, next: { text: Airplane }, within: { id: row } }",
|
||
" - assert: { hidden: { any: true, within: { id: empty-state } } }",
|
||
].join("\n") + "\n";
|
||
const flow = parseFlow(yaml);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "tap",
|
||
selector: { any: true, within: { identifier: "row" }, next: { text: "Airplane" } },
|
||
},
|
||
{
|
||
kind: "assert",
|
||
condition: "hidden",
|
||
selector: { any: true, within: { identifier: "empty-state" } },
|
||
},
|
||
]);
|
||
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
|
||
});
|
||
|
||
it("a bare-string sibling scope stays loose, and serializes back to the bare spelling", async () => {
|
||
const flow = parseFlow("steps:\n - tap: { role: Switch, next: wifi-row }\n");
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "tap", selector: { role: "Switch", next: { text: "wifi-row", loose: true } } },
|
||
]);
|
||
expect(serializeFlow(flow)).toContain("next: wifi-row");
|
||
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
|
||
});
|
||
|
||
it("scopes combine and nest, round-tripping through YAML", async () => {
|
||
const flow = parseFlow(
|
||
[
|
||
"steps:",
|
||
" - tap:",
|
||
" role: Button",
|
||
" within: { id: cards }",
|
||
" after: { text: Danger, within: { id: cards } }",
|
||
].join("\n") + "\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{
|
||
kind: "tap",
|
||
selector: {
|
||
role: "Button",
|
||
within: { identifier: "cards" },
|
||
after: { text: "Danger", within: { identifier: "cards" } },
|
||
},
|
||
},
|
||
]);
|
||
expect(parseFlow(serializeFlow(flow))).toEqual(flow);
|
||
});
|
||
|
||
it("accepts the regex text matcher inside a sibling scope", async () => {
|
||
const flow = parseFlow(
|
||
"steps:\n - tap: { role: Switch, next: { text: { matches: '^Row \\d+$' } } }\n"
|
||
);
|
||
expect(flow.steps).toEqual([
|
||
{ kind: "tap", selector: { role: "Switch", next: { textMatches: "^Row \\d+$" } } },
|
||
]);
|
||
});
|
||
|
||
it("rejects a selector that is ONLY a sibling scope", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { after: { text: Danger } }\n")).toThrow(
|
||
/`after` only scopes where to look — the selector still needs its own text\/id\/role/
|
||
);
|
||
});
|
||
|
||
it("rejects `any: true` alongside the fields it would make redundant", async () => {
|
||
expect(() =>
|
||
parseFlow("steps:\n - tap: { any: true, role: Button, next: { text: Wi-Fi } }\n")
|
||
).toThrow(/already matches every element — drop it, or drop the `role`/);
|
||
});
|
||
|
||
it("rejects a bare `any: true` with no scope to narrow it", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { any: true }\n")).toThrow(
|
||
/matches every element on screen — pair it with a scope \(within\/after\/next\)/
|
||
);
|
||
});
|
||
|
||
it("rejects a non-`true` any value rather than reading it as a locator", async () => {
|
||
// Falsy AND truthy: a truthiness check would wave `any: 1` / `any: yes`
|
||
// through as the universal selector — a spelling no reader can predict and
|
||
// the serializer cannot reproduce.
|
||
for (const value of ["false", "1", "yes", "'true'", "0"]) {
|
||
expect(() => parseFlow(`steps:\n - tap: { any: ${value}, within: { id: card } }\n`)).toThrow(
|
||
/`any` takes only `true`/
|
||
);
|
||
}
|
||
});
|
||
|
||
it("rejects unknown keys inside a sibling scope, naming the nested slot", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { role: Switch, next: { roel: Button } }\n")).toThrow(
|
||
/tap\.next: selector has unknown key `roel` \(did you mean `role`\?\)/
|
||
);
|
||
});
|
||
|
||
it("rejects a cyclic sibling alias via the scope budget", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: &s { text: Delete, after: *s }\n")).toThrow(
|
||
/more than \d+ scopes|cyclic YAML alias/
|
||
);
|
||
});
|
||
|
||
it("bounds a selector's whole scope TREE, not just its depth", async () => {
|
||
// Three relations per level means a depth cap alone still admits 3^depth
|
||
// scopes — and the runner expands one alternative per combination of
|
||
// bare-string scopes, so a few hundred bytes of YAML would exhaust the heap
|
||
// before any tree is ever read. Six scopes down one branch is fine; the
|
||
// same six spread across branches must cost the same budget.
|
||
const chain = (n: number): string =>
|
||
Array.from({ length: n }, () => "{ id: c, within: ").join("") +
|
||
"{ id: leaf }" +
|
||
Array.from({ length: n }, () => " }").join("");
|
||
expect(() => parseFlow(`steps:\n - tap: { text: T, within: ${chain(5)} }\n`)).not.toThrow();
|
||
expect(() => parseFlow(`steps:\n - tap: { text: T, within: ${chain(6)} }\n`)).toThrow(
|
||
/more than 6 scopes/
|
||
);
|
||
// The branching form the depth cap alone would have let through: 3 + 9 = 12
|
||
// scopes across two levels, all shallow.
|
||
const branch = "{ id: b, within: x, after: y, next: z }";
|
||
expect(() =>
|
||
parseFlow(
|
||
`steps:\n - tap: { text: T, within: ${branch}, after: ${branch}, next: ${branch} }\n`
|
||
)
|
||
).toThrow(/more than 6 scopes/);
|
||
});
|
||
|
||
it("serializeFlow refuses an `any` selector the parser would reject on read-back", async () => {
|
||
// appendStep re-parses the whole file on every recorded step, so a selector
|
||
// that violates the parser's `any` rules must fail where it was built, not
|
||
// on some later append.
|
||
const step = (selector: Record<string, unknown>): Parameters<typeof serializeFlow>[0] => ({
|
||
executionPrerequisite: "",
|
||
steps: [{ kind: "tap", selector } as never],
|
||
});
|
||
expect(() => serializeFlow(step({ any: true }))).toThrow(/needs a scope/);
|
||
expect(() =>
|
||
serializeFlow(step({ any: true, text: "Save", within: { identifier: "c" } }))
|
||
).toThrow(/cannot be combined with text\/id\/role/);
|
||
expect(() => serializeFlow(step({ any: false, within: { identifier: "c" } }))).toThrow(
|
||
/takes only `true`/
|
||
);
|
||
expect(() => serializeFlow(step({ within: { identifier: "c" } }))).toThrow(
|
||
/only narrows where to look/
|
||
);
|
||
// The legal shape still round-trips.
|
||
const yaml = serializeFlow(step({ any: true, within: { identifier: "c" } }));
|
||
expect(parseFlow(yaml).steps).toEqual([
|
||
{ kind: "tap", selector: { any: true, within: { identifier: "c" } } },
|
||
]);
|
||
});
|
||
|
||
it("rejects a sibling-scoped selector mixed with coordinates or tap options", async () => {
|
||
expect(() => parseFlow("steps:\n - tap: { after: { id: card }, x: 0.5, y: 0.5 }\n")).toThrow(
|
||
/takes a selector or x\/y coordinates, not both/
|
||
);
|
||
expect(() =>
|
||
parseFlow("steps:\n - tap: { on: Photo, times: 2, next: { id: card } }\n")
|
||
).toThrow(/the tap options form takes a nested selector/);
|
||
});
|
||
|
||
it("a loose bare-string selector cannot carry a scope through serialization", async () => {
|
||
expect(() =>
|
||
serializeFlow({
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{ kind: "tap", selector: { text: "Delete", loose: true, after: { identifier: "hdr" } } },
|
||
],
|
||
})
|
||
).toThrow(/incompatible fields: after/);
|
||
});
|
||
|
||
it("names the missing scroll-to target instead of leaking a schema message", async () => {
|
||
// `within` is a selector key now, so this body reads like a scoped selector
|
||
// — it is actually the options map, missing its target.
|
||
for (const body of ["{ within: { id: list } }", "{ direction: up }", "{}"]) {
|
||
expect(() => parseFlow(`steps:\n - scroll-to: ${body}\n`)).toThrow(
|
||
/scroll-to needs a `target`/
|
||
);
|
||
}
|
||
// The sibling scopes are not step options at all.
|
||
expect(() => parseFlow("steps:\n - scroll-to: { target: Row, after: { id: hdr } }\n")).toThrow(
|
||
/unknown key `after` — allowed keys: target, direction, within/
|
||
);
|
||
// ...while they are welcome inside the target.
|
||
expect(() =>
|
||
parseFlow(
|
||
"steps:\n - scroll-to: { target: { text: Delete, after: { id: hdr } }, within: { id: list } }\n"
|
||
)
|
||
).not.toThrow();
|
||
});
|
||
|
||
it("describeSelector renders each scope, and `*` for the universal selector", async () => {
|
||
expect(describeSelector({ role: "Switch", next: { text: "Wi-Fi" } })).toBe(
|
||
'role="Switch" next (text="Wi-Fi")'
|
||
);
|
||
expect(
|
||
describeSelector({ any: true, within: { identifier: "row" }, after: { text: "Name" } })
|
||
).toBe('* within (id="row") after (text="Name")');
|
||
});
|
||
|
||
it("when guards reject a {{secret:…}} placeholder hidden in ANY scope", async () => {
|
||
// Every relation, so no branch of the walk can be skipped unnoticed.
|
||
for (const scope of ["within", "after", "next"]) {
|
||
expect(() =>
|
||
parseFlow(
|
||
[
|
||
"steps:",
|
||
` - when: { visible: { role: Switch, ${scope}: { text: '{{secret:TOKEN}}' } } }`,
|
||
" steps:",
|
||
" - echo: hi",
|
||
].join("\n") + "\n"
|
||
)
|
||
).toThrow(/secret/);
|
||
}
|
||
// ...including one buried two relations deep.
|
||
expect(() =>
|
||
parseFlow(
|
||
[
|
||
"steps:",
|
||
" - when: { visible: { role: Switch, after: { id: row, within: { text: '{{secret:TOKEN}}' } } } }",
|
||
" steps:",
|
||
" - echo: hi",
|
||
].join("\n") + "\n"
|
||
)
|
||
).toThrow(/secret/);
|
||
});
|
||
});
|
||
|
||
// ── countStepsOnDisk ─────────────────────────────────────────────────
|
||
|
||
// The count `flow-start-recording` reports for a take it is about to truncate.
|
||
// Its contract is the distinction between "0 steps" and "no answer": an empty
|
||
// take really did hold nothing, while an unreadable one is a loss of unknown
|
||
// size, and reporting the first for the second understates it in exactly the
|
||
// case that produced it.
|
||
describe("countStepsOnDisk", () => {
|
||
let dir: string;
|
||
|
||
beforeEach(async () => {
|
||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "count-steps-"));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await fs.rm(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
const write = async (content: string) => {
|
||
const file = path.join(dir, "flow.yaml");
|
||
await fs.writeFile(file, content, "utf8");
|
||
return file;
|
||
};
|
||
|
||
it("counts the steps a readable flow file holds", async () => {
|
||
const file = await write(
|
||
serializeFlow({
|
||
executionPrerequisite: "",
|
||
steps: [
|
||
{ kind: "echo", message: "one" },
|
||
{ kind: "echo", message: "two" },
|
||
{ kind: "echo", message: "three" },
|
||
],
|
||
})
|
||
);
|
||
expect(await countStepsOnDisk(file)).toBe(3);
|
||
});
|
||
|
||
it("counts an empty take as 0, which is a real answer", async () => {
|
||
const file = await write(serializeFlow({ executionPrerequisite: "", steps: [] }));
|
||
expect(await countStepsOnDisk(file)).toBe(0);
|
||
});
|
||
|
||
it("returns undefined for a file that does not exist", async () => {
|
||
expect(await countStepsOnDisk(path.join(dir, "absent.yaml"))).toBeUndefined();
|
||
});
|
||
|
||
it("returns undefined rather than 0 for YAML the parser rejects", async () => {
|
||
// A hand-edit can leave this behind, and `parseFlow("")` returning an empty
|
||
// flow with no error is the reason 0 cannot double as "unknown".
|
||
const file = await write("steps: [ this: is: not: a: flow\n");
|
||
expect(await countStepsOnDisk(file)).toBeUndefined();
|
||
});
|
||
|
||
it("returns undefined for a directory in the file's place", async () => {
|
||
const asDir = path.join(dir, "flow-dir.yaml");
|
||
await fs.mkdir(asDir);
|
||
expect(await countStepsOnDisk(asDir)).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("writeFlowFile failure hints", () => {
|
||
let root: string;
|
||
|
||
beforeEach(async () => {
|
||
root = await fs.mkdtemp(path.join(os.tmpdir(), "flow-write-hint-"));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
// Restore write permission first, or the recursive rm cannot descend.
|
||
for (const dir of [path.join(root, "vault"), path.join(root, ".argent", "flows")]) {
|
||
await fs.chmod(dir, 0o755).catch(() => {});
|
||
}
|
||
await fs.rm(root, { recursive: true, force: true });
|
||
});
|
||
|
||
/** Whether this process can be denied by mode bits at all (root cannot). */
|
||
async function modeBitsBite(dir: string): Promise<boolean> {
|
||
await fs.chmod(dir, 0o555);
|
||
const probe = path.join(dir, ".probe");
|
||
const denied = await fs
|
||
.writeFile(probe, "x", "utf8")
|
||
.then(() => false)
|
||
.catch(() => true);
|
||
if (!denied) await fs.rm(probe, { force: true });
|
||
return denied;
|
||
}
|
||
|
||
it("does not call the flow file a symlink when only an ANCESTOR is one", async () => {
|
||
// On macOS the temp dir is reached through /var -> /private/var, so the
|
||
// resolved swap directory differs from the spelled one for a flow file that
|
||
// is a perfectly ordinary regular file. Comparing the two spellings made
|
||
// every such failure claim a symlink and then contrast one directory with
|
||
// itself.
|
||
const flowsDir = path.join(root, ".argent", "flows");
|
||
await fs.mkdir(flowsDir, { recursive: true });
|
||
if (!(await modeBitsBite(flowsDir))) return;
|
||
|
||
const err = await writeNewFlowFile(path.join(flowsDir, "x.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
const message = (err as Error).message;
|
||
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED);
|
||
expect(message).toContain("must be writable");
|
||
expect(message).not.toMatch(/is a symlink/);
|
||
});
|
||
|
||
it("blames the name length, not the directory, on ENAMETOOLONG", async () => {
|
||
// The arm the hint was split for: an over-long flow name comes out of
|
||
// `rename` (the scratch name is short), and reporting it as a
|
||
// directory-permissions problem sent the reader looking for one that is not
|
||
// there.
|
||
const flowsDir = path.join(root, ".argent", "flows");
|
||
await fs.mkdir(flowsDir, { recursive: true });
|
||
|
||
const err = await writeNewFlowFile(
|
||
path.join(flowsDir, `${"n".repeat(400)}.yaml`),
|
||
"steps: []\n"
|
||
).catch((e: unknown) => e);
|
||
|
||
const message = (err as Error).message;
|
||
expect(message).toContain("(ENAMETOOLONG)");
|
||
expect(message).toContain("use a shorter name");
|
||
expect(message).not.toContain("must be writable");
|
||
});
|
||
|
||
it("names the missing VAULT directory when the link points into one", async () => {
|
||
// ENOENT out of the scratch write, in the directory the swap actually uses.
|
||
// Naming `.argent/flows` here would point at a directory that exists.
|
||
const flowsDir = path.join(root, ".argent", "flows");
|
||
await fs.mkdir(flowsDir, { recursive: true });
|
||
const absentVault = path.join(root, "no-such-vault");
|
||
await fs.symlink(path.join(absentVault, "shared.yaml"), path.join(flowsDir, "shared.yaml"));
|
||
|
||
const err = await writeNewFlowFile(path.join(flowsDir, "shared.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
const message = (err as Error).message;
|
||
expect(message).toContain("(ENOENT)");
|
||
expect(message).toContain(`${absentVault} does not exist`);
|
||
expect(message).toContain("shared.yaml is a symlink");
|
||
});
|
||
|
||
it("still points at the vault when the flow file really is a symlink", async () => {
|
||
// The case the clause exists for: naming `.argent/flows` here would send the
|
||
// reader to a directory that is already writable while the vault, the only
|
||
// unwritable thing in the picture, went unmentioned.
|
||
const flowsDir = path.join(root, ".argent", "flows");
|
||
const vault = path.join(root, "vault");
|
||
await fs.mkdir(flowsDir, { recursive: true });
|
||
await fs.mkdir(vault, { recursive: true });
|
||
await fs.writeFile(path.join(vault, "shared.yaml"), "steps: []\n", "utf8");
|
||
await fs.symlink(path.join(vault, "shared.yaml"), path.join(flowsDir, "shared.yaml"));
|
||
if (!(await modeBitsBite(vault))) return;
|
||
|
||
const err = await writeNewFlowFile(path.join(flowsDir, "shared.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
const message = (err as Error).message;
|
||
expect(message).toContain("shared.yaml is a symlink, so the write lands in");
|
||
expect(message).toContain(await fs.realpath(vault));
|
||
});
|
||
});
|
||
|
||
describe("flow file permissions across an atomic append", () => {
|
||
let root: string;
|
||
|
||
beforeEach(async () => {
|
||
root = await fs.mkdtemp(path.join(os.tmpdir(), "flow-mode-"));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await fs.rm(root, { recursive: true, force: true });
|
||
});
|
||
|
||
/** Whether mode bits can refuse this process at all (root ignores them). */
|
||
async function modeBitsBite(file: string): Promise<boolean> {
|
||
return fs
|
||
.access(file, fsConstants.W_OK)
|
||
.then(() => false)
|
||
.catch(() => true);
|
||
}
|
||
|
||
it("carries the flow file's mode across the swap", async () => {
|
||
// The scratch file is created under the process umask and rename carries
|
||
// ITS mode over, so without preserving it every append quietly rewrote the
|
||
// flow file's permissions to 0644.
|
||
const file = path.join(root, "flow.yaml");
|
||
await fs.writeFile(file, "steps: []\n", "utf8");
|
||
await fs.chmod(file, 0o600);
|
||
|
||
await writeNewFlowFile(file, "steps: []\n");
|
||
|
||
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
|
||
});
|
||
|
||
it("refuses to overwrite a read-only flow file", async () => {
|
||
// The swap needs permission on the DIRECTORY, so it would replace a
|
||
// `chmod 0444` file regardless — turning a plain write's EACCES into a
|
||
// silent success that also relaxed the mode.
|
||
const file = path.join(root, "flow.yaml");
|
||
await fs.writeFile(file, "steps: []\nkeep: me\n", "utf8");
|
||
await fs.chmod(file, 0o444);
|
||
if (!(await modeBitsBite(file))) return;
|
||
|
||
const err = await writeNewFlowFile(file, "steps: []\n").catch((e: unknown) => e);
|
||
|
||
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED);
|
||
expect((err as Error).message).toMatch(/not writable \(mode 0444\)/);
|
||
// And it really did not touch the file.
|
||
expect(await fs.readFile(file, "utf8")).toContain("keep: me");
|
||
});
|
||
|
||
it("leaves no scratch file behind when it refuses", async () => {
|
||
const flows = path.join(root, ".argent", "flows");
|
||
await fs.mkdir(flows, { recursive: true });
|
||
const file = path.join(flows, "flow.yaml");
|
||
await fs.writeFile(file, "steps: []\n", "utf8");
|
||
await fs.chmod(file, 0o444);
|
||
if (!(await modeBitsBite(file))) return;
|
||
|
||
await writeNewFlowFile(file, "steps: []\n").catch(() => {});
|
||
|
||
expect(await fs.readdir(flows)).toEqual(["flow.yaml"]);
|
||
});
|
||
|
||
it("still creates a flow file that does not exist yet", async () => {
|
||
// The control: nothing to preserve and nothing to be refused by.
|
||
const file = path.join(root, "fresh.yaml");
|
||
await writeNewFlowFile(file, "steps: []\n");
|
||
expect(await fs.readFile(file, "utf8")).toBe("steps: []\n");
|
||
});
|
||
});
|
||
|
||
describe("mkdirFailureHint arms", () => {
|
||
// The flows-directory half of writeNewFlowFile's classification. Only its
|
||
// wrapping was covered; each errno arm names a different cause, and the
|
||
// ENOTDIR one — a `project_root` that names a FILE — is the mistake the hint
|
||
// exists for.
|
||
let root: string;
|
||
|
||
beforeEach(async () => {
|
||
root = await fs.mkdtemp(path.join(os.tmpdir(), "flow-mkdir-hint-"));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await fs.chmod(root, 0o755).catch(() => {});
|
||
await fs.rm(root, { recursive: true, force: true });
|
||
});
|
||
|
||
it("blames a project_root that names a file, not a directory", async () => {
|
||
const asFile = path.join(root, "notadir");
|
||
await fs.writeFile(asFile, "", "utf8");
|
||
const flows = path.join(asFile, ".argent", "flows");
|
||
|
||
const err = await writeNewFlowFile(path.join(flows, "x.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED);
|
||
expect(getFailureSignal(err)?.failure_stage).toBe("flow_dir_create");
|
||
expect((err as Error).message).toContain("(ENOTDIR)");
|
||
expect((err as Error).message).toContain(
|
||
"check that project_root names a directory rather than a file"
|
||
);
|
||
});
|
||
|
||
it("blames the nearest existing parent when it is not writable", async () => {
|
||
await fs.chmod(root, 0o555);
|
||
if (
|
||
await fs.access(root, fsConstants.W_OK).then(
|
||
() => true,
|
||
() => false
|
||
)
|
||
)
|
||
return;
|
||
const flows = path.join(root, ".argent", "flows");
|
||
|
||
const err = await writeNewFlowFile(path.join(flows, "x.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
expect((err as Error).message).toMatch(/\((EACCES|EPERM)\)/);
|
||
expect((err as Error).message).toContain("nearest existing parent");
|
||
});
|
||
|
||
it("blames the name length when the path is too long for the filesystem", async () => {
|
||
// ENAMETOOLONG out of mkdir -p, which must not read as a permissions
|
||
// problem the user would then go and not find.
|
||
const tooLong = path.join(root, "d".repeat(512), ".argent", "flows");
|
||
|
||
const err = await writeNewFlowFile(path.join(tooLong, "x.yaml"), "steps: []\n").catch(
|
||
(e: unknown) => e
|
||
);
|
||
|
||
expect((err as Error).message).toContain("(ENAMETOOLONG)");
|
||
expect((err as Error).message).toContain("longer than this filesystem allows");
|
||
});
|
||
});
|