mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { TOOLS } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FUZZ_COST_OVERRIDES,
|
||||
fuzzBudgetFor,
|
||||
parseFuzzConfig,
|
||||
runFuzzCaseWithWatchdog,
|
||||
} from "../../helpers/fuzz-policy.js";
|
||||
|
||||
describe("generated fuzz configuration", () => {
|
||||
it("uses deterministic defaults", () => {
|
||||
expect(parseFuzzConfig({})).toEqual({
|
||||
runs: 25,
|
||||
seed: 20_260_724,
|
||||
seedSource: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the canonical FUZZ_SEED variable", () => {
|
||||
expect(parseFuzzConfig({ FUZZ_RUNS: "50", FUZZ_SEED: "1234" })).toEqual({
|
||||
runs: 50,
|
||||
seed: 1234,
|
||||
seedSource: "FUZZ_SEED",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts FC_SEED as a deprecated alias", () => {
|
||||
expect(parseFuzzConfig({ FC_SEED: "5678" })).toEqual({
|
||||
runs: 25,
|
||||
seed: 5678,
|
||||
seedSource: "FC_SEED",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects conflicting canonical and deprecated seeds", () => {
|
||||
expect(() => parseFuzzConfig({ FUZZ_SEED: "1234", FC_SEED: "5678" })).toThrow(
|
||||
/FUZZ_SEED.*FC_SEED.*differ/i,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ FUZZ_RUNS: "0" }, /FUZZ_RUNS.*between 1 and 10000/i],
|
||||
[{ FUZZ_RUNS: "1.5" }, /FUZZ_RUNS.*integer/i],
|
||||
[{ FUZZ_SEED: "NaN" }, /FUZZ_SEED.*integer/i],
|
||||
[{ FUZZ_SEED: "2147483648" }, /FUZZ_SEED.*between 0 and 2147483647/i],
|
||||
[{ FC_SEED: "-1" }, /FC_SEED.*between 0 and 2147483647/i],
|
||||
])("rejects invalid fuzz environment %#", (environment, message) => {
|
||||
expect(() => parseFuzzConfig(environment)).toThrow(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated fuzz budgets", () => {
|
||||
it("scales standard, long, and explicitly slow targets by run count", () => {
|
||||
expect(fuzzBudgetFor({ id: "resize", executionHint: "fast" }, 25)).toEqual({
|
||||
costClass: "standard",
|
||||
caseTimeoutMs: 8_000,
|
||||
targetTimeoutMs: 268_000,
|
||||
});
|
||||
expect(fuzzBudgetFor({ id: "ocr", executionHint: "long" }, 25)).toEqual({
|
||||
costClass: "long",
|
||||
caseTimeoutMs: 12_000,
|
||||
targetTimeoutMs: 372_000,
|
||||
});
|
||||
expect(fuzzBudgetFor({ id: "webp-to-gif", executionHint: "fast" }, 25)).toEqual({
|
||||
costClass: "slow-codec",
|
||||
caseTimeoutMs: 15_000,
|
||||
targetTimeoutMs: 450_000,
|
||||
});
|
||||
|
||||
expect(
|
||||
fuzzBudgetFor({ id: "webp-to-gif", executionHint: "fast" }, 50).targetTimeoutMs,
|
||||
).toBeGreaterThan(
|
||||
fuzzBudgetFor({ id: "webp-to-gif", executionHint: "fast" }, 25).targetTimeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
it("only overrides real registered tool IDs", () => {
|
||||
const registered = new Set(TOOLS.map(({ id }) => id));
|
||||
expect(Object.keys(FUZZ_COST_OVERRIDES).filter((id) => !registered.has(id))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated fuzz per-case watchdog", () => {
|
||||
it("aborts and reports the tool, seed, run, settings, and timeout", async () => {
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
|
||||
await expect(
|
||||
runFuzzCaseWithWatchdog(
|
||||
{
|
||||
toolId: "webp-to-avif",
|
||||
seed: 20_260_724,
|
||||
run: 7,
|
||||
settings: { quality: 91 },
|
||||
timeoutMs: 10,
|
||||
},
|
||||
async (signal) => {
|
||||
receivedSignal = signal;
|
||||
return await new Promise<never>(() => {});
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'[fuzz-timeout] tool=webp-to-avif seed=20260724 run=7 timeoutMs=10 settings={"quality":91}',
|
||||
);
|
||||
expect(receivedSignal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
featureUnavailableDisposition,
|
||||
GeneratedCaseAccounting,
|
||||
} from "../../helpers/generated-case-accounting.js";
|
||||
|
||||
describe("GeneratedCaseAccounting", () => {
|
||||
it("fails a tool that executes no generated cases", () => {
|
||||
const accounting = new GeneratedCaseAccounting("resize");
|
||||
|
||||
expect(() => accounting.assertCovered()).toThrow(
|
||||
"resize: generated coverage incomplete (attempted=0, accepted=0, rejected=0, skipped=0)",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails a tool whose generated cases are all rejected", () => {
|
||||
const accounting = new GeneratedCaseAccounting("resize");
|
||||
accounting.attempt();
|
||||
accounting.reject();
|
||||
|
||||
expect(() => accounting.assertCovered()).toThrow(
|
||||
"resize: generated coverage incomplete (attempted=1, accepted=0, rejected=1, skipped=0)",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports accepted and rejected cases after proving coverage", () => {
|
||||
const accounting = new GeneratedCaseAccounting("resize");
|
||||
accounting.attempt();
|
||||
accounting.accept();
|
||||
accounting.attempt();
|
||||
accounting.reject();
|
||||
|
||||
expect(accounting.assertCovered()).toEqual({
|
||||
attempted: 2,
|
||||
accepted: 1,
|
||||
rejected: 1,
|
||||
skipped: 0,
|
||||
skips: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("records bounded machine-readable skip categories and reasons", () => {
|
||||
const accounting = new GeneratedCaseAccounting("remove-background");
|
||||
accounting.attempt();
|
||||
accounting.skip("optional-feature", "background-removal bundle is not installed");
|
||||
accounting.attempt();
|
||||
accounting.accept();
|
||||
|
||||
expect(accounting.assertCovered()).toEqual({
|
||||
attempted: 2,
|
||||
accepted: 1,
|
||||
rejected: 0,
|
||||
skipped: 1,
|
||||
skips: [
|
||||
{
|
||||
category: "optional-feature",
|
||||
reason: "background-removal bundle is not installed",
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects impossible accounting where accepted exceeds attempted", () => {
|
||||
const accounting = new GeneratedCaseAccounting("resize");
|
||||
accounting.attempt();
|
||||
accounting.accept();
|
||||
accounting.accept();
|
||||
|
||||
expect(() => accounting.assertCovered()).toThrow(
|
||||
"resize: generated accounting is not conserved (attempted=1, accepted=2, rejected=0, skipped=0)",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects campaigns with no accepted case even when all attempts have outcomes", () => {
|
||||
const accounting = new GeneratedCaseAccounting("resize");
|
||||
for (let index = 0; index < 99; index += 1) {
|
||||
accounting.attempt();
|
||||
accounting.reject();
|
||||
}
|
||||
accounting.attempt();
|
||||
accounting.skip("missing-fixture", "one optional fixture was unavailable");
|
||||
|
||||
expect(() => accounting.assertCovered()).toThrow(
|
||||
"resize: generated coverage incomplete (attempted=100, accepted=0, rejected=99, skipped=1)",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unbounded or unknown skip reason", () => {
|
||||
const accounting = new GeneratedCaseAccounting("resize");
|
||||
accounting.attempt();
|
||||
|
||||
expect(() =>
|
||||
accounting.skip("missing-fixture", `fixture unavailable: ${"x".repeat(300)}`),
|
||||
).toThrow("resize: generated skip reason must be 1-240 characters");
|
||||
});
|
||||
});
|
||||
|
||||
describe("featureUnavailableDisposition", () => {
|
||||
it("turns an absent optional AI prerequisite into an explicit skip", () => {
|
||||
expect(
|
||||
featureUnavailableDisposition({
|
||||
toolId: "remove-background",
|
||||
statusCode: 501,
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
requireAiFeatures: false,
|
||||
}),
|
||||
).toBe("skip");
|
||||
});
|
||||
|
||||
it("fails a missing AI feature in an installed-feature campaign", () => {
|
||||
expect(() =>
|
||||
featureUnavailableDisposition({
|
||||
toolId: "remove-background",
|
||||
statusCode: 501,
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
requireAiFeatures: true,
|
||||
}),
|
||||
).toThrow("remove-background: required AI feature returned 501 FEATURE_NOT_INSTALLED");
|
||||
});
|
||||
|
||||
it("does not classify unrelated responses as prerequisite skips", () => {
|
||||
expect(
|
||||
featureUnavailableDisposition({
|
||||
toolId: "resize",
|
||||
statusCode: 422,
|
||||
code: "PROCESSING_FAILED",
|
||||
requireAiFeatures: false,
|
||||
}),
|
||||
).toBe("continue");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const entries = vi.hoisted(() => ["z-last.png", "a-first.png", "m-middle.png"]);
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: () => true,
|
||||
readdirSync: () => [...entries],
|
||||
}));
|
||||
|
||||
import { buildGeneratedFixtureIndex } from "../../helpers/generated-fixtures.js";
|
||||
|
||||
describe("generated fixture ordering", () => {
|
||||
beforeEach(() => {
|
||||
entries.splice(0, entries.length, "z-last.png", "a-first.png", "m-middle.png");
|
||||
});
|
||||
|
||||
it("sorts filesystem discovery deterministically", () => {
|
||||
const fixtures = buildGeneratedFixtureIndex(["/fixtures"]);
|
||||
|
||||
expect(fixtures.get(".png")?.map(({ filename }) => filename)).toEqual([
|
||||
"a-first.png",
|
||||
"m-middle.png",
|
||||
"z-last.png",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { TOOLS } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildGeneratedFixtureIndex,
|
||||
type GeneratedFixture,
|
||||
generatedFixtureDirectories,
|
||||
selectFixturesForTool,
|
||||
} from "../../helpers/generated-fixtures.js";
|
||||
|
||||
const GIF_IMAGE_TO_VIDEO_TOOLS = [
|
||||
"gif-to-video",
|
||||
"images-to-video",
|
||||
"gif-to-mp4",
|
||||
"gif-to-webm",
|
||||
"gif-to-mov",
|
||||
] as const;
|
||||
|
||||
describe("generated fixture discovery", () => {
|
||||
it.each(GIF_IMAGE_TO_VIDEO_TOOLS)("finds image/GIF fixtures for %s", (toolId) => {
|
||||
const tool = TOOLS.find((candidate) => candidate.id === toolId);
|
||||
expect(tool, `missing tool metadata for ${toolId}`).toBeDefined();
|
||||
if (!tool) throw new Error(`missing tool metadata for ${toolId}`);
|
||||
|
||||
const fixtures = selectFixturesForTool(
|
||||
buildGeneratedFixtureIndex(generatedFixtureDirectories()),
|
||||
tool,
|
||||
);
|
||||
|
||||
expect(fixtures.length, `${toolId} should not be emitted as a no-fixture skip`).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
fixtures.some((fixture) => [".gif", ".png", ".jpg", ".webp"].includes(fixture.ext)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an empty accepted-input list as accepting any generated fixture", () => {
|
||||
const fixtures = selectFixturesForTool(
|
||||
buildGeneratedFixtureIndex(generatedFixtureDirectories()),
|
||||
{ acceptedInputs: [] },
|
||||
);
|
||||
|
||||
expect(fixtures.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("prioritizes semantic positive-control fixtures for generated tools", () => {
|
||||
const fixture = (filename: string, ext: string): GeneratedFixture => ({
|
||||
dir: "/fixtures",
|
||||
filename,
|
||||
ext,
|
||||
});
|
||||
const index = new Map([
|
||||
[".csv", [fixture("tiny-a.csv", ".csv"), fixture("tiny.csv", ".csv")]],
|
||||
[".mp4", [fixture("tiny.mp4", ".mp4")]],
|
||||
[".mkv", [fixture("tiny-subs.mkv", ".mkv")]],
|
||||
]);
|
||||
|
||||
expect(
|
||||
selectFixturesForTool(index, { id: "chart-maker", acceptedInputs: [".csv"] })[0].filename,
|
||||
).toBe("tiny.csv");
|
||||
expect(
|
||||
selectFixturesForTool(index, {
|
||||
id: "extract-subtitles",
|
||||
acceptedInputs: [".mp4", ".mkv"],
|
||||
})[0].filename,
|
||||
).toBe("tiny-subs.mkv");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const GENERATED = join(process.cwd(), "tests/integration/generated");
|
||||
const source = (filename: string): string => readFileSync(join(GENERATED, filename), "utf8");
|
||||
|
||||
describe("generated QA harness contract", () => {
|
||||
it.each(["fuzz-settings.test.ts", "settings-pairwise.test.ts"])(
|
||||
"%s accounts for execution through the production v2 contract and uses explicit runtime skips",
|
||||
(filename) => {
|
||||
const text = source(filename);
|
||||
expect(text).toContain("GeneratedCaseAccounting");
|
||||
expect(text).toContain("runGeneratedTool");
|
||||
expect(text).toContain("context.skip(");
|
||||
expect(text).toContain("isExpectedGeneratedRejection");
|
||||
expect(text).toContain("findMissingGeneratedPrerequisite");
|
||||
expect(text).toContain("buildGeneratedProcessInputs(fixtures, config, tool.modality)");
|
||||
expect(text).not.toMatch(/if \(!config\) return;/);
|
||||
expect(text).not.toContain("config.process(");
|
||||
expect(text).not.toContain("CRASH_PATTERN");
|
||||
expect(text).not.toContain("Clean operational failure");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"format-matrix-generated.shared.ts",
|
||||
"format-matrix-multimodal.test.ts",
|
||||
"format-matrix-ai.test.ts",
|
||||
])("%s makes required-AI mode strict", (filename) => {
|
||||
const text = source(filename);
|
||||
expect(text).toContain("REQUIRE_AI_FEATURES");
|
||||
expect(text).toContain("featureUnavailableDisposition");
|
||||
});
|
||||
|
||||
it("multimodal matrix consumes the shared image-inclusive fixture index", () => {
|
||||
const text = source("format-matrix-multimodal.test.ts");
|
||||
expect(text).toContain("generatedFixtureDirectories");
|
||||
expect(text).toContain("selectFixturesForTool");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"format-matrix-generated.shared.ts",
|
||||
"format-matrix-multimodal.test.ts",
|
||||
"settings-matrix.test.ts",
|
||||
])("%s resolves every 202 and validates its completed artifact", (filename) => {
|
||||
const text = source(filename);
|
||||
expect(text).toContain("waitForGeneratedJobArtifact");
|
||||
expect(text).not.toContain("cancelAcceptedJobAndWait");
|
||||
expect(text).not.toMatch(
|
||||
/statusCode === 200 \|\| res\.statusCode === 202\) accounting\.accept/,
|
||||
);
|
||||
});
|
||||
|
||||
it("fuzz executes the exact requested run count instead of interrupting successfully", () => {
|
||||
const text = source("fuzz-settings.test.ts");
|
||||
expect(text).not.toContain("interruptAfterTimeLimit");
|
||||
expect(text).toContain("expectedAttempts: FUZZ_CONFIG.runs + 1");
|
||||
});
|
||||
|
||||
it("pairwise does not truncate the covering array after it is generated", () => {
|
||||
const text = source("settings-pairwise.test.ts");
|
||||
expect(text).not.toContain("MAX_CASES_PER_TOOL");
|
||||
expect(text).not.toMatch(/\.slice\(0,/);
|
||||
});
|
||||
|
||||
it("settings capability gates preserve every collected variation", () => {
|
||||
const text = source("settings-matrix.test.ts");
|
||||
expect(text).toContain("allPythonVariationsUnavailable");
|
||||
expect(text).not.toContain("describe.skip(");
|
||||
});
|
||||
|
||||
it("the actual nightly extended lane requires installed AI features", () => {
|
||||
const workflow = readFileSync(join(process.cwd(), ".github/workflows/nightly.yml"), "utf8");
|
||||
const extendedLane = workflow.slice(
|
||||
workflow.indexOf(" extended-matrix:"),
|
||||
workflow.indexOf(" api-fuzz:"),
|
||||
);
|
||||
expect(extendedLane).toContain('REQUIRE_AI_FEATURES: "1"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGeneratedMultipartFields } from "../../helpers/generated-multipart.js";
|
||||
|
||||
const primary = { filename: "primary.png", content: Buffer.from("primary") };
|
||||
const image = { filename: "companion.png", content: Buffer.from("image") };
|
||||
const audio = { filename: "companion.wav", content: Buffer.from("audio") };
|
||||
const subtitle = { filename: "companion.srt", content: Buffer.from("subtitle") };
|
||||
|
||||
describe("generated multipart payloads", () => {
|
||||
it.each([
|
||||
"sprite-sheet",
|
||||
"stitch",
|
||||
"images-to-video",
|
||||
"merge-audio",
|
||||
"compare",
|
||||
"find-duplicates",
|
||||
])("sends two ordinary file parts for %s", (toolId) => {
|
||||
const fields = buildGeneratedMultipartFields({
|
||||
toolId,
|
||||
primary,
|
||||
settings: {},
|
||||
companions: { image, audio, subtitle },
|
||||
});
|
||||
|
||||
expect(fields.filter(({ name }) => name === "file")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses named watermark and overlay parts for custom image routes", () => {
|
||||
expect(
|
||||
buildGeneratedMultipartFields({
|
||||
toolId: "watermark-image",
|
||||
primary,
|
||||
settings: {},
|
||||
companions: { image, audio, subtitle },
|
||||
}).map(({ name }) => name),
|
||||
).toEqual(["file", "watermark", "settings"]);
|
||||
expect(
|
||||
buildGeneratedMultipartFields({
|
||||
toolId: "compose",
|
||||
primary,
|
||||
settings: {},
|
||||
companions: { image, audio, subtitle },
|
||||
}).map(({ name }) => name),
|
||||
).toEqual(["file", "overlay", "settings"]);
|
||||
});
|
||||
|
||||
it("builds valid mixed-kind tuples", () => {
|
||||
const subtitleFields = buildGeneratedMultipartFields({
|
||||
toolId: "burn-subtitles",
|
||||
primary: { filename: "video.mp4", content: Buffer.from("video") },
|
||||
settings: {},
|
||||
companions: { image, audio, subtitle },
|
||||
});
|
||||
expect(
|
||||
subtitleFields.filter(({ name }) => name === "file").map(({ filename }) => filename),
|
||||
).toEqual(["video.mp4", "companion.srt"]);
|
||||
|
||||
const audioFields = buildGeneratedMultipartFields({
|
||||
toolId: "replace-audio",
|
||||
primary: { filename: "video.mp4", content: Buffer.from("video") },
|
||||
settings: {},
|
||||
companions: { image, audio, subtitle },
|
||||
});
|
||||
expect(
|
||||
audioFields.filter(({ name }) => name === "file").map(({ filename }) => filename),
|
||||
).toEqual(["video.mp4", "companion.wav"]);
|
||||
});
|
||||
|
||||
it("supplies the smallest valid collage template", () => {
|
||||
const settings = buildGeneratedMultipartFields({
|
||||
toolId: "collage",
|
||||
primary,
|
||||
settings: {},
|
||||
companions: { image, audio, subtitle },
|
||||
}).find(({ name }) => name === "settings");
|
||||
|
||||
expect(JSON.parse(settings?.content.toString() ?? "{}")).toMatchObject({
|
||||
templateId: "2-h-equal",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds signature-specific parts instead of a generic settings field", () => {
|
||||
const fields = buildGeneratedMultipartFields({
|
||||
toolId: "sign-pdf",
|
||||
primary: { filename: "document.pdf", content: Buffer.from("pdf") },
|
||||
settings: {},
|
||||
companions: { image, audio, subtitle },
|
||||
});
|
||||
|
||||
expect(fields.map(({ name }) => name)).toEqual(["file", "sig0", "placements"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { installedAiCapabilityGate } from "../../helpers/installed-ai-capability-gate.js";
|
||||
|
||||
const INTEGRATION_ROOT = join(process.cwd(), "tests/integration");
|
||||
const INSTALLED_AI_SUITES = [
|
||||
{
|
||||
path: "tools/audio/transcribe-audio.test.ts",
|
||||
toolId: "transcribe-audio",
|
||||
oracles: ["expectKnownTranscript"],
|
||||
},
|
||||
{
|
||||
path: "tools/video/auto-subtitles.test.ts",
|
||||
toolId: "auto-subtitles",
|
||||
oracles: ["expectKnownTranscript"],
|
||||
},
|
||||
{
|
||||
path: "tools/image/blur-background.test.ts",
|
||||
toolId: "blur-background",
|
||||
oracles: ["expectObservablePixelChange", "expectForegroundPreserved"],
|
||||
},
|
||||
{
|
||||
path: "tools/image/background-replace.test.ts",
|
||||
toolId: "background-replace",
|
||||
oracles: [
|
||||
"expectObservablePixelChange",
|
||||
"expectConfiguredBackground",
|
||||
"expectForegroundPreserved",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe("installed AI integration capability gate", () => {
|
||||
it.each([
|
||||
{
|
||||
installed: false,
|
||||
required: false,
|
||||
runInstalledContract: false,
|
||||
runUnavailableContract: true,
|
||||
},
|
||||
{
|
||||
installed: false,
|
||||
required: true,
|
||||
runInstalledContract: true,
|
||||
runUnavailableContract: true,
|
||||
},
|
||||
{
|
||||
installed: true,
|
||||
required: false,
|
||||
runInstalledContract: true,
|
||||
runUnavailableContract: false,
|
||||
},
|
||||
{
|
||||
installed: true,
|
||||
required: true,
|
||||
runInstalledContract: true,
|
||||
runUnavailableContract: false,
|
||||
},
|
||||
])(
|
||||
"installed=$installed required=$required selects the correct contract lanes",
|
||||
({ installed, required, runInstalledContract, runUnavailableContract }) => {
|
||||
expect(installedAiCapabilityGate("transcribe-audio", required, () => installed)).toEqual({
|
||||
installed,
|
||||
runInstalledContract,
|
||||
runUnavailableContract,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(INSTALLED_AI_SUITES)(
|
||||
"$toolId has no unconditional suite disable and accounts for both capability lanes",
|
||||
({ path, toolId }) => {
|
||||
const source = readFileSync(join(INTEGRATION_ROOT, path), "utf8");
|
||||
expect(source).not.toContain("describe.skip(");
|
||||
expect(source).toContain("REQUIRE_AI_FEATURES");
|
||||
expect(source).toContain("isToolInstalled");
|
||||
expect(source).toMatch(new RegExp(`installedAiCapabilityGate\\(\\s*"${toolId}"`));
|
||||
expect(source).toContain("describe.skipIf(!AI_CAPABILITY.runInstalledContract)");
|
||||
expect(source).toContain("it.skipIf(!AI_CAPABILITY.runUnavailableContract)");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(INSTALLED_AI_SUITES)(
|
||||
"$toolId settles every installed 202 and applies a tool-specific output oracle",
|
||||
({ path, oracles }) => {
|
||||
const source = readFileSync(join(INTEGRATION_ROOT, path), "utf8");
|
||||
const installedContract = source.slice(
|
||||
source.indexOf("// -- Installed/required capability contract --"),
|
||||
);
|
||||
const admissionPattern = /expect\(res\.statusCode\)\.toBe\(202\)/g;
|
||||
const settlementPattern = /await waitForDownloadedJobArtifact\(/g;
|
||||
const admissions = [...installedContract.matchAll(admissionPattern)];
|
||||
const settlements = [...installedContract.matchAll(settlementPattern)];
|
||||
const oracleMatches = oracles.map(
|
||||
(oracle) =>
|
||||
[oracle, [...installedContract.matchAll(new RegExp(`${oracle}\\(`, "g"))]] as const,
|
||||
);
|
||||
|
||||
expect(admissions.length).toBeGreaterThan(0);
|
||||
expect(settlements).toHaveLength(admissions.length);
|
||||
for (const [, matches] of oracleMatches) expect(matches).toHaveLength(admissions.length);
|
||||
for (const [index, admission] of admissions.entries()) {
|
||||
const start = admission.index ?? -1;
|
||||
const end = admissions[index + 1]?.index ?? Number.MAX_VALUE;
|
||||
expect(settlements[index].index).toBeGreaterThan(start);
|
||||
expect(settlements[index].index).toBeLessThan(end);
|
||||
for (const [, matches] of oracleMatches) {
|
||||
expect(matches[index].index).toBeGreaterThan(settlements[index].index ?? start);
|
||||
expect(matches[index].index).toBeLessThan(end);
|
||||
}
|
||||
}
|
||||
expect(installedContract).not.toContain("just verify job was accepted");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createGradientBackground } from "../../../apps/api/src/lib/bg-effects.js";
|
||||
import {
|
||||
expectBackgroundBlurEnergyReduced,
|
||||
expectConfiguredBackground,
|
||||
expectForegroundPreserved,
|
||||
expectKnownTranscript,
|
||||
expectObservablePixelChange,
|
||||
expectSrtArtifact,
|
||||
expectVttArtifact,
|
||||
} from "../../helpers/installed-ai-output-oracles.js";
|
||||
|
||||
describe("installed AI output oracles", () => {
|
||||
it("recognizes the committed speech fixture transcript without exact wording", () => {
|
||||
expect(() =>
|
||||
expectKnownTranscript("The quick brown fox transcribes audio files reliably."),
|
||||
).not.toThrow();
|
||||
expect(() => expectKnownTranscript("unrelated noise with no fixture vocabulary")).toThrow();
|
||||
});
|
||||
|
||||
it("requires real SRT and VTT timing structures", () => {
|
||||
expect(() =>
|
||||
expectSrtArtifact("1\n00:00:00,000 --> 00:00:01,250\nThe quick brown fox.\n"),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
expectVttArtifact("WEBVTT\n\n00:00:00.000 --> 00:00:01.250\nThe quick brown fox.\n"),
|
||||
).not.toThrow();
|
||||
expect(() => expectSrtArtifact("not subtitles")).toThrow();
|
||||
expect(() => expectVttArtifact("not subtitles")).toThrow();
|
||||
});
|
||||
|
||||
it("requires observable decoded changes in the background region", async () => {
|
||||
const input = await sharp({
|
||||
create: { width: 100, height: 100, channels: 3, background: "#000000" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const changed = await sharp(input)
|
||||
.composite([
|
||||
{
|
||||
input: await sharp({
|
||||
create: { width: 100, height: 12, channels: 3, background: "#ffffff" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer(),
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
])
|
||||
.webp({ lossless: true })
|
||||
.toBuffer();
|
||||
|
||||
const centerOnly = await sharp(input)
|
||||
.composite([
|
||||
{
|
||||
input: await sharp({
|
||||
create: { width: 30, height: 30, channels: 3, background: "#ffffff" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer(),
|
||||
left: 35,
|
||||
top: 35,
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
await expect(expectObservablePixelChange(input, changed)).resolves.toBeUndefined();
|
||||
await expect(expectObservablePixelChange(input, centerOnly)).rejects.toThrow();
|
||||
await expect(expectObservablePixelChange(input, input)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("requires real high-frequency energy loss in the background region", async () => {
|
||||
const checker = Buffer.alloc(200 * 200 * 3);
|
||||
for (let y = 0; y < 200; y += 1) {
|
||||
for (let x = 0; x < 200; x += 1) {
|
||||
const value = (x + y) % 2 === 0 ? 0 : 255;
|
||||
const offset = (y * 200 + x) * 3;
|
||||
checker[offset] = value;
|
||||
checker[offset + 1] = value;
|
||||
checker[offset + 2] = value;
|
||||
}
|
||||
}
|
||||
const input = await sharp(checker, { raw: { width: 200, height: 200, channels: 3 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const blurred = await sharp(input).blur(12).webp({ lossless: true }).toBuffer();
|
||||
|
||||
await expect(expectBackgroundBlurEnergyReduced(input, blurred)).resolves.toBeUndefined();
|
||||
await expect(expectBackgroundBlurEnergyReduced(input, input)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("is calibrated against the committed portrait fixture used in production QA", async () => {
|
||||
const portrait = readFileSync("tests/fixtures/image/valid/portrait-color.jpg");
|
||||
const blurred = await sharp(portrait).blur(37.75).webp({ lossless: true }).toBuffer();
|
||||
const reencoded = await sharp(portrait).webp({ lossless: true }).toBuffer();
|
||||
|
||||
await expect(expectBackgroundBlurEnergyReduced(portrait, blurred)).resolves.toBeUndefined();
|
||||
await expect(expectBackgroundBlurEnergyReduced(portrait, reencoded)).rejects.toThrow(
|
||||
"background high-frequency energy ratio",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires the known central foreground region to remain recognizable", async () => {
|
||||
const input = await sharp({
|
||||
create: { width: 100, height: 100, channels: 3, background: "#808080" },
|
||||
})
|
||||
.composite([
|
||||
{
|
||||
input: await sharp({
|
||||
create: { width: 20, height: 40, channels: 3, background: "#2080e0" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer(),
|
||||
left: 40,
|
||||
top: 33,
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
const backgroundChanged = await sharp(input)
|
||||
.composite([
|
||||
{
|
||||
input: await sharp({
|
||||
create: { width: 100, height: 20, channels: 3, background: "#ff0000" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer(),
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
])
|
||||
.webp({ lossless: true })
|
||||
.toBuffer();
|
||||
const foregroundDestroyed = await sharp(input)
|
||||
.composite([
|
||||
{
|
||||
input: await sharp({
|
||||
create: { width: 20, height: 40, channels: 3, background: "#ff0000" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer(),
|
||||
left: 40,
|
||||
top: 33,
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
await expect(expectForegroundPreserved(input, backgroundChanged)).resolves.toBeUndefined();
|
||||
await expect(expectForegroundPreserved(input, foregroundDestroyed)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("requires configured solid and gradient background colors", async () => {
|
||||
const red = await sharp({
|
||||
create: { width: 20, height: 20, channels: 3, background: "#ff0000" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const gradient = await createGradientBackground(200, 200, "#ff0000", "#0000ff", 45);
|
||||
|
||||
await expect(expectConfiguredBackground(red, "solid-red")).resolves.toBeUndefined();
|
||||
await expect(
|
||||
expectConfiguredBackground(gradient, "red-blue-gradient"),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(expectConfiguredBackground(red, "red-blue-gradient")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findMissingGeneratedPythonPrerequisite,
|
||||
resolvePython,
|
||||
} from "../../helpers/python-gate.js";
|
||||
|
||||
const NO_CAPABILITIES = {
|
||||
fitz: false,
|
||||
markdown: false,
|
||||
pdf2docx: false,
|
||||
pikepdf: false,
|
||||
weasyprint: false,
|
||||
};
|
||||
|
||||
describe("Python interpreter resolution", () => {
|
||||
it("uses Scripts/python.exe from a configured Windows venv", () => {
|
||||
const located: string[] = [];
|
||||
|
||||
expect(
|
||||
resolvePython({
|
||||
cwd: "C:\\repo",
|
||||
env: { PYTHON_VENV_PATH: "C:\\venv" },
|
||||
fileExists: (path) => path === "C:\\venv\\Scripts\\python.exe",
|
||||
locate: (command, executable) => {
|
||||
located.push(`${command} ${executable}`);
|
||||
return [];
|
||||
},
|
||||
platform: "win32",
|
||||
}),
|
||||
).toBe("C:\\venv\\Scripts\\python.exe");
|
||||
expect(located).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses where to find a Windows system interpreter", () => {
|
||||
expect(
|
||||
resolvePython({
|
||||
cwd: "C:\\repo",
|
||||
env: {},
|
||||
fileExists: (path) => path === "C:\\Python313\\python.exe",
|
||||
locate: (command, executable) => {
|
||||
expect([command, executable]).toEqual(["where", "python"]);
|
||||
return ["C:\\missing\\python.exe", "C:\\Python313\\python.exe"];
|
||||
},
|
||||
platform: "win32",
|
||||
}),
|
||||
).toBe("C:\\Python313\\python.exe");
|
||||
});
|
||||
|
||||
it("uses bin/python3 from a Unix venv", () => {
|
||||
const located: string[] = [];
|
||||
|
||||
expect(
|
||||
resolvePython({
|
||||
cwd: "/repo",
|
||||
env: {},
|
||||
fileExists: (path) => path === "/repo/.venv/bin/python3",
|
||||
locate: (command, executable) => {
|
||||
located.push(`${command} ${executable}`);
|
||||
return [];
|
||||
},
|
||||
platform: "linux",
|
||||
}),
|
||||
).toBe("/repo/.venv/bin/python3");
|
||||
expect(located).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses which to find a Unix system interpreter", () => {
|
||||
expect(
|
||||
resolvePython({
|
||||
cwd: "/repo",
|
||||
env: {},
|
||||
fileExists: (path) => path === "/usr/local/bin/python3",
|
||||
locate: (command, executable) => {
|
||||
expect([command, executable]).toEqual(["which", "python3"]);
|
||||
return ["/usr/local/bin/python3"];
|
||||
},
|
||||
platform: "linux",
|
||||
}),
|
||||
).toBe("/usr/local/bin/python3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated source Python prerequisites", () => {
|
||||
it.each([
|
||||
["flatten-pdf", "fitz"],
|
||||
["redact-pdf", "fitz"],
|
||||
["sign-pdf", "fitz"],
|
||||
["pdf-to-text", "fitz"],
|
||||
["pdf-to-word", "pdf2docx"],
|
||||
["pdf-metadata", "pikepdf"],
|
||||
["html-to-pdf", "weasyprint"],
|
||||
["markdown-to-pdf", "weasyprint"],
|
||||
])("gates %s on %s", (toolId, moduleName) => {
|
||||
expect(findMissingGeneratedPythonPrerequisite(toolId, {}, NO_CAPABILITIES)).toContain(
|
||||
moduleName,
|
||||
);
|
||||
});
|
||||
|
||||
it("gates only the PDF branch of epub-convert", () => {
|
||||
expect(
|
||||
findMissingGeneratedPythonPrerequisite("epub-convert", { format: "pdf" }, NO_CAPABILITIES),
|
||||
).toContain("weasyprint");
|
||||
expect(
|
||||
findMissingGeneratedPythonPrerequisite("epub-convert", { format: "html" }, NO_CAPABILITIES),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows tools when their Python capability is present", () => {
|
||||
const capabilities = {
|
||||
fitz: true,
|
||||
markdown: true,
|
||||
pdf2docx: true,
|
||||
pikepdf: true,
|
||||
weasyprint: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
findMissingGeneratedPythonPrerequisite("markdown-to-pdf", {}, capabilities),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires the markdown module in addition to weasyprint", () => {
|
||||
expect(
|
||||
findMissingGeneratedPythonPrerequisite(
|
||||
"markdown-to-pdf",
|
||||
{},
|
||||
{
|
||||
...NO_CAPABILITIES,
|
||||
weasyprint: true,
|
||||
},
|
||||
),
|
||||
).toContain("markdown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user