Files
gstack/make-pdf/test/e2e/toc-gate.test.ts
T
ac20ef1e13 v1.91.16.0 fix: safe installs and upgrades, skills start everywhere, truthful completion (parallel wave) (#3014)
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807)

* ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997)

* fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805)

* fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847)

* feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096)

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>

* fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851)

* fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906)

* test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910)

* chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes

* v1.91.14.0: release metadata

* test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group

* ci: Codex login steps run under bash (the CI container's default sh rejects pipefail)

* fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt

* fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts

* test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call

* fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier

* test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers)

* test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts)

* test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep)

* fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit

* test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently

* test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census

* fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic

* test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output

* fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens

* test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design)

* test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept)

* fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files

---------

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
2026-10-03 07:54:09 -07:00

144 lines
7.0 KiB
TypeScript

/**
* TOC page-number gate (#2903) — through the compiled binary and a real
* browser (Aside when it runs, otherwise gstack's own; CI uses the latter).
*
* Before the fix every `.toc-page` cell printed empty: no pagination engine
* shipped and the print went ahead after a 3s Paged.js wait. Now each cell
* must hold the page its heading prints on and each TOC link must land there.
*
* Oracles (independent of make-pdf's own PDF reader where it matters):
* - pdftotext: the number printed next to each TOC label, and the page
* whose text actually contains that heading;
* - pdfinfo: page boxes, so the fixture provably spans a landscape page;
* - the PDF's link annotations: one per entry, in TOC order, each naming a
* destination on the page its entry prints.
*
* The fixture covers a multi-page TOC, a cover (and none), custom ids, a
* duplicate id, a user id squatting the generated `toc-N` scheme, a non-ASCII
* id, an empty heading, and a forced landscape page between headings.
*/
import { describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { resolvePopplerTool } from "../../src/pdftotext";
import { pdfDestinationPages } from "../../src/toc-pages";
import { browserAvailable, NO_BROWSER_REASON } from "./browser-available";
const ROOT = path.resolve(__dirname, "../../..");
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
const RED_BOX = path.resolve(__dirname, "../fixtures/diagram-assets/red-box.png");
const CHILD_TIMEOUT_MS = 60_000;
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
if (!browserAvailable()) return { ok: false, reason: NO_BROWSER_REASON };
if (!resolvePopplerTool("pdfinfo")) return { ok: false, reason: "pdfinfo not found (install poppler-utils)." };
if (!resolvePopplerTool("pdftotext")) return { ok: false, reason: "pdftotext not found (install poppler-utils)." };
return { ok: true };
}
const LABELS = [
"Gate Intro", "Squatter Heading", "Custom Heading", "Duplicate Heading", "Unicode Heading",
"Landscape Heading", "After Landscape",
...Array.from({ length: 48 }, (_, i) => (i % 6 === 0 ? `Chapter ${String(i + 1).padStart(2, "0")}` : `Section ${String(i + 1).padStart(2, "0")}`)),
];
function fixtureMarkdown(): string {
const filler = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(24);
const lines = [
"# Gate Intro", "", "Intro paragraph.", "",
"#", "",
`<h2 id="toc-1">Squatter Heading</h2>`, "", "Body.", "",
`<h2 id="custom-id">Custom Heading</h2>`, "", "Body.", "",
`<h2 id="custom-id">Duplicate Heading</h2>`, "", "Body.", "",
`<h2 id="café-ü">Unicode Heading</h2>`, "", "Body.", "",
"## Landscape Heading", "", "![forced](./red-box.png){page=landscape}", "",
"## After Landscape", "", "Body.", "",
];
for (const label of LABELS.slice(7)) lines.push(`${label.startsWith("Chapter") ? "#" : "##"} ${label}`, "", filler, "");
return lines.join("\n");
}
const run = (tool: "pdfinfo" | "pdftotext", args: string[]) => execFileSync(resolvePopplerTool(tool)!, args, { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
const pageText = (pdf: string, page: number, layout = false) =>
run("pdftotext", [...(layout ? ["-layout"] : []), "-f", String(page), "-l", String(page), pdf, "-"]);
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function checkToc(pdf: string): void {
const info = run("pdfinfo", ["-f", "1", "-l", "999", pdf]);
const boxes = [...info.matchAll(/Page\s+(\d+)\s+size:\s+([0-9.]+)\s+x\s+([0-9.]+)\s+pts/g)].map((m) => ({ page: Number(m[1]), landscape: parseFloat(m[2]) > parseFloat(m[3]) }));
expect(boxes.length).toBeGreaterThan(10);
expect(boxes.some((b) => b.landscape)).toBe(true);
// The number printed beside each label, read from the TOC pages' layout text.
const tocPages = new Set<number>();
const printed = new Map<string, number>();
for (const { page } of boxes) {
const text = pageText(pdf, page, true);
for (const label of LABELS) {
const m = text.match(new RegExp(`^\\s*${escapeRe(label)}\\s+(\\d+)\\s*$`, "m"));
if (!m || printed.has(label)) continue;
printed.set(label, Number(m[1]));
tocPages.add(page);
}
}
expect([...printed.keys()]).toEqual(LABELS);
expect(tocPages.size).toBeGreaterThanOrEqual(2); // a multi-page TOC
// Each number names the page whose own text holds that heading.
for (const label of LABELS) {
const page = printed.get(label)!;
expect(tocPages.has(page)).toBe(false);
const lines = pageText(pdf, page).split("\n").map((l) => l.trim());
expect({ label, page, found: lines.includes(label) }).toEqual({ label, page, found: true });
}
const landscapePage = boxes.find((b) => b.landscape)!.page;
expect(printed.get("Landscape Heading")!).toBeLessThan(landscapePage);
expect(printed.get("After Landscape")!).toBeGreaterThan(landscapePage);
// One link per entry, in TOC order, each to a destination on its entry's page.
const raw = fs.readFileSync(pdf);
const links = [...raw.toString("latin1").matchAll(/\/Subtype \/Link\b[^>]*?\/Dest \/([^\s/>\]]+)/g)].map((m) => m[1]);
expect(links).toHaveLength(LABELS.length);
const dests = pdfDestinationPages(raw);
const decoded = links.map((n) => decodeURIComponent(Buffer.from(n.replace(/#([0-9a-fA-F]{2})/g, (_x, h) => String.fromCharCode(parseInt(h, 16))), "latin1").toString("utf8")));
expect(decoded.map((d) => dests.get(d))).toEqual(LABELS.map((l) => printed.get(l)));
expect(new Set(decoded).size).toBe(LABELS.length);
}
describe("TOC page-number gate (#2903)", () => {
const avail = prerequisitesAvailable();
for (const variant of [{ name: "cover + toc", args: ["--cover", "--toc"] }, { name: "toc only", args: ["--toc"] }]) {
test.skipIf(!avail.ok)(`${variant.name}: every TOC cell holds its heading's printed page and every link lands there`, () => {
if (!avail.ok) return;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "make-pdf-toc-gate-"));
try {
fs.copyFileSync(RED_BOX, path.join(dir, "red-box.png"));
fs.writeFileSync(path.join(dir, "toc.md"), fixtureMarkdown());
const out = path.join(dir, "out.pdf");
execFileSync(PDF_BIN, ["generate", path.join(dir, "toc.md"), out, "--quiet", "--title", "Gate Doc", ...variant.args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: CHILD_TIMEOUT_MS,
});
checkToc(out);
if (variant.args.includes("--cover")) expect(pageText(out, 1)).not.toMatch(/CONTENTS/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 120_000);
}
if (!avail.ok) {
// A visible skip: ci-prereqs.test.ts is the CI tripwire for the binaries + poppler.
test("toc gate prerequisites are present", () => {
console.warn(`[skip] ${avail.reason}`);
});
}
});