@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.
- package/README.md +20 -6
- package/index.ts +29 -33
- package/package.json +6 -2
- package/src/core/paint.ts +17 -2
- package/src/core/types/host.ts +15 -18
- package/src/core/types.ts +0 -1
- package/src/renderers/content/artifacts/cards.ts +13 -6
- package/src/renderers/content/code-panel.ts +53 -31
- package/src/renderers/content/index.ts +70 -21
- package/src/renderers/content/json-panel.ts +9 -17
- package/src/renderers/content/link-paragraph.ts +153 -0
- package/src/renderers/content/table.ts +4 -1
- package/src/renderers/tool/runtime.ts +44 -5
- package/src/renderers/tool/specs/search.ts +18 -10
- package/src/renderers/tool/types.ts +6 -1
- package/src/commands/canvas.test.ts +0 -150
- package/src/commands/canvas.ts +0 -57
- 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,340 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* read.test.ts — the read spec at its seam: buildCall, build and project, with the marker paint double.
|
|
3
|
-
*
|
|
4
|
-
* Spec basis (from the captured host payloads below, not from the implementation):
|
|
5
|
-
* buildCall(incomplete args) -> { title: "read" } — the tool name only.
|
|
6
|
-
* build(complete text read) -> a file model: the cwd-relative label, the 1-indexed offset, the
|
|
7
|
-
* content's lines without the trailing terminator, no notice.
|
|
8
|
-
* build(offset/limit read) -> the same, with the host's own continuation notice kept apart
|
|
9
|
-
* from the lines, and the numbering starting at the offset.
|
|
10
|
-
* build(details.truncation) -> the body comes from the cut body in details, not from the text.
|
|
11
|
-
* build(no text block) -> "unknown" (the runtime draws its raw row).
|
|
12
|
-
* build(isError) -> the compacted failure body, labelled with the path.
|
|
13
|
-
* project(model, {expanded: false}) -> one summary line: header, range, count, expand hint.
|
|
14
|
-
* project(model, {expanded: true}) -> the header, the numbered lines, then the notice when one exists.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { readFileSync } from "node:fs";
|
|
18
|
-
import { describe, expect, it } from "vitest";
|
|
19
|
-
import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
|
|
20
|
-
import type { RenderContext, RowSpec, RowView, ToolResult } from "../../../core/types.ts";
|
|
21
|
-
import { readRow, readSpec } from "./read.ts";
|
|
22
|
-
|
|
23
|
-
const PAINT = markerPaint();
|
|
24
|
-
const COLLAPSED: RowView = { expanded: false, isError: false, isPartial: false };
|
|
25
|
-
const EXPANDED: RowView = { expanded: true, isError: false, isPartial: false };
|
|
26
|
-
|
|
27
|
-
/** The results below are captured read-tool payloads; only the harness varies between tests. */
|
|
28
|
-
const ctxOf = (args: Record<string, unknown>): RenderContext =>
|
|
29
|
-
testRenderContext({ args, cwd: "/work", argsComplete: true });
|
|
30
|
-
|
|
31
|
-
/** The spec's parse for one result, with "unknown" ruled out by the caller's fixture choice. */
|
|
32
|
-
function modelOf(result: ToolResult, ctx: RenderContext) {
|
|
33
|
-
const model = readSpec.build(result, ctx);
|
|
34
|
-
if (model === "unknown") throw new Error("fixture parsed as unknown");
|
|
35
|
-
return model;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Fixtures
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* recorded-from: executed @earendil-works/pi-coding-agent@1.0.4 `createReadTool(tmp).execute(…)` 2026-10-06 —
|
|
44
|
-
* a complete text read returns `{ content: [{ type: "text", text }] }` with no `details` key at all, and the
|
|
45
|
-
* text carries the file's trailing newline verbatim. The five file lines are the probe's own fixture file.
|
|
46
|
-
*/
|
|
47
|
-
const FILE_RESULT: ToolResult = {
|
|
48
|
-
content: [{ type: "text", text: "const a = 1;\nlet b = 2;\nfunction f(): number {\n\treturn a + b;\n}\n" }],
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* recorded-from: same probe, `{ path, offset: 3, limit: 2 }` — the host stops at the caller's limit and appends
|
|
53
|
-
* its own notice after a blank line, with NO details block (read.js:132-137). Only the path is replaced.
|
|
54
|
-
*/
|
|
55
|
-
const LIMIT_RESULT: ToolResult = {
|
|
56
|
-
content: [
|
|
57
|
-
{
|
|
58
|
-
type: "text",
|
|
59
|
-
text: "function f(): number {\n\treturn a + b;\n\n[2 more lines in file. Use offset=5 to continue.]",
|
|
60
|
-
},
|
|
61
|
-
],
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* recorded-from: same probe, a 4200-line file read from line 1 — the host cut it at the line limit and reported
|
|
66
|
-
* the cut in `details.truncation`, whose `content` is the body WITHOUT the notice. Body and the notice's range
|
|
67
|
-
* are scaled down (2000 -> 2 lines) so the fixture stays readable; totals, limits, flags and the notice wording
|
|
68
|
-
* are the capture's, and no key was dropped.
|
|
69
|
-
*/
|
|
70
|
-
const TRUNCATED_RESULT: ToolResult = {
|
|
71
|
-
content: [{ type: "text", text: "line 1\nline 2\n\n[Showing lines 1-2 of 4200. Use offset=3 to continue.]" }],
|
|
72
|
-
details: {
|
|
73
|
-
truncation: {
|
|
74
|
-
content: "line 1\nline 2",
|
|
75
|
-
truncated: true,
|
|
76
|
-
truncatedBy: "lines",
|
|
77
|
-
totalLines: 4200,
|
|
78
|
-
totalBytes: 40892,
|
|
79
|
-
outputLines: 2,
|
|
80
|
-
outputBytes: 12,
|
|
81
|
-
lastLinePartial: false,
|
|
82
|
-
firstLineExceedsLimit: false,
|
|
83
|
-
maxLines: 2000,
|
|
84
|
-
maxBytes: 51200,
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* recorded-from: ~/.agents/skills/typescript-coding/SKILL.md (read 2026-10-06), trimmed to its frontmatter and
|
|
91
|
-
* first heading — the YAML `name:` is what the skill header shows, and the closing `---` is what ends the scan.
|
|
92
|
-
*/
|
|
93
|
-
const SKILL_RESULT: ToolResult = {
|
|
94
|
-
content: [{ type: "text", text: "---\nname: typescript-coding\ndescription: Dev+test standard\n---\n\n# Mission\n" }],
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
// invented: the docs label keys on the path, not the body, so three stable lines are enough here.
|
|
98
|
-
const DOCS_RESULT: ToolResult = { content: [{ type: "text", text: "# Usage\n\nRun it.\n" }] };
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* recorded-from: the host's read tool threw `ENOENT: no such file or directory, access '<path>'` for a missing
|
|
102
|
-
* path (probe 2026-10-06); a failure reaches a renderer as a result with the message in a text block and the
|
|
103
|
-
* error flag set (agent-session.js:390-397). The path is the probe's own fixture name, cwd-relative.
|
|
104
|
-
*/
|
|
105
|
-
const ERROR_RESULT: ToolResult = {
|
|
106
|
-
content: [{ type: "text", text: "ENOENT: no such file or directory, access '/work/src/gone.ts'" }],
|
|
107
|
-
isError: true,
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
// invented: the multi-line failure shape the predecessor compacted (P helpers.ts:63-72) — consecutive blanks
|
|
111
|
-
// collapse to one, a single blank line survives.
|
|
112
|
-
const MULTILINE_ERROR: ToolResult = {
|
|
113
|
-
content: [{ type: "text", text: "Error: read failed\n\n\n at read (gone.ts:1)\n" }],
|
|
114
|
-
isError: true,
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
// invented: the message the collapse cap exists for; the path is long enough to pass the limit.
|
|
118
|
-
const LONG_ERROR = `ENOENT: no such file or directory, access '${"/nested".repeat(20)}'`;
|
|
119
|
-
|
|
120
|
-
// invented: a result whose only block is not text — the shape a future content kind would have (the declared
|
|
121
|
-
// ToolResultContent carries no image fields, so the block stays at its minimal shape).
|
|
122
|
-
const NO_TEXT_RESULT: ToolResult = { content: [{ type: "image" }] };
|
|
123
|
-
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
// AC-4 — the call line
|
|
126
|
-
// ---------------------------------------------------------------------------
|
|
127
|
-
|
|
128
|
-
describe("buildCall — the args gate", () => {
|
|
129
|
-
it("AC-4: an incomplete argument set yields the tool name only", () => {
|
|
130
|
-
const ctx = testRenderContext({ args: {}, argsComplete: false });
|
|
131
|
-
// fails_when: the call line invents an argument value before the host has delivered the arguments
|
|
132
|
-
expect(readSpec.buildCall({}, ctx).title).toBe("read");
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("AC-4: no argument value ever reaches the call line", () => {
|
|
136
|
-
const ctx = ctxOf({ path: "secret/gone.ts", offset: 7, limit: 2 });
|
|
137
|
-
// fails_when: the path or range is fabricated into the title (the summary line is where they belong)
|
|
138
|
-
expect(readSpec.buildCall({ path: "secret/gone.ts", offset: 7, limit: 2 }, ctx).title).toBe("read");
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
// AC-1 — the golden rows
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
|
|
146
|
-
describe("project — the file row", () => {
|
|
147
|
-
it("AC-1: a complete read summarises the file, and numbers it from line 1 when expanded", () => {
|
|
148
|
-
const model = modelOf(FILE_RESULT, ctxOf({ path: "src/small.ts" }));
|
|
149
|
-
// fails_when: the summary renders a wrong path or count, or the content loses the trailing newline's trim
|
|
150
|
-
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
151
|
-
`${PAINT.title("→ read")} ${PAINT.title("src/small.ts")} ${PAINT.muted("5 lines — ctrl+o to expand")}`,
|
|
152
|
-
);
|
|
153
|
-
expect(readSpec.project(model, EXPANDED, PAINT)).toBe(
|
|
154
|
-
[
|
|
155
|
-
`${PAINT.title("→ read")} ${PAINT.title("src/small.ts")}`,
|
|
156
|
-
`${PAINT.gutter(" 1")} ${PAINT.muted("│")} ${PAINT.output("const a = 1;")}`,
|
|
157
|
-
`${PAINT.gutter(" 2")} ${PAINT.muted("│")} ${PAINT.output("let b = 2;")}`,
|
|
158
|
-
`${PAINT.gutter(" 3")} ${PAINT.muted("│")} ${PAINT.output("function f(): number {")}`,
|
|
159
|
-
`${PAINT.gutter(" 4")} ${PAINT.muted("│")} ${PAINT.output("\treturn a + b;")}`,
|
|
160
|
-
`${PAINT.gutter(" 5")} ${PAINT.muted("│")} ${PAINT.output("}")}`,
|
|
161
|
-
].join("\n"),
|
|
162
|
-
);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("AC-1: an offset read numbers from the offset and keeps the host's notice out of the lines", () => {
|
|
166
|
-
const model = modelOf(LIMIT_RESULT, ctxOf({ path: "src/small.ts", offset: 3, limit: 2 }));
|
|
167
|
-
// fails_when: the numbering starts at 1 (the predecessor's 0-based leak), or the notice is numbered as a line
|
|
168
|
-
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
169
|
-
`${PAINT.title("→ read")} ${PAINT.title("src/small.ts")}${PAINT.muted(":3")} ${PAINT.muted("2 lines — ctrl+o to expand")}`,
|
|
170
|
-
);
|
|
171
|
-
expect(readSpec.project(model, EXPANDED, PAINT)).toBe(
|
|
172
|
-
[
|
|
173
|
-
`${PAINT.title("→ read")} ${PAINT.title("src/small.ts")}${PAINT.muted(":3")}`,
|
|
174
|
-
`${PAINT.gutter(" 3")} ${PAINT.muted("│")} ${PAINT.output("function f(): number {")}`,
|
|
175
|
-
`${PAINT.gutter(" 4")} ${PAINT.muted("│")} ${PAINT.output("\treturn a + b;")}`,
|
|
176
|
-
PAINT.warning("[2 more lines in file. Use offset=5 to continue.]"),
|
|
177
|
-
].join("\n"),
|
|
178
|
-
);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("AC-1: a truncated read takes its body from details and warns on the cut", () => {
|
|
182
|
-
const model = modelOf(TRUNCATED_RESULT, ctxOf({ path: "src/many.txt" }));
|
|
183
|
-
// fails_when: the displayed body comes from the notice-bearing text (so the notice becomes line 3), or
|
|
184
|
-
// a cut read renders silently
|
|
185
|
-
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
186
|
-
`${PAINT.title("→ read")} ${PAINT.title("src/many.txt")} ${PAINT.muted("2 lines — ctrl+o to expand")}`,
|
|
187
|
-
);
|
|
188
|
-
expect(readSpec.project(model, EXPANDED, PAINT)).toBe(
|
|
189
|
-
[
|
|
190
|
-
`${PAINT.title("→ read")} ${PAINT.title("src/many.txt")}`,
|
|
191
|
-
`${PAINT.gutter(" 1")} ${PAINT.muted("│")} ${PAINT.output("line 1")}`,
|
|
192
|
-
`${PAINT.gutter(" 2")} ${PAINT.muted("│")} ${PAINT.output("line 2")}`,
|
|
193
|
-
PAINT.warning("[Showing lines 1-2 of 4200. Use offset=3 to continue.]"),
|
|
194
|
-
].join("\n"),
|
|
195
|
-
);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("AC-1: a SKILL.md read draws the skill header from the frontmatter name", () => {
|
|
199
|
-
const model = modelOf(SKILL_RESULT, ctxOf({ path: ".agents/skills/typescript-coding/SKILL.md" }));
|
|
200
|
-
// fails_when: the frontmatter name is missed (a directory name or a path appears instead), or the expanded
|
|
201
|
-
// header loses its collapse hint
|
|
202
|
-
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
203
|
-
`${PAINT.accent("[skill]")} ${PAINT.title("typescript-coding")} ${PAINT.muted("6 lines — ctrl+o to expand")}`,
|
|
204
|
-
);
|
|
205
|
-
const expanded = readSpec.project(model, EXPANDED, PAINT).split("\n");
|
|
206
|
-
expect(expanded[0]).toBe(
|
|
207
|
-
`${PAINT.accent("[skill]")} ${PAINT.title("typescript-coding")} ${PAINT.muted("ctrl+o to collapse")}`,
|
|
208
|
-
);
|
|
209
|
-
expect(expanded.slice(1)).toEqual([
|
|
210
|
-
`${PAINT.gutter(" 1")} ${PAINT.muted("│")} ${PAINT.output("---")}`,
|
|
211
|
-
`${PAINT.gutter(" 2")} ${PAINT.muted("│")} ${PAINT.output("name: typescript-coding")}`,
|
|
212
|
-
`${PAINT.gutter(" 3")} ${PAINT.muted("│")} ${PAINT.output("description: Dev+test standard")}`,
|
|
213
|
-
`${PAINT.gutter(" 4")} ${PAINT.muted("│")} ${PAINT.output("---")}`,
|
|
214
|
-
`${PAINT.gutter(" 5")} ${PAINT.muted("│")} ${PAINT.output("")}`,
|
|
215
|
-
`${PAINT.gutter(" 6")} ${PAINT.muted("│")} ${PAINT.output("# Mission")}`,
|
|
216
|
-
]);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("AC-1: only docs paths read as documents", () => {
|
|
220
|
-
const docs = modelOf(DOCS_RESULT, ctxOf({ path: "docs/usage.md" }));
|
|
221
|
-
// fails_when: every path is labelled a docs row, or a docs path is not
|
|
222
|
-
expect(readSpec.project(docs, COLLAPSED, PAINT)).toBe(
|
|
223
|
-
`${PAINT.title("→ read docs")} ${PAINT.title("docs/usage.md")} ${PAINT.muted("3 lines — ctrl+o to expand")}`,
|
|
224
|
-
);
|
|
225
|
-
const other = modelOf(DOCS_RESULT, ctxOf({ path: "src/notes.md" }));
|
|
226
|
-
expect(readSpec.project(other, COLLAPSED, PAINT)).toBe(
|
|
227
|
-
`${PAINT.title("→ read")} ${PAINT.title("src/notes.md")} ${PAINT.muted("3 lines — ctrl+o to expand")}`,
|
|
228
|
-
);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("AC-1: the count follows the content, including the empty and single-line cases", () => {
|
|
232
|
-
const empty = modelOf({ content: [{ type: "text", text: "" }] }, ctxOf({ path: "src/empty.ts" }));
|
|
233
|
-
const single = modelOf({ content: [{ type: "text", text: "only\n" }] }, ctxOf({ path: "src/one.ts" }));
|
|
234
|
-
// fails_when: an empty file reports a phantom line, or a one-line file reads "1 lines"
|
|
235
|
-
expect(readSpec.project(empty, COLLAPSED, PAINT)).toBe(
|
|
236
|
-
`${PAINT.title("→ read")} ${PAINT.title("src/empty.ts")} ${PAINT.muted("0 lines — ctrl+o to expand")}`,
|
|
237
|
-
);
|
|
238
|
-
expect(readSpec.project(single, COLLAPSED, PAINT)).toBe(
|
|
239
|
-
`${PAINT.title("→ read")} ${PAINT.title("src/one.ts")} ${PAINT.muted("1 line — ctrl+o to expand")}`,
|
|
240
|
-
);
|
|
241
|
-
});
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
// ---------------------------------------------------------------------------
|
|
245
|
-
// AC-2 — error rows
|
|
246
|
-
// ---------------------------------------------------------------------------
|
|
247
|
-
|
|
248
|
-
describe("project — the error row", () => {
|
|
249
|
-
it("AC-2: a failed read carries its message on the collapsed line and its body when expanded", () => {
|
|
250
|
-
const model = modelOf(ERROR_RESULT, ctxOf({ path: "src/gone.ts" }));
|
|
251
|
-
// fails_when: an error row renders blank, loses the path, or shows the whole body collapsed
|
|
252
|
-
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
253
|
-
`${PAINT.error("→ read")} ${PAINT.title("src/gone.ts")} ${PAINT.error("ENOENT: no such file or directory, access '/work/src/gone.ts'")}`,
|
|
254
|
-
);
|
|
255
|
-
expect(readSpec.project(model, EXPANDED, PAINT)).toBe(
|
|
256
|
-
PAINT.error("ENOENT: no such file or directory, access '/work/src/gone.ts'"),
|
|
257
|
-
);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it("AC-2: a multi-line failure compacts to one blank separator and keeps a blank line blank", () => {
|
|
261
|
-
const model = modelOf(MULTILINE_ERROR, ctxOf({ path: "src/gone.ts" }));
|
|
262
|
-
// fails_when: the collapsed line shows a blank first line, or the body paints blank lines
|
|
263
|
-
expect(readSpec.project(model, COLLAPSED, PAINT)).toBe(
|
|
264
|
-
`${PAINT.error("→ read")} ${PAINT.title("src/gone.ts")} ${PAINT.error("Error: read failed")}`,
|
|
265
|
-
);
|
|
266
|
-
expect(readSpec.project(model, EXPANDED, PAINT)).toBe(
|
|
267
|
-
[PAINT.error("Error: read failed"), "", PAINT.error(" at read (gone.ts:1)")].join("\n"),
|
|
268
|
-
);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it("AC-2: a failure with no body still renders a row, and a long message stays one line", () => {
|
|
272
|
-
const empty = modelOf({ content: [], isError: true }, ctxOf({ path: "src/gone.ts" }));
|
|
273
|
-
const long = modelOf(
|
|
274
|
-
{ content: [{ type: "text", text: LONG_ERROR }], isError: true },
|
|
275
|
-
ctxOf({ path: "src/gone.ts" }),
|
|
276
|
-
);
|
|
277
|
-
// fails_when: an empty error body renders a driverless blank line, or the collapsed line grows past one line
|
|
278
|
-
expect(readSpec.project(empty, COLLAPSED, PAINT)).toBe(
|
|
279
|
-
`${PAINT.error("→ read")} ${PAINT.title("src/gone.ts")} ${PAINT.error("failed")}`,
|
|
280
|
-
);
|
|
281
|
-
expect(readSpec.project(long, COLLAPSED, PAINT)).toBe(
|
|
282
|
-
`${PAINT.error("→ read")} ${PAINT.title("src/gone.ts")} ${PAINT.error(`${LONG_ERROR.slice(0, 119)}…`)}`,
|
|
283
|
-
);
|
|
284
|
-
});
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
// Degradation and the seam's structural contract
|
|
289
|
-
// ---------------------------------------------------------------------------
|
|
290
|
-
|
|
291
|
-
describe("the read spec's degradation and exports", () => {
|
|
292
|
-
it("degrades to unknown when the result carries no text block", () => {
|
|
293
|
-
// fails_when: a content kind the spec cannot read is drawn as an empty file row instead of the raw row
|
|
294
|
-
expect(readSpec.build(NO_TEXT_RESULT, ctxOf({ path: "shot.png" }))).toBe("unknown");
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it("AC-3: the source carries no execution, registration or prompt text", () => {
|
|
298
|
-
const source = readFileSync(new URL("./read.ts", import.meta.url), "utf8");
|
|
299
|
-
// fails_when: an execute wrapper, a tool registration, or model-facing prompt text comes back in
|
|
300
|
-
for (const pattern of [
|
|
301
|
-
/registerTool/,
|
|
302
|
-
/\bexecute\s*[:(=]/,
|
|
303
|
-
/promptSnippet|promptGuidelines/,
|
|
304
|
-
/\bdescription\s*:/,
|
|
305
|
-
/\bparameters\s*:/,
|
|
306
|
-
/as unknown as/,
|
|
307
|
-
]) {
|
|
308
|
-
expect(source).not.toMatch(pattern);
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it("AC-5: every escape a row draws is a paint marker", () => {
|
|
313
|
-
const ctx = ctxOf({ path: "src/small.ts" });
|
|
314
|
-
const outputs = [
|
|
315
|
-
readSpec.project(modelOf(FILE_RESULT, ctx), COLLAPSED, PAINT),
|
|
316
|
-
readSpec.project(modelOf(FILE_RESULT, ctx), EXPANDED, PAINT),
|
|
317
|
-
readSpec.project(modelOf(SKILL_RESULT, ctxOf({ path: "s/SKILL.md" })), COLLAPSED, PAINT),
|
|
318
|
-
readSpec.project(modelOf(ERROR_RESULT, ctxOf({ path: "src/gone.ts" })), COLLAPSED, PAINT),
|
|
319
|
-
readSpec.project(modelOf(ERROR_RESULT, ctxOf({ path: "src/gone.ts" })), EXPANDED, PAINT),
|
|
320
|
-
];
|
|
321
|
-
for (const output of outputs) {
|
|
322
|
-
// fails_when: a hardcoded color or reset leaks past paint
|
|
323
|
-
assertNoRealEscapes(output);
|
|
324
|
-
// Non-vacuity: a row always carries the marker paint's own codes (200..231), never an empty string.
|
|
325
|
-
expect(output).toMatch(/\[38;5;2[0-3]\dm/);
|
|
326
|
-
}
|
|
327
|
-
const source = readFileSync(new URL("./read.ts", import.meta.url), "utf8");
|
|
328
|
-
// fails_when: an escape literal appears in the source, painting around the paint seam
|
|
329
|
-
expect(source).not.toMatch(/\\x1b|\\u001b|\\033|\[38;5;/);
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it("exports the record T-28 composes, and erases into the resolver's unknown-typed table", () => {
|
|
333
|
-
const table: Record<string, RowSpec> = { [readRow.name]: readRow.spec };
|
|
334
|
-
// fails_when: the table key, the descriptor or the RowSpec<M> erasure stops matching the seam map
|
|
335
|
-
expect(readRow.name).toBe("read");
|
|
336
|
-
expect(readRow.spec).toBe(readSpec);
|
|
337
|
-
expect(readRow.descriptor).toEqual({ key: "row.read", name: "Read row", defaultEnabled: true, settings: [] });
|
|
338
|
-
expect(table.read).toBe(readSpec);
|
|
339
|
-
});
|
|
340
|
-
});
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
// Fixture provenance:
|
|
2
|
-
// - pi-fff fixtures (GREP_FFF, GREP_FFF_DETAILS, FUZZY, FIND_WEAK, D0..D2, and the parity bodies):
|
|
3
|
-
// invented — synthesized from pi-fff 0.11.0's formatting code via the DP4 dossier
|
|
4
|
-
// (system-analysis/working/dossier-dp4-search-fingerprints.md, 2026-10-06); no live capture exists.
|
|
5
|
-
// - host fixtures (HOST_GREP): invented — synthesized from host pi 1.0.4 grep.js:206 (DP4 raw-data).
|
|
6
|
-
// - the parity bodies are the format examples the predecessor parser pinned at
|
|
7
|
-
// pi-pretty-tui/src/core/fff-format.ts:12-18,31-41; it shipped no test file (verified — nothing in
|
|
8
|
-
// pi-pretty-tui imports parseGrepOutput/parseFindOutput), so AC-6 ports those expectations as fixtures.
|
|
9
|
-
|
|
10
|
-
// shape: none — test module: every behavior is asserted only through the exported SEARCH_SPECS /
|
|
11
|
-
// createSearchSpec seam.
|
|
12
|
-
import { describe, expect, test } from "vitest";
|
|
13
|
-
import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
|
|
14
|
-
import { createSearchSpec, SEARCH_SPECS, SEARCH_TOOL_NAMES, type SearchModel, type SearchToolName } from "./search.ts";
|
|
15
|
-
|
|
16
|
-
const PAINT = markerPaint();
|
|
17
|
-
const COLLAPSED = { expanded: false, isError: false, isPartial: false };
|
|
18
|
-
const EXPANDED = { expanded: true, isError: false, isPartial: false };
|
|
19
|
-
|
|
20
|
-
type MatchesModel = Extract<SearchModel, { kind: "matches" }>;
|
|
21
|
-
type FilesModel = Extract<SearchModel, { kind: "files" }>;
|
|
22
|
-
type EmptyModel = Extract<SearchModel, { kind: "empty" }>;
|
|
23
|
-
|
|
24
|
-
function parse(name: SearchToolName, text: string, details: unknown, args: Record<string, unknown> = {}) {
|
|
25
|
-
const spec = createSearchSpec(name);
|
|
26
|
-
const row = testRenderContext({ args });
|
|
27
|
-
const invocation = row.result({ content: [{ type: "text", text }], details });
|
|
28
|
-
return { spec, model: spec.build(invocation.result, invocation.ctx) };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function narrowMatches(model: SearchModel | "unknown"): MatchesModel {
|
|
32
|
-
if (model === "unknown" || model.kind !== "matches") {
|
|
33
|
-
throw new Error(`expected a matches model, got ${JSON.stringify(model)}`);
|
|
34
|
-
}
|
|
35
|
-
return model;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function narrowFiles(model: SearchModel | "unknown"): FilesModel {
|
|
39
|
-
if (model === "unknown" || model.kind !== "files") {
|
|
40
|
-
throw new Error(`expected a files model, got ${JSON.stringify(model)}`);
|
|
41
|
-
}
|
|
42
|
-
return model;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function narrowEmpty(model: SearchModel | "unknown"): EmptyModel {
|
|
46
|
-
if (model === "unknown" || model.kind !== "empty") {
|
|
47
|
-
throw new Error(`expected an empty model, got ${JSON.stringify(model)}`);
|
|
48
|
-
}
|
|
49
|
-
return model;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const GREP_FFF = [
|
|
53
|
-
"src/payments.ts [modified in git]",
|
|
54
|
-
" 1: export interface PaymentRequest {",
|
|
55
|
-
" 3: route(...)",
|
|
56
|
-
"",
|
|
57
|
-
"src/other.ts [often touched file]",
|
|
58
|
-
" 7: const total = sum(payments)",
|
|
59
|
-
].join("\n");
|
|
60
|
-
const GREP_FFF_DETAILS = { totalMatched: 3, totalFiles: 2 };
|
|
61
|
-
|
|
62
|
-
const HOST_GREP = ["src/payments.ts:1: export interface PaymentRequest {", "src/payments.ts:3: route(...)"].join("\n");
|
|
63
|
-
|
|
64
|
-
const FUZZY = [
|
|
65
|
-
"[0 exact matches. Maybe you meant this?]",
|
|
66
|
-
"src/payments.ts",
|
|
67
|
-
" 1: export interface PaymentRequest {",
|
|
68
|
-
].join("\n");
|
|
69
|
-
|
|
70
|
-
const FIND_WEAK = [
|
|
71
|
-
"src/pay.ts",
|
|
72
|
-
"src/payments.ts",
|
|
73
|
-
"",
|
|
74
|
-
'[Query "p" produced only weak scattered fuzzy matches. Output capped at 5/30.]',
|
|
75
|
-
].join("\n");
|
|
76
|
-
|
|
77
|
-
// The totals pi-fff always attaches to the shapes above (DP4: FFF:1126-1131).
|
|
78
|
-
const D0 = { totalMatched: 0, totalFiles: 0 };
|
|
79
|
-
const D1 = { totalMatched: 1, totalFiles: 1 };
|
|
80
|
-
const D2 = { totalMatched: 2, totalFiles: 2 };
|
|
81
|
-
|
|
82
|
-
describe("renderers/tool/specs/search — pi-fff fingerprint rows", () => {
|
|
83
|
-
test("AC-1 a pi-fff grep result renders a grouped row with the file count and highlighted matches", () => {
|
|
84
|
-
// fails_when: raw text shown for a pi-fff result.
|
|
85
|
-
const { spec, model } = parse("ffgrep", GREP_FFF, GREP_FFF_DETAILS, { pattern: "route" });
|
|
86
|
-
const matches = narrowMatches(model);
|
|
87
|
-
expect(matches.matchCount).toBe(3);
|
|
88
|
-
expect(matches.blocks).toHaveLength(2);
|
|
89
|
-
expect(matches.blocks[0]?.path).toBe("src/payments.ts");
|
|
90
|
-
expect(matches.blocks[0]?.annotation).toBe("modified in git");
|
|
91
|
-
|
|
92
|
-
expect(spec.project(matches, COLLAPSED, PAINT)).toBe(PAINT.muted("3 matches in 2 files"));
|
|
93
|
-
|
|
94
|
-
const expanded = spec.project(matches, EXPANDED, PAINT);
|
|
95
|
-
expect(expanded).toContain(PAINT.muted("3 matches in 2 files"));
|
|
96
|
-
expect(expanded).toContain(PAINT.accent("src/payments.ts"));
|
|
97
|
-
expect(expanded).toContain(PAINT.muted("[modified in git]"));
|
|
98
|
-
expect(expanded).toContain(PAINT.match("route"));
|
|
99
|
-
assertNoRealEscapes(expanded);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("AC-2 host builtin output is not pi-fff, so build returns unknown", () => {
|
|
103
|
-
// fails_when: host output gets pi-fff styling (a plausible-but-wrong row).
|
|
104
|
-
expect(parse("grep", HOST_GREP, undefined).model).toBe("unknown");
|
|
105
|
-
expect(parse("find", "src/payments.ts\ntest/payments.test.ts", undefined).model).toBe("unknown");
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("AC-3 the empty strings separate on details presence only", () => {
|
|
109
|
-
// fails_when: both render the same way.
|
|
110
|
-
const styled = narrowEmpty(parse("ffgrep", "No matches found", D0).model);
|
|
111
|
-
expect(styled.noun).toBe("matches");
|
|
112
|
-
const emptyRow = parse("ffgrep", "No matches found", D0);
|
|
113
|
-
expect(emptyRow.spec.project(styled, COLLAPSED, PAINT)).toBe(PAINT.muted("0 matches"));
|
|
114
|
-
expect(parse("ffgrep", "No matches found", undefined).model).toBe("unknown");
|
|
115
|
-
|
|
116
|
-
const files = narrowEmpty(parse("fffind", "No files found matching pattern", D0).model);
|
|
117
|
-
expect(files.noun).toBe("files");
|
|
118
|
-
expect(parse("fffind", "No files found matching pattern", undefined).model).toBe("unknown");
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("AC-4 the fuzzy prefix is a notice, never a phantom file header", () => {
|
|
122
|
-
// fails_when: a phantom header inflates the file count (the ported bug).
|
|
123
|
-
const matches = narrowMatches(parse("ffgrep", FUZZY, D1).model);
|
|
124
|
-
expect(matches.blocks).toHaveLength(1);
|
|
125
|
-
expect(matches.blocks[0]?.path).toBe("src/payments.ts");
|
|
126
|
-
expect(matches.notices).toEqual(["0 exact matches. Maybe you meant this?"]);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test("AC-5 a multi-sentence notice stays one notice", () => {
|
|
130
|
-
// fails_when: the weak-match notice fragments on its internal ". ".
|
|
131
|
-
const files = narrowFiles(parse("fffind", FIND_WEAK, { totalMatched: 30, totalFiles: 30 }).model);
|
|
132
|
-
expect(files.files).toHaveLength(2);
|
|
133
|
-
expect(files.notices).toEqual(['Query "p" produced only weak scattered fuzzy matches. Output capped at 5/30.']);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("AC-1 (find) a pi-fff find result groups by directory with counts and annotations", () => {
|
|
137
|
-
// fails_when: entries render ungrouped, or a count or annotation is wrong.
|
|
138
|
-
const body = ["src/payments.ts", "test/payments.test.ts [modified in git]", "src/api/payments.ts"].join("\n");
|
|
139
|
-
const { spec, model } = parse("fffind", body, { totalMatched: 3, totalFiles: 3 });
|
|
140
|
-
const files = narrowFiles(model);
|
|
141
|
-
expect(spec.project(files, COLLAPSED, PAINT)).toBe(PAINT.muted("3 files"));
|
|
142
|
-
|
|
143
|
-
const expanded = spec.project(files, EXPANDED, PAINT);
|
|
144
|
-
expect(expanded).toContain(PAINT.accent("src/"));
|
|
145
|
-
expect(expanded).toContain(PAINT.accent("src/api/"));
|
|
146
|
-
expect(expanded).toContain(PAINT.accent("test/"));
|
|
147
|
-
expect(expanded).toContain(PAINT.output("payments.test.ts"));
|
|
148
|
-
expect(expanded).toContain(PAINT.muted("[modified in git]"));
|
|
149
|
-
assertNoRealEscapes(expanded);
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
describe("renderers/tool/specs/search — AC-6 predecessor fff-format parity", () => {
|
|
154
|
-
test("find: bare path list, annotation suffix, empty string", () => {
|
|
155
|
-
expect(narrowFiles(parse("fffind", "src/payments.ts\ntest/payments.test.ts", D2).model).files).toHaveLength(2);
|
|
156
|
-
const annotated = narrowFiles(parse("fffind", "src/payments.ts [modified in git]", D1).model);
|
|
157
|
-
expect(annotated.files[0]).toEqual({ path: "src/payments.ts", annotation: "modified in git" });
|
|
158
|
-
expect(narrowEmpty(parse("fffind", "No files found matching pattern", D0).model).noun).toBe("files");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test("grep: numbered match and context lines, file blocks, empty string", () => {
|
|
162
|
-
const body = ["src/payments.ts", " 2- export interface Payment {", " 3: route(...)", " 4- }"].join("\n");
|
|
163
|
-
const matches = narrowMatches(parse("ffgrep", body, D1).model);
|
|
164
|
-
expect(matches.matchCount).toBe(1);
|
|
165
|
-
expect(matches.blocks[0]?.lines.map((l) => [l.lineNo, l.isMatch])).toEqual([
|
|
166
|
-
[2, false],
|
|
167
|
-
[3, true],
|
|
168
|
-
[4, false],
|
|
169
|
-
]);
|
|
170
|
-
expect(narrowEmpty(parse("ffgrep", "No matches found", D0).model).noun).toBe("matches");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("a notice trailer surfaces as one notice", () => {
|
|
174
|
-
const body = ["src/payments.ts", " 1: x", "", '[Continue with cursor="fff_c1" to continue]'].join("\n");
|
|
175
|
-
expect(narrowMatches(parse("ffgrep", body, D1).model).notices).toEqual([
|
|
176
|
-
'Continue with cursor="fff_c1" to continue',
|
|
177
|
-
]);
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
describe("renderers/tool/specs/search — name binding", () => {
|
|
182
|
-
test("SEARCH_SPECS carries exactly the four names, each buildCall titles its own name", () => {
|
|
183
|
-
expect(SEARCH_SPECS.map((record) => record.name)).toEqual(["find", "grep", "fffind", "ffgrep"]);
|
|
184
|
-
expect(SEARCH_TOOL_NAMES).toEqual(["find", "grep", "fffind", "ffgrep"]);
|
|
185
|
-
const row = testRenderContext({ args: { pattern: "x", path: "src" } });
|
|
186
|
-
for (const record of SEARCH_SPECS) {
|
|
187
|
-
expect(record.spec.buildCall(row.args ?? {}, row).title.startsWith(record.name)).toBe(true);
|
|
188
|
-
}
|
|
189
|
-
expect(new Set(SEARCH_SPECS.map((record) => record.descriptor.key))).toEqual(new Set(["row.search"]));
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test("args gate: buildCall shows the name only until argsComplete", () => {
|
|
193
|
-
// fails_when: a partial argument value is rendered.
|
|
194
|
-
const row = testRenderContext({ args: {}, argsComplete: false });
|
|
195
|
-
expect(createSearchSpec("fffind").buildCall({}, row).title).toBe("fffind");
|
|
196
|
-
});
|
|
197
|
-
});
|