@zenodinh/pi-render 0.1.0 → 0.1.2
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/index.ts +6 -1
- package/package.json +6 -2
- package/src/core/types/host.ts +11 -2
- package/src/commands/canvas.test.ts +0 -150
- 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,183 +0,0 @@
|
|
|
1
|
-
// Fixture provenance — every document below is invented: pi-render itself writes this file, so there
|
|
2
|
-
// is no upstream payload to record; the file bytes are the fixture. The log double and the fs mock are
|
|
3
|
-
// local, so this ticket stays independent of T-06's logger implementation.
|
|
4
|
-
|
|
5
|
-
// The `node:fs` ESM namespace is not configurable, so read/write/rename are wrapped to *observe* the
|
|
6
|
-
// real syscalls without replacing them.
|
|
7
|
-
vi.mock("node:fs", async (importOriginal) => {
|
|
8
|
-
const actual = await importOriginal<typeof import("node:fs")>();
|
|
9
|
-
return {
|
|
10
|
-
...actual,
|
|
11
|
-
readFileSync: vi.fn(actual.readFileSync),
|
|
12
|
-
writeFileSync: vi.fn(actual.writeFileSync),
|
|
13
|
-
renameSync: vi.fn(actual.renameSync),
|
|
14
|
-
};
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
// shape: none — test module: every behavior is asserted only through the createSettingsStore seam.
|
|
18
|
-
import * as fs from "node:fs";
|
|
19
|
-
import { tmpdir } from "node:os";
|
|
20
|
-
import { join } from "node:path";
|
|
21
|
-
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
22
|
-
import type { LogEntry, Logger, SettingsDoc } from "../core/types.ts";
|
|
23
|
-
import { createSettingsStore } from "./settings.ts";
|
|
24
|
-
|
|
25
|
-
const DEFAULTS: SettingsDoc = { version: 1, modules: {} };
|
|
26
|
-
|
|
27
|
-
function readCount(): number {
|
|
28
|
-
return vi.mocked(fs.readFileSync).mock.calls.length;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function createRecordingLogger(): Logger & { entries: LogEntry[] } {
|
|
32
|
-
const entries: LogEntry[] = [];
|
|
33
|
-
const push = (scope: string, message: string): void => {
|
|
34
|
-
entries.push({ scope, message, time: Date.now() });
|
|
35
|
-
};
|
|
36
|
-
return {
|
|
37
|
-
entries,
|
|
38
|
-
logLine: push,
|
|
39
|
-
logOnce: (_key, scope, message) => push(scope, message),
|
|
40
|
-
drain: () => entries.splice(0),
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
describe("core/settings — persisted settings document", () => {
|
|
45
|
-
let dir: string;
|
|
46
|
-
let path: string;
|
|
47
|
-
|
|
48
|
-
beforeEach(() => {
|
|
49
|
-
vi.clearAllMocks();
|
|
50
|
-
dir = fs.mkdtempSync(join(tmpdir(), "pi-render-settings-"));
|
|
51
|
-
path = join(dir, "settings.json");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
afterEach(() => {
|
|
55
|
-
vi.unstubAllEnvs();
|
|
56
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("default path: with no injected path the doc lands under the host agent dir", () => {
|
|
60
|
-
vi.stubEnv("PI_CODING_AGENT_DIR", dir);
|
|
61
|
-
const doc: SettingsDoc = { version: 1, modules: { "row.bash": { enabled: false } } };
|
|
62
|
-
createSettingsStore().save(doc);
|
|
63
|
-
|
|
64
|
-
expect(JSON.parse(fs.readFileSync(join(dir, "pi-render", "settings.json"), "utf8"))).toEqual(doc);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("AC-1: after the first load every load is served from memory, not the disk", () => {
|
|
68
|
-
const store = createSettingsStore({ path });
|
|
69
|
-
expect(store.load()).toEqual(DEFAULTS);
|
|
70
|
-
expect(readCount()).toBe(1); // the one and only disk read
|
|
71
|
-
|
|
72
|
-
expect(store.load()).toEqual(DEFAULTS);
|
|
73
|
-
expect(store.load()).toEqual(DEFAULTS);
|
|
74
|
-
expect(readCount()).toBe(1); // a re-read per call is the #21 contributor returning
|
|
75
|
-
|
|
76
|
-
// A cache miss would be observable: the file now holds a different doc, yet the cached one reads back.
|
|
77
|
-
fs.writeFileSync(path, JSON.stringify({ version: 1, modules: { "row.bash": { enabled: false } } }));
|
|
78
|
-
expect(store.load()).toEqual(DEFAULTS);
|
|
79
|
-
expect(readCount()).toBe(1);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("AC-1: save() reaches the disk and refreshes the cache without a read", () => {
|
|
83
|
-
const store = createSettingsStore({ path });
|
|
84
|
-
store.load();
|
|
85
|
-
const doc2: SettingsDoc = { version: 1, modules: { "row.bash": { enabled: false, settings: { collapseAt: 3 } } } };
|
|
86
|
-
store.save(doc2);
|
|
87
|
-
|
|
88
|
-
expect(readCount()).toBe(1); // save wrote; it did not read
|
|
89
|
-
expect(store.load()).toEqual(doc2);
|
|
90
|
-
expect(readCount()).toBe(1);
|
|
91
|
-
expect(createSettingsStore({ path }).load()).toEqual(doc2); // fresh store: the save is durable
|
|
92
|
-
expect(readCount()).toBe(2);
|
|
93
|
-
|
|
94
|
-
// Cached: replacing the file's content does not change what this store returns.
|
|
95
|
-
fs.writeFileSync(path, JSON.stringify({ version: 1, modules: {} }));
|
|
96
|
-
expect(store.load()).toEqual(doc2);
|
|
97
|
-
expect(readCount()).toBe(2);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("AC-2: a malformed doc returns defaults with exactly one log line, no throw", () => {
|
|
101
|
-
fs.writeFileSync(path, "{ not json");
|
|
102
|
-
const log = createRecordingLogger();
|
|
103
|
-
const store = createSettingsStore({ path, logger: log });
|
|
104
|
-
|
|
105
|
-
expect(() => store.load()).not.toThrow();
|
|
106
|
-
expect(store.load()).toEqual(DEFAULTS);
|
|
107
|
-
expect(log.entries).toHaveLength(1);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("AC-2: a parseable non-document returns defaults with exactly one log line, no throw", () => {
|
|
111
|
-
fs.writeFileSync(path, JSON.stringify({ version: 1, modules: "not-a-map" }));
|
|
112
|
-
const log = createRecordingLogger();
|
|
113
|
-
const store = createSettingsStore({ path, logger: log });
|
|
114
|
-
|
|
115
|
-
expect(() => store.load()).not.toThrow();
|
|
116
|
-
expect(store.load()).toEqual(DEFAULTS);
|
|
117
|
-
expect(log.entries).toHaveLength(1);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("AC-2: an absent file is a first run, not a fault — defaults with no log line", () => {
|
|
121
|
-
const log = createRecordingLogger();
|
|
122
|
-
const store = createSettingsStore({ path, logger: log });
|
|
123
|
-
|
|
124
|
-
expect(store.load()).toEqual(DEFAULTS);
|
|
125
|
-
expect(log.entries).toHaveLength(0);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("AC-3: an entry missing its fields reads defaults while the other entry persists", () => {
|
|
129
|
-
fs.writeFileSync(
|
|
130
|
-
path,
|
|
131
|
-
JSON.stringify({
|
|
132
|
-
modules: {
|
|
133
|
-
"row.bash": { enabled: false, settings: { collapseAt: 3 } },
|
|
134
|
-
"content.table": {},
|
|
135
|
-
},
|
|
136
|
-
}),
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
const doc = createSettingsStore({ path }).load();
|
|
140
|
-
expect(doc.version).toBe(1); // doc-level default filled
|
|
141
|
-
expect(doc.modules["row.bash"]).toEqual({ enabled: false, settings: { collapseAt: 3 } });
|
|
142
|
-
expect(doc.modules["content.table"]).toEqual({ enabled: true });
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test("AC-3: a module key absent from the doc does not wipe the other keys", () => {
|
|
146
|
-
fs.writeFileSync(path, JSON.stringify({ version: 1, modules: { "row.bash": { enabled: false } } }));
|
|
147
|
-
|
|
148
|
-
const doc = createSettingsStore({ path }).load();
|
|
149
|
-
expect(doc.version).toBe(1);
|
|
150
|
-
expect(doc.modules["row.bash"]).toEqual({ enabled: false });
|
|
151
|
-
expect("content.table" in doc.modules).toBe(false); // absent here; the caller applies that module's defaults
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test("AC-4: the final path is never written in place — only swapped by rename", () => {
|
|
155
|
-
const store = createSettingsStore({ path });
|
|
156
|
-
const doc1: SettingsDoc = { version: 1, modules: { "row.bash": { enabled: true } } };
|
|
157
|
-
const doc2: SettingsDoc = { version: 1, modules: { "content.table": { enabled: false } } };
|
|
158
|
-
store.save(doc1);
|
|
159
|
-
|
|
160
|
-
const writeSpy = vi.mocked(fs.writeFileSync);
|
|
161
|
-
const renameSpy = vi.mocked(fs.renameSync);
|
|
162
|
-
writeSpy.mockClear();
|
|
163
|
-
renameSpy.mockClear();
|
|
164
|
-
store.save(doc2);
|
|
165
|
-
|
|
166
|
-
// A write to `path` itself is the torn-write case: the final file is only ever complete.
|
|
167
|
-
expect(writeSpy.mock.calls.map(([file]) => String(file))).toEqual([`${path}.tmp`]);
|
|
168
|
-
expect(renameSpy.mock.calls.map(([from, to]) => [String(from), String(to)])).toEqual([[`${path}.tmp`, path]]);
|
|
169
|
-
expect(JSON.parse(fs.readFileSync(path, "utf8"))).toEqual(doc2);
|
|
170
|
-
expect(fs.existsSync(`${path}.tmp`)).toBe(false); // no leftover temp file
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("AC-4: a reader of the final path sees the old doc complete, then the new doc complete", () => {
|
|
174
|
-
const store = createSettingsStore({ path });
|
|
175
|
-
const doc1: SettingsDoc = { version: 1, modules: { "row.bash": { enabled: true } } };
|
|
176
|
-
const doc2: SettingsDoc = { version: 1, modules: { "content.table": { enabled: false } } };
|
|
177
|
-
store.save(doc1);
|
|
178
|
-
expect(JSON.parse(fs.readFileSync(path, "utf8"))).toEqual(doc1);
|
|
179
|
-
|
|
180
|
-
store.save(doc2);
|
|
181
|
-
expect(JSON.parse(fs.readFileSync(path, "utf8"))).toEqual(doc2);
|
|
182
|
-
});
|
|
183
|
-
});
|
|
@@ -1,199 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,216 +0,0 @@
|
|
|
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
|
-
});
|