@love-moon/tui-driver 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 +142 -0
- package/dist/driver/StateMachine.d.ts +28 -0
- package/dist/driver/StateMachine.d.ts.map +1 -0
- package/dist/driver/StateMachine.js +56 -0
- package/dist/driver/StateMachine.js.map +1 -0
- package/dist/driver/TuiDriver.d.ts +73 -0
- package/dist/driver/TuiDriver.d.ts.map +1 -0
- package/dist/driver/TuiDriver.js +506 -0
- package/dist/driver/TuiDriver.js.map +1 -0
- package/dist/driver/TuiProfile.d.ts +59 -0
- package/dist/driver/TuiProfile.d.ts.map +1 -0
- package/dist/driver/TuiProfile.js +13 -0
- package/dist/driver/TuiProfile.js.map +1 -0
- package/dist/driver/index.d.ts +5 -0
- package/dist/driver/index.d.ts.map +1 -0
- package/dist/driver/index.js +13 -0
- package/dist/driver/index.js.map +1 -0
- package/dist/driver/profiles/claudeCode.profile.d.ts +4 -0
- package/dist/driver/profiles/claudeCode.profile.d.ts.map +1 -0
- package/dist/driver/profiles/claudeCode.profile.js +91 -0
- package/dist/driver/profiles/claudeCode.profile.js.map +1 -0
- package/dist/driver/profiles/codex.profile.d.ts +4 -0
- package/dist/driver/profiles/codex.profile.d.ts.map +1 -0
- package/dist/driver/profiles/codex.profile.js +82 -0
- package/dist/driver/profiles/codex.profile.js.map +1 -0
- package/dist/driver/profiles/index.d.ts +3 -0
- package/dist/driver/profiles/index.d.ts.map +1 -0
- package/dist/driver/profiles/index.js +8 -0
- package/dist/driver/profiles/index.js.map +1 -0
- package/dist/example.d.ts +2 -0
- package/dist/example.d.ts.map +1 -0
- package/dist/example.js +43 -0
- package/dist/example.js.map +1 -0
- package/dist/expect/ExpectEngine.d.ts +34 -0
- package/dist/expect/ExpectEngine.d.ts.map +1 -0
- package/dist/expect/ExpectEngine.js +121 -0
- package/dist/expect/ExpectEngine.js.map +1 -0
- package/dist/expect/Matchers.d.ts +24 -0
- package/dist/expect/Matchers.d.ts.map +1 -0
- package/dist/expect/Matchers.js +71 -0
- package/dist/expect/Matchers.js.map +1 -0
- package/dist/expect/index.d.ts +3 -0
- package/dist/expect/index.d.ts.map +1 -0
- package/dist/expect/index.js +8 -0
- package/dist/expect/index.js.map +1 -0
- package/dist/extract/Diff.d.ts +10 -0
- package/dist/extract/Diff.d.ts.map +1 -0
- package/dist/extract/Diff.js +44 -0
- package/dist/extract/Diff.js.map +1 -0
- package/dist/extract/OutputExtractor.d.ts +16 -0
- package/dist/extract/OutputExtractor.d.ts.map +1 -0
- package/dist/extract/OutputExtractor.js +71 -0
- package/dist/extract/OutputExtractor.js.map +1 -0
- package/dist/extract/index.d.ts +3 -0
- package/dist/extract/index.d.ts.map +1 -0
- package/dist/extract/index.js +11 -0
- package/dist/extract/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/pty/PtySession.d.ts +38 -0
- package/dist/pty/PtySession.d.ts.map +1 -0
- package/dist/pty/PtySession.js +231 -0
- package/dist/pty/PtySession.js.map +1 -0
- package/dist/pty/index.d.ts +2 -0
- package/dist/pty/index.d.ts.map +1 -0
- package/dist/pty/index.js +6 -0
- package/dist/pty/index.js.map +1 -0
- package/dist/term/HeadlessScreen.d.ts +29 -0
- package/dist/term/HeadlessScreen.d.ts.map +1 -0
- package/dist/term/HeadlessScreen.js +126 -0
- package/dist/term/HeadlessScreen.js.map +1 -0
- package/dist/term/ScreenSnapshot.d.ts +37 -0
- package/dist/term/ScreenSnapshot.d.ts.map +1 -0
- package/dist/term/ScreenSnapshot.js +68 -0
- package/dist/term/ScreenSnapshot.js.map +1 -0
- package/dist/term/index.d.ts +3 -0
- package/dist/term/index.d.ts.map +1 -0
- package/dist/term/index.js +8 -0
- package/dist/term/index.js.map +1 -0
- package/docs/tui-driver_implementation_plan.md +307 -0
- package/package.json +33 -0
- package/pnpm-workspace.yaml +1 -0
- package/src/driver/StateMachine.ts +90 -0
- package/src/driver/TuiDriver.ts +624 -0
- package/src/driver/TuiProfile.ts +72 -0
- package/src/driver/index.ts +4 -0
- package/src/driver/profiles/claudeCode.profile.ts +96 -0
- package/src/driver/profiles/codex.profile.ts +87 -0
- package/src/driver/profiles/index.ts +2 -0
- package/src/example.ts +45 -0
- package/src/expect/ExpectEngine.ts +171 -0
- package/src/expect/Matchers.ts +92 -0
- package/src/expect/index.ts +2 -0
- package/src/extract/Diff.ts +51 -0
- package/src/extract/OutputExtractor.ts +88 -0
- package/src/extract/index.ts +2 -0
- package/src/index.ts +67 -0
- package/src/pty/PtySession.ts +234 -0
- package/src/pty/index.ts +1 -0
- package/src/term/HeadlessScreen.ts +151 -0
- package/src/term/ScreenSnapshot.ts +89 -0
- package/src/term/index.ts +2 -0
- package/test/claude-profile.test.ts +11 -0
- package/test/codex-profile.test.ts +108 -0
- package/test/debug-claude.ts +51 -0
- package/test/integration.ts +174 -0
- package/test/output-extractor.test.ts +49 -0
- package/test/state-diff.test.ts +120 -0
- package/test/unit.test.ts +136 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Matchers } from "../src/expect/Matchers.js";
|
|
3
|
+
import { ScreenSnapshot } from "../src/term/ScreenSnapshot.js";
|
|
4
|
+
import { codexProfile } from "../src/driver/profiles/codex.profile.js";
|
|
5
|
+
|
|
6
|
+
function createSnapshot(text: string): ScreenSnapshot {
|
|
7
|
+
return new ScreenSnapshot({
|
|
8
|
+
viewportText: text,
|
|
9
|
+
scrollbackText: text,
|
|
10
|
+
cursor: { x: 0, y: 0 },
|
|
11
|
+
hash: ScreenSnapshot.computeHash(text),
|
|
12
|
+
timestamp: Date.now(),
|
|
13
|
+
cols: 120,
|
|
14
|
+
rows: 40,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("codex profile ready anchors", () => {
|
|
19
|
+
it("matches ready prompt even when boot logs still exist", () => {
|
|
20
|
+
const snapshot = createSnapshot([
|
|
21
|
+
"Booting MCP server...",
|
|
22
|
+
"model: gpt-5.3-codex",
|
|
23
|
+
"› Use /skills to list available skills",
|
|
24
|
+
].join("\n"));
|
|
25
|
+
|
|
26
|
+
const matcher = Matchers.anyOf(codexProfile.anchors.ready);
|
|
27
|
+
expect(matcher(snapshot)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does not match while model is still loading", () => {
|
|
31
|
+
const snapshot = createSnapshot([
|
|
32
|
+
"model: loading",
|
|
33
|
+
"› ",
|
|
34
|
+
].join("\n"));
|
|
35
|
+
|
|
36
|
+
const matcher = Matchers.anyOf(codexProfile.anchors.ready);
|
|
37
|
+
expect(matcher(snapshot)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("matches prompt line with built-in suggestion text", () => {
|
|
41
|
+
const snapshot = createSnapshot([
|
|
42
|
+
"╭───────────────────────────────────────────────────╮",
|
|
43
|
+
"│ >_ OpenAI Codex (v0.98.0) │",
|
|
44
|
+
"╰───────────────────────────────────────────────────╯",
|
|
45
|
+
"",
|
|
46
|
+
"› Write tests for @filename",
|
|
47
|
+
].join("\n"));
|
|
48
|
+
|
|
49
|
+
const matcher = Matchers.anyOf(codexProfile.anchors.ready);
|
|
50
|
+
expect(matcher(snapshot)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("does not treat trust option selector as ready prompt", () => {
|
|
54
|
+
const snapshot = createSnapshot([
|
|
55
|
+
"> You are running Codex in /private/tmp/demo",
|
|
56
|
+
"",
|
|
57
|
+
"1. Allow Codex to work in this folder without asking for approval",
|
|
58
|
+
"› 2. Require approval of edits and commands",
|
|
59
|
+
"",
|
|
60
|
+
"Press enter to continue",
|
|
61
|
+
].join("\n"));
|
|
62
|
+
|
|
63
|
+
const matcher = Matchers.anyOf(codexProfile.anchors.ready);
|
|
64
|
+
expect(matcher(snapshot)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("matches busy/status line with leading spaces", () => {
|
|
68
|
+
const snapshot = createSnapshot([
|
|
69
|
+
" • Working (11s • esc to interrupt)",
|
|
70
|
+
"",
|
|
71
|
+
" ? for shortcuts",
|
|
72
|
+
].join("\n"));
|
|
73
|
+
|
|
74
|
+
const busyMatcher = Matchers.anyOf(codexProfile.anchors.busy ?? []);
|
|
75
|
+
const statusMatcher = Matchers.anyOf(codexProfile.signals?.status ?? []);
|
|
76
|
+
expect(busyMatcher(snapshot)).toBe(true);
|
|
77
|
+
expect(statusMatcher(snapshot)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("codex profile trust anchors", () => {
|
|
82
|
+
it("matches workspace approval gate in non-git folders", () => {
|
|
83
|
+
const snapshot = createSnapshot([
|
|
84
|
+
"> You are running Codex in /private/tmp/demo",
|
|
85
|
+
"",
|
|
86
|
+
"Since this folder is not version controlled, we recommend requiring approval of all edits and commands.",
|
|
87
|
+
"",
|
|
88
|
+
"1. Allow Codex to work in this folder without asking for approval",
|
|
89
|
+
"› 2. Require approval of edits and commands",
|
|
90
|
+
"",
|
|
91
|
+
"Press enter to continue",
|
|
92
|
+
].join("\n"));
|
|
93
|
+
|
|
94
|
+
const matcher = Matchers.anyOf(codexProfile.anchors.trust ?? []);
|
|
95
|
+
expect(matcher(snapshot)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("codex profile timeout policy", () => {
|
|
100
|
+
it("disables hard timeouts for long-running turns", () => {
|
|
101
|
+
expect(codexProfile.timeouts?.boot).toBe(0);
|
|
102
|
+
expect(codexProfile.timeouts?.ready).toBe(0);
|
|
103
|
+
expect(codexProfile.timeouts?.streamStart).toBe(0);
|
|
104
|
+
expect(codexProfile.timeouts?.streamEnd).toBe(0);
|
|
105
|
+
expect(codexProfile.keys.trustConfirm).toEqual(["UP", "ENTER"]);
|
|
106
|
+
expect(codexProfile.requireReplyStart).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Debug script to see what Claude Code TUI looks like
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { PtySession } from "../src/pty/PtySession.js";
|
|
7
|
+
import { HeadlessScreen } from "../src/term/HeadlessScreen.js";
|
|
8
|
+
|
|
9
|
+
async function debug() {
|
|
10
|
+
const pty = new PtySession("claude", [], {
|
|
11
|
+
cols: 120,
|
|
12
|
+
rows: 40,
|
|
13
|
+
env: {
|
|
14
|
+
TERM: "xterm-256color",
|
|
15
|
+
LANG: "en_US.UTF-8",
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const screen = new HeadlessScreen({ cols: 120, rows: 40 });
|
|
20
|
+
|
|
21
|
+
pty.onData((chunk) => {
|
|
22
|
+
screen.write(chunk);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
console.log("Spawning Claude Code...\n");
|
|
26
|
+
pty.spawn();
|
|
27
|
+
|
|
28
|
+
// Wait and capture snapshots
|
|
29
|
+
for (let i = 0; i < 10; i++) {
|
|
30
|
+
await sleep(2000);
|
|
31
|
+
const snapshot = screen.snapshot();
|
|
32
|
+
console.log(`\n=== Snapshot ${i + 1} (${formatBeijingTimestamp()}) ===`);
|
|
33
|
+
console.log(`Hash: ${snapshot.hash}`);
|
|
34
|
+
console.log(`Cursor: (${snapshot.cursor.x}, ${snapshot.cursor.y})`);
|
|
35
|
+
console.log("--- Viewport ---");
|
|
36
|
+
console.log(snapshot.viewportText);
|
|
37
|
+
console.log("--- End ---\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
pty.kill();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sleep(ms: number): Promise<void> {
|
|
44
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatBeijingTimestamp(date = new Date()): string {
|
|
48
|
+
return date.toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false }).replace(" ", "T");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
debug().catch(console.error);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Integration test for TUI Driver
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx tsx test/integration.ts # Test with echo (mock)
|
|
7
|
+
* npx tsx test/integration.ts --claude # Test with real Claude Code
|
|
8
|
+
* npx tsx test/integration.ts --codex # Test with real Codex
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { TuiDriver, createProfile, TuiProfile } from "../src/index.js";
|
|
12
|
+
|
|
13
|
+
// Mock profile that uses a simple shell script for testing
|
|
14
|
+
const mockProfile: TuiProfile = createProfile({
|
|
15
|
+
name: "claude-code",
|
|
16
|
+
command: "/bin/bash",
|
|
17
|
+
args: [],
|
|
18
|
+
anchors: {
|
|
19
|
+
ready: [/\$\s*$/m, /bash.*\$/],
|
|
20
|
+
busy: [],
|
|
21
|
+
},
|
|
22
|
+
keys: {
|
|
23
|
+
submit: ["ENTER"],
|
|
24
|
+
cancel: ["CTRL_C"],
|
|
25
|
+
},
|
|
26
|
+
extraction: {
|
|
27
|
+
mode: "diff-scrollback",
|
|
28
|
+
stripPatterns: [/^\$\s*/gm],
|
|
29
|
+
bottomUiLines: 1,
|
|
30
|
+
},
|
|
31
|
+
timeouts: {
|
|
32
|
+
boot: 5000,
|
|
33
|
+
ready: 3000,
|
|
34
|
+
streamStart: 3000,
|
|
35
|
+
streamEnd: 10000,
|
|
36
|
+
idle: 500,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function testMock() {
|
|
41
|
+
console.log("=== Testing with Mock TUI ===\n");
|
|
42
|
+
|
|
43
|
+
const driver = new TuiDriver({
|
|
44
|
+
profile: mockProfile,
|
|
45
|
+
debug: true,
|
|
46
|
+
onSnapshot: (snapshot, state) => {
|
|
47
|
+
console.log(` [Snapshot] State: ${state}, Hash: ${snapshot.hash.slice(0, 8)}`);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
console.log("1. Booting...");
|
|
53
|
+
await driver.boot();
|
|
54
|
+
console.log(" Boot successful!\n");
|
|
55
|
+
|
|
56
|
+
console.log("2. Sending first prompt (echo command)...");
|
|
57
|
+
const result1 = await driver.ask("echo 'Hello, world!'");
|
|
58
|
+
console.log(` Success: ${result1.success}`);
|
|
59
|
+
console.log(` Answer: ${result1.answer.slice(0, 100)}`);
|
|
60
|
+
console.log(` Elapsed: ${result1.elapsedMs}ms\n`);
|
|
61
|
+
|
|
62
|
+
console.log("3. Sending second prompt (date command)...");
|
|
63
|
+
const result2 = await driver.ask("date");
|
|
64
|
+
console.log(` Success: ${result2.success}`);
|
|
65
|
+
console.log(` Answer: ${result2.answer.slice(0, 100)}`);
|
|
66
|
+
console.log(` Elapsed: ${result2.elapsedMs}ms\n`);
|
|
67
|
+
|
|
68
|
+
console.log("=== All tests passed! ===");
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error("Test failed:", error);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
} finally {
|
|
73
|
+
driver.kill();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function testReal(profileName: "claude-code" | "codex") {
|
|
78
|
+
console.log(`=== Testing with real ${profileName} ===\n`);
|
|
79
|
+
console.log("NOTE: Make sure the CLI is installed and authenticated.\n");
|
|
80
|
+
|
|
81
|
+
const { createDriver } = await import("../src/index.js");
|
|
82
|
+
const driver = createDriver(profileName, {
|
|
83
|
+
debug: true,
|
|
84
|
+
onSnapshot: (snapshot, state) => {
|
|
85
|
+
console.log(`\n ========== [Snapshot] State: ${state} ==========`);
|
|
86
|
+
console.log(` Hash: ${snapshot.hash}`);
|
|
87
|
+
console.log(` Cursor: (${snapshot.cursor.x}, ${snapshot.cursor.y})`);
|
|
88
|
+
console.log(" --- Viewport ---");
|
|
89
|
+
console.log(snapshot.viewportText);
|
|
90
|
+
console.log(" --- End Viewport ---\n");
|
|
91
|
+
},
|
|
92
|
+
onSignals: (signals) => {
|
|
93
|
+
console.log(" --- Signals ---");
|
|
94
|
+
console.log(` promptLine: ${signals.promptLine ?? ""}`);
|
|
95
|
+
console.log(` replyInProgress: ${signals.replyInProgress}`);
|
|
96
|
+
console.log(` statusLine: ${signals.statusLine ?? ""}`);
|
|
97
|
+
console.log(` statusDoneLine: ${signals.statusDoneLine ?? ""}`);
|
|
98
|
+
console.log(` replyText: ${signals.replyText ?? ""}`);
|
|
99
|
+
console.log(` replyBlocks: ${(signals.replyBlocks ?? []).length}`);
|
|
100
|
+
console.log(" --- End Signals ---\n");
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
console.log("1. Booting...");
|
|
106
|
+
await driver.boot();
|
|
107
|
+
console.log(" Boot successful!\n");
|
|
108
|
+
|
|
109
|
+
console.log("2. Asking a simple question...");
|
|
110
|
+
const result = await driver.ask("Write a quicksort python code");
|
|
111
|
+
console.log(` Success: ${result.success}`);
|
|
112
|
+
console.log(` Answer: ${result.answer}`);
|
|
113
|
+
if (result.signals) {
|
|
114
|
+
console.log(" Signals:");
|
|
115
|
+
console.log(` promptLine: ${result.promptLine ?? ""}`);
|
|
116
|
+
console.log(` replyInProgress: ${result.replyInProgress ?? false}`);
|
|
117
|
+
console.log(` statusLine: ${result.statusLine ?? ""}`);
|
|
118
|
+
console.log(` statusDoneLine: ${result.statusDoneLine ?? ""}`);
|
|
119
|
+
console.log(` replyText: ${result.replyText ?? ""}`);
|
|
120
|
+
console.log(` replyBlocks: ${(result.replyBlocks ?? []).length}`);
|
|
121
|
+
}
|
|
122
|
+
console.log(` Elapsed: ${result.elapsedMs}ms\n`);
|
|
123
|
+
|
|
124
|
+
console.log("3. Asking one more question...");
|
|
125
|
+
const result2 = await driver.ask("How many lines in previous code?");
|
|
126
|
+
console.log(` Success: ${result2.success}`);
|
|
127
|
+
console.log(` Answer: ${result2.answer}`);
|
|
128
|
+
if (result2.signals) {
|
|
129
|
+
console.log(" Signals:");
|
|
130
|
+
console.log(` promptLine: ${result2.promptLine ?? ""}`);
|
|
131
|
+
console.log(` replyInProgress: ${result2.replyInProgress ?? false}`);
|
|
132
|
+
console.log(` statusLine: ${result2.statusLine ?? ""}`);
|
|
133
|
+
console.log(` statusDoneLine: ${result2.statusDoneLine ?? ""}`);
|
|
134
|
+
console.log(` replyText: ${result2.replyText ?? ""}`);
|
|
135
|
+
console.log(` replyBlocks: ${(result2.replyBlocks ?? []).length}`);
|
|
136
|
+
}
|
|
137
|
+
console.log(` Elapsed: ${result2.elapsedMs}ms\n`);
|
|
138
|
+
|
|
139
|
+
if (result2.success) {
|
|
140
|
+
console.log("=== Test passed! ===");
|
|
141
|
+
} else {
|
|
142
|
+
console.log("=== Test failed ===");
|
|
143
|
+
console.log("Error:", result2.error?.message);
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error("Test failed:", error);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
} finally {
|
|
149
|
+
driver.kill();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Main
|
|
154
|
+
async function main() {
|
|
155
|
+
const args = process.argv.slice(2);
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
if (args.includes("--claude")) {
|
|
159
|
+
await testReal("claude-code");
|
|
160
|
+
} else if (args.includes("--codex")) {
|
|
161
|
+
await testReal("codex");
|
|
162
|
+
} else {
|
|
163
|
+
await testMock();
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error(error);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Force exit after test completes to ensure no hanging handles
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
main();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { OutputExtractor } from "../src/extract/OutputExtractor.js";
|
|
3
|
+
import { ScreenSnapshot } from "../src/term/ScreenSnapshot.js";
|
|
4
|
+
import { TuiExtraction } from "../src/driver/TuiProfile.js";
|
|
5
|
+
|
|
6
|
+
function createSnapshot(scrollbackText: string): ScreenSnapshot {
|
|
7
|
+
return new ScreenSnapshot({
|
|
8
|
+
viewportText: scrollbackText,
|
|
9
|
+
scrollbackText,
|
|
10
|
+
cursor: { x: 0, y: 0 },
|
|
11
|
+
hash: ScreenSnapshot.computeHash(scrollbackText),
|
|
12
|
+
timestamp: Date.now(),
|
|
13
|
+
cols: 120,
|
|
14
|
+
rows: 40,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("OutputExtractor", () => {
|
|
19
|
+
it("still extracts text when includeLinePatterns misses", () => {
|
|
20
|
+
const extraction: TuiExtraction = {
|
|
21
|
+
mode: "diff-scrollback",
|
|
22
|
+
includeLinePatterns: [/^•\s(?!Working|Thinking)/m],
|
|
23
|
+
stopLinePatterns: [/^›\s*/m],
|
|
24
|
+
stripPatterns: [/^›\s*/gm],
|
|
25
|
+
stripEcho: true,
|
|
26
|
+
bottomUiLines: 2,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const before = createSnapshot("› 写一篇800字作文");
|
|
30
|
+
const after = createSnapshot([
|
|
31
|
+
"› 写一篇800字作文",
|
|
32
|
+
"",
|
|
33
|
+
"2026年的春天,风里有潮湿的花香。",
|
|
34
|
+
"当当和七喜跟着我回南方。",
|
|
35
|
+
"› Write tests for @filename",
|
|
36
|
+
].join("\n"));
|
|
37
|
+
|
|
38
|
+
const primary = new OutputExtractor(extraction).extract(before, after).text;
|
|
39
|
+
expect(primary).toBe("");
|
|
40
|
+
|
|
41
|
+
const relaxed = new OutputExtractor({
|
|
42
|
+
...extraction,
|
|
43
|
+
includeLinePatterns: undefined,
|
|
44
|
+
}).extract(before, after).text;
|
|
45
|
+
|
|
46
|
+
expect(relaxed).toContain("2026年的春天");
|
|
47
|
+
expect(relaxed).toContain("当当和七喜");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { StateMachine } from "../src/driver/StateMachine.js";
|
|
3
|
+
import { computeLineDiff, getAddedText, getRemovedText } from "../src/extract/Diff.js";
|
|
4
|
+
|
|
5
|
+
describe("StateMachine", () => {
|
|
6
|
+
it("should start in IDLE state", () => {
|
|
7
|
+
const sm = new StateMachine();
|
|
8
|
+
expect(sm.state).toBe("IDLE");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("should transition between states", () => {
|
|
12
|
+
const sm = new StateMachine();
|
|
13
|
+
|
|
14
|
+
sm.transition("BOOT");
|
|
15
|
+
expect(sm.state).toBe("BOOT");
|
|
16
|
+
|
|
17
|
+
sm.transition("WAIT_READY");
|
|
18
|
+
expect(sm.state).toBe("WAIT_READY");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should record transition history", () => {
|
|
22
|
+
const sm = new StateMachine();
|
|
23
|
+
|
|
24
|
+
sm.transition("BOOT");
|
|
25
|
+
sm.transition("WAIT_READY");
|
|
26
|
+
|
|
27
|
+
expect(sm.history).toHaveLength(2);
|
|
28
|
+
expect(sm.history[0].from).toBe("IDLE");
|
|
29
|
+
expect(sm.history[0].to).toBe("BOOT");
|
|
30
|
+
expect(sm.history[1].from).toBe("BOOT");
|
|
31
|
+
expect(sm.history[1].to).toBe("WAIT_READY");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should emit stateChange event", () => {
|
|
35
|
+
const sm = new StateMachine();
|
|
36
|
+
const handler = vi.fn();
|
|
37
|
+
|
|
38
|
+
sm.on("stateChange", handler);
|
|
39
|
+
sm.transition("BOOT");
|
|
40
|
+
|
|
41
|
+
expect(handler).toHaveBeenCalledWith({
|
|
42
|
+
from: "IDLE",
|
|
43
|
+
to: "BOOT",
|
|
44
|
+
timestamp: expect.any(Number),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should check if in specific states", () => {
|
|
49
|
+
const sm = new StateMachine();
|
|
50
|
+
sm.transition("BOOT");
|
|
51
|
+
|
|
52
|
+
expect(sm.isIn("BOOT")).toBe(true);
|
|
53
|
+
expect(sm.isIn("IDLE", "BOOT")).toBe(true);
|
|
54
|
+
expect(sm.isIn("WAIT_READY")).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should reset state", () => {
|
|
58
|
+
const sm = new StateMachine();
|
|
59
|
+
sm.transition("BOOT");
|
|
60
|
+
sm.transition("WAIT_READY");
|
|
61
|
+
|
|
62
|
+
sm.reset();
|
|
63
|
+
|
|
64
|
+
expect(sm.state).toBe("IDLE");
|
|
65
|
+
expect(sm.history).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should handle error state", () => {
|
|
69
|
+
const sm = new StateMachine();
|
|
70
|
+
const errorHandler = vi.fn();
|
|
71
|
+
|
|
72
|
+
sm.on("error", errorHandler);
|
|
73
|
+
sm.transition("BOOT");
|
|
74
|
+
sm.error(new Error("Test error"));
|
|
75
|
+
|
|
76
|
+
expect(sm.state).toBe("ERROR");
|
|
77
|
+
expect(errorHandler).toHaveBeenCalledWith(
|
|
78
|
+
expect.any(Error),
|
|
79
|
+
"BOOT"
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("Diff utilities", () => {
|
|
85
|
+
it("should compute line diff", () => {
|
|
86
|
+
const before = "line1\nline2\nline3";
|
|
87
|
+
const after = "line1\nline2\nline4\nline5";
|
|
88
|
+
|
|
89
|
+
const result = computeLineDiff(before, after);
|
|
90
|
+
|
|
91
|
+
expect(result.added).toContain("line4");
|
|
92
|
+
expect(result.added).toContain("line5");
|
|
93
|
+
expect(result.removed).toContain("line3");
|
|
94
|
+
expect(result.unchanged).toContain("line1");
|
|
95
|
+
expect(result.unchanged).toContain("line2");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should get added text", () => {
|
|
99
|
+
const before = "Hello";
|
|
100
|
+
const after = "Hello World";
|
|
101
|
+
|
|
102
|
+
const added = getAddedText(before, after);
|
|
103
|
+
|
|
104
|
+
expect(added).toBe(" World");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should get removed text", () => {
|
|
108
|
+
const before = "Hello World";
|
|
109
|
+
const after = "Hello";
|
|
110
|
+
|
|
111
|
+
const removed = getRemovedText(before, after);
|
|
112
|
+
|
|
113
|
+
expect(removed).toBe(" World");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should handle empty strings", () => {
|
|
117
|
+
expect(getAddedText("", "new")).toBe("new");
|
|
118
|
+
expect(getRemovedText("old", "")).toBe("old");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ScreenSnapshot } from "../src/term/ScreenSnapshot.js";
|
|
3
|
+
import { Matchers } from "../src/expect/Matchers.js";
|
|
4
|
+
|
|
5
|
+
describe("ScreenSnapshot", () => {
|
|
6
|
+
const createSnapshot = (viewportText: string): ScreenSnapshot => {
|
|
7
|
+
return new ScreenSnapshot({
|
|
8
|
+
viewportText,
|
|
9
|
+
scrollbackText: viewportText,
|
|
10
|
+
cursor: { x: 0, y: 0 },
|
|
11
|
+
hash: ScreenSnapshot.computeHash(viewportText),
|
|
12
|
+
timestamp: Date.now(),
|
|
13
|
+
cols: 120,
|
|
14
|
+
rows: 40,
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
it("should compute hash correctly", () => {
|
|
19
|
+
const hash1 = ScreenSnapshot.computeHash("hello");
|
|
20
|
+
const hash2 = ScreenSnapshot.computeHash("hello");
|
|
21
|
+
const hash3 = ScreenSnapshot.computeHash("world");
|
|
22
|
+
|
|
23
|
+
expect(hash1).toBe(hash2);
|
|
24
|
+
expect(hash1).not.toBe(hash3);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should get viewport lines", () => {
|
|
28
|
+
const snapshot = createSnapshot("line1\nline2\nline3");
|
|
29
|
+
const lines = snapshot.getViewportLines();
|
|
30
|
+
|
|
31
|
+
expect(lines).toEqual(["line1", "line2", "line3"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should get viewport without bottom lines", () => {
|
|
35
|
+
const snapshot = createSnapshot("line1\nline2\nline3\nline4\nline5");
|
|
36
|
+
const text = snapshot.getViewportWithoutBottom(2);
|
|
37
|
+
|
|
38
|
+
expect(text).toBe("line1\nline2\nline3");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should match patterns", () => {
|
|
42
|
+
const snapshot = createSnapshot("Ready > \nSome content");
|
|
43
|
+
|
|
44
|
+
expect(snapshot.matchesPattern(/Ready/)).toBe(true);
|
|
45
|
+
expect(snapshot.matchesPattern(/NotFound/)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should diff snapshots", () => {
|
|
49
|
+
const before = createSnapshot("line1\nline2");
|
|
50
|
+
const after = new ScreenSnapshot({
|
|
51
|
+
...before.toJSON(),
|
|
52
|
+
scrollbackText: "line1\nline2\nline3",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const { added, removed } = before.diff(after);
|
|
56
|
+
|
|
57
|
+
expect(added).toContain("line3");
|
|
58
|
+
expect(removed).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("Matchers", () => {
|
|
63
|
+
const createSnapshot = (text: string): ScreenSnapshot => {
|
|
64
|
+
return new ScreenSnapshot({
|
|
65
|
+
viewportText: text,
|
|
66
|
+
scrollbackText: text,
|
|
67
|
+
cursor: { x: 5, y: 10 },
|
|
68
|
+
hash: "test",
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
cols: 120,
|
|
71
|
+
rows: 40,
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
it("should match regex pattern", () => {
|
|
76
|
+
const snapshot = createSnapshot("Ready > ");
|
|
77
|
+
const matcher = Matchers.regex(/Ready/);
|
|
78
|
+
|
|
79
|
+
expect(matcher(snapshot)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should match any of patterns", () => {
|
|
83
|
+
const snapshot = createSnapshot("Thinking...");
|
|
84
|
+
const matcher = Matchers.anyOf([/Ready/, /Thinking/]);
|
|
85
|
+
|
|
86
|
+
expect(matcher(snapshot)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should match all of patterns", () => {
|
|
90
|
+
const snapshot = createSnapshot("Ready and Waiting");
|
|
91
|
+
const matcher = Matchers.allOf([/Ready/, /Waiting/]);
|
|
92
|
+
|
|
93
|
+
expect(matcher(snapshot)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should match none of patterns", () => {
|
|
97
|
+
const snapshot = createSnapshot("Ready");
|
|
98
|
+
const matcher = Matchers.noneOf([/Error/, /Failed/]);
|
|
99
|
+
|
|
100
|
+
expect(matcher(snapshot)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should match cursor position", () => {
|
|
104
|
+
const snapshot = createSnapshot("test");
|
|
105
|
+
const matcher = Matchers.cursorAt(5, 10);
|
|
106
|
+
|
|
107
|
+
expect(matcher(snapshot)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should combine matchers with and", () => {
|
|
111
|
+
const snapshot = createSnapshot("Ready > ");
|
|
112
|
+
const matcher = Matchers.and(
|
|
113
|
+
Matchers.regex(/Ready/),
|
|
114
|
+
Matchers.regex(/>/),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(matcher(snapshot)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should combine matchers with or", () => {
|
|
121
|
+
const snapshot = createSnapshot("Error occurred");
|
|
122
|
+
const matcher = Matchers.or(
|
|
123
|
+
Matchers.regex(/Ready/),
|
|
124
|
+
Matchers.regex(/Error/),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(matcher(snapshot)).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should negate matcher", () => {
|
|
131
|
+
const snapshot = createSnapshot("Ready");
|
|
132
|
+
const matcher = Matchers.not(Matchers.regex(/Error/));
|
|
133
|
+
|
|
134
|
+
expect(matcher(snapshot)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true,
|
|
16
|
+
"resolveJsonModule": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist"]
|
|
20
|
+
}
|
package/vitest.config.ts
ADDED