mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-15 17:35:29 +02:00
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:
+72
-51
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user