opencode-miniterm 1.0.16 → 1.0.18
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/bun.lock +4 -4
- package/package.json +2 -2
- package/src/ansi.ts +2 -0
- package/src/input.ts +86 -28
- package/src/server.ts +9 -7
- package/test/input.test.ts +313 -0
- package/test/README.md +0 -164
- package/test/tmux-menu-examples.ts +0 -119
- package/test/tmux-readline.test.ts +0 -518
package/bun.lock
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"": {
|
|
6
6
|
"name": "opencode-miniterm",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@opencode-ai/sdk": "^1.2.
|
|
8
|
+
"@opencode-ai/sdk": "^1.2.25",
|
|
9
9
|
"allmark": "^1.0.2",
|
|
10
10
|
},
|
|
11
11
|
"devDependencies": {
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
|
|
48
48
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
|
49
49
|
|
|
50
|
-
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.
|
|
50
|
+
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.27", "", {}, "sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA=="],
|
|
51
51
|
|
|
52
52
|
"@trivago/prettier-plugin-sort-imports": ["@trivago/prettier-plugin-sort-imports@6.0.2", "", { "dependencies": { "@babel/generator": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "javascript-natural-sort": "^0.7.1", "lodash-es": "^4.17.21", "minimatch": "^9.0.0", "parse-imports-exports": "^0.2.4" }, "peerDependencies": { "@vue/compiler-sfc": "3.x", "prettier": "2.x - 3.x", "prettier-plugin-ember-template-tag": ">= 2.0.0", "prettier-plugin-svelte": "3.x", "svelte": "4.x || 5.x" }, "optionalPeers": ["@vue/compiler-sfc", "prettier-plugin-ember-template-tag", "prettier-plugin-svelte", "svelte"] }, "sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA=="],
|
|
53
53
|
|
|
54
|
-
"@types/bun": ["@types/bun@1.3.
|
|
54
|
+
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
|
55
55
|
|
|
56
56
|
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
|
|
57
57
|
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
|
|
78
78
|
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
|
79
79
|
|
|
80
|
-
"bun-types": ["bun-types@1.3.
|
|
80
|
+
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
|
81
81
|
|
|
82
82
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
83
83
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-miniterm",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.18",
|
|
4
4
|
"description": "A small front-end terminal UI for OpenCode",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"typescript": "^5"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@opencode-ai/sdk": "^1.2.
|
|
32
|
+
"@opencode-ai/sdk": "^1.2.27",
|
|
33
33
|
"allmark": "^1.0.2"
|
|
34
34
|
}
|
|
35
35
|
}
|
package/src/ansi.ts
CHANGED
|
@@ -6,6 +6,8 @@ export const CURSOR_HOME = "\x1b[0G";
|
|
|
6
6
|
export const CURSOR_HIDE = "\x1b[?25l";
|
|
7
7
|
export const CURSOR_SHOW = "\x1b[?25h";
|
|
8
8
|
export const CURSOR_UP = (lines: number) => `\x1b[${lines}A`;
|
|
9
|
+
export const CURSOR_DOWN = (lines: number) => `\x1b[${lines}B`;
|
|
10
|
+
export const CURSOR_COL = (col: number) => `\x1b[${col + 1}G`;
|
|
9
11
|
export const DISABLE_LINE_WRAP = "\x1b[?7l";
|
|
10
12
|
export const ENABLE_LINE_WRAP = "\x1b[?7h";
|
|
11
13
|
export const RESET = "\x1b[0m";
|
package/src/input.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Part } from "@opencode-ai/sdk";
|
|
2
2
|
import { glob } from "node:fs/promises";
|
|
3
3
|
import { stat } from "node:fs/promises";
|
|
4
|
-
import
|
|
4
|
+
import { type Key } from "node:readline";
|
|
5
5
|
import * as ansi from "./ansi";
|
|
6
6
|
import agentsCommand from "./commands/agents";
|
|
7
7
|
import debugCommand from "./commands/debug";
|
|
@@ -57,68 +57,103 @@ export function renderLine(): void {
|
|
|
57
57
|
const consoleWidth = process.stdout.columns || 80;
|
|
58
58
|
|
|
59
59
|
// Move to the start of the line (i.e. the prompt position)
|
|
60
|
-
|
|
60
|
+
process.stdout.write(ansi.CURSOR_HOME);
|
|
61
61
|
if (oldWrappedRows > 0) {
|
|
62
62
|
if (cursorPosition < inputBuffer.length) {
|
|
63
|
-
|
|
63
|
+
process.stdout.write(ansi.CURSOR_DOWN(oldWrappedRows - oldCursorRow));
|
|
64
64
|
}
|
|
65
|
-
|
|
65
|
+
process.stdout.write(ansi.CURSOR_UP(oldWrappedRows));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// Find the position where the input has changed (i.e. where the user has
|
|
69
69
|
// typed something)
|
|
70
70
|
let start = 0;
|
|
71
71
|
let currentCol = 2;
|
|
72
|
+
let currentRow = 0;
|
|
72
73
|
let newWrappedRows = 0;
|
|
73
74
|
for (let i = 0; i < Math.min(oldInputBuffer.length, inputBuffer.length); i++) {
|
|
74
75
|
if (oldInputBuffer[i] !== inputBuffer[i]) {
|
|
75
76
|
break;
|
|
76
77
|
}
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
currentCol =
|
|
80
|
-
|
|
78
|
+
if (inputBuffer[i] === "\n") {
|
|
79
|
+
currentRow++;
|
|
80
|
+
currentCol = 2;
|
|
81
|
+
} else {
|
|
82
|
+
if (currentCol >= consoleWidth) {
|
|
83
|
+
currentCol = 0;
|
|
84
|
+
currentRow++;
|
|
85
|
+
newWrappedRows++;
|
|
86
|
+
}
|
|
87
|
+
currentCol++;
|
|
81
88
|
}
|
|
82
|
-
currentCol++;
|
|
83
89
|
start++;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
// Position the cursor at where the difference starts, then clear
|
|
93
|
+
// Check if we need to wrap after the comparison loop
|
|
94
|
+
if (currentCol >= consoleWidth) {
|
|
95
|
+
currentCol = 0;
|
|
96
|
+
currentRow++;
|
|
97
|
+
newWrappedRows++;
|
|
98
|
+
}
|
|
99
|
+
if (currentRow > 0) {
|
|
100
|
+
process.stdout.write(ansi.CURSOR_DOWN(currentRow));
|
|
101
|
+
}
|
|
102
|
+
process.stdout.write(ansi.CURSOR_COL(currentCol));
|
|
103
|
+
process.stdout.write(ansi.CLEAR_FROM_CURSOR);
|
|
89
104
|
|
|
90
105
|
// Write the prompt if this is a fresh buffer
|
|
91
106
|
if (start === 0) {
|
|
92
|
-
|
|
107
|
+
process.stdout.write(ansi.CURSOR_HOME);
|
|
93
108
|
writePrompt();
|
|
94
|
-
|
|
109
|
+
process.stdout.write(ansi.CURSOR_COL(2));
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
// Write the changes from the new input buffer
|
|
98
113
|
let renderExtent = Math.max(cursorPosition + 1, inputBuffer.length);
|
|
99
114
|
for (let i = start; i < renderExtent; i++) {
|
|
100
|
-
if (
|
|
115
|
+
if (i < inputBuffer.length && inputBuffer[i] === "\n") {
|
|
101
116
|
process.stdout.write("\n");
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
117
|
+
currentRow++;
|
|
118
|
+
currentCol = 2;
|
|
119
|
+
} else {
|
|
120
|
+
if (currentCol >= consoleWidth) {
|
|
121
|
+
process.stdout.write("\n");
|
|
122
|
+
currentCol = 0;
|
|
123
|
+
newWrappedRows++;
|
|
124
|
+
}
|
|
125
|
+
if (i < inputBuffer.length) {
|
|
126
|
+
process.stdout.write(inputBuffer[i]!);
|
|
127
|
+
}
|
|
128
|
+
currentCol++;
|
|
107
129
|
}
|
|
108
|
-
currentCol++;
|
|
109
130
|
}
|
|
110
131
|
|
|
111
132
|
// Calculate and move to the cursor's position
|
|
112
|
-
let
|
|
113
|
-
let
|
|
114
|
-
let
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
133
|
+
let row = 0;
|
|
134
|
+
let col = 2;
|
|
135
|
+
for (let i = 0; i < cursorPosition; i++) {
|
|
136
|
+
if (i < inputBuffer.length && inputBuffer[i] === "\n") {
|
|
137
|
+
row++;
|
|
138
|
+
col = 2;
|
|
139
|
+
} else {
|
|
140
|
+
col++;
|
|
141
|
+
if (col >= consoleWidth) {
|
|
142
|
+
row++;
|
|
143
|
+
col = 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
process.stdout.write(ansi.CURSOR_HOME);
|
|
148
|
+
let rowsToMove = newWrappedRows - row;
|
|
149
|
+
if (rowsToMove > 0) {
|
|
150
|
+
process.stdout.write(ansi.CURSOR_UP(rowsToMove));
|
|
151
|
+
}
|
|
152
|
+
process.stdout.write(ansi.CURSOR_COL(col));
|
|
118
153
|
|
|
119
154
|
oldInputBuffer = inputBuffer;
|
|
120
155
|
oldWrappedRows = newWrappedRows;
|
|
121
|
-
oldCursorRow =
|
|
156
|
+
oldCursorRow = row;
|
|
122
157
|
}
|
|
123
158
|
|
|
124
159
|
export async function handleKeyPress(state: State, str: string, key: Key) {
|
|
@@ -451,3 +486,26 @@ function findNextWordBoundary(text: string, pos: number): number {
|
|
|
451
486
|
|
|
452
487
|
return newPos;
|
|
453
488
|
}
|
|
489
|
+
|
|
490
|
+
// Test helpers
|
|
491
|
+
export function _setInputState(state: {
|
|
492
|
+
inputBuffer?: string;
|
|
493
|
+
cursorPosition?: number;
|
|
494
|
+
oldInputBuffer?: string;
|
|
495
|
+
oldWrappedRows?: number;
|
|
496
|
+
oldCursorRow?: number;
|
|
497
|
+
}): void {
|
|
498
|
+
if (state.inputBuffer !== undefined) inputBuffer = state.inputBuffer;
|
|
499
|
+
if (state.cursorPosition !== undefined) cursorPosition = state.cursorPosition;
|
|
500
|
+
if (state.oldInputBuffer !== undefined) oldInputBuffer = state.oldInputBuffer;
|
|
501
|
+
if (state.oldWrappedRows !== undefined) oldWrappedRows = state.oldWrappedRows;
|
|
502
|
+
if (state.oldCursorRow !== undefined) oldCursorRow = state.oldCursorRow;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function _resetInputState(): void {
|
|
506
|
+
inputBuffer = "";
|
|
507
|
+
cursorPosition = 0;
|
|
508
|
+
oldInputBuffer = "";
|
|
509
|
+
oldWrappedRows = 0;
|
|
510
|
+
oldCursorRow = 0;
|
|
511
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -342,6 +342,7 @@ function processDelta(state: State, partID: string, delta: string) {
|
|
|
342
342
|
|
|
343
343
|
async function processDiff(state: State, diff: FileDiff[]) {
|
|
344
344
|
const parts: string[] = [];
|
|
345
|
+
|
|
345
346
|
for (const file of diff) {
|
|
346
347
|
const newAfter = file.after ?? "";
|
|
347
348
|
const oldAfter = state.lastFileAfter.get(file.file);
|
|
@@ -358,18 +359,19 @@ async function processDiff(state: State, diff: FileDiff[]) {
|
|
|
358
359
|
}
|
|
359
360
|
|
|
360
361
|
if (parts.length > 0) {
|
|
361
|
-
|
|
362
|
+
const diffText = parts.join("\n");
|
|
363
|
+
state.accumulatedResponse.push({ key: "diff", title: "files", text: diffText });
|
|
362
364
|
|
|
363
|
-
|
|
364
|
-
await writeToLog(`${diffText}\n\n`);
|
|
365
|
+
await writeToLog(`${ansi.stripAnsiCodes(diffText)}\n\n`);
|
|
365
366
|
|
|
366
367
|
render(state);
|
|
367
368
|
}
|
|
368
369
|
}
|
|
369
370
|
|
|
370
371
|
async function processTodos(state: State, todos: Todo[]) {
|
|
371
|
-
|
|
372
|
+
const parts: string[] = [];
|
|
372
373
|
|
|
374
|
+
parts.push("Todo:");
|
|
373
375
|
for (let todo of todos) {
|
|
374
376
|
let todoText = "";
|
|
375
377
|
if (todo.status === "completed") {
|
|
@@ -378,13 +380,13 @@ async function processTodos(state: State, todos: Todo[]) {
|
|
|
378
380
|
todoText += "- [ ] ";
|
|
379
381
|
}
|
|
380
382
|
todoText += todo.content;
|
|
381
|
-
|
|
383
|
+
parts.push(todoText);
|
|
382
384
|
}
|
|
383
385
|
|
|
386
|
+
const todoListText = parts.join("\n");
|
|
384
387
|
state.accumulatedResponse.push({ key: "todo", title: "files", text: todoListText });
|
|
385
388
|
|
|
386
|
-
|
|
387
|
-
await writeToLog(`${cleanTodoText}\n`);
|
|
389
|
+
await writeToLog(`${ansi.stripAnsiCodes(todoListText)}\n\n`);
|
|
388
390
|
|
|
389
391
|
render(state);
|
|
390
392
|
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
2
|
+
import * as ansi from "../src/ansi";
|
|
3
|
+
import { _resetInputState, _setInputState, renderLine } from "../src/input";
|
|
4
|
+
import * as render from "../src/render";
|
|
5
|
+
|
|
6
|
+
describe("renderLine", () => {
|
|
7
|
+
let writeSpy: ReturnType<typeof spyOn>;
|
|
8
|
+
let writePromptSpy: ReturnType<typeof spyOn>;
|
|
9
|
+
const originalColumns = process.stdout.columns;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
_resetInputState();
|
|
13
|
+
writeSpy = spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
14
|
+
writePromptSpy = spyOn(render, "writePrompt").mockImplementation(() => {});
|
|
15
|
+
// Set a reasonable console width for testing
|
|
16
|
+
Object.defineProperty(process.stdout, "columns", {
|
|
17
|
+
value: 80,
|
|
18
|
+
writable: true,
|
|
19
|
+
configurable: true,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
writeSpy.mockRestore();
|
|
25
|
+
writePromptSpy.mockRestore();
|
|
26
|
+
Object.defineProperty(process.stdout, "columns", {
|
|
27
|
+
value: originalColumns,
|
|
28
|
+
writable: true,
|
|
29
|
+
configurable: true,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("initial render", () => {
|
|
34
|
+
it("should render prompt and cursor at position 0 on first call", () => {
|
|
35
|
+
_setInputState({ inputBuffer: "", cursorPosition: 0 });
|
|
36
|
+
|
|
37
|
+
renderLine();
|
|
38
|
+
|
|
39
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
40
|
+
// Should start with CURSOR_HOME
|
|
41
|
+
expect(calls[0]).toBe(ansi.CURSOR_HOME);
|
|
42
|
+
// Should contain CURSOR_COL for positioning after prompt (col 2)
|
|
43
|
+
expect(calls).toContain(ansi.CURSOR_COL(2));
|
|
44
|
+
// Should contain CLEAR_FROM_CURSOR
|
|
45
|
+
expect(calls).toContain(ansi.CLEAR_FROM_CURSOR);
|
|
46
|
+
// Should end with cursor positioning at col 2
|
|
47
|
+
expect(calls[calls.length - 1]).toBe(ansi.CURSOR_COL(2));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should write prompt when starting fresh", () => {
|
|
51
|
+
_setInputState({ inputBuffer: "", cursorPosition: 0 });
|
|
52
|
+
|
|
53
|
+
renderLine();
|
|
54
|
+
|
|
55
|
+
expect(writePromptSpy).toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("typing characters", () => {
|
|
60
|
+
it("should write new characters when typing", () => {
|
|
61
|
+
_setInputState({
|
|
62
|
+
inputBuffer: "a",
|
|
63
|
+
cursorPosition: 1,
|
|
64
|
+
oldInputBuffer: "",
|
|
65
|
+
oldWrappedRows: 0,
|
|
66
|
+
oldCursorRow: 0,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
renderLine();
|
|
70
|
+
|
|
71
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
72
|
+
expect(calls).toContain("a");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should only write changed portion of input", () => {
|
|
76
|
+
_setInputState({
|
|
77
|
+
inputBuffer: "hello world",
|
|
78
|
+
cursorPosition: 11,
|
|
79
|
+
oldInputBuffer: "hello ",
|
|
80
|
+
oldWrappedRows: 0,
|
|
81
|
+
oldCursorRow: 0,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
renderLine();
|
|
85
|
+
|
|
86
|
+
const output = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]).join("");
|
|
87
|
+
// Should only write "world" not the whole string
|
|
88
|
+
expect(output).toContain("world");
|
|
89
|
+
expect(output).not.toContain("hellohello");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should clear from cursor before writing changes", () => {
|
|
93
|
+
_setInputState({
|
|
94
|
+
inputBuffer: "ab",
|
|
95
|
+
cursorPosition: 2,
|
|
96
|
+
oldInputBuffer: "a",
|
|
97
|
+
oldWrappedRows: 0,
|
|
98
|
+
oldCursorRow: 0,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
renderLine();
|
|
102
|
+
|
|
103
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
104
|
+
const clearIndex = calls.indexOf(ansi.CLEAR_FROM_CURSOR);
|
|
105
|
+
const writeIndex = calls.indexOf("b");
|
|
106
|
+
expect(clearIndex).toBeLessThan(writeIndex);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("cursor movement", () => {
|
|
111
|
+
it("should position cursor at correct column after render", () => {
|
|
112
|
+
_setInputState({
|
|
113
|
+
inputBuffer: "hello",
|
|
114
|
+
cursorPosition: 5,
|
|
115
|
+
oldInputBuffer: "",
|
|
116
|
+
oldWrappedRows: 0,
|
|
117
|
+
oldCursorRow: 0,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
renderLine();
|
|
121
|
+
|
|
122
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
123
|
+
// Cursor should be at column 7 (prompt "❯ " = 2 chars + 5 chars typed)
|
|
124
|
+
expect(calls).toContain(ansi.CURSOR_COL(7));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should move cursor up when old content was multiple rows", () => {
|
|
128
|
+
_setInputState({
|
|
129
|
+
inputBuffer: "x",
|
|
130
|
+
cursorPosition: 1,
|
|
131
|
+
oldInputBuffer: "",
|
|
132
|
+
oldWrappedRows: 2,
|
|
133
|
+
oldCursorRow: 0,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
renderLine();
|
|
137
|
+
|
|
138
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
139
|
+
expect(calls).toContain(ansi.CURSOR_UP(2));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("line wrapping", () => {
|
|
144
|
+
it("should handle content that wraps to next line", () => {
|
|
145
|
+
Object.defineProperty(process.stdout, "columns", {
|
|
146
|
+
value: 10,
|
|
147
|
+
writable: true,
|
|
148
|
+
configurable: true,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
_setInputState({
|
|
152
|
+
inputBuffer: "helloworld",
|
|
153
|
+
cursorPosition: 10,
|
|
154
|
+
oldInputBuffer: "",
|
|
155
|
+
oldWrappedRows: 0,
|
|
156
|
+
oldCursorRow: 0,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
renderLine();
|
|
160
|
+
|
|
161
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
162
|
+
// Should have a newline when wrapping
|
|
163
|
+
expect(calls).toContain("\n");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("should calculate correct cursor position when wrapped", () => {
|
|
167
|
+
Object.defineProperty(process.stdout, "columns", {
|
|
168
|
+
value: 10,
|
|
169
|
+
writable: true,
|
|
170
|
+
configurable: true,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
_setInputState({
|
|
174
|
+
inputBuffer: "helloworld",
|
|
175
|
+
cursorPosition: 10,
|
|
176
|
+
oldInputBuffer: "",
|
|
177
|
+
oldWrappedRows: 0,
|
|
178
|
+
oldCursorRow: 0,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
renderLine();
|
|
182
|
+
|
|
183
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
184
|
+
// Absolute position 12 (2 prompt + 10 chars), row 1, col 2
|
|
185
|
+
expect(calls).toContain(ansi.CURSOR_COL(2));
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("backspace/delete", () => {
|
|
190
|
+
it("should clear and rewrite when deleting characters", () => {
|
|
191
|
+
_setInputState({
|
|
192
|
+
inputBuffer: "he",
|
|
193
|
+
cursorPosition: 2,
|
|
194
|
+
oldInputBuffer: "hel",
|
|
195
|
+
oldWrappedRows: 0,
|
|
196
|
+
oldCursorRow: 0,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
renderLine();
|
|
200
|
+
|
|
201
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
202
|
+
// Should clear from cursor
|
|
203
|
+
expect(calls).toContain(ansi.CLEAR_FROM_CURSOR);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("state tracking", () => {
|
|
208
|
+
it("should update old state after render", () => {
|
|
209
|
+
_setInputState({
|
|
210
|
+
inputBuffer: "test",
|
|
211
|
+
cursorPosition: 4,
|
|
212
|
+
oldInputBuffer: "",
|
|
213
|
+
oldWrappedRows: 0,
|
|
214
|
+
oldCursorRow: 0,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
renderLine();
|
|
218
|
+
|
|
219
|
+
// After render, calling again should recognize no changes
|
|
220
|
+
writeSpy.mockClear();
|
|
221
|
+
renderLine();
|
|
222
|
+
|
|
223
|
+
const output = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]).join("");
|
|
224
|
+
// Should not write "test" again since nothing changed
|
|
225
|
+
expect(output).not.toContain("test");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("control sequences", () => {
|
|
230
|
+
it("should use CURSOR_HOME to move to start", () => {
|
|
231
|
+
_setInputState({ inputBuffer: "hi", cursorPosition: 2 });
|
|
232
|
+
|
|
233
|
+
renderLine();
|
|
234
|
+
|
|
235
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
236
|
+
const homeCount = calls.filter((c: string) => c === ansi.CURSOR_HOME).length;
|
|
237
|
+
expect(homeCount).toBeGreaterThanOrEqual(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("should use CLEAR_FROM_CURSOR to clear content", () => {
|
|
241
|
+
_setInputState({
|
|
242
|
+
inputBuffer: "changed",
|
|
243
|
+
cursorPosition: 7,
|
|
244
|
+
oldInputBuffer: "old",
|
|
245
|
+
oldWrappedRows: 0,
|
|
246
|
+
oldCursorRow: 0,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
renderLine();
|
|
250
|
+
|
|
251
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
252
|
+
expect(calls).toContain(ansi.CLEAR_FROM_CURSOR);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("should use CURSOR_COL for horizontal positioning", () => {
|
|
256
|
+
_setInputState({ inputBuffer: "abc", cursorPosition: 3 });
|
|
257
|
+
|
|
258
|
+
renderLine();
|
|
259
|
+
|
|
260
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
261
|
+
// Should have CURSOR_COL calls for positioning
|
|
262
|
+
const hasColCodes = calls.some(
|
|
263
|
+
(c: string) => typeof c === "string" && c.match(/^\x1b\[\d+G$/),
|
|
264
|
+
);
|
|
265
|
+
expect(hasColCodes).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("should not emit CURSOR_UP(0) which would move up 1 line", () => {
|
|
269
|
+
_setInputState({ inputBuffer: "x", cursorPosition: 1 });
|
|
270
|
+
|
|
271
|
+
renderLine();
|
|
272
|
+
|
|
273
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
274
|
+
// CURSOR_UP(0) produces "\x1b[0A" which terminals interpret as "move up 1"
|
|
275
|
+
// We should not emit this - only emit CURSOR_UP when rows > 0
|
|
276
|
+
expect(calls).not.toContain(ansi.CURSOR_UP(0));
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("should not move cursor during comparison loop (only calculate position)", () => {
|
|
280
|
+
// With 10-char width, prompt takes 2 chars, so 8 chars fit on first line
|
|
281
|
+
// Typing the 10th char when 9 chars already exist should not move cursor down
|
|
282
|
+
// during the comparison loop, only position at the end
|
|
283
|
+
Object.defineProperty(process.stdout, "columns", {
|
|
284
|
+
value: 10,
|
|
285
|
+
writable: true,
|
|
286
|
+
configurable: true,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
_setInputState({
|
|
290
|
+
inputBuffer: "abcdefghij",
|
|
291
|
+
cursorPosition: 10,
|
|
292
|
+
oldInputBuffer: "abcdefghi",
|
|
293
|
+
oldWrappedRows: 1,
|
|
294
|
+
oldCursorRow: 1,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
renderLine();
|
|
298
|
+
|
|
299
|
+
const calls = writeSpy.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
300
|
+
|
|
301
|
+
// Should have CURSOR_UP to get to top
|
|
302
|
+
expect(calls).toContain(ansi.CURSOR_UP(1));
|
|
303
|
+
|
|
304
|
+
// Should NOT have CURSOR_DOWN during comparison (would be between UP and CLEAR)
|
|
305
|
+
const upIndex = calls.indexOf(ansi.CURSOR_UP(1));
|
|
306
|
+
const clearIndex = calls.indexOf(ansi.CURSOR_UP(1));
|
|
307
|
+
const downAfterUp = calls
|
|
308
|
+
.slice(upIndex, clearIndex)
|
|
309
|
+
.some((c: string) => c === ansi.CURSOR_DOWN(1));
|
|
310
|
+
expect(downAfterUp).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
package/test/README.md
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
# Tmux Readline Tests
|
|
2
|
-
|
|
3
|
-
This directory contains tmux-based integration tests for the readline functionality of opencode-miniterm.
|
|
4
|
-
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
The `tmux-readline.test.ts` script uses tmux to simulate real terminal interactions with the application, testing:
|
|
8
|
-
|
|
9
|
-
- **Input Editing**: Character insertion, backspace, delete key
|
|
10
|
-
- **Cursor Navigation**: Left/right arrow keys, word boundaries (Meta+left/right)
|
|
11
|
-
- **History Navigation**: Up/down arrow through command history
|
|
12
|
-
- **Tab Completion**: Slash commands (`/`) and file references (`@`)
|
|
13
|
-
- **Smart Features**: Double-space to period conversion
|
|
14
|
-
- **Escape Key**: Clearing input and canceling requests
|
|
15
|
-
|
|
16
|
-
## Running the Tests
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
# Run all tmux readline tests
|
|
20
|
-
bun run test:tmux
|
|
21
|
-
|
|
22
|
-
# Or run directly
|
|
23
|
-
bun test/tmux-readline.test.ts
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
## How It Works
|
|
27
|
-
|
|
28
|
-
The test script:
|
|
29
|
-
|
|
30
|
-
1. **Sets up a tmux session** - Creates a detached tmux session for testing
|
|
31
|
-
2. **Starts the application** - Launches opencode-miniterm in the tmux session
|
|
32
|
-
3. **Sends keystrokes** - Simulates user input using `tmux send-keys`
|
|
33
|
-
4. **Captures output** - Uses `tmux capture-pane` to read terminal state
|
|
34
|
-
5. **Verifies behavior** - Checks that the output matches expected results
|
|
35
|
-
6. **Cleans up** - Kills the tmux session after tests complete
|
|
36
|
-
|
|
37
|
-
## Test Cases
|
|
38
|
-
|
|
39
|
-
### Input Editing
|
|
40
|
-
|
|
41
|
-
- **Character insertion**: Typing "hello" should insert characters correctly
|
|
42
|
-
- **Backspace**: Deleting characters before the cursor
|
|
43
|
-
- **Delete key**: Deleting character at the cursor position
|
|
44
|
-
|
|
45
|
-
### Cursor Navigation
|
|
46
|
-
|
|
47
|
-
- **Left arrow**: Moving cursor left one character
|
|
48
|
-
- **Right arrow**: Moving cursor right one character
|
|
49
|
-
- **Meta+Left**: Jumping to previous word boundary
|
|
50
|
-
- **Meta+Right**: Jumping to next word boundary
|
|
51
|
-
|
|
52
|
-
### History Navigation
|
|
53
|
-
|
|
54
|
-
- **Up arrow**: Navigating backward through command history
|
|
55
|
-
- **Down arrow**: Navigating forward through command history
|
|
56
|
-
|
|
57
|
-
### Tab Completion
|
|
58
|
-
|
|
59
|
-
- **Slash commands**: Tab after `/` shows available commands
|
|
60
|
-
- **File references**: Tab after `@` completes file paths
|
|
61
|
-
|
|
62
|
-
### Smart Features
|
|
63
|
-
|
|
64
|
-
- **Double space**: Two spaces within 500ms converts to period + space
|
|
65
|
-
|
|
66
|
-
### Escape Key
|
|
67
|
-
|
|
68
|
-
- **Clear input**: Escape clears the current input buffer
|
|
69
|
-
|
|
70
|
-
## Requirements
|
|
71
|
-
|
|
72
|
-
- **tmux**: Must be installed on your system (`brew install tmux` on macOS)
|
|
73
|
-
- **Bun**: Must have bun installed to run the tests
|
|
74
|
-
- **opencode-miniterm**: Must be able to run the application
|
|
75
|
-
|
|
76
|
-
## Troubleshooting
|
|
77
|
-
|
|
78
|
-
### Tests fail with "Expected prompt to contain X"
|
|
79
|
-
|
|
80
|
-
The test might be running too fast. Increase `WAIT_MS` at the top of the test file.
|
|
81
|
-
|
|
82
|
-
### Tests hang or timeout
|
|
83
|
-
|
|
84
|
-
The application might be stuck in a command menu. Press `Escape` or `C-c` in the tmux session to recover.
|
|
85
|
-
|
|
86
|
-
### tmux session already exists
|
|
87
|
-
|
|
88
|
-
The script automatically kills existing test sessions. If you see errors, manually run:
|
|
89
|
-
|
|
90
|
-
```bash
|
|
91
|
-
tmux kill-session -t opencode-test
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
### Tests don't start
|
|
95
|
-
|
|
96
|
-
Ensure tmux is installed and working:
|
|
97
|
-
|
|
98
|
-
```bash
|
|
99
|
-
tmux -V
|
|
100
|
-
tmux new-session -d -s test
|
|
101
|
-
tmux kill-session -t test
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
## Adding New Tests
|
|
105
|
-
|
|
106
|
-
To add a new test:
|
|
107
|
-
|
|
108
|
-
1. Create an async test function that:
|
|
109
|
-
- Clears input with `await clearInput()`
|
|
110
|
-
- Sends keystrokes with `await sendKeys("key")`
|
|
111
|
-
- Waits for processing with `await sleep(ms)`
|
|
112
|
-
- Captures output with `await capturePane()`
|
|
113
|
-
- Asserts behavior with `assertPromptContains()` or `assertContains()`
|
|
114
|
-
- Throws errors with descriptive messages
|
|
115
|
-
|
|
116
|
-
2. Register the test in `runAllTests()`:
|
|
117
|
-
```typescript
|
|
118
|
-
await runTest("Your test name", testYourFunction);
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
### Example Test
|
|
122
|
-
|
|
123
|
-
```typescript
|
|
124
|
-
async function testYourFeature(): Promise<void> {
|
|
125
|
-
await clearInput();
|
|
126
|
-
await sendKeys("test");
|
|
127
|
-
await sendKeys("Enter");
|
|
128
|
-
await sleep(1000);
|
|
129
|
-
|
|
130
|
-
const output = await capturePane();
|
|
131
|
-
if (!assertContains(output, "expected text")) {
|
|
132
|
-
throw new Error(`Expected output to contain 'expected text'`);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
## Command Menu Tests
|
|
138
|
-
|
|
139
|
-
For examples of testing the interactive command menus (agents, models, sessions), see `tmux-menu-examples.ts`. This file demonstrates:
|
|
140
|
-
|
|
141
|
-
- How to enter command menus (`/agents`, `/models`, `/sessions`)
|
|
142
|
-
- How to navigate menu options (Up, Down)
|
|
143
|
-
- How to filter/search within menus
|
|
144
|
-
- How to select items (Enter)
|
|
145
|
-
- How to cancel menus (Escape)
|
|
146
|
-
- How to verify the results
|
|
147
|
-
|
|
148
|
-
## Key Constants
|
|
149
|
-
|
|
150
|
-
- `TMUX_SESSION`: "opencode-test" - The tmux session name
|
|
151
|
-
- `TMUX_WINDOW`: "readline-test" - The tmux window name
|
|
152
|
-
- `WAIT_MS`: 100 - Default wait time between keystrokes
|
|
153
|
-
- `APP_PATH`: Path to the application entry point
|
|
154
|
-
|
|
155
|
-
## Helper Functions
|
|
156
|
-
|
|
157
|
-
- `setupTmux()` / `teardownTmux()`: Create/destroy test environment
|
|
158
|
-
- `startApp()` / `stopApp()`: Start/stop the application
|
|
159
|
-
- `sendKeys(keys)`: Send keystrokes to tmux (e.g., "C-c", "Enter", "M-Left")
|
|
160
|
-
- `capturePane()`: Get current terminal output
|
|
161
|
-
- `clearInput()`: Clear the current input line
|
|
162
|
-
- `assertContains(output, expected)`: Check if output contains text
|
|
163
|
-
- `assertPromptContains(text, expected)`: Check if prompt contains text
|
|
164
|
-
- `stripAnsi(text)`: Remove ANSI escape codes from text
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
// Example: How to add command menu tests
|
|
2
|
-
// This file contains examples for testing the interactive command menus
|
|
3
|
-
import { $ } from "bun";
|
|
4
|
-
|
|
5
|
-
const TMUX_SESSION = "opencode-test-menu";
|
|
6
|
-
|
|
7
|
-
async function testAgentMenu(): Promise<void> {
|
|
8
|
-
console.log("Testing agent menu navigation");
|
|
9
|
-
|
|
10
|
-
// Start with /agents command
|
|
11
|
-
await $`tmux send-keys -t ${TMUX_SESSION} "/agents" Enter`;
|
|
12
|
-
await sleep(500);
|
|
13
|
-
|
|
14
|
-
// Send some filter text
|
|
15
|
-
await $`tmux send-keys -t ${TMUX_SESSION} "code"`;
|
|
16
|
-
await sleep(100);
|
|
17
|
-
|
|
18
|
-
// Navigate up
|
|
19
|
-
await $`tmux send-keys -t ${TMUX_SESSION} Up`;
|
|
20
|
-
await sleep(100);
|
|
21
|
-
|
|
22
|
-
// Navigate down
|
|
23
|
-
await $`tmux send-keys -t ${TMUX_SESSION} Down`;
|
|
24
|
-
await sleep(100);
|
|
25
|
-
|
|
26
|
-
// Clear filter
|
|
27
|
-
await $`tmux send-keys -t ${TMUX_SESSION} C-u`;
|
|
28
|
-
await sleep(100);
|
|
29
|
-
|
|
30
|
-
// Cancel with escape
|
|
31
|
-
await $`tmux send-keys -t ${TMUX_SESSION} Escape`;
|
|
32
|
-
await sleep(100);
|
|
33
|
-
|
|
34
|
-
// Verify we're back at prompt
|
|
35
|
-
const output = await capturePane();
|
|
36
|
-
if (!assertPromptContains(output, "#")) {
|
|
37
|
-
throw new Error("Should be back at prompt");
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function testModelMenu(): Promise<void> {
|
|
42
|
-
console.log("Testing model menu navigation");
|
|
43
|
-
|
|
44
|
-
await $`tmux send-keys -t ${TMUX_SESSION} "/models" Enter`;
|
|
45
|
-
await sleep(500);
|
|
46
|
-
|
|
47
|
-
// Navigate through options
|
|
48
|
-
await $`tmux send-keys -t ${TMUX_SESSION} Down`;
|
|
49
|
-
await sleep(100);
|
|
50
|
-
await $`tmux send-keys -t ${TMUX_SESSION} Down`;
|
|
51
|
-
await sleep(100);
|
|
52
|
-
|
|
53
|
-
// Select a model
|
|
54
|
-
await $`tmux send-keys -t ${TMUX_SESSION} Enter`;
|
|
55
|
-
await sleep(200);
|
|
56
|
-
|
|
57
|
-
// Verify selection worked
|
|
58
|
-
const output = await capturePane();
|
|
59
|
-
if (!assertContains(output, "Model changed")) {
|
|
60
|
-
throw new Error("Model should be changed");
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function testSessionMenu(): Promise<void> {
|
|
65
|
-
console.log("Testing session menu navigation");
|
|
66
|
-
|
|
67
|
-
await $`tmux send-keys -t ${TMUX_SESSION} "/sessions" Enter`;
|
|
68
|
-
await sleep(500);
|
|
69
|
-
|
|
70
|
-
// Test pagination
|
|
71
|
-
await $`tmux send-keys -t ${TMUX_SESSION} Down`;
|
|
72
|
-
await sleep(100);
|
|
73
|
-
await $`tmux send-keys -t ${TMUX_SESSION} Down`;
|
|
74
|
-
await sleep(100);
|
|
75
|
-
|
|
76
|
-
// Select a session
|
|
77
|
-
await $`tmux send-keys -t ${TMUX_SESSION} Enter`;
|
|
78
|
-
await sleep(200);
|
|
79
|
-
|
|
80
|
-
// Verify we're using the selected session
|
|
81
|
-
const output = await capturePane();
|
|
82
|
-
if (!assertContains(output, "Session loaded")) {
|
|
83
|
-
throw new Error("Session should be loaded");
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Note: These are example tests that would need to be integrated
|
|
88
|
-
// into the main tmux-readline.test.ts file. They demonstrate:
|
|
89
|
-
// 1. How to enter command menus (/agents, /models, /sessions)
|
|
90
|
-
// 2. How to navigate menu options (Up, Down)
|
|
91
|
-
// 3. How to filter/search within menus
|
|
92
|
-
// 4. How to select items (Enter)
|
|
93
|
-
// 5. How to cancel menus (Escape)
|
|
94
|
-
// 6. How to verify the results
|
|
95
|
-
|
|
96
|
-
function sleep(ms: number): Promise<void> {
|
|
97
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function capturePane(): Promise<string> {
|
|
101
|
-
const result = await $`tmux capture-pane -t ${TMUX_SESSION} -p`.quiet();
|
|
102
|
-
return result.stdout.toString();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function assertPromptContains(text: string, expected: string): boolean {
|
|
106
|
-
const lines = text.split("\n");
|
|
107
|
-
for (const line of lines) {
|
|
108
|
-
if (line.includes("#")) {
|
|
109
|
-
return line.includes(expected);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function assertContains(output: string, expected: string): boolean {
|
|
116
|
-
return output.includes(expected);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export {};
|
|
@@ -1,518 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
import { $ } from "bun";
|
|
3
|
-
|
|
4
|
-
const TMUX_SESSION = "opencode-test";
|
|
5
|
-
const TMUX_WINDOW = "readline-test";
|
|
6
|
-
const APP_PATH = "/Users/andrewjk/Source/opencode-miniterm/src/index.ts";
|
|
7
|
-
const WAIT_MS = 80;
|
|
8
|
-
|
|
9
|
-
interface TestResult {
|
|
10
|
-
name: string;
|
|
11
|
-
passed: boolean;
|
|
12
|
-
output: string;
|
|
13
|
-
expected: string;
|
|
14
|
-
error?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const results: TestResult[] = [];
|
|
18
|
-
|
|
19
|
-
async function setupTmux(): Promise<void> {
|
|
20
|
-
try {
|
|
21
|
-
await $`tmux kill-session -t ${TMUX_SESSION}`.quiet();
|
|
22
|
-
} catch {
|
|
23
|
-
// Session might not exist
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
await $`tmux new-session -d -s ${TMUX_SESSION} -n ${TMUX_WINDOW}`;
|
|
27
|
-
await sleep(300);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function teardownTmux(): Promise<void> {
|
|
31
|
-
await $`tmux kill-session -t ${TMUX_SESSION}`.quiet();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function startApp(): Promise<void> {
|
|
35
|
-
await $`tmux send-keys -t ${TMUX_SESSION} "cd /Users/andrewjk/Source/opencode-miniterm && bun run ${APP_PATH}" Enter`;
|
|
36
|
-
await sleep(3000);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function stopApp(): Promise<void> {
|
|
40
|
-
await $`tmux send-keys -t ${TMUX_SESSION} C-c`;
|
|
41
|
-
await sleep(200);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function sendKeys(keys: string): Promise<void> {
|
|
45
|
-
await $`tmux send-keys -t ${TMUX_SESSION} ${keys}`.quiet();
|
|
46
|
-
await sleep(WAIT_MS);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function capturePane(): Promise<string> {
|
|
50
|
-
const result = await $`tmux capture-pane -t ${TMUX_SESSION} -p`.quiet();
|
|
51
|
-
const text = result.stdout.toString();
|
|
52
|
-
return text;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function clearInput(): Promise<void> {
|
|
56
|
-
// Send Enter to execute/clear current input
|
|
57
|
-
await sendKeys("Enter");
|
|
58
|
-
await sleep(500);
|
|
59
|
-
// Send Escape to cancel any active state
|
|
60
|
-
await sendKeys("Escape");
|
|
61
|
-
await sleep(100);
|
|
62
|
-
// Send Ctrl+U to clear from cursor to start
|
|
63
|
-
await sendKeys("C-u");
|
|
64
|
-
await sleep(100);
|
|
65
|
-
// Send Ctrl+K to clear from cursor to end
|
|
66
|
-
await sendKeys("C-k");
|
|
67
|
-
await sleep(100);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function sleep(ms: number): Promise<void> {
|
|
71
|
-
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function stripAnsi(text: string): string {
|
|
75
|
-
return text.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\[[0-9;]*[ABCDEFGJKLM]/g, "");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function extractPromptLine(text: string): string {
|
|
79
|
-
const lines = text.split("\n");
|
|
80
|
-
let lastPromptIndex = -1;
|
|
81
|
-
|
|
82
|
-
// Find the LAST prompt line (with #)
|
|
83
|
-
for (let i = 0; i < lines.length; i++) {
|
|
84
|
-
const line = lines[i];
|
|
85
|
-
if (line && line.includes("#")) {
|
|
86
|
-
lastPromptIndex = i;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (lastPromptIndex === -1) {
|
|
91
|
-
return "";
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Collect the prompt line and its wrapped continuations
|
|
95
|
-
const promptLine = lines[lastPromptIndex]!;
|
|
96
|
-
const afterPrompt = promptLine.substring(promptLine.indexOf("#") + 1).trim();
|
|
97
|
-
|
|
98
|
-
const promptLines: string[] = [afterPrompt];
|
|
99
|
-
for (let j = lastPromptIndex + 1; j < lines.length; j++) {
|
|
100
|
-
const nextLine = lines[j];
|
|
101
|
-
if (!nextLine) {
|
|
102
|
-
break;
|
|
103
|
-
}
|
|
104
|
-
const trimmed = nextLine.trim();
|
|
105
|
-
// Stop if we hit another prompt or an empty line
|
|
106
|
-
if (nextLine.includes("#") || trimmed === "") {
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
promptLines.push(trimmed);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return stripAnsi(promptLines.join(" "));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function assertContains(output: string, expected: string): boolean {
|
|
116
|
-
const stripped = stripAnsi(output);
|
|
117
|
-
return stripped.includes(expected);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function assertPromptContains(text: string, expected: string): boolean {
|
|
121
|
-
const promptLine = extractPromptLine(text);
|
|
122
|
-
return promptLine.includes(expected);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function runTest(name: string, testFn: () => Promise<void>): Promise<void> {
|
|
126
|
-
console.log(`Running: ${name}`);
|
|
127
|
-
try {
|
|
128
|
-
await testFn();
|
|
129
|
-
results.push({
|
|
130
|
-
name,
|
|
131
|
-
passed: true,
|
|
132
|
-
output: "",
|
|
133
|
-
expected: "",
|
|
134
|
-
});
|
|
135
|
-
console.log(`✓ ${name}`);
|
|
136
|
-
} catch (error) {
|
|
137
|
-
results.push({
|
|
138
|
-
name,
|
|
139
|
-
passed: false,
|
|
140
|
-
output: (error as Error).message,
|
|
141
|
-
expected: "",
|
|
142
|
-
error: (error as Error).stack,
|
|
143
|
-
});
|
|
144
|
-
console.error(`✗ ${name}`);
|
|
145
|
-
console.error(` Error: ${(error as Error).message}`);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function testCharacterInsertion(): Promise<void> {
|
|
150
|
-
await clearInput();
|
|
151
|
-
await sleep(100);
|
|
152
|
-
await sendKeys("h");
|
|
153
|
-
await sendKeys("e");
|
|
154
|
-
await sendKeys("l");
|
|
155
|
-
await sendKeys("l");
|
|
156
|
-
await sendKeys("o");
|
|
157
|
-
|
|
158
|
-
const output = await capturePane();
|
|
159
|
-
if (!assertPromptContains(output, "hello")) {
|
|
160
|
-
throw new Error(`Expected prompt to contain 'hello', got: ${extractPromptLine(output)}`);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
async function testBackspace(): Promise<void> {
|
|
165
|
-
await clearInput();
|
|
166
|
-
await sleep(100);
|
|
167
|
-
await sendKeys("hello");
|
|
168
|
-
await sendKeys("Space");
|
|
169
|
-
await sendKeys("world");
|
|
170
|
-
await sendKeys("C-h");
|
|
171
|
-
await sendKeys("C-h");
|
|
172
|
-
await sendKeys("C-h");
|
|
173
|
-
await sendKeys("C-h");
|
|
174
|
-
await sendKeys("C-h");
|
|
175
|
-
|
|
176
|
-
const output = await capturePane();
|
|
177
|
-
if (!assertPromptContains(output, "hello")) {
|
|
178
|
-
throw new Error(`Expected prompt to contain 'hello', got: ${extractPromptLine(output)}`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async function testDelete(): Promise<void> {
|
|
183
|
-
await clearInput();
|
|
184
|
-
await sleep(100);
|
|
185
|
-
await sendKeys("hello");
|
|
186
|
-
await sendKeys("Left");
|
|
187
|
-
await sendKeys("Left");
|
|
188
|
-
await sendKeys("Delete");
|
|
189
|
-
|
|
190
|
-
const output = await capturePane();
|
|
191
|
-
if (!assertPromptContains(output, "helo")) {
|
|
192
|
-
throw new Error(`Expected prompt to contain 'helo', got: ${extractPromptLine(output)}`);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function testLeftArrow(): Promise<void> {
|
|
197
|
-
await clearInput();
|
|
198
|
-
await sleep(100);
|
|
199
|
-
await sendKeys("test");
|
|
200
|
-
await sendKeys("Left");
|
|
201
|
-
await sendKeys("Left");
|
|
202
|
-
await sendKeys("X");
|
|
203
|
-
|
|
204
|
-
const output = await capturePane();
|
|
205
|
-
if (!assertPromptContains(output, "teXst")) {
|
|
206
|
-
throw new Error(`Expected prompt to contain 'teXst', got: ${extractPromptLine(output)}`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async function testRightArrow(): Promise<void> {
|
|
211
|
-
await clearInput();
|
|
212
|
-
await sleep(200);
|
|
213
|
-
await sendKeys("test");
|
|
214
|
-
await sleep(150);
|
|
215
|
-
await sendKeys("Left");
|
|
216
|
-
await sleep(150);
|
|
217
|
-
await sendKeys("Left");
|
|
218
|
-
await sleep(150);
|
|
219
|
-
await sendKeys("Right");
|
|
220
|
-
await sleep(150);
|
|
221
|
-
await sendKeys("Right");
|
|
222
|
-
await sleep(150);
|
|
223
|
-
await sendKeys("X");
|
|
224
|
-
|
|
225
|
-
const output = await capturePane();
|
|
226
|
-
if (!assertPromptContains(output, "tesXt") && !assertPromptContains(output, "testX")) {
|
|
227
|
-
throw new Error(
|
|
228
|
-
`Expected prompt to contain 'tesXt' or 'testX', got: ${extractPromptLine(output)}`,
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function testTabCompletionSlashCommands(): Promise<void> {
|
|
234
|
-
await clearInput();
|
|
235
|
-
await sleep(100);
|
|
236
|
-
await sendKeys("/");
|
|
237
|
-
await sleep(200);
|
|
238
|
-
await sendKeys("Tab");
|
|
239
|
-
await sleep(500);
|
|
240
|
-
|
|
241
|
-
const output = await capturePane();
|
|
242
|
-
const stripped = stripAnsi(output);
|
|
243
|
-
if (!stripped.includes("/") && !stripped.includes("/help") && !stripped.includes("commands")) {
|
|
244
|
-
throw new Error(
|
|
245
|
-
`Expected output to contain command completions, got: ${stripped.substring(0, 200)}`,
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
async function testTabCompletionFileRef(): Promise<void> {
|
|
251
|
-
await clearInput();
|
|
252
|
-
await sleep(100);
|
|
253
|
-
await sendKeys("@");
|
|
254
|
-
await sleep(100);
|
|
255
|
-
await sendKeys("src/");
|
|
256
|
-
await sleep(100);
|
|
257
|
-
await sendKeys("Tab");
|
|
258
|
-
await sleep(300);
|
|
259
|
-
|
|
260
|
-
const output = await capturePane();
|
|
261
|
-
const stripped = stripAnsi(output);
|
|
262
|
-
if (!stripped.includes("src/")) {
|
|
263
|
-
throw new Error(`Expected output to contain 'src/', got: ${stripped.substring(0, 200)}`);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
async function testHistoryUpArrow(): Promise<void> {
|
|
268
|
-
await clearInput();
|
|
269
|
-
await sleep(100);
|
|
270
|
-
|
|
271
|
-
// Add some commands to history
|
|
272
|
-
await sendKeys("first command");
|
|
273
|
-
await sendKeys("Enter");
|
|
274
|
-
await sleep(1000);
|
|
275
|
-
|
|
276
|
-
await sendKeys("second command");
|
|
277
|
-
await sendKeys("Enter");
|
|
278
|
-
await sleep(1000);
|
|
279
|
-
|
|
280
|
-
await clearInput();
|
|
281
|
-
await sleep(100);
|
|
282
|
-
await sendKeys("Up");
|
|
283
|
-
await sleep(100);
|
|
284
|
-
await sendKeys("Up");
|
|
285
|
-
await sleep(100);
|
|
286
|
-
|
|
287
|
-
const output = await capturePane();
|
|
288
|
-
if (!assertPromptContains(output, "first command")) {
|
|
289
|
-
throw new Error(
|
|
290
|
-
`Expected prompt to contain 'first command', got: ${extractPromptLine(output)}`,
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
async function testHistoryDownArrow(): Promise<void> {
|
|
296
|
-
await clearInput();
|
|
297
|
-
await sleep(100);
|
|
298
|
-
|
|
299
|
-
await sendKeys("test history");
|
|
300
|
-
await sendKeys("Enter");
|
|
301
|
-
await sleep(1000);
|
|
302
|
-
|
|
303
|
-
await clearInput();
|
|
304
|
-
await sleep(100);
|
|
305
|
-
await sendKeys("Up");
|
|
306
|
-
await sleep(100);
|
|
307
|
-
await sendKeys("Down");
|
|
308
|
-
await sleep(100);
|
|
309
|
-
|
|
310
|
-
const output = await capturePane();
|
|
311
|
-
const promptLine = extractPromptLine(output);
|
|
312
|
-
if (promptLine.includes("test history")) {
|
|
313
|
-
throw new Error(`Expected prompt to be empty after down arrow, got: ${promptLine}`);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
async function testEscapeClearsInput(): Promise<void> {
|
|
318
|
-
await clearInput();
|
|
319
|
-
await sleep(200);
|
|
320
|
-
await sendKeys("test");
|
|
321
|
-
await sleep(100);
|
|
322
|
-
await sendKeys("Escape");
|
|
323
|
-
await sleep(300);
|
|
324
|
-
|
|
325
|
-
const output = await capturePane();
|
|
326
|
-
const promptLine = extractPromptLine(output);
|
|
327
|
-
// Escape should clear input or cancel active request
|
|
328
|
-
if (
|
|
329
|
-
promptLine &&
|
|
330
|
-
promptLine.includes("test") &&
|
|
331
|
-
!promptLine.includes("test ") &&
|
|
332
|
-
promptLine.trim() !== ""
|
|
333
|
-
) {
|
|
334
|
-
// Input still has "test" - escape might not have cleared it, but this is okay if it cancels a request
|
|
335
|
-
// Just mark as pass since the key was processed
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
async function testDoubleSpaceToPeriod(): Promise<void> {
|
|
340
|
-
await clearInput();
|
|
341
|
-
await sleep(100);
|
|
342
|
-
await sendKeys("hello");
|
|
343
|
-
await sleep(100);
|
|
344
|
-
await sendKeys("Space");
|
|
345
|
-
await sleep(100);
|
|
346
|
-
await sendKeys("Space");
|
|
347
|
-
await sleep(700);
|
|
348
|
-
|
|
349
|
-
const output = await capturePane();
|
|
350
|
-
if (!assertPromptContains(output, "hello.")) {
|
|
351
|
-
throw new Error(`Expected prompt to contain 'hello.', got: ${extractPromptLine(output)}`);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
async function testMetaLeftWordNavigation(): Promise<void> {
|
|
356
|
-
await clearInput();
|
|
357
|
-
await sleep(200);
|
|
358
|
-
await sendKeys("hello world test");
|
|
359
|
-
await sleep(200);
|
|
360
|
-
|
|
361
|
-
// Try to send Alt+b using different methods
|
|
362
|
-
await $`tmux send-keys -t ${TMUX_SESSION} 'M-b'`.quiet();
|
|
363
|
-
await sleep(200);
|
|
364
|
-
await $`tmux send-keys -t ${TMUX_SESSION} 'M-b'`.quiet();
|
|
365
|
-
await sleep(200);
|
|
366
|
-
await sendKeys("X");
|
|
367
|
-
|
|
368
|
-
const output = await capturePane();
|
|
369
|
-
const promptLine = extractPromptLine(output);
|
|
370
|
-
// Check if cursor moved by verifying X is not at the end
|
|
371
|
-
if (promptLine && promptLine.endsWith("testX")) {
|
|
372
|
-
// Cursor didn't move, try to skip this test or mark as partial pass
|
|
373
|
-
console.log(" (Meta key not recognized, test skipped)");
|
|
374
|
-
} else if (
|
|
375
|
-
!assertPromptContains(output, "X world test") &&
|
|
376
|
-
!assertPromptContains(output, "hello worldX test") &&
|
|
377
|
-
!assertPromptContains(output, "hello world testX")
|
|
378
|
-
) {
|
|
379
|
-
throw new Error(`Unexpected prompt: ${promptLine}`);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
async function testMetaRightWordNavigation(): Promise<void> {
|
|
384
|
-
await clearInput();
|
|
385
|
-
await sleep(100);
|
|
386
|
-
await sendKeys("hello");
|
|
387
|
-
await sendKeys("Space");
|
|
388
|
-
await sleep(100);
|
|
389
|
-
|
|
390
|
-
// Use Alt+f instead of M-Right for better compatibility
|
|
391
|
-
await $`tmux send-keys -t ${TMUX_SESSION} M-f`.quiet();
|
|
392
|
-
await sleep(100);
|
|
393
|
-
await sendKeys("X");
|
|
394
|
-
|
|
395
|
-
const output = await capturePane();
|
|
396
|
-
if (!assertPromptContains(output, "hello X")) {
|
|
397
|
-
throw new Error(`Expected prompt to contain 'hello X', got: ${extractPromptLine(output)}`);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
async function testMultiLineBackspaceToSingleLine(): Promise<void> {
|
|
402
|
-
await clearInput();
|
|
403
|
-
await sleep(200);
|
|
404
|
-
|
|
405
|
-
// Type a command that will produce output above the prompt
|
|
406
|
-
await sendKeys("echo test");
|
|
407
|
-
await sendKeys("Enter");
|
|
408
|
-
await sleep(1000);
|
|
409
|
-
|
|
410
|
-
// Capture state before multi-line input - we should have output above
|
|
411
|
-
const outputBefore = await capturePane();
|
|
412
|
-
const linesBefore = outputBefore.split("\n");
|
|
413
|
-
|
|
414
|
-
// Find the line with our output (should contain "test" or similar)
|
|
415
|
-
const outputLineIndex = linesBefore.findIndex(
|
|
416
|
-
(line) => line.includes("test") && !line.includes("#"),
|
|
417
|
-
);
|
|
418
|
-
if (outputLineIndex === -1) {
|
|
419
|
-
throw new Error("Could not find output line before typing multi-line input");
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const outputLineBefore = linesBefore[outputLineIndex]!;
|
|
423
|
-
|
|
424
|
-
// Type enough text to span multiple lines
|
|
425
|
-
await sendKeys(
|
|
426
|
-
"This is a very long line of text that should wrap to the next line when typed in the terminal",
|
|
427
|
-
);
|
|
428
|
-
await sleep(200);
|
|
429
|
-
|
|
430
|
-
// Press backspace multiple times to reduce to single line
|
|
431
|
-
for (let i = 0; i < 40; i++) {
|
|
432
|
-
await sendKeys("C-h");
|
|
433
|
-
await sleep(30);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
await sleep(300);
|
|
437
|
-
const outputAfter = await capturePane();
|
|
438
|
-
const linesAfter = outputAfter.split("\n");
|
|
439
|
-
|
|
440
|
-
// Find the output line after backspace
|
|
441
|
-
const outputLineIndexAfter = linesAfter.findIndex(
|
|
442
|
-
(line) => line.includes("test") && !line.includes("#"),
|
|
443
|
-
);
|
|
444
|
-
|
|
445
|
-
if (outputLineIndexAfter === -1) {
|
|
446
|
-
throw new Error(`Output line was cleared! Before: '${outputLineBefore}'`);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const outputLineAfter = linesAfter[outputLineIndexAfter]!;
|
|
450
|
-
|
|
451
|
-
// Verify the output line is still there and not modified
|
|
452
|
-
if (!outputLineAfter.includes("test")) {
|
|
453
|
-
throw new Error(
|
|
454
|
-
`Output line was modified or cleared! Before: '${outputLineBefore}', After: '${outputLineAfter}'`,
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Also verify the output hasn't been corrupted with extra characters from the prompt
|
|
459
|
-
const strippedBefore = stripAnsi(outputLineBefore);
|
|
460
|
-
const strippedAfter = stripAnsi(outputLineAfter);
|
|
461
|
-
if (strippedBefore !== strippedAfter) {
|
|
462
|
-
throw new Error(
|
|
463
|
-
`Output line content changed! Before: '${strippedBefore}', After: '${strippedAfter}'`,
|
|
464
|
-
);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
async function runAllTests(): Promise<void> {
|
|
469
|
-
console.log("Setting up tmux test environment...");
|
|
470
|
-
await setupTmux();
|
|
471
|
-
await startApp();
|
|
472
|
-
|
|
473
|
-
console.log("\nRunning readline tests...\n");
|
|
474
|
-
|
|
475
|
-
await runTest("Character insertion", testCharacterInsertion);
|
|
476
|
-
await runTest("Backspace deletion", testBackspace);
|
|
477
|
-
await runTest("Delete key", testDelete);
|
|
478
|
-
await runTest("Left arrow navigation", testLeftArrow);
|
|
479
|
-
await runTest("Right arrow navigation", testRightArrow);
|
|
480
|
-
await runTest("Tab completion - slash commands", testTabCompletionSlashCommands);
|
|
481
|
-
await runTest("Tab completion - file references", testTabCompletionFileRef);
|
|
482
|
-
await runTest("History navigation - up arrow", testHistoryUpArrow);
|
|
483
|
-
await runTest("History navigation - down arrow", testHistoryDownArrow);
|
|
484
|
-
await runTest("Escape clears input", testEscapeClearsInput);
|
|
485
|
-
await runTest("Double space to period", testDoubleSpaceToPeriod);
|
|
486
|
-
await runTest("Word navigation - Meta+Left", testMetaLeftWordNavigation);
|
|
487
|
-
await runTest("Word navigation - Meta+Right", testMetaRightWordNavigation);
|
|
488
|
-
await runTest("Multi-line backspace to single line", testMultiLineBackspaceToSingleLine);
|
|
489
|
-
|
|
490
|
-
await stopApp();
|
|
491
|
-
await teardownTmux();
|
|
492
|
-
|
|
493
|
-
console.log("\n" + "=".repeat(60));
|
|
494
|
-
console.log("Test Results:");
|
|
495
|
-
console.log("=".repeat(60));
|
|
496
|
-
|
|
497
|
-
const passed = results.filter((r) => r.passed).length;
|
|
498
|
-
const failed = results.filter((r) => !r.passed).length;
|
|
499
|
-
|
|
500
|
-
results.forEach((result) => {
|
|
501
|
-
const icon = result.passed ? "✓" : "✗";
|
|
502
|
-
console.log(`${icon} ${result.name}`);
|
|
503
|
-
if (!result.passed) {
|
|
504
|
-
console.log(` ${result.output}`);
|
|
505
|
-
}
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
console.log("=".repeat(60));
|
|
509
|
-
console.log(`Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
|
|
510
|
-
console.log("=".repeat(60));
|
|
511
|
-
|
|
512
|
-
process.exit(failed > 0 ? 1 : 0);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
runAllTests().catch((error) => {
|
|
516
|
-
console.error("Fatal error:", error);
|
|
517
|
-
process.exit(1);
|
|
518
|
-
});
|