@xynogen/pix-pretty 1.19.0 → 1.20.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, diff rendering, and FFF search",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -25,13 +25,15 @@
25
25
  "./resize": "./src/resize.ts",
26
26
  "./context": "./src/tools/context.ts",
27
27
  "./gate-overlay": "./src/gate-overlay.ts",
28
- "./modal-frame": "./src/modal-frame.ts"
28
+ "./modal-frame": "./src/modal-frame.ts",
29
+ "./test-utils": "./src/test-utils.ts"
29
30
  },
30
31
  "scripts": {
31
32
  "test": "bun test"
32
33
  },
33
34
  "files": [
34
35
  "src",
36
+ "!src/**/*.test.*",
35
37
  "README.md",
36
38
  "LICENSE"
37
39
  ],
@@ -0,0 +1,100 @@
1
+ // Shared test harness for tool renderers. Test-only — every pix tool package
2
+ // rebuilt these same mocks (MockTextComponent, capture-pi, theme, render ctx)
3
+ // by hand; this collapses them to one import. Pure and Pi-host-agnostic.
4
+
5
+ import type { CursorStore, FffState } from "./fff.js";
6
+ import type { ToolContext } from "./tools/context.js";
7
+ import type { PiPrettyApi, RenderContextLike, TextComponentCtor, ThemeLike } from "./types.js";
8
+
9
+ /** In-memory TextComponent: stores text, splits on render. */
10
+ export class MockTextComponent {
11
+ private text: string;
12
+ constructor(text = "") {
13
+ this.text = text;
14
+ }
15
+ setText(value: string): void {
16
+ this.text = value;
17
+ }
18
+ getText(): string {
19
+ return this.text;
20
+ }
21
+ render(_width?: number): string[] {
22
+ return this.text.split("\n");
23
+ }
24
+ invalidate(): void {}
25
+ }
26
+
27
+ /** The tool object a registrar hands to `registerTool`, with the render hooks tests poke. */
28
+ export interface CapturedTool {
29
+ name?: string;
30
+ renderCall?: (...args: unknown[]) => MockTextComponent;
31
+ renderResult?: (...args: unknown[]) => MockTextComponent;
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ /**
36
+ * A PiPrettyApi that captures every registered tool. `tool` is the last one
37
+ * registered (Object.assign'd so render hooks are directly callable); `names`
38
+ * lists all registered tool names in order.
39
+ */
40
+ export function capturePi(): { pi: PiPrettyApi; tool: CapturedTool; names: string[] } {
41
+ const tool: CapturedTool = {};
42
+ const names: string[] = [];
43
+ const pi: PiPrettyApi = {
44
+ registerTool(t: unknown) {
45
+ const name = (t as { name?: string }).name;
46
+ if (name) names.push(name);
47
+ Object.assign(tool, t);
48
+ },
49
+ registerCommand() {},
50
+ on() {},
51
+ };
52
+ return { pi, tool, names };
53
+ }
54
+
55
+ /** Build the ToolContext every registrar expects; override any field. */
56
+ export function makeToolContext(overrides: Partial<ToolContext> = {}): ToolContext {
57
+ return {
58
+ cwd: process.cwd(),
59
+ sp: (p: string) => p,
60
+ // SAFETY: MockTextComponent implements the setText/getText/render/invalidate surface
61
+ // TextComponentCtor requires; the ctor arity differs but callers only use `new C(text)`.
62
+ TextComponent: MockTextComponent as unknown as TextComponentCtor,
63
+ fffState: { module: null, finder: null, partialIndex: false, dbDir: null } as FffState,
64
+ // SAFETY: tool renderers only call store()/get() on the cursor store; this stub covers both.
65
+ cursorStore: { store: () => "", get: () => undefined } as unknown as CursorStore,
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Theme mock. Default is passthrough (`fg`/`bold` return the raw value).
72
+ * Pass `tag: true` to wrap `dim`/`muted` foregrounds as `<key>value</key>`,
73
+ * which lets tests assert on visual-hierarchy roles.
74
+ */
75
+ export function makeTheme({ tag = false }: { tag?: boolean } = {}): ThemeLike {
76
+ return {
77
+ fg: (key: string, value: string) =>
78
+ tag && (key === "dim" || key === "muted") ? `<${key}>${value}</${key}>` : value,
79
+ bold: (value: string) => value,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Build a RenderContextLike with sane defaults; override expanded/isError/state/etc.
85
+ * `state` is intentionally loose (`Record<string, unknown>`) because renderers stash
86
+ * booleans/numbers/timers there at runtime that the strict `state` type doesn't model.
87
+ */
88
+ export function makeRenderCtx(
89
+ overrides: Partial<Omit<RenderContextLike, "state">> & { state?: Record<string, unknown> } = {},
90
+ ): RenderContextLike {
91
+ // SAFETY: loose state bag matches runtime renderer usage; the strict RenderContextLike
92
+ // state type only lists string keys, but renderers read/write timers and flags there.
93
+ return {
94
+ expanded: false,
95
+ isError: false,
96
+ invalidate: () => {},
97
+ state: {},
98
+ ...overrides,
99
+ } as unknown as RenderContextLike;
100
+ }
package/src/utils.ts CHANGED
@@ -24,7 +24,7 @@ import type {
24
24
  } from "./types.js";
25
25
 
26
26
  export function renderToolError(error: string, theme: FgTheme): string {
27
- return fillToolBackground(`\n${theme.fg("error", error)}`, BG_ERROR);
27
+ return fillToolBackground(theme.fg("error", error), BG_ERROR);
28
28
  }
29
29
 
30
30
  export function normalizeLineEndings(text: string): string {
@@ -324,8 +324,13 @@ export function renderCollapsedToolRow(
324
324
  target: string,
325
325
  meta = "",
326
326
  status: CollapsedToolStatus = "success",
327
+ width?: number,
327
328
  ): string {
328
- return fillToolBackground(formatCollapsedToolRow(theme, tool, target, meta, status));
329
+ return fillToolBackground(
330
+ formatCollapsedToolRow(theme, tool, target, meta, status),
331
+ BG_BASE,
332
+ width,
333
+ );
329
334
  }
330
335
 
331
336
  /** Hide renderCall after its paired result has auto-collapsed. */
package/src/ansi.test.ts DELETED
@@ -1,12 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import * as ansi from "./ansi.ts";
3
- import { resolveBaseBackground } from "./ansi.ts";
4
-
5
- describe("tool surfaces", () => {
6
- test("always preserves terminal background", () => {
7
- resolveBaseBackground({ getBgAnsi: () => "\x1b[48;2;10;20;30m" });
8
- expect(ansi.BG_BASE).toBe("\x1b[49m");
9
- expect(ansi.BG_ERROR).toBe("\x1b[49m");
10
- expect(ansi.RST).toBe("\x1b[0m");
11
- });
12
- });
@@ -1,32 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
-
6
- const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
7
- const repoRoot = dirname(dirname(packageRoot));
8
-
9
- interface PackageManifest {
10
- dependencies?: Record<string, string>;
11
- overrides?: Record<string, string>;
12
- }
13
-
14
- function readManifest(path: string): PackageManifest {
15
- try {
16
- return JSON.parse(readFileSync(path, "utf8")) as PackageManifest;
17
- } catch (cause) {
18
- throw new Error(`Unable to read package manifest: ${path}`, { cause });
19
- }
20
- }
21
-
22
- describe("dependency security floors", () => {
23
- it("uses a jsdiff release without GHSA-73rr-hh4g-fpgx", () => {
24
- const manifest = readManifest(join(packageRoot, "package.json"));
25
- expect(manifest.dependencies?.diff).toBe("^8.0.3");
26
- });
27
-
28
- it("pins protobufjs above GHSA-j3f2-48v5-ccww", () => {
29
- const manifest = readManifest(join(repoRoot, "package.json"));
30
- expect(manifest.overrides?.protobufjs).toBe("7.6.5");
31
- });
32
- });
package/src/diff.test.ts DELETED
@@ -1,117 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { parseDiff } from "./diff.js";
3
- import {
4
- DEFAULT_DIFF_COLORS,
5
- diffThemeCacheKey,
6
- renderDiffSummary,
7
- renderUnified,
8
- resolveDiffColors,
9
- } from "./diff-render.js";
10
-
11
- const OLD = "line1\nline2\nline3";
12
- const NEW = "line1\nCHANGED\nline3";
13
- const ANSI_RE = /\x1b\[[0-9;]*m|<\/?syntax\w+>/g;
14
-
15
- describe("theme-derived diff rendering", () => {
16
- const theme = {
17
- fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
18
- getFgAnsi: (key: string) => {
19
- if (key === "toolDiffAdded") return "\x1b[38;2;120;210;150m";
20
- if (key === "toolDiffRemoved") return "\x1b[38;2;230;120;130m";
21
- if (key === "toolDiffContext") return "\x1b[38;2;130;140;150m";
22
- return "";
23
- },
24
- };
25
-
26
- it("uses semantic foregrounds", () => {
27
- const colors = resolveDiffColors(theme);
28
- expect(colors.fgAdd).toBe("\x1b[38;2;120;210;150m");
29
- expect(colors.fgDel).toBe("\x1b[38;2;230;120;130m");
30
- expect(colors.fgCtx).toBe("\x1b[38;2;130;140;150m");
31
- });
32
-
33
- // Regression: these six slots were all set to BG_BASE (\x1b[49m), which
34
- // dropped the faint green/red row tint and left only the gutter chips
35
- // colored. A diff must read as add/remove bands at a glance.
36
- it("gives changed rows a faint tint background", () => {
37
- for (const colors of [DEFAULT_DIFF_COLORS, resolveDiffColors(theme)]) {
38
- for (const key of [
39
- "bgAdd",
40
- "bgDel",
41
- "bgAddHighlight",
42
- "bgDelHighlight",
43
- "bgGutterAdd",
44
- "bgGutterDel",
45
- ] as const) {
46
- expect(colors[key]).not.toBe("\x1b[49m");
47
- expect(colors[key]).toMatch(/^\x1b\[48;2;\d+;\d+;\d+m$/);
48
- }
49
- // Word-diff emphasis must be distinguishable from the row tint.
50
- expect(colors.bgAddHighlight).not.toBe(colors.bgAdd);
51
- expect(colors.bgDelHighlight).not.toBe(colors.bgDel);
52
- // Add and remove must never collide.
53
- expect(colors.bgAdd).not.toBe(colors.bgDel);
54
- }
55
- });
56
-
57
- it("includes semantic theme colors in cache identity", () => {
58
- const changed = { ...theme, getFgAnsi: () => "\x1b[38;2;1;2;3m" };
59
- expect(diffThemeCacheKey(theme)).not.toBe(diffThemeCacheKey(changed));
60
- });
61
-
62
- it("colors persisted plain summaries only at render time", () => {
63
- expect(renderDiffSummary("+3 -2", theme)).toBe(
64
- "<toolDiffAdded>+3</toolDiffAdded> <toolDiffRemoved>-2</toolDiffRemoved>",
65
- );
66
- expect(renderDiffSummary("no changes", theme)).toBe(
67
- "<toolDiffContext>no changes</toolDiffContext>",
68
- );
69
- });
70
-
71
- it("emits tint backgrounds on changed rows", async () => {
72
- const rendered = await renderUnified(
73
- parseDiff("const oldValue = 1;", "const newValue = 2;"),
74
- "typescript",
75
- 80,
76
- resolveDiffColors({ ...theme, fg: (_key, text) => text }),
77
- );
78
- const { bgAdd, bgDel } = resolveDiffColors(theme);
79
- expect(rendered).toContain(bgDel);
80
- expect(rendered).toContain(bgAdd);
81
- });
82
-
83
- it("keeps gutter numbering and rule layout intact", async () => {
84
- const rendered = await renderUnified(
85
- parseDiff("const oldValue = 1;", "const newValue = 2;"),
86
- "typescript",
87
- 80,
88
- resolveDiffColors({ ...theme, fg: (_key, text) => text }),
89
- );
90
- const lines = rendered.replace(ANSI_RE, "").split("\n");
91
-
92
- expect(lines).toHaveLength(4);
93
- expect(lines[0]).toMatch(/^─+$/);
94
- expect(lines[1]).toMatch(/^▌\s+1- │ const oldValue = 1;\s*$/);
95
- expect(lines[2]).toMatch(/^▌\s+1\+ │ const newValue = 2;\s*$/);
96
- expect(lines[3]).toMatch(/^─+$/);
97
- expect(rendered).toContain(theme.getFgAnsi("toolDiffRemoved"));
98
- expect(rendered).toContain(theme.getFgAnsi("toolDiffAdded"));
99
- });
100
- });
101
-
102
- describe("parseDiff baseLine", () => {
103
- it("is snippet-relative when baseLine omitted (default 0)", () => {
104
- const { lines } = parseDiff(OLD, NEW);
105
- const del = lines.find((l) => l.type === "del");
106
- expect(del?.oldNum).toBe(2); // line2 is the 2nd line of the snippet
107
- });
108
-
109
- it("shifts gutter numbers to absolute when baseLine given", () => {
110
- // Snippet begins at file line 84 → snippet line 2 becomes file line 85.
111
- const { lines } = parseDiff(OLD, NEW, 3, 84);
112
- const del = lines.find((l) => l.type === "del");
113
- const add = lines.find((l) => l.type === "add");
114
- expect(del?.oldNum).toBe(85);
115
- expect(add?.newNum).toBe(85);
116
- });
117
- });
@@ -1,382 +0,0 @@
1
- import { describe, expect, jest, test } from "bun:test";
2
- import { type OverlayUI, showOverlay } from "./gate-overlay.ts";
3
- import { modalOverlayOptions } from "./modal-frame.ts";
4
-
5
- // ── Mock host ─────────────────────────────────────────────────────────────────
6
- //
7
- // Drive showOverlay deterministically without a real TUI. The mock invokes the
8
- // builder callback (so components initialise + wire their handlers), captures
9
- // the rendered lines, then hands a `drive(comp, done)` hook to the test which
10
- // triggers selectList.onSelect / maskedInput.onSubmit / etc via the real
11
- // component instances — exactly what real keyboard input would do.
12
-
13
- const theme = {
14
- fg: (_c: string, t: string) => t,
15
- bg: (_c: string, t: string) => t,
16
- bold: (t: string) => t,
17
- };
18
-
19
- interface Wired {
20
- render(w: number): string[];
21
- invalidate(): void;
22
- handleInput(d: string): void;
23
- }
24
-
25
- /**
26
- * Build a mock UI. `drive` receives the rendered lines and a `feed` fn that
27
- * pushes raw input strings into the component. To trigger a selection we feed
28
- * the SelectList keys; simpler: we expose the live component so the test can
29
- * call its handlers. We do the latter via the captured component ref.
30
- */
31
- function makeUI(
32
- onReady: (comp: Wired, finish: (v: unknown) => void) => void,
33
- onOptions?: (options: unknown) => void,
34
- renderTheme = theme,
35
- ): OverlayUI {
36
- return {
37
- custom: async <T>(
38
- cb: (
39
- tui: { requestRender(): void },
40
- th: typeof theme,
41
- kb: unknown,
42
- done: (v: T) => void,
43
- ) => Wired,
44
- options?: unknown,
45
- ): Promise<T | undefined> => {
46
- onOptions?.(options);
47
- let resolved: T | undefined;
48
- const done = (v: T) => {
49
- resolved = v;
50
- };
51
- const comp = cb({ requestRender: () => {} }, renderTheme, undefined, done);
52
- comp.render(80); // initialise render path
53
- onReady(comp, done as (v: unknown) => void);
54
- return resolved;
55
- },
56
- };
57
- }
58
-
59
- // SelectList handles "\r" (enter) to select the highlighted item, and arrow
60
- // keys to move. The first item is highlighted by default.
61
- const ENTER = "\r";
62
- const DOWN = "\x1b[B";
63
-
64
- describe("showOverlay — confirm mode", () => {
65
- test("uses configured global overlay bounds", async () => {
66
- let options: unknown;
67
- await showOverlay(
68
- makeUI(
69
- (comp) => comp.handleInput(ENTER),
70
- (value) => {
71
- options = value;
72
- },
73
- ),
74
- { mode: "confirm", title: "T" },
75
- );
76
- expect(options).toEqual({ overlay: true, overlayOptions: modalOverlayOptions() });
77
- });
78
-
79
- test("selecting the approve choice (first) returns approved", async () => {
80
- const result = await showOverlay(
81
- makeUI((comp) => {
82
- comp.handleInput(ENTER); // select item 0 = "yes"
83
- }),
84
- { mode: "confirm", title: "T", timeoutMs: 0 },
85
- );
86
- expect(result.action).toBe("approved");
87
- expect(result.password).toBeUndefined();
88
- });
89
-
90
- test("selecting the deny choice (second) returns denied", async () => {
91
- const result = await showOverlay(
92
- makeUI((comp) => {
93
- comp.handleInput(DOWN); // move to item 1 = "no"
94
- comp.handleInput(ENTER);
95
- }),
96
- { mode: "confirm", title: "T", timeoutMs: 0 },
97
- );
98
- expect(result.action).toBe("denied");
99
- });
100
-
101
- test("deny-first ordering: item 0 is the deny choice when configured so", async () => {
102
- const result = await showOverlay(
103
- makeUI((comp) => {
104
- comp.handleInput(ENTER); // select item 0
105
- }),
106
- {
107
- mode: "confirm",
108
- title: "Critical",
109
- timeoutMs: 0,
110
- approveValue: "yes",
111
- choices: [
112
- { value: "no", label: "Block", description: "deny" },
113
- { value: "yes", label: "Allow", description: "approve" },
114
- ],
115
- },
116
- );
117
- // item 0 = "no" => not the approveValue => denied
118
- expect(result.action).toBe("denied");
119
- });
120
-
121
- test("renders title and body lines", async () => {
122
- let captured: string[] = [];
123
- await showOverlay(
124
- makeUI((comp) => {
125
- captured = comp.render(80);
126
- comp.handleInput(ENTER);
127
- }),
128
- {
129
- mode: "confirm",
130
- title: "MY TITLE",
131
- body: ["body-line-x"],
132
- timeoutMs: 0,
133
- },
134
- );
135
- const joined = captured.join("\n");
136
- expect(joined).toContain("MY TITLE");
137
- expect(joined).toContain("body-line-x");
138
- });
139
-
140
- test("uses semantic colors for transfer details", async () => {
141
- const coloredTheme = {
142
- fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
143
- bg: (_color: string, text: string) => text,
144
- bold: (text: string) => text,
145
- };
146
- let captured: string[] = [];
147
- await showOverlay(
148
- makeUI(
149
- (comp) => {
150
- captured = comp.render(100);
151
- comp.handleInput(ENTER);
152
- },
153
- undefined,
154
- coloredTheme,
155
- ),
156
- {
157
- mode: "confirm",
158
- title: "SSH FILE TRANSFER",
159
- body: [
160
- "Intent: Copy a release artifact",
161
- "Command: scp app.tar.gz host:/tmp",
162
- "Host: deploy@example.com",
163
- "Direction: Download",
164
- "From: /srv/releases/app.tar.gz",
165
- "To: /tmp/app.tar.gz",
166
- "Mode: Single item",
167
- "Warning: existing destination may be overwritten",
168
- "Auth: SSH key (no password)",
169
- ],
170
- timeoutMs: 0,
171
- },
172
- );
173
- const joined = captured.join("\n");
174
- expect(joined).toContain("<dim>Intent:</dim> <text>Copy a release artifact</text>");
175
- expect(joined).toContain("<dim>Command:</dim> <dim>scp app.tar.gz host:/tmp</dim>");
176
- expect(joined).toContain("<dim>Host:</dim> <accent>deploy@example.com</accent>");
177
- expect(joined).toContain("<dim>Direction:</dim> <warning>Download</warning>");
178
- expect(joined).toContain("<dim>From:</dim> <text>/srv/releases/app.tar.gz</text>");
179
- expect(joined).toContain("<dim>To:</dim> <accent>/tmp/app.tar.gz</accent>");
180
- expect(joined).toContain("<warning>Warning: existing destination may be overwritten</warning>");
181
- expect(joined).toContain("<dim>Auth:</dim> <success>SSH key (no password)</success>");
182
- });
183
-
184
- // Regression guard (readability fix, pix-pretty 1.18.4): Intent:/Command:
185
- // lines must go through the label/value colour map — a <dim> label plus a
186
- // semantic value — never a bare bright value with the label stripped off.
187
- // Pre-1.18.4 they were sliced and dumped as bright <text> with no label,
188
- // which was hard to read on the modal background. Command value must be <dim>.
189
- test("Intent/Command body lines keep a dim label and never drop it", async () => {
190
- const coloredTheme = {
191
- fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
192
- bg: (_color: string, text: string) => text,
193
- bold: (text: string) => text,
194
- };
195
- let captured: string[] = [];
196
- await showOverlay(
197
- makeUI(
198
- (comp) => {
199
- captured = comp.render(100);
200
- comp.handleInput(ENTER);
201
- },
202
- undefined,
203
- coloredTheme,
204
- ),
205
- {
206
- mode: "sudo",
207
- title: "ROOT COMMAND REQUEST",
208
- accent: "error",
209
- body: ["Intent: install a package", "Command: apt install foo"],
210
- timeoutMs: 0,
211
- },
212
- );
213
- const joined = captured.join("\n");
214
- // Command label + value both dim; intent value stays readable text but is
215
- // always prefixed by a dim label (never a bare label-less value).
216
- expect(joined).toContain("<dim>Command:</dim> <dim>apt install foo</dim>");
217
- expect(joined).toContain("<dim>Intent:</dim> <text>install a package</text>");
218
- // The pre-1.18.4 bug: label stripped, value dumped bright with no dim label.
219
- expect(joined).not.toContain("<text>apt install foo</text>"); // command value is dim, not text
220
- });
221
-
222
- test("wraps a long body command instead of truncating it", async () => {
223
- // A command far wider than any modal width — must survive in full, wrapped.
224
- const longCmd = `echo ${"pix-gate-installed-or-linked ".repeat(8)}done`;
225
- let captured: string[] = [];
226
- await showOverlay(
227
- makeUI((comp) => {
228
- captured = comp.render(80);
229
- comp.handleInput(ENTER);
230
- }),
231
- { mode: "confirm", title: "T", body: [longCmd], timeoutMs: 0 },
232
- );
233
- // Every whitespace-delimited token of the command appears somewhere in the
234
- // frame — nothing was dropped by truncation.
235
- const joined = captured.join("\n");
236
- for (const tok of longCmd.split(" ")) expect(joined).toContain(tok);
237
- });
238
- });
239
-
240
- describe("showOverlay — sudo mode", () => {
241
- test("approve then submit password returns approved + real password", async () => {
242
- const result = await showOverlay(
243
- makeUI((comp) => {
244
- comp.handleInput(ENTER); // select item 0 = "yes" => switch to password stage
245
- comp.handleInput("s3cret"); // type into MaskedInput
246
- comp.handleInput(ENTER); // submit
247
- }),
248
- { mode: "sudo", title: "ROOT", timeoutMs: 0 },
249
- );
250
- expect(result.action).toBe("approved");
251
- expect(result.password).toBe("s3cret");
252
- });
253
-
254
- test("deny at select stage returns denied, never reaches password", async () => {
255
- const result = await showOverlay(
256
- makeUI((comp) => {
257
- comp.handleInput(DOWN); // item 1 = "no"
258
- comp.handleInput(ENTER);
259
- }),
260
- { mode: "sudo", title: "ROOT", timeoutMs: 0 },
261
- );
262
- expect(result.action).toBe("denied");
263
- expect(result.password).toBeUndefined();
264
- });
265
-
266
- test("wrong password retries inside the same overlay", async () => {
267
- let component: Wired | undefined;
268
- let overlayCount = 0;
269
- const attempts: string[] = [];
270
- const ui: OverlayUI = {
271
- custom: <T>(cb: Parameters<OverlayUI["custom"]>[0]): Promise<T | undefined> => {
272
- overlayCount += 1;
273
- return new Promise((resolve) => {
274
- component = cb({ requestRender: () => {} }, theme, undefined, (value) =>
275
- resolve(value as T),
276
- );
277
- });
278
- },
279
- };
280
-
281
- const pending = showOverlay(ui, {
282
- mode: "sudo",
283
- title: "ROOT",
284
- timeoutMs: 0,
285
- maxPasswordAttempts: 3,
286
- validatePassword: async (password) => {
287
- attempts.push(password);
288
- return password === "correct";
289
- },
290
- });
291
- component?.handleInput(ENTER);
292
- component?.handleInput("wrong");
293
- component?.handleInput(ENTER);
294
- await new Promise((resolve) => setTimeout(resolve, 0));
295
- expect(component?.render(80).join("\n")).toContain("Incorrect password — attempt 1 of 3");
296
- component?.handleInput("correct");
297
- component?.handleInput(ENTER);
298
-
299
- expect(await pending).toEqual({ action: "approved", password: "correct" });
300
- expect(attempts).toEqual(["wrong", "correct"]);
301
- expect(overlayCount).toBe(1);
302
- });
303
-
304
- test("password is masked in render (● not plaintext)", async () => {
305
- let pwFrame: string[] = [];
306
- await showOverlay(
307
- makeUI((comp) => {
308
- comp.handleInput(ENTER); // to password stage
309
- comp.handleInput("abc");
310
- pwFrame = comp.render(80);
311
- comp.handleInput(ENTER); // submit so the promise resolves
312
- }),
313
- { mode: "sudo", title: "ROOT", timeoutMs: 0 },
314
- );
315
- const joined = pwFrame.join("\n");
316
- expect(joined).not.toContain("abc");
317
- expect(joined).toContain("●");
318
- });
319
- });
320
-
321
- // ── Auto-deny timer (dead-man's switch) ───────────────────────────────────────
322
- //
323
- // Timer-aware mock: unlike makeUI, this keeps the promise pending and resolves
324
- // only when `done` fires — so a real setInterval expiry can drive the result.
325
- // `onReady` gets the live component to optionally feed input before expiry.
326
- function makeTimerUI(onReady?: (comp: Wired) => void): OverlayUI {
327
- return {
328
- custom: <T>(
329
- cb: (
330
- tui: { requestRender(): void },
331
- th: typeof theme,
332
- kb: unknown,
333
- done: (v: T) => void,
334
- ) => Wired,
335
- ): Promise<T | undefined> =>
336
- new Promise((resolve) => {
337
- const comp = cb({ requestRender: () => {} }, theme, undefined, (v) => resolve(v));
338
- comp.render(80);
339
- onReady?.(comp);
340
- }),
341
- };
342
- }
343
-
344
- describe("showOverlay — auto-deny timer", () => {
345
- test("expires to timeout when left untouched", async () => {
346
- jest.useFakeTimers();
347
- try {
348
- const pending = showOverlay(makeTimerUI(), {
349
- mode: "confirm",
350
- title: "T",
351
- timeoutMs: 1000, // ceil → 1s, fires on first tick
352
- });
353
- jest.advanceTimersByTime(1000); // fire the auto-deny tick without a real wait
354
- const result = await pending;
355
- expect(result.action).toBe("timeout");
356
- } finally {
357
- jest.useRealTimers();
358
- }
359
- });
360
-
361
- test("first keypress cancels the timer (no auto-deny)", async () => {
362
- jest.useFakeTimers();
363
- try {
364
- let live: Wired | undefined;
365
- const pending = showOverlay(
366
- makeTimerUI((comp) => {
367
- live = comp;
368
- comp.handleInput(DOWN); // any key — cancels the dead-man's switch
369
- }),
370
- { mode: "confirm", title: "T", timeoutMs: 1000 },
371
- );
372
- // Advance well past the 1s window. A live timer would have resolved
373
- // "timeout"; the keypress cancelled it, so the promise stays pending.
374
- jest.advanceTimersByTime(1300);
375
- live?.handleInput(ENTER); // now deny explicitly
376
- const result = await pending;
377
- expect(result.action).toBe("denied");
378
- } finally {
379
- jest.useRealTimers();
380
- }
381
- });
382
- });