mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
178 lines
6.1 KiB
TypeScript
178 lines
6.1 KiB
TypeScript
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|||
|
|
import { tmpdir } from "node:os";
|
||
|
|
import { join } from "node:path";
|
||
|
|
import { describe, expect, it, vi } from "vitest";
|
||
|
|
import { InputValidationError } from "../../../apps/api/src/modality/contract.js";
|
||
|
|
import type { AnyToolRouteConfig } from "../../../apps/api/src/routes/tool-factory.js";
|
||
|
|
import type { GeneratedFixture } from "../../helpers/generated-fixtures.js";
|
||
|
|
import {
|
||
|
|
buildGeneratedProcessInputs,
|
||
|
|
findMissingGeneratedPrerequisite,
|
||
|
|
isExpectedGeneratedRejection,
|
||
|
|
runGeneratedTool,
|
||
|
|
} from "../../helpers/run-generated-tool.js";
|
||
|
|
|
||
|
|
const schema = {
|
||
|
|
safeParse: (data: unknown) => ({ success: true as const, data }),
|
||
|
|
parse: (data: unknown) => data,
|
||
|
|
};
|
||
|
|
|
||
|
|
function config(
|
||
|
|
processV2: NonNullable<AnyToolRouteConfig["processV2"]>,
|
||
|
|
overrides: Partial<AnyToolRouteConfig> = {},
|
||
|
|
): AnyToolRouteConfig {
|
||
|
|
return {
|
||
|
|
toolId: "generated-test",
|
||
|
|
settingsSchema: schema as never,
|
||
|
|
process: async () => {
|
||
|
|
throw new Error("legacy process must not run");
|
||
|
|
},
|
||
|
|
processV2,
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("generated tool process harness", () => {
|
||
|
|
it("executes v2-only tools and returns their buffered output", async () => {
|
||
|
|
const processV2 = vi.fn(async () => ({
|
||
|
|
buffer: Buffer.from("result"),
|
||
|
|
filename: "result.bin",
|
||
|
|
contentType: "application/octet-stream",
|
||
|
|
}));
|
||
|
|
|
||
|
|
const output = await runGeneratedTool(
|
||
|
|
config(processV2),
|
||
|
|
[{ buffer: Buffer.from("input"), filename: "input.bin", ref: "generated/input.bin" }],
|
||
|
|
{ quality: 80 },
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(output.equals(Buffer.from("result"))).toBe(true);
|
||
|
|
expect(processV2).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({
|
||
|
|
inputs: [expect.objectContaining({ filename: "input.bin", ref: "generated/input.bin" })],
|
||
|
|
settings: { quality: 80 },
|
||
|
|
signal: expect.any(AbortSignal),
|
||
|
|
report: expect.any(Function),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("forwards a caller-provided watchdog signal to the resolved process", async () => {
|
||
|
|
const processV2 = vi.fn(async () => ({
|
||
|
|
buffer: Buffer.from("result"),
|
||
|
|
filename: "result.bin",
|
||
|
|
contentType: "application/octet-stream",
|
||
|
|
}));
|
||
|
|
const controller = new AbortController();
|
||
|
|
|
||
|
|
await runGeneratedTool(
|
||
|
|
config(processV2),
|
||
|
|
[{ buffer: Buffer.from("input"), filename: "input.bin", ref: "generated/input.bin" }],
|
||
|
|
{ quality: 80 },
|
||
|
|
controller.signal,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(processV2).toHaveBeenCalledWith(expect.objectContaining({ signal: controller.signal }));
|
||
|
|
});
|
||
|
|
|
||
|
|
it("reads scratch-path output before deleting the isolated scratch directory", async () => {
|
||
|
|
let scratchPath = "";
|
||
|
|
const output = await runGeneratedTool(
|
||
|
|
config(async ({ scratchDir }) => {
|
||
|
|
scratchPath = join(scratchDir, "result.bin");
|
||
|
|
await writeFile(scratchPath, "scratch-result");
|
||
|
|
return {
|
||
|
|
scratchPath,
|
||
|
|
filename: "result.bin",
|
||
|
|
contentType: "application/octet-stream",
|
||
|
|
};
|
||
|
|
}),
|
||
|
|
[{ buffer: Buffer.from("input"), filename: "input.bin", ref: "generated/input.bin" }],
|
||
|
|
{},
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(output.equals(Buffer.from("scratch-result"))).toBe(true);
|
||
|
|
await expect(readFile(scratchPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("builds the configured minimum cardinality and honors mixed input kinds", async () => {
|
||
|
|
const fixtureRoot = await mkdtemp(join(tmpdir(), "snapotter-generated-fixtures-"));
|
||
|
|
const fixtures: GeneratedFixture[] = [
|
||
|
|
{ dir: fixtureRoot, filename: "tiny.mp4", ext: ".mp4" },
|
||
|
|
{ dir: fixtureRoot, filename: "tiny.wav", ext: ".wav" },
|
||
|
|
];
|
||
|
|
try {
|
||
|
|
await Promise.all(
|
||
|
|
fixtures.map((fixture) => writeFile(join(fixture.dir, fixture.filename), fixture.ext)),
|
||
|
|
);
|
||
|
|
|
||
|
|
const inputs = await buildGeneratedProcessInputs(fixtures, {
|
||
|
|
minInputs: 2,
|
||
|
|
inputKinds: ["video", "audio"],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(inputs).toHaveLength(2);
|
||
|
|
expect(inputs.map(({ filename }) => filename)).toEqual(["tiny.mp4", "tiny.wav"]);
|
||
|
|
expect(inputs.every(({ ref }) => ref.startsWith("generated/"))).toBe(true);
|
||
|
|
} finally {
|
||
|
|
await rm(fixtureRoot, { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("fails closed when a tool has no resolved v2 process", async () => {
|
||
|
|
const unresolved = config(async () => ({
|
||
|
|
buffer: Buffer.from("unused"),
|
||
|
|
filename: "unused",
|
||
|
|
contentType: "application/octet-stream",
|
||
|
|
}));
|
||
|
|
unresolved.processV2 = undefined;
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
runGeneratedTool(
|
||
|
|
unresolved,
|
||
|
|
[{ buffer: Buffer.from("input"), filename: "input.bin", ref: "generated/input.bin" }],
|
||
|
|
{},
|
||
|
|
),
|
||
|
|
).rejects.toThrow(/no processV2/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("only classifies typed input failures as clean generated-case rejections", () => {
|
||
|
|
expect(isExpectedGeneratedRejection(new InputValidationError("bad upload"))).toBe(true);
|
||
|
|
expect(
|
||
|
|
isExpectedGeneratedRejection(
|
||
|
|
Object.assign(new Error("bad tool input"), {
|
||
|
|
isToolInputError: true,
|
||
|
|
}),
|
||
|
|
),
|
||
|
|
).toBe(true);
|
||
|
|
expect(isExpectedGeneratedRejection(new TypeError("bug"))).toBe(false);
|
||
|
|
expect(isExpectedGeneratedRejection(new Error("untyped operational failure"))).toBe(false);
|
||
|
|
expect(isExpectedGeneratedRejection("not an error")).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("classifies the content-aware resize native binary prerequisite explicitly", async () => {
|
||
|
|
const fixtureRoot = await mkdtemp(join(tmpdir(), "snapotter-caire-prerequisite-"));
|
||
|
|
const executable = join(fixtureRoot, "caire");
|
||
|
|
try {
|
||
|
|
expect(
|
||
|
|
await findMissingGeneratedPrerequisite("content-aware-resize", {
|
||
|
|
cairePath: join(fixtureRoot, "missing-caire"),
|
||
|
|
path: "",
|
||
|
|
}),
|
||
|
|
).toMatch(/caire binary/i);
|
||
|
|
|
||
|
|
await writeFile(executable, "#!/bin/sh\nexit 0\n");
|
||
|
|
await chmod(executable, 0o755);
|
||
|
|
expect(
|
||
|
|
await findMissingGeneratedPrerequisite("content-aware-resize", {
|
||
|
|
cairePath: executable,
|
||
|
|
path: "",
|
||
|
|
}),
|
||
|
|
).toBeUndefined();
|
||
|
|
expect(await findMissingGeneratedPrerequisite("resize", { path: "" })).toBeUndefined();
|
||
|
|
} finally {
|
||
|
|
await rm(fixtureRoot, { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|