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.
155 lines
6.1 KiB
TypeScript
155 lines
6.1 KiB
TypeScript
/**
|
||
* Cross-format matrix, part 1 of 2 (see format-matrix.shared.ts).
|
||
*
|
||
* Vitest shards by file and runs a file's tests serially in one fork, so the
|
||
* cross-format matrix is striped across two part files that together cover
|
||
* every entry of FORMAT_SAMPLES exactly once.
|
||
*/
|
||
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { apiToolPath } from "@snapotter/shared";
|
||
import { describe, expect, it } from "vitest";
|
||
import { fixtureDir } from "../../fixtures/index.js";
|
||
import { settleAsyncFallback } from "../settle-job.js";
|
||
import {
|
||
ACCEPTABLE_FALLBACK_CODES,
|
||
adminToken,
|
||
app,
|
||
buildPayload,
|
||
formatSamplesForPart,
|
||
needsFallback,
|
||
setupMatrixApp,
|
||
TOOLS,
|
||
} from "./format-matrix.shared.js";
|
||
|
||
setupMatrixApp();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cross-format matrix: every tool x every format
|
||
// ---------------------------------------------------------------------------
|
||
describe("Cross-format matrix", () => {
|
||
for (const fmt of formatSamplesForPart(1)) {
|
||
describe(`${fmt.name} input (${fmt.file})`, () => {
|
||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||
|
||
for (const tool of TOOLS) {
|
||
// Skip "Convert to PNG" when input is already PNG (no-op conversion)
|
||
if (tool.id === "convert" && fmt.name === "PNG") continue;
|
||
|
||
const perTestTimeout =
|
||
(fmt.needsHeifDecoder || fmt.needsCliDecoder) && tool.id === "image-enhancement"
|
||
? 300_000
|
||
: fmt.needsHeifDecoder || fmt.needsCliDecoder
|
||
? 180_000
|
||
: tool.id === "image-enhancement"
|
||
? 120_000
|
||
: 60_000; // 2× SYNC_WAIT_MS so a 202 fallback never races the Vitest timeout
|
||
|
||
it(
|
||
`${tool.label}`,
|
||
async () => {
|
||
if (!existsSync(fixturePath)) return;
|
||
|
||
const buffer = readFileSync(fixturePath);
|
||
const { body: payload, contentType } = buildPayload(fmt, tool, buffer);
|
||
|
||
const res = await app.inject({
|
||
method: "POST",
|
||
url: apiToolPath(tool.id),
|
||
headers: {
|
||
authorization: `Bearer ${adminToken}`,
|
||
"content-type": contentType,
|
||
},
|
||
body: payload,
|
||
});
|
||
|
||
// ------------------------------------------------------------------
|
||
// Assert status code
|
||
// ------------------------------------------------------------------
|
||
// A heavy encode may fall back to async (202) under CI load -- accept it.
|
||
if (await settleAsyncFallback(res)) return;
|
||
|
||
if (needsFallback(fmt)) {
|
||
// Formats with optional decoders: accept success or graceful error
|
||
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
||
} else {
|
||
// Core formats must always succeed
|
||
expect(res.statusCode).toBe(200);
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// If successful, validate the response shape
|
||
// ------------------------------------------------------------------
|
||
if (res.statusCode === 200) {
|
||
const body = JSON.parse(res.body);
|
||
|
||
switch (tool.responseType) {
|
||
case "download":
|
||
expect(body.downloadUrl).toBeDefined();
|
||
expect(typeof body.downloadUrl).toBe("string");
|
||
expect(body.processedSize).toBeGreaterThan(0);
|
||
expect(body.originalSize).toBeGreaterThan(0);
|
||
break;
|
||
|
||
case "info":
|
||
expect(body.width).toBeGreaterThan(0);
|
||
expect(body.height).toBeGreaterThan(0);
|
||
expect(body.fileSize).toBeGreaterThan(0);
|
||
expect(body.format).toBeDefined();
|
||
expect(body.channels).toBeGreaterThan(0);
|
||
break;
|
||
|
||
case "base64":
|
||
// image-to-base64 returns { results: [...], errors: [...] }
|
||
expect(Array.isArray(body.results)).toBe(true);
|
||
expect(body.results.length + body.errors.length).toBeGreaterThan(0);
|
||
if (body.results.length > 0) {
|
||
const r = body.results[0];
|
||
expect(r.base64).toBeDefined();
|
||
expect(typeof r.base64).toBe("string");
|
||
expect(r.base64.length).toBeGreaterThan(0);
|
||
expect(r.dataUri).toMatch(/^data:/);
|
||
expect(r.width).toBeGreaterThan(0);
|
||
expect(r.height).toBeGreaterThan(0);
|
||
}
|
||
break;
|
||
|
||
case "palette":
|
||
// color-palette returns { colors: string[], count: number }
|
||
expect(Array.isArray(body.colors)).toBe(true);
|
||
expect(body.colors.length).toBeGreaterThan(0);
|
||
expect(body.count).toBeGreaterThan(0);
|
||
// Each color should be a hex string
|
||
for (const color of body.colors) {
|
||
expect(color).toMatch(/^#[0-9a-f]{6}$/);
|
||
}
|
||
break;
|
||
|
||
case "pdf":
|
||
// image-to-pdf returns { downloadUrl, processedSize, pages }
|
||
expect(body.downloadUrl).toBeDefined();
|
||
expect(typeof body.downloadUrl).toBe("string");
|
||
expect(body.processedSize).toBeGreaterThan(0);
|
||
expect(body.pages).toBeGreaterThanOrEqual(1);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// If the API returned an error, verify it is a clean JSON error
|
||
// (not a raw crash / stack trace / HTML error page)
|
||
// ------------------------------------------------------------------
|
||
if (res.statusCode >= 400) {
|
||
const body = JSON.parse(res.body);
|
||
expect(body.error).toBeDefined();
|
||
expect(typeof body.error).toBe("string");
|
||
}
|
||
},
|
||
perTestTimeout,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
});
|