@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 +4 -2
- package/src/test-utils.ts +100 -0
- package/src/utils.ts +7 -2
- package/src/ansi.test.ts +0 -12
- package/src/dependency-security.test.ts +0 -32
- package/src/diff.test.ts +0 -117
- package/src/gate-overlay.test.ts +0 -382
- package/src/highlight.test.ts +0 -36
- package/src/icon-catalog.test.ts +0 -97
- package/src/icon-persist.test.ts +0 -59
- package/src/icons.test.ts +0 -35
- package/src/index.test.ts +0 -13
- package/src/modal-frame.test.ts +0 -445
- package/src/utils.test.ts +0 -576
- package/src/widget-format.test.ts +0 -127
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-pretty",
|
|
3
|
-
"version": "1.
|
|
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(
|
|
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(
|
|
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
|
-
});
|
package/src/gate-overlay.test.ts
DELETED
|
@@ -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
|
-
});
|