refactor(diagram): the triplet is one gstack-render call

SVG, PNG and excalidraw from one invocation over the content-addressed bundle staged under /tmp/gstack-render; every diagram type gets an excalidraw export; gstack-render picks the engine and prints ENGINE=; the diagram E2E gates on either engine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sina
2026-09-05 16:48:37 -04:00
co-authored by Claude Fable 5.1
parent f74d3a1a63
commit c86590f0db
3 changed files with 136 additions and 77 deletions
+72 -51
View File
@@ -35,21 +35,24 @@ Every run emits a **triplet**, never a dead pixel dump:
| `<slug>.excalidraw` | editable scene — open it at excalidraw.com, move a box, keep working | | `<slug>.excalidraw` | editable scene — open it at excalidraw.com, move a box, keep working |
| `<slug>.svg` + `<slug>.png` | crisp vector for docs + raster for chat/issues/READMEs | | `<slug>.svg` + `<slug>.png` | crisp vector for docs + raster for chat/issues/READMEs |
Rendering is fully offline via the diagram-render bundle in the browse daemon Rendering is fully offline: the diagram-render bundle
(`lib/diagram-render/dist/diagram-render.html`). No CDN, no network. (`lib/diagram-render/dist/diagram-render.html`) is one self-contained page, and
`gstack-render` opens it from a loopback server on this machine — in the Aside
browser when Aside is running, otherwise in gstack's own headless browser. Its
first output line says which (`ENGINE=aside` or `ENGINE=browse`); the triplet
is identical either way. No CDN, no network.
## Step 1 — Author the diagram ## Step 1 — Author the diagram
Write mermaid for the user's request. Rules: Write mermaid for the user's request. Rules:
- **Flowcharts (`graph LR`/`graph TD`)** are the sweet spot: they convert to a - **Flowcharts (`graph LR`/`graph TD`) and sequence diagrams** convert to a
fully editable excalidraw scene. Prefer `graph LR` for pipelines/flows, fully editable excalidraw scene (real boxes, arrows, and text). Prefer
`graph TD` for hierarchies. `graph LR` for pipelines/flows, `graph TD` for hierarchies.
- Sequence, state, gantt, and other mermaid types render to SVG/PNG fine, but - State, class, gantt, and the other mermaid types render to SVG/PNG fine and
the official converter only supports flowcharts — for those types the still get an `.excalidraw`, but the converter exports them as ONE image
`.excalidraw` artifact is skipped and you MUST tell the user: element: it opens at excalidraw.com and can be moved and annotated, not
"sequence diagrams render but aren't excalidraw-editable yet (upstream edited box by box. Tell the user that when you deliver one.
converter limitation — flowcharts are)."
- Keep node labels short; put detail in edge labels. 5-15 nodes is the - Keep node labels short; put detail in edge labels. 5-15 nodes is the
readable range. If the user's ask needs more, split into multiple diagrams readable range. If the user's ask needs more, split into multiple diagrams
and say why. and say why.
@@ -60,8 +63,11 @@ Decide the output directory: `./diagrams/` when the cwd is a git repo
## Step 2 — Stage the render bundle (once per session) ## Step 2 — Stage the render bundle (once per session)
The staged copy is content-addressed (same convention as make-pdf's pre-pass), `gstack-render` serves the bundle's directory on 127.0.0.1 for each render
so concurrent sessions and mixed gstack versions never clobber each other: (Aside refuses `file://`, and both engines get the same origin). Stage the bundle under
gstack's own render staging directory, `/tmp/gstack-render/`, content-addressed
by bundle sha: the served directory holds nothing but gstack bundles, and
concurrent sessions or mixed gstack versions never clobber each other.
```bash ```bash
BUNDLE="" BUNDLE=""
@@ -70,50 +76,60 @@ for c in "$HOME/.claude/skills/gstack/lib/diagram-render/dist/diagram-render.htm
[ -f "$c" ] && BUNDLE="$c" && break [ -f "$c" ] && BUNDLE="$c" && break
done done
[ -z "$BUNDLE" ] && echo "BUNDLE_MISSING — run: cd ~/.claude/skills/gstack && bun run build:diagram-render" && exit 1 [ -z "$BUNDLE" ] && echo "BUNDLE_MISSING — run: cd ~/.claude/skills/gstack && bun run build:diagram-render" && exit 1
mkdir -p /tmp/gstack-render
SHA=$(shasum -a 256 "$BUNDLE" | cut -c1-16) SHA=$(shasum -a 256 "$BUNDLE" | cut -c1-16)
STAGED="/tmp/gstack-diagram-render-$SHA.html" STAGED="/tmp/gstack-render/gstack-diagram-render-$SHA.html"
[ -f "$STAGED" ] && shasum -a 256 "$STAGED" | grep -q "^$SHA" || { cp "$BUNDLE" "$STAGED.$$" && mv "$STAGED.$$" "$STAGED"; } [ -f "$STAGED" ] && shasum -a 256 "$STAGED" | grep -q "^$SHA" || { cp "$BUNDLE" "$STAGED.$$" && mv "$STAGED.$$" "$STAGED"; }
TAB=$($B newtab --json | sed -n 's/.*"tabId":\s*\([0-9]*\).*/\1/p') echo "STAGED: $STAGED"
[ -z "$TAB" ] && echo "TAB_OPEN_FAILED — daemon busy? check browse status" && exit 1
$B load-html "$STAGED" --tab-id "$TAB"
$B wait '#done' --tab-id "$TAB"
echo "RENDER_TAB_READY: tab $TAB"
``` ```
Remember `$TAB` — **every** `$B js` / `$B wait` / `$B closetab` below MUST pass Remember the `STAGED:` path — every render below opens it (it stands in for
`--tab-id $TAB`. Without it, calls hit whatever tab is active, which may be a `<staged>`). If `BUNDLE_MISSING`: stop and show the user the build command.
live /qa or /scrape session sharing the daemon. Do not improvise a CDN fallback — offline is the contract.
If `BUNDLE_MISSING`: stop and show the user the build command. Do not improvise
a CDN fallback — offline is the contract.
## Step 3 — Render the triplet ## Step 3 — Render the triplet
Write the mermaid source to `<outdir>/<slug>.mmd` first (Write tool). The page Write the mermaid source to `<outdir>/<slug>.mmd` first (Write tool). ONE
cannot read files itself, so ship the source in via **base64** — never splice `gstack-render` call renders the whole triplet: it opens the staged bundle in
file contents into a JS template literal (backticks, `${`, and backslashes in the browser, waits for the page to finish loading (`#done`), runs the `--eval`
the source would be interpreted and corrupt it): expressions in order inside that page, and writes each result to the `--out`
path that follows it. The page cannot read files itself, so ship the source in
via **base64** — never splice file contents into a JS template literal
(backticks, `${`, and backslashes in the source would be interpreted and
corrupt it):
```bash ```bash
# SVG (always). atob() decodes the base64 inside the page. SRC=$(base64 < <outdir>/<slug>.mmd | tr -d '\n')
$B js --tab-id "$TAB" "window.__renderMermaid('diagram-1', atob('$(base64 < <outdir>/<slug>.mmd | tr -d '\n')')).then(s => { window.__svg = s; return 'SVG OK ' + s.length })" bun run ~/.claude/skills/gstack/bin/gstack-render.ts "<staged>" --wait-selector '#done' \
$B js --tab-id "$TAB" "window.__svg" --out <outdir>/<slug>.svg --eval "window.__renderMermaid('diagram-1', atob('$SRC')).then(s => (window.__svg = s))" --out <outdir>/<slug>.svg \
--eval "window.__rasterize(window.__svg, 1950)" --out <outdir>/<slug>.png \
# PNG at 300dpi of a 6.5in placement (1950px) --eval "window.__mermaidToExcalidraw(atob('$SRC')).then(j => (window.__scene = j))" --out <outdir>/<slug>.excalidraw
$B js --tab-id "$TAB" "window.__rasterize(window.__svg, 1950)" --out <outdir>/<slug>.png
# Editable scene (flowcharts only)
$B js --tab-id "$TAB" "window.__mermaidToExcalidraw(atob('$(base64 < <outdir>/<slug>.mmd | tr -d '\n')')).then(j => { window.__scene = j; return 'SCENE OK ' + JSON.parse(j).elements.length + ' elements' })"
$B js --tab-id "$TAB" "window.__scene" --out <outdir>/<slug>.excalidraw
``` ```
Always run all three `--eval`/`--out` pairs, whatever the diagram type. The PNG
is 1950px wide (300dpi of a 6.5in placement). Success prints one `OK <path>`
line per artifact. Read the output for two other lines:
- A hard `ERROR:` line (e.g. `ERROR: render script did not finish: Error: Parse
error on line 4: ...`) is a mermaid parse error. Nothing was copied out. Show
the error to the user, fix the `.mmd`, and retry — do not hand the user a
broken source file.
- A `PAGE_ERRORS=[...]` entry containing `Error processing Mermaid diagram`
means the excalidraw converter fell back to a single image element (Step 1's
state/class/gantt case). The triplet is complete and correct; deliver the
`.excalidraw` anyway with the note that it is not element-editable. Any OTHER
`PAGE_ERRORS` text: read it before trusting the output.
Note: `atob()` yields Latin-1; for sources with non-ASCII labels use Note: `atob()` yields Latin-1; for sources with non-ASCII labels use
`decodeURIComponent(escape(atob('…')))` to recover UTF-8 exactly. `decodeURIComponent(escape(atob('…')))` to recover UTF-8 exactly.
If the mermaid render returns an error, show the parse error to the user, fix `gstack-render` picks the browser itself: Aside when it is running, otherwise
the mermaid, and retry — do not hand the user a broken source file. If gstack's own headless browser. Only when it prints `NEEDS_ASIDE` or
`__mermaidToExcalidraw` fails on a non-flowchart type, skip the `.excalidraw` `ASIDE_NOT_RUNNING` followed by `ERROR: no browser available` is there nothing
artifact and deliver the rest with the limitation note from Step 1. to render with — Aside (macOS 15+, aside.com) is not open and gstack's browser
is not built. Tell the user to open Aside, or to run `./setup` in the gstack
repo to build the fallback, and stop. Never install Aside for them, and never
substitute a CDN or another renderer.
## Step 4 — Show and deliver ## Step 4 — Show and deliver
@@ -129,23 +145,28 @@ and export without touching the mermaid — base64 transport again, since scene
JSON is full of quotes and backslashes: JSON is full of quotes and backslashes:
```bash ```bash
$B js --tab-id "$TAB" "window.__excalidrawToSvg(atob('$(base64 < <outdir>/<slug>.excalidraw | tr -d '\n')')).then(s => { window.__svg = s; return 'OK' })" SCENE=$(base64 < <outdir>/<slug>.excalidraw | tr -d '\n')
$B js --tab-id "$TAB" "window.__svg" --out <outdir>/<slug>.svg bun run ~/.claude/skills/gstack/bin/gstack-render.ts "<staged>" --wait-selector '#done' \
$B js --tab-id "$TAB" "window.__rasterize(window.__svg, 1950)" --out <outdir>/<slug>.png --eval "window.__excalidrawToSvg(atob('$SCENE')).then(s => (window.__svg = s))" --out <outdir>/<slug>.svg \
--eval "window.__rasterize(window.__svg, 1950)" --out <outdir>/<slug>.png
``` ```
This path prints one benign `PAGE_ERRORS` entry — excalidraw's font subsetter
falls back from a worker to the main thread inside the single-file bundle
(`WorkerInTheMainChunkError`). The SVG/PNG are correct; ignore that one.
## Rules ## Rules
- **Never ship the triplet without rendering it.** A `.mmd` file alone is not - **Never ship the triplet without rendering it.** A `.mmd` file alone is not
a diagram. If rendering is impossible (bundle missing, browse down), say so a diagram. If rendering is impossible (bundle missing, no browser available),
and stop. say so and stop.
- **Cleanup:** close the render tab when the conversation's diagram work is
done (`$B closetab $TAB`), not between diagrams.
- For diagrams destined for a PDF: remind the user that `make-pdf` renders - For diagrams destined for a PDF: remind the user that `make-pdf` renders
` ```mermaid ` fences natively — embedding the `.mmd` in their markdown is ` ```mermaid ` fences natively — embedding the `.mmd` in their markdown is
better than embedding the PNG. better than embedding the PNG.
## Completion status ## Completion status
- DONE — triplet (or SVG/PNG pair + limitation note) delivered and shown. - DONE — triplet delivered and shown (with the image-only note for
- BLOCKED — bundle or browse unavailable; build/setup command surfaced. non-flowchart, non-sequence types).
- BLOCKED — bundle missing or no browser available; the build command, "open
Aside", or "run ./setup" surfaced.
+10 -6
View File
@@ -2,9 +2,13 @@
Offline diagram rendering for make-pdf and /diagram. One self-contained HTML Offline diagram rendering for make-pdf and /diagram. One self-contained HTML
page (`dist/diagram-render.html`, ~9MB) bundles mermaid, the excalidraw export page (`dist/diagram-render.html`, ~9MB) bundles mermaid, the excalidraw export
utilities, and the official mermaid→excalidraw converter. The browse daemon utilities, and the official mermaid→excalidraw converter. Callers open it
loads it with `load-html`; callers drive it through `browse js` and pull bytes through `lib/aside-render.ts` (the TypeScript API make-pdf embeds) or
back with `js --out`. `bin/gstack-render.ts` (the CLI the /diagram skill runs) — in the Aside browser
when it is running, otherwise in gstack's own headless browser (the browse
daemon; `ENGINE=` on the CLI's first line says which). Either way the page's
directory is served on 127.0.0.1 for one render, `--eval` calls the page API,
and `--out` copies each result out (strings verbatim, data URLs as bytes).
The built page is **committed** (eng-review D2): rendering works with zero The built page is **committed** (eng-review D2): rendering works with zero
network at install time and render time, and there is no npm supply-chain network at install time and render time, and there is no npm supply-chain
@@ -16,15 +20,15 @@ fails CI if `dist/` is edited by hand or falls out of sync with `BUILD_INFO.json
| Function | In → Out | | Function | In → Out |
|---|---| |---|---|
| `__renderMermaid(id, text)` | mermaid text → SVG string. `id` must be unique per fence (`mermaid-fence-<n>`) — it namespaces every internal SVG id. | | `__renderMermaid(id, text)` | mermaid text → SVG string. `id` must be unique per fence (`mermaid-fence-<n>`) — it namespaces every internal SVG id. |
| `__mermaidToExcalidraw(text)` | mermaid text → `.excalidraw` scene JSON (flowcharts fully; other types degrade upstream). | | `__mermaidToExcalidraw(text)` | mermaid text → `.excalidraw` scene JSON (flowcharts and sequence diagrams as editable elements; other types fall back to one image element and log `Error processing Mermaid diagram` to the console). |
| `__excalidrawToSvg(sceneJson)` | scene JSON → SVG string (Excalifont embedded, offline). | | `__excalidrawToSvg(sceneJson)` | scene JSON → SVG string (Excalifont embedded, offline). |
| `__rasterize(svg, targetWidthPx)` | SVG → PNG data URL. Callers own DPI math: `targetWidthPx = placed width (in) × 300`. Throws on tainted canvas. | | `__rasterize(svg, targetWidthPx)` | SVG → PNG data URL. Callers own DPI math: `targetWidthPx = placed width (in) × 300`. Throws on tainted canvas. |
| `__downscaleRaster(dataUri, targetWidthPx, mime)` | raster data URI → smaller data URI at `targetWidthPx` (same mime). make-pdf uses it to normalize oversized photos to print resolution. | | `__downscaleRaster(dataUri, targetWidthPx, mime)` | raster data URI → smaller data URI at `targetWidthPx` (same mime). make-pdf uses it to normalize oversized photos to print resolution. |
| `__mountForScreenshot(svg, px)` | taint-proof fallback: mounts SVG at `#raster-stage` for `browse screenshot --selector`. | | `__mountForScreenshot(svg, px)` | taint-proof fallback: mounts SVG at `#raster-stage` for `gstack-render --screenshot out.png --selector '#raster-stage'`. |
| `__probeImage(src)` | data URI/URL → `{width, height}` JSON. | | `__probeImage(src)` | data URI/URL → `{width, height}` JSON. |
| `__bundleInfo` | `{ name, deps }` — pinned dependency versions baked at build. | | `__bundleInfo` | `{ name, deps }` — pinned dependency versions baked at build. |
Readiness: poll until `#status` text is `ready` (or `browse wait '#done'`). Readiness: poll until `#status` text is `ready` (or `gstack-render ... --wait-selector '#done'`).
Page errors accumulate in `window.__errors`. Page errors accumulate in `window.__errors`.
## Updating ## Updating
+54 -20
View File
@@ -1,50 +1,74 @@
/** /**
* /diagram skill E2E (paid, claude -p). * /diagram skill E2E (paid, claude -p).
* *
* Two tests with deliberately different tiers (eng-review D5): * gstack renders local HTML through `bin/gstack-render.ts`: the Aside browser
* when it is running, otherwise gstack's own browse daemon. The /diagram skill
* runs ONE gstack-render call per triplet (staged bundle, three --eval/--out
* pairs) and never picks the engine itself — so the test needs SOME browser:
* Aside on a Mac, or a browse binary (evals.yml's `bun run build` compiles one
* and PLAYWRIGHT_BROWSERS_PATH survives the hermetic env). With neither, the
* whole file self-skips — never fails.
*
* Two tests with deliberately different tiers (eng-review D5, CLAUDE.md rules):
* *
* diagram-triplet (gate) — deterministic functional contract: from an * diagram-triplet (gate) — deterministic functional contract: from an
* English ask, the agent following the skill emits a parseable triplet * English ask, the agent following the skill emits a parseable triplet
* .mmd source, .excalidraw scene with elements, SVG markup, PNG bytes. * (.mmd source, .excalidraw scene with elements, SVG markup, PNG bytes) and
* No quality judgment; either the artifacts exist and parse or they don't. * did it through gstack-render (a Bash tool call names it). No quality
* judgment; either the artifacts exist and parse or they don't.
* *
* diagram-authoring-quality (periodic) — LLM-judged benchmark of the * diagram-authoring-quality (periodic) — LLM-judged benchmark of the
* authored mermaid itself (faithfulness to the ask, label quality, * authored mermaid itself (faithfulness to the ask, label quality, readable
* readable size). Non-deterministic by nature → never blocks merge. * size). Non-deterministic by nature → never blocks merge.
* *
* Per the extract-don't-copy fixture rule, the prompt embeds only the skill's * Per the extract-don't-copy fixture rule, the prompt embeds only the skill's
* working section (from "# /diagram" onward), not the full generated SKILL.md * working section (from "# /diagram" onward), not the full generated SKILL.md
* with its preamble. * with its preamble.
*/ */
import { describe, expect } from 'bun:test'; import { expect } from 'bun:test';
import { CAPTURE_MS } from './helpers/eval-budgets'; import { CAPTURE_MS } from './helpers/eval-budgets';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import * as os from 'node:os'; import * as os from 'node:os';
import { runSkillTest } from './helpers/session-runner'; import { runSkillTest, type SkillTestResult } from './helpers/session-runner';
import { import {
ROOT, browseBin, runId, ROOT, runId,
describeIfSelected, testConcurrentIfSelected, describeIfSelected, testConcurrentIfSelected,
logCost, logCost,
} from './helpers/e2e-helpers'; } from './helpers/e2e-helpers';
import { asideAvailable } from './helpers/aside-available';
import { resolveBrowseBin } from '../lib/aside-render';
import { callJudge } from './helpers/llm-judge'; import { callJudge } from './helpers/llm-judge';
// --- Whole-file gate: a browser gstack-render can drive. Skip, never fail. ---
const browserOk = asideAvailable() || resolveBrowseBin() !== null;
if (process.env.EVALS && !browserOk) {
process.stderr.write('\nskill-e2e-diagram: SKIPPED — no browser: Aside is not running and no browse binary resolves (bun run build)\n');
}
/** describeIfSelected, forced to describe.skip when no browser is available. */
const describeDiagram = (name: string, keys: string[], fn: () => void) =>
describeIfSelected(name, keys, fn, browserOk ? undefined : []);
const BUNDLE = path.join(ROOT, 'lib', 'diagram-render', 'dist', 'diagram-render.html'); const BUNDLE = path.join(ROOT, 'lib', 'diagram-render', 'dist', 'diagram-render.html');
const RENDER = path.join(ROOT, 'bin', 'gstack-render.ts');
/** Extract the working section of the generated skill doc (post-preamble). */ /** Extract the working section of the generated skill doc (post-preamble). */
function skillExtract(): string { function skillExtract(): string {
const full = fs.readFileSync(path.join(ROOT, 'diagram', 'SKILL.md'), 'utf-8'); const full = fs.readFileSync(path.join(ROOT, 'diagram', 'SKILL.md'), 'utf-8');
const start = full.indexOf('# /diagram'); const start = full.indexOf('# /diagram');
if (start < 0) throw new Error('diagram/SKILL.md missing "# /diagram" section — regenerate skill docs'); if (start < 0) throw new Error('diagram/SKILL.md missing "# /diagram" section — regenerate skill docs');
if (!full.includes('gstack-render')) throw new Error('diagram/SKILL.md does not render through gstack-render (stale generated doc) — run `bun run gen:skill-docs`');
return full.slice(start); return full.slice(start);
} }
function setupDir(prefix: string): string { function setupDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.writeFileSync(path.join(dir, 'diagram-skill.md'), skillExtract()); fs.writeFileSync(path.join(dir, 'diagram-skill.md'), skillExtract());
// Pre-stage the bundle so the test is hermetic (no global install needed in // Pre-stage the bundle so the test is hermetic (no global install needed);
// CI); the prompt tells the agent discovery is already done. // gstack-render serves this directory on loopback for the render.
fs.copyFileSync(BUNDLE, path.join(dir, 'diagram-render.html')); fs.copyFileSync(BUNDLE, path.join(dir, 'diagram-render.html'));
fs.mkdirSync(path.join(dir, 'out')); fs.mkdirSync(path.join(dir, 'out'));
return dir; return dir;
@@ -53,23 +77,30 @@ function setupDir(prefix: string): string {
function basePrompt(dir: string, ask: string): string { function basePrompt(dir: string, ask: string): string {
return `You have the /diagram skill instructions at ./diagram-skill.md — read them and follow Steps 1-4. return `You have the /diagram skill instructions at ./diagram-skill.md — read them and follow Steps 1-4.
Environment notes (already set up — skip Step 2's bundle discovery): Environment notes (already set up — skip Step 2's bundle discovery and staging):
- The browse binary is at ${browseBin} — use it wherever the skill says $B. - gstack-render picks the browser itself (its first output line is ENGINE=aside or ENGINE=browse); either is fine. Do not probe for or start a browser yourself.
- The render bundle is ALREADY staged at ./diagram-render.html in this directory; load it with: ${browseBin} load-html ./diagram-render.html - The render bundle is ALREADY staged at ${dir}/diagram-render.html — use that path wherever the skill says <staged>.
- gstack-render lives at ${RENDER}; run it as \`bun run ${RENDER}\` instead of the ~/.claude/skills/gstack/bin/gstack-render.ts path the skill shows.
- Write all four artifacts into ./out/ with the slug "flow" (out/flow.mmd, out/flow.excalidraw, out/flow.svg, out/flow.png). - Write all four artifacts into ./out/ with the slug "flow" (out/flow.mmd, out/flow.excalidraw, out/flow.svg, out/flow.png).
- Do not open any other applications. Do not use the Read tool on the PNG (no inline display needed here). - Do not open any other applications. Do not use the Read tool on the PNG (no inline display needed here).
The diagram to create: ${ask}`; The diagram to create: ${ask}`;
} }
describeIfSelected('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-quality'], () => { /** The skill's render contract: at least one Bash tool call ran gstack-render. */
function expectRenderedViaGstackRender(result: SkillTestResult): void {
const ran = result.toolCalls.some((c) => c.tool === 'Bash' && JSON.stringify(c.input ?? {}).includes('gstack-render'));
expect(ran).toBe(true);
}
describeDiagram('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-quality'], () => {
testConcurrentIfSelected('diagram-triplet', async () => { testConcurrentIfSelected('diagram-triplet', async () => {
const dir = setupDir('diagram-triplet-'); const dir = setupDir('diagram-triplet-');
try { try {
const result = await runSkillTest({ const result = await runSkillTest({
prompt: basePrompt( prompt: basePrompt(
dir, dir,
'a flowchart (graph LR) of a 4-stage pipeline: markdown → prepass → Chromium → PDF.', 'a flowchart (graph LR) of a 4-stage pipeline: markdown → prepass → browser render → PDF.',
), ),
workingDirectory: dir, workingDirectory: dir,
maxTurns: 25, maxTurns: 25,
@@ -80,6 +111,7 @@ describeIfSelected('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-
}); });
logCost('diagram triplet', result); logCost('diagram triplet', result);
expect(result.exitReason).toBe('success'); expect(result.exitReason).toBe('success');
expectRenderedViaGstackRender(result);
// The deterministic contract: all four artifacts exist and parse. // The deterministic contract: all four artifacts exist and parse.
const mmd = fs.readFileSync(path.join(dir, 'out', 'flow.mmd'), 'utf-8'); const mmd = fs.readFileSync(path.join(dir, 'out', 'flow.mmd'), 'utf-8');
@@ -107,7 +139,7 @@ describeIfSelected('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-
const result = await runSkillTest({ const result = await runSkillTest({
prompt: basePrompt( prompt: basePrompt(
dir, dir,
'how gstack renders diagrams in PDFs: markdown containing mermaid fences goes through a pre-pass that extracts the fences, renders them in a browse daemon tab using an offline bundle, substitutes the SVG back in, inlines local images, and prints via Chromium. Failures become visible diagnostic blocks.', 'how gstack renders diagrams in PDFs: markdown containing mermaid fences goes through a pre-pass that extracts the fences, renders them in a browser (Aside, or gstack\'s own headless fallback) using an offline bundle, substitutes the SVG back in, inlines local images, and prints the PDF through the same browser. Failures become visible diagnostic blocks.',
), ),
workingDirectory: dir, workingDirectory: dir,
maxTurns: 25, maxTurns: 25,
@@ -118,6 +150,7 @@ describeIfSelected('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-
}); });
logCost('diagram authoring quality', result); logCost('diagram authoring quality', result);
expect(result.exitReason).toBe('success'); expect(result.exitReason).toBe('success');
expectRenderedViaGstackRender(result);
const mmd = fs.readFileSync(path.join(dir, 'out', 'flow.mmd'), 'utf-8'); const mmd = fs.readFileSync(path.join(dir, 'out', 'flow.mmd'), 'utf-8');
const svg = fs.readFileSync(path.join(dir, 'out', 'flow.svg'), 'utf-8'); const svg = fs.readFileSync(path.join(dir, 'out', 'flow.svg'), 'utf-8');
@@ -127,9 +160,10 @@ describeIfSelected('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-
`You are judging the quality of an agent-authored mermaid diagram. `You are judging the quality of an agent-authored mermaid diagram.
THE ASK: a diagram of gstack's PDF diagram-rendering flow — mermaid fences are THE ASK: a diagram of gstack's PDF diagram-rendering flow — mermaid fences are
extracted by a pre-pass, rendered in a browse tab via an offline bundle, extracted by a pre-pass, rendered in a browser (Aside, or gstack's own headless
substituted back as SVG, images inlined, printed by Chromium, with render fallback) via an offline bundle, substituted back as SVG, images inlined,
failures becoming visible diagnostic blocks. printed to PDF through the same browser, with render failures becoming visible
diagnostic blocks.
THE AUTHORED MERMAID: THE AUTHORED MERMAID:
\`\`\`mermaid \`\`\`mermaid