@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,207 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* local-binary.test.ts — the audited spawn boundary (T-25 follow-up): spawnLocal's real argv/cwd/timeout
|
|
3
|
-
* contract, cmux's absolute-candidate resolution, openInCmux's fixed argv and its no-shell guarantee, and
|
|
4
|
-
* the PlantUML include guard.
|
|
5
|
-
*
|
|
6
|
-
* Compatibility tests for the two seams nothing else exercises: the package's only `node:child_process`
|
|
7
|
-
* call site and the include guard in front of the JVM. Where behavior needs a real process it runs the
|
|
8
|
-
* Node binary this test is already running under and `/bin/echo` — never a PATH lookup, never a shell;
|
|
9
|
-
* everywhere else the seam is the injected `spawn`/`resolve`/`exists`. Each test carries a spec +
|
|
10
|
-
* fails_when line.
|
|
11
|
-
*
|
|
12
|
-
* shape: none — test module, no runtime unit for DSG-1's trigger table to select a shape for.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { existsSync, mkdtempSync, realpathSync } from "node:fs";
|
|
16
|
-
import { tmpdir } from "node:os";
|
|
17
|
-
import { isAbsolute, join } from "node:path";
|
|
18
|
-
import { beforeEach, describe, expect, it } from "vitest";
|
|
19
|
-
import {
|
|
20
|
-
type LocalSpawn,
|
|
21
|
-
type LocalSpawnResult,
|
|
22
|
-
openInCmux,
|
|
23
|
-
plantumlIncludeOffense,
|
|
24
|
-
resolveCmux,
|
|
25
|
-
spawnLocal,
|
|
26
|
-
} from "./local-binary.ts";
|
|
27
|
-
|
|
28
|
-
// invented: the canned result an exit-0 process reports; there is no upstream payload to record.
|
|
29
|
-
const OK: LocalSpawnResult = { status: 0, signal: null, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
|
|
30
|
-
|
|
31
|
-
const CMUX_URL = "http://127.0.0.1:7777/artifact/9f2c";
|
|
32
|
-
|
|
33
|
-
/** boundary: a spawn error is `unknown` until its `code` is narrowed here — no cast, no field read blind. */
|
|
34
|
-
function errorCode(result: LocalSpawnResult): unknown {
|
|
35
|
-
const error: unknown = result.error;
|
|
36
|
-
return error !== null && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function only<T>(items: T[]): T {
|
|
40
|
-
const first = items[0];
|
|
41
|
-
if (first === undefined) throw new Error("test fixture: expected exactly one recorded item");
|
|
42
|
-
return first;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
interface SpawnCall {
|
|
46
|
-
cmd: string;
|
|
47
|
-
args: string[];
|
|
48
|
-
timeoutMs: number;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** invented: the recording spawn is the fixture — it captures the argv/timeout and never runs a process. */
|
|
52
|
-
function recorder() {
|
|
53
|
-
const calls: SpawnCall[] = [];
|
|
54
|
-
const spawn: LocalSpawn = (cmd, args, opts) => {
|
|
55
|
-
calls.push({ cmd, args, timeoutMs: opts.timeoutMs });
|
|
56
|
-
return OK;
|
|
57
|
-
};
|
|
58
|
-
return { calls, spawn };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
let dir: string;
|
|
62
|
-
|
|
63
|
-
beforeEach(() => {
|
|
64
|
-
dir = mkdtempSync(join(tmpdir(), "pi-render-local-binary-"));
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe("cmux resolution", () => {
|
|
68
|
-
// spec: resolveCmux scans the two absolute app-bundle candidates in order and returns the first that
|
|
69
|
-
// exists — no PATH lookup, no environment override, undefined when neither is there.
|
|
70
|
-
// fails_when: a relative or PATH-derived candidate is launched in the user's terminal.
|
|
71
|
-
it("returns the first existing absolute candidate and undefined when none exist", () => {
|
|
72
|
-
expect(resolveCmux(() => false)).toBeUndefined();
|
|
73
|
-
expect(resolveCmux(() => true)).toBe("/Applications/cmux.app/Contents/Resources/bin/cmux");
|
|
74
|
-
|
|
75
|
-
const second = resolveCmux((candidate) => candidate !== "/Applications/cmux.app/Contents/Resources/bin/cmux");
|
|
76
|
-
expect(second).toBeDefined();
|
|
77
|
-
expect(isAbsolute(second as string)).toBe(true);
|
|
78
|
-
expect(second?.endsWith("/Applications/cmux.app/Contents/Resources/bin/cmux")).toBe(true);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// spec: with no injected binary, openInCmux resolves cmux itself; on a machine without it the call is
|
|
82
|
-
// a named error, never a spawn.
|
|
83
|
-
// fails_when: the default resolution is skipped, or a missing cmux turns into a failed spawn attempt.
|
|
84
|
-
it("resolves cmux itself when no binary is injected", () => {
|
|
85
|
-
const { calls, spawn } = recorder();
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
openInCmux(CMUX_URL, { spawn });
|
|
89
|
-
} catch (error) {
|
|
90
|
-
expect(error instanceof Error ? error.message : String(error)).toBe("cmux: CLI not found");
|
|
91
|
-
expect(calls).toEqual([]);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const call = only(calls);
|
|
96
|
-
expect(call.cmd).toBe(resolveCmux());
|
|
97
|
-
expect(isAbsolute(call.cmd)).toBe(true);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe("openInCmux — the fixed cmux argv", () => {
|
|
102
|
-
// spec: openInCmux spawns the resolved binary with exactly
|
|
103
|
-
// `new-pane --type browser --url <url> --focus false` and the 10s default timeout.
|
|
104
|
-
// fails_when: a cmux CLI change (reordered flags, dropped --focus) silently stops opening the card.
|
|
105
|
-
it("sends the fixed argv with the default timeout", () => {
|
|
106
|
-
const binary = "/Applications/cmux.app/Contents/Resources/bin/cmux";
|
|
107
|
-
const { calls, spawn } = recorder();
|
|
108
|
-
|
|
109
|
-
expect(openInCmux(CMUX_URL, { binary, spawn })).toEqual(OK);
|
|
110
|
-
|
|
111
|
-
const call = only(calls);
|
|
112
|
-
expect(call.cmd).toBe(binary);
|
|
113
|
-
expect(call.args).toEqual(["new-pane", "--type", "browser", "--url", CMUX_URL, "--focus", "false"]);
|
|
114
|
-
expect(call.timeoutMs).toBe(10_000);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// spec: an injected resolve() wins over the candidate scan, and a caller timeout overrides the 10s one.
|
|
118
|
-
// fails_when: the seams are ignored — a test or a caller cannot point the op at its own cmux.
|
|
119
|
-
it("honors an injected resolve and a timeout override", () => {
|
|
120
|
-
const { calls, spawn } = recorder();
|
|
121
|
-
|
|
122
|
-
openInCmux(CMUX_URL, { resolve: () => "/opt/pi-render-test/bin/cmux", spawn, timeoutMs: 250 });
|
|
123
|
-
|
|
124
|
-
const call = only(calls);
|
|
125
|
-
expect(call.cmd).toBe("/opt/pi-render-test/bin/cmux");
|
|
126
|
-
expect(call.timeoutMs).toBe(250);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// spec: with no cmux resolvable the call throws the named error and no process is started.
|
|
130
|
-
// fails_when: the caller gets a raw spawn error instead of the actionable message.
|
|
131
|
-
it("throws the named error when no cmux resolves and spawns nothing", () => {
|
|
132
|
-
const { calls, spawn } = recorder();
|
|
133
|
-
|
|
134
|
-
expect(() => openInCmux(CMUX_URL, { resolve: () => undefined, spawn })).toThrow("cmux: CLI not found");
|
|
135
|
-
expect(calls).toEqual([]);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
// spec: the default spawn goes straight to the binary — no shell — so shell metacharacters in the URL
|
|
139
|
-
// stay literal arguments and execute nothing.
|
|
140
|
-
// fails_when: a shell is introduced at this boundary and a card URL can run a command.
|
|
141
|
-
it("runs argv without a shell, so metacharacters in the URL stay literal", () => {
|
|
142
|
-
const canary = join(dir, "pwned");
|
|
143
|
-
const url = `http://127.0.0.1:7777/a;touch ${canary}`;
|
|
144
|
-
|
|
145
|
-
const result = openInCmux(url, { binary: "/bin/echo", timeoutMs: 2_000 });
|
|
146
|
-
|
|
147
|
-
expect(result.status).toBe(0);
|
|
148
|
-
expect(result.stdout?.toString("utf8")).toBe(`new-pane --type browser --url ${url} --focus false\n`);
|
|
149
|
-
expect(existsSync(canary)).toBe(false);
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
describe("spawnLocal — the one process runner", () => {
|
|
154
|
-
// spec: spawnLocal runs a fixed argv with a buffer encoding and returns the exit status and stdout.
|
|
155
|
-
// fails_when: the encoding or the argv passthrough changes and every caller reads the wrong bytes.
|
|
156
|
-
it("runs a fixed argv and returns the status with buffered output", () => {
|
|
157
|
-
const result = spawnLocal(process.execPath, ["-e", "process.stdout.write('ok')"], { timeoutMs: 10_000 });
|
|
158
|
-
|
|
159
|
-
expect(result.status).toBe(0);
|
|
160
|
-
expect(result.stdout?.toString("utf8")).toBe("ok");
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// spec: spawnLocal runs the process in the cwd it was given.
|
|
164
|
-
// fails_when: a caller's temp-dir cwd is ignored, so a spawn writes into the user's directory.
|
|
165
|
-
it("runs in the cwd it was given", () => {
|
|
166
|
-
const result = spawnLocal(process.execPath, ["-e", "process.stdout.write(process.cwd())"], {
|
|
167
|
-
cwd: dir,
|
|
168
|
-
timeoutMs: 10_000,
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
const printed = result.stdout?.toString("utf8") ?? "";
|
|
172
|
-
expect(realpathSync(printed)).toBe(realpathSync(dir));
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
// spec: a process outliving its timeout is killed and reported as ETIMEDOUT with SIGTERM.
|
|
176
|
-
// fails_when: a hung binary pins the render thread forever.
|
|
177
|
-
it("kills a process past its timeout and reports ETIMEDOUT", () => {
|
|
178
|
-
const result = spawnLocal(process.execPath, ["-e", "setTimeout(() => {}, 5_000)"], { timeoutMs: 300 });
|
|
179
|
-
|
|
180
|
-
expect(errorCode(result)).toBe("ETIMEDOUT");
|
|
181
|
-
expect(result.signal).toBe("SIGTERM");
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
// spec: a missing binary is reported as ENOENT in the result, never thrown.
|
|
185
|
-
// fails_when: the caller crashes on a machine where the binary is absent instead of degrading.
|
|
186
|
-
it("reports a missing binary as ENOENT without throwing", () => {
|
|
187
|
-
const result = spawnLocal("/nonexistent/pi-render-no-such-binary", [], { timeoutMs: 1_000 });
|
|
188
|
-
|
|
189
|
-
expect(result.status).toBeNull();
|
|
190
|
-
expect(errorCode(result)).toBe("ENOENT");
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
describe("the PlantUML include guard", () => {
|
|
195
|
-
// spec: plantumlIncludeOffense reports every include-shaped directive by name — case-insensitively,
|
|
196
|
-
// after leading whitespace, on any line — and passes ordinary source through.
|
|
197
|
-
// fails_when: a directive variant slips past the guard and an included local file reaches the image.
|
|
198
|
-
it("reports each include directive variant and passes ordinary source", () => {
|
|
199
|
-
expect(plantumlIncludeOffense("@startuml\n!include /etc/passwd\n@enduml")).toBe("!include");
|
|
200
|
-
expect(plantumlIncludeOffense("!includeurl https://example.invalid/x.puml")).toBe("!includeurl");
|
|
201
|
-
expect(plantumlIncludeOffense(" !includesub other.puml!FOO")).toBe("!includesub");
|
|
202
|
-
expect(plantumlIncludeOffense("!include_many a.puml")).toBe("!include_many");
|
|
203
|
-
expect(plantumlIncludeOffense("IDEA\n!IMPORT x.yaml\n")).toBe("!IMPORT");
|
|
204
|
-
expect(plantumlIncludeOffense("@startuml\n!pragma layout smetana\nAlice -> Bob\n@enduml")).toBeUndefined();
|
|
205
|
-
expect(plantumlIncludeOffense("")).toBeUndefined();
|
|
206
|
-
});
|
|
207
|
-
});
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
// Fixture provenance — every image fixture is a real file written into a temp dir, so the assertions
|
|
2
|
-
// drive the surface's own default probe (the bounded header read) instead of an injected stub.
|
|
3
|
-
// pic.png — invented: a real 3×2 PNG (73 bytes) built with zlib + CRC32 on 2026-10-06; non-square,
|
|
4
|
-
// so a width/height swap cannot pass.
|
|
5
|
-
// pic.gif — recorded-from: macOS sips converting that 3×2 PNG to GIF on 2026-10-06 (49 bytes, GIF87a).
|
|
6
|
-
// hdr.jpeg — invented: a 33-byte JPEG header (SOI, one APP1 segment to walk over, SOF0 3×2, EOI); the
|
|
7
|
-
// reader's contract is the segment walk, not the scan data.
|
|
8
|
-
// rst.jpeg — invented: a 25-byte JPEG header with a standalone RST0 marker before SOF0 3×2 — the walk
|
|
9
|
-
// must step over a marker that carries no length field.
|
|
10
|
-
// trunc.jpeg — invented: a 10-byte JPEG header whose APP1 segment claims 32 bytes the file does not
|
|
11
|
-
// hold — the walk must give up rather than read past the end.
|
|
12
|
-
// hdr.webp — invented: a 12-byte RIFF/WEBP signature — a known image whose dimensions this module does
|
|
13
|
-
// not walk, which is the path that cards with the path alone.
|
|
14
|
-
// bad.png — invented: the PNG header with both dimensions zeroed (33 bytes) — the shape a corrupt or
|
|
15
|
-
// truncated header produces, where the dimensions must be dropped, not trusted.
|
|
16
|
-
// cut.png — invented: the 8-byte PNG signature alone, no IHDR.
|
|
17
|
-
// notes.txt — invented: real text behind an image reference, the not-an-image case.
|
|
18
|
-
|
|
19
|
-
// shape: none — test module: every behavior is asserted only through the createImageCardSurface seam.
|
|
20
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
-
import { tmpdir } from "node:os";
|
|
22
|
-
import { join } from "node:path";
|
|
23
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
24
|
-
import { markerPaint } from "../../../test/fakes/index.ts";
|
|
25
|
-
import type { TransformContext } from "../../core/types/host.ts";
|
|
26
|
-
import { createImageCardSurface } from "./image-card.ts";
|
|
27
|
-
|
|
28
|
-
const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAIAAAASFvFNAAAAEElEQVR42mP4z8AAQQxwFgBB0gX7b/EWxwAAAABJRU5ErkJggg==";
|
|
29
|
-
const GIF = "R0lGODdhAwACAJEAAAAAAP8AAP///wAAACH5BAQAAAAALAAAAAADAAIAAAICjF8AOw==";
|
|
30
|
-
const JPEG_HEADER = "/9j/4QAIRXhpZgAA/8AAEQgAAgADAwERAAIRAQMRAf/Z";
|
|
31
|
-
const JPEG_RST_HEADER = "/9j/0P/AABEIAAIAAwMBEQACEQEDEQH/2Q==";
|
|
32
|
-
const JPEG_TRUNCATED_HEADER = "/9j/4QAgRXhpZg==";
|
|
33
|
-
const WEBP_HEADER = "UklGRgQAAABXRUJQ";
|
|
34
|
-
const ZEROED_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAAAAAAACAIAAAAAAAAA";
|
|
35
|
-
const CUT_PNG = "iVBORw0KGgo=";
|
|
36
|
-
|
|
37
|
-
/** The offered width every assertion here runs at: a 64-column card, 60 columns of content. */
|
|
38
|
-
const ctx: TransformContext = { messageType: "assistant", isStreaming: false, availableWidth: 100 };
|
|
39
|
-
|
|
40
|
-
let dir: string;
|
|
41
|
-
|
|
42
|
-
beforeAll(() => {
|
|
43
|
-
dir = mkdtempSync(join(tmpdir(), "pi-render-image-card-"));
|
|
44
|
-
writeFileSync(join(dir, "pic.png"), Buffer.from(PNG, "base64"));
|
|
45
|
-
writeFileSync(join(dir, "pic.gif"), Buffer.from(GIF, "base64"));
|
|
46
|
-
writeFileSync(join(dir, "hdr.jpeg"), Buffer.from(JPEG_HEADER, "base64"));
|
|
47
|
-
writeFileSync(join(dir, "rst.jpeg"), Buffer.from(JPEG_RST_HEADER, "base64"));
|
|
48
|
-
writeFileSync(join(dir, "trunc.jpeg"), Buffer.from(JPEG_TRUNCATED_HEADER, "base64"));
|
|
49
|
-
writeFileSync(join(dir, "hdr.webp"), Buffer.from(WEBP_HEADER, "base64"));
|
|
50
|
-
writeFileSync(join(dir, "bad.png"), Buffer.from(ZEROED_PNG, "base64"));
|
|
51
|
-
writeFileSync(join(dir, "cut.png"), Buffer.from(CUT_PNG, "base64"));
|
|
52
|
-
writeFileSync(join(dir, "notes.txt"), "not an image\n");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
afterAll(() => {
|
|
56
|
-
rmSync(dir, { recursive: true, force: true });
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe("createImageCardSurface — image references", () => {
|
|
60
|
-
it("AC-1: an existing local image renders the golden card with its resolved path and dimensions", () => {
|
|
61
|
-
const paint = markerPaint();
|
|
62
|
-
const surface = createImageCardSurface({ projectDir: dir });
|
|
63
|
-
const path = join(dir, "pic.png");
|
|
64
|
-
const out = surface.rewrite("", ctx, paint);
|
|
65
|
-
// Golden geometry at 64 columns: header rule run 56, content 60, bottom run 62; the frame carries
|
|
66
|
-
// the rule role and every text row the quote role — the only content role that reads as metadata.
|
|
67
|
-
// A temp path outgrows one row on some machines, so the expected path rows are its pinned
|
|
68
|
-
// 60-column chunks: the widths and the row shape are literals, only the path's length varies.
|
|
69
|
-
const pathRows: string[] = [];
|
|
70
|
-
for (let at = 0; at < path.length; at += 60) {
|
|
71
|
-
pathRows.push(`${paint.rule("│")} ${paint.quote(path.slice(at, at + 60).padEnd(60))} ${paint.rule("│")}`);
|
|
72
|
-
}
|
|
73
|
-
const golden = [
|
|
74
|
-
paint.rule(`╭─ png ${"─".repeat(56)}╮`),
|
|
75
|
-
`${paint.rule("│")} ${paint.quote(`pic.png · 3×2${" ".repeat(47)}`)} ${paint.rule("│")}`,
|
|
76
|
-
...pathRows,
|
|
77
|
-
paint.rule(`╰${"─".repeat(62)}╯`),
|
|
78
|
-
].join(" \n");
|
|
79
|
-
// fails_when: card shape drifts or dims are wrong
|
|
80
|
-
expect(out).toBe(`${golden}\n`);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("AC-1: PNG, GIF and JPEG headers become dimensions and an unwalked format still cards", () => {
|
|
84
|
-
const paint = markerPaint();
|
|
85
|
-
const surface = createImageCardSurface({ projectDir: dir });
|
|
86
|
-
const titleRow = (ref: string): string => surface.rewrite(``, ctx, paint).split(" \n")[1] ?? "";
|
|
87
|
-
const cases: ReadonlyArray<readonly [string, string]> = [
|
|
88
|
-
["pic.png", "pic.png · 3×2"],
|
|
89
|
-
["pic.gif", "pic.gif · 3×2"],
|
|
90
|
-
["hdr.jpeg", "hdr.jpeg · 3×2"],
|
|
91
|
-
["rst.jpeg", "rst.jpeg · 3×2"],
|
|
92
|
-
];
|
|
93
|
-
for (const [ref, title] of cases) {
|
|
94
|
-
// fails_when: a header's offsets are wrong or a width/height swap goes unnoticed
|
|
95
|
-
expect(titleRow(ref)).toContain(title);
|
|
96
|
-
}
|
|
97
|
-
// fails_when: an image format whose dimensions we do not read drops the card instead of the path
|
|
98
|
-
expect(titleRow("hdr.webp")).toContain("hdr.webp");
|
|
99
|
-
expect(titleRow("hdr.webp")).not.toContain("·");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("AC-1: a corrupt header drops the dimensions instead of the card", () => {
|
|
103
|
-
const paint = markerPaint();
|
|
104
|
-
const surface = createImageCardSurface({ projectDir: dir });
|
|
105
|
-
const titleRow = (ref: string): string => surface.rewrite(``, ctx, paint).split(" \n")[1] ?? "";
|
|
106
|
-
for (const ref of ["bad.png", "cut.png", "trunc.jpeg"]) {
|
|
107
|
-
// fails_when: a header we cannot trust is trusted anyway
|
|
108
|
-
expect(titleRow(ref)).toContain(ref);
|
|
109
|
-
expect(titleRow(ref)).not.toContain("·");
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("AC-2: a reference to a file that is not there stays raw with one log line", () => {
|
|
114
|
-
const log: string[] = [];
|
|
115
|
-
const surface = createImageCardSurface({ projectDir: dir, log: (line) => log.push(line) });
|
|
116
|
-
const markdown = "";
|
|
117
|
-
// fails_when: a missing image breaks the answer render
|
|
118
|
-
expect(surface.rewrite(markdown, ctx, markerPaint())).toBe(markdown);
|
|
119
|
-
expect(log).toHaveLength(1);
|
|
120
|
-
expect(log[0]).toContain("gone.png");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("AC-2: a local file that is not an image stays raw with one log line", () => {
|
|
124
|
-
const log: string[] = [];
|
|
125
|
-
const surface = createImageCardSurface({ projectDir: dir, log: (line) => log.push(line) });
|
|
126
|
-
const markdown = "";
|
|
127
|
-
// fails_when: a non-image file behind an image reference is carded
|
|
128
|
-
expect(surface.rewrite(markdown, ctx, markerPaint())).toBe(markdown);
|
|
129
|
-
expect(log).toHaveLength(1);
|
|
130
|
-
expect(log[0]).toContain("notes.txt");
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("AC-2: a probe that throws keeps the reference and logs its message", () => {
|
|
134
|
-
const log: string[] = [];
|
|
135
|
-
const surface = createImageCardSurface({
|
|
136
|
-
projectDir: dir,
|
|
137
|
-
log: (line) => log.push(line),
|
|
138
|
-
probe: () => {
|
|
139
|
-
throw new Error("disk gone");
|
|
140
|
-
},
|
|
141
|
-
});
|
|
142
|
-
const markdown = "";
|
|
143
|
-
// fails_when: a failing probe takes the answer render down
|
|
144
|
-
expect(surface.rewrite(markdown, ctx, markerPaint())).toBe(markdown);
|
|
145
|
-
expect(log).toHaveLength(1);
|
|
146
|
-
expect(log[0]).toContain("disk gone");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("AC-2: a remote, protocol-relative or anchored reference is left alone and never logged", () => {
|
|
150
|
-
const log: string[] = [];
|
|
151
|
-
const surface = createImageCardSurface({ projectDir: dir, log: (line) => log.push(line) });
|
|
152
|
-
const hrefs = ["https://example.test/a.png", "//example.test/a.png", "#figure", "data:image/png;base64,AAAA"];
|
|
153
|
-
for (const href of hrefs) {
|
|
154
|
-
const markdown = ``;
|
|
155
|
-
// fails_when: a reference pi-render does not own is read as a local path
|
|
156
|
-
expect(surface.rewrite(markdown, ctx, markerPaint())).toBe(markdown);
|
|
157
|
-
}
|
|
158
|
-
expect(log).toEqual([]);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("leaves a reference inside a fenced block alone and cards the one outside it", () => {
|
|
162
|
-
const surface = createImageCardSurface({ projectDir: dir });
|
|
163
|
-
const markdown = ["```md", "", "```", ""].join("\n");
|
|
164
|
-
const out = surface.rewrite(markdown, ctx, markerPaint());
|
|
165
|
-
// fails_when: text inside a code block is rewritten as an image reference
|
|
166
|
-
expect(out).toContain("```md\n\n```");
|
|
167
|
-
// exactly one card, and it is the reference outside the fence
|
|
168
|
-
expect(out.match(/╭─ png /g)).toHaveLength(1);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* panels.test.ts — the spec of the two content Surfaces at their seam: fenced markdown in, panel
|
|
3
|
-
* markdown out. Every test drives `createCodePanel(...).rewrite` / `createJsonPanel(...).rewrite` with a
|
|
4
|
-
* plain transform context; no module internals are imported.
|
|
5
|
-
*
|
|
6
|
-
* Fixtures: the code theme is `createCodeTheme` over a deterministic engine double that stamps its bound
|
|
7
|
-
* theme into each line, so a panel names the theme it was colored for. Panels are driven with the marker
|
|
8
|
-
* paint double, so a frame escape that is not the codeBlockBorder role is visible in the golden.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
-
import { describe, expect, it } from "vitest";
|
|
13
|
-
import { markerPaint } from "../../../test/fakes/index.ts";
|
|
14
|
-
import { type BindHighlightEngine, type CodeThemeName, createCodeTheme } from "../../core/code-theme.ts";
|
|
15
|
-
import type { CodeTheme, TransformContext } from "../../core/types.ts";
|
|
16
|
-
import { createCodePanel } from "./code-panel.ts";
|
|
17
|
-
import { createJsonPanel, type JsonPanelDeps } from "./json-panel.ts";
|
|
18
|
-
|
|
19
|
-
const ESC = String.fromCharCode(27);
|
|
20
|
-
/** The codeBlockBorder role's marker pair (test/fakes/paint.ts), the only frame escape a panel may emit. */
|
|
21
|
-
const border = (text: string): string => `${ESC}[38;5;211m${text}${ESC}[38;5;231m`;
|
|
22
|
-
/** The quote role's marker pair, used for the JSON pane's footer. */
|
|
23
|
-
const quote = (text: string): string => `${ESC}[38;5;209m${text}${ESC}[38;5;229m`;
|
|
24
|
-
/** One highlighted body line as the engine double emits it. */
|
|
25
|
-
const ink = (rgb: string, text: string): string => `${ESC}[38;2;${rgb}m${text}${ESC}[0m`;
|
|
26
|
-
/** Panel rows embedded as markdown: one code span per row, joined by the hard break the host honors. */
|
|
27
|
-
const rows = (lines: string[]): string => lines.map((line) => `\`${line}\``).join(" \n");
|
|
28
|
-
|
|
29
|
-
// invented: the engine is faked, so the golden pins OUR frame and highlight wiring, not shiki's palette.
|
|
30
|
-
// Colours sit above the low-contrast floor (72) so code-theme's normalization leaves them alone.
|
|
31
|
-
const INK: Record<CodeThemeName, string> = { "dracula-soft": "200;100;50", "one-dark-pro": "50;200;100" };
|
|
32
|
-
const THEMES = Object.entries(INK) as Array<[CodeThemeName, string]>;
|
|
33
|
-
|
|
34
|
-
/** Theme double: an engine that stamps the bound theme's ink into each line. */
|
|
35
|
-
function themeNamed(name: CodeThemeName): CodeTheme {
|
|
36
|
-
const engine: BindHighlightEngine = (theme) => (code) => code.split("\n").map((line) => ink(INK[theme], line));
|
|
37
|
-
return createCodeTheme(engine, { registry: { getSettings: () => ({ codeTheme: name }) } });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Markdown transform context; only availableWidth is read (core/types/host.ts). */
|
|
41
|
-
// invented: the two gate fields are the completed-assistant case, which is the only case a surface sees.
|
|
42
|
-
const ctx = (availableWidth: number): TransformContext => ({
|
|
43
|
-
messageType: "assistant",
|
|
44
|
-
isStreaming: false,
|
|
45
|
-
availableWidth,
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
/** Reader double over a fixed file map (the injected seam T-28 wires to the filesystem). */
|
|
49
|
-
function reader(files: Record<string, string>): JsonPanelDeps {
|
|
50
|
-
return { read: (href) => files[href] };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
describe("code panel", () => {
|
|
54
|
-
it.each(THEMES)("AC-1: a fence renders as a bordered panel in %s's ink", (name, inkRgb) => {
|
|
55
|
-
const out = createCodePanel(themeNamed(name)).rewrite("```python\nx = 1\n```\n", ctx(80), markerPaint());
|
|
56
|
-
|
|
57
|
-
const expected = `${rows([
|
|
58
|
-
border("╭─ python ─────────────╮"),
|
|
59
|
-
`${border("│")} ${ink(inkRgb, "x = 1")}${" ".repeat(15)} ${border("│")}`,
|
|
60
|
-
border("╰──────────────────────╯"),
|
|
61
|
-
])}\n`;
|
|
62
|
-
// fails_when: the frame geometry, the language header, or the theme's ink drifts.
|
|
63
|
-
expect(out).toBe(expected);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("AC-1: a bare fence and the mermaid/artifact fences keep their raw text", () => {
|
|
67
|
-
const panel = createCodePanel(themeNamed("dracula-soft"));
|
|
68
|
-
for (const raw of ["```\nx\n```\n", "```mermaid\ngraph TD;\n```\n", "```plantuml\nA -> B\n```\n"]) {
|
|
69
|
-
// fails_when: a host-owned or artifact-bound fence is consumed into a panel.
|
|
70
|
-
expect(panel.rewrite(raw, ctx(80), markerPaint())).toBe(raw);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("AC-1: a fence hugging its content stays under the offered width", () => {
|
|
75
|
-
const out = createCodePanel(themeNamed("dracula-soft")).rewrite("```py\nx = 1\n```\n", ctx(80), markerPaint());
|
|
76
|
-
// fails_when: the panel spans the terminal instead of hugging its content.
|
|
77
|
-
expect(visibleWidth(out.split("\n")[0] ?? "")).toBeLessThan(30);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("AC-2: a throwing highlightSync degrades to a plain panel, no throw", () => {
|
|
81
|
-
const throwing: CodeTheme = {
|
|
82
|
-
highlight: () => Promise.reject(new Error("boom")),
|
|
83
|
-
highlightSync: () => {
|
|
84
|
-
throw new Error("boom");
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
const panel = createCodePanel(throwing);
|
|
88
|
-
let out = "";
|
|
89
|
-
// fails_when: a highlighter failure kills the answer render.
|
|
90
|
-
expect(() => {
|
|
91
|
-
out = panel.rewrite("```ts\nlet x = 1;\n```\n", ctx(40), markerPaint());
|
|
92
|
-
}).not.toThrow();
|
|
93
|
-
expect(out).toContain("let x = 1;");
|
|
94
|
-
expect(out).not.toContain(`${ESC}[38;2;`); // no engine ink survived
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("AC-2: a throwing engine behind the real CodeTheme degrades the same way", () => {
|
|
98
|
-
const engine: BindHighlightEngine = () => () => {
|
|
99
|
-
throw new Error("boom");
|
|
100
|
-
};
|
|
101
|
-
const theme = createCodeTheme(engine, { registry: { getSettings: () => ({ codeTheme: "dracula-soft" }) } });
|
|
102
|
-
const out = createCodePanel(theme).rewrite("```ts\nlet x = 1;\n```\n", ctx(40), markerPaint());
|
|
103
|
-
// fails_when: code-theme's own degrade does not reach the panel.
|
|
104
|
-
expect(out).toContain("let x = 1;");
|
|
105
|
-
expect(out).not.toContain(`${ESC}[38;2;`);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("AC-3: box columns align when truecolor escapes sit inside CJK content", () => {
|
|
109
|
-
const cjk = "// 撤销转退款场景,根据是否超过保价时间限制判断是否进行汇率决策";
|
|
110
|
-
const out = createCodePanel(themeNamed("dracula-soft")).rewrite(
|
|
111
|
-
`\`\`\`java\n${cjk}\npublic class X {}\n\`\`\`\n`,
|
|
112
|
-
ctx(110),
|
|
113
|
-
markerPaint(),
|
|
114
|
-
);
|
|
115
|
-
const widths = new Set(
|
|
116
|
-
out
|
|
117
|
-
.split("\n")
|
|
118
|
-
.filter((line) => line !== "")
|
|
119
|
-
.map((line) => visibleWidth(line.trimEnd())),
|
|
120
|
-
);
|
|
121
|
-
// fails_when: a wide character is measured as one column, so the closing │ no longer lines up.
|
|
122
|
-
expect(widths.size).toBe(1);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe("json panel", () => {
|
|
127
|
-
// recorded-from: pi-pretty-tui/src/features/canvas/json-panel.test.ts (2026-10-06) — the same two-key body.
|
|
128
|
-
const DATA = '{\n "name": "pi-pretty-tui",\n "version": "0.1.0"\n}\n';
|
|
129
|
-
|
|
130
|
-
it.each(THEMES)("AC-1: a local .json link renders as a json pane in %s's ink", (name, inkRgb) => {
|
|
131
|
-
const panel = createJsonPanel(themeNamed(name), reader({ "/repo/data.json": DATA }));
|
|
132
|
-
const out = panel.rewrite("[data.json](/repo/data.json)\n", ctx(80), markerPaint());
|
|
133
|
-
|
|
134
|
-
const body = ["{", ' "name": "pi-pretty-tui",', ' "version": "0.1.0"', "}"];
|
|
135
|
-
const expected = `${rows([
|
|
136
|
-
border("╭─ json ─────────────────────╮"),
|
|
137
|
-
...body.map((line) => `${border("│")} ${ink(inkRgb, line)}${" ".repeat(26 - line.length)} ${border("│")}`),
|
|
138
|
-
border("╰────────────────────────────╯"),
|
|
139
|
-
])}\n\n${quote("/repo/data.json")}\n`;
|
|
140
|
-
// fails_when: the pane frame, the header, the footer, or the theme's ink drifts.
|
|
141
|
-
expect(out).toBe(expected);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("AC-1: a ```json fence renders through the same panel", () => {
|
|
145
|
-
const panel = createJsonPanel(themeNamed("dracula-soft"), reader({}));
|
|
146
|
-
const out = panel.rewrite("```json\n{}\n```\n", ctx(40), markerPaint());
|
|
147
|
-
// fails_when: the JSON fence is left raw or rendered without the json header.
|
|
148
|
-
expect(out).toContain("╭─ json ");
|
|
149
|
-
expect(out).toContain(ink("200;100;50", "{}"));
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("caps a long file at 40 lines and leaves one way out", () => {
|
|
153
|
-
// invented: 60 numbered lines is one past the 40-line cap plus a countable remainder.
|
|
154
|
-
const sixty = Array.from({ length: 60 }, (_, index) => `line ${index}`).join("\n");
|
|
155
|
-
const panel = createJsonPanel(themeNamed("dracula-soft"), reader({ "/repo/big.json": sixty }));
|
|
156
|
-
const out = panel.rewrite("[big.json](/repo/big.json)\n", ctx(80), markerPaint());
|
|
157
|
-
|
|
158
|
-
// fails_when: the transcript swallows the whole file, or loses the way to the rest of it.
|
|
159
|
-
expect(out).toContain("╭─ json ");
|
|
160
|
-
expect(out).toContain("… +20 more lines");
|
|
161
|
-
expect(out).toContain("[Open](/repo/big.json)");
|
|
162
|
-
expect(out).not.toContain("line 40");
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("leaves a non-JSON or unreadable link as-is", () => {
|
|
166
|
-
const panel = createJsonPanel(themeNamed("dracula-soft"), reader({}));
|
|
167
|
-
const notes = "[notes.md](/repo/notes.md)\n";
|
|
168
|
-
const missing = "[gone.json](/repo/gone.json)\n";
|
|
169
|
-
// fails_when: a markdown link is pane-ized, or a missing file breaks the answer render.
|
|
170
|
-
expect(panel.rewrite(notes, ctx(80), markerPaint())).toBe(notes);
|
|
171
|
-
expect(panel.rewrite(missing, ctx(80), markerPaint())).toBe(missing);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("logs once when the linked file cannot be read", () => {
|
|
175
|
-
const seen: string[] = [];
|
|
176
|
-
const panel = createJsonPanel(themeNamed("dracula-soft"), {
|
|
177
|
-
read: () => undefined,
|
|
178
|
-
log: {
|
|
179
|
-
logOnce: (key) => {
|
|
180
|
-
seen.push(key);
|
|
181
|
-
},
|
|
182
|
-
},
|
|
183
|
-
});
|
|
184
|
-
panel.rewrite("[gone.json](/repo/gone.json)\n", ctx(80), markerPaint());
|
|
185
|
-
// fails_when: an unreadable link is silent.
|
|
186
|
-
expect(seen).toEqual(["read:/repo/gone.json"]);
|
|
187
|
-
});
|
|
188
|
-
});
|