diff --git a/packages/cli/src/capture/assetDownloader.test.ts b/packages/cli/src/capture/assetDownloader.test.ts index 715965485d..b29e7d00ab 100644 --- a/packages/cli/src/capture/assetDownloader.test.ts +++ b/packages/cli/src/capture/assetDownloader.test.ts @@ -351,6 +351,7 @@ describe("drop counts — why a referenced asset is not in the capture", () => { expect(css).toContain("assets/fonts/font-0.woff2"); expect(drops).toEqual({ "size-floor": 0, + "size-ceiling": 0, "budget-exhausted": 0, "cap-reached": 0, unavailable: 0, @@ -421,6 +422,32 @@ describe("drop counts — why a referenced asset is not in the capture", () => { expect(drops["cap-reached"]).toBe(4); }); }); + + it("writes an inline SVG logo over 10 KB whole, and skips one over the size cap uncut", async () => { + await withTempDir(async (dir) => { + const shapes = (n: number) => + Array.from({ length: n }, (_, i) => ``).join(""); + const logo = `${shapes(3000)}`; + const huge = `${shapes(1)}${"x".repeat(3 * 1024 * 1024)}`; + expect(logo.length).toBeGreaterThan(10_000); + const { assets, drops } = await downloadAssets( + { + svgs: [ + { outerHTML: logo, isLogo: true }, + { outerHTML: huge, isLogo: false }, + ], + sections: [], + ogImage: "", + } as unknown as DesignTokens, + dir, + ); + expect(assets).toHaveLength(1); + const written = readFileSync(join(dir, assets[0]!.localPath), "utf-8"); + expect(written).toBe(toStandaloneSvg(logo)); + expect(written.endsWith("")).toBe(true); + expect(drops["size-ceiling"]).toBe(1); + }); + }); }); describe("asset fetches present the same identity as the page navigation", () => { diff --git a/packages/cli/src/capture/assetDownloader.ts b/packages/cli/src/capture/assetDownloader.ts index 3dbf6188a1..0d1cea7391 100644 --- a/packages/cli/src/capture/assetDownloader.ts +++ b/packages/cli/src/capture/assetDownloader.ts @@ -40,6 +40,8 @@ interface DownloadBudgetOptions { export type AssetDropReason = /** Fetched, then judged too small to be a real asset rather than a spacer or tracking pixel. */ | "size-floor" + /** Larger than any one asset may be; skipped whole, never cut. */ + | "size-ceiling" /** The post-navigation clock ran out before this one was reached. */ | "budget-exhausted" /** A per-run or per-family limit was already met. */ @@ -51,7 +53,13 @@ export type AssetDropCounts = Record; /** A tally with every reason at zero — the shape a caller merges into. */ export function noDrops(): AssetDropCounts { - return { "size-floor": 0, "budget-exhausted": 0, "cap-reached": 0, unavailable: 0 }; + return { + "size-floor": 0, + "size-ceiling": 0, + "budget-exhausted": 0, + "cap-reached": 0, + unavailable: 0, + }; } /** Sum two tallies. Used to fold the font pass and the asset pass into one capture-wide count. */ @@ -326,6 +334,7 @@ export async function downloadAssets( ensureCaptureDirSync(outputDir, join(outputDir, "assets", "svgs")); const usedSvgNames = new Set(); const MAX_INLINE_SVGS = 30; + const MAX_INLINE_SVG_BYTES = 2 * 1024 * 1024; drops["cap-reached"] += Math.max(0, tokens.svgs.length - MAX_INLINE_SVGS); for (let i = 0; i < tokens.svgs.length && i < MAX_INLINE_SVGS; i++) { const svg = tokens.svgs[i]!; @@ -335,6 +344,10 @@ export async function downloadAssets( } // Hash the bytes that actually land on disk, so the filename still can't drift from content. const svgFile = toStandaloneSvg(svg.outerHTML); + if (Buffer.byteLength(svgFile, "utf-8") > MAX_INLINE_SVG_BYTES) { + drops["size-ceiling"]++; + continue; + } const slug = svgContentHashSlug(svgFile, !!svg.isLogo); let finalSlug = slug; let suffix = 2; diff --git a/packages/cli/src/capture/tokenExtractor.test.ts b/packages/cli/src/capture/tokenExtractor.test.ts new file mode 100644 index 0000000000..8b18c6981e --- /dev/null +++ b/packages/cli/src/capture/tokenExtractor.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { runInNewContext } from "node:vm"; +import { Window } from "happy-dom"; +import type { Page } from "puppeteer-core"; +import { extractTokens } from "./tokenExtractor.js"; + +async function tokensOf(body: string) { + const window = new Window(); + window.document.body.innerHTML = body; + const evaluate: Page["evaluate"] = async (script, ..._args) => { + if (typeof script !== "string") throw new Error("Expected a page expression"); + return runInNewContext(script, { + window, + document: window.document, + location: window.location, + getComputedStyle: window.getComputedStyle.bind(window), + }); + }; + try { + return await extractTokens({ evaluate } as Page); + } finally { + await window.happyDOM.close(); + } +} + +describe("capture tokens", () => { + it("keeps an inline SVG logo over 10 KB whole", async () => { + const shapes = Array.from({ length: 3000 }, (_, i) => ``); + const logo = `${shapes.join("")}`; + expect(logo.length).toBeGreaterThan(10_000); + const tokens = await tokensOf(``); + expect(tokens.svgs).toHaveLength(1); + expect(tokens.svgs[0]!.outerHTML.length).toBe(logo.length); + expect(tokens.svgs[0]!.outerHTML.endsWith("")).toBe(true); + expect(tokens.svgs[0]!.isLogo).toBe(true); + }); +}); diff --git a/packages/cli/src/capture/tokenExtractor.ts b/packages/cli/src/capture/tokenExtractor.ts index 7c42f28ef9..acb48a6b5c 100644 --- a/packages/cli/src/capture/tokenExtractor.ts +++ b/packages/cli/src/capture/tokenExtractor.ts @@ -386,7 +386,7 @@ const EXTRACT_SCRIPT = `(() => { viewBox: svg.getAttribute("viewBox") || undefined, width: Math.round(rect.width), height: Math.round(rect.height), - outerHTML: svg.outerHTML.slice(0, 10000), + outerHTML: svg.outerHTML, isLogo: isLogo }; }).filter(Boolean).slice(0, 50); diff --git a/packages/cli/src/whisper/sherpa.test.ts b/packages/cli/src/whisper/sherpa.test.ts index 25f0377020..32342c35c9 100644 --- a/packages/cli/src/whisper/sherpa.test.ts +++ b/packages/cli/src/whisper/sherpa.test.ts @@ -14,7 +14,9 @@ import { basename, dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DownloadOptions } from "../cloud/download.js"; +import { execFileSync } from "node:child_process"; import { + decodeTimeoutMs, ensureParakeetModel, installSherpaRuntime, SHERPA_RUNTIME_DIR, @@ -470,3 +472,16 @@ describe("sherpaUnsupportedReason", () => { ); }); }); + +describe("decodeTimeoutMs", () => { + // 1851.245125 s of audio once gave 3702490.25, which execFile refused as out of range. + it.each([1851.245125, 10_000_000])( + "gives execFile a timeout it accepts for %s s of audio", + (seconds) => { + const timeout = decodeTimeoutMs(seconds); + expect(Number.isInteger(timeout)).toBe(true); + expect(timeout).toBeGreaterThanOrEqual(Math.min(seconds * 2000, 2 ** 31 - 1)); + expect(() => execFileSync(process.execPath, ["-e", ""], { timeout })).not.toThrow(); + }, + ); +}); diff --git a/packages/cli/src/whisper/sherpa.ts b/packages/cli/src/whisper/sherpa.ts index 9615059186..61fe2a76d5 100644 --- a/packages/cli/src/whisper/sherpa.ts +++ b/packages/cli/src/whisper/sherpa.ts @@ -82,8 +82,9 @@ const PARAKEET_MODEL_FILES: readonly ModelFile[] = [ }, ]; -/** At least 30 minutes, and twice the audio length for slow CPUs. */ -const decodeTimeoutMs = (audioSeconds: number) => Math.max(1_800_000, audioSeconds * 2000); +/** At least 30 minutes, twice the audio length for slow CPUs; whole ms, within Node's timer limit. */ +export const decodeTimeoutMs = (audioSeconds: number) => + Math.min(2 ** 31 - 1, Math.max(1_800_000, Math.ceil(audioSeconds * 2000))); /** The platforms sherpa-onnx-node publishes a native package for. */ const SUPPORTED_TARGETS = [