From 03c0d24d3457bdf1d78030363ac2aba6258ce856 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 12:57:59 -0700 Subject: [PATCH 1/9] fix(design): reject invalid variant counts --- design/src/variants.ts | 9 ++++++++- design/test/variants-retry-after.test.ts | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/design/src/variants.ts b/design/src/variants.ts index 257079dea5..c4824740b0 100644 --- a/design/src/variants.ts +++ b/design/src/variants.ts @@ -19,6 +19,13 @@ export interface VariantsOptions { viewports?: string; // "desktop,tablet,mobile" — generates at multiple sizes } +export function normalizeVariantCount(count: number): number { + if (!Number.isInteger(count) || count < 1) { + throw new Error("--count must be a positive integer"); + } + return Math.min(count, 7); +} + const STYLE_VARIATIONS = [ "", // First variant uses the brief as-is "Use a bolder, more dramatic visual style with stronger contrast and larger typography.", @@ -153,7 +160,7 @@ export async function variants(options: VariantsOptions): Promise { return; } - const count = Math.min(options.count, 7); // Cap at 7 style variations + const count = normalizeVariantCount(options.count); // Cap at 7 style variations const size = options.size || "1536x1024"; console.error(`Generating ${count} variants...`); diff --git a/design/test/variants-retry-after.test.ts b/design/test/variants-retry-after.test.ts index 2060791d53..7616cc0750 100644 --- a/design/test/variants-retry-after.test.ts +++ b/design/test/variants-retry-after.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import fs from "fs"; import os from "os"; import path from "path"; -import { generateVariant } from "../src/variants"; +import { generateVariant, normalizeVariantCount } from "../src/variants"; // 1x1 transparent PNG, base64 — valid bytes that fs.writeFileSync can write. const TINY_PNG_BASE64 = @@ -131,3 +131,18 @@ describe("generateVariant Retry-After handling", () => { expect(gap).toBeLessThan(500); }); }); + +describe("normalizeVariantCount", () => { + test("rejects values that would silently generate no variants", () => { + expect(() => normalizeVariantCount(NaN)).toThrow("--count must be a positive integer"); + expect(() => normalizeVariantCount(0)).toThrow("--count must be a positive integer"); + expect(() => normalizeVariantCount(-2)).toThrow("--count must be a positive integer"); + expect(() => normalizeVariantCount(2.5)).toThrow("--count must be a positive integer"); + }); + + test("keeps valid counts and preserves the existing upper cap", () => { + expect(normalizeVariantCount(1)).toBe(1); + expect(normalizeVariantCount(3)).toBe(3); + expect(normalizeVariantCount(10)).toBe(7); + }); +}); From 8ae097d9c658a9f0d339e3d294d3d70a8f5bc08b Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 12:58:52 -0700 Subject: [PATCH 2/9] fix(design): report the configured variant timeout --- design/src/variants.ts | 6 ++++-- design/test/variants-retry-after.test.ts | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/design/src/variants.ts b/design/src/variants.ts index c4824740b0..4e0432ce72 100644 --- a/design/src/variants.ts +++ b/design/src/variants.ts @@ -36,6 +36,8 @@ const STYLE_VARIATIONS = [ "Use a playful, modern style with asymmetric layout and unexpected color accents.", ]; +const VARIANT_TIMEOUT_MS = 240_000; + /** * Generate a single variant with retry on 429. * @@ -65,7 +67,7 @@ export async function generateVariant( skipLeadingDelay = false; const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 240_000); + const timeout = setTimeout(() => controller.abort(), VARIANT_TIMEOUT_MS); try { const response = await fetchFn("https://api.openai.com/v1/responses", { @@ -132,7 +134,7 @@ export async function generateVariant( } catch (err: any) { clearTimeout(timeout); if (err.name === "AbortError") { - return { path: outputPath, success: false, error: "Timeout (120s)" }; + return { path: outputPath, success: false, error: `Timeout (${VARIANT_TIMEOUT_MS / 1000}s)` }; } lastError = err.message; } diff --git a/design/test/variants-retry-after.test.ts b/design/test/variants-retry-after.test.ts index 7616cc0750..6505093990 100644 --- a/design/test/variants-retry-after.test.ts +++ b/design/test/variants-retry-after.test.ts @@ -146,3 +146,23 @@ describe("normalizeVariantCount", () => { expect(normalizeVariantCount(10)).toBe(7); }); }); + +test("AbortError reports the configured 240 second timeout", async () => { + const originalSetTimeout = globalThis.setTimeout; + (globalThis as any).setTimeout = ((handler: any, timeout?: number, ...rest: any[]) => + originalSetTimeout(handler, timeout === 240_000 ? 0 : timeout, ...rest)) as typeof setTimeout; + const fetchFn = ((_input: any, init?: any) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }); + })) as typeof fetch; + + try { + const result = await generateVariant("key", "prompt", path.join(os.tmpdir(), "gstack-timeout-test.png"), "1024x1024", "high", fetchFn); + expect(result).toMatchObject({ success: false, error: "Timeout (240s)" }); + } finally { + (globalThis as any).setTimeout = originalSetTimeout; + } +}); From 2b4121611a2a10c1265b083a66e62b1efe524ab7 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 12:59:46 -0700 Subject: [PATCH 3/9] fix(make-pdf): avoid blank page for invisible preambles --- make-pdf/src/render.ts | 17 +++++++++++++++-- make-pdf/test/render.test.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/make-pdf/src/render.ts b/make-pdf/src/render.ts index 514fbbc89e..dae6aab190 100644 --- a/make-pdf/src/render.ts +++ b/make-pdf/src/render.ts @@ -357,17 +357,30 @@ function wrapChaptersByH1(html: string): string { } const chunks: string[] = []; const preamble = html.slice(0, matches[0]); + let carriedPreamble = ""; if (preamble.trim().length > 0) { - chunks.push(`
${preamble}
`); + if (stripNonRenderingMarkup(preamble).trim().length > 0) { + chunks.push(`
${preamble}
`); + } else { + carriedPreamble = preamble; + } } for (let i = 0; i < matches.length; i++) { const start = matches[i]; const end = i + 1 < matches.length ? matches[i + 1] : html.length; - chunks.push(`
${html.slice(start, end)}
`); + const body = i === 0 ? carriedPreamble + html.slice(start, end) : html.slice(start, end); + chunks.push(`
${body}
`); } return chunks.join("\n"); } +function stripNonRenderingMarkup(html: string): string { + return html + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(//g, ""); +} + function extractFirstHeading(html: string): string | null { const m = html.match(/]*>([\s\S]*?)<\/h1>/i); return m ? decodeTextEntities(stripTags(m[1]).trim()) : null; diff --git a/make-pdf/test/render.test.ts b/make-pdf/test/render.test.ts index b54085ecb2..07488a5aae 100644 --- a/make-pdf/test/render.test.ts +++ b/make-pdf/test/render.test.ts @@ -249,6 +249,18 @@ describe("render (end-to-end)", () => { expect(result.printCss).toContain("Hiragino Kaku Gothic"); expect(result.printCss).toContain("Noto Sans CJK"); }); + + test("keeps a non-rendering preamble with the first chapter", () => { + const result = render({ markdown: `\n\n# Hello\n\nBody.` }); + expect(result.html.match(/class="chapter"/g)?.length).toBe(1); + expect(result.html).toMatch(/