mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { apiToolPath } from "../../packages/shared/src/constants.js";
|
|
import {
|
|
expectBackgroundBlurEnergyReduced,
|
|
expectConfiguredBackground,
|
|
expectForegroundPreserved,
|
|
expectKnownTranscript,
|
|
expectObservablePixelChange,
|
|
expectSrtArtifact,
|
|
} from "../helpers/installed-ai-output-oracles.js";
|
|
|
|
const REQUIRED_BUNDLES = ["transcription", "background-removal"] as const;
|
|
|
|
interface BundleState {
|
|
error?: string | null;
|
|
id: string;
|
|
status: string;
|
|
}
|
|
|
|
interface TerminalResult {
|
|
downloadUrl?: string;
|
|
processedSize?: number;
|
|
}
|
|
|
|
interface TerminalFrame {
|
|
error?: string;
|
|
jobId?: string;
|
|
phase?: string;
|
|
result?: TerminalResult;
|
|
type?: string;
|
|
}
|
|
|
|
export interface VerifierFixtures {
|
|
portrait: Buffer;
|
|
speechMp4: Buffer;
|
|
speechWav: Buffer;
|
|
}
|
|
|
|
export interface InstalledAiVerifierOptions {
|
|
baseUrl: string;
|
|
fetchImpl?: typeof fetch;
|
|
fixtures?: VerifierFixtures;
|
|
installPollMs?: number;
|
|
installTimeoutMs?: number;
|
|
jobTimeoutMs?: number;
|
|
password: string;
|
|
requestTimeoutMs?: number;
|
|
sleep?: (delayMs: number) => Promise<void>;
|
|
username: string;
|
|
}
|
|
|
|
interface ToolSpec {
|
|
contentType: string;
|
|
file: Buffer;
|
|
filename: string;
|
|
settings: Record<string, unknown>;
|
|
toolId: string;
|
|
verify: (output: Buffer) => Promise<void> | void;
|
|
}
|
|
|
|
function assertRecord(value: unknown, context: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
throw new Error(`${context} returned a non-object payload`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
async function responseJson(response: Response, context: string): Promise<Record<string, unknown>> {
|
|
const body = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(`${context} failed with HTTP ${response.status}: ${body.slice(0, 300)}`);
|
|
}
|
|
try {
|
|
return assertRecord(JSON.parse(body), context);
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) throw new Error(`${context} returned invalid JSON`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function withRequestTimeout<T>(
|
|
timeoutMs: number,
|
|
context: string,
|
|
operation: (signal: AbortSignal) => Promise<T>,
|
|
): Promise<T> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
return await operation(controller.signal);
|
|
} catch (error) {
|
|
if (controller.signal.aborted) throw new Error(`${context} timed out after ${timeoutMs}ms`);
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function requestJson(
|
|
fetchImpl: typeof fetch,
|
|
url: string,
|
|
init: RequestInit,
|
|
timeoutMs: number,
|
|
context: string,
|
|
): Promise<{ payload: Record<string, unknown>; response: Response }> {
|
|
return withRequestTimeout(timeoutMs, context, async (signal) => {
|
|
const response = await fetchImpl(url, { ...init, redirect: "error", signal });
|
|
const payload = await responseJson(response, context);
|
|
return { payload, response };
|
|
});
|
|
}
|
|
|
|
async function loadFixtures(): Promise<VerifierFixtures> {
|
|
const [portrait, speechMp4, speechWav] = await Promise.all([
|
|
readFile("tests/fixtures/image/valid/portrait-color.jpg"),
|
|
readFile("tests/fixtures/video/valid/speech-10s.mp4"),
|
|
readFile("tests/fixtures/audio/valid/speech-10s.wav"),
|
|
]);
|
|
return { portrait, speechMp4, speechWav };
|
|
}
|
|
|
|
function authHeaders(token: string, extra?: HeadersInit): Headers {
|
|
const headers = new Headers(extra);
|
|
headers.set("authorization", `Bearer ${token}`);
|
|
return headers;
|
|
}
|
|
|
|
async function readTerminalFrame(
|
|
fetchImpl: typeof fetch,
|
|
url: string,
|
|
token: string,
|
|
timeoutMs: number,
|
|
expectedJobId: string,
|
|
): Promise<TerminalFrame> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
try {
|
|
const response = await fetchImpl(url, {
|
|
headers: authHeaders(token),
|
|
redirect: "error",
|
|
signal: controller.signal,
|
|
});
|
|
if (response.status !== 200 || !response.body) {
|
|
throw new Error(`job progress failed with HTTP ${response.status}`);
|
|
}
|
|
const contentType = (response.headers.get("content-type") ?? "").split(";", 1)[0];
|
|
if (contentType !== "text/event-stream") {
|
|
throw new Error(`job progress MIME mismatch: ${contentType || "missing"}`);
|
|
}
|
|
reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let pending = "";
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
pending += decoder.decode(value, { stream: !done });
|
|
const lines = pending.split("\n");
|
|
pending = done ? "" : (lines.pop() ?? "");
|
|
for (const rawLine of lines) {
|
|
const line = rawLine.trim();
|
|
if (!line.startsWith("data:")) continue;
|
|
let frame: TerminalFrame;
|
|
try {
|
|
frame = assertRecord(JSON.parse(line.slice(5).trim()), "job progress") as TerminalFrame;
|
|
} catch {
|
|
throw new Error("job progress emitted malformed JSON");
|
|
}
|
|
if (frame.type === "heartbeat") continue;
|
|
if (frame.type !== "single" || frame.jobId !== expectedJobId) {
|
|
throw new Error("job progress emitted a frame for the wrong job or job type");
|
|
}
|
|
if (frame.phase === "failed") {
|
|
throw new Error(`job failed: ${frame.error || "unknown worker error"}`);
|
|
}
|
|
if (frame.phase === "complete") return frame;
|
|
}
|
|
if (done) throw new Error("job progress stream ended before a terminal frame");
|
|
}
|
|
} catch (error) {
|
|
if (controller.signal.aborted) throw new Error(`job progress timed out after ${timeoutMs}ms`);
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
await reader?.cancel().catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function listBundles(
|
|
fetchImpl: typeof fetch,
|
|
baseUrl: string,
|
|
token: string,
|
|
requestTimeoutMs: number,
|
|
): Promise<BundleState[]> {
|
|
const { payload } = await requestJson(
|
|
fetchImpl,
|
|
`${baseUrl}/api/v1/features`,
|
|
{ headers: authHeaders(token) },
|
|
requestTimeoutMs,
|
|
"feature inventory",
|
|
);
|
|
const bundles = payload.bundles;
|
|
if (!Array.isArray(bundles)) throw new Error("feature inventory omitted bundles");
|
|
return bundles.map((bundle, index) => {
|
|
const record = assertRecord(bundle, `feature inventory bundle ${index}`);
|
|
if (typeof record.id !== "string" || typeof record.status !== "string") {
|
|
throw new Error(`feature inventory bundle ${index} has invalid identity`);
|
|
}
|
|
return {
|
|
id: record.id,
|
|
status: record.status,
|
|
error: typeof record.error === "string" ? record.error : null,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function installRealBundles(
|
|
fetchImpl: typeof fetch,
|
|
baseUrl: string,
|
|
token: string,
|
|
options: Required<
|
|
Pick<
|
|
InstalledAiVerifierOptions,
|
|
"installPollMs" | "installTimeoutMs" | "requestTimeoutMs" | "sleep"
|
|
>
|
|
>,
|
|
): Promise<void> {
|
|
const initial = await listBundles(fetchImpl, baseUrl, token, options.requestTimeoutMs);
|
|
for (const bundleId of REQUIRED_BUNDLES) {
|
|
const bundle = initial.find((candidate) => candidate.id === bundleId);
|
|
if (!bundle) throw new Error(`feature inventory omitted required bundle ${bundleId}`);
|
|
if (bundle.status !== "not_installed") {
|
|
throw new Error(`fresh production data unexpectedly reports ${bundleId} as ${bundle.status}`);
|
|
}
|
|
}
|
|
|
|
for (const bundleId of REQUIRED_BUNDLES) {
|
|
const { payload, response } = await requestJson(
|
|
fetchImpl,
|
|
`${baseUrl}/api/v1/admin/features/${bundleId}/install`,
|
|
{ method: "POST", headers: authHeaders(token) },
|
|
options.requestTimeoutMs,
|
|
`${bundleId} install`,
|
|
);
|
|
if (response.status !== 202 || typeof payload.jobId !== "string" || !payload.jobId) {
|
|
throw new Error(`${bundleId} install did not return a 202 job identity`);
|
|
}
|
|
}
|
|
|
|
const deadline = Date.now() + options.installTimeoutMs;
|
|
while (true) {
|
|
const inventory = await listBundles(fetchImpl, baseUrl, token, options.requestTimeoutMs);
|
|
let installed = 0;
|
|
for (const bundleId of REQUIRED_BUNDLES) {
|
|
const bundle = inventory.find((candidate) => candidate.id === bundleId);
|
|
if (!bundle) throw new Error(`feature inventory omitted required bundle ${bundleId}`);
|
|
if (bundle.status === "error" || bundle.status === "incompatible") {
|
|
throw new Error(`${bundleId} install failed: ${bundle.error || bundle.status}`);
|
|
}
|
|
if (bundle.status === "installed") installed += 1;
|
|
}
|
|
if (installed === REQUIRED_BUNDLES.length) return;
|
|
if (Date.now() >= deadline) {
|
|
throw new Error(`AI bundle installation timed out after ${options.installTimeoutMs}ms`);
|
|
}
|
|
await options.sleep(options.installPollMs);
|
|
}
|
|
}
|
|
|
|
async function runTool(
|
|
fetchImpl: typeof fetch,
|
|
baseUrl: string,
|
|
token: string,
|
|
timeoutMs: number,
|
|
requestTimeoutMs: number,
|
|
spec: ToolSpec,
|
|
): Promise<void> {
|
|
const form = new FormData();
|
|
form.append(
|
|
"file",
|
|
new Blob([new Uint8Array(spec.file)], { type: spec.contentType }),
|
|
spec.filename,
|
|
);
|
|
form.append("settings", JSON.stringify(spec.settings));
|
|
const { payload: accepted, response } = await requestJson(
|
|
fetchImpl,
|
|
`${baseUrl}${apiToolPath(spec.toolId)}`,
|
|
{ method: "POST", headers: authHeaders(token), body: form },
|
|
requestTimeoutMs,
|
|
`${spec.toolId} submission`,
|
|
);
|
|
if (response.status !== 202 || typeof accepted.jobId !== "string" || !accepted.jobId) {
|
|
throw new Error(`${spec.toolId} did not return a 202 job identity`);
|
|
}
|
|
|
|
const jobId = accepted.jobId;
|
|
const terminal = await readTerminalFrame(
|
|
fetchImpl,
|
|
`${baseUrl}/api/v1/jobs/${encodeURIComponent(jobId)}/progress`,
|
|
token,
|
|
timeoutMs,
|
|
jobId,
|
|
);
|
|
const downloadUrl = terminal.result?.downloadUrl;
|
|
if (typeof downloadUrl !== "string" || !downloadUrl) {
|
|
throw new Error(`${spec.toolId} terminal result omitted downloadUrl`);
|
|
}
|
|
const base = new URL(baseUrl);
|
|
const download = new URL(downloadUrl, base);
|
|
const expectedPrefix = `/api/v1/download/${encodeURIComponent(jobId)}/`;
|
|
if (
|
|
download.origin !== base.origin ||
|
|
download.username !== "" ||
|
|
download.password !== "" ||
|
|
download.search !== "" ||
|
|
download.hash !== "" ||
|
|
!download.pathname.startsWith(expectedPrefix)
|
|
) {
|
|
throw new Error(`${spec.toolId} returned an untrusted download URL`);
|
|
}
|
|
const artifact = await withRequestTimeout(
|
|
requestTimeoutMs,
|
|
`${spec.toolId} artifact download`,
|
|
async (signal) => {
|
|
const artifactResponse = await fetchImpl(download, {
|
|
headers: authHeaders(token),
|
|
redirect: "error",
|
|
signal,
|
|
});
|
|
if (!artifactResponse.ok) {
|
|
throw new Error(
|
|
`${spec.toolId} artifact download failed with HTTP ${artifactResponse.status}`,
|
|
);
|
|
}
|
|
const expectedType =
|
|
spec.toolId === "transcribe-audio"
|
|
? "text/plain"
|
|
: spec.toolId === "auto-subtitles"
|
|
? "application/x-subrip"
|
|
: spec.toolId === "blur-background"
|
|
? "image/webp"
|
|
: "image/png";
|
|
const actualType = (artifactResponse.headers.get("content-type") ?? "").split(";", 1)[0];
|
|
if (actualType !== expectedType) {
|
|
throw new Error(`${spec.toolId} artifact MIME mismatch: ${actualType || "missing"}`);
|
|
}
|
|
return Buffer.from(await artifactResponse.arrayBuffer());
|
|
},
|
|
);
|
|
if (artifact.length === 0) throw new Error(`${spec.toolId} returned an empty artifact`);
|
|
if (
|
|
typeof terminal.result?.processedSize !== "number" ||
|
|
terminal.result.processedSize !== artifact.length
|
|
) {
|
|
throw new Error(`${spec.toolId} artifact size does not match the terminal result`);
|
|
}
|
|
await spec.verify(artifact);
|
|
}
|
|
|
|
export async function verifyInstalledAiProduction(
|
|
options: InstalledAiVerifierOptions,
|
|
): Promise<void> {
|
|
const baseUrl = options.baseUrl.replace(/\/$/u, "");
|
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
const fixtures = options.fixtures ?? (await loadFixtures());
|
|
const requestTimeoutMs = options.requestTimeoutMs ?? 60_000;
|
|
const { payload: login } = await requestJson(
|
|
fetchImpl,
|
|
`${baseUrl}/api/auth/login`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ username: options.username, password: options.password }),
|
|
},
|
|
requestTimeoutMs,
|
|
"admin login",
|
|
);
|
|
if (typeof login.token !== "string" || !login.token) {
|
|
throw new Error("admin login omitted bearer token");
|
|
}
|
|
const token = login.token;
|
|
|
|
await installRealBundles(fetchImpl, baseUrl, token, {
|
|
installPollMs: options.installPollMs ?? 10_000,
|
|
installTimeoutMs: options.installTimeoutMs ?? 2 * 60 * 60_000,
|
|
requestTimeoutMs,
|
|
sleep:
|
|
options.sleep ??
|
|
((delayMs) => new Promise((resolveSleep) => setTimeout(resolveSleep, delayMs))),
|
|
});
|
|
|
|
const toolSpecs: ToolSpec[] = [
|
|
{
|
|
toolId: "transcribe-audio",
|
|
file: fixtures.speechWav,
|
|
filename: "speech-10s.wav",
|
|
contentType: "audio/wav",
|
|
settings: { language: "auto", outputFormat: "txt" },
|
|
verify: (artifact) => expectKnownTranscript(artifact.toString("utf8")),
|
|
},
|
|
{
|
|
toolId: "auto-subtitles",
|
|
file: fixtures.speechMp4,
|
|
filename: "speech-10s.mp4",
|
|
contentType: "video/mp4",
|
|
settings: { language: "auto", format: "srt" },
|
|
verify: (artifact) => {
|
|
const subtitles = artifact.toString("utf8");
|
|
expectSrtArtifact(subtitles);
|
|
expectKnownTranscript(subtitles);
|
|
},
|
|
},
|
|
{
|
|
toolId: "blur-background",
|
|
file: fixtures.portrait,
|
|
filename: "portrait-color.jpg",
|
|
contentType: "image/jpeg",
|
|
settings: { intensity: 75, feather: 3, format: "webp" },
|
|
verify: async (artifact) => {
|
|
await expectObservablePixelChange(fixtures.portrait, artifact);
|
|
await expectBackgroundBlurEnergyReduced(fixtures.portrait, artifact);
|
|
await expectForegroundPreserved(fixtures.portrait, artifact);
|
|
},
|
|
},
|
|
{
|
|
toolId: "background-replace",
|
|
file: fixtures.portrait,
|
|
filename: "portrait-color.jpg",
|
|
contentType: "image/jpeg",
|
|
settings: { backgroundType: "color", color: "#ff0000", format: "png" },
|
|
verify: async (artifact) => {
|
|
await expectObservablePixelChange(fixtures.portrait, artifact);
|
|
await expectConfiguredBackground(artifact, "solid-red");
|
|
await expectForegroundPreserved(fixtures.portrait, artifact);
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const spec of toolSpecs) {
|
|
await runTool(
|
|
fetchImpl,
|
|
baseUrl,
|
|
token,
|
|
options.jobTimeoutMs ?? 20 * 60_000,
|
|
requestTimeoutMs,
|
|
spec,
|
|
);
|
|
}
|
|
}
|
|
|
|
function requiredEnv(name: string): string {
|
|
const value = process.env[name];
|
|
if (!value) throw new Error(`${name} is required`);
|
|
return value;
|
|
}
|
|
|
|
const isMain =
|
|
process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
if (isMain) {
|
|
verifyInstalledAiProduction({
|
|
baseUrl: requiredEnv("QA_BASE_URL"),
|
|
username: requiredEnv("QA_USERNAME"),
|
|
password: requiredEnv("QA_PASSWORD"),
|
|
})
|
|
.then(() => {
|
|
process.stdout.write("Verified installed transcription and background-removal outputs.\n");
|
|
})
|
|
.catch((error) => {
|
|
process.stderr.write(`Installed AI verification failed: ${String(error)}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|