@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
package/index.ts
CHANGED
|
@@ -275,5 +275,10 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
|
|
|
275
275
|
await settle("commands", log, () => installCanvasCommand(pi, registry));
|
|
276
276
|
|
|
277
277
|
// Collapse-first reading model (owner decision): every tool row starts on one line.
|
|
278
|
-
|
|
278
|
+
// The host exposes this on the per-event UI context (ctx.ui), not on the pi API — only
|
|
279
|
+
// interactive mode implements it (the runner's non-UI fallback is a no-op stub), so the
|
|
280
|
+
// call rides session_start: real in TUI, harmless in text/rpc.
|
|
281
|
+
pi.on("session_start", (_event, ctx) => {
|
|
282
|
+
ctx.ui?.setToolsExpanded?.(false);
|
|
283
|
+
});
|
|
279
284
|
}
|
package/package.json
CHANGED
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"index.ts",
|
|
12
12
|
"src",
|
|
13
|
-
"themes"
|
|
13
|
+
"themes",
|
|
14
|
+
"!src/**/*.test.ts",
|
|
15
|
+
"!src/**/__snapshots__",
|
|
16
|
+
"!test",
|
|
17
|
+
"!themes/*.test.ts"
|
|
14
18
|
],
|
|
15
19
|
"scripts": {
|
|
16
20
|
"check": "tsc --noEmit",
|
|
@@ -63,5 +67,5 @@
|
|
|
63
67
|
"typescript": "^7.0.2",
|
|
64
68
|
"vitest": "^5.0.3"
|
|
65
69
|
},
|
|
66
|
-
"version": "0.1.
|
|
70
|
+
"version": "0.1.2"
|
|
67
71
|
}
|
package/src/core/types/host.ts
CHANGED
|
@@ -155,6 +155,15 @@ export interface ExtensionApi {
|
|
|
155
155
|
name: string,
|
|
156
156
|
options: { description?: string; handler: (args: string, ctx: CommandContext) => Promise<void> },
|
|
157
157
|
): void;
|
|
158
|
-
/**
|
|
159
|
-
|
|
158
|
+
/** Subscribes to a host event, e.g. "session_start". Required; the per-event ctx carries ui. */
|
|
159
|
+
on(event: string, handler: (event: unknown, ctx: EventContext) => void): void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The context a host event handler receives. Structural host shape. */
|
|
163
|
+
export interface EventContext {
|
|
164
|
+
/** The host's UI surface for this event — present in interactive mode, a no-op stub otherwise. Optional. */
|
|
165
|
+
ui?: {
|
|
166
|
+
/** Collapses (false) / expands (true) every tool row. The collapse-first default rides this. Optional. */
|
|
167
|
+
setToolsExpanded?(expanded: boolean): void;
|
|
168
|
+
};
|
|
160
169
|
}
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* canvas through its seam — installCommands(pi, deps) on the recording host API, its handler driven
|
|
3
|
-
* with a host-shaped context and the REAL registry (the command's only channel to the package).
|
|
4
|
-
*
|
|
5
|
-
* invented: the settings documents and the content.artifacts descriptor below are hand-built to the
|
|
6
|
-
* SA §3 literal (version + modules map) keyed on SA §6's artifacts module — T-25/T-26 own the real
|
|
7
|
-
* descriptor and T-07 the on-disk file, so there is no recorded payload to reuse; the documents are
|
|
8
|
-
* what drives the enabled/disabled state this command reports.
|
|
9
|
-
* invented: the context double and the view recorder stand in for the host and for the browser T-28
|
|
10
|
-
* injects — the command's own seam is exactly these two collaborators.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
14
|
-
import { describe, expect, test } from "vitest";
|
|
15
|
-
import type { RecordedApiCall, RecordedCommandRegistration } from "../../test/fakes/index.ts";
|
|
16
|
-
import { recordingExtensionApi } from "../../test/fakes/index.ts";
|
|
17
|
-
import { createRegistry } from "../core/registry.ts";
|
|
18
|
-
import type { CommandContext, Logger, ModuleDescriptor, SettingsDoc } from "../core/types.ts";
|
|
19
|
-
import { installCommands } from "./canvas.ts";
|
|
20
|
-
|
|
21
|
-
const ARTIFACTS: ModuleDescriptor = { key: "content.artifacts", name: "Artifacts", defaultEnabled: true, settings: [] };
|
|
22
|
-
const ENABLED: SettingsDoc = { version: 1, modules: { "content.artifacts": { enabled: true } } };
|
|
23
|
-
const DISABLED: SettingsDoc = { version: 1, modules: { "content.artifacts": { enabled: false } } };
|
|
24
|
-
const NON_TUI = ["rpc", "json", "print"] as const;
|
|
25
|
-
|
|
26
|
-
/** The registry's own diagnostics are T-09's concern, not this seam's. */
|
|
27
|
-
const SILENT: Logger = { logLine: () => {}, logOnce: () => {}, drain: () => [] };
|
|
28
|
-
|
|
29
|
-
/** Every `from "<specifier>"` in a source file. */
|
|
30
|
-
const IMPORT_SPECIFIER = /from\s+"([^"]+)"/g;
|
|
31
|
-
|
|
32
|
-
interface Harness {
|
|
33
|
-
/** Every registration the install made, in order. */
|
|
34
|
-
readonly calls: readonly RecordedApiCall[];
|
|
35
|
-
/** The command registrations, in order. */
|
|
36
|
-
readonly commands: readonly RecordedCommandRegistration[];
|
|
37
|
-
/** Every ctx the injected view opened with. */
|
|
38
|
-
readonly opened: CommandContext[];
|
|
39
|
-
/** Every document the registry saved — a /canvas run must leave this empty (SA §7). */
|
|
40
|
-
readonly saved: SettingsDoc[];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function harness(doc: SettingsDoc): Harness {
|
|
44
|
-
const pi = recordingExtensionApi();
|
|
45
|
-
const opened: CommandContext[] = [];
|
|
46
|
-
const saved: SettingsDoc[] = [];
|
|
47
|
-
const registry = createRegistry({ load: () => structuredClone(doc), save: (next) => void saved.push(next) }, SILENT);
|
|
48
|
-
registry.defineModule(ARTIFACTS);
|
|
49
|
-
installCommands(pi, {
|
|
50
|
-
registry,
|
|
51
|
-
canvasView: async (ctx) => {
|
|
52
|
-
// One macrotask, like the real panel open: an implementation that drops the await leaves
|
|
53
|
-
// `opened` empty when the test asserts (ASY-1), so the assertion pins the await too.
|
|
54
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
55
|
-
opened.push(ctx);
|
|
56
|
-
},
|
|
57
|
-
});
|
|
58
|
-
return { calls: pi.calls, commands: pi.commands, opened, saved };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface Run {
|
|
62
|
-
/** The context the host handed the handler. */
|
|
63
|
-
readonly ctx: CommandContext;
|
|
64
|
-
/** Every notify message the handler produced, in order. */
|
|
65
|
-
readonly notices: readonly string[];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Enters where the host enters: the registered handler, with a host-shaped context, awaited. */
|
|
69
|
-
async function runCanvas(h: Harness, mode: CommandContext["mode"]): Promise<Run> {
|
|
70
|
-
const handler = h.commands[0]?.options.handler;
|
|
71
|
-
if (handler === undefined) throw new Error("no canvas command registered");
|
|
72
|
-
const notices: string[] = [];
|
|
73
|
-
const ctx: CommandContext = {
|
|
74
|
-
mode,
|
|
75
|
-
ui: {
|
|
76
|
-
notify: (message) => {
|
|
77
|
-
notices.push(message);
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
await handler("", ctx);
|
|
82
|
-
return { ctx, notices };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
describe("canvas command", () => {
|
|
86
|
-
// spec: installCommands(pi, deps) -> exactly one registerCommand call, named "canvas", handler callable.
|
|
87
|
-
// fails_when: zero, duplicate, or extra commands (a second site, a renamed or missing command).
|
|
88
|
-
test("AC-1 installCommands registers exactly one command named canvas", () => {
|
|
89
|
-
const h = harness(ENABLED);
|
|
90
|
-
|
|
91
|
-
expect(h.commands.map((command) => command.name)).toEqual(["canvas"]);
|
|
92
|
-
expect(typeof h.commands[0]?.options.handler).toBe("function");
|
|
93
|
-
// The command lane touches its own seam only — no setToolsExpanded, no resolver, no transformer.
|
|
94
|
-
expect(h.calls.map((call) => call.method)).toEqual(["registerCommand"]);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
// spec: /canvas in a non-TUI host (rpc/json/print) -> one notify carrying the status summary; the
|
|
98
|
-
// view never opens and nothing throws.
|
|
99
|
-
// fails_when: a non-TUI host crashes, goes silent, or opens a view it cannot draw.
|
|
100
|
-
test("AC-2 a non-TUI host gets a notify summary and never the view", async () => {
|
|
101
|
-
for (const mode of NON_TUI) {
|
|
102
|
-
const h = harness(ENABLED);
|
|
103
|
-
|
|
104
|
-
const run = await runCanvas(h, mode);
|
|
105
|
-
|
|
106
|
-
expect(run.notices).toEqual([
|
|
107
|
-
`canvas: content.artifacts is enabled — the artifact browser needs a TUI host (mode: ${mode})`,
|
|
108
|
-
]);
|
|
109
|
-
expect(h.opened).toEqual([]);
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// spec: the module's imports -> exactly ["../core/types.ts"]; status comes from the registry, not a lane.
|
|
114
|
-
// fails_when: the command reaches into a renderer lane for state instead of the registry (SA §1 rule 3).
|
|
115
|
-
test("AC-3 the command reaches the package only through core", () => {
|
|
116
|
-
const source = readFileSync(new URL("./canvas.ts", import.meta.url), "utf8");
|
|
117
|
-
|
|
118
|
-
const specifiers = [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1] ?? "");
|
|
119
|
-
|
|
120
|
-
expect(specifiers).toEqual(["../core/types.ts"]);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// spec: content.artifacts disabled in the registry + a TUI host -> one notify reporting the disabled
|
|
124
|
-
// state, the view never opens, the registry is never written.
|
|
125
|
-
// fails_when: a disabled module's command acts anyway, or the report stays silent.
|
|
126
|
-
test("AC-4 a disabled content.artifacts reports instead of opening the view", async () => {
|
|
127
|
-
const h = harness(DISABLED);
|
|
128
|
-
|
|
129
|
-
const run = await runCanvas(h, "tui");
|
|
130
|
-
|
|
131
|
-
expect(run.notices).toEqual(["canvas: content.artifacts is disabled — enable it to open the artifact browser"]);
|
|
132
|
-
expect(h.opened).toEqual([]);
|
|
133
|
-
expect(h.saved).toEqual([]);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
// spec: content.artifacts enabled + a TUI host -> the injected view opens once, with the host's own
|
|
137
|
-
// ctx, no notify, and no registry write (SA §7: /canvas reads state, never writes it).
|
|
138
|
-
// fails_when: the view never opens, opens twice, gets a rebuilt ctx, or the command writes settings —
|
|
139
|
-
// the positive control that keeps AC-4 from passing on a command that never acts at all.
|
|
140
|
-
test("AC-4 positive control: an enabled TUI host opens the injected view", async () => {
|
|
141
|
-
const h = harness(ENABLED);
|
|
142
|
-
|
|
143
|
-
const run = await runCanvas(h, "tui");
|
|
144
|
-
|
|
145
|
-
expect(h.opened).toHaveLength(1);
|
|
146
|
-
expect(h.opened[0]).toBe(run.ctx);
|
|
147
|
-
expect(run.notices).toEqual([]);
|
|
148
|
-
expect(h.saved).toEqual([]);
|
|
149
|
-
});
|
|
150
|
-
});
|
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* code-theme.test.ts — the spec of createCodeTheme at its seam, with a counting engine double.
|
|
3
|
-
*
|
|
4
|
-
* Spec: createCodeTheme(bindEngine, {registry, log}) resolves `codeTheme` to a bundled name (unknown →
|
|
5
|
-
* dracula-soft + one log line), highlights once per theme+lang+code, never hands the engine an empty or
|
|
6
|
-
* over-80k block, and returns plain source lines when the engine or the bind throws.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { describe, expect, it } from "vitest";
|
|
10
|
-
import { type BindHighlightEngine, CODE_THEME_MODULE, type CodeThemeName, createCodeTheme } from "./code-theme.ts";
|
|
11
|
-
|
|
12
|
-
// invented: the engine is faked, so the block only has to be two stable lines.
|
|
13
|
-
const CODE = "const a = 1;\nlet b = 2;";
|
|
14
|
-
|
|
15
|
-
/** One engine invocation: the theme it was bound to, plus the block it was asked for. */
|
|
16
|
-
interface EngineCall {
|
|
17
|
-
theme: CodeThemeName;
|
|
18
|
-
code: string;
|
|
19
|
-
lang: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Engine double that counts: it records every call and stamps its bound theme into the lines, so the
|
|
24
|
-
* resolved theme is visible in the output rather than only in the bind argument.
|
|
25
|
-
*/
|
|
26
|
-
function countingEngine(options: { throwOnBind?: boolean; throwOnCall?: boolean } = {}): {
|
|
27
|
-
bind: BindHighlightEngine;
|
|
28
|
-
calls: EngineCall[];
|
|
29
|
-
} {
|
|
30
|
-
const calls: EngineCall[] = [];
|
|
31
|
-
const bind: BindHighlightEngine = (theme) => {
|
|
32
|
-
if (options.throwOnBind) throw new Error("bind failed");
|
|
33
|
-
return (code, lang) => {
|
|
34
|
-
calls.push({ theme, code, lang });
|
|
35
|
-
if (options.throwOnCall) throw new Error("engine failed");
|
|
36
|
-
return code.split("\n").map((line) => `«${theme}»${line}`);
|
|
37
|
-
};
|
|
38
|
-
};
|
|
39
|
-
return { bind, calls };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Logger double: logOnce's once-per-key rule plus the readout, so "exactly one line" is observable. */
|
|
43
|
-
function logDouble(): {
|
|
44
|
-
logOnce(key: string, scope: string, message: string): void;
|
|
45
|
-
entries: Array<{ key: string; scope: string; message: string }>;
|
|
46
|
-
attempts: number;
|
|
47
|
-
} {
|
|
48
|
-
const entries: Array<{ key: string; scope: string; message: string }> = [];
|
|
49
|
-
const seen = new Set<string>();
|
|
50
|
-
const double = {
|
|
51
|
-
entries,
|
|
52
|
-
attempts: 0,
|
|
53
|
-
logOnce(key: string, scope: string, message: string): void {
|
|
54
|
-
double.attempts += 1;
|
|
55
|
-
if (seen.has(key)) return;
|
|
56
|
-
seen.add(key);
|
|
57
|
-
entries.push({ key, scope, message });
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
return double;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Settings double over the injected contract. invented: the slice is contracted as
|
|
65
|
-
* `Record<string, unknown>`, so any bag is a legal stored document; `set` models the registry's
|
|
66
|
-
* "a write lands on the next read".
|
|
67
|
-
*/
|
|
68
|
-
function registryDouble(initial: Record<string, unknown> = {}): {
|
|
69
|
-
getSettings(key: string): Record<string, unknown>;
|
|
70
|
-
set(patch: Record<string, unknown>): void;
|
|
71
|
-
} {
|
|
72
|
-
const slices = new Map<string, Record<string, unknown>>([["codetheme", { ...initial }]]);
|
|
73
|
-
return {
|
|
74
|
-
getSettings: (key) => slices.get(key) ?? {},
|
|
75
|
-
set: (patch) => slices.set("codetheme", { ...(slices.get("codetheme") ?? {}), ...patch }),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
describe("createCodeTheme — theme resolution", () => {
|
|
80
|
-
it("AC-1: an unknown codeTheme renders the default theme and logs exactly one line", async () => {
|
|
81
|
-
const engine = countingEngine();
|
|
82
|
-
const log = logDouble();
|
|
83
|
-
// invented: the setting is persisted JSON, so an out-of-set name is the exact input AC-1 names.
|
|
84
|
-
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "nope" }), log });
|
|
85
|
-
|
|
86
|
-
const first = await theme.highlight(CODE, "ts");
|
|
87
|
-
const second = await theme.highlight(CODE, "ts");
|
|
88
|
-
const joined = theme.highlightSync(CODE, "ts");
|
|
89
|
-
|
|
90
|
-
// fails_when: a crash, empty output, or one log line per call.
|
|
91
|
-
expect(first).toEqual(["«dracula-soft»const a = 1;", "«dracula-soft»let b = 2;"]);
|
|
92
|
-
expect([second, joined]).toEqual([first, first.join("\n")]);
|
|
93
|
-
expect(engine.calls.map((call) => call.theme)).toEqual(["dracula-soft"]);
|
|
94
|
-
expect(log.entries.map((entry) => entry.key)).toEqual(["theme:nope"]);
|
|
95
|
-
expect(log.entries[0]?.message).toContain("dracula-soft");
|
|
96
|
-
expect(log.attempts).toBe(3);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("AC-1: a bundled non-default theme is rendered without a log line", async () => {
|
|
100
|
-
const engine = countingEngine();
|
|
101
|
-
const log = logDouble();
|
|
102
|
-
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "one-dark-pro" }), log });
|
|
103
|
-
|
|
104
|
-
// fails_when: the setting is ignored and the default theme always renders.
|
|
105
|
-
expect(await theme.highlight(CODE, "ts")).toEqual(["«one-dark-pro»const a = 1;", "«one-dark-pro»let b = 2;"]);
|
|
106
|
-
expect(log.entries).toEqual([]);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("AC-1: an absent setting reads the default theme silently", async () => {
|
|
110
|
-
const engine = countingEngine();
|
|
111
|
-
const log = logDouble();
|
|
112
|
-
const theme = createCodeTheme(engine.bind, { registry: registryDouble(), log });
|
|
113
|
-
|
|
114
|
-
// fails_when: a fresh install logs a fallback line on every render.
|
|
115
|
-
expect(await theme.highlight(CODE, "ts")).toEqual(["«dracula-soft»const a = 1;", "«dracula-soft»let b = 2;"]);
|
|
116
|
-
expect(log.entries).toEqual([]);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("AC-1: the descriptor is exported for T-28, and the injected registry has no write route", async () => {
|
|
120
|
-
// The injected registry shape is read-only (getSettings only), so a registration here cannot compile.
|
|
121
|
-
expect(CODE_THEME_MODULE).toEqual({
|
|
122
|
-
key: "codetheme",
|
|
123
|
-
name: "Code theme",
|
|
124
|
-
defaultEnabled: true,
|
|
125
|
-
settings: [
|
|
126
|
-
{ name: "codeTheme", kind: "enum", values: ["dracula-soft", "one-dark-pro"], default: "dracula-soft" },
|
|
127
|
-
],
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe("createCodeTheme — cache", () => {
|
|
133
|
-
it("AC-2: the same theme+lang+code highlights once", async () => {
|
|
134
|
-
const engine = countingEngine();
|
|
135
|
-
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
136
|
-
|
|
137
|
-
const first = await theme.highlight(CODE, "ts");
|
|
138
|
-
const second = await theme.highlight(CODE, "ts");
|
|
139
|
-
|
|
140
|
-
// fails_when: every call re-highlights (the #21 defect class).
|
|
141
|
-
expect(second).toEqual(first);
|
|
142
|
-
expect(engine.calls).toHaveLength(1);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("AC-2: lang and theme are part of the key", async () => {
|
|
146
|
-
const engine = countingEngine();
|
|
147
|
-
const registry = registryDouble({ codeTheme: "dracula-soft" });
|
|
148
|
-
const theme = createCodeTheme(engine.bind, { registry });
|
|
149
|
-
|
|
150
|
-
await theme.highlight(CODE, "ts");
|
|
151
|
-
await theme.highlight(CODE, "py");
|
|
152
|
-
registry.set({ codeTheme: "one-dark-pro" });
|
|
153
|
-
await theme.highlight(CODE, "ts");
|
|
154
|
-
|
|
155
|
-
// fails_when: the key ignores lang or theme and serves the previous theme's colours.
|
|
156
|
-
expect(engine.calls.map((call) => `${call.theme}:${call.lang}`)).toEqual([
|
|
157
|
-
"dracula-soft:ts",
|
|
158
|
-
"dracula-soft:py",
|
|
159
|
-
"one-dark-pro:ts",
|
|
160
|
-
]);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("AC-2: highlightSync shares the cache with highlight", async () => {
|
|
164
|
-
const engine = countingEngine();
|
|
165
|
-
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
166
|
-
|
|
167
|
-
await theme.highlight(CODE, "ts");
|
|
168
|
-
const joined = theme.highlightSync(CODE, "ts");
|
|
169
|
-
|
|
170
|
-
// fails_when: the panel path keeps a second cache and highlights the same block again.
|
|
171
|
-
expect(joined).toBe("«dracula-soft»const a = 1;\n«dracula-soft»let b = 2;");
|
|
172
|
-
expect(engine.calls).toHaveLength(1);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("AC-2: the cache is bounded at 128 entries and a hit counts as recent use", async () => {
|
|
176
|
-
const engine = countingEngine();
|
|
177
|
-
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
178
|
-
|
|
179
|
-
for (let i = 0; i < 128; i += 1) await theme.highlight(`line ${i}`, "ts");
|
|
180
|
-
await theme.highlight("line 0", "ts");
|
|
181
|
-
await theme.highlight("line 128", "ts");
|
|
182
|
-
expect(engine.calls).toHaveLength(129); // the 129th entry evicted the oldest untouched one
|
|
183
|
-
|
|
184
|
-
// fails_when: the cache grows without bound, or a hit does not count as recent use.
|
|
185
|
-
expect(await theme.highlight("line 0", "ts")).toEqual(["«dracula-soft»line 0"]);
|
|
186
|
-
expect(engine.calls).toHaveLength(129);
|
|
187
|
-
expect(await theme.highlight("line 1", "ts")).toEqual(["«dracula-soft»line 1"]);
|
|
188
|
-
expect(engine.calls).toHaveLength(130);
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
describe("createCodeTheme — what never reaches the engine", () => {
|
|
193
|
-
it("AC-3: a block past 80k characters returns its own plain lines", async () => {
|
|
194
|
-
const engine = countingEngine();
|
|
195
|
-
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
196
|
-
// invented: one character past the ported 80_000 gate (P render.ts:86).
|
|
197
|
-
const over = `x${"y".repeat(80_000)}`;
|
|
198
|
-
|
|
199
|
-
// fails_when: a blob this size enters shiki.
|
|
200
|
-
expect(await theme.highlight(over, "ts")).toEqual([over]);
|
|
201
|
-
expect(engine.calls).toEqual([]);
|
|
202
|
-
|
|
203
|
-
// the gate is strict: exactly 80_000 characters reach the engine.
|
|
204
|
-
await theme.highlight("y".repeat(80_000), "ts");
|
|
205
|
-
expect(engine.calls).toHaveLength(1);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("AC-3: empty code returns one empty line", async () => {
|
|
209
|
-
const engine = countingEngine();
|
|
210
|
-
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
211
|
-
|
|
212
|
-
// fails_when: an empty block enters shiki, or the panel path emits a stray newline.
|
|
213
|
-
expect(await theme.highlight("", "ts")).toEqual([""]);
|
|
214
|
-
expect(theme.highlightSync("", "ts")).toBe("");
|
|
215
|
-
expect(engine.calls).toEqual([]);
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
describe("createCodeTheme — degradation", () => {
|
|
220
|
-
it("AC-4: a throwing engine returns plain lines and nothing escapes", async () => {
|
|
221
|
-
const engine = countingEngine({ throwOnCall: true });
|
|
222
|
-
const log = logDouble();
|
|
223
|
-
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }), log });
|
|
224
|
-
|
|
225
|
-
// fails_when: a shiki failure kills a render.
|
|
226
|
-
await expect(theme.highlight(CODE, "ts")).resolves.toEqual(["const a = 1;", "let b = 2;"]);
|
|
227
|
-
expect(theme.highlightSync(CODE, "ts")).toBe("const a = 1;\nlet b = 2;");
|
|
228
|
-
expect(log.entries.map((entry) => entry.key)).toEqual(["fail:ts"]);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("AC-4: a throwing engine bind degrades the same way", async () => {
|
|
232
|
-
const engine = countingEngine({ throwOnBind: true });
|
|
233
|
-
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
234
|
-
|
|
235
|
-
// fails_when: a bind failure (shiki not loaded yet, a bad theme) escapes into the render path.
|
|
236
|
-
await expect(theme.highlight(CODE, "ts")).resolves.toEqual(["const a = 1;", "let b = 2;"]);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("AC-4: an engine with no registry and no logger still highlights and still degrades", async () => {
|
|
240
|
-
const engine = countingEngine();
|
|
241
|
-
const theme = createCodeTheme(engine.bind);
|
|
242
|
-
|
|
243
|
-
// fails_when: the optional collaborators are required, so a bare construction throws.
|
|
244
|
-
expect(await theme.highlight(CODE, "ts")).toEqual(["«dracula-soft»const a = 1;", "«dracula-soft»let b = 2;"]);
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
describe("createCodeTheme — contrast normalization (P render.ts:112-140)", () => {
|
|
249
|
-
it("drops a foreground below the readability floor and keeps the rest", async () => {
|
|
250
|
-
// invented: RGB triples chosen around the 72 luminance floor (20 below, 80 just above) and the
|
|
251
|
-
// indexed-black form P treated as dark.
|
|
252
|
-
const DIM = "\u001b[38;2;20;20;20m";
|
|
253
|
-
const INDEXED = "\u001b[38;5;0m";
|
|
254
|
-
const READABLE = "\u001b[38;2;80;80;80m";
|
|
255
|
-
const RESET = "\u001b[0m";
|
|
256
|
-
const bind: BindHighlightEngine = () => () => [
|
|
257
|
-
`${DIM}dim${RESET}`,
|
|
258
|
-
`${INDEXED}indexed${RESET}`,
|
|
259
|
-
`${READABLE}readable${RESET}`,
|
|
260
|
-
];
|
|
261
|
-
const theme = createCodeTheme(bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
262
|
-
|
|
263
|
-
// fails_when: an unreadable near-black token survives to the terminal.
|
|
264
|
-
expect(await theme.highlight("x", "ts")).toEqual([`dim${RESET}`, `indexed${RESET}`, `${READABLE}readable${RESET}`]);
|
|
265
|
-
});
|
|
266
|
-
});
|
package/src/core/log.test.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* log.test.ts — createLogger's behavior through its seam: logLine / logOnce in, drain() out (T-06).
|
|
3
|
-
*
|
|
4
|
-
* No module internals appear here; every assertion reads the entries a test or the future /render
|
|
5
|
-
* panel would read back.
|
|
6
|
-
*
|
|
7
|
-
* shape: none — test module, no runtime unit for DSG-1's trigger table to select a shape for.
|
|
8
|
-
*/
|
|
9
|
-
// invented fixtures: the keys, scopes, and messages below are plain strings with no upstream payload to
|
|
10
|
-
// record; "tool:call-42" is the key shape named in src/core/types/log.ts.
|
|
11
|
-
import { describe, expect, test, vi } from "vitest";
|
|
12
|
-
import { createLogger } from "./log.ts";
|
|
13
|
-
|
|
14
|
-
describe("createLogger", () => {
|
|
15
|
-
// spec: logOnce(key, …) appends one entry for a key; each distinct key appends its own.
|
|
16
|
-
// fails_when: a repeated key adds a duplicate entry, or a second key collides onto the first.
|
|
17
|
-
test("AC-1 logOnce keeps one entry per key, distinct keys do not collide", () => {
|
|
18
|
-
const logger = createLogger();
|
|
19
|
-
|
|
20
|
-
logger.logOnce("tool:call-42", "tool.bash", "first");
|
|
21
|
-
logger.logOnce("tool:call-42", "tool.bash", "repeat of the same key");
|
|
22
|
-
logger.logOnce("tool:call-43", "tool.bash", "a different key");
|
|
23
|
-
|
|
24
|
-
expect(logger.drain().map((entry) => entry.message)).toEqual(["first", "a different key"]);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
// spec: any sequence of logging calls returns without throwing, including drain() on a logger with
|
|
28
|
-
// no entries; the render path calls these, so a throw is a permanently broken row.
|
|
29
|
-
// fails_when: a logging call throws — the render path can be broken by its own diagnostics.
|
|
30
|
-
test("AC-2 no logging call throws, even on hostile text", () => {
|
|
31
|
-
const logger = createLogger();
|
|
32
|
-
const longText = "x".repeat(200_000);
|
|
33
|
-
|
|
34
|
-
expect(logger.drain()).toEqual([]);
|
|
35
|
-
|
|
36
|
-
expect(() => {
|
|
37
|
-
logger.logLine("", "");
|
|
38
|
-
logger.logLine("tool.bash", "line\nwith newlines\tand a 🖼 surrogate pair");
|
|
39
|
-
logger.logLine("tool.bash", longText);
|
|
40
|
-
logger.logOnce("tool:call-42", "tool.bash", longText);
|
|
41
|
-
logger.logOnce("tool:call-42", "tool.bash", "repeat");
|
|
42
|
-
logger.drain();
|
|
43
|
-
}).not.toThrow();
|
|
44
|
-
|
|
45
|
-
expect(logger.drain()).toEqual([]);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
// spec: a logging call writes nothing to process.stdout or process.stderr — pi owns the terminal
|
|
49
|
-
// for the TUI, so a write lands inside the rendered frame.
|
|
50
|
-
// fails_when: a diagnostic leaks to the terminal.
|
|
51
|
-
test("AC-3 logging writes neither stdout nor stderr", () => {
|
|
52
|
-
const logger = createLogger();
|
|
53
|
-
const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true);
|
|
54
|
-
const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
logger.logLine("tool.bash", "line");
|
|
58
|
-
logger.logOnce("tool:call-42", "tool.bash", "once");
|
|
59
|
-
logger.logOnce("tool:call-42", "tool.bash", "once");
|
|
60
|
-
expect(logger.drain()).toHaveLength(2);
|
|
61
|
-
// Asserted before mockRestore(): restoring also discards the recorded calls.
|
|
62
|
-
expect(stdout).not.toHaveBeenCalled();
|
|
63
|
-
expect(stderr).not.toHaveBeenCalled();
|
|
64
|
-
} finally {
|
|
65
|
-
stdout.mockRestore();
|
|
66
|
-
stderr.mockRestore();
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// spec: logLine(scope, message) leaves the entry attributable — that scope, that message, and a
|
|
71
|
-
// capture time inside the call window.
|
|
72
|
-
// fails_when: entries cannot be attributed to the module that logged them.
|
|
73
|
-
test("AC-4 each entry keeps its scope, message, and capture time", () => {
|
|
74
|
-
const logger = createLogger();
|
|
75
|
-
const before = Date.now();
|
|
76
|
-
|
|
77
|
-
logger.logLine("find", "no matches");
|
|
78
|
-
logger.logOnce("tool:call-42", "tool.bash", "degraded row");
|
|
79
|
-
|
|
80
|
-
const after = Date.now();
|
|
81
|
-
const entries = logger.drain();
|
|
82
|
-
|
|
83
|
-
expect(entries.map((entry) => entry.scope)).toEqual(["find", "tool.bash"]);
|
|
84
|
-
expect(entries.map((entry) => entry.message)).toEqual(["no matches", "degraded row"]);
|
|
85
|
-
for (const entry of entries) {
|
|
86
|
-
expect(entry.time).toBeGreaterThanOrEqual(before);
|
|
87
|
-
expect(entry.time).toBeLessThanOrEqual(after);
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
// spec: past 256 entries the newest 256 survive in order and the oldest are dropped.
|
|
92
|
-
// fails_when: the buffer grows unbounded, or it drops the newest instead of the oldest.
|
|
93
|
-
test("AC-5 more than 256 entries keeps the newest 256 in order", () => {
|
|
94
|
-
const logger = createLogger();
|
|
95
|
-
for (let i = 0; i < 300; i += 1) logger.logLine("loop", `line-${i}`);
|
|
96
|
-
|
|
97
|
-
const messages = logger.drain().map((entry) => entry.message);
|
|
98
|
-
|
|
99
|
-
expect(messages).toHaveLength(256);
|
|
100
|
-
expect(messages.at(0)).toBe("line-44");
|
|
101
|
-
expect(messages.at(-1)).toBe("line-299");
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
// spec: drain() returns every captured entry in log order, and the buffer is empty afterwards.
|
|
105
|
-
// fails_when: a drained buffer still holds entries, so a panel re-shows the same lines.
|
|
106
|
-
test("drain() returns every captured entry and then empties the buffer", () => {
|
|
107
|
-
const logger = createLogger();
|
|
108
|
-
logger.logLine("tool.bash", "one");
|
|
109
|
-
logger.logLine("find", "two");
|
|
110
|
-
|
|
111
|
-
expect(logger.drain().map((entry) => entry.scope)).toEqual(["tool.bash", "find"]);
|
|
112
|
-
expect(logger.drain()).toEqual([]);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
// spec: "at most one entry per key" is a property of the logger, not of the current buffer — a
|
|
116
|
-
// panel that drains every frame must not re-emit a key's line.
|
|
117
|
-
// fails_when: drain() clears the dedup marks, so the same key logs again after a readout.
|
|
118
|
-
test("logOnce keeps a key spent across drain()", () => {
|
|
119
|
-
const logger = createLogger();
|
|
120
|
-
logger.logOnce("tool:call-42", "tool.bash", "once");
|
|
121
|
-
logger.drain();
|
|
122
|
-
|
|
123
|
-
logger.logOnce("tool:call-42", "tool.bash", "again");
|
|
124
|
-
|
|
125
|
-
expect(logger.drain()).toEqual([]);
|
|
126
|
-
});
|
|
127
|
-
});
|