@zenodinh/pi-render 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -6
- package/index.ts +29 -33
- package/package.json +6 -2
- package/src/core/paint.ts +17 -2
- package/src/core/types/host.ts +15 -18
- package/src/core/types.ts +0 -1
- package/src/renderers/content/artifacts/cards.ts +13 -6
- package/src/renderers/content/code-panel.ts +53 -31
- package/src/renderers/content/index.ts +70 -21
- package/src/renderers/content/json-panel.ts +9 -17
- package/src/renderers/content/link-paragraph.ts +153 -0
- package/src/renderers/content/table.ts +4 -1
- package/src/renderers/tool/runtime.ts +44 -5
- package/src/renderers/tool/specs/search.ts +18 -10
- package/src/renderers/tool/types.ts +6 -1
- package/src/commands/canvas.test.ts +0 -150
- package/src/commands/canvas.ts +0 -57
- package/src/core/code-theme.test.ts +0 -266
- package/src/core/log.test.ts +0 -127
- package/src/core/paint.test.ts +0 -322
- package/src/core/registry.test.ts +0 -307
- package/src/core/settings.test.ts +0 -183
- package/src/renderers/content/artifacts/artifacts.test.ts +0 -199
- package/src/renderers/content/artifacts/cards.test.ts +0 -216
- package/src/renderers/content/artifacts/engines-extra.test.ts +0 -556
- package/src/renderers/content/artifacts/local-binary.test.ts +0 -207
- package/src/renderers/content/image-card.test.ts +0 -170
- package/src/renderers/content/panels.test.ts +0 -188
- package/src/renderers/content/table.test.ts +0 -209
- package/src/renderers/content/transformer.test.ts +0 -254
- package/src/renderers/tool/resolver.test.ts +0 -257
- package/src/renderers/tool/runtime.test.ts +0 -313
- package/src/renderers/tool/specs/bash.test.ts +0 -110
- package/src/renderers/tool/specs/codemode.test.ts +0 -212
- package/src/renderers/tool/specs/edit.test.ts +0 -260
- package/src/renderers/tool/specs/ls.test.ts +0 -173
- package/src/renderers/tool/specs/read.test.ts +0 -340
- package/src/renderers/tool/specs/search.test.ts +0 -197
- package/src/renderers/tool/specs/write.test.ts +0 -145
- package/themes/themes.test.ts +0 -251
|
@@ -1,556 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* engines-extra.test.ts — the artifacts engine seam's spawn paths (T-25 follow-up): the four form
|
|
3
|
-
* dispatches, the PlantUML and HTML binary boundaries, and the cache interaction, driven entirely
|
|
4
|
-
* through renderDiagram's injectable {engines, cache, prereqs, spawn, log} seam.
|
|
5
|
-
*
|
|
6
|
-
* Complements artifacts.test.ts (the ported cache tests plus the dedup and missing-binary contracts)
|
|
7
|
-
* and leaves it untouched. Every test carries a spec + fails_when line; no test reaches past the seam,
|
|
8
|
-
* and no test runs a real plantuml or browser.
|
|
9
|
-
*
|
|
10
|
-
* shape: none — test module, no runtime unit for DSG-1's trigger table to select a shape for.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
15
|
-
import { dirname, isAbsolute, join } from "node:path";
|
|
16
|
-
import { beforeEach, describe, expect, it } from "vitest";
|
|
17
|
-
import { createLogger } from "../../../core/log.ts";
|
|
18
|
-
import { type ArtifactCache, type CacheMeta, createArtifactCache } from "./cache.ts";
|
|
19
|
-
import { type Engine, renderDiagram, status, warmup } from "./engines.ts";
|
|
20
|
-
import type { LocalSpawn, LocalSpawnResult } from "./local-binary.ts";
|
|
21
|
-
|
|
22
|
-
// recorded-from: the 1x1 RGBA PNG fixture generated for the artifacts tests, 2026-10-03 (the same bytes
|
|
23
|
-
// artifacts.test.ts uses).
|
|
24
|
-
const PNG_BYTES = Buffer.from(
|
|
25
|
-
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63300e5df51f0004230232e7bf98d60000000049454e44ae426082",
|
|
26
|
-
"hex",
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
// invented: absolute paths handed to the *injected* spawn; no real binary is ever executed.
|
|
30
|
-
const FAKE_PLANTUML = "/opt/pi-render-test/bin/plantuml";
|
|
31
|
-
const FAKE_BROWSER = "/opt/pi-render-test/chrome-headless-shell";
|
|
32
|
-
const SOURCE = "@startuml\nAlice -> Bob\n@enduml";
|
|
33
|
-
|
|
34
|
-
// invented: the canned result an exit-0 process reports; there is no upstream payload to record.
|
|
35
|
-
const OK: LocalSpawnResult = { status: 0, signal: null, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
|
|
36
|
-
|
|
37
|
-
interface SpawnCall {
|
|
38
|
-
cmd: string;
|
|
39
|
-
args: string[];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function only<T>(items: T[]): T {
|
|
43
|
-
const first = items[0];
|
|
44
|
-
if (first === undefined) throw new Error("test fixture: expected exactly one recorded item");
|
|
45
|
-
return first;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** The thrown error's message; a non-throwing call is itself a failure. Exact-match assertions follow. */
|
|
49
|
-
function messageOf(fn: () => unknown): string {
|
|
50
|
-
try {
|
|
51
|
-
fn();
|
|
52
|
-
} catch (error) {
|
|
53
|
-
return error instanceof Error ? error.message : String(error);
|
|
54
|
-
}
|
|
55
|
-
throw new Error("test fixture: expected the call to throw");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function argAfter(args: string[], flag: string): string {
|
|
59
|
-
const index = args.indexOf(flag);
|
|
60
|
-
const value = index === -1 ? undefined : args[index + 1];
|
|
61
|
-
if (value === undefined) throw new Error(`test fixture: no ${flag} in [${args.join(" ")}]`);
|
|
62
|
-
return value;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function argWithPrefix(args: string[], prefix: string): string {
|
|
66
|
-
const found = args.find((arg) => arg.startsWith(prefix));
|
|
67
|
-
if (found === undefined) throw new Error(`test fixture: no argument starting with ${prefix}`);
|
|
68
|
-
return found;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* invented: the recording spawn is the fixture — it captures cmd/argv/timeout and delegates to the
|
|
73
|
-
* per-test behavior that writes whatever the real binary would have written.
|
|
74
|
-
*/
|
|
75
|
-
function recorder(behavior?: (call: SpawnCall) => LocalSpawnResult) {
|
|
76
|
-
const calls: Array<SpawnCall & { timeoutMs: number }> = [];
|
|
77
|
-
const spawn: LocalSpawn = (cmd, args, opts) => {
|
|
78
|
-
calls.push({ cmd, args, timeoutMs: opts.timeoutMs });
|
|
79
|
-
return behavior?.({ cmd, args }) ?? OK;
|
|
80
|
-
};
|
|
81
|
-
return { calls, spawn };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** invented: plantuml's observed behavior — it writes its output into the `-o` directory. */
|
|
85
|
-
function plantumlOutput(name: string, body: string): (call: SpawnCall) => LocalSpawnResult {
|
|
86
|
-
return ({ args }) => {
|
|
87
|
-
writeFileSync(join(argAfter(args, "-o"), name), body);
|
|
88
|
-
return OK;
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** invented: the browser's observed behavior — it writes the file named by `--screenshot=`. */
|
|
93
|
-
function screenshot(png: Buffer): (call: SpawnCall) => LocalSpawnResult {
|
|
94
|
-
return ({ args }) => {
|
|
95
|
-
writeFileSync(argWithPrefix(args, "--screenshot=").slice("--screenshot=".length), png);
|
|
96
|
-
return OK;
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function spawnError(code: string): LocalSpawnResult {
|
|
101
|
-
return { status: null, signal: null, error: Object.assign(new Error(`spawn failed: ${code}`), { code }) };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function countingViz(runs: () => void): Engine {
|
|
105
|
-
return {
|
|
106
|
-
id: "viz",
|
|
107
|
-
warmup: async () => {},
|
|
108
|
-
status: () => true,
|
|
109
|
-
render: (source) => {
|
|
110
|
-
runs();
|
|
111
|
-
return { kind: "svg", svg: `<svg>${source}</svg>` };
|
|
112
|
-
},
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** A cache bound to its own fresh temp dir, so a render can never be answered by another one's write. */
|
|
117
|
-
function freshCache(): ArtifactCache {
|
|
118
|
-
return createArtifactCache({ cacheDir: mkdtempSync(join(tmpdir(), "pi-render-engines-cache-")) });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
let dir: string;
|
|
122
|
-
let cacheDir: string;
|
|
123
|
-
let cache: ArtifactCache;
|
|
124
|
-
|
|
125
|
-
beforeEach(() => {
|
|
126
|
-
dir = mkdtempSync(join(tmpdir(), "pi-render-engines-extra-"));
|
|
127
|
-
cacheDir = mkdtempSync(join(tmpdir(), "pi-render-engines-cache-"));
|
|
128
|
-
cache = createArtifactCache({ cacheDir });
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
describe("engine pre-warm", () => {
|
|
132
|
-
// The default engine table is a module-level lazy singleton, so this block runs before the warmup()
|
|
133
|
-
// below; vitest runs one file's tests in declaration order.
|
|
134
|
-
|
|
135
|
-
// spec: before warmup(), the three wasm engines report not-ready and a dot fence throws the named
|
|
136
|
-
// cold-engine error — the synchronous render seam never calls an uninitialised instance.
|
|
137
|
-
// fails_when: a cold engine renders, or the caller gets a TypeError instead of the named error.
|
|
138
|
-
it("a cold engine set reports not-ready and rejects a dot fence by name", () => {
|
|
139
|
-
expect(status()).toMatchObject({ viz: false, resvg: false, canvaskit: false });
|
|
140
|
-
|
|
141
|
-
expect(messageOf(() => renderDiagram("dot", "digraph { a -> b }", { cache }))).toBe("dot engine not warmed");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
// spec: warmup() is what lets the synchronous render seam call the asynchronous engines: afterwards
|
|
145
|
-
// they report ready and a dot fence renders to SVG through the default viz engine.
|
|
146
|
-
// fails_when: an engine is called cold, or a warm-up failure is thrown instead of reported.
|
|
147
|
-
it("warmup flips the wasm engines ready and a dot fence then renders", async () => {
|
|
148
|
-
const warmed = await warmup();
|
|
149
|
-
|
|
150
|
-
expect(warmed).toMatchObject({ viz: true, resvg: true, canvaskit: true });
|
|
151
|
-
|
|
152
|
-
const result = renderDiagram("dot", "digraph { a -> b }", { cache });
|
|
153
|
-
expect(result.kind).toBe("svg");
|
|
154
|
-
if (result.kind === "svg") expect(result.svg).toContain("<svg");
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
describe("plantuml form — the JVM spawn boundary", () => {
|
|
159
|
-
// spec: a plantuml fence writes its source to diagram.puml in the given workDir, spawns the pinned
|
|
160
|
-
// absolute binary with `-tsvg -o <dir> <file>` under the 5s local timeout, and returns the .svg
|
|
161
|
-
// file plantuml actually wrote.
|
|
162
|
-
// fails_when: the argv, the input file, or the output discovery drifts — the JVM then renders
|
|
163
|
-
// nothing and every plantuml fence falls back to the raw fence.
|
|
164
|
-
it("renders through the pinned binary and reads the output it discovers", () => {
|
|
165
|
-
const { calls, spawn } = recorder(plantumlOutput("diagram.svg", "<svg>rendered</svg>"));
|
|
166
|
-
|
|
167
|
-
const result = renderDiagram("plantuml", SOURCE, { plantumlPath: FAKE_PLANTUML, spawn, cache, workDir: dir });
|
|
168
|
-
|
|
169
|
-
expect(result).toEqual({ kind: "svg", svg: "<svg>rendered</svg>" });
|
|
170
|
-
const call = only(calls);
|
|
171
|
-
expect(call.cmd).toBe(FAKE_PLANTUML);
|
|
172
|
-
expect(call.args).toEqual(["-tsvg", "-o", dir, join(dir, "diagram.puml")]);
|
|
173
|
-
expect(call.timeoutMs).toBe(5_000);
|
|
174
|
-
expect(readFileSync(join(dir, "diagram.puml"), "utf8")).toBe(SOURCE);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// spec: the output file is discovered by extension and picked deterministically, because plantuml
|
|
178
|
-
// names it after the diagram title when the source has one.
|
|
179
|
-
// fails_when: the discovery takes an arbitrary match, so the same fence renders different bytes.
|
|
180
|
-
it("discovers the output by extension and picks it deterministically", () => {
|
|
181
|
-
const { spawn } = recorder(({ args }) => {
|
|
182
|
-
const target = argAfter(args, "-o");
|
|
183
|
-
writeFileSync(join(target, "zeta.svg"), "<svg>zeta</svg>");
|
|
184
|
-
writeFileSync(join(target, "alpha.svg"), "<svg>alpha</svg>");
|
|
185
|
-
return OK;
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
const result = renderDiagram("plantuml", SOURCE, { plantumlPath: FAKE_PLANTUML, spawn, cache });
|
|
189
|
-
|
|
190
|
-
expect(result).toEqual({ kind: "svg", svg: "<svg>alpha</svg>" });
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
// spec: text mode asks for `-tutxt`, returns the Unicode art, and never caches it — the art is a
|
|
194
|
-
// view of the render, not an artifact.
|
|
195
|
-
// fails_when: text mode caches, or reuses, the font-dependent art of another terminal width.
|
|
196
|
-
it("renders text art with -tutxt and writes no artifact to the cache", () => {
|
|
197
|
-
const { calls, spawn } = recorder(plantumlOutput("diagram.utxt", "Alice -> Bob"));
|
|
198
|
-
const options = { plantumlPath: FAKE_PLANTUML, spawn, cache, mode: "text" as const };
|
|
199
|
-
|
|
200
|
-
expect(renderDiagram("plantuml", SOURCE, options)).toEqual({ kind: "text", art: "Alice -> Bob" });
|
|
201
|
-
expect(renderDiagram("plantuml", SOURCE, options)).toEqual({ kind: "text", art: "Alice -> Bob" });
|
|
202
|
-
|
|
203
|
-
expect(only(calls).args[0]).toBe("-tutxt");
|
|
204
|
-
expect(calls).toHaveLength(2);
|
|
205
|
-
expect(readdirSync(cacheDir)).toEqual([]);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
// spec: a workDir the caller supplied is used as given and left in place for inspection; without one
|
|
209
|
-
// the seam creates a temp dir and removes it.
|
|
210
|
-
// fails_when: a leaked temp dir per fence render, or a caller's dir deleted under it.
|
|
211
|
-
it("removes the temp dir it created and keeps the caller's workDir", () => {
|
|
212
|
-
const created = recorder(plantumlOutput("diagram.svg", "<svg/>"));
|
|
213
|
-
renderDiagram("plantuml", SOURCE, { plantumlPath: FAKE_PLANTUML, spawn: created.spawn, cache });
|
|
214
|
-
const mine = argAfter(only(created.calls).args, "-o");
|
|
215
|
-
expect(isAbsolute(mine)).toBe(true);
|
|
216
|
-
expect(existsSync(mine)).toBe(false);
|
|
217
|
-
|
|
218
|
-
const kept = recorder(plantumlOutput("diagram.svg", "<svg/>"));
|
|
219
|
-
// A fresh store: the identical fence would otherwise be answered by the first render's cache.
|
|
220
|
-
renderDiagram("plantuml", SOURCE, {
|
|
221
|
-
plantumlPath: FAKE_PLANTUML,
|
|
222
|
-
spawn: kept.spawn,
|
|
223
|
-
cache: freshCache(),
|
|
224
|
-
workDir: dir,
|
|
225
|
-
});
|
|
226
|
-
expect(existsSync(join(dir, "diagram.puml"))).toBe(true);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// spec: an include directive is rejected by name before any process starts — an include can read a
|
|
230
|
-
// local file into the rendered image.
|
|
231
|
-
// fails_when: a hostile fence reaches the JVM, or the guard throws without naming the token.
|
|
232
|
-
it("rejects an include directive before any spawn", () => {
|
|
233
|
-
const { calls, spawn } = recorder();
|
|
234
|
-
|
|
235
|
-
const message = messageOf(() =>
|
|
236
|
-
renderDiagram("plantuml", "@startuml\n!include /etc/passwd\n@enduml", {
|
|
237
|
-
plantumlPath: FAKE_PLANTUML,
|
|
238
|
-
spawn,
|
|
239
|
-
cache,
|
|
240
|
-
}),
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
expect(message).toBe('plantuml: "!include" is rejected — includes can read local files');
|
|
244
|
-
expect(calls).toEqual([]);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// spec: an exit-0 run that wrote no output file is an error, never an empty artifact.
|
|
248
|
-
// fails_when: a silent JVM failure renders as an empty diagram.
|
|
249
|
-
it("errors when the run wrote no matching output", () => {
|
|
250
|
-
const { spawn } = recorder();
|
|
251
|
-
|
|
252
|
-
expect(messageOf(() => renderDiagram("plantuml", SOURCE, { plantumlPath: FAKE_PLANTUML, spawn, cache }))).toBe(
|
|
253
|
-
"plantuml: no .svg output was written",
|
|
254
|
-
);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// spec: a spawn failure names the missing binary.
|
|
258
|
-
// fails_when: a missing binary surfaces as an unhandled crash, or the message loses the command.
|
|
259
|
-
it("names the missing binary", () => {
|
|
260
|
-
const { spawn } = recorder(() => spawnError("ENOENT"));
|
|
261
|
-
|
|
262
|
-
expect(messageOf(() => renderDiagram("plantuml", SOURCE, { plantumlPath: FAKE_PLANTUML, spawn, cache }))).toBe(
|
|
263
|
-
`plantuml: "${FAKE_PLANTUML}" not found`,
|
|
264
|
-
);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
// spec: a timed-out run reports the timeout the spawn was given.
|
|
268
|
-
// fails_when: a hung JVM reports as a generic failure, losing the bound that stopped it.
|
|
269
|
-
it("names the timeout it was given", () => {
|
|
270
|
-
const { calls, spawn } = recorder(() => spawnError("ETIMEDOUT"));
|
|
271
|
-
|
|
272
|
-
const message = messageOf(() =>
|
|
273
|
-
renderDiagram("plantuml", SOURCE, { plantumlPath: FAKE_PLANTUML, spawn, cache, timeoutMs: 1_234 }),
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
expect(message).toBe(`plantuml: "${FAKE_PLANTUML}" timed out after 1234ms`);
|
|
277
|
-
expect(only(calls).timeoutMs).toBe(1_234);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
// spec: a run killed by SIGTERM is reported as a timeout of the default 5s.
|
|
281
|
-
// fails_when: the timeout kill surfaces as an unhandled status-less exit.
|
|
282
|
-
it("treats a SIGTERM'd run as a timeout", () => {
|
|
283
|
-
const { spawn } = recorder(() => ({ status: null, signal: "SIGTERM" }));
|
|
284
|
-
|
|
285
|
-
expect(messageOf(() => renderDiagram("plantuml", SOURCE, { plantumlPath: FAKE_PLANTUML, spawn, cache }))).toBe(
|
|
286
|
-
`plantuml: "${FAKE_PLANTUML}" timed out after 5000ms`,
|
|
287
|
-
);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
// spec: a spawn-level error is reported with its own message even when the status looks like success.
|
|
291
|
-
// fails_when: an error beside status 0 is read as a successful render.
|
|
292
|
-
it("reports a spawn error even beside a zero exit status", () => {
|
|
293
|
-
const { spawn } = recorder(() => ({ status: 0, error: new Error("EACCES: permission denied") }));
|
|
294
|
-
|
|
295
|
-
expect(messageOf(() => renderDiagram("plantuml", SOURCE, { plantumlPath: FAKE_PLANTUML, spawn, cache }))).toBe(
|
|
296
|
-
`plantuml: "${FAKE_PLANTUML}" failed: EACCES: permission denied`,
|
|
297
|
-
);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
// spec: a failing exit reports the first stderr line, not the whole multi-line dump.
|
|
301
|
-
// fails_when: the diagnostic is either lost or floods the single-line error contract.
|
|
302
|
-
it("reports the first stderr line of a failing exit", () => {
|
|
303
|
-
const { spawn } = recorder(() => ({ status: 1, signal: null, stderr: Buffer.from("Error one\nError two\n") }));
|
|
304
|
-
|
|
305
|
-
expect(messageOf(() => renderDiagram("plantuml", SOURCE, { plantumlPath: FAKE_PLANTUML, spawn, cache }))).toBe(
|
|
306
|
-
`plantuml: "${FAKE_PLANTUML}" exited 1: Error one`,
|
|
307
|
-
);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
// spec: a failing exit with no stderr reports the status alone, with no trailing separator.
|
|
311
|
-
// fails_when: an empty stderr renders as "exited 1: " with a dangling colon.
|
|
312
|
-
it("reports a failing exit with no stderr by status alone", () => {
|
|
313
|
-
const { spawn } = recorder(() => ({ status: 3, signal: null, stderr: Buffer.alloc(0) }));
|
|
314
|
-
|
|
315
|
-
expect(messageOf(() => renderDiagram("plantuml", SOURCE, { plantumlPath: FAKE_PLANTUML, spawn, cache }))).toBe(
|
|
316
|
-
`plantuml: "${FAKE_PLANTUML}" exited 3`,
|
|
317
|
-
);
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
// spec: a status-less, error-less exit is reported as a signal rather than as exit 0/NaN.
|
|
321
|
-
// fails_when: the message reads "exited null" or "exited 0" for a process that was killed.
|
|
322
|
-
it("reports a status-less, error-less exit as a signal", () => {
|
|
323
|
-
const { spawn } = recorder(() => ({ status: null, signal: null }));
|
|
324
|
-
|
|
325
|
-
expect(messageOf(() => renderDiagram("plantuml", SOURCE, { plantumlPath: FAKE_PLANTUML, spawn, cache }))).toBe(
|
|
326
|
-
`plantuml: "${FAKE_PLANTUML}" exited on signal`,
|
|
327
|
-
);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
// spec: the binary comes from the injected prereq probe when no explicit path is given, so an
|
|
331
|
-
// installed plantuml is spawned from the absolute candidate the resolver picked.
|
|
332
|
-
// fails_when: the render ignores the probe and re-resolves (or reaches for PATH).
|
|
333
|
-
it("spawns the absolute candidate the injected probe resolved", () => {
|
|
334
|
-
const { calls, spawn } = recorder(plantumlOutput("diagram.svg", "<svg/>"));
|
|
335
|
-
// invented: a linux candidate list whose first entry exists — no real probe, no real binary.
|
|
336
|
-
const prereqs = { platform: "linux" as const, exists: (path: string) => path === "/usr/bin/plantuml" };
|
|
337
|
-
|
|
338
|
-
renderDiagram("plantuml", SOURCE, { prereqs, spawn, cache });
|
|
339
|
-
|
|
340
|
-
expect(only(calls).cmd).toBe("/usr/bin/plantuml");
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
// spec: a binary warmup() already resolved is the one spawned (no second probe), and on a machine
|
|
344
|
-
// where warmup found none the render reports the install hint before any spawn site is reached.
|
|
345
|
-
// fails_when: a warmed absolute path is discarded for a fresh lookup, or a missing plantuml reaches a
|
|
346
|
-
// spawn instead of the actionable hint.
|
|
347
|
-
it("spawns the path warmup resolved, or reports the install hint when there is none", () => {
|
|
348
|
-
const { calls, spawn } = recorder(plantumlOutput("diagram.svg", "<svg/>"));
|
|
349
|
-
|
|
350
|
-
// The branch this machine takes is the contract's two halves; both are asserted.
|
|
351
|
-
if (!status().plantuml) {
|
|
352
|
-
expect(messageOf(() => renderDiagram("plantuml", SOURCE, { spawn, cache }))).toMatch(
|
|
353
|
-
/^plantuml: not found — install with /,
|
|
354
|
-
);
|
|
355
|
-
expect(calls).toEqual([]);
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
renderDiagram("plantuml", SOURCE, { spawn, cache });
|
|
360
|
-
|
|
361
|
-
const call = only(calls);
|
|
362
|
-
expect(isAbsolute(call.cmd)).toBe(true);
|
|
363
|
-
expect(call.cmd.endsWith("plantuml")).toBe(true);
|
|
364
|
-
});
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
describe("html form — the browser screenshot boundary", () => {
|
|
368
|
-
// spec: an html fence is wrapped in the dark shell with `default-src 'none'`, written to
|
|
369
|
-
// fragment.html, and screenshotted by the pinned browser into a throwaway profile under the same
|
|
370
|
-
// temp dir; the PNG bytes come back base64.
|
|
371
|
-
// fails_when: the CSP is dropped (a fragment may then fetch), the profile is the user's, or the
|
|
372
|
-
// argv loses --headless/--screenshot and the browser never writes an image.
|
|
373
|
-
it("screenshots through the pinned browser into a throwaway profile", () => {
|
|
374
|
-
const { calls, spawn } = recorder(screenshot(PNG_BYTES));
|
|
375
|
-
|
|
376
|
-
const result = renderDiagram("html", "<h1>hi</h1>", {
|
|
377
|
-
browserPath: FAKE_BROWSER,
|
|
378
|
-
spawn,
|
|
379
|
-
cache,
|
|
380
|
-
workDir: dir,
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
expect(result).toEqual({ kind: "image", base64: PNG_BYTES.toString("base64") });
|
|
384
|
-
const call = only(calls);
|
|
385
|
-
expect(call.cmd).toBe(FAKE_BROWSER);
|
|
386
|
-
expect(call.args).toEqual([
|
|
387
|
-
"--headless",
|
|
388
|
-
"--disable-gpu",
|
|
389
|
-
`--user-data-dir=${join(dir, "browser-profile")}`,
|
|
390
|
-
"--no-first-run",
|
|
391
|
-
"--disable-background-networking",
|
|
392
|
-
"--hide-scrollbars",
|
|
393
|
-
`--screenshot=${join(dir, "screenshot.png")}`,
|
|
394
|
-
"--window-size=1200,800",
|
|
395
|
-
"--virtual-time-budget=3000",
|
|
396
|
-
`file://${join(dir, "fragment.html")}`,
|
|
397
|
-
]);
|
|
398
|
-
expect(call.timeoutMs).toBe(15_000);
|
|
399
|
-
const fragment = readFileSync(join(dir, "fragment.html"), "utf8");
|
|
400
|
-
expect(fragment).toContain(`content="default-src 'none'; style-src 'unsafe-inline'"`);
|
|
401
|
-
expect(fragment).toContain("<body><h1>hi</h1></body>");
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
// spec: the browser timeout is the pinned 15s by default and a caller override otherwise; the temp
|
|
405
|
-
// dir a caller did not supply is removed after the render.
|
|
406
|
-
// fails_when: a hung browser pins the render forever, or a per-fence temp dir leaks.
|
|
407
|
-
it("uses the pinned 15s timeout, honors an override, and cleans up its temp dir", () => {
|
|
408
|
-
const defaulted = recorder(screenshot(PNG_BYTES));
|
|
409
|
-
renderDiagram("html", "<p>x</p>", { browserPath: FAKE_BROWSER, spawn: defaulted.spawn, cache });
|
|
410
|
-
const used = dirname(argWithPrefix(only(defaulted.calls).args, "--screenshot=").slice("--screenshot=".length));
|
|
411
|
-
expect(only(defaulted.calls).timeoutMs).toBe(15_000);
|
|
412
|
-
expect(existsSync(used)).toBe(false);
|
|
413
|
-
|
|
414
|
-
const overridden = recorder(screenshot(PNG_BYTES));
|
|
415
|
-
// A fresh store: the identical fence would otherwise be answered by the first render's cache.
|
|
416
|
-
renderDiagram("html", "<p>x</p>", {
|
|
417
|
-
browserPath: FAKE_BROWSER,
|
|
418
|
-
spawn: overridden.spawn,
|
|
419
|
-
cache: freshCache(),
|
|
420
|
-
timeoutMs: 250,
|
|
421
|
-
});
|
|
422
|
-
expect(only(overridden.calls).timeoutMs).toBe(250);
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
// spec: an exit-0 browser run that wrote no screenshot is an error, never an empty image.
|
|
426
|
-
// fails_when: the documented silent shell failure (the script flag) renders as a blank card.
|
|
427
|
-
it("errors when the browser wrote no screenshot", () => {
|
|
428
|
-
const { spawn } = recorder();
|
|
429
|
-
|
|
430
|
-
expect(messageOf(() => renderDiagram("html", "<p>x</p>", { browserPath: FAKE_BROWSER, spawn, cache }))).toBe(
|
|
431
|
-
"html: browser produced no screenshot",
|
|
432
|
-
);
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
// spec: with no pinned browser on the machine the form reports the install hint and spawns nothing.
|
|
436
|
-
// fails_when: a PATH lookup or a spawn is attempted for a browser that is not there.
|
|
437
|
-
it("reports the install hint and spawns nothing when no browser is pinned", () => {
|
|
438
|
-
const { calls, spawn } = recorder();
|
|
439
|
-
// invented: an empty candidate list on linux — the machine-independent "none installed" state.
|
|
440
|
-
const missing = { platform: "linux" as const, exists: () => false };
|
|
441
|
-
|
|
442
|
-
expect(messageOf(() => renderDiagram("html", "<p>x</p>", { prereqs: missing, spawn, cache }))).toMatch(
|
|
443
|
-
/^html: no pinned browser found — /,
|
|
444
|
-
);
|
|
445
|
-
expect(calls).toEqual([]);
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
// spec: a repeated html fence is served from the cached PNG without spawning the browser again.
|
|
449
|
-
// fails_when: every re-render of an answer launches a browser for the same fragment.
|
|
450
|
-
it("serves a repeated html fence from the cache with one spawn", () => {
|
|
451
|
-
const { calls, spawn } = recorder(screenshot(PNG_BYTES));
|
|
452
|
-
const options = { browserPath: FAKE_BROWSER, spawn, cache };
|
|
453
|
-
|
|
454
|
-
const first = renderDiagram("html", "<p>x</p>", options);
|
|
455
|
-
const second = renderDiagram("html", "<p>x</p>", options);
|
|
456
|
-
|
|
457
|
-
expect(calls).toHaveLength(1);
|
|
458
|
-
expect(second).toEqual(first);
|
|
459
|
-
});
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
describe("cache interaction at the seam", () => {
|
|
463
|
-
// spec: a cache that cannot be read is a miss, not a failed render — the engine still runs.
|
|
464
|
-
// fails_when: a cache read error propagates into the render path.
|
|
465
|
-
it("treats an unreadable cache as a miss", () => {
|
|
466
|
-
// invented: a store whose read throws is the fixture; no real directory is involved.
|
|
467
|
-
const unreadable: ArtifactCache = {
|
|
468
|
-
get: () => undefined,
|
|
469
|
-
put: () => {},
|
|
470
|
-
putVector: () => {},
|
|
471
|
-
vectorPath: () => {
|
|
472
|
-
throw new Error("EACCES: cache unreadable");
|
|
473
|
-
},
|
|
474
|
-
putSource: () => undefined,
|
|
475
|
-
evict: () => {},
|
|
476
|
-
};
|
|
477
|
-
|
|
478
|
-
const result = renderDiagram("dot", "digraph { a -> b }", {
|
|
479
|
-
cache: unreadable,
|
|
480
|
-
engines: { viz: countingViz(() => {}) },
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
expect(result).toEqual({ kind: "svg", svg: "<svg>digraph { a -> b }</svg>" });
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
// spec: a vector artifact is stored under the width-clamped key (a fence width below 1 clamps to 1)
|
|
487
|
-
// with its mime, so a re-render at the clamped width is a file read.
|
|
488
|
-
// fails_when: the width reaches the key unclamped — a zero-width fence caches under a key no
|
|
489
|
-
// re-render will look up.
|
|
490
|
-
it("stores the vector under a width-clamped key with its mime", () => {
|
|
491
|
-
const puts: Array<{ key: string; svg: string; meta: CacheMeta }> = [];
|
|
492
|
-
// invented: the recording store is the fixture — it captures the artifact the seam would persist.
|
|
493
|
-
const recording: ArtifactCache = {
|
|
494
|
-
get: () => undefined,
|
|
495
|
-
put: () => {},
|
|
496
|
-
putVector: (key, svg, meta) => puts.push({ key, svg, meta }),
|
|
497
|
-
vectorPath: () => undefined,
|
|
498
|
-
putSource: () => undefined,
|
|
499
|
-
evict: () => {},
|
|
500
|
-
};
|
|
501
|
-
const engine = countingViz(() => {});
|
|
502
|
-
|
|
503
|
-
renderDiagram("dot", "digraph { a -> b }", { cache: recording, engines: { viz: engine }, widthCells: 0 });
|
|
504
|
-
renderDiagram("dot", "digraph { a -> b }", { cache: recording, engines: { viz: engine }, widthCells: -4 });
|
|
505
|
-
|
|
506
|
-
expect(puts).toHaveLength(2);
|
|
507
|
-
expect(puts[0]?.key).toBe(puts[1]?.key);
|
|
508
|
-
expect(only(puts).meta).toMatchObject({ mime: "image/svg+xml", widthCells: 1, source: "dot", mode: "image" });
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
// spec: a text result is never written to the artifact cache, even when the mode asks for caching.
|
|
512
|
-
// fails_when: font-dependent Unicode art is persisted as if it were an artifact.
|
|
513
|
-
it("never caches a text result", () => {
|
|
514
|
-
// invented: an engine returning text under image mode is the fixture — no real JVM is involved.
|
|
515
|
-
const textEngine: Engine = {
|
|
516
|
-
id: "plantuml",
|
|
517
|
-
warmup: async () => {},
|
|
518
|
-
status: () => true,
|
|
519
|
-
render: () => ({ kind: "text", art: "Alice -> Bob" }),
|
|
520
|
-
};
|
|
521
|
-
|
|
522
|
-
const result = renderDiagram("plantuml", SOURCE, { engines: { plantuml: textEngine }, cache });
|
|
523
|
-
|
|
524
|
-
expect(result).toEqual({ kind: "text", art: "Alice -> Bob" });
|
|
525
|
-
expect(readdirSync(cacheDir)).toEqual([]);
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
// spec: a cache write failure is reported through the injected logger and the render still returns.
|
|
529
|
-
// fails_when: a full disk costs the artifact that just rendered, or the failure vanishes silently.
|
|
530
|
-
it("reports a failed cache write and still returns the artifact", () => {
|
|
531
|
-
const log = createLogger();
|
|
532
|
-
// invented: a store whose write throws is the fixture; no real directory is involved.
|
|
533
|
-
const broken: ArtifactCache = {
|
|
534
|
-
get: () => undefined,
|
|
535
|
-
put: () => {},
|
|
536
|
-
putVector: () => {
|
|
537
|
-
throw new Error("ENOSPC: no space left on device");
|
|
538
|
-
},
|
|
539
|
-
vectorPath: () => undefined,
|
|
540
|
-
putSource: () => undefined,
|
|
541
|
-
evict: () => {},
|
|
542
|
-
};
|
|
543
|
-
|
|
544
|
-
const result = renderDiagram("dot", "digraph { a -> b }", {
|
|
545
|
-
cache: broken,
|
|
546
|
-
engines: { viz: countingViz(() => {}) },
|
|
547
|
-
log,
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
expect(result.kind).toBe("svg");
|
|
551
|
-
const entries = log.drain();
|
|
552
|
-
expect(entries).toHaveLength(1);
|
|
553
|
-
expect(entries[0]?.scope).toBe("content.artifacts");
|
|
554
|
-
expect(entries[0]?.message).toMatch(/^artifact not cached: ENOSPC: no space left on device$/);
|
|
555
|
-
});
|
|
556
|
-
});
|