@zenodinh/pi-render 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +20 -6
  2. package/index.ts +29 -33
  3. package/package.json +6 -2
  4. package/src/core/paint.ts +17 -2
  5. package/src/core/types/host.ts +15 -18
  6. package/src/core/types.ts +0 -1
  7. package/src/renderers/content/artifacts/cards.ts +13 -6
  8. package/src/renderers/content/code-panel.ts +53 -31
  9. package/src/renderers/content/index.ts +70 -21
  10. package/src/renderers/content/json-panel.ts +9 -17
  11. package/src/renderers/content/link-paragraph.ts +153 -0
  12. package/src/renderers/content/table.ts +4 -1
  13. package/src/renderers/tool/runtime.ts +44 -5
  14. package/src/renderers/tool/specs/search.ts +18 -10
  15. package/src/renderers/tool/types.ts +6 -1
  16. package/src/commands/canvas.test.ts +0 -150
  17. package/src/commands/canvas.ts +0 -57
  18. package/src/core/code-theme.test.ts +0 -266
  19. package/src/core/log.test.ts +0 -127
  20. package/src/core/paint.test.ts +0 -322
  21. package/src/core/registry.test.ts +0 -307
  22. package/src/core/settings.test.ts +0 -183
  23. package/src/renderers/content/artifacts/artifacts.test.ts +0 -199
  24. package/src/renderers/content/artifacts/cards.test.ts +0 -216
  25. package/src/renderers/content/artifacts/engines-extra.test.ts +0 -556
  26. package/src/renderers/content/artifacts/local-binary.test.ts +0 -207
  27. package/src/renderers/content/image-card.test.ts +0 -170
  28. package/src/renderers/content/panels.test.ts +0 -188
  29. package/src/renderers/content/table.test.ts +0 -209
  30. package/src/renderers/content/transformer.test.ts +0 -254
  31. package/src/renderers/tool/resolver.test.ts +0 -257
  32. package/src/renderers/tool/runtime.test.ts +0 -313
  33. package/src/renderers/tool/specs/bash.test.ts +0 -110
  34. package/src/renderers/tool/specs/codemode.test.ts +0 -212
  35. package/src/renderers/tool/specs/edit.test.ts +0 -260
  36. package/src/renderers/tool/specs/ls.test.ts +0 -173
  37. package/src/renderers/tool/specs/read.test.ts +0 -340
  38. package/src/renderers/tool/specs/search.test.ts +0 -197
  39. package/src/renderers/tool/specs/write.test.ts +0 -145
  40. package/themes/themes.test.ts +0 -251
@@ -1,257 +0,0 @@
1
- /**
2
- * resolver.test.ts — the master resolver at its seam: createMasterResolver(registry, specs, deps),
3
- * driven with fake specs, a fake registry and a fake base (the captured next()).
4
- *
5
- * Each scenario resolves through the factory and then invokes the returned wrappers the way the host
6
- * does — through the component's own render(width) — toggling the registry between invocations of the
7
- * SAME resolved row. Nothing below the seam is imported.
8
- *
9
- * invented: the fake registry / base / specs stand in for core/registry, the host chain and T-14..T-20;
10
- * they satisfy the structural contracts and record every call. The theme is invented because
11
- * markerPaint ignores it — the host still passes one per render call.
12
- */
13
-
14
- import { describe, expect, it } from "vitest";
15
- import type { TestResultInvocation } from "../../../test/fakes/index.ts";
16
- import { markerPaint, testRenderContext } from "../../../test/fakes/index.ts";
17
- import { createLogger } from "../../core/log.ts";
18
- import type { HostTheme, Registry, RenderContext, ToolRenderers, ToolResult, UiComponent } from "../../core/types.ts";
19
- import { createMasterResolver } from "./index.ts";
20
- import type { RowSpec } from "./types.ts";
21
-
22
- const PAINT = markerPaint();
23
- const THEME: HostTheme = { fg: (_key, text) => text, bold: (text) => text };
24
- const DEPS = { paint: markerPaint, log: createLogger() };
25
-
26
- /** Reads a returned component the way the host does: through its own render(width). */
27
- function show(component: UiComponent): string {
28
- return component.render(80).join("\n");
29
- }
30
-
31
- function requireRenderers(renderers: ToolRenderers | undefined): ToolRenderers {
32
- if (renderers === undefined) throw new Error("resolver returned no renderers for a known name");
33
- return renderers;
34
- }
35
-
36
- function showResult(renderers: ToolRenderers, inv: TestResultInvocation): string {
37
- const renderResult = renderers.renderResult;
38
- if (renderResult === undefined) throw new Error("resolver returned no renderResult");
39
- return show(renderResult(inv.result, inv.options, THEME, inv.ctx));
40
- }
41
-
42
- function showCall(renderers: ToolRenderers, args: Record<string, unknown>, ctx: RenderContext): string {
43
- const renderCall = renderers.renderCall;
44
- if (renderCall === undefined) throw new Error("resolver returned no renderCall");
45
- return show(renderCall(args, THEME, ctx));
46
- }
47
-
48
- /** The registry double: the resolver reads only isEnabled, and a toggle lands on the next read. */
49
- function fakeRegistry(): { registry: Registry; setEnabled(key: string, enabled: boolean): void } {
50
- const enabledKeys = new Set<string>();
51
- return {
52
- registry: {
53
- defineModule: () => {},
54
- isEnabled: (key) => enabledKeys.has(key),
55
- getSettings: () => ({}),
56
- setSettings: () => {},
57
- },
58
- setEnabled(key, enabled) {
59
- if (enabled) enabledKeys.add(key);
60
- else enabledKeys.delete(key);
61
- },
62
- };
63
- }
64
-
65
- /** A component the way a host renderer returns one: lines in, no text protocol. */
66
- function plain(text: string): UiComponent {
67
- return {
68
- render: () => [text],
69
- invalidate: () => {},
70
- };
71
- }
72
-
73
- /** What the captured next() returns, plus the ctx each slot invocation was handed. */
74
- interface FakeBase {
75
- renderers: ToolRenderers;
76
- calls: { call: RenderContext[]; result: RenderContext[] };
77
- }
78
-
79
- /**
80
- * invented: the base throws on a foreign component the way the host's own renderers do — they clear the
81
- * component they are handed (host grep.js:65-67), so a non-blank slot is a type mismatch, not a redraw.
82
- */
83
- function fakeBase(): FakeBase {
84
- const call: RenderContext[] = [];
85
- const result: RenderContext[] = [];
86
- const rejectForeign = (ctx: RenderContext): void => {
87
- if (ctx.lastComponent !== undefined) throw new Error("base received a foreign component");
88
- };
89
- return {
90
- calls: { call, result },
91
- renderers: {
92
- renderCall(_args, _theme, ctx) {
93
- rejectForeign(ctx);
94
- call.push(ctx);
95
- return plain("base-call");
96
- },
97
- renderResult(_result, _options, _theme, ctx) {
98
- rejectForeign(ctx);
99
- result.push(ctx);
100
- return plain("base-result");
101
- },
102
- },
103
- };
104
- }
105
-
106
- interface FakeModel {
107
- text: string;
108
- }
109
-
110
- /** A spec labelled so the rendered output names the spec the resolver actually picked. */
111
- function fakeSpec(label: string): RowSpec<FakeModel> {
112
- return {
113
- buildCall: () => ({ title: `call:${label}` }),
114
- build: (result) => ({ text: engineText(result) }),
115
- project: (m, _view, paint) => paint.output(`${label}(${m.text})`),
116
- };
117
- }
118
-
119
- function engineText(result: ToolResult): string {
120
- return (result.content ?? [])
121
- .filter((block) => block.type === "text")
122
- .map((block) => block.text ?? "")
123
- .join(" ");
124
- }
125
-
126
- const CONTENT = [{ type: "text", text: "engine" }];
127
-
128
- describe("createMasterResolver — names outside the table", () => {
129
- it("AC-1: an unknown name returns exactly what next() returned, with next() called once", () => {
130
- const { registry } = fakeRegistry();
131
- const resolver = createMasterResolver(registry, { bash: { spec: fakeSpec("bash"), key: "row.bash" } }, DEPS);
132
- const base = fakeBase().renderers;
133
- let nextCalls = 0;
134
-
135
- const resolved = resolver("not-a-row", () => {
136
- nextCalls += 1;
137
- return base;
138
- });
139
-
140
- // fails_when: an unknown name gets our wrappers (or undefined) instead of the host chain
141
- expect(resolved).toBe(base);
142
- expect(nextCalls).toBe(1);
143
- });
144
-
145
- it("AC-1: an exhausted chain stays undefined", () => {
146
- const { registry } = fakeRegistry();
147
- const resolver = createMasterResolver(registry, { bash: { spec: fakeSpec("bash"), key: "row.bash" } }, DEPS);
148
-
149
- expect(resolver("not-a-row", () => undefined)).toBeUndefined();
150
- });
151
- });
152
-
153
- describe("createMasterResolver — known names", () => {
154
- it("a shared registry key governs several names (search's four, T-20 ruling)", () => {
155
- const { registry, setEnabled } = fakeRegistry();
156
- const resolver = createMasterResolver(
157
- registry,
158
- {
159
- find: { spec: fakeSpec("find"), key: "row.search" },
160
- grep: { spec: fakeSpec("grep"), key: "row.search" },
161
- },
162
- DEPS,
163
- );
164
- const base = fakeBase();
165
- setEnabled("row.search", true);
166
- const grepWrapper = requireRenderers(resolver("grep", () => base.renderers));
167
- const row = testRenderContext();
168
- expect(showResult(grepWrapper, row.result({ content: CONTENT }))).toBe(PAINT.output("grep(engine)"));
169
-
170
- // disabling the SHARED key flips every name governed by it — not just the one resolved first
171
- setEnabled("row.search", false);
172
- const findWrapper = requireRenderers(resolver("find", () => base.renderers));
173
- // fails_when: a per-name derived key ("row.find") would read false and the row would stay styled
174
- expect(showResult(findWrapper, row.result({ content: CONTENT }))).toBe("base-result");
175
- });
176
-
177
- it("AC-2: an enabled module renders the row through its own spec, over one captured base", () => {
178
- const { registry, setEnabled } = fakeRegistry();
179
- setEnabled("row.ls", true);
180
- const resolver = createMasterResolver(
181
- registry,
182
- { bash: { spec: fakeSpec("bash"), key: "row.bash" }, ls: { spec: fakeSpec("ls"), key: "row.ls" } },
183
- DEPS,
184
- );
185
- const base = fakeBase();
186
- let nextCalls = 0;
187
- const wrapper = requireRenderers(
188
- resolver("ls", () => {
189
- nextCalls += 1;
190
- return base.renderers;
191
- }),
192
- );
193
- const row = testRenderContext();
194
-
195
- // fails_when: the enabled row bypasses its spec (falls through to the host base, or to another name's spec)
196
- expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe(PAINT.output("ls(engine)"));
197
- expect(showCall(wrapper, { command: "ls" }, row.derive())).toBe(PAINT.title("call:ls"));
198
- expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe(PAINT.output("ls(engine)"));
199
- // fails_when: the host chain is consulted per invocation instead of captured once at resolution
200
- expect(nextCalls).toBe(1);
201
- expect(wrapper.renderShell).toBe("default");
202
- });
203
-
204
- it("AC-3: disabling the module hands the very next invocation of an existing row to the base", () => {
205
- const { registry, setEnabled } = fakeRegistry();
206
- setEnabled("row.bash", true);
207
- const resolver = createMasterResolver(registry, { bash: { spec: fakeSpec("bash"), key: "row.bash" } }, DEPS);
208
- const base = fakeBase();
209
- const wrapper = requireRenderers(resolver("bash", () => base.renderers));
210
- const row = testRenderContext();
211
-
212
- expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe(PAINT.output("bash(engine)"));
213
-
214
- // registry.ts: a write lands on the next read and pushes no redraw, so the toggle shows up here
215
- setEnabled("row.bash", false);
216
-
217
- // fails_when: a disabled module keeps styling the row (the choice was frozen at construction)
218
- expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe("base-result");
219
- expect(showCall(wrapper, { command: "bash" }, row.derive())).toBe("base-call");
220
- });
221
-
222
- it("AC-4: a delegated call receives a blank component slot, never our component", () => {
223
- const { registry } = fakeRegistry();
224
- const resolver = createMasterResolver(registry, { bash: { spec: fakeSpec("bash"), key: "row.bash" } }, DEPS);
225
- const base = fakeBase();
226
- const wrapper = requireRenderers(resolver("bash", () => base.renderers));
227
- const row = testRenderContext();
228
- const hostComponent = plain("host row");
229
- row.recordLastComponent("call", hostComponent);
230
- row.recordLastComponent("result", hostComponent);
231
- const inv = row.result({ content: CONTENT });
232
- expect(inv.ctx.lastComponent).toBe(hostComponent);
233
-
234
- // fails_when: the base clears our component, throws, and the row degrades for the row's lifetime
235
- expect(showResult(wrapper, inv)).toBe("base-result");
236
- expect(showCall(wrapper, { command: "bash" }, row.derive())).toBe("base-call");
237
- expect(base.calls.result[0]?.lastComponent).toBeUndefined();
238
- expect(base.calls.call[0]?.lastComponent).toBeUndefined();
239
- // the host's own ctx object is left intact for its bookkeeping
240
- expect(inv.ctx.lastComponent).toBe(hostComponent);
241
- });
242
-
243
- it("AC-5: the registry is read per invocation, so a row resolved while disabled can come back", () => {
244
- const { registry, setEnabled } = fakeRegistry();
245
- const resolver = createMasterResolver(registry, { bash: { spec: fakeSpec("bash"), key: "row.bash" } }, DEPS);
246
- const base = fakeBase();
247
- const wrapper = requireRenderers(resolver("bash", () => base.renderers));
248
- const row = testRenderContext();
249
-
250
- expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe("base-result");
251
-
252
- setEnabled("row.bash", true);
253
-
254
- // fails_when: the enabled flag is read once at resolution and the row never returns
255
- expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe(PAINT.output("bash(engine)"));
256
- });
257
- });
@@ -1,313 +0,0 @@
1
- /**
2
- * runtime.test.ts — the row runtime at its seam: createRowRenderers + a counting spec + the fake row.
3
- *
4
- * Every test drives the public seam only (T-12); the spec is the caller-side double, and rendered
5
- * output is read back through the returned component's own render(width), exactly as the host does.
6
- */
7
-
8
- import { describe, expect, it } from "vitest";
9
- import type { TestResultInvocation } from "../../../test/fakes/index.ts";
10
- import { markerPaint, testRenderContext } from "../../../test/fakes/index.ts";
11
- import { createLogger } from "../../core/log.ts";
12
- import { createRowPaint } from "../../core/paint.ts";
13
- import type { HostTheme } from "../../core/types/host.ts";
14
- import type { RenderContext, ToolRenderers, ToolResult, UiComponent } from "../../core/types.ts";
15
- import { createRowRenderers } from "./runtime.ts";
16
- import type { RowSpec } from "./types.ts";
17
-
18
- /** The parsed model the test spec derives — the runtime never inspects M, only the spec does. */
19
- interface TestModel {
20
- text: string;
21
- link?: string;
22
- }
23
-
24
- // Fixture provenance — invented: a token-naming theme stand-in, so a painted output is asserted as a
25
- // literal ("toolOutput(engine)") rather than an opaque escape. createRowPaint reads fg live, so two
26
- // differently-prefixed themes prove a theme switch repainted.
27
- const THEME_A: HostTheme = { fg: (key, text) => `A:${key}(${text})`, bold: (text) => text };
28
- const THEME_B: HostTheme = { fg: (key, text) => `B:${key}(${text})`, bold: (text) => text };
29
-
30
- function textOf(result: ToolResult): string {
31
- const content = result.content;
32
- if (!Array.isArray(content)) return "";
33
- return content.map((block) => (typeof block.text === "string" ? block.text : "")).join(" ");
34
- }
35
-
36
- /** Reads a returned component the way the host does: through its own render(width). */
37
- function show(component: UiComponent): string {
38
- return component.render(80).join("\n");
39
- }
40
-
41
- function showResult(renderers: ToolRenderers, inv: TestResultInvocation, theme: HostTheme): string {
42
- const renderResult = renderers.renderResult;
43
- if (renderResult === undefined) throw new Error("runtime returned no renderResult");
44
- return show(renderResult(inv.result, inv.options, theme, inv.ctx));
45
- }
46
-
47
- function showCall(
48
- renderers: ToolRenderers,
49
- args: Record<string, unknown>,
50
- ctx: RenderContext,
51
- theme: HostTheme,
52
- ): string {
53
- const renderCall = renderers.renderCall;
54
- if (renderCall === undefined) throw new Error("runtime returned no renderCall");
55
- return show(renderCall(args, theme, ctx));
56
- }
57
-
58
- /** A spec that counts build/buildCall invocations and projects "text|expanded" through the paint. */
59
- function countingSpec(): { spec: RowSpec<TestModel>; counts: { builds: number; calls: number } } {
60
- const counts = { builds: 0, calls: 0 };
61
- const spec: RowSpec<TestModel> = {
62
- buildCall(args) {
63
- counts.calls += 1;
64
- return { title: typeof args.command === "string" ? `bash: ${args.command}` : "bash" };
65
- },
66
- build(result) {
67
- counts.builds += 1;
68
- return { text: textOf(result) };
69
- },
70
- project(m, view, paint) {
71
- return paint.output(`${m.text}|${view.expanded ? "expanded" : "collapsed"}`);
72
- },
73
- };
74
- return { spec, counts };
75
- }
76
-
77
- describe("createRowRenderers — result", () => {
78
- it("AC-1: builds once and re-projects for every expanded/collapsed invocation", () => {
79
- const { spec, counts } = countingSpec();
80
- const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
81
- const row = testRenderContext();
82
- const content = [{ type: "text", text: "engine" }];
83
- const outputs: string[] = [];
84
- for (let i = 0; i < 10; i += 1) {
85
- const expanded = i % 2 === 0;
86
- outputs.push(showResult(renderers, row.result({ content, expanded }), THEME_A));
87
- }
88
- // fails_when: build count > 1 per content reference (the #21 class returns)
89
- expect(counts.builds).toBe(1);
90
- expect(outputs).toEqual([
91
- "A:toolOutput(engine|expanded)",
92
- "A:toolOutput(engine|collapsed)",
93
- "A:toolOutput(engine|expanded)",
94
- "A:toolOutput(engine|collapsed)",
95
- "A:toolOutput(engine|expanded)",
96
- "A:toolOutput(engine|collapsed)",
97
- "A:toolOutput(engine|expanded)",
98
- "A:toolOutput(engine|collapsed)",
99
- "A:toolOutput(engine|expanded)",
100
- "A:toolOutput(engine|collapsed)",
101
- ]);
102
- });
103
-
104
- it("AC-2: rebuilds for each streaming delta whose content reference changed", () => {
105
- const { spec, counts } = countingSpec();
106
- const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
107
- const row = testRenderContext();
108
- const outputs = ["one", "two", "three"].map((delta) =>
109
- showResult(renderers, row.result({ content: [{ type: "text", text: delta }] }), THEME_A),
110
- );
111
- // fails_when: a stale partial output serves a later delta
112
- expect(counts.builds).toBe(3);
113
- expect(outputs).toEqual([
114
- "A:toolOutput(one|collapsed)",
115
- "A:toolOutput(two|collapsed)",
116
- "A:toolOutput(three|collapsed)",
117
- ]);
118
- });
119
-
120
- it("AC-3: a theme switch repaints from the memoized model", () => {
121
- const { spec, counts } = countingSpec();
122
- const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
123
- const row = testRenderContext();
124
- const content = [{ type: "text", text: "engine" }];
125
- const underA = showResult(renderers, row.result({ content }), THEME_A);
126
- const underB = showResult(renderers, row.result({ content }), THEME_B);
127
- // fails_when: the memoized frame keeps theme A's colors after the theme changed
128
- expect([underA, underB]).toEqual(["A:toolOutput(engine|collapsed)", "B:toolOutput(engine|collapsed)"]);
129
- expect(counts.builds).toBe(1);
130
- });
131
-
132
- it("AC-4: a malformed result and a throwing paint degrade to one line and one log", () => {
133
- const spec: RowSpec<TestModel> = {
134
- buildCall: () => ({ title: "bash" }),
135
- build(result) {
136
- if (result.details !== undefined) throw new Error("bad details shape");
137
- return { text: textOf(result) };
138
- },
139
- project(m, _view, paint) {
140
- return paint.output(m.text);
141
- },
142
- };
143
- const log = createLogger();
144
- const paint = () => ({
145
- ...markerPaint(),
146
- output: () => {
147
- throw new Error("paint boom");
148
- },
149
- });
150
- const renderers = createRowRenderers(spec, { paint, log });
151
- const row = testRenderContext();
152
-
153
- const malformed = showResult(renderers, row.result({ details: "wrong" }), THEME_A);
154
- const paintThrows = showResult(
155
- renderers,
156
- row.result({ content: [{ type: "text", text: "engine text" }] }),
157
- THEME_A,
158
- );
159
-
160
- // fails_when: an exception reaches the host (the row would permanently degrade)
161
- expect(malformed).toBe("");
162
- expect(paintThrows).toBe("engine text");
163
- expect(log.drain()).toHaveLength(1);
164
- });
165
-
166
- it("AC-5: an unrecognized result renders the engine text verbatim, without logging", () => {
167
- const spec: RowSpec<TestModel> = {
168
- buildCall: () => ({ title: "bash" }),
169
- build: () => "unknown",
170
- project: () => {
171
- throw new Error("project must not run for an unrecognized result");
172
- },
173
- };
174
- const log = createLogger();
175
- const renderers = createRowRenderers(spec, { paint: createRowPaint, log });
176
- const row = testRenderContext();
177
- const out = showResult(renderers, row.result({ content: [{ type: "text", text: "no matches found" }] }), THEME_A);
178
- // fails_when: a blank row or a crash (either would leave the text unstyled or drop a log line)
179
- expect(out).toBe("A:muted(no matches found)");
180
- expect(log.drain()).toHaveLength(0);
181
- });
182
-
183
- it("reuses the component the host recorded for the same slot", () => {
184
- const { spec } = countingSpec();
185
- const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
186
- const renderResult = renderers.renderResult;
187
- if (renderResult === undefined) throw new Error("runtime returned no renderResult");
188
- const row = testRenderContext();
189
- const content = [{ type: "text", text: "engine" }];
190
-
191
- const first = row.result({ content });
192
- const firstComponent = renderResult(first.result, first.options, THEME_A, first.ctx);
193
- row.recordLastComponent("result", firstComponent);
194
- const second = row.result({ content, expanded: true });
195
- const secondComponent = renderResult(second.result, second.options, THEME_A, second.ctx);
196
-
197
- // fails_when: a fresh component is allocated every frame instead of mutating the host's
198
- expect(secondComponent).toBe(firstComponent);
199
- expect(show(secondComponent)).toBe("A:toolOutput(engine|expanded)");
200
- });
201
- });
202
-
203
- describe("createRowRenderers — call", () => {
204
- it("AC-6: shows the name only until the host reports argsComplete", () => {
205
- const { spec } = countingSpec();
206
- const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
207
- const row = testRenderContext();
208
-
209
- const empty = showCall(renderers, {}, row.derive({ argsComplete: false, args: {} }), THEME_A);
210
- const partial = showCall(
211
- renderers,
212
- { command: "rm -rf /" },
213
- row.derive({ argsComplete: false, args: { command: "rm -rf /" } }),
214
- THEME_A,
215
- );
216
- const complete = showCall(
217
- renderers,
218
- { command: "rm -rf /" },
219
- row.derive({ argsComplete: true, args: { command: "rm -rf /" } }),
220
- THEME_A,
221
- );
222
-
223
- expect(empty).toBe("A:toolTitle(bash)");
224
- // fails_when: a streamed argument value appears before argsComplete
225
- expect(partial).toBe("A:toolTitle(bash)");
226
- expect(complete).toBe("A:toolTitle(bash: rm -rf /)");
227
- });
228
- });
229
-
230
- describe("createRowRenderers — decorate", () => {
231
- it("applies the resolved partial through ctx.invalidate without rebuilding", async () => {
232
- let builds = 0;
233
- const resolvers: Array<(partial: Partial<TestModel>) => void> = [];
234
- const spec: RowSpec<TestModel> = {
235
- buildCall: () => ({ title: "bash" }),
236
- build() {
237
- builds += 1;
238
- return { text: "engine", link: "pending" };
239
- },
240
- project: (m, _view, paint) => paint.output(m.link ?? "none"),
241
- decorate: () =>
242
- new Promise<Partial<TestModel>>((resolve) => {
243
- resolvers.push(resolve);
244
- }),
245
- };
246
- let invalidations = 0;
247
- const row = testRenderContext({
248
- invalidate: () => {
249
- invalidations += 1;
250
- },
251
- });
252
- const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
253
- const content = [{ type: "text", text: "engine" }];
254
-
255
- expect(showResult(renderers, row.result({ content }), THEME_A)).toBe("A:toolOutput(pending)");
256
- resolvers[0]?.({ link: "resolved" });
257
- await new Promise((resolve) => setTimeout(resolve, 0));
258
-
259
- expect(invalidations).toBe(1);
260
- // fails_when: the decorated model is dropped (the memo was not updated)
261
- expect(showResult(renderers, row.result({ content }), THEME_A)).toBe("A:toolOutput(resolved)");
262
- expect(builds).toBe(1);
263
- });
264
-
265
- it("drops a decorate that resolves after the content changed", async () => {
266
- const resolvers: Array<(partial: Partial<TestModel>) => void> = [];
267
- const spec: RowSpec<TestModel> = {
268
- buildCall: () => ({ title: "bash" }),
269
- build: () => ({ text: "engine", link: "pending" }),
270
- project: (m, _view, paint) => paint.output(m.link ?? "none"),
271
- decorate: () =>
272
- new Promise<Partial<TestModel>>((resolve) => {
273
- resolvers.push(resolve);
274
- }),
275
- };
276
- let invalidations = 0;
277
- const row = testRenderContext({
278
- invalidate: () => {
279
- invalidations += 1;
280
- },
281
- });
282
- const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
283
- const first = [{ type: "text", text: "one" }];
284
- const second = [{ type: "text", text: "two" }];
285
-
286
- showResult(renderers, row.result({ content: first }), THEME_A);
287
- resolvers[0]?.({ link: "late" });
288
- showResult(renderers, row.result({ content: second }), THEME_A);
289
- await new Promise((resolve) => setTimeout(resolve, 0));
290
-
291
- // fails_when: a stale decorate enriches the new model or redraws the row
292
- expect(invalidations).toBe(0);
293
- expect(showResult(renderers, row.result({ content: second }), THEME_A)).toBe("A:toolOutput(pending)");
294
- });
295
- });
296
-
297
- describe("createRowRenderers — model cache bound", () => {
298
- it("evicts the least-recently-used parsed model past the entry cap", () => {
299
- const { spec, counts } = countingSpec();
300
- const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
301
- const row = testRenderContext();
302
- const first = [{ type: "text", text: "delta 0" }];
303
- const rest = Array.from({ length: 512 }, (_, i) => [{ type: "text", text: `delta ${i + 1}` }]);
304
- for (const content of [first, ...rest]) {
305
- showResult(renderers, row.result({ content }), THEME_A);
306
- }
307
- expect(counts.builds).toBe(513);
308
-
309
- showResult(renderers, row.result({ content: first }), THEME_A);
310
- // fails_when: the evicted model is still cached, so the rebuild is skipped (builds stays 513)
311
- expect(counts.builds).toBe(514);
312
- });
313
- });
@@ -1,110 +0,0 @@
1
- /**
2
- * bash.test.ts — the bash row spec as a specification.
3
- *
4
- * Behavior spec (from the T-14 port source, pi-pretty-tui/src/tools/bash.ts):
5
- * buildCall({command}, argsComplete) -> title "bash: <command>"; gate closed -> "bash" only.
6
- * build(result, ctx) -> { output, exitCode, durationMs, isError } with the exit
7
- * status inferred from the output and the time read from ctx.state.
8
- * project(model, collapsed) -> one summary line: "<n> lines · exit <code> · <duration> · hint";
9
- * an error shows its message on that line instead.
10
- * project(model, expanded) -> summary header + blank + indented output body.
11
- *
12
- * Enters only at the exported record's seam with the marker fake paint and testRenderContext; no
13
- * module internals are imported.
14
- */
15
-
16
- import { readFileSync } from "node:fs";
17
- import { fileURLToPath } from "node:url";
18
- import { describe, expect, it } from "vitest";
19
- import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
20
- import type { RenderContext, RowView, ToolResult } from "../../../core/types.ts";
21
- import { bashRow } from "./bash.ts";
22
-
23
- // Marker escapes pinned from test/fakes/paint.ts:22-35 (muted 202/222, output 201/221, error 204/224);
24
- // a real escape cannot read as a marker because the marker codes sit off the host palette.
25
- const MUTED = (text: string): string => `\u001b[38;5;202m${text}\u001b[38;5;222m`;
26
- const OUTPUT = (text: string): string => `\u001b[38;5;201m${text}\u001b[38;5;221m`;
27
- const ERROR = (text: string): string => `\u001b[38;5;204m${text}\u001b[38;5;224m`;
28
-
29
- const MARKER = markerPaint();
30
-
31
- // recorded-from: pi host bash result — content is the engine's stdout verbatim, trailing newline
32
- // included; the single-line shape is pi-pretty-tui/src/tools/bash-collapse.test.ts:46, 2026-10-03.
33
- const CANONICAL: ToolResult = { content: [{ type: "text", text: "42\n" }] };
34
- // invented: a real `bash: <cmd>: command not found` stderr line — the literal text only has to match
35
- // inferExit's recorded pattern (pi-pretty-tui/src/core/helpers.ts:44-49).
36
- const FAILED: ToolResult = { content: [{ type: "text", text: "bash: nope: command not found\n" }] };
37
-
38
- // Assembled so a future edit cannot satisfy the scan by quoting its own needle.
39
- const FORBIDDEN = ["registerTool", "promptSnippet", "promptGuidelines", "sdkTool", ".execute", "as unknown as"];
40
-
41
- type BashModel = Exclude<ReturnType<typeof bashRow.spec.build>, "unknown">;
42
-
43
- function view(expanded: boolean): RowView {
44
- return { expanded, isError: false, isPartial: false };
45
- }
46
-
47
- function parse(result: ToolResult, ctx: RenderContext): BashModel {
48
- const model = bashRow.spec.build(result, ctx);
49
- if (model === "unknown") throw new Error("a bash result must always parse");
50
- return model;
51
- }
52
-
53
- describe("bash row spec", () => {
54
- it("AC-1 (golden row): canonical collapsed and expanded output is byte-pinned", () => {
55
- // spec: project(build(CANONICAL), collapsed) -> "1 line · exit 0 · 1.2s · ctrl+o to expand"
56
- const ctx = testRenderContext({ args: { command: "npm test" }, state: { startedAt: 1_000, endedAt: 2_200 } });
57
- expect(bashRow.spec.buildCall({ command: "npm test" }, ctx).title).toBe("bash: npm test");
58
- const model = parse(CANONICAL, ctx);
59
- // fails_when: a plausible-looking row renders wrong data (count, status, duration)
60
- expect(bashRow.spec.project(model, view(false), MARKER)).toBe(MUTED("1 line · exit 0 · 1.2s · ctrl+o to expand"));
61
- expect(bashRow.spec.project(model, view(true), MARKER)).toBe(
62
- [MUTED("1 line · exit 0 · 1.2s"), "", ` ${OUTPUT("42")}`].join("\n"),
63
- );
64
- });
65
-
66
- it("AC-2 (error row): the error summarizes collapsed and opens to its body", () => {
67
- // spec: build(FAILED, isError) -> exit 1; project collapsed -> error on the summary line
68
- const ctx = testRenderContext({
69
- args: { command: "nope" },
70
- isError: true,
71
- state: { startedAt: 1_000, endedAt: 1_500 },
72
- });
73
- const model = parse(FAILED, ctx);
74
- expect(model.exitCode).toBe(1);
75
- // fails_when: errors render blank or crash
76
- expect(bashRow.spec.project(model, view(false), MARKER)).toBe(
77
- ERROR("bash: nope: command not found") + MUTED(" · 1 line · exit 1 · 500ms · ctrl+o to expand"),
78
- );
79
- expect(bashRow.spec.project(model, view(true), MARKER)).toBe(
80
- [MUTED("1 line · exit 1 · 500ms"), "", ` ${ERROR("bash: nope: command not found")}`].join("\n"),
81
- );
82
- });
83
-
84
- it("AC-3 (no execution ported): the source carries no registration or prompt text", () => {
85
- const source = readFileSync(fileURLToPath(new URL("./bash.ts", import.meta.url)), "utf8");
86
- // fails_when: the port smuggles execution back in
87
- for (const needle of FORBIDDEN) expect(source, needle).not.toContain(needle);
88
- expect(bashRow.name).toBe("bash");
89
- expect(bashRow.descriptor).toEqual({ key: "row.bash", name: "Bash row", defaultEnabled: true, settings: [] });
90
- });
91
-
92
- it("AC-4 (args gate): the call line is the tool name only until argsComplete", () => {
93
- const gated = testRenderContext({ args: {}, argsComplete: false });
94
- // fails_when: fabricated argument values reach the title
95
- expect(bashRow.spec.buildCall({}, gated).title).toBe("bash");
96
- expect(bashRow.spec.buildCall({ command: "rm -rf /" }, gated).title).toBe("bash");
97
- // the gate open with no command is still name-only, never an empty title
98
- expect(bashRow.spec.buildCall({}, testRenderContext({ args: {} })).title).toBe("bash");
99
- });
100
-
101
- it("AC-5 (paint-only color): every escape in a projected row is a paint marker", () => {
102
- const ctx = testRenderContext({ args: { command: "npm test" }, state: { startedAt: 1_000, endedAt: 2_200 } });
103
- const model = parse(CANONICAL, ctx);
104
- const collapsed = bashRow.spec.project(model, view(false), MARKER);
105
- const expanded = bashRow.spec.project(model, view(true), MARKER);
106
- // fails_when: a hardcoded color appears outside core/paint (marker + scan both catch it)
107
- for (const output of [collapsed, expanded]) assertNoRealEscapes(output);
108
- expect(collapsed).toContain("\u001b[38;5;202m");
109
- });
110
- });