@pi-archimedes/core 2.10.4 → 2.11.1
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 +3 -3
- package/package.json +7 -4
- package/src/bridge/events.test.ts +438 -0
- package/src/bridge/events.ts +98 -39
- package/src/bridge/index.test.ts +50 -22
- package/src/bridge/index.ts +18 -5
- package/src/bridge/self-usage.test.ts +90 -12
- package/src/bridge/self-usage.ts +19 -6
- package/src/chrome-old-host.test.ts +84 -0
- package/src/chrome.test.ts +435 -2
- package/src/chrome.ts +138 -20
- package/src/events-host-bus.test.ts +113 -0
- package/src/events.test.ts +85 -0
- package/src/events.ts +107 -0
- package/src/index.test.ts +3 -9
- package/src/index.ts +5 -5
- package/src/log-discipline.test.ts +187 -0
- package/src/log.test.ts +172 -0
- package/src/log.ts +69 -0
- package/src/overlay.test.ts +178 -6
- package/src/overlay.ts +46 -62
- package/src/pi-support.test.ts +444 -0
- package/src/pi-support.ts +190 -0
- package/src/profiler.ts +14 -5
- package/src/screen-mode-discipline.test.ts +214 -0
- package/src/screen-mode.test.ts +48 -0
- package/src/screen-mode.ts +45 -0
- package/src/settings-io.test.ts +174 -1
- package/src/settings-io.ts +54 -0
- package/src/text.test.ts +58 -0
- package/src/text.ts +21 -20
- package/src/tool-render.test.ts +48 -0
- package/src/tool-render.ts +44 -0
- package/src/bus.test.ts +0 -154
- package/src/bus.ts +0 -137
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
// ── The rule ────────────────────────────────────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// Nothing in the suite writes to the console. Two independent reasons:
|
|
9
|
+
//
|
|
10
|
+
// 1. On fullscreen screen a stray `console.*` lands inside the differential
|
|
11
|
+
// frame and smears the viewport until the next full redraw (on regular
|
|
12
|
+
// screen it scrolls away unseen, which is why the habit survived so long).
|
|
13
|
+
// 2. Inside a subagent child every write is silently discarded:
|
|
14
|
+
// `subagent/src/stream.ts` drops any non-JSON stdout line (`catch { return }`,
|
|
15
|
+
// a guard for pi's own startup messages), so a warning vanishes exactly when
|
|
16
|
+
// someone is debugging a child run.
|
|
17
|
+
//
|
|
18
|
+
// Diagnostics go to `diag()` in `log.ts` (a file, only when PI_ARCHIMEDES_LOG
|
|
19
|
+
// names one) and messages the user must see go to the notification surface
|
|
20
|
+
// (`ctx.ui.notify`). Both are safe on every screen and in a child process.
|
|
21
|
+
//
|
|
22
|
+
// The pattern demands the open parenthesis, so it is narrower than a loose
|
|
23
|
+
// `grep console.log`: a monkey-patch site (`console.log = (…) => {…}`,
|
|
24
|
+
// `let orig: typeof console.log`) is not a write and is not reported here.
|
|
25
|
+
//
|
|
26
|
+
// Every *method* is covered, not just log/warn/error. `console.info` and
|
|
27
|
+
// `console.debug` land on stdout/stderr exactly like `console.log`, and
|
|
28
|
+
// `console.trace`/`dir`/`table`/`group`/`count`/`clear` write too (and `clear`
|
|
29
|
+
// wipes the frame outright), so naming a subset would leave a guard that stays
|
|
30
|
+
// green while a future write corrupts the display.
|
|
31
|
+
const PATTERN = /console\.[a-zA-Z]+\(/;
|
|
32
|
+
|
|
33
|
+
interface AllowEntry {
|
|
34
|
+
readonly file: string;
|
|
35
|
+
readonly reason: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Files exempt from the rule, whole-file, each with the reason it is tolerated
|
|
40
|
+
* and when that goes away. A new violation in an exempt file still shows up in
|
|
41
|
+
* the census, so this list can only ever be argued down, not silently widened.
|
|
42
|
+
*/
|
|
43
|
+
const ALLOW_LIST: readonly AllowEntry[] = [
|
|
44
|
+
{
|
|
45
|
+
file: "packages/core/src/log.ts",
|
|
46
|
+
reason:
|
|
47
|
+
"The diagnostics sink itself. Exempt by construction: it is the one module allowed to own output plumbing, and a last-resort fallback there must not trip the guard. It writes files, never the console.",
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// ── Walking the tree ────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** Repo root, resolved from this file (`packages/core/src` → repo root). */
|
|
54
|
+
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
|
55
|
+
|
|
56
|
+
/** Every `packages/<name>/src` directory, plus `meta/src`. */
|
|
57
|
+
function sourceDirs(): string[] {
|
|
58
|
+
const dirs: string[] = [];
|
|
59
|
+
for (const entry of readdirSync(join(REPO_ROOT, "packages"), { withFileTypes: true })) {
|
|
60
|
+
if (!entry.isDirectory()) continue;
|
|
61
|
+
const src = join(REPO_ROOT, "packages", entry.name, "src");
|
|
62
|
+
if (existsSync(src)) dirs.push(src);
|
|
63
|
+
}
|
|
64
|
+
const metaSrc = join(REPO_ROOT, "meta", "src");
|
|
65
|
+
if (existsSync(metaSrc)) dirs.push(metaSrc);
|
|
66
|
+
return dirs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Source files under those trees; tests are excluded (they spy on the console). */
|
|
70
|
+
function collectSourceFiles(): string[] {
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
const walk = (dir: string): void => {
|
|
73
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
74
|
+
const full = join(dir, entry.name);
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
if (entry.name !== "node_modules") walk(full);
|
|
77
|
+
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
|
|
78
|
+
out.push(relative(REPO_ROOT, full).split(/[\\/]/).join("/"));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
for (const dir of sourceDirs()) walk(dir);
|
|
83
|
+
return out.sort();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Hit {
|
|
87
|
+
file: string;
|
|
88
|
+
line: number;
|
|
89
|
+
text: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findViolations(files: readonly string[]): Hit[] {
|
|
93
|
+
const hits: Hit[] = [];
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
const lines = readFileSync(join(REPO_ROOT, file), "utf8").split("\n");
|
|
96
|
+
lines.forEach((text, i) => {
|
|
97
|
+
if (PATTERN.test(text)) hits.push({ file, line: i + 1, text: text.trim() });
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return hits;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const FILES = collectSourceFiles();
|
|
104
|
+
const HITS = findViolations(FILES);
|
|
105
|
+
const ALLOWED = new Set(ALLOW_LIST.map((e) => e.file));
|
|
106
|
+
const VIOLATIONS = HITS.filter((h) => !ALLOWED.has(h.file));
|
|
107
|
+
|
|
108
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe("console discipline: no console writes from render or event paths", () => {
|
|
111
|
+
it("walks the whole suite, not an empty file set", () => {
|
|
112
|
+
expect(existsSync(join(REPO_ROOT, "pnpm-workspace.yaml"))).toBe(true);
|
|
113
|
+
expect(FILES.length).toBeGreaterThan(50);
|
|
114
|
+
// The trees the guard covers, and the shape of what it skips.
|
|
115
|
+
for (const expected of [
|
|
116
|
+
"packages/core/src/log.ts",
|
|
117
|
+
"packages/ui/src/startup/index.ts",
|
|
118
|
+
"meta/src/index.ts",
|
|
119
|
+
]) {
|
|
120
|
+
expect(FILES).toContain(expected);
|
|
121
|
+
}
|
|
122
|
+
expect(FILES.filter((f) => f.endsWith(".test.ts"))).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("reports no console writes outside the allow-list", () => {
|
|
126
|
+
expect(VIOLATIONS.map((v) => `${v.file}:${v.line} ${v.text}`)).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("matches any console write and not a patch site (the guard is not a loose grep)", () => {
|
|
130
|
+
const offenders = [
|
|
131
|
+
'console.log("x");',
|
|
132
|
+
' console.warn(`y`);',
|
|
133
|
+
"console.error(err);",
|
|
134
|
+
"if (bad) console.error('nope');",
|
|
135
|
+
// The rest of the console writes to the terminal just as hard. `info` and
|
|
136
|
+
// `debug` used to be *pinned* as allowed here, which encoded the hole in
|
|
137
|
+
// the guard rather than the rule; the suite has none today, so widening
|
|
138
|
+
// costs nothing and closes it.
|
|
139
|
+
'console.info("x");',
|
|
140
|
+
'console.debug("x");',
|
|
141
|
+
"console.trace('here');",
|
|
142
|
+
'console.dir(obj);',
|
|
143
|
+
'console.table(rows);',
|
|
144
|
+
'console.group("g");',
|
|
145
|
+
"console.count();",
|
|
146
|
+
"console.clear();",
|
|
147
|
+
];
|
|
148
|
+
for (const line of offenders) {
|
|
149
|
+
expect(PATTERN.test(line), `expected the guard to reject: ${line}`).toBe(true);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Assignments, type references and prose about the console are not writes.
|
|
153
|
+
// This is exactly the difference between the guard and the census grep, and
|
|
154
|
+
// it is why the startup console patch this file used to exempt needed one
|
|
155
|
+
// entry for its single real write while its patch plumbing did not count.
|
|
156
|
+
const fine = [
|
|
157
|
+
"console.log = (...args) => {};",
|
|
158
|
+
"let _origLog: typeof console.log | undefined;",
|
|
159
|
+
"_origLog = console.log;",
|
|
160
|
+
"console.log = _origLog;",
|
|
161
|
+
"// Patch console.log for model scope capture",
|
|
162
|
+
"const orig = Object.getOwnPropertyDescriptor(console, 'log');",
|
|
163
|
+
];
|
|
164
|
+
for (const line of fine) {
|
|
165
|
+
expect(PATTERN.test(line), `expected no rule to reject: ${line}`).toBe(false);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("keeps the allow-list as small as the work that removes it", () => {
|
|
170
|
+
const sink = "packages/core/src/log.ts";
|
|
171
|
+
for (const entry of ALLOW_LIST) {
|
|
172
|
+
expect(FILES, `allow-listed file is no longer scanned: ${entry.file}`).toContain(entry.file);
|
|
173
|
+
expect(entry.reason.length).toBeGreaterThan(0);
|
|
174
|
+
// Anything exempted beyond the sink itself must name the task that
|
|
175
|
+
// removes it — otherwise the exemption is permanent by accident.
|
|
176
|
+
if (entry.file !== sink) expect(entry.reason).toMatch(/Task \d+/);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Nothing outside the sink has a console write to excuse: the one file that
|
|
180
|
+
// did (the startup `console.log` scrape) is gone, so the sink entry is
|
|
181
|
+
// exempt by construction alone. Pin that, or the list grows a member nobody
|
|
182
|
+
// remembers granting.
|
|
183
|
+
const exercised = new Set(HITS.map((h) => h.file));
|
|
184
|
+
expect([...exercised]).toEqual([]);
|
|
185
|
+
expect(exercised.has(sink)).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
package/src/log.test.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
import { diag } from "./log.js";
|
|
8
|
+
|
|
9
|
+
// A scratch directory per test file; `diag` is pointed at files inside it.
|
|
10
|
+
const SANDBOX = mkdtempSync(join(tmpdir(), `archimedes-log-${randomUUID()}-`));
|
|
11
|
+
|
|
12
|
+
const ENV = "PI_ARCHIMEDES_LOG";
|
|
13
|
+
const previous = process.env[ENV];
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
if (previous === undefined) delete process.env[ENV];
|
|
17
|
+
else process.env[ENV] = previous;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("diag — the diagnostics sink", () => {
|
|
21
|
+
it("writes nothing at all when the env var is unset", () => {
|
|
22
|
+
// Unset is the default state of every real session, so this is the
|
|
23
|
+
// "logging costs nothing and touches no disk" guarantee. A file from an
|
|
24
|
+
// earlier enabled run proves the sink had somewhere to go; it must not
|
|
25
|
+
// gain a line now.
|
|
26
|
+
const target = join(SANDBOX, "unset.log");
|
|
27
|
+
process.env[ENV] = target;
|
|
28
|
+
diag("core", "enabled");
|
|
29
|
+
|
|
30
|
+
delete process.env[ENV];
|
|
31
|
+
expect(() => diag("core", "a message")).not.toThrow();
|
|
32
|
+
|
|
33
|
+
expect(readFileSync(target, "utf8").trimEnd().split("\n")).toEqual([
|
|
34
|
+
expect.stringContaining("[core] enabled"),
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("treats an empty value as off, not as the file named ''", () => {
|
|
39
|
+
// `PI_ARCHIMEDES_LOG=` is how an operator switches logging back off in a
|
|
40
|
+
// shell, and appendFileSync("") would resolve against the cwd — so the
|
|
41
|
+
// empty string must short-circuit rather than write somewhere surprising.
|
|
42
|
+
const target = join(SANDBOX, "blank.log");
|
|
43
|
+
process.env[ENV] = target;
|
|
44
|
+
diag("core", "before");
|
|
45
|
+
|
|
46
|
+
process.env[ENV] = "";
|
|
47
|
+
expect(() => diag("core", "after")).not.toThrow();
|
|
48
|
+
|
|
49
|
+
expect(readFileSync(target, "utf8").trimEnd().split("\n")).toEqual([
|
|
50
|
+
expect.stringContaining("[core] before"),
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("appends one line carrying an ISO-8601 timestamp, the scope and the message", () => {
|
|
55
|
+
const target = join(SANDBOX, "single.log");
|
|
56
|
+
process.env[ENV] = target;
|
|
57
|
+
|
|
58
|
+
const before = new Date();
|
|
59
|
+
diag("session-name", "title request failed");
|
|
60
|
+
const after = new Date();
|
|
61
|
+
|
|
62
|
+
const text = readFileSync(target, "utf8");
|
|
63
|
+
expect(text.endsWith("\n")).toBe(true);
|
|
64
|
+
const lines = text.trimEnd().split("\n");
|
|
65
|
+
expect(lines).toHaveLength(1);
|
|
66
|
+
|
|
67
|
+
const match = /^\[(.+?)\] \[(.+?)\] (.*)$/.exec(lines[0]!);
|
|
68
|
+
expect(match, `unexpected line shape: ${lines[0]}`).not.toBeNull();
|
|
69
|
+
const [, stamp, scope, message] = match!;
|
|
70
|
+
expect(scope).toBe("session-name");
|
|
71
|
+
expect(message).toBe("title request failed");
|
|
72
|
+
// ISO-8601 with an offset — parseable, and inside the call window.
|
|
73
|
+
const at = new Date(stamp!);
|
|
74
|
+
expect(Number.isNaN(at.getTime())).toBe(false);
|
|
75
|
+
expect(at.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000);
|
|
76
|
+
expect(at.getTime()).toBeLessThanOrEqual(after.getTime() + 1000);
|
|
77
|
+
expect(stamp!).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(Z|[+-]\d{2}:\d{2})$/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("appends across calls instead of truncating the file", () => {
|
|
81
|
+
const target = join(SANDBOX, "append.log");
|
|
82
|
+
process.env[ENV] = target;
|
|
83
|
+
|
|
84
|
+
diag("bus", "first");
|
|
85
|
+
diag("footer", "second");
|
|
86
|
+
|
|
87
|
+
const lines = readFileSync(target, "utf8").trimEnd().split("\n");
|
|
88
|
+
expect(lines).toHaveLength(2);
|
|
89
|
+
expect(lines[0]).toContain("[bus] first");
|
|
90
|
+
expect(lines[1]).toContain("[footer] second");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("re-reads the env var on every call (a later export takes effect)", () => {
|
|
94
|
+
// pi sets nothing itself: the operator exports PI_ARCHIMEDES_LOG in a shell,
|
|
95
|
+
// but a test/tool that sets it mid-run must also be honoured — reading it
|
|
96
|
+
// once at module load would freeze the no-op path.
|
|
97
|
+
const target = join(SANDBOX, "late.log");
|
|
98
|
+
delete process.env[ENV];
|
|
99
|
+
diag("core", "dropped");
|
|
100
|
+
expect(existsSync(target)).toBe(false);
|
|
101
|
+
|
|
102
|
+
process.env[ENV] = target;
|
|
103
|
+
diag("core", "captured");
|
|
104
|
+
expect(readFileSync(target, "utf8")).toContain("[core] captured");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("never throws when the target cannot be written, and creates no directories", () => {
|
|
108
|
+
// A path under a directory that does not exist: appendFileSync fails with
|
|
109
|
+
// ENOENT. Logging must swallow it — and must not "helpfully" mkdir.
|
|
110
|
+
const missingDir = join(SANDBOX, "missing-dir");
|
|
111
|
+
process.env[ENV] = join(missingDir, "log.txt");
|
|
112
|
+
|
|
113
|
+
expect(() => diag("core", "unreachable")).not.toThrow();
|
|
114
|
+
expect(existsSync(missingDir)).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("never throws when the target is a directory", () => {
|
|
118
|
+
const dir = join(SANDBOX, "a-directory");
|
|
119
|
+
mkdirSync(dir, { recursive: true });
|
|
120
|
+
process.env[ENV] = dir;
|
|
121
|
+
|
|
122
|
+
expect(() => diag("core", "EISDIR")).not.toThrow();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("tolerates a message containing newlines without throwing", () => {
|
|
126
|
+
// Callers format error objects; a stack would arrive here multi-line. The
|
|
127
|
+
// sink keeps it verbatim rather than trying to sanitise.
|
|
128
|
+
const target = join(SANDBOX, "multiline.log");
|
|
129
|
+
process.env[ENV] = target;
|
|
130
|
+
|
|
131
|
+
expect(() => diag("core", "line one\nline two")).not.toThrow();
|
|
132
|
+
expect(readFileSync(target, "utf8")).toContain("[core] line one\nline two");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── describeError — what a swallowed error looks like in the log ───────────
|
|
137
|
+
//
|
|
138
|
+
// `console.error(scope, err)` used to print the stack. Routing a swallowed
|
|
139
|
+
// error through a formatter that keeps only `err.message` would leave the log
|
|
140
|
+
// with a message and no origin, and for these call sites the log is the ONLY
|
|
141
|
+
// record: the write smears a fullscreen frame, and a subagent child discards
|
|
142
|
+
// it. So the formatter keeps the whole `Error`, stack included. (The user-facing
|
|
143
|
+
// surface keeps taking the message alone — see `describeError`'s doc.)
|
|
144
|
+
|
|
145
|
+
import { describeError } from "./log.js";
|
|
146
|
+
|
|
147
|
+
describe("describeError", () => {
|
|
148
|
+
function exploded(): Error {
|
|
149
|
+
// A named frame on purpose: the assertion below wants an origin, not just
|
|
150
|
+
// a message — that is the whole reason the stack is kept.
|
|
151
|
+
return new Error("listener exploded");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
it("keeps the stack, which is the only source location a swallowed error ever gets", () => {
|
|
155
|
+
const text = describeError(exploded());
|
|
156
|
+
expect(text).toContain("listener exploded");
|
|
157
|
+
expect(text).toMatch(/\n\s+at\s+\S/);
|
|
158
|
+
expect(text).toContain("exploded");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("falls back to the message when the error carries no stack", () => {
|
|
162
|
+
const err = new Error("no stack here");
|
|
163
|
+
delete (err as { stack?: string }).stack;
|
|
164
|
+
expect(describeError(err)).toBe("no stack here");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("stringifies anything that is not an Error", () => {
|
|
168
|
+
expect(describeError("a string")).toBe("a string");
|
|
169
|
+
expect(describeError({ code: 42 })).toBe("[object Object]");
|
|
170
|
+
expect(describeError(undefined)).toBe("undefined");
|
|
171
|
+
});
|
|
172
|
+
});
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// ── Diagnostics sink ────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// The suite does not write to the console. Two independent reasons:
|
|
6
|
+
//
|
|
7
|
+
// 1. On fullscreen screen a stray write lands inside the differential frame
|
|
8
|
+
// and smears the viewport until the next full redraw. On regular screen it
|
|
9
|
+
// scrolled away unseen, which is why the habit was cheap to keep.
|
|
10
|
+
// 2. In a subagent child every write is discarded anyway:
|
|
11
|
+
// `subagent/src/stream.ts` drops any non-JSON stdout line (a guard for pi's
|
|
12
|
+
// own startup messages), so a warning vanishes exactly when someone is
|
|
13
|
+
// debugging a child run.
|
|
14
|
+
//
|
|
15
|
+
// So developer diagnostics go here, and a message the user must see goes to the
|
|
16
|
+
// notification surface (`ctx.ui.notify`) — never to stdout/stderr.
|
|
17
|
+
//
|
|
18
|
+
// Logging is opt-in and dumb on purpose: one env var naming a file, one
|
|
19
|
+
// appended line per call, and a `try/catch` that swallows every failure. A
|
|
20
|
+
// diagnostic that can throw, block, or fill a disk is a new bug class, and the
|
|
21
|
+
// whole point of the sink is to be trustworthy while another bug is in flight.
|
|
22
|
+
// Nothing else in this module touches the filesystem — it does not create
|
|
23
|
+
// directories, rotate, buffer, or truncate.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Append one diagnostic line to `$PI_ARCHIMEDES_LOG`.
|
|
27
|
+
*
|
|
28
|
+
* No-op when the variable is unset or empty, so a default install never
|
|
29
|
+
* touches disk. When set, appends `[ISO-8601] [scope] message`. Never throws:
|
|
30
|
+
* an unwritable path (missing directory, EISDIR, EACCES, a full volume) is
|
|
31
|
+
* silently ignored, because losing a log line is always cheaper than failing a
|
|
32
|
+
* render, an event handler, or a session.
|
|
33
|
+
*
|
|
34
|
+
* One *append* per call; the message is kept verbatim, so an entry built by
|
|
35
|
+
* `describeError` spans the lines of its stack — which is the point of it.
|
|
36
|
+
*
|
|
37
|
+
* @param scope the package the line came from (e.g. `"session-name"`)
|
|
38
|
+
* @param message the diagnostic text; multi-line text is kept verbatim
|
|
39
|
+
*/
|
|
40
|
+
export function diag(scope: string, message: string): void {
|
|
41
|
+
const file = process.env.PI_ARCHIMEDES_LOG;
|
|
42
|
+
if (!file) return;
|
|
43
|
+
try {
|
|
44
|
+
appendFileSync(file, `[${new Date().toISOString()}] [${scope}] ${message}\n`);
|
|
45
|
+
} catch {
|
|
46
|
+
// Logging must never be the reason something failed.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An error, formatted for `diag` — stack included.
|
|
52
|
+
*
|
|
53
|
+
* Task 6 moved errors off the console, and handing an error object to the
|
|
54
|
+
* console used to print its stack on the way out. Formatting them down to
|
|
55
|
+
* `err.message` would trade a visible stack for an invisible one, and at the sites this exists for —
|
|
56
|
+
* a bus listener that threw, a footer frame that failed, a package whose
|
|
57
|
+
* `import()` was refused — the error is **swallowed**, so this log is the only
|
|
58
|
+
* place it is ever recorded and the stack is the only part naming its origin.
|
|
59
|
+
*
|
|
60
|
+
* Use the message alone (`err.message`) where someone on screen is about to
|
|
61
|
+
* read the text — `ctx.ui.notify`, a tool result, a widget. A stack belongs in a
|
|
62
|
+
* log file, never in a notification.
|
|
63
|
+
*
|
|
64
|
+
* @param err whatever a `catch` caught
|
|
65
|
+
*/
|
|
66
|
+
export function describeError(err: unknown): string {
|
|
67
|
+
if (err instanceof Error) return err.stack ?? err.message;
|
|
68
|
+
return String(err);
|
|
69
|
+
}
|
package/src/overlay.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { CURSOR_MARKER, stripTerminalSequences, visibleWidth as tuiVisibleWidth } from "@earendil-works/pi-tui";
|
|
2
3
|
import {
|
|
3
4
|
visibleWidth,
|
|
4
5
|
padEnd,
|
|
@@ -52,9 +53,14 @@ describe("padEnd", () => {
|
|
|
52
53
|
expect(padEnd("hi", 5)).toBe("hi ");
|
|
53
54
|
});
|
|
54
55
|
|
|
55
|
-
it("
|
|
56
|
+
it("leaves a line that already fits untouched", () => {
|
|
56
57
|
expect(padEnd("hello", 5)).toBe("hello");
|
|
57
|
-
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("clips a line wider than the target instead of letting it overflow", () => {
|
|
61
|
+
// The frame is a hard limit: a padded row must never measure past it, or
|
|
62
|
+
// the closing border lands off-screen and the terminal hard-wraps the row.
|
|
63
|
+
expect(visibleWidth(padEnd("toolong", 5))).toBe(5);
|
|
58
64
|
});
|
|
59
65
|
|
|
60
66
|
it("returns '' for width <= 0", () => {
|
|
@@ -67,11 +73,11 @@ describe("padEnd", () => {
|
|
|
67
73
|
|
|
68
74
|
describe("wrapText", () => {
|
|
69
75
|
it("wraps a long line to width", () => {
|
|
70
|
-
expect(wrapText("aaa bbb ccc ddd", 8)).toEqual(["aaa bbb
|
|
76
|
+
expect(wrapText("aaa bbb ccc ddd", 8)).toEqual(["aaa bbb", "ccc ddd"]);
|
|
71
77
|
});
|
|
72
78
|
|
|
73
79
|
it("wraps long words to width", () => {
|
|
74
|
-
expect(wrapText("hello world", 5)).toEqual(["hello", "
|
|
80
|
+
expect(wrapText("hello world", 5)).toEqual(["hello", "world"]);
|
|
75
81
|
});
|
|
76
82
|
|
|
77
83
|
it("preserves empty paragraphs as blank lines", () => {
|
|
@@ -91,7 +97,10 @@ describe("hardTruncate", () => {
|
|
|
91
97
|
});
|
|
92
98
|
|
|
93
99
|
it("truncates at the visible-width boundary", () => {
|
|
94
|
-
|
|
100
|
+
// pi-tui closes any styling it cut into, so the visible text — not the raw
|
|
101
|
+
// bytes — is the contract.
|
|
102
|
+
expect(stripTerminalSequences(hardTruncate("hello world", 5))).toBe("hello");
|
|
103
|
+
expect(visibleWidth(hardTruncate("hello world", 5))).toBe(5);
|
|
95
104
|
});
|
|
96
105
|
|
|
97
106
|
it("truncates an ANSI-colored string and appends a reset", () => {
|
|
@@ -101,6 +110,80 @@ describe("hardTruncate", () => {
|
|
|
101
110
|
});
|
|
102
111
|
});
|
|
103
112
|
|
|
113
|
+
// ── Terminal-hostile content ────────────────────────────────────────────────
|
|
114
|
+
//
|
|
115
|
+
// Every measurement in this module used to be `.length` after stripping only
|
|
116
|
+
// `\x1b[…m`. That is wrong for the three things the real overlays actually
|
|
117
|
+
// print: pi's cursor marker (an APC sequence, zero columns wide), OSC 8
|
|
118
|
+
// hyperlinks (a URL is invisible but enormous), and east-asian text (two
|
|
119
|
+
// columns per character). Each case below is measured with pi-tui's own
|
|
120
|
+
// `visibleWidth`, i.e. what the terminal draws.
|
|
121
|
+
|
|
122
|
+
describe("width maths on terminal-hostile content", () => {
|
|
123
|
+
it("measures a line carrying the real CURSOR_MARKER at its visible width", () => {
|
|
124
|
+
const line = `Search: plan${CURSOR_MARKER}`;
|
|
125
|
+
expect(visibleWidth(line)).toBe("Search: plan".length);
|
|
126
|
+
// The row this feeds must still land on exactly the target column: the
|
|
127
|
+
// marker occupies no cells, so the padding has to make up all of them.
|
|
128
|
+
expect(visibleWidth(padEnd(line, 20))).toBe(20);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns an OSC 8 hyperlink that already fits byte-for-byte", () => {
|
|
132
|
+
const url = `https://example.com/${"a".repeat(40)}`;
|
|
133
|
+
const line = `\x1b]8;;${url}\x07docs\x1b]8;;\x07`;
|
|
134
|
+
// 4 visible columns at a limit of 4: the no-cut path. pi-tui hands the input
|
|
135
|
+
// straight back, which is what makes a fitting row render identically to the
|
|
136
|
+
// old chrome. This is NOT the cutting case — see the next test.
|
|
137
|
+
expect(visibleWidth(line)).toBe(4);
|
|
138
|
+
expect(hardTruncate(line, 4)).toBe(line);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("cuts inside an OSC 8 hyperlink without leaving a half-written sequence", () => {
|
|
142
|
+
const url = `https://example.com/${"a".repeat(40)}`;
|
|
143
|
+
const line = `\x1b]8;;${url}\x07documentation\x1b]8;;\x07`; // 13 visible columns
|
|
144
|
+
expect(visibleWidth(line)).toBe(13);
|
|
145
|
+
const cut = hardTruncate(line, 4);
|
|
146
|
+
// The cut really happened: four columns of the linked text survive.
|
|
147
|
+
expect(tuiVisibleWidth(cut)).toBe(4);
|
|
148
|
+
expect(stripTerminalSequences(cut)).toBe("docu");
|
|
149
|
+
// The URL stays inside its escape sequence and every OSC 8 opener is
|
|
150
|
+
// terminated — a half-written OSC swallows the rest of the row, which is how
|
|
151
|
+
// the old byte-walking truncator failed here (it produced the 3-column stub
|
|
152
|
+
// "\x1b]8;" for this exact input, with no BEL and no closing sequence).
|
|
153
|
+
const opens = cut.match(/\x1b\]8;;/g) ?? [];
|
|
154
|
+
const terminated = cut.match(/\x1b\]8;;[^\x07]*\x07/g) ?? [];
|
|
155
|
+
expect(opens.length).toBeGreaterThan(0);
|
|
156
|
+
expect(terminated.length).toBe(opens.length);
|
|
157
|
+
// And the cut emits an explicit hyperlink close plus a reset, so the styling
|
|
158
|
+
// and the link target cannot bleed into the padding and the closing border.
|
|
159
|
+
expect(cut).toContain("\x1b]8;;\x07");
|
|
160
|
+
expect(cut.endsWith("\x1b[0m")).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("wraps a CJK line at the right column", () => {
|
|
164
|
+
const lines = wrapText("日本語のテキストです", 6);
|
|
165
|
+
expect(lines).toEqual(["日本語", "のテキ", "ストで", "す"]);
|
|
166
|
+
for (const line of lines) {
|
|
167
|
+
expect(tuiVisibleWidth(line)).toBeLessThanOrEqual(6);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("keeps an SGR colour across a wrap", () => {
|
|
172
|
+
const lines = wrapText("\x1b[31mred word and more words\x1b[0m", 8);
|
|
173
|
+
expect(lines.map((l) => stripTerminalSequences(l))).toEqual([
|
|
174
|
+
"red word",
|
|
175
|
+
"and more",
|
|
176
|
+
"words",
|
|
177
|
+
]);
|
|
178
|
+
// Each continuation line re-opens the colour, so the wrap does not spill
|
|
179
|
+
// red onto whatever the surface prints next.
|
|
180
|
+
for (const line of lines) {
|
|
181
|
+
expect(line).toContain("\x1b[31m");
|
|
182
|
+
expect(tuiVisibleWidth(line)).toBeLessThanOrEqual(8);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
104
187
|
// ── renderHeader / renderFooter ──────────────────────────────────────────────
|
|
105
188
|
|
|
106
189
|
describe("renderHeader", () => {
|
|
@@ -164,7 +247,10 @@ describe("wrapWithBorder", () => {
|
|
|
164
247
|
for (const line of out) {
|
|
165
248
|
expect(visibleWidth(line)).toBe(W);
|
|
166
249
|
}
|
|
167
|
-
|
|
250
|
+
// Asserted on visible text, not bytes: closing the truncation appends a
|
|
251
|
+
// zero-width reset, which is what keeps the style from bleeding into the
|
|
252
|
+
// padding and the closing border.
|
|
253
|
+
expect(stripTerminalSequences(out[1]!)).toBe("│ " + "z".repeat(W - 4) + " │");
|
|
168
254
|
});
|
|
169
255
|
});
|
|
170
256
|
|
|
@@ -179,3 +265,89 @@ describe("OVERLAY_CHROME", () => {
|
|
|
179
265
|
});
|
|
180
266
|
});
|
|
181
267
|
});
|
|
268
|
+
|
|
269
|
+
// ── Frame-width audit ────────────────────────────────────────────────────────
|
|
270
|
+
//
|
|
271
|
+
// The automated stand-in for a `PI_TUI_WRITE_LOG` capture: render the chrome the
|
|
272
|
+
// way the overlay surfaces do, measure every produced line with pi-tui's own
|
|
273
|
+
// `visibleWidth` (what the terminal actually draws), and require it to fit the
|
|
274
|
+
// frame. A line wider than the frame is not a cosmetic defect — the terminal
|
|
275
|
+
// hard-wraps it, which shoves the closing border onto the next row and shifts
|
|
276
|
+
// every row below it.
|
|
277
|
+
//
|
|
278
|
+
// The same audit runs per surface where that surface lives (the agents panel in
|
|
279
|
+
// @pi-archimedes/subagent, the settings/onboarding overlays in meta), so the
|
|
280
|
+
// numbers below are the chrome's share of the measurement.
|
|
281
|
+
|
|
282
|
+
/** Hostile content, one entry per failure mode of the old `.length` maths. */
|
|
283
|
+
function hostileLines(theme: OverlayTheme): string[] {
|
|
284
|
+
const url = `https://example.com/${"segment/".repeat(12)}`;
|
|
285
|
+
return [
|
|
286
|
+
// real pi cursor marker (APC: ESC _ … BEL) — zero columns wide
|
|
287
|
+
`Search: wide-query${CURSOR_MARKER}`,
|
|
288
|
+
// OSC 8 hyperlink — long invisible payload, short visible text
|
|
289
|
+
`docs: \x1b]8;;${url}\x07readme\x1b]8;;\x07 tail`,
|
|
290
|
+
// east-asian text — two columns per code unit
|
|
291
|
+
"設定パネルの横幅の測定を検証するための日本語テキスト",
|
|
292
|
+
// SGR-coloured runs
|
|
293
|
+
theme.fg("accent", "accent run ") + theme.fg("dim", "dim run ") + "tail",
|
|
294
|
+
// all three at once, wider than any frame
|
|
295
|
+
theme.fg("error", "✗ " + "失敗".repeat(40)) + `\x1b]8;;${url}\x07link\x1b]8;;\x07` + CURSOR_MARKER,
|
|
296
|
+
];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface AuditViolation {
|
|
300
|
+
surface: string;
|
|
301
|
+
frameWidth: number;
|
|
302
|
+
row: number;
|
|
303
|
+
measured: number;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Measure every produced line against the frame. Returns one entry per line
|
|
308
|
+
* that overflows (or, for a bordered frame, fails to land on it exactly).
|
|
309
|
+
*/
|
|
310
|
+
function auditFrameWidth(
|
|
311
|
+
surface: string,
|
|
312
|
+
frameWidth: number,
|
|
313
|
+
lines: string[],
|
|
314
|
+
bordered: boolean,
|
|
315
|
+
): AuditViolation[] {
|
|
316
|
+
const violations: AuditViolation[] = [];
|
|
317
|
+
lines.forEach((line, row) => {
|
|
318
|
+
const measured = tuiVisibleWidth(line);
|
|
319
|
+
const bad = bordered ? measured !== frameWidth : measured > frameWidth;
|
|
320
|
+
if (bad) violations.push({ surface, frameWidth, row, measured });
|
|
321
|
+
});
|
|
322
|
+
return violations;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
describe("frame-width audit (overlay chrome)", () => {
|
|
326
|
+
const FRAME_WIDTHS = [40, 60, 84, 100];
|
|
327
|
+
|
|
328
|
+
it("renders chrome inside the frame at every width, for hostile content", () => {
|
|
329
|
+
const violations: AuditViolation[] = [];
|
|
330
|
+
const content = hostileLines(mockTheme);
|
|
331
|
+
|
|
332
|
+
for (const frameWidth of FRAME_WIDTHS) {
|
|
333
|
+
const contentWidth = borderContentWidth(frameWidth);
|
|
334
|
+
// Composed the way the real surfaces compose it: header + padded body
|
|
335
|
+
// rows (some wrapped) + footer, then the border.
|
|
336
|
+
const body = content.flatMap((line) => wrapText(line, contentWidth));
|
|
337
|
+
const lines = [
|
|
338
|
+
renderHeader(" Settings ", contentWidth, mockTheme),
|
|
339
|
+
...body.map((line) => padEnd(line, contentWidth)),
|
|
340
|
+
renderFooter(" [esc] close ", contentWidth, mockTheme),
|
|
341
|
+
];
|
|
342
|
+
violations.push(
|
|
343
|
+
...auditFrameWidth("chrome", frameWidth, wrapWithBorder(lines, frameWidth, mockTheme), true),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
expect(
|
|
348
|
+
violations,
|
|
349
|
+
`${violations.length} chrome line(s) overflow the frame: ` +
|
|
350
|
+
violations.slice(0, 8).map((v) => `w=${v.frameWidth} row=${v.row} measured=${v.measured}`).join(", "),
|
|
351
|
+
).toEqual([]);
|
|
352
|
+
});
|
|
353
|
+
});
|