@hoox-sh/hoox-tui 0.2.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/README.md +247 -0
- package/bin/hoox-tui.js +35 -0
- package/dist/main.js +17594 -0
- package/package.json +72 -0
- package/src/app.tsx +696 -0
- package/src/cli-bridge-test-double.ts +212 -0
- package/src/components/layout/layout.test.tsx +205 -0
- package/src/components/layout/sidebar.tsx +69 -0
- package/src/components/layout/statusbar.test.tsx +423 -0
- package/src/components/layout/statusbar.tsx +348 -0
- package/src/components/shared/animated-border.tsx +58 -0
- package/src/components/shared/chrome.test.tsx +29 -0
- package/src/components/shared/command-palette.tsx +361 -0
- package/src/components/shared/cool-brackets.tsx +75 -0
- package/src/components/shared/crash-screen.tsx +167 -0
- package/src/components/shared/error-boundary.tsx +99 -0
- package/src/components/shared/keybinding-hint.tsx +37 -0
- package/src/components/shared/panel.tsx +61 -0
- package/src/components/shared/quit-modal.tsx +65 -0
- package/src/components/shared/shared.test.tsx +538 -0
- package/src/components/shared/spinner.tsx +85 -0
- package/src/components/shared/status-dot.tsx +54 -0
- package/src/components/shared/view-header.tsx +53 -0
- package/src/components/ui/connection-toasts.test.ts +50 -0
- package/src/components/ui/connection-toasts.ts +215 -0
- package/src/components/ui/dialog.tsx +281 -0
- package/src/components/ui/select.tsx +197 -0
- package/src/components/ui/toast.tsx +185 -0
- package/src/components/views/ai-chat.test.tsx +81 -0
- package/src/components/views/ai-chat.tsx +576 -0
- package/src/components/views/config-editor/action-bar.tsx +102 -0
- package/src/components/views/config-editor/code-editor.tsx +597 -0
- package/src/components/views/config-editor/file-tree.tsx +113 -0
- package/src/components/views/config-editor/types.ts +37 -0
- package/src/components/views/config-editor.test.tsx +465 -0
- package/src/components/views/config-editor.tsx +709 -0
- package/src/components/views/dashboard/alerts-panel.tsx +154 -0
- package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
- package/src/components/views/dashboard/model-health-section.tsx +276 -0
- package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
- package/src/components/views/dashboard.test.tsx +347 -0
- package/src/components/views/dashboard.tsx +647 -0
- package/src/components/views/db-query.test.tsx +115 -0
- package/src/components/views/db-query.tsx +849 -0
- package/src/components/views/edge-topology.test.tsx +198 -0
- package/src/components/views/edge-topology.tsx +572 -0
- package/src/components/views/kv-viewer.test.tsx +241 -0
- package/src/components/views/kv-viewer.tsx +717 -0
- package/src/components/views/logs-viewer.test.tsx +273 -0
- package/src/components/views/logs-viewer.tsx +616 -0
- package/src/components/views/queue-depth.test.tsx +221 -0
- package/src/components/views/queue-depth.tsx +376 -0
- package/src/components/views/secrets-viewer.test.tsx +65 -0
- package/src/components/views/secrets-viewer.tsx +548 -0
- package/src/components/views/service-manager.test.tsx +358 -0
- package/src/components/views/service-manager.tsx +946 -0
- package/src/components/views/settings.test.tsx +648 -0
- package/src/components/views/settings.tsx +1497 -0
- package/src/components/views/setup-wizard.test.tsx +297 -0
- package/src/components/views/setup-wizard.tsx +1321 -0
- package/src/components/views/trade-monitor.test.tsx +592 -0
- package/src/components/views/trade-monitor.tsx +745 -0
- package/src/components/views/worker-detail.test.tsx +289 -0
- package/src/components/views/worker-detail.tsx +676 -0
- package/src/components/views/worker-settings.test.tsx +43 -0
- package/src/components/views/worker-settings.tsx +563 -0
- package/src/components/views/workers-overview.test.tsx +260 -0
- package/src/components/views/workers-overview.tsx +631 -0
- package/src/hooks/hooks.test.ts +72 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/renderer-ref.ts +22 -0
- package/src/hooks/use-keyboard.ts +130 -0
- package/src/hooks/use-polling.ts +73 -0
- package/src/hooks/use-service-data.ts +18 -0
- package/src/main.tsx +117 -0
- package/src/network-test-double.ts +133 -0
- package/src/opentui-augment.d.ts +45 -0
- package/src/opentui-jsx.d.ts +70 -0
- package/src/services/cli-bridge/index.ts +1662 -0
- package/src/services/cli-bridge/standalone.ts +315 -0
- package/src/services/cli-bridge/types.ts +307 -0
- package/src/services/cli-bridge.test.ts +406 -0
- package/src/services/clipboard.test.ts +57 -0
- package/src/services/clipboard.ts +180 -0
- package/src/services/dashboard-settings-loader.ts +31 -0
- package/src/services/dev-log.test.ts +168 -0
- package/src/services/dev-log.ts +194 -0
- package/src/services/hoox-path-service.test.ts +183 -0
- package/src/services/hoox-path-service.ts +185 -0
- package/src/services/tui-connection.test.ts +212 -0
- package/src/services/tui-connection.ts +236 -0
- package/src/services/tui-storage.test.ts +92 -0
- package/src/services/tui-storage.ts +86 -0
- package/src/stores/config-store.test.ts +360 -0
- package/src/stores/service-store.test.ts +1026 -0
- package/src/stores/session-restore.test.ts +134 -0
- package/src/stores/ui-store.test.ts +276 -0
- package/src/test-setup.ts +265 -0
- package/src/test-utils.ts +179 -0
- package/src/types.ts +153 -0
- package/src/utils/colors.test.ts +326 -0
- package/src/utils/formatters.test.ts +236 -0
- package/src/view-registry.test.ts +132 -0
- package/src/view-registry.tsx +368 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @jsxImportSource @opentui/react */
|
|
7
|
+
/**
|
|
8
|
+
* FileTree Component — Renders a flat list of file/directory nodes
|
|
9
|
+
* from the tree structure with selection, focus, and unsaved indicators.
|
|
10
|
+
*/
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { Colors } from "@hoox-sh/hoox-shared";
|
|
13
|
+
import type { FileNode } from "./types";
|
|
14
|
+
|
|
15
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
interface FileTreeProps {
|
|
18
|
+
nodes: FileNode[];
|
|
19
|
+
selectedPath: string | null;
|
|
20
|
+
unsavedPaths: Set<string>;
|
|
21
|
+
focusedPath: string | null;
|
|
22
|
+
onSelectFile: (path: string) => void;
|
|
23
|
+
level?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** Renders a flat list of file/directory nodes from the tree structure. */
|
|
29
|
+
export function flattenTree(
|
|
30
|
+
nodes: FileNode[],
|
|
31
|
+
level: number = 0
|
|
32
|
+
): Array<{ node: FileNode; level: number }> {
|
|
33
|
+
const result: Array<{ node: FileNode; level: number }> = [];
|
|
34
|
+
for (const node of nodes) {
|
|
35
|
+
result.push({ node, level });
|
|
36
|
+
if (node.children) {
|
|
37
|
+
result.push(...flattenTree(node.children, level + 1));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export function FileTree({
|
|
46
|
+
nodes,
|
|
47
|
+
selectedPath,
|
|
48
|
+
unsavedPaths,
|
|
49
|
+
focusedPath,
|
|
50
|
+
onSelectFile,
|
|
51
|
+
}: FileTreeProps) {
|
|
52
|
+
const flatNodes = useMemo(() => flattenTree(nodes), [nodes]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<box
|
|
56
|
+
flexDirection="column"
|
|
57
|
+
flexGrow={1}
|
|
58
|
+
border={true}
|
|
59
|
+
borderStyle="single"
|
|
60
|
+
borderColor={Colors.border}
|
|
61
|
+
paddingX={1}
|
|
62
|
+
paddingY={0}
|
|
63
|
+
>
|
|
64
|
+
{/* Header */}
|
|
65
|
+
<box paddingLeft={1} paddingRight={1} paddingTop={0} paddingBottom={0}>
|
|
66
|
+
<text fg={Colors.accent} bold>
|
|
67
|
+
FILES
|
|
68
|
+
</text>
|
|
69
|
+
</box>
|
|
70
|
+
|
|
71
|
+
<text fg={Colors.border} dim>
|
|
72
|
+
{"─".repeat(30)}
|
|
73
|
+
</text>
|
|
74
|
+
|
|
75
|
+
{/* File tree items */}
|
|
76
|
+
<scrollbox width="100%" flexGrow={1} border={false}>
|
|
77
|
+
{flatNodes.map(({ node, level }, _idx) => {
|
|
78
|
+
const isSelected = node.path === selectedPath;
|
|
79
|
+
const isFocused = node.path === focusedPath;
|
|
80
|
+
const isUnsaved = node.type === "file" && unsavedPaths.has(node.path);
|
|
81
|
+
const indent = " ".repeat(level);
|
|
82
|
+
|
|
83
|
+
// Prefix: directory gets ▶/▼, file gets space
|
|
84
|
+
const prefix = node.type === "directory" ? "📁" : " ";
|
|
85
|
+
const marker = isUnsaved ? " [*]" : "";
|
|
86
|
+
const label = `${indent}${prefix} ${node.name}${marker}`;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<text
|
|
90
|
+
key={node.path}
|
|
91
|
+
fg={
|
|
92
|
+
isSelected
|
|
93
|
+
? Colors.accent
|
|
94
|
+
: isFocused
|
|
95
|
+
? Colors.foreground
|
|
96
|
+
: node.type === "directory"
|
|
97
|
+
? Colors.info
|
|
98
|
+
: Colors.foreground
|
|
99
|
+
}
|
|
100
|
+
bg={isSelected || isFocused ? Colors.card : undefined}
|
|
101
|
+
bold={isSelected}
|
|
102
|
+
onMouseUp={() => {
|
|
103
|
+
if (node.type === "file") onSelectFile(node.path);
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{label}
|
|
107
|
+
</text>
|
|
108
|
+
);
|
|
109
|
+
})}
|
|
110
|
+
</scrollbox>
|
|
111
|
+
</box>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @jsxImportSource @opentui/react */
|
|
7
|
+
|
|
8
|
+
// ─── Shared Types for Config Editor ──────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** A node in the file tree (file or directory). */
|
|
11
|
+
export interface FileNode {
|
|
12
|
+
name: string;
|
|
13
|
+
path: string;
|
|
14
|
+
type: "file" | "directory";
|
|
15
|
+
children?: FileNode[];
|
|
16
|
+
isLoading?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Validation error discovered during syntax check. */
|
|
20
|
+
export interface SyntaxErrorEntry {
|
|
21
|
+
line: number;
|
|
22
|
+
column: number;
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Pane focus target for tab cycling. */
|
|
27
|
+
export type ActivePane = "tree" | "editor";
|
|
28
|
+
|
|
29
|
+
/** Colored segment within a single line of TOML/JSON. */
|
|
30
|
+
export interface TokenSpan {
|
|
31
|
+
text: string;
|
|
32
|
+
color: string;
|
|
33
|
+
bold: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Determine file type from path extension. */
|
|
37
|
+
export type FileType = "toml" | "json" | "env" | "unknown";
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @jsxImportSource @opentui/react */
|
|
7
|
+
import { describe, it, expect } from "bun:test";
|
|
8
|
+
import { Colors } from "@hoox-sh/hoox-shared";
|
|
9
|
+
import {
|
|
10
|
+
tokenizeTomlLine,
|
|
11
|
+
tokenizeJsonLine,
|
|
12
|
+
detectFileType,
|
|
13
|
+
validateSyntax,
|
|
14
|
+
formatContent,
|
|
15
|
+
flattenTree,
|
|
16
|
+
CONFIG_TREE_BLUEPRINT,
|
|
17
|
+
resolveConfigDir,
|
|
18
|
+
resolveConfigFilePath,
|
|
19
|
+
resetResolvedConfigDir,
|
|
20
|
+
isSafeConfigRelativePath,
|
|
21
|
+
sanitizeConfigStatus,
|
|
22
|
+
MAX_CONFIG_FILE_BYTES,
|
|
23
|
+
MAX_EDITOR_DISPLAY_LINES,
|
|
24
|
+
} from "./config-editor";
|
|
25
|
+
import type { FileNode } from "./config-editor";
|
|
26
|
+
|
|
27
|
+
describe("ConfigEditor", () => {
|
|
28
|
+
// ─── Path resolution (no double config/config) ─────────────────────────
|
|
29
|
+
|
|
30
|
+
describe("resolveConfigFilePath", () => {
|
|
31
|
+
it("does not double-prefix config/ under a project root", () => {
|
|
32
|
+
resetResolvedConfigDir();
|
|
33
|
+
const root = resolveConfigDir();
|
|
34
|
+
const full = resolveConfigFilePath("config/wrangler.toml");
|
|
35
|
+
expect(full).not.toContain("config/config/");
|
|
36
|
+
expect(
|
|
37
|
+
full.endsWith("config/wrangler.toml") ||
|
|
38
|
+
full.endsWith("config\\wrangler.toml")
|
|
39
|
+
).toBe(true);
|
|
40
|
+
// Root should not itself be the config directory when blueprint uses config/…
|
|
41
|
+
if (root.endsWith("/config") || root.endsWith("\\config")) {
|
|
42
|
+
// If root is config dir, relative paths must strip config/ — covered above
|
|
43
|
+
expect(full.includes(`${root}/config/`)).toBe(false);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects path traversal attempts", () => {
|
|
48
|
+
resetResolvedConfigDir();
|
|
49
|
+
expect(() => resolveConfigFilePath("../etc/passwd")).toThrow(
|
|
50
|
+
/Unsafe|escapes/i
|
|
51
|
+
);
|
|
52
|
+
expect(() => resolveConfigFilePath("config/../../etc/passwd")).toThrow(
|
|
53
|
+
/Unsafe|escapes/i
|
|
54
|
+
);
|
|
55
|
+
expect(() => resolveConfigFilePath("/etc/passwd")).toThrow(/Unsafe/i);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("isSafeConfigRelativePath", () => {
|
|
60
|
+
it("accepts blueprint-style relative paths", () => {
|
|
61
|
+
expect(isSafeConfigRelativePath("config/wrangler.toml")).toBe(true);
|
|
62
|
+
expect(
|
|
63
|
+
isSafeConfigRelativePath("config/strategies/grid.config.json")
|
|
64
|
+
).toBe(true);
|
|
65
|
+
expect(isSafeConfigRelativePath("config/.env")).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("rejects absolute and traversal paths", () => {
|
|
69
|
+
expect(isSafeConfigRelativePath("/etc/passwd")).toBe(false);
|
|
70
|
+
expect(isSafeConfigRelativePath("../secret")).toBe(false);
|
|
71
|
+
expect(isSafeConfigRelativePath("config/../../../etc/passwd")).toBe(
|
|
72
|
+
false
|
|
73
|
+
);
|
|
74
|
+
expect(isSafeConfigRelativePath("")).toBe(false);
|
|
75
|
+
expect(isSafeConfigRelativePath("foo\0bar")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("sanitizeConfigStatus", () => {
|
|
80
|
+
it("redacts bearer tokens and truncates long messages", () => {
|
|
81
|
+
const out = sanitizeConfigStatus(
|
|
82
|
+
"error Authorization: Bearer sk-live-supersecret-token-value more detail " +
|
|
83
|
+
"x".repeat(200),
|
|
84
|
+
80
|
|
85
|
+
);
|
|
86
|
+
expect(out).not.toContain("sk-live-supersecret");
|
|
87
|
+
expect(out.toLowerCase()).toContain("redacted");
|
|
88
|
+
expect(out.length).toBeLessThanOrEqual(80);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("size limits", () => {
|
|
93
|
+
it("exports sane max file and display line caps", () => {
|
|
94
|
+
expect(MAX_CONFIG_FILE_BYTES).toBe(1024 * 1024);
|
|
95
|
+
expect(MAX_EDITOR_DISPLAY_LINES).toBe(5_000);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ─── Tree Structure Tests ──────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("CONFIG_TREE_BLUEPRINT", () => {
|
|
102
|
+
it("has config directory as root", async () => {
|
|
103
|
+
expect(CONFIG_TREE_BLUEPRINT.length).toBeGreaterThan(0);
|
|
104
|
+
expect(CONFIG_TREE_BLUEPRINT[0].name).toBe("config");
|
|
105
|
+
expect(CONFIG_TREE_BLUEPRINT[0].type).toBe("directory");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("contains all 5 top-level entries in config/", async () => {
|
|
109
|
+
const children = CONFIG_TREE_BLUEPRINT[0].children!;
|
|
110
|
+
expect(children.length).toBe(5); // wrangler.toml, trade.config.json, risk.config.json, strategies, .env
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("strategies directory has 3 config files", async () => {
|
|
114
|
+
const strategies = CONFIG_TREE_BLUEPRINT[0].children!.find(
|
|
115
|
+
(c) => c.name === "strategies"
|
|
116
|
+
);
|
|
117
|
+
expect(strategies).toBeDefined();
|
|
118
|
+
expect(strategies!.children!.length).toBe(3);
|
|
119
|
+
const names = strategies!.children!.map((c) => c.name);
|
|
120
|
+
expect(names).toContain("grid.config.json");
|
|
121
|
+
expect(names).toContain("macd.config.json");
|
|
122
|
+
expect(names).toContain("scalping.config.json");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ─── flattenTree Tests ─────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
describe("flattenTree", () => {
|
|
129
|
+
it("flattens directory tree with levels", async () => {
|
|
130
|
+
const nodes: FileNode[] = [
|
|
131
|
+
{
|
|
132
|
+
name: "dir",
|
|
133
|
+
path: "dir",
|
|
134
|
+
type: "directory",
|
|
135
|
+
children: [{ name: "file.txt", path: "dir/file.txt", type: "file" }],
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
const flat = flattenTree(nodes);
|
|
139
|
+
expect(flat.length).toBe(2); // directory + file
|
|
140
|
+
expect(flat[0].node.name).toBe("dir");
|
|
141
|
+
expect(flat[0].level).toBe(0);
|
|
142
|
+
expect(flat[1].node.name).toBe("file.txt");
|
|
143
|
+
expect(flat[1].level).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns empty array for empty input", async () => {
|
|
147
|
+
expect(flattenTree([])).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ─── detectFileType Tests ──────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
describe("detectFileType", () => {
|
|
154
|
+
it("detects TOML files", async () => {
|
|
155
|
+
expect(detectFileType("config/wrangler.toml")).toBe("toml");
|
|
156
|
+
expect(detectFileType("config/settings.TOML")).toBe("toml");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("detects JSON files", async () => {
|
|
160
|
+
expect(detectFileType("config/trade.config.json")).toBe("json");
|
|
161
|
+
expect(detectFileType("risk.config.json")).toBe("json");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("detects .env files", async () => {
|
|
165
|
+
expect(detectFileType("config/.env")).toBe("env");
|
|
166
|
+
expect(detectFileType(".env.local")).toBe("env");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("returns unknown for other extensions", async () => {
|
|
170
|
+
expect(detectFileType("config/notes.txt")).toBe("unknown");
|
|
171
|
+
expect(detectFileType("config/data.yaml")).toBe("unknown");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─── TOML Syntax Highlighting Tests ────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
describe("tokenizeTomlLine", () => {
|
|
178
|
+
it("highlights full-line comments as muted", async () => {
|
|
179
|
+
const tokens = tokenizeTomlLine("# this is a comment");
|
|
180
|
+
expect(tokens.length).toBeGreaterThan(0);
|
|
181
|
+
expect(tokens[0].text).toContain("#");
|
|
182
|
+
expect(tokens[0].color).toBe(Colors.muted);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("highlights section headers as accent bold", async () => {
|
|
186
|
+
const tokens = tokenizeTomlLine("[env.production]");
|
|
187
|
+
const sectionToken = tokens.find((t) => t.text.includes("["));
|
|
188
|
+
expect(sectionToken).toBeDefined();
|
|
189
|
+
expect(sectionToken!.color).toBe(Colors.accent);
|
|
190
|
+
expect(sectionToken!.bold).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("highlights key values with accent color", async () => {
|
|
194
|
+
const tokens = tokenizeTomlLine('name = "hoox"');
|
|
195
|
+
const keyToken = tokens.find((t) => t.text.trim() === "name");
|
|
196
|
+
expect(keyToken).toBeDefined();
|
|
197
|
+
expect(keyToken!.color).toBe(Colors.accent);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("highlights string values with info color", async () => {
|
|
201
|
+
const tokens = tokenizeTomlLine('url = "https://example.com"');
|
|
202
|
+
const stringToken = tokens.find((t) => t.text.includes('"'));
|
|
203
|
+
expect(stringToken).toBeDefined();
|
|
204
|
+
expect(stringToken!.color).toBe(Colors.info);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("highlights boolean values with info color", async () => {
|
|
208
|
+
const tokens = tokenizeTomlLine("enabled = true");
|
|
209
|
+
const boolToken = tokens.find((t) => t.text.trim() === "true");
|
|
210
|
+
expect(boolToken).toBeDefined();
|
|
211
|
+
expect(boolToken!.color).toBe(Colors.info);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("handles empty lines", async () => {
|
|
215
|
+
const tokens = tokenizeTomlLine("");
|
|
216
|
+
expect(tokens.length).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("handles inline trailing comments", async () => {
|
|
220
|
+
const tokens = tokenizeTomlLine('name = "value" # trailing comment');
|
|
221
|
+
const commentToken = tokens.find((t) => t.text.includes("#"));
|
|
222
|
+
expect(commentToken).toBeDefined();
|
|
223
|
+
expect(commentToken!.color).toBe(Colors.muted);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ─── JSON Syntax Highlighting Tests ────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
describe("tokenizeJsonLine", () => {
|
|
230
|
+
it("highlights JSON keys with accent color", async () => {
|
|
231
|
+
const tokens = tokenizeJsonLine(' "name": "value",');
|
|
232
|
+
const keyToken = tokens.find((t) => t.text.includes("name"));
|
|
233
|
+
expect(keyToken).toBeDefined();
|
|
234
|
+
expect(keyToken!.color).toBe(Colors.accent);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("highlights JSON string values with info color", async () => {
|
|
238
|
+
const tokens = tokenizeJsonLine(' "key": "hello world"');
|
|
239
|
+
const valToken = tokens.find((t) => t.text.includes("hello"));
|
|
240
|
+
expect(valToken).toBeDefined();
|
|
241
|
+
expect(valToken!.color).toBe(Colors.info);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("highlights JSON numbers with foreground color", async () => {
|
|
245
|
+
const tokens = tokenizeJsonLine(' "count": 42');
|
|
246
|
+
const numToken = tokens.find((t) => t.text.trim() === "42");
|
|
247
|
+
expect(numToken).toBeDefined();
|
|
248
|
+
expect(numToken!.color).toBe(Colors.foreground);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("highlights JSON null with muted color", async () => {
|
|
252
|
+
const tokens = tokenizeJsonLine(' "data": null');
|
|
253
|
+
const nullToken = tokens.find((t) => t.text.trim() === "null");
|
|
254
|
+
expect(nullToken).toBeDefined();
|
|
255
|
+
expect(nullToken!.color).toBe(Colors.muted);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("highlights JSON booleans with info color", async () => {
|
|
259
|
+
const tokens = tokenizeJsonLine(' "active": true');
|
|
260
|
+
const boolToken = tokens.find((t) => t.text.trim() === "true");
|
|
261
|
+
expect(boolToken).toBeDefined();
|
|
262
|
+
expect(boolToken!.color).toBe(Colors.info);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("handles nested braces", async () => {
|
|
266
|
+
const tokens = tokenizeJsonLine('{ "key": { "nested": 1 } }');
|
|
267
|
+
// Should not crash
|
|
268
|
+
expect(tokens.length).toBeGreaterThan(0);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ─── Syntax Validation Tests ───────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
describe("validateSyntax", () => {
|
|
275
|
+
it("returns no errors for valid JSON", async () => {
|
|
276
|
+
const errors = validateSyntax('{"valid": "json", "num": 42}', "json");
|
|
277
|
+
expect(errors.length).toBe(0);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("returns errors for invalid JSON", async () => {
|
|
281
|
+
const errors = validateSyntax('{"broken": invalid}', "json");
|
|
282
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("returns errors for JSON with trailing comma", async () => {
|
|
286
|
+
const errors = validateSyntax('{"key": "value",}', "json");
|
|
287
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("returns no errors for well-formed TOML", async () => {
|
|
291
|
+
const errors = validateSyntax('[section]\nkey = "value"\n', "toml");
|
|
292
|
+
expect(errors.length).toBe(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("returns errors for TOML with unbalanced brackets", async () => {
|
|
296
|
+
const errors = validateSyntax("[section\nkey = value\n", "toml");
|
|
297
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("returns errors for TOML with unbalanced quotes", async () => {
|
|
301
|
+
const errors = validateSyntax('key = "unclosed string\n', "toml");
|
|
302
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ─── Real TOML parser integration ────────────────────────────────
|
|
306
|
+
|
|
307
|
+
it("parses multi-line literal strings (''' ... ''')", async () => {
|
|
308
|
+
const toml = [
|
|
309
|
+
"[post]",
|
|
310
|
+
"title = '''",
|
|
311
|
+
"First line",
|
|
312
|
+
"Second line",
|
|
313
|
+
"'''",
|
|
314
|
+
"",
|
|
315
|
+
].join("\n");
|
|
316
|
+
const errors = validateSyntax(toml, "toml");
|
|
317
|
+
expect(errors.length).toBe(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('parses multi-line basic strings (""" ... """)', async () => {
|
|
321
|
+
const toml = [
|
|
322
|
+
"[post]",
|
|
323
|
+
'description = """',
|
|
324
|
+
"Line one",
|
|
325
|
+
'Line two with "quotes"',
|
|
326
|
+
'"""',
|
|
327
|
+
"",
|
|
328
|
+
].join("\n");
|
|
329
|
+
const errors = validateSyntax(toml, "toml");
|
|
330
|
+
expect(errors.length).toBe(0);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('parses inline tables ({ key = "value" })', async () => {
|
|
334
|
+
const toml = [
|
|
335
|
+
"[servers]",
|
|
336
|
+
'alpha = { ip = "10.0.0.1", port = 8000 }',
|
|
337
|
+
'beta = { ip = "10.0.0.2", port = 8001, enabled = true }',
|
|
338
|
+
"",
|
|
339
|
+
].join("\n");
|
|
340
|
+
const errors = validateSyntax(toml, "toml");
|
|
341
|
+
expect(errors.length).toBe(0);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("parses arrays of tables ([[name]])", async () => {
|
|
345
|
+
const toml = [
|
|
346
|
+
"[[fruits]]",
|
|
347
|
+
'name = "apple"',
|
|
348
|
+
"",
|
|
349
|
+
"[[fruits]]",
|
|
350
|
+
'name = "banana"',
|
|
351
|
+
"",
|
|
352
|
+
].join("\n");
|
|
353
|
+
const errors = validateSyntax(toml, "toml");
|
|
354
|
+
expect(errors.length).toBe(0);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("parses arrays with mixed primitive types", async () => {
|
|
358
|
+
const toml = [
|
|
359
|
+
"[data]",
|
|
360
|
+
"ints = [1, 2, 3]",
|
|
361
|
+
'strings = ["a", "b", "c"]',
|
|
362
|
+
"nested = [[1, 2], [3, 4]]",
|
|
363
|
+
"",
|
|
364
|
+
].join("\n");
|
|
365
|
+
const errors = validateSyntax(toml, "toml");
|
|
366
|
+
expect(errors.length).toBe(0);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("parses dotted keys (a.b.c = value)", async () => {
|
|
370
|
+
const toml = 'physical.color = "orange"\nphysical.shape = "round"\n';
|
|
371
|
+
const errors = validateSyntax(toml, "toml");
|
|
372
|
+
expect(errors.length).toBe(0);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("reports accurate line numbers for invalid TOML", async () => {
|
|
376
|
+
// Line 1 valid, Line 2 valid, Line 3 has the error
|
|
377
|
+
const toml = [
|
|
378
|
+
"[section]",
|
|
379
|
+
'key1 = "ok"',
|
|
380
|
+
'key2 = "unclosed',
|
|
381
|
+
'key3 = "fine"',
|
|
382
|
+
"",
|
|
383
|
+
].join("\n");
|
|
384
|
+
const errors = validateSyntax(toml, "toml");
|
|
385
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
386
|
+
// smol-toml's TomlError is 1-based. The error originates on
|
|
387
|
+
// line 3 (unclosed string); the parser may point to the same
|
|
388
|
+
// line or to the first line where it becomes unrecoverable.
|
|
389
|
+
// Either way, the error must NOT be on a known-valid line.
|
|
390
|
+
expect(errors[0].line).toBeGreaterThanOrEqual(3);
|
|
391
|
+
expect(errors[0].line).toBeLessThanOrEqual(4);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("reports 1-based column for invalid TOML", async () => {
|
|
395
|
+
const toml = 'key = "ok"\nbad = = "invalid"\n';
|
|
396
|
+
const errors = validateSyntax(toml, "toml");
|
|
397
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
398
|
+
// Column must be at least 1 (1-based) for a useful error marker.
|
|
399
|
+
expect(errors[0].column).toBeGreaterThanOrEqual(1);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("includes the parser's error message verbatim", async () => {
|
|
403
|
+
const toml = 'key = = "invalid"\n';
|
|
404
|
+
const errors = validateSyntax(toml, "toml");
|
|
405
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
406
|
+
expect(errors[0].message.length).toBeGreaterThan(0);
|
|
407
|
+
// Must not be the old generic "Unbalanced brackets" message.
|
|
408
|
+
expect(errors[0].message).not.toMatch(/unbalanced/i);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("returns empty errors for empty TOML", async () => {
|
|
412
|
+
const errors = validateSyntax("", "toml");
|
|
413
|
+
expect(errors.length).toBe(0);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("handles TOML with only comments", async () => {
|
|
417
|
+
const toml = "# just a comment\n# another one\n";
|
|
418
|
+
const errors = validateSyntax(toml, "toml");
|
|
419
|
+
expect(errors.length).toBe(0);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("parses 10KB of TOML in under 100ms", async () => {
|
|
423
|
+
// Build a 10KB TOML document with many keys.
|
|
424
|
+
const lines: string[] = ["[big]"];
|
|
425
|
+
let i = 0;
|
|
426
|
+
while (lines.join("\n").length < 10_000) {
|
|
427
|
+
lines.push(`key_${i} = "value_${i}"`);
|
|
428
|
+
i++;
|
|
429
|
+
}
|
|
430
|
+
const toml = lines.join("\n") + "\n";
|
|
431
|
+
|
|
432
|
+
const t0 = performance.now();
|
|
433
|
+
const errors = validateSyntax(toml, "toml");
|
|
434
|
+
const elapsed = performance.now() - t0;
|
|
435
|
+
|
|
436
|
+
expect(errors.length).toBe(0);
|
|
437
|
+
expect(elapsed).toBeLessThan(100);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// ─── Format Content Tests ──────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
describe("formatContent", () => {
|
|
444
|
+
it("formats valid JSON with indentation", async () => {
|
|
445
|
+
const unformatted = '{"key":"value","num":42}';
|
|
446
|
+
const formatted = formatContent(unformatted, "json");
|
|
447
|
+
expect(formatted).toContain("\n");
|
|
448
|
+
expect(formatted).toContain('"key"');
|
|
449
|
+
expect(formatted).toContain('"value"');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("returns unchanged content for invalid JSON", async () => {
|
|
453
|
+
const invalid = '{"broken": invalid}';
|
|
454
|
+
const formatted = formatContent(invalid, "json");
|
|
455
|
+
expect(formatted).toBe(invalid);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("trims trailing whitespace in TOML", async () => {
|
|
459
|
+
const withSpaces = "key = value \n [section] \n";
|
|
460
|
+
const formatted = formatContent(withSpaces, "toml");
|
|
461
|
+
expect(formatted).not.toMatch(/ {2}\n/);
|
|
462
|
+
expect(formatted.endsWith("\n")).toBe(true);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
});
|