mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
abe5ff3c56
`when` is the only directive with a sibling `steps:` list, and four runner sites asked `kind === "when"` directly to decide whether a step has authored children to expand as skip lines. A second block directive would have had to remember all four, and a forgotten one silently drops a whole block from the report — exactly the invariant `reportBlockSkipped` exists to hold. This introduces `blockSteps(step)` as the single runtime predicate and `BLOCK_DIRECTIVE_KEYS` as its parse-time counterpart, and extracts the parse-side block plumbing (`assertBlockDepth`, `parseBlockSteps`) that a second directive must share — notably the depth cap, renamed `MAX_BLOCK_DEPTH`, which has to be ONE counter across directives or an alternating nesting chain evades it and reopens the cyclic-YAML-alias hole it was added to close. No behavior change, with one deliberate exception: the depth-cap message now reads `block directives nest deeper than 20 levels` where it said `when blocks nest deeper than 20 levels` — the cap stopped being `when`'s the moment it became shared, and a message naming only `when` would misdirect an author who hit it through a different directive. Every other error message, their ordering, and the report shapes are untouched. 559 flow tests pass unchanged. --- **Stack** 1. **#748 — you are here** · `refactor(flow)`: generalize when's block machinery → `main` 2. #749 · `feat(flow)`: add `repeat:` blocks → #748
2163 lines
85 KiB
TypeScript
2163 lines
85 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("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: [...] }",
|
||
},
|
||
};
|
||
|
||
// 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");
|
||
});
|
||
});
|