@zenodinh/pi-render 0.0.0-stage → 0.1.0

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/index.ts +279 -0
  4. package/package.json +66 -5
  5. package/src/commands/canvas.test.ts +150 -0
  6. package/src/commands/canvas.ts +57 -0
  7. package/src/core/code-theme.test.ts +266 -0
  8. package/src/core/code-theme.ts +275 -0
  9. package/src/core/log.test.ts +127 -0
  10. package/src/core/log.ts +57 -0
  11. package/src/core/paint.test.ts +322 -0
  12. package/src/core/paint.ts +64 -0
  13. package/src/core/registry.test.ts +307 -0
  14. package/src/core/registry.ts +135 -0
  15. package/src/core/settings.test.ts +183 -0
  16. package/src/core/settings.ts +119 -0
  17. package/src/core/types/code-theme.ts +24 -0
  18. package/src/core/types/host.ts +160 -0
  19. package/src/core/types/log.ts +28 -0
  20. package/src/core/types/paint.ts +54 -0
  21. package/src/core/types/registry.ts +44 -0
  22. package/src/core/types/settings.ts +31 -0
  23. package/src/core/types.ts +34 -0
  24. package/src/renderers/content/artifacts/artifacts.test.ts +199 -0
  25. package/src/renderers/content/artifacts/cache.ts +234 -0
  26. package/src/renderers/content/artifacts/cards.test.ts +216 -0
  27. package/src/renderers/content/artifacts/cards.ts +136 -0
  28. package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
  29. package/src/renderers/content/artifacts/engines.ts +396 -0
  30. package/src/renderers/content/artifacts/local-binary.test.ts +207 -0
  31. package/src/renderers/content/artifacts/local-binary.ts +80 -0
  32. package/src/renderers/content/artifacts/prereqs.ts +128 -0
  33. package/src/renderers/content/artifacts/server.ts +181 -0
  34. package/src/renderers/content/code-panel.ts +161 -0
  35. package/src/renderers/content/image-card.test.ts +170 -0
  36. package/src/renderers/content/image-card.ts +252 -0
  37. package/src/renderers/content/index.ts +79 -0
  38. package/src/renderers/content/json-panel.ts +116 -0
  39. package/src/renderers/content/panels.test.ts +188 -0
  40. package/src/renderers/content/table.test.ts +209 -0
  41. package/src/renderers/content/table.ts +174 -0
  42. package/src/renderers/content/transformer.test.ts +254 -0
  43. package/src/renderers/content/types.ts +20 -0
  44. package/src/renderers/tool/index.ts +113 -0
  45. package/src/renderers/tool/resolver.test.ts +257 -0
  46. package/src/renderers/tool/runtime.test.ts +313 -0
  47. package/src/renderers/tool/runtime.ts +267 -0
  48. package/src/renderers/tool/specs/bash.test.ts +110 -0
  49. package/src/renderers/tool/specs/bash.ts +168 -0
  50. package/src/renderers/tool/specs/codemode.test.ts +212 -0
  51. package/src/renderers/tool/specs/codemode.ts +248 -0
  52. package/src/renderers/tool/specs/edit.test.ts +260 -0
  53. package/src/renderers/tool/specs/edit.ts +213 -0
  54. package/src/renderers/tool/specs/ls.test.ts +173 -0
  55. package/src/renderers/tool/specs/ls.ts +136 -0
  56. package/src/renderers/tool/specs/read.test.ts +340 -0
  57. package/src/renderers/tool/specs/read.ts +296 -0
  58. package/src/renderers/tool/specs/search.test.ts +197 -0
  59. package/src/renderers/tool/specs/search.ts +325 -0
  60. package/src/renderers/tool/specs/write.test.ts +145 -0
  61. package/src/renderers/tool/specs/write.ts +142 -0
  62. package/src/renderers/tool/types.ts +45 -0
  63. package/themes/dracula-soft.json +81 -0
  64. package/themes/one-dark.json +80 -0
  65. package/themes/themes.test.ts +251 -0
@@ -0,0 +1,199 @@
1
+ /**
2
+ * artifacts.test.ts — the artifacts seam: renderDiagram / warmup / status (engines.ts) and the cache
3
+ * (cache.ts), driven through fake engines and temp cache directories only (T-25).
4
+ *
5
+ * The first describe block is the predecessor's cache test ported unchanged (AC-4 parity); the second
6
+ * is this ticket's behavior spec: one engine run per repeated fence, a missing binary reported without a
7
+ * spawn, and a bounded store that never fails a render.
8
+ *
9
+ * shape: none — test module, no runtime unit for DSG-1's trigger table to select a shape for.
10
+ */
11
+
12
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, truncateSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { beforeEach, describe, expect, it } from "vitest";
16
+ import {
17
+ type CacheMeta,
18
+ createArtifactCache,
19
+ evictIfNeeded,
20
+ getRenderedEntry,
21
+ putRenderedEntry,
22
+ putRenderedSource,
23
+ } from "./cache.ts";
24
+ import { type DiagramResult, type Engine, renderDiagram, status, warmup } from "./engines.ts";
25
+ import type { LocalSpawn } from "./local-binary.ts";
26
+
27
+ // recorded-from: generated 1x1 RGBA PNG fixture, 2026-10-03
28
+ const PNG_BYTES = Buffer.from(
29
+ "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63300e5df51f0004230232e7bf98d60000000049454e44ae426082",
30
+ "hex",
31
+ );
32
+
33
+ let dir: string;
34
+
35
+ beforeEach(() => {
36
+ dir = mkdtempSync(join(tmpdir(), "pretty-cache-"));
37
+ });
38
+
39
+ function meta(createdAt: string): CacheMeta {
40
+ return { source: "plantuml", mode: "image", mime: "image/png", rows: 4, widthCells: 40, createdAt };
41
+ }
42
+
43
+ describe("render cache", () => {
44
+ it("round-trips pairs, misses cleanly, and evicts oldest-first past the byte cap", () => {
45
+ putRenderedEntry("aaa", PNG_BYTES, meta("2026-10-02T00:00:00.000Z"), { cacheDir: dir });
46
+ putRenderedEntry("bbb", PNG_BYTES, meta("2026-10-02T00:01:00.000Z"), { cacheDir: dir });
47
+
48
+ const hit = getRenderedEntry("aaa", { cacheDir: dir });
49
+ expect(hit?.png.equals(PNG_BYTES)).toBe(true);
50
+ expect(hit?.meta).toEqual(meta("2026-10-02T00:00:00.000Z"));
51
+ expect(getRenderedEntry("missing", { cacheDir: dir })).toBeUndefined();
52
+ expect(existsSync(join(dir, "aaa.png.tmp"))).toBe(false);
53
+ expect(existsSync(join(dir, "aaa.meta.json.tmp"))).toBe(false);
54
+
55
+ // The saved source rides the same key, written atomically and evicted with
56
+ // its pair.
57
+ const sourcePath = putRenderedSource("aaa", "@startuml\nA -> B\n@enduml\n", "puml", { cacheDir: dir });
58
+ expect(sourcePath).toBe(join(dir, "aaa.src.puml"));
59
+ expect(readFileSync(sourcePath as string, "utf8")).toBe("@startuml\nA -> B\n@enduml\n");
60
+
61
+ // Seed 30 backdated entries whose PNGs are 10 MiB each (sparse): 300 MiB
62
+ // crosses the 256 MiB cap, so the oldest must leave while recent pairs stay.
63
+ for (let i = 0; i < 30; i++) {
64
+ const key = `seed${i.toString().padStart(2, "0")}`;
65
+ writeFileSync(
66
+ join(dir, `${key}.meta.json`),
67
+ JSON.stringify(meta(`2026-10-01T00:${String(i).padStart(2, "0")}:00.000Z`)),
68
+ );
69
+ const png = join(dir, `${key}.png`);
70
+ writeFileSync(png, Buffer.alloc(0));
71
+ truncateSync(png, 10 * 1024 * 1024);
72
+ }
73
+ writeFileSync(join(dir, "seed00.src.puml"), "stale source");
74
+
75
+ evictIfNeeded({ cacheDir: dir });
76
+
77
+ expect(existsSync(join(dir, "seed00.png"))).toBe(false);
78
+ expect(existsSync(join(dir, "seed00.meta.json"))).toBe(false);
79
+ expect(existsSync(join(dir, "seed00.src.puml"))).toBe(false);
80
+ expect(existsSync(join(dir, "seed29.meta.json"))).toBe(true);
81
+ expect(existsSync(join(dir, "aaa.png"))).toBe(true);
82
+ expect(existsSync(join(dir, "aaa.src.puml"))).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe("artifact render seam", () => {
87
+ // spec: renderDiagram("dot", source, …) twice with the same source, mode and width runs the engine
88
+ // once and returns the identical artifact — a repeated fence is a file read, not a render.
89
+ // fails_when: duplicate engine runs (every re-render of an answer re-spawns/re-renders).
90
+ it("AC-1 a repeated fence runs its engine exactly once and returns identical output", () => {
91
+ let calls = 0;
92
+ // invented: the counting engine is the whole fixture — there is no upstream payload to record.
93
+ const fakeViz: Engine = {
94
+ id: "viz",
95
+ warmup: async () => {},
96
+ status: () => true,
97
+ render: (source) => {
98
+ calls += 1;
99
+ return { kind: "svg", svg: `<svg>${source}</svg>` };
100
+ },
101
+ };
102
+ const cache = createArtifactCache({ cacheDir: dir });
103
+ const options = { cache, engines: { viz: fakeViz } };
104
+
105
+ const first: DiagramResult = renderDiagram("dot", "digraph { a -> b }", options);
106
+ const second: DiagramResult = renderDiagram("dot", "digraph { a -> b }", options);
107
+
108
+ expect(calls).toBe(1);
109
+ expect(second.kind).toBe("svg");
110
+ expect(second).toEqual(first);
111
+ });
112
+
113
+ // spec: on a machine whose plantuml candidate list holds no file, status() reports the form
114
+ // unavailable and renderDiagram throws the install hint — the caller then keeps the raw fence.
115
+ // fails_when: a spawn is attempted for a binary that is known to be missing, or the render crashes.
116
+ it("AC-2 a missing plantuml binary reports unavailable and attempts no spawn", () => {
117
+ const spawned: string[] = [];
118
+ const spawn: LocalSpawn = (cmd) => {
119
+ spawned.push(cmd);
120
+ return { status: 0, signal: null, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
121
+ };
122
+ // invented: an empty candidate list on linux — the machine-independent "not installed" state.
123
+ const missing = { platform: "linux" as const, exists: () => false };
124
+
125
+ expect(status(missing).plantuml).toBe(false);
126
+ expect(() =>
127
+ renderDiagram("plantuml", "@startuml\nA -> B\n@enduml\n", {
128
+ cache: createArtifactCache({ cacheDir: dir }),
129
+ prereqs: missing,
130
+ spawn,
131
+ }),
132
+ ).toThrow(/apt install plantuml/);
133
+ expect(spawned).toEqual([]);
134
+ });
135
+
136
+ // spec: a cache holding more entries than the 2048 cap loses its oldest entries on eviction.
137
+ // fails_when: the store grows without limit.
138
+ it("AC-3 eviction shrinks an over-cap store to the cap, oldest first", () => {
139
+ const total = 2050;
140
+ for (let i = 0; i < total; i++) {
141
+ const key = `e${i.toString().padStart(4, "0")}`;
142
+ writeFileSync(join(dir, `${key}.png`), Buffer.from("x"));
143
+ writeFileSync(
144
+ join(dir, `${key}.meta.json`),
145
+ JSON.stringify(meta(new Date(Date.UTC(2026, 9, 1, 0, 0, i)).toISOString())),
146
+ );
147
+ }
148
+
149
+ evictIfNeeded({ cacheDir: dir });
150
+
151
+ const entries = readdirSync(dir).filter((name) => name.endsWith(".meta.json"));
152
+ expect(entries.length).toBeLessThanOrEqual(2048);
153
+ expect(existsSync(join(dir, "e0000.meta.json"))).toBe(false);
154
+ expect(existsSync(join(dir, "e2049.meta.json"))).toBe(true);
155
+ });
156
+
157
+ // spec: an unusable cache directory (here: a path below a regular file) is a lost optimization, not a
158
+ // failed artifact — the render still returns its result and eviction still returns quietly.
159
+ // fails_when: a cache write or eviction error propagates into the render path.
160
+ it("AC-3 a failing store never fails the render or throws from eviction", () => {
161
+ const blocker = join(dir, "blocker");
162
+ writeFileSync(blocker, "");
163
+ const cache = createArtifactCache({ cacheDir: join(blocker, "cache") });
164
+ // invented: the counting engine is the whole fixture — no upstream payload to record.
165
+ const fakeViz: Engine = {
166
+ id: "viz",
167
+ warmup: async () => {},
168
+ status: () => true,
169
+ render: () => ({ kind: "svg", svg: "<svg/>" }),
170
+ };
171
+
172
+ const result = renderDiagram("dot", "digraph { a -> b }", { cache, engines: { viz: fakeViz } });
173
+
174
+ expect(result.kind).toBe("svg");
175
+ expect(() => cache.evict()).not.toThrow();
176
+ });
177
+
178
+ // spec: warmup() is what lets the synchronous render seam call an asynchronous engine: after it, the
179
+ // three wasm engines report ready.
180
+ // fails_when: an engine is called cold, or a warm-up failure is thrown instead of reported.
181
+ it("warmup reports the wasm engines ready", async () => {
182
+ const warmed = await warmup();
183
+
184
+ expect(warmed.resvg).toBe(true);
185
+ expect(warmed.viz).toBe(true);
186
+ expect(warmed.canvaskit).toBe(true);
187
+ });
188
+
189
+ // spec: a warmed viz engine turns a DOT fence into SVG through the same seam a fence uses.
190
+ // fails_when: the ported engine call cannot render, or the result kind drifts from vector.
191
+ it("a warmed viz engine renders a dot fence to SVG", async () => {
192
+ await warmup();
193
+
194
+ const result = renderDiagram("dot", "digraph { a -> b }", { cache: createArtifactCache({ cacheDir: dir }) });
195
+
196
+ expect(result.kind).toBe("svg");
197
+ if (result.kind === "svg") expect(result.svg).toContain("<svg");
198
+ });
199
+ });
@@ -0,0 +1,234 @@
1
+ // ported from pi-pretty-tui/src/features/canvas/cache.ts — survives because: the hash-keyed atomic pair
2
+ // is what makes a repeated fence free, and the two caps keep that promise bounded.
3
+
4
+ /**
5
+ * Disk cache for rendered diagrams: hash-keyed artifact + meta pairs under
6
+ * `~/.pi/agent/pi-render/cache`, bounded so repeat renders stay free without the store growing without
7
+ * limit.
8
+ *
9
+ * Why pairs and why atomic: the artifact IS the render result and the meta is its
10
+ * provenance (source form, mode, dimensions, creation time) — eviction orders
11
+ * by meta.createdAt. Every write lands via tmp+rename so a concurrent reader
12
+ * sees the old or the new complete file, never a partial one.
13
+ *
14
+ * shape: closure returning an object literal — trigger #4, one bound directory plus the store methods,
15
+ * so no class, subclassing, or instanceof.
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import type { Logger } from "../../../core/types/log.ts";
22
+
23
+ export interface CacheMeta {
24
+ source: string;
25
+ mode: string;
26
+ mime: string;
27
+ rows: number;
28
+ widthCells: number;
29
+ createdAt: string;
30
+ }
31
+
32
+ export interface CacheOptions {
33
+ cacheDir?: string;
34
+ /** Diagnostics sink; absent means eviction failures are swallowed unwritten. */
35
+ log?: Logger;
36
+ }
37
+
38
+ const CACHE_DIR_NAME = "cache";
39
+ const SCOPE = "content.artifacts";
40
+ const META_SUFFIX = ".meta.json";
41
+ const PNG_SUFFIX = ".png";
42
+ const VECTOR_SUFFIX = ".svg";
43
+ const SOURCE_INFIX = ".src.";
44
+ const MAX_BYTES = 256 * 1024 * 1024;
45
+ const MAX_ENTRIES = 2048;
46
+
47
+ /**
48
+ * shape: none — one env lookup beside the cache dir name; `PI_CODING_AGENT_DIR` mirrors the host's
49
+ * agent-directory override, and the tree is the one `settings.json` already lives in.
50
+ */
51
+ function resolveCacheDir(opts?: CacheOptions): string | undefined {
52
+ if (opts?.cacheDir) return opts.cacheDir;
53
+ const agentDir = process.env.PI_CODING_AGENT_DIR || (process.env.HOME ? join(process.env.HOME, ".pi/agent") : "");
54
+ return agentDir ? join(agentDir, "pi-render", CACHE_DIR_NAME) : undefined;
55
+ }
56
+
57
+ function writeAtomic(path: string, data: Buffer): void {
58
+ const temporary = `${path}.tmp`;
59
+ writeFileSync(temporary, data);
60
+ renameSync(temporary, path);
61
+ }
62
+
63
+ /** shape: none — one hash over a fixed tuple; the key format is the whole contract. */
64
+ export function renderCacheKey(form: string, source: string, mode: string, widthCells: number): string {
65
+ return createHash("sha256").update(`${form}\0${source}\0${mode}\0${widthCells}`).digest("hex");
66
+ }
67
+
68
+ /** The directory the render cache lives in; exported so callers can point a viewer at it. */
69
+ export function resolveRenderedCacheDir(opts?: CacheOptions): string | undefined {
70
+ return resolveCacheDir(opts);
71
+ }
72
+
73
+ /** shape: none — the vector is the artifact the card opens; existence is the hit. */
74
+ export function putRenderedVector(key: string, svg: string, meta: CacheMeta, opts: CacheOptions = {}): void {
75
+ const dir = resolveCacheDir(opts);
76
+ if (!dir) return;
77
+ mkdirSync(dir, { recursive: true });
78
+ writeAtomic(join(dir, `${key}${VECTOR_SUFFIX}`), Buffer.from(svg));
79
+ writeAtomic(join(dir, `${key}${META_SUFFIX}`), Buffer.from(`${JSON.stringify(meta, null, 2)}\n`));
80
+ }
81
+
82
+ /** shape: none — one existence check; the path is what the card opens. */
83
+ export function getRenderedVectorPath(key: string, opts: CacheOptions = {}): string | undefined {
84
+ const dir = resolveCacheDir(opts);
85
+ if (!dir) return undefined;
86
+ const path = join(dir, `${key}${VECTOR_SUFFIX}`);
87
+ return existsSync(path) ? path : undefined;
88
+ }
89
+
90
+ /** shape: none — one atomic write; the source is kept for copy/reuse, never read back. */
91
+ export function putRenderedSource(
92
+ key: string,
93
+ source: string,
94
+ extension: string,
95
+ opts: CacheOptions = {},
96
+ ): string | undefined {
97
+ const dir = resolveCacheDir(opts);
98
+ if (!dir) return undefined;
99
+ mkdirSync(dir, { recursive: true });
100
+ const path = join(dir, `${key}${SOURCE_INFIX}${extension}`);
101
+ writeAtomic(path, Buffer.from(source));
102
+ return path;
103
+ }
104
+
105
+ /** boundary: a persisted meta file is untrusted JSON and narrows here before any field is read. */
106
+ function isCacheMeta(value: unknown): value is CacheMeta {
107
+ if (typeof value !== "object" || value === null) return false;
108
+ if (
109
+ !(
110
+ "source" in value &&
111
+ "mode" in value &&
112
+ "mime" in value &&
113
+ "rows" in value &&
114
+ "widthCells" in value &&
115
+ "createdAt" in value
116
+ )
117
+ ) {
118
+ return false;
119
+ }
120
+ return (
121
+ typeof value.source === "string" &&
122
+ typeof value.mode === "string" &&
123
+ typeof value.mime === "string" &&
124
+ typeof value.rows === "number" &&
125
+ typeof value.widthCells === "number" &&
126
+ typeof value.createdAt === "string"
127
+ );
128
+ }
129
+
130
+ /** shape: none — one atomic pair write; Command does not apply (not queued or replayable). */
131
+ export function putRenderedEntry(key: string, png: Buffer, meta: CacheMeta, opts: CacheOptions = {}): void {
132
+ const dir = resolveCacheDir(opts);
133
+ if (!dir) return;
134
+ mkdirSync(dir, { recursive: true });
135
+ writeAtomic(join(dir, `${key}${PNG_SUFFIX}`), png);
136
+ writeAtomic(join(dir, `${key}${META_SUFFIX}`), Buffer.from(`${JSON.stringify(meta, null, 2)}\n`));
137
+ }
138
+
139
+ /** shape: none — one guarded read pair; returns undefined on any miss. */
140
+ export function getRenderedEntry(key: string, opts: CacheOptions = {}): { png: Buffer; meta: CacheMeta } | undefined {
141
+ const dir = resolveCacheDir(opts);
142
+ if (!dir) return undefined;
143
+ try {
144
+ const raw: unknown = JSON.parse(readFileSync(join(dir, `${key}${META_SUFFIX}`), "utf8"));
145
+ if (!isCacheMeta(raw)) return undefined;
146
+ return { png: readFileSync(join(dir, `${key}${PNG_SUFFIX}`)), meta: raw };
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * shape: none — a bounded sweep; eviction is cleanup, never a render outcome.
154
+ * Two caps (bytes and entry count) both order by meta.createdAt ascending; an
155
+ * unreadable meta sorts first so the least trustworthy entry leaves earliest.
156
+ * Every failure is logged and swallowed — a full disk must not fail a render.
157
+ */
158
+ export function evictIfNeeded(opts: CacheOptions = {}): void {
159
+ const dir = resolveCacheDir(opts);
160
+ if (!dir || !existsSync(dir)) return;
161
+ try {
162
+ const entries = new Map<string, { bytes: number; createdAt: string; files: string[] }>();
163
+ for (const name of readdirSync(dir)) {
164
+ const base = name.endsWith(META_SUFFIX)
165
+ ? name.slice(0, -META_SUFFIX.length)
166
+ : name.endsWith(PNG_SUFFIX)
167
+ ? name.slice(0, -PNG_SUFFIX.length)
168
+ : name.endsWith(VECTOR_SUFFIX)
169
+ ? name.slice(0, -VECTOR_SUFFIX.length)
170
+ : name.includes(SOURCE_INFIX)
171
+ ? name.slice(0, name.indexOf(SOURCE_INFIX))
172
+ : undefined;
173
+ if (base === undefined) continue;
174
+ const entry = entries.get(base) ?? { bytes: 0, createdAt: "", files: [] };
175
+ entry.files.push(name);
176
+ entry.bytes += statSync(join(dir, name)).size;
177
+ entries.set(base, entry);
178
+ }
179
+ for (const [base, entry] of entries) {
180
+ try {
181
+ const raw: unknown = JSON.parse(readFileSync(join(dir, `${base}${META_SUFFIX}`), "utf8"));
182
+ if (isCacheMeta(raw)) entry.createdAt = raw.createdAt;
183
+ } catch {
184
+ // An unreadable meta keeps the empty sort key: evicted first.
185
+ }
186
+ }
187
+
188
+ let totalBytes = 0;
189
+ for (const entry of entries.values()) totalBytes += entry.bytes;
190
+ let count = entries.size;
191
+ if (totalBytes <= MAX_BYTES && count <= MAX_ENTRIES) return;
192
+
193
+ const oldestFirst = [...entries.entries()].sort((a, b) => (a[1].createdAt < b[1].createdAt ? -1 : 1));
194
+ for (const [, entry] of oldestFirst) {
195
+ if (totalBytes <= MAX_BYTES && count <= MAX_ENTRIES) break;
196
+ for (const file of entry.files) rmSync(join(dir, file), { force: true });
197
+ totalBytes -= entry.bytes;
198
+ count -= 1;
199
+ }
200
+ } catch (error) {
201
+ opts.log?.logLine(SCOPE, `cache eviction failed: ${error instanceof Error ? error.message : String(error)}`);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The artifacts-internal cache surface: content-hash keyed through `renderCacheKey`, one instance bound
207
+ * to a directory. T-26 reads artifact paths from it; T-28 injects the one production instance.
208
+ */
209
+ export interface ArtifactCache {
210
+ /** The cached raster pair, or undefined on any miss. */
211
+ get(key: string): { png: Buffer; meta: CacheMeta } | undefined;
212
+ /** One atomic raster pair write. */
213
+ put(key: string, png: Buffer, meta: CacheMeta): void;
214
+ /** One atomic vector write — the artifact a vector card opens. */
215
+ putVector(key: string, svg: string, meta: CacheMeta): void;
216
+ /** Absolute vector path, or undefined when absent. */
217
+ vectorPath(key: string): string | undefined;
218
+ /** Saves the fence source beside its artifact; returns the absolute path. */
219
+ putSource(key: string, source: string, extension: string): string | undefined;
220
+ /** Bounded sweep; never throws. */
221
+ evict(): void;
222
+ }
223
+
224
+ /** shape: none — one delegating literal over the ported free functions; no state of its own. */
225
+ export function createArtifactCache(opts: CacheOptions = {}): ArtifactCache {
226
+ return {
227
+ get: (key) => getRenderedEntry(key, opts),
228
+ put: (key, png, meta) => putRenderedEntry(key, png, meta, opts),
229
+ putVector: (key, svg, meta) => putRenderedVector(key, svg, meta, opts),
230
+ vectorPath: (key) => getRenderedVectorPath(key, opts),
231
+ putSource: (key, source, extension) => putRenderedSource(key, source, extension, opts),
232
+ evict: () => evictIfNeeded(opts),
233
+ };
234
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * cards.test.ts — the artifacts seam: createArtifactServer() (server.ts) and the card renderer
3
+ * (cards.ts). The server tests start a REAL server on an ephemeral port and issue real HTTP requests;
4
+ * the card tests drive the renderer with the marker paint double, so a wrong paint role is visible.
5
+ *
6
+ * shape: none — test module, no runtime unit for DSG-1's trigger table to select a shape for.
7
+ */
8
+
9
+ import { once } from "node:events";
10
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { connect } from "node:net";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { visibleWidth } from "@earendil-works/pi-tui";
15
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
16
+ import { markerPaint } from "../../../../test/fakes/index.ts";
17
+ import { createLogger } from "../../../core/log.ts";
18
+ import { artifactTitle, imageLineMetadata, renderArtifactCard } from "./cards.ts";
19
+ import { type ArtifactListen, type ArtifactServer, createArtifactServer } from "./server.ts";
20
+
21
+ // recorded-from: generated 1x1 RGBA PNG fixture, 2026-10-03 (the predecessor's emit.test.ts fixture)
22
+ const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGMwDl31HwAEIwIy57+Y1gAAAABJRU5ErkJggg==";
23
+ // invented: a tiny real SVG whose body and extension pin the served content-type.
24
+ const SVG_BODY = '<svg xmlns="http://www.w3.org/2000/svg"><title>demo</title></svg>';
25
+
26
+ let dir: string;
27
+ let artifactPath: string;
28
+
29
+ beforeAll(() => {
30
+ dir = mkdtempSync(join(tmpdir(), "pi-render-cards-"));
31
+ artifactPath = join(dir, "diagram.svg");
32
+ writeFileSync(artifactPath, SVG_BODY);
33
+ });
34
+
35
+ afterAll(() => {
36
+ rmSync(dir, { recursive: true, force: true });
37
+ });
38
+
39
+ async function started(): Promise<{ server: ArtifactServer; url: string }> {
40
+ const server = createArtifactServer();
41
+ const state = await server.start();
42
+ if (!state.ok) throw new Error(`server did not start: ${state.reason}`);
43
+ return { server, url: state.url };
44
+ }
45
+
46
+ describe("createArtifactServer", () => {
47
+ it("AC-1 binds 127.0.0.1 on an ephemeral port", async () => {
48
+ const { server, url } = await started();
49
+ try {
50
+ const address = new URL(url);
51
+ // fails_when: the bind is on another interface, or the port is not ephemeral.
52
+ expect(address.hostname).toBe("127.0.0.1");
53
+ expect(Number(address.port)).toBeGreaterThan(0);
54
+ expect(url.startsWith("http://127.0.0.1:")).toBe(true);
55
+ // A real request to that loopback address proves the listener answers there.
56
+ expect((await fetch(`${url}/a/nothing-carded`)).status).toBe(404);
57
+ } finally {
58
+ await server.stop();
59
+ }
60
+ });
61
+
62
+ it("AC-2 serves a carded path and 404s everything else with no listing", async () => {
63
+ const { server, url } = await started();
64
+ try {
65
+ const carded = server.urlFor(artifactPath);
66
+ expect(carded).toBeDefined();
67
+ expect(carded?.startsWith("http://127.0.0.1:")).toBe(true);
68
+ // One carded file keeps one opaque id, so the link is stable across re-renders.
69
+ expect(server.urlFor(artifactPath)).toBe(carded);
70
+
71
+ const ok = await fetch(carded as string);
72
+ expect(ok.status).toBe(200);
73
+ expect(ok.headers.get("content-type")).toBe("image/svg+xml");
74
+ expect(await ok.text()).toBe(SVG_BODY);
75
+
76
+ // fails_when: anything not carded is served — a listing, a request-named path, or a probe.
77
+ expect((await fetch(`${url}/`)).status).toBe(404);
78
+ expect((await fetch(`${url}/a/`)).status).toBe(404);
79
+ expect((await fetch(`${url}/a/unknown-id`)).status).toBe(404);
80
+ expect((await fetch(`${url}${artifactPath}`)).status).toBe(404);
81
+ expect((await fetch(`${url}/etc/passwd`)).status).toBe(404);
82
+ } finally {
83
+ await server.stop();
84
+ }
85
+ });
86
+
87
+ it("AC-3 a failed bind reports degraded state and renders the card on a file:// link", async () => {
88
+ const log = createLogger();
89
+ // invented: the injected listen is the whole fixture — a bind failure without an occupied port.
90
+ const listen: ArtifactListen = async () => {
91
+ throw new Error("EADDRINUSE: address already in use");
92
+ };
93
+ const server = createArtifactServer({ log, listen });
94
+
95
+ const state = await server.start();
96
+ expect(state.ok).toBe(false);
97
+ if (state.ok) return;
98
+ expect(state.reason).toContain("EADDRINUSE");
99
+
100
+ // invented: a short path so the degraded file:// target still fits the card line (the card only
101
+ // builds the URL; the served-path tests above use the real artifact).
102
+ const path = "/tmp/pi-render-degraded.svg";
103
+ const paint = markerPaint();
104
+ const spec = { typeLabel: "svg", title: "demo", path, width: 64 };
105
+ // fails_when: the failed bind breaks the card instead of degrading only the Open link.
106
+ const card = renderArtifactCard(server, spec, paint);
107
+ renderArtifactCard(server, spec, paint);
108
+ expect(card).toContain("demo");
109
+ expect(card).toContain("[Open (⌘click)](file:///tmp/pi-render-degraded.svg)");
110
+
111
+ // The cause is named exactly once, on the first click — not on every re-render.
112
+ const degraded = log.drain().filter((entry) => entry.message.includes("Open link degraded"));
113
+ expect(degraded).toHaveLength(1);
114
+ });
115
+
116
+ it("drops an idle request past the budget and logs the reason", async () => {
117
+ const log = createLogger();
118
+ const server = createArtifactServer({ log, requestTimeoutMs: 50 });
119
+ const state = await server.start();
120
+ if (!state.ok) throw new Error(state.reason);
121
+ const port = Number(new URL(state.url).port);
122
+ const socket = connect(port, "127.0.0.1");
123
+ await once(socket, "connect");
124
+ try {
125
+ // Nothing is sent: the server's own budget must close the socket.
126
+ await Promise.race([
127
+ once(socket, "close"),
128
+ new Promise((_, reject) => setTimeout(() => reject(new Error("socket was not dropped")), 2000)),
129
+ ]);
130
+ const messages = log.drain().map((entry) => entry.message);
131
+ expect(messages.some((message) => message.includes("request dropped after 50ms"))).toBe(true);
132
+ } finally {
133
+ socket.destroy();
134
+ await server.stop();
135
+ }
136
+ });
137
+
138
+ it("stop() releases the port so the loopback URL stops answering", async () => {
139
+ const first = await started();
140
+ const url = first.url;
141
+ await first.server.stop();
142
+ // fails_when: stop() leaves the listener bound.
143
+ await expect(fetch(`${url}/a/x`)).rejects.toThrow();
144
+ const second = await started();
145
+ await second.server.stop();
146
+ });
147
+ });
148
+
149
+ describe("renderArtifactCard", () => {
150
+ it("renders the box with the server's Open link as the title-line action", async () => {
151
+ const { server } = await started();
152
+ try {
153
+ const paint = markerPaint();
154
+ const openUrl = server.urlFor(artifactPath) as string;
155
+ const out = renderArtifactCard(server, { typeLabel: "svg", title: "demo", path: artifactPath, width: 64 }, paint);
156
+
157
+ // Golden geometry at 64 columns: header run 56, content 60, bottom run 62; the frame carries
158
+ // the rule role and every text row the quote role. The temp path varies in length, so its
159
+ // pinned 60-column chunks are rebuilt here; the widths and row counts are literals.
160
+ const link = `[Open (⌘click)](${openUrl})`;
161
+ const titlePad = " ".repeat(60 - visibleWidth("demo") - visibleWidth(link));
162
+ const copyPad = " ".repeat(60 - visibleWidth("Copy path (⌥C)"));
163
+ const pathRows: string[] = [];
164
+ for (let at = 0; at < artifactPath.length; at += 60) pathRows.push(artifactPath.slice(at, at + 60));
165
+ const golden = [
166
+ paint.rule(`╭─ svg ${"─".repeat(56)}╮`),
167
+ `${paint.rule("│")} ${paint.quote("demo")}${titlePad}${link} ${paint.rule("│")}`,
168
+ `${paint.rule("│")} ${paint.quote("Copy path (⌥C)")}${copyPad} ${paint.rule("│")}`,
169
+ ...pathRows.map(
170
+ (chunk) =>
171
+ `${paint.rule("│")} ${paint.quote(chunk)}${" ".repeat(60 - visibleWidth(chunk))} ${paint.rule("│")}`,
172
+ ),
173
+ paint.rule(`╰${"─".repeat(62)}╯`),
174
+ ].join(" \n");
175
+ // fails_when: the card geometry, the Open link, or the paint roles drift.
176
+ expect(out).toBe(`${golden}\n`);
177
+ // The field bug class: a line whose RAW width exceeds the card gets host-wrapped, breaking the link.
178
+ for (const line of out.split("\n")) expect(visibleWidth(line.trimEnd())).toBeLessThanOrEqual(64);
179
+ } finally {
180
+ await server.stop();
181
+ }
182
+ });
183
+
184
+ it("degrades the link to the plain label when the target cannot fit the line", async () => {
185
+ const log = createLogger();
186
+ const server = createArtifactServer({ log, listen: async () => Promise.reject(new Error("down")) });
187
+ await server.start();
188
+ // invented: a path long enough that its file:// URL outgrows the 60-column content line.
189
+ const longPath = `/${"x".repeat(120)}.svg`;
190
+ const out = renderArtifactCard(server, { typeLabel: "svg", title: "t", path: longPath, width: 64 }, markerPaint());
191
+ const titleLine = out.split(" \n")[1] ?? "";
192
+ // fails_when: an unfittable link is emitted anyway and the host wraps it into literal brackets.
193
+ expect(titleLine).toContain("Open (⌘click)");
194
+ expect(titleLine).not.toContain("](file://");
195
+ expect(visibleWidth(titleLine.trimEnd())).toBeLessThanOrEqual(64);
196
+ });
197
+ });
198
+
199
+ describe("artifactTitle", () => {
200
+ it("takes the artifact's own name where the source carries one, and the form otherwise", () => {
201
+ // invented: fence sources pinning the four title sources and the fallback.
202
+ expect(artifactTitle("plantuml", '@startuml "Payments"\nA -> B\n@enduml')).toBe("Payments");
203
+ expect(artifactTitle("plantuml", "@startuml\ntitle Order flow\nA -> B\n@enduml")).toBe("Order flow");
204
+ expect(artifactTitle("svg", "<svg><title>Logo</title></svg>")).toBe("Logo");
205
+ expect(artifactTitle("html", "<h1>Report</h1>")).toBe("Report");
206
+ expect(artifactTitle("dot", "digraph { a -> b }")).toBe("dot");
207
+ });
208
+ });
209
+
210
+ describe("imageLineMetadata", () => {
211
+ it("reports a PNG's transcript row count and one row for unparseable bytes", () => {
212
+ // The 1x1 PNG at the default 60-cell width fits as 30 rows (8x16 cell ratio).
213
+ expect(imageLineMetadata(PNG_BASE64)).toEqual({ rows: 30 });
214
+ expect(imageLineMetadata("not-a-png")).toEqual({ rows: 1 });
215
+ });
216
+ });