From eccdcf5ee2701ebcb45f213c18f2bea5a19da48d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 00:53:35 -0400 Subject: [PATCH 1/7] fix(cli): keep the contact sheet png test within its budget on slow runners sharp's bundled Pango/Fontconfig backend enumerates the OS font directories on first text render and builds an on-disk cache before it can draw the sheet's labels. On Windows CI that first-render scan is unbounded (measured 12s-103s here for otherwise-millisecond work), which is what the 60s ceiling was really absorbing. Point Fontconfig at an empty, per-run config on win32 so label rendering has nothing to scan, and bring the budget down to reflect the real raster work. --- packages/cli/src/capture/contactSheet.test.ts | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/capture/contactSheet.test.ts b/packages/cli/src/capture/contactSheet.test.ts index afc9cd4de1..03c83beb33 100644 --- a/packages/cli/src/capture/contactSheet.test.ts +++ b/packages/cli/src/capture/contactSheet.test.ts @@ -1,8 +1,8 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import sharp from "sharp"; -import { describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it } from "vitest"; import { createContactSheet, createScrollContactSheet, @@ -13,21 +13,31 @@ function tempDir(): string { return mkdtempSync(join(tmpdir(), "hf-contact-sheet-test-")); } +// Windows: Fontconfig would scan every OS font on the first label render. Give it one +// copied font instead, so labels still draw and the scan is one file. +const fontconfigDir = + process.platform === "win32" ? mkdtempSync(join(tmpdir(), "hf-fontconfig-")) : null; +if (fontconfigDir) { + const fontsDir = join(process.env.WINDIR ?? "C:\\Windows", "Fonts"); + copyFileSync(join(fontsDir, "arial.ttf"), join(fontconfigDir, "arial.ttf")); + const fontconfigFile = join(fontconfigDir, "fonts.conf"); + writeFileSync( + fontconfigFile, + `${fontconfigDir}${fontconfigDir}`, + ); + process.env.FONTCONFIG_FILE = fontconfigFile; +} +afterAll(() => { + if (fontconfigDir) rmSync(fontconfigDir, { recursive: true, force: true }); +}); + describe("createContactSheet", () => { - // Sharp on Windows CI runners exercises a native-binary fork per operation - // and the runner's I/O throughput varies with concurrent-job pressure. The - // default 20s ceiling has landed just-over the wall clock repeatedly (see - // PR #2492's earlier lightweighting attempt); the actual work here — two - // 16×9 PNG writes + one contact-sheet composite + one metadata probe — - // is milliseconds of compute, so the extra ceiling only absorbs runner - // I/O jitter, it does not hide a real slowdown. it("writes PNG output when the output path uses a .png extension", async () => { const dir = tempDir(); try { const a = join(dir, "a.png"); const b = join(dir, "b.png"); const out = join(dir, "sheet.png"); - console.time("sharp.toFile(a)"); await sharp({ create: { width: 16, @@ -38,8 +48,6 @@ describe("createContactSheet", () => { }) .png() .toFile(a); - console.timeEnd("sharp.toFile(a)"); - console.time("sharp.toFile(b)"); await sharp({ create: { width: 16, @@ -50,9 +58,7 @@ describe("createContactSheet", () => { }) .png() .toFile(b); - console.timeEnd("sharp.toFile(b)"); - console.time("createContactSheet"); await createContactSheet([a, b], out, { cols: 2, cellWidth: 16, @@ -60,15 +66,25 @@ describe("createContactSheet", () => { labels: ["A", "B"], maxImages: 2, }); - console.timeEnd("createContactSheet"); - console.time("sharp(out).metadata()"); + // format alone would pass even if the SVG label overlay silently drew + // nothing (e.g. Fontconfig misconfigured): the label band (default + // padding=4, labelH=26 in contactSheet.ts) must contain pixels that + // aren't the label background (#1a1a1a), not just an empty rect. + const { data, info } = await sharp(out).raw().toBuffer({ resolveWithObject: true }); + let nonBackgroundPixels = 0; + for (let y = 4; y < 30; y++) { + for (let x = 0; x < info.width; x++) { + const i = (y * info.width + x) * info.channels; + if (data[i] !== 26 || data[i + 1] !== 26 || data[i + 2] !== 26) nonBackgroundPixels++; + } + } + expect(nonBackgroundPixels).toBeGreaterThan(0); await expect(sharp(out).metadata()).resolves.toMatchObject({ format: "png" }); - console.timeEnd("sharp(out).metadata()"); } finally { rmSync(dir, { recursive: true, force: true }); } - }, 60_000); + }, 20_000); }); describe("contact-sheet capture budget", () => { From f461bbbd2a00c65da737e65e4bb56c485f9517b6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 12:33:22 -0400 Subject: [PATCH 2/7] test(cli): keep the createContactSheet timing label to measure the Windows font fix Diagnostic: the Windows job on this branch prints createContactSheet's wall time with the one-font Fontconfig override, to compare against 3.7-18.4s without it. --- packages/cli/src/capture/contactSheet.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/capture/contactSheet.test.ts b/packages/cli/src/capture/contactSheet.test.ts index 03c83beb33..3e7e9794ef 100644 --- a/packages/cli/src/capture/contactSheet.test.ts +++ b/packages/cli/src/capture/contactSheet.test.ts @@ -59,6 +59,7 @@ describe("createContactSheet", () => { .png() .toFile(b); + console.time("createContactSheet"); await createContactSheet([a, b], out, { cols: 2, cellWidth: 16, @@ -66,6 +67,7 @@ describe("createContactSheet", () => { labels: ["A", "B"], maxImages: 2, }); + console.timeEnd("createContactSheet"); // format alone would pass even if the SVG label overlay silently drew // nothing (e.g. Fontconfig misconfigured): the label band (default From 74f590078821ed060b8f37d16be6746cec3ab3c1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 12:36:29 -0400 Subject: [PATCH 3/7] test(cli): temporary diagnostic, time the first svg and first svg text render --- packages/cli/src/capture/contactSheet.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/cli/src/capture/contactSheet.test.ts b/packages/cli/src/capture/contactSheet.test.ts index 3e7e9794ef..fdea809db0 100644 --- a/packages/cli/src/capture/contactSheet.test.ts +++ b/packages/cli/src/capture/contactSheet.test.ts @@ -59,6 +59,16 @@ describe("createContactSheet", () => { .png() .toFile(b); + // DIAGNOSTIC (temporary): which first-use step costs the ~4 s on Windows. + const svg = (inner: string) => Buffer.from(`${inner}`); + console.time("first svg without text"); + await sharp(svg('')).png().toBuffer(); + console.timeEnd("first svg without text"); + console.time("first svg with text"); + await sharp(svg('A')) + .png() + .toBuffer(); + console.timeEnd("first svg with text"); console.time("createContactSheet"); await createContactSheet([a, b], out, { cols: 2, From 0d7eb2d34e0fedc68090c5cfbfe5732e42be49cf Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 12:58:14 -0400 Subject: [PATCH 4/7] test(cli): temporary diagnostic, first text render in a child process --- packages/cli/src/capture/contactSheet.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/cli/src/capture/contactSheet.test.ts b/packages/cli/src/capture/contactSheet.test.ts index fdea809db0..443e0da69a 100644 --- a/packages/cli/src/capture/contactSheet.test.ts +++ b/packages/cli/src/capture/contactSheet.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import { copyFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -59,6 +60,20 @@ describe("createContactSheet", () => { .png() .toFile(b); + // DIAGNOSTIC (temporary): first text render in a fresh process, with and without a Fontconfig file + // present at process start (a mid-process env write may not reach the native library on Windows). + const probe = `const sharp=require("sharp");const t=Date.now();sharp(Buffer.from('A')).png().toBuffer().then(()=>console.log("child first text render ms",Date.now()-t));`; + for (const [label, env] of [ + ["child without override", { ...process.env, FONTCONFIG_FILE: "" }], + ["child with override at start", { ...process.env }], + ] as const) { + const out = spawnSync(process.execPath, ["-e", probe], { + env, + encoding: "utf8", + cwd: process.cwd(), + }); + console.log(label, out.stdout.trim(), out.stderr.trim().slice(0, 200)); + } // DIAGNOSTIC (temporary): which first-use step costs the ~4 s on Windows. const svg = (inner: string) => Buffer.from(`${inner}`); console.time("first svg without text"); From 5b6fb4382d84afb2805769ff2e66f8c7a18fbc53 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 13:14:39 -0400 Subject: [PATCH 5/7] test(cli): set the Windows Fontconfig file in the vitest config, before workers start --- packages/cli/src/capture/contactSheet.test.ts | 24 +++---------------- packages/cli/vitest.config.ts | 21 +++++++++++++++- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/capture/contactSheet.test.ts b/packages/cli/src/capture/contactSheet.test.ts index 443e0da69a..36a2728873 100644 --- a/packages/cli/src/capture/contactSheet.test.ts +++ b/packages/cli/src/capture/contactSheet.test.ts @@ -1,9 +1,9 @@ import { spawnSync } from "node:child_process"; -import { copyFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import sharp from "sharp"; -import { afterAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { createContactSheet, createScrollContactSheet, @@ -14,24 +14,6 @@ function tempDir(): string { return mkdtempSync(join(tmpdir(), "hf-contact-sheet-test-")); } -// Windows: Fontconfig would scan every OS font on the first label render. Give it one -// copied font instead, so labels still draw and the scan is one file. -const fontconfigDir = - process.platform === "win32" ? mkdtempSync(join(tmpdir(), "hf-fontconfig-")) : null; -if (fontconfigDir) { - const fontsDir = join(process.env.WINDIR ?? "C:\\Windows", "Fonts"); - copyFileSync(join(fontsDir, "arial.ttf"), join(fontconfigDir, "arial.ttf")); - const fontconfigFile = join(fontconfigDir, "fonts.conf"); - writeFileSync( - fontconfigFile, - `${fontconfigDir}${fontconfigDir}`, - ); - process.env.FONTCONFIG_FILE = fontconfigFile; -} -afterAll(() => { - if (fontconfigDir) rmSync(fontconfigDir, { recursive: true, force: true }); -}); - describe("createContactSheet", () => { it("writes PNG output when the output path uses a .png extension", async () => { const dir = tempDir(); @@ -111,7 +93,7 @@ describe("createContactSheet", () => { } finally { rmSync(dir, { recursive: true, force: true }); } - }, 20_000); + }, 60_000); }); describe("contact-sheet capture budget", () => { diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index beebf2669d..d8c12c8741 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -1,6 +1,25 @@ -import { resolve } from "node:path"; +import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { defineConfig } from "vitest/config"; +// Windows: sharp's first text render builds Fontconfig's cache for every OS font (about 9 s on a +// fresh runner). Set here, before workers fork, because an in-process env write never reaches it. +if (process.platform === "win32") { + const dir = join(tmpdir(), "hf-vitest-fontconfig"); + mkdirSync(dir, { recursive: true }); + copyFileSync( + join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf"), + join(dir, "arial.ttf"), + ); + const file = join(dir, "fonts.conf"); + writeFileSync( + file, + `${dir}${dir}`, + ); + process.env.FONTCONFIG_FILE = file; +} + export default defineConfig({ resolve: { alias: [ From 2ab5a656f78cab96cd4196d9230605fbae711017 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 13:29:24 -0400 Subject: [PATCH 6/7] test(cli): drop the contact sheet timing diagnostics --- packages/cli/src/capture/contactSheet.test.ts | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/packages/cli/src/capture/contactSheet.test.ts b/packages/cli/src/capture/contactSheet.test.ts index 36a2728873..03cae017b8 100644 --- a/packages/cli/src/capture/contactSheet.test.ts +++ b/packages/cli/src/capture/contactSheet.test.ts @@ -1,4 +1,3 @@ -import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -42,31 +41,6 @@ describe("createContactSheet", () => { .png() .toFile(b); - // DIAGNOSTIC (temporary): first text render in a fresh process, with and without a Fontconfig file - // present at process start (a mid-process env write may not reach the native library on Windows). - const probe = `const sharp=require("sharp");const t=Date.now();sharp(Buffer.from('A')).png().toBuffer().then(()=>console.log("child first text render ms",Date.now()-t));`; - for (const [label, env] of [ - ["child without override", { ...process.env, FONTCONFIG_FILE: "" }], - ["child with override at start", { ...process.env }], - ] as const) { - const out = spawnSync(process.execPath, ["-e", probe], { - env, - encoding: "utf8", - cwd: process.cwd(), - }); - console.log(label, out.stdout.trim(), out.stderr.trim().slice(0, 200)); - } - // DIAGNOSTIC (temporary): which first-use step costs the ~4 s on Windows. - const svg = (inner: string) => Buffer.from(`${inner}`); - console.time("first svg without text"); - await sharp(svg('')).png().toBuffer(); - console.timeEnd("first svg without text"); - console.time("first svg with text"); - await sharp(svg('A')) - .png() - .toBuffer(); - console.timeEnd("first svg with text"); - console.time("createContactSheet"); await createContactSheet([a, b], out, { cols: 2, cellWidth: 16, @@ -74,7 +48,6 @@ describe("createContactSheet", () => { labels: ["A", "B"], maxImages: 2, }); - console.timeEnd("createContactSheet"); // format alone would pass even if the SVG label overlay silently drew // nothing (e.g. Fontconfig misconfigured): the label band (default From 5adae0c30ef7856bb468412b1a1cecb46fa775ac Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 14:13:06 -0400 Subject: [PATCH 7/7] fix(cli): the vitest Fontconfig dir is created with an unpredictable name and removed on exit --- packages/cli/vitest.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index d8c12c8741..fefec715c0 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -1,4 +1,4 @@ -import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { defineConfig } from "vitest/config"; @@ -6,8 +6,8 @@ import { defineConfig } from "vitest/config"; // Windows: sharp's first text render builds Fontconfig's cache for every OS font (about 9 s on a // fresh runner). Set here, before workers fork, because an in-process env write never reaches it. if (process.platform === "win32") { - const dir = join(tmpdir(), "hf-vitest-fontconfig"); - mkdirSync(dir, { recursive: true }); + const dir = mkdtempSync(join(tmpdir(), "hf-vitest-fontconfig-")); + process.once("exit", () => rmSync(dir, { recursive: true, force: true })); copyFileSync( join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf"), join(dir, "arial.ttf"),