mono/packages/media/cpp/orchestrator/test-media.mjs

1193 lines
40 KiB
JavaScript

/**
* Integration tests: media-img REST (`serve`) and line IPC (`ipc`) — TCP on all platforms;
* Unix domain socket on non-Windows (same JSON line protocol as TCP).
*
* Run (from packages/media/cpp, after build:release):
* npm run test:media
*
* npm run test:media -- --rest-only
* npm run test:media -- --ipc-only
* npm run test:media -- --templates-only
* npm run test:media -- --glob-batch-only
* npm run test:media:glob:raw (ARW under tests/assets/raw; skips if folder missing or empty)
* npm run test:media:url (HTTP URL inputs — needs network)
* npm run test:media:multipart (multipart upload → image body only)
*
* Fixtures: tests/assets (run `node tests/assets/build-fixtures.mjs` if missing).
*
* Each run writes **tests/test-report-last.md** (markdown: summary, per-suite timings, step ms).
*
* Env:
* MEDIA_IMG_TEST_UNIX — Unix socket path for IPC UDS test (default /tmp/media-img-test.sock)
*/
import { spawn, spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import net from 'node:net';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
mediaExePath,
defaultAssetsDir,
timeouts,
ipcUnixPath,
platform,
} from './media-presets.js';
import { requestLineJson, connectTcp, connectUnix } from './media-line-ipc.js';
import { probeTcpPort, createAssert, pipeWorkerStderr } from './test-commons.js';
import {
buildMetricsBundle,
createMetricsCollector,
describePngFile,
fileByteSize,
pngDimensionsFromBuffer,
renderMarkdownReport,
} from './reports.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Last run markdown report (overwritten each invocation). */
const TEST_REPORT_PATH = join(__dirname, '..', 'tests', 'test-report-last.md');
const EXE = mediaExePath(__dirname);
const stats = createAssert();
const { assert } = stats;
const restOnly = process.argv.includes('--rest-only');
const ipcOnly = process.argv.includes('--ipc-only');
const templatesOnly = process.argv.includes('--templates-only');
const globBatchOnly = process.argv.includes('--glob-batch-only');
const globRaw = process.argv.includes('--glob-raw');
const urlOnly = process.argv.includes('--url-only');
const multipartOnly = process.argv.includes('--multipart-only');
function getFreePort() {
return new Promise((resolvePort, reject) => {
const s = net.createServer();
s.listen(0, '127.0.0.1', () => {
const p = s.address().port;
s.close(() => resolvePort(p));
});
s.on('error', reject);
});
}
async function waitListen(host, port, label) {
for (let i = 0; i < timeouts.connectAttempts; i++) {
if (await probeTcpPort(host, port, 300)) return;
await new Promise((r) => setTimeout(r, timeouts.connectRetryMs));
}
throw new Error(`${label}: nothing listening on ${host}:${port}`);
}
function createTestReport() {
return {
meta: {},
notes: [],
suites: [],
images: [],
_cur: null,
note(text) {
this.notes.push(text);
},
addImage(row) {
this.images.push(row);
},
beginSuite(name) {
if (this._cur) this.endSuite();
this._cur = { name, steps: [], t0: performance.now() };
},
endSuite() {
if (!this._cur) return;
this._cur.totalMs = performance.now() - this._cur.t0;
this.suites.push(this._cur);
this._cur = null;
},
step(label, ms, detail = '') {
if (!this._cur) return;
this._cur.steps.push({
label,
ms: Math.round(ms * 100) / 100,
detail: detail || '',
});
},
finalize(stats, wallMs, exe, assetsDir, extra = {}) {
const prevAbort = this.meta.abortReason;
const prevErr = this.meta.uncaughtError;
this.meta = {
generatedAt: new Date().toISOString(),
node: process.version,
platform: process.platform,
arch: process.arch,
cwd: process.cwd(),
argv: process.argv.slice(2),
exe,
assetsDir: assetsDir || '',
passed: stats.passed,
failed: stats.failed,
wallClockMs: Math.round(wallMs * 100) / 100,
...(prevAbort ? { abortReason: prevAbort } : {}),
...(prevErr ? { uncaughtError: prevErr } : {}),
...extra,
};
if (this._cur) this.endSuite();
},
};
}
/** Record main fixture PNGs (bytes + pixel size from IHDR). */
function registerFixtureImages(rep, assetsDir) {
const entries = [
['fixture square-64.png', join(assetsDir, 'square-64.png')],
['fixture checker-128x128.png', join(assetsDir, 'checker-128x128.png')],
['fixture glob-in/root.png', join(assetsDir, 'glob-in', 'root.png')],
['fixture glob-in/sub/leaf.png', join(assetsDir, 'glob-in', 'sub', 'leaf.png')],
];
for (const [label, p] of entries) {
if (!existsSync(p)) continue;
const d = describePngFile(p);
rep.addImage({
label,
inputBytes: d.bytes,
widthPx: d.widthPx,
heightPx: d.heightPx,
note: 'on-disk fixture',
});
}
}
function timeSync(rep, label, fn, detailFn) {
const t0 = performance.now();
const result = fn();
const ms = performance.now() - t0;
rep.step(label, ms, detailFn ? detailFn(result) : '');
return { result, ms };
}
async function timeAsync(rep, label, fn, detailFn) {
const t0 = performance.now();
const result = await fn();
const ms = performance.now() - t0;
rep.step(label, ms, detailFn ? detailFn(result) : '');
return { result, ms };
}
function writeTestReportFile(rep, metricsCollector, startedAtIso) {
mkdirSync(dirname(TEST_REPORT_PATH), { recursive: true });
const finishedAtIso = new Date().toISOString();
const metrics = buildMetricsBundle(metricsCollector, startedAtIso, finishedAtIso);
const failed = rep.meta.failed ?? 0;
const ok =
failed === 0 && !rep.meta.abortReason && !rep.meta.uncaughtError;
let md = renderMarkdownReport({
ok,
passed: rep.meta.passed,
failed,
abortReason: rep.meta.abortReason,
uncaughtError: rep.meta.uncaughtError,
error: rep.meta.uncaughtError,
meta: {
cwd: process.cwd(),
displayName: 'media-img integration',
testName: 'media-img-integration',
writtenAt: finishedAtIso,
generatedAt: rep.meta.generatedAt,
exe: rep.meta.exe,
assetsDir: rep.meta.assetsDir,
argv: Array.isArray(rep.meta.argv) ? rep.meta.argv.join(' ') : String(rep.meta.argv ?? ''),
wallClockMs: rep.meta.wallClockMs,
},
metrics,
images: rep.images,
mediaSuites: rep.suites,
integrationNotes: rep.notes,
});
md += `\n---\n\n*Artifact: \`tests/test-report-last.md\` — overwritten on each test run.*\n`;
writeFileSync(TEST_REPORT_PATH, md, 'utf8');
}
/** Multipart POST /v1/resize: image bytes in response (not JSON). */
async function multipartResizeTests(base, inPng, rep) {
const pngBytes = readFileSync(inPng);
const srcDim = pngDimensionsFromBuffer(pngBytes);
const inPngSize = fileByteSize(inPng) ?? pngBytes.length;
rep.addImage({
label: 'multipart upload source (PNG body in form)',
inputBytes: inPngSize,
widthPx: srcDim?.width ?? null,
heightPx: srcDim?.height ?? null,
note: 'fixture bytes attached to each multipart request',
});
const blob = () => new Blob([pngBytes], { type: 'image/png' });
const form = new FormData();
form.append('file', blob(), 'square-64.png');
form.append('max_width', '32');
form.append('max_height', '32');
const { result: r1, ms: r1Ms } = await timeAsync(
rep,
'multipart POST (file → jpeg)',
async () => {
const res = await fetch(`${base}/v1/resize`, {
method: 'POST',
body: form,
signal: AbortSignal.timeout(timeouts.httpMs),
});
const ab = await res.arrayBuffer();
return { res, ab };
},
(x) => `HTTP ${x.res.status}, ${x.res.headers.get('content-type') ?? ''}`,
);
assert(r1.res.ok, 'multipart: field file');
assert(r1.res.headers.get('content-type') === 'image/jpeg', 'multipart default format → image/jpeg');
{
const ab = r1.ab;
rep.addImage({
label: 'multipart HTTP response (→ jpeg)',
inputBytes: inPngSize,
outputBytes: ab.byteLength,
computeMs: r1Ms,
contentType: r1.res.headers.get('content-type') ?? '',
note: 'resized output body',
});
assert(Buffer.from(ab).length > 0, 'multipart file: non-empty body');
}
const fImage = new FormData();
fImage.append('image', blob(), 'x.png');
fImage.append('max_width', '24');
fImage.append('format', 'webp');
const { result: r2, ms: r2Ms } = await timeAsync(
rep,
'multipart POST (image → webp)',
async () => {
const res = await fetch(`${base}/v1/resize`, {
method: 'POST',
body: fImage,
signal: AbortSignal.timeout(timeouts.httpMs),
});
const ab = await res.arrayBuffer();
return { res, ab };
},
(x) => `HTTP ${x.res.status}, ${x.res.headers.get('content-type') ?? ''}`,
);
assert(r2.res.ok, 'multipart: field image + format webp');
assert(r2.res.headers.get('content-type') === 'image/webp', 'multipart webp → image/webp');
{
const ab = r2.ab;
rep.addImage({
label: 'multipart HTTP response (→ webp)',
inputBytes: inPngSize,
outputBytes: ab.byteLength,
computeMs: r2Ms,
contentType: r2.res.headers.get('content-type') ?? '',
note: 'resized output body',
});
assert(Buffer.from(ab).byteLength > 8, 'multipart webp: RIFF/WebP header size');
}
const fUpload = new FormData();
fUpload.append('upload', blob(), 'up.png');
fUpload.append('max_width', '20');
const { result: r3, ms: r3Ms } = await timeAsync(
rep,
'multipart POST (upload alias → jpeg)',
async () => {
const res = await fetch(`${base}/v1/resize`, {
method: 'POST',
body: fUpload,
signal: AbortSignal.timeout(timeouts.httpMs),
});
const ab = await res.arrayBuffer();
return { res, ab };
},
(x) => `HTTP ${x.res.status}, ${x.res.headers.get('content-type') ?? ''}`,
);
assert(r3.res.ok, 'multipart: field upload');
assert(r3.res.headers.get('content-type') === 'image/jpeg', 'multipart upload alias → jpeg');
{
const ab = r3.ab;
rep.addImage({
label: 'multipart HTTP response (upload field → jpeg)',
inputBytes: inPngSize,
outputBytes: ab.byteLength,
computeMs: r3Ms,
contentType: r3.res.headers.get('content-type') ?? '',
note: 'resized output body',
});
assert(Buffer.from(ab).length > 0, 'multipart upload: non-empty body');
}
const fPng = new FormData();
fPng.append('file', blob(), 'square-64.png');
fPng.append('max_width', '16');
fPng.append('format', 'png');
const { result: r4, ms: r4Ms } = await timeAsync(
rep,
'multipart POST (file → png)',
async () => {
const res = await fetch(`${base}/v1/resize`, {
method: 'POST',
body: fPng,
signal: AbortSignal.timeout(timeouts.httpMs),
});
const ab = await res.arrayBuffer();
return { res, ab };
},
(x) => `HTTP ${x.res.status}, ${x.res.headers.get('content-type') ?? ''}`,
);
assert(r4.res.ok, 'multipart: format png');
assert(r4.res.headers.get('content-type') === 'image/png', 'multipart png → image/png');
{
const ab = r4.ab;
const dim = pngDimensionsFromBuffer(Buffer.from(ab));
rep.addImage({
label: 'multipart HTTP response (→ png)',
inputBytes: inPngSize,
outputBytes: ab.byteLength,
computeMs: r4Ms,
widthPx: dim?.width ?? null,
heightPx: dim?.height ?? null,
contentType: r4.res.headers.get('content-type') ?? '',
note: 'resized output body',
});
}
const noFile = new FormData();
noFile.append('max_width', '10');
const { result: r5, ms: r5Ms } = await timeAsync(
rep,
'multipart POST (no file → 400)',
async () => {
const res = await fetch(`${base}/v1/resize`, {
method: 'POST',
body: noFile,
signal: AbortSignal.timeout(timeouts.httpMs),
});
const errText = await res.text();
return { res, errText };
},
(x) => `HTTP ${x.res.status}`,
);
assert(r5.res.status === 400, 'multipart without image → 400');
rep.addImage({
label: 'multipart HTTP 400 error body',
outputBytes: Buffer.byteLength(r5.errText, 'utf8'),
computeMs: r5Ms,
contentType: r5.res.headers.get('content-type') ?? '',
note: 'JSON error',
});
const jErr = JSON.parse(r5.errText);
assert(jErr?.error && typeof jErr.error === 'string', 'multipart 400 JSON error body');
}
async function suiteMultipartOnly(assetsDir, rep) {
console.log('\n── REST: multipart POST /v1/resize only ──\n');
const inPng = resolve(assetsDir, 'square-64.png');
assert(existsSync(inPng), `fixture ${inPng}`);
const port = await getFreePort();
const proc = spawn(EXE, ['serve', '--host', '127.0.0.1', '--port', String(port)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
pipeWorkerStderr(proc, '[media-img:serve:multipart]');
rep.beginSuite('REST: multipart POST /v1/resize only');
try {
await timeAsync(rep, 'wait until HTTP server accepts', () => waitListen('127.0.0.1', port, 'serve'), () => `127.0.0.1:${port}`);
const base = `http://127.0.0.1:${port}`;
const { result: h } = await timeAsync(
rep,
'GET /health',
() => fetch(`${base}/health`, { signal: AbortSignal.timeout(timeouts.httpMs) }),
(res) => `HTTP ${res.status}`,
);
assert(h.ok, 'GET /health ok');
const hj = await h.json();
assert(hj?.ok === true && hj?.service === 'media-img', 'GET /health JSON');
await multipartResizeTests(base, inPng, rep);
} finally {
rep.endSuite();
proc.kill();
await new Promise((r) => setTimeout(r, 150));
}
}
async function suiteRest(assetsDir, rep) {
console.log('\n── REST (media-img serve) ──\n');
const inPng = resolve(assetsDir, 'square-64.png');
assert(existsSync(inPng), `fixture ${inPng}`);
const port = await getFreePort();
const proc = spawn(EXE, ['serve', '--host', '127.0.0.1', '--port', String(port)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
pipeWorkerStderr(proc, '[media-img:serve]');
rep.beginSuite('REST (media-img serve)');
try {
await timeAsync(rep, 'wait until HTTP server accepts', () => waitListen('127.0.0.1', port, 'serve'), () => `127.0.0.1:${port}`);
const base = `http://127.0.0.1:${port}`;
const { result: h } = await timeAsync(
rep,
'GET /health',
() => fetch(`${base}/health`, { signal: AbortSignal.timeout(timeouts.httpMs) }),
(res) => `HTTP ${res.status}`,
);
assert(h.ok, 'GET /health ok');
const hj = await h.json();
assert(hj?.ok === true && hj?.service === 'media-img', 'GET /health JSON');
const outDir = mkdtempSync(join(tmpdir(), 'media-rest-'));
const outPng = join(outDir, 'out-32.png');
const inPngBytes = fileByteSize(inPng);
const { result: r1, ms: r1Ms } = await timeAsync(
rep,
'POST /v1/resize JSON → PNG on disk',
async () => {
const res = await fetch(`${base}/v1/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input: inPng,
output: outPng,
max_width: 32,
max_height: 32,
}),
signal: AbortSignal.timeout(timeouts.httpMs),
});
const j = await res.json();
return { res, j };
},
(x) => `HTTP ${x.res.status}`,
);
assert(r1.res.ok, 'POST /v1/resize ok');
const j1 = r1.j;
assert(j1?.ok === true, 'resize response ok');
assert(existsSync(outPng), 'output png exists');
{
const d = describePngFile(outPng);
rep.addImage({
label: 'REST JSON → disk (out-32.png)',
inputBytes: inPngBytes,
outputBytes: d.bytes,
computeMs: r1Ms,
widthPx: d.widthPx,
heightPx: d.heightPx,
note: 'server wrote from JSON resize',
});
}
const outJpg = join(outDir, 'out.jpg');
const { result: r2, ms: r2Ms } = await timeAsync(
rep,
'POST /v1/resize JSON → JPEG on disk',
async () => {
const res = await fetch(`${base}/v1/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input: inPng,
output: outJpg,
max_width: 48,
format: 'jpeg',
}),
signal: AbortSignal.timeout(timeouts.httpMs),
});
const j = await res.json();
return { res, j };
},
(x) => `HTTP ${x.res.status}`,
);
assert(r2.res.ok, 'POST /v1/resize jpeg');
assert(existsSync(outJpg), 'output jpg exists');
{
const b = fileByteSize(outJpg);
if (b != null) {
rep.addImage({
label: 'REST JSON → disk (out.jpg)',
inputBytes: inPngBytes,
outputBytes: b,
computeMs: r2Ms,
contentType: 'image/jpeg',
note: 'server wrote from JSON resize',
});
}
}
await multipartResizeTests(base, inPng, rep);
const { result: bad } = await timeAsync(
rep,
'POST /v1/resize JSON missing input file → 500',
() =>
fetch(`${base}/v1/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '/nope/nope.png', output: join(outDir, 'x.png') }),
signal: AbortSignal.timeout(timeouts.httpMs),
}),
(res) => `HTTP ${res.status}`,
);
assert(bad.status === 500, 'POST /v1/resize missing file → 500');
rmSync(outDir, { recursive: true, force: true });
} finally {
rep.endSuite();
proc.kill();
await new Promise((r) => setTimeout(r, 150));
}
}
async function suiteDstTemplateRest(assetsDir, rep) {
console.log('\n── REST: dst path templates (${SRC_DIR}, ${SRC_NAME}, …) ──\n');
const inPng = resolve(assetsDir, 'square-64.png');
assert(existsSync(inPng), `fixture ${inPng}`);
const port = await getFreePort();
const proc = spawn(EXE, ['serve', '--host', '127.0.0.1', '--port', String(port)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
pipeWorkerStderr(proc, '[media-img:serve:tpl]');
rep.beginSuite('REST: dst path templates (${SRC_DIR}, ${SRC_NAME}, …)');
try {
await timeAsync(rep, 'wait until HTTP server accepts', () => waitListen('127.0.0.1', port, 'serve'), () => `port ${port}`);
const base = `http://127.0.0.1:${port}`;
const outDir = mkdtempSync(join(tmpdir(), 'media-dst-rest-'));
const outPattern = join(outDir, '${SRC_NAME}_thumb.webp');
const { result: rt } = await timeAsync(
rep,
'POST ${SRC_NAME}_thumb.webp template',
() =>
fetch(`${base}/v1/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input: inPng,
output: outPattern,
max_width: 24,
format: 'webp',
}),
signal: AbortSignal.timeout(timeouts.httpMs),
}),
(res) => `HTTP ${res.status}`,
);
assert(rt.ok, 'POST /v1/resize template ok');
const jt = await rt.json();
assert(jt?.ok === true, 'template resize JSON ok');
const expectedWebp = join(outDir, 'square-64_thumb.webp');
assert(existsSync(expectedWebp), `expected ${expectedWebp}`);
const outAmp = join(outDir, '&{SRC_NAME}_tiny.png');
const { result: r2 } = await timeAsync(
rep,
'POST &{SRC_NAME} + expand_glob false',
() =>
fetch(`${base}/v1/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input: inPng,
output: outAmp,
expand_glob: false,
max_width: 16,
}),
signal: AbortSignal.timeout(timeouts.httpMs),
}),
(res) => `HTTP ${res.status}`,
);
assert(r2.ok, 'template + expand_glob false');
assert(existsSync(join(outDir, 'square-64_tiny.png')), 'ampersand template output');
const subDir = join(assetsDir, 'tpl-sub');
const outNested = join(subDir, '${SRC_NAME}_nested.png');
const { result: rn } = await timeAsync(
rep,
'POST nested ${SRC_NAME} template',
() =>
fetch(`${base}/v1/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input: inPng,
output: outNested,
max_width: 12,
}),
signal: AbortSignal.timeout(timeouts.httpMs),
}),
(res) => `HTTP ${res.status}`,
);
assert(rn.ok, 'POST nested subdir + ${SRC_NAME}');
const expectedNested = join(subDir, 'square-64_nested.png');
assert(existsSync(expectedNested), `nested template ${expectedNested}`);
const outViaSrcDir = '${SRC_DIR}/tpl-from-srcdir/${SRC_NAME}_sd.webp';
const { result: rsd } = await timeAsync(
rep,
'POST ${SRC_DIR} in output path',
() =>
fetch(`${base}/v1/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input: inPng,
output: outViaSrcDir,
max_width: 10,
format: 'webp',
}),
signal: AbortSignal.timeout(timeouts.httpMs),
}),
(res) => `HTTP ${res.status}`,
);
assert(rsd.ok, 'POST ${SRC_DIR} in output path');
const fromSrcDir = join(assetsDir, 'tpl-from-srcdir', 'square-64_sd.webp');
assert(existsSync(fromSrcDir), `expected ${fromSrcDir}`);
rmSync(outDir, { recursive: true, force: true });
rmSync(subDir, { recursive: true, force: true });
rmSync(join(assetsDir, 'tpl-from-srcdir'), { recursive: true, force: true });
} finally {
rep.endSuite();
proc.kill();
await new Promise((r) => setTimeout(r, 150));
}
}
async function suiteIpcTcp(assetsDir, rep) {
console.log('\n── IPC TCP (media-img ipc --host --port) ──\n');
const inPng = resolve(assetsDir, 'square-64.png');
assert(existsSync(inPng), `fixture ${inPng}`);
const port = await getFreePort();
const proc = spawn(EXE, ['ipc', '--host', '127.0.0.1', '--port', String(port)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
pipeWorkerStderr(proc, '[media-img:ipc]');
rep.beginSuite('IPC TCP (media-img ipc --host --port)');
try {
await timeAsync(rep, 'wait until IPC TCP accepts', () => waitListen('127.0.0.1', port, 'ipc'), () => `port ${port}`);
const outDir = mkdtempSync(join(tmpdir(), 'media-ipc-tcp-'));
const outPng = join(outDir, 'ipc-out.png');
const { result: sock } = await timeAsync(rep, 'TCP connect (line 1)', () => connectTcp('127.0.0.1', port), () => '');
const { result: res, ms: ipcMs } = await timeAsync(
rep,
'IPC JSON line (ok resize)',
() =>
requestLineJson(
sock,
{
input: inPng,
output: outPng,
max_width: 32,
max_height: 32,
},
timeouts.ipcReadMs,
),
(r) => (r && typeof r === 'object' ? `ok=${r.ok}` : ''),
);
sock.destroy();
assert(res?.ok === true, 'IPC line JSON ok');
assert(existsSync(outPng), 'IPC output file exists');
{
const d = describePngFile(outPng);
const inB = fileByteSize(inPng);
rep.addImage({
label: 'IPC TCP → disk (ipc-out.png)',
inputBytes: inB,
outputBytes: d.bytes,
computeMs: ipcMs,
widthPx: d.widthPx,
heightPx: d.heightPx,
note: 'line-JSON resize',
});
}
const { result: sock2 } = await timeAsync(rep, 'TCP connect (line 2)', () => connectTcp('127.0.0.1', port), () => '');
const { result: res2 } = await timeAsync(
rep,
'IPC JSON line (missing input)',
() =>
requestLineJson(sock2, { input: '/not/found.png', output: join(outDir, 'bad.png') }, timeouts.ipcReadMs),
(r) => (r && typeof r === 'object' ? `ok=${r.ok}` : ''),
);
sock2.destroy();
assert(res2?.ok === false, 'IPC error path ok=false');
rmSync(outDir, { recursive: true, force: true });
} finally {
rep.endSuite();
proc.kill();
await new Promise((r) => setTimeout(r, 150));
}
}
async function suiteDstTemplateIpcTcp(assetsDir, rep) {
console.log('\n── IPC TCP: dst templates ──\n');
const inPng = resolve(assetsDir, 'square-64.png');
assert(existsSync(inPng), `fixture ${inPng}`);
const port = await getFreePort();
const proc = spawn(EXE, ['ipc', '--host', '127.0.0.1', '--port', String(port)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
pipeWorkerStderr(proc, '[media-img:ipc:tpl]');
rep.beginSuite('IPC TCP: dst templates');
try {
await timeAsync(rep, 'wait until IPC TCP accepts', () => waitListen('127.0.0.1', port, 'ipc'), () => `port ${port}`);
const outDir = mkdtempSync(join(tmpdir(), 'media-dst-ipc-'));
const outPattern = join(outDir, '${SRC_NAME}_ipc.webp');
const { result: sock } = await timeAsync(rep, 'TCP connect', () => connectTcp('127.0.0.1', port), () => '');
const { result: res } = await timeAsync(
rep,
'IPC JSON line (${SRC_NAME}_ipc.webp)',
() =>
requestLineJson(
sock,
{
input: inPng,
output: outPattern,
max_width: 20,
format: 'webp',
},
timeouts.ipcReadMs,
),
(r) => (r && typeof r === 'object' ? `ok=${r.ok}` : ''),
);
sock.destroy();
assert(res?.ok === true, 'IPC template ok');
assert(existsSync(join(outDir, 'square-64_ipc.webp')), 'IPC template output file');
rmSync(outDir, { recursive: true, force: true });
} finally {
rep.endSuite();
proc.kill();
await new Promise((r) => setTimeout(r, 150));
}
}
function suiteDstTemplateCli(assetsDir, rep) {
console.log('\n── CLI: resize --src / --dst templates ──\n');
const inPng = resolve(assetsDir, 'square-64.png');
assert(existsSync(inPng), `fixture ${inPng}`);
const outDir = mkdtempSync(join(tmpdir(), 'media-dst-cli-'));
const dst = join(outDir, '&{SRC_NAME}_cli.jpg');
rep.beginSuite('CLI: resize --src / --dst templates');
try {
const { result: r } = timeSync(
rep,
'spawnSync resize (template dst)',
() =>
spawnSync(EXE, ['resize', '--src', inPng, '--dst', dst, '--max-width', '18', '--format', 'jpeg'], {
encoding: 'utf8',
}),
(x) => `exit ${x.status}`,
);
assert(r.status === 0, `CLI template exit 0, stderr: ${r.stderr}`);
assert(existsSync(join(outDir, 'square-64_cli.jpg')), 'CLI template output');
rmSync(outDir, { recursive: true, force: true });
} finally {
rep.endSuite();
}
}
/**
* HTTPS fetch + default output basename under cwd (picsum → 200.jpg, 1600.jpg).
* Requires network; can be slow.
*/
function suiteUrlResizeCli(rep) {
console.log('\n── CLI: resize https://picsum.photos (default cwd output) ──\n');
const tmp = mkdtempSync(join(tmpdir(), 'media-url-'));
const cases = [
{ url: 'https://picsum.photos/200', expect: '200.jpg' },
{ url: 'https://picsum.photos/1600', expect: '1600.jpg' },
];
rep.beginSuite('CLI: resize HTTPS URL (picsum.photos)');
try {
for (const { url, expect } of cases) {
const outPath = join(tmp, expect);
if (existsSync(outPath)) {
try {
unlinkSync(outPath);
} catch {
/* ignore */
}
}
const { result: r, ms: urlMs } = timeSync(
rep,
`spawnSync resize URL → ${expect}`,
() =>
spawnSync(
EXE,
['resize', url, '--max-width', '64', '--max-height', '64', '--no-cache', '--url-timeout', '45'],
{
cwd: tmp,
encoding: 'utf8',
timeout: 120_000,
},
),
(x) => `exit ${x.status}, network+libvips`,
);
assert(r.status === 0, `resize URL exit 0 (${expect}): ${r.stderr || r.stdout}`);
assert(existsSync(outPath), `expected output ${outPath}`);
const outSz = fileByteSize(outPath);
rep.addImage({
label: `CLI URL resize → ${expect}`,
outputBytes: outSz,
computeMs: urlMs,
note: 'HTTPS source + resize',
});
}
} finally {
rep.endSuite();
rmSync(tmp, { recursive: true, force: true });
}
}
/** Recursive glob under tests/assets/glob-in; outputs kept for manual inspection (see tests/assets/.gitignore). */
function suiteGlobBatchCli(assetsDir, rep) {
console.log('\n── CLI: **/*.png glob + ${SRC_DIR}/out/${SRC_NAME}_medium.jpg ──\n');
console.log(' (outputs under glob-in/**/out/ are kept for manual verification)\n');
const rootPng = join(assetsDir, 'glob-in', 'root.png');
const leafPng = join(assetsDir, 'glob-in', 'sub', 'leaf.png');
assert(existsSync(rootPng), `fixture ${rootPng}`);
assert(existsSync(leafPng), `fixture ${leafPng}`);
const dstTmpl = '${SRC_DIR}/out/${SRC_NAME}_medium.jpg';
const jobs = [
{ src: rootPng, out: join(assetsDir, 'glob-in', 'out', 'root_medium.jpg') },
{ src: leafPng, out: join(assetsDir, 'glob-in', 'sub', 'out', 'leaf_medium.jpg') },
];
rep.beginSuite('CLI: recursive glob batch + dst templates');
try {
for (const job of jobs) {
const srcAbs = resolve(job.src).replace(/\\/g, '/');
const { result: r, ms } = timeSync(
rep,
`spawnSync resize (${basename(job.src)} → _medium.jpg)`,
() =>
spawnSync(EXE, ['resize', '--src', srcAbs, '--dst', dstTmpl, '--max-width', '40', '--format', 'jpeg'], {
encoding: 'utf8',
}),
(x) => `exit ${x.status}`,
);
assert(r.status === 0, `glob batch exit 0, stderr: ${r.stderr}`);
assert(existsSync(job.out), `expected ${job.out}`);
rep.addImage({
label: `CLI glob: ${basename(job.src)}${basename(job.out)}`,
inputBytes: fileByteSize(job.src),
outputBytes: fileByteSize(job.out),
computeMs: ms,
note: '${SRC_DIR}/out/${SRC_NAME}_medium.jpg',
});
}
} finally {
rep.endSuite();
}
}
function collectArwPathsRecursive(rawDir) {
const out = [];
function walk(d) {
let entries;
try {
entries = readdirSync(d, { withFileTypes: true });
} catch {
return;
}
for (const ent of entries) {
const p = join(d, ent.name);
if (ent.isDirectory()) {
if (ent.name === 'out') continue;
walk(p);
} else if (/\.arw$/i.test(ent.name)) out.push(p);
}
}
walk(rawDir);
return out;
}
/** Recursive glob under tests/assets/raw for *.arw (user-supplied; gitignored). */
function suiteGlobBatchRawArw(assetsDir, rep) {
const rawRoot = join(assetsDir, 'raw');
console.log('\n── CLI: **/*.arw glob (tests/assets/raw) + ${SRC_DIR}/out/${SRC_NAME}_medium.jpg ──\n');
console.log(' (outputs under raw/**/out/ are kept for manual verification)\n');
if (!existsSync(rawRoot)) {
console.log(' (skip: tests/assets/raw does not exist — create it and add .arw files)\n');
rep.note('CLI glob batch (raw ARW): skipped (tests/assets/raw missing)');
return;
}
const arwFiles = collectArwPathsRecursive(rawRoot);
if (arwFiles.length === 0) {
console.log(' (skip: no .arw files under tests/assets/raw)\n');
rep.note('CLI glob batch (raw ARW): skipped (no .arw files)');
return;
}
const dstTmpl = '${SRC_DIR}/out/${SRC_NAME}_medium.jpg';
rep.beginSuite('CLI: recursive glob batch (raw **/*.arw) + dst templates');
try {
for (const arwPath of arwFiles) {
const srcAbs = resolve(arwPath).replace(/\\/g, '/');
const stem = basename(arwPath).replace(/\.arw$/i, '');
const expected = join(dirname(arwPath), 'out', `${stem}_medium.jpg`);
const { result: r, ms } = timeSync(
rep,
`spawnSync resize (${basename(arwPath)} → _medium.jpg)`,
() =>
spawnSync(EXE, ['resize', '--src', srcAbs, '--dst', dstTmpl, '--max-width', '40', '--format', 'jpeg'], {
encoding: 'utf8',
}),
(x) => `exit ${x.status}`,
);
assert(r.status === 0, `glob batch raw exit 0, stderr: ${r.stderr}`);
assert(existsSync(expected), `expected ${expected}`);
rep.addImage({
label: `CLI raw: ${basename(arwPath)}${basename(expected)}`,
inputBytes: fileByteSize(arwPath),
outputBytes: fileByteSize(expected),
computeMs: ms,
note: 'ARW → JPEG',
});
}
} finally {
rep.endSuite();
}
}
async function suiteIpcUnix(assetsDir, rep) {
console.log('\n── IPC Unix (media-img ipc --unix) ──\n');
const path = ipcUnixPath();
if (existsSync(path)) {
try {
unlinkSync(path);
} catch {
/* ignore */
}
}
const inPng = resolve(assetsDir, 'checker-128x128.png');
assert(existsSync(inPng), `fixture ${inPng}`);
const proc = spawn(EXE, ['ipc', '--unix', path], {
stdio: ['ignore', 'pipe', 'pipe'],
});
pipeWorkerStderr(proc, '[media-img:ipc:uds]');
const outDir = mkdtempSync(join(tmpdir(), 'media-ipc-uds-'));
const outPng = join(outDir, 'uds-out.png');
rep.beginSuite('IPC Unix (media-img ipc --unix)');
try {
await timeAsync(rep, 'wait for UDS path', async () => {
for (let i = 0; i < timeouts.connectAttempts; i++) {
if (existsSync(path)) return;
await new Promise((r) => setTimeout(r, timeouts.connectRetryMs));
}
throw new Error('unix socket path did not appear');
}, () => path);
const { result: res, ms: udsMs } = await timeAsync(
rep,
'Unix connect + IPC JSON line',
async () => {
let sock;
for (let i = 0; i < timeouts.connectAttempts; i++) {
try {
sock = await connectUnix(path);
break;
} catch {
if (i === timeouts.connectAttempts - 1) throw new Error('connect unix failed');
await new Promise((r) => setTimeout(r, timeouts.connectRetryMs));
}
}
const lineRes = await requestLineJson(
sock,
{
input: inPng,
output: outPng,
max_width: 64,
},
timeouts.ipcReadMs,
);
sock.destroy();
return lineRes;
},
(r) => (r && typeof r === 'object' ? `ok=${r.ok}` : ''),
);
assert(res?.ok === true, 'UDS line JSON ok');
assert(existsSync(outPng), 'UDS output file exists');
{
const d = describePngFile(outPng);
const inB = fileByteSize(inPng);
rep.addImage({
label: 'IPC Unix → disk (uds-out.png)',
inputBytes: inB,
outputBytes: d.bytes,
computeMs: udsMs,
widthPx: d.widthPx,
heightPx: d.heightPx,
note: 'UDS line-JSON resize',
});
}
} finally {
rep.endSuite();
proc.kill();
try {
if (existsSync(path)) unlinkSync(path);
} catch {
/* ignore */
}
rmSync(outDir, { recursive: true, force: true });
await new Promise((r) => setTimeout(r, 150));
}
}
async function run() {
const wallStart = performance.now();
const rep = createTestReport();
const metricsCollector = createMetricsCollector();
const startedAtIso = new Date().toISOString();
let exitCode = 1;
let assetsDir = '';
try {
assetsDir = resolve(defaultAssetsDir(__dirname));
if (!existsSync(EXE)) {
rep.meta.abortReason = `Binary not found: ${EXE}`;
console.error(rep.meta.abortReason);
return;
}
if (!(globBatchOnly && globRaw)) {
const need = ['square-64.png', 'checker-128x128.png', 'glob-in/root.png', 'glob-in/sub/leaf.png'];
const missing = need.filter((f) => !existsSync(join(assetsDir, f)));
if (missing.length) {
rep.meta.abortReason = `Missing fixtures under ${assetsDir}: ${missing.join(', ')} (run: node tests/assets/build-fixtures.mjs)`;
console.error(`Missing fixtures under ${assetsDir}: ${missing.join(', ')}`);
console.error('Run: node tests/assets/build-fixtures.mjs');
return;
}
}
console.log(`\nmedia-img integration tests\n binary: ${EXE}\n assets: ${assetsDir}\n`);
registerFixtureImages(rep, assetsDir);
if (templatesOnly) {
await suiteDstTemplateRest(assetsDir, rep);
await suiteDstTemplateIpcTcp(assetsDir, rep);
suiteDstTemplateCli(assetsDir, rep);
console.log(`\nDone (templates only). Passed: ${stats.passed} Failed: ${stats.failed}\n`);
exitCode = stats.failed > 0 ? 1 : 0;
return;
}
if (globBatchOnly) {
if (globRaw) {
suiteGlobBatchRawArw(assetsDir, rep);
console.log(`\nDone (glob batch raw only). Passed: ${stats.passed} Failed: ${stats.failed}\n`);
} else {
suiteGlobBatchCli(assetsDir, rep);
console.log(`\nDone (glob batch only). Passed: ${stats.passed} Failed: ${stats.failed}\n`);
}
exitCode = stats.failed > 0 ? 1 : 0;
return;
}
if (urlOnly) {
suiteUrlResizeCli(rep);
console.log(`\nDone (URL only). Passed: ${stats.passed} Failed: ${stats.failed}\n`);
exitCode = stats.failed > 0 ? 1 : 0;
return;
}
if (multipartOnly) {
await suiteMultipartOnly(assetsDir, rep);
console.log(`\nDone (multipart only). Passed: ${stats.passed} Failed: ${stats.failed}\n`);
exitCode = stats.failed > 0 ? 1 : 0;
return;
}
const runRest = !ipcOnly;
const runIpc = !restOnly;
if (runRest) {
await suiteRest(assetsDir, rep);
await suiteDstTemplateRest(assetsDir, rep);
}
if (runIpc) {
await suiteIpcTcp(assetsDir, rep);
await suiteDstTemplateIpcTcp(assetsDir, rep);
if (!platform.isWin) {
await suiteIpcUnix(assetsDir, rep);
} else {
console.log('\n── IPC Unix (media-img ipc --unix) ──\n');
console.log(' (skipped on Windows — use TCP IPC or run tests on Linux/macOS)\n');
rep.note('IPC Unix suite skipped on this platform (Windows).');
}
}
if (!ipcOnly) {
suiteDstTemplateCli(assetsDir, rep);
suiteGlobBatchCli(assetsDir, rep);
}
console.log(`\nDone. Passed: ${stats.passed} Failed: ${stats.failed}\n`);
exitCode = stats.failed > 0 ? 1 : 0;
} catch (e) {
rep.meta.uncaughtError = String(e?.stack || e);
console.error(e);
exitCode = 1;
} finally {
rep.finalize(stats, performance.now() - wallStart, EXE, assetsDir);
try {
writeTestReportFile(rep, metricsCollector, startedAtIso);
console.log(`Test report written: ${TEST_REPORT_PATH}`);
} catch (e) {
console.error('Failed to write test report:', e);
}
process.exit(exitCode);
}
}
run().catch((e) => {
console.error(e);
process.exit(1);
});