mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
a644d52d39
`argent flow run` now takes an explicit YAML path instead of a saved-flow name: ``` argent flow run .argent/flows/checkout.yaml --platform ios ``` The path may point anywhere on the local filesystem; the filename stem names the run's report/artifacts, so it must be `[A-Za-z0-9_-]+` with a lowercase `.yaml` extension. A bare name fails with the exact replacement command, and `argent flow list` prints paths instead of names. Because sibling `run:` files and `__baselines__` resolve beside the YAML, the CLI now requires the auto-started local tool server and refuses `ARGENT_TOOLS_URL` / `argent link` routing. The `flow-execute` tool stays remotely callable via `name` + `project_root`, and gains a `flow_path` parameter (exactly one of `name`/`flow_path`) that is only honored when the file-input boundary verified it as a real host path — uploads and boundary bypasses are rejected, so it can't execute arbitrary server YAML. Also: the flow-name pattern is now a single shared export from `@argent/registry`, and the recorder composes a `flow_path` call into a `run:` step when it targets a sibling of the recording.
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
prepareFileInputs,
|
|
applyClientFileDirectives,
|
|
FILE_INPUT_MARKER,
|
|
CLIENT_FILE_MARKER,
|
|
type FileInputSpec,
|
|
type FileInputWire,
|
|
} from "../src/file-inputs.js";
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-file-inputs-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("prepareFileInputs", () => {
|
|
it("returns args unchanged when no specs apply", async () => {
|
|
const args = { udid: "ABC" };
|
|
const out = await prepareFileInputs(undefined, args, { includeContent: false });
|
|
expect(out).toBe(args);
|
|
});
|
|
|
|
it("wraps a file param with stat info, without content when local", async () => {
|
|
const filePath = path.join(tmpDir, "baseline.png");
|
|
await fs.writeFile(filePath, "png");
|
|
const st = await fs.stat(filePath);
|
|
|
|
const specs: FileInputSpec[] = [
|
|
{ target: "baselinePath", path: "${baselinePath}", kind: "file", optional: true },
|
|
];
|
|
const out = (await prepareFileInputs(
|
|
specs,
|
|
{ baselinePath: filePath, udid: "X" },
|
|
{ includeContent: false }
|
|
)) as Record<string, unknown>;
|
|
|
|
expect(out.udid).toBe("X");
|
|
expect(out.baselinePath).toEqual({
|
|
[FILE_INPUT_MARKER]: true,
|
|
path: filePath,
|
|
size: st.size,
|
|
mtimeMs: st.mtimeMs,
|
|
});
|
|
});
|
|
|
|
it("inlines base64 content when routed to a remote server", async () => {
|
|
const filePath = path.join(tmpDir, "baseline.png");
|
|
await fs.writeFile(filePath, "png-bytes");
|
|
|
|
const specs: FileInputSpec[] = [
|
|
{ target: "baselinePath", path: "${baselinePath}", kind: "file" },
|
|
];
|
|
const out = (await prepareFileInputs(
|
|
specs,
|
|
{ baselinePath: filePath },
|
|
{ includeContent: true }
|
|
)) as Record<string, FileInputWire>;
|
|
|
|
expect(Buffer.from(out.baselinePath!.content!, "base64").toString()).toBe("png-bytes");
|
|
});
|
|
|
|
it("marks oversize content as omitted instead of silently skipping it", async () => {
|
|
const filePath = path.join(tmpDir, "huge.bin");
|
|
await fs.writeFile(filePath, Buffer.alloc(32 * 1024 * 1024 + 1));
|
|
const st = await fs.stat(filePath);
|
|
|
|
const specs: FileInputSpec[] = [
|
|
{ target: "baselinePath", path: "${baselinePath}", kind: "file" },
|
|
];
|
|
const out = (await prepareFileInputs(
|
|
specs,
|
|
{ baselinePath: filePath },
|
|
{ includeContent: true }
|
|
)) as Record<string, FileInputWire>;
|
|
|
|
// Stat survives so a co-located copy still resolves in place; only the
|
|
// bytes are withheld, with the reason on the wire.
|
|
expect(out.baselinePath).toEqual({
|
|
[FILE_INPUT_MARKER]: true,
|
|
path: filePath,
|
|
size: st.size,
|
|
mtimeMs: st.mtimeMs,
|
|
contentOmitted: "size-limit",
|
|
});
|
|
});
|
|
|
|
it("derives a new target param from a multi-param template (flow_file)", async () => {
|
|
const flowsDir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(flowsDir, { recursive: true });
|
|
const flowPath = path.join(flowsDir, "my-flow.yaml");
|
|
await fs.writeFile(flowPath, "steps: []\n");
|
|
|
|
const specs: FileInputSpec[] = [
|
|
{ target: "flow_file", path: "${project_root}/.argent/flows/${name}.yaml", kind: "file" },
|
|
];
|
|
const out = (await prepareFileInputs(
|
|
specs,
|
|
{ name: "my-flow", project_root: tmpDir },
|
|
{ includeContent: true }
|
|
)) as Record<string, unknown>;
|
|
|
|
// Source params stay strings; the derived target carries the wrapper.
|
|
expect(out.project_root).toBe(tmpDir);
|
|
expect(out.name).toBe("my-flow");
|
|
expect(out.flow_file).toMatchObject({ [FILE_INPUT_MARKER]: true, path: flowPath });
|
|
expect((out.flow_file as FileInputWire).content).toBeDefined();
|
|
});
|
|
|
|
it("skips a spec whose referenced params are absent (live-capture mode)", async () => {
|
|
const specs: FileInputSpec[] = [
|
|
{ target: "baselinePath", path: "${baselinePath}", kind: "file", optional: true },
|
|
];
|
|
const args = { captureBaseline: true, udid: "X" };
|
|
const out = await prepareFileInputs(specs, args, { includeContent: true });
|
|
expect(out).toBe(args);
|
|
});
|
|
|
|
it("does not derive flow_file when the superseding flow_path source is set (skipWhenSet)", async () => {
|
|
const flowsDir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(flowsDir, { recursive: true });
|
|
const savedPath = path.join(flowsDir, "my-flow.yaml");
|
|
await fs.writeFile(savedPath, "steps: []\n");
|
|
const explicitPath = path.join(tmpDir, "explicit.yaml");
|
|
await fs.writeFile(explicitPath, "steps: []\n");
|
|
|
|
const specs: FileInputSpec[] = [
|
|
{ target: "flow_path", path: "${flow_path}", kind: "file", optional: true },
|
|
{
|
|
target: "flow_file",
|
|
path: "${project_root}/.argent/flows/${name}.yaml",
|
|
kind: "file",
|
|
skipWhenSet: "flow_path",
|
|
},
|
|
];
|
|
|
|
// Dual-source misuse: flow_file must not be derived — even though the
|
|
// saved flow exists — so the server's exactly-one validation, not this
|
|
// boundary's file resolution, diagnoses the call.
|
|
const both = (await prepareFileInputs(
|
|
specs,
|
|
{ name: "my-flow", project_root: tmpDir, flow_path: explicitPath },
|
|
{ includeContent: true }
|
|
)) as Record<string, unknown>;
|
|
expect(both.flow_file).toBeUndefined();
|
|
expect(both.flow_path).toMatchObject({ [FILE_INPUT_MARKER]: true, path: explicitPath });
|
|
|
|
// "" is still a provided source to the server's `=== undefined`
|
|
// dual-source check, so it must suppress the derivation the same way.
|
|
const emptySource = (await prepareFileInputs(
|
|
specs,
|
|
{ name: "my-flow", project_root: tmpDir, flow_path: "" },
|
|
{ includeContent: true }
|
|
)) as Record<string, unknown>;
|
|
expect(emptySource.flow_file).toBeUndefined();
|
|
|
|
// Name-only calls keep deriving the wrapper exactly as before.
|
|
const nameOnly = (await prepareFileInputs(
|
|
specs,
|
|
{ name: "my-flow", project_root: tmpDir },
|
|
{ includeContent: true }
|
|
)) as Record<string, unknown>;
|
|
expect(nameOnly.flow_file).toMatchObject({ [FILE_INPUT_MARKER]: true, path: savedPath });
|
|
});
|
|
|
|
it("respects an explicitly set derived target (server-side override)", async () => {
|
|
const specs: FileInputSpec[] = [
|
|
{ target: "flow_file", path: "${project_root}/.argent/flows/${name}.yaml", kind: "file" },
|
|
];
|
|
const out = (await prepareFileInputs(
|
|
specs,
|
|
{ name: "f", project_root: tmpDir, flow_file: "/server/side/flow.yaml" },
|
|
{ includeContent: true }
|
|
)) as Record<string, unknown>;
|
|
expect(out.flow_file).toBe("/server/side/flow.yaml");
|
|
});
|
|
|
|
it("still sends a path-only wrapper for an unreadable file", async () => {
|
|
const specs: FileInputSpec[] = [{ target: "p", path: "${p}", kind: "file" }];
|
|
const ghost = path.join(tmpDir, "ghost.png");
|
|
const out = (await prepareFileInputs(specs, { p: ghost }, { includeContent: true })) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(out.p).toEqual({ [FILE_INPUT_MARKER]: true, path: ghost });
|
|
});
|
|
});
|
|
|
|
describe("prepareFileInputs — tar-upload kind", () => {
|
|
const specs: FileInputSpec[] = [{ target: "appPath", path: "${appPath}", kind: "tar-upload" }];
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("tars and uploads a local directory when routed to a remote server", async () => {
|
|
const appDir = path.join(tmpDir, "MyApp.app");
|
|
await fs.mkdir(appDir);
|
|
await fs.writeFile(path.join(appDir, "Info.plist"), "<plist/>");
|
|
|
|
const fetchMock = vi.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({ uploadId: "upload-123" }),
|
|
}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const out = (await prepareFileInputs(
|
|
specs,
|
|
{ appPath: appDir },
|
|
{ includeContent: true, uploadEndpoint: { url: "https://sim.example", token: "tok" } }
|
|
)) as Record<string, FileInputWire>;
|
|
|
|
expect(out.appPath!.uploadId).toBe("upload-123");
|
|
expect(out.appPath!.contentHash).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(out.appPath!.size).toBeTypeOf("number");
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://sim.example/upload",
|
|
expect.objectContaining({ method: "POST" })
|
|
);
|
|
});
|
|
|
|
it("tars and uploads a local file (e.g. an .apk) when routed to a remote server", async () => {
|
|
const apk = path.join(tmpDir, "app.apk");
|
|
await fs.writeFile(apk, "apk-bytes");
|
|
|
|
const fetchMock = vi.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({ uploadId: "upload-apk" }),
|
|
}));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const out = (await prepareFileInputs(
|
|
specs,
|
|
{ appPath: apk },
|
|
{ includeContent: true, uploadEndpoint: { url: "https://sim.example", token: "tok" } }
|
|
)) as Record<string, FileInputWire>;
|
|
|
|
expect(out.appPath!.uploadId).toBe("upload-apk");
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("propagates an upload failure", async () => {
|
|
const appDir = path.join(tmpDir, "MyApp.app");
|
|
await fs.mkdir(appDir);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => ({ ok: false, status: 500, statusText: "Boom" }))
|
|
);
|
|
|
|
await expect(
|
|
prepareFileInputs(
|
|
specs,
|
|
{ appPath: appDir },
|
|
{ includeContent: true, uploadEndpoint: { url: "https://sim.example", token: "tok" } }
|
|
)
|
|
).rejects.toThrow(/failed/i);
|
|
});
|
|
|
|
it("skips upload and sends a path-only wrapper when the path is not local", async () => {
|
|
// A path that exists only on the remote host (e.g. pre-uploaded to the VM).
|
|
const remotePath = path.join(tmpDir, "not", "on", "this", "machine", "MyApp.app");
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const out = (await prepareFileInputs(
|
|
specs,
|
|
{ appPath: remotePath },
|
|
{ includeContent: true, uploadEndpoint: { url: "https://sim.example", token: "tok" } }
|
|
)) as Record<string, FileInputWire>;
|
|
|
|
expect(out.appPath).toEqual({ [FILE_INPUT_MARKER]: true, path: remotePath });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not upload for a co-located session (no upload endpoint)", async () => {
|
|
const appDir = path.join(tmpDir, "MyApp.app");
|
|
await fs.mkdir(appDir);
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const out = (await prepareFileInputs(
|
|
specs,
|
|
{ appPath: appDir },
|
|
{ includeContent: false }
|
|
)) as Record<string, FileInputWire>;
|
|
|
|
expect(out.appPath).toMatchObject({
|
|
[FILE_INPUT_MARKER]: true,
|
|
path: appDir,
|
|
size: expect.any(Number),
|
|
mtimeMs: expect.any(Number),
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("applyClientFileDirectives", () => {
|
|
function directive(p: string, content = "steps: []\n") {
|
|
return { [CLIENT_FILE_MARKER]: true, path: p, content };
|
|
}
|
|
|
|
it("writes an allowed flow path and rewrites the directive to it", async () => {
|
|
const flowPath = path.join(tmpDir, ".argent", "flows", "demo.yaml");
|
|
const result = { message: "ok", savedTo: directive(flowPath) };
|
|
|
|
const { result: rewritten, written } = await applyClientFileDirectives(result);
|
|
|
|
expect(written).toEqual([flowPath]);
|
|
expect((rewritten as { savedTo: string }).savedTo).toBe(flowPath);
|
|
expect(await fs.readFile(flowPath, "utf8")).toBe("steps: []\n");
|
|
});
|
|
|
|
it("passes results without directives through untouched", async () => {
|
|
const result = { a: 1, nested: { b: "x" } };
|
|
const { result: rewritten, written } = await applyClientFileDirectives(result);
|
|
expect(rewritten).toEqual(result);
|
|
expect(written).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
["relative path", path.join(".argent", "flows", "x.yaml")],
|
|
["outside .argent/flows", path.join(os.tmpdir(), "x.yaml")],
|
|
// Built by concatenation: path.join would collapse the ".." segments
|
|
// before the validator ever saw them.
|
|
["traversal", `${os.tmpdir()}/.argent/flows/../../evil.yaml`],
|
|
["bad extension", path.join(os.tmpdir(), ".argent", "flows", "x.sh")],
|
|
["bad name charset", path.join(os.tmpdir(), ".argent", "flows", "x y.yaml")],
|
|
])("refuses to write %s and resolves the directive to null", async (_label, p) => {
|
|
const { result: rewritten, written } = await applyClientFileDirectives({
|
|
savedTo: directive(p),
|
|
});
|
|
expect(written).toEqual([]);
|
|
expect((rewritten as { savedTo: unknown }).savedTo).toBeNull();
|
|
});
|
|
});
|