mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A 202 means the sync window expired while the job was still running. Tests treated it as a terminal pass: `if (isAsyncFallback(res)) return;` checked the envelope and returned, asserting nothing about the outcome and leaving the job running into the next test, which is the leak cancelAcceptedJobAndWait exists to prevent. Because the window only expires under load, coverage tracked runner load. On CI 44 tests took this path and verified nothing; the same tests on a dev machine asserted in full (one measured 6.4s locally against 31s on CI). settleAsyncFallback waits for a terminal state and asserts the job finished, and that a failure carries a message rather than being a crash. A clean failure stays valid, since the exotic-format fixtures are meant to be rejected. All 82 call sites moved over. per-fork-env no longer floors SYNC_WAIT_MS, so forcing it to 0 drives every request through its 202 path. 570 tests were validated that way and matched their normal-window results exactly. The 29-34s band dropped from 44 tests (23.4% of test time) to 6 (3.0%). Total test time rose 5.8% and CI wall went 12.8 to 13.1 min: the forks were doing real work during that wait, so this buys determinism, not speed. Per-shard totals unchanged at 9903 tests, 9435 passed, 468 skipped.
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
/**
|
|
* Cross-modality launch smoke test.
|
|
*
|
|
* The ship / no-ship signal: one fast tool per modality (image, video, audio,
|
|
* document, data) run end to end via buildTestApp() -- upload a real fixture,
|
|
* process it, assert valid output -- plus an auth check.
|
|
*
|
|
* All chosen tools have executionHint "fast" and use real (small) fixtures so
|
|
* they stay within the sync window under normal conditions. If a tool falls
|
|
* back to async (202) under CI load, the test validates the async response
|
|
* shape and passes (not a real failure).
|
|
*
|
|
* Tools needing a local binary (ffmpeg, qpdf) are gated with skipIf so the
|
|
* test stays green on machines without those binaries.
|
|
*/
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { apiToolPath } from "@snapotter/shared";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { fixtures, readFixture } from "../../fixtures/index.js";
|
|
import { settleAsyncFallback } from "../settle-job.js";
|
|
import {
|
|
buildTestApp,
|
|
createMultipartPayload,
|
|
loginAsAdmin,
|
|
type TestApp,
|
|
} from "../test-server.js";
|
|
|
|
// ── Binary gates ──────────────────────────────────────────────────────
|
|
function hasBinary(name: string): boolean {
|
|
const res = spawnSync("which", [name], { encoding: "utf8" });
|
|
return res.status === 0 && res.stdout.trim().length > 0;
|
|
}
|
|
|
|
const HAS_FFMPEG = hasBinary("ffmpeg");
|
|
const HAS_QPDF = hasBinary("qpdf");
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
async function postTool(
|
|
app: TestApp["app"],
|
|
token: string,
|
|
toolId: string,
|
|
file: Buffer,
|
|
filename: string,
|
|
contentType: string,
|
|
settings: Record<string, unknown>,
|
|
): Promise<{ statusCode: number; body: string }> {
|
|
const { body, contentType: ct } = createMultipartPayload([
|
|
{ name: "file", filename, contentType, content: file },
|
|
{ name: "settings", content: JSON.stringify(settings) },
|
|
]);
|
|
return app.inject({
|
|
method: "POST",
|
|
url: apiToolPath(toolId),
|
|
headers: { authorization: `Bearer ${token}`, "content-type": ct },
|
|
body,
|
|
});
|
|
}
|
|
|
|
// ── Test suite ────────────────────────────────────────────────────────
|
|
|
|
describe("cross-modality launch smoke", () => {
|
|
let testApp: TestApp;
|
|
let app: TestApp["app"];
|
|
let adminToken: string;
|
|
|
|
beforeAll(async () => {
|
|
testApp = await buildTestApp();
|
|
app = testApp.app;
|
|
adminToken = await loginAsAdmin(app);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await testApp.cleanup();
|
|
}, 10_000);
|
|
|
|
// ── Auth gate ─────────────────────────────────────────────────────
|
|
it("unauthenticated request is rejected", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "test.png",
|
|
contentType: "image/png",
|
|
content: readFixture(fixtures.image.base.png200),
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ angle: 90, flipH: false, flipV: false }) },
|
|
]);
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/rotate",
|
|
headers: { "content-type": contentType },
|
|
body,
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
// ── Image: rotate (Sharp, no external binary) ─────────────────────
|
|
it("image modality: rotate", async () => {
|
|
const file = readFixture(fixtures.image.base.png200);
|
|
const res = await postTool(app, adminToken, "rotate", file, "test.png", "image/png", {
|
|
angle: 90,
|
|
flipH: false,
|
|
flipV: false,
|
|
});
|
|
if (await settleAsyncFallback(res)) return;
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.jobId).toBeDefined();
|
|
});
|
|
|
|
// ── Video: mute-video (ffmpeg) ────────────────────────────────────
|
|
it.skipIf(!HAS_FFMPEG)(
|
|
"video modality: mute-video",
|
|
async () => {
|
|
const file = readFixture(fixtures.video.tiny("mp4"));
|
|
const res = await postTool(app, adminToken, "mute-video", file, "tiny.mp4", "video/mp4", {});
|
|
if (await settleAsyncFallback(res)) return;
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.jobId).toBeDefined();
|
|
},
|
|
30_000,
|
|
);
|
|
|
|
// ── Audio: convert-audio (ffmpeg) ─────────────────────────────────
|
|
it.skipIf(!HAS_FFMPEG)(
|
|
"audio modality: convert-audio",
|
|
async () => {
|
|
const file = readFixture(fixtures.audio.tiny("wav"));
|
|
const res = await postTool(app, adminToken, "convert-audio", file, "tone.wav", "audio/wav", {
|
|
format: "mp3",
|
|
bitrate: 128,
|
|
});
|
|
if (await settleAsyncFallback(res)) return;
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.jobId).toBeDefined();
|
|
},
|
|
30_000,
|
|
);
|
|
|
|
// ── Document: rotate-pdf (qpdf) ──────────────────────────────────
|
|
it.skipIf(!HAS_QPDF)(
|
|
"document modality: rotate-pdf",
|
|
async () => {
|
|
const file = readFixture(fixtures.document.tiny("pdf"));
|
|
const res = await postTool(
|
|
app,
|
|
adminToken,
|
|
"rotate-pdf",
|
|
file,
|
|
"tiny.pdf",
|
|
"application/pdf",
|
|
{ angle: 90, range: "1-z" },
|
|
);
|
|
if (await settleAsyncFallback(res)) return;
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.jobId).toBeDefined();
|
|
},
|
|
30_000,
|
|
);
|
|
|
|
// ── Files: csv-json (pure JS, no binary) ─────────────────────
|
|
it("file modality: csv-json", async () => {
|
|
const file = readFixture(fixtures.data.csv);
|
|
const res = await postTool(app, adminToken, "csv-json", file, "tiny.csv", "text/csv", {
|
|
direction: "csv-to-json",
|
|
});
|
|
if (await settleAsyncFallback(res)) return;
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.jobId).toBeDefined();
|
|
});
|
|
});
|