brosh 0.2.2
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/LICENSE +21 -0
- package/README.md +181 -0
- package/brosh_brandmark.svg +3 -0
- package/brosh_logo.svg +27 -0
- package/cli_icon.svg +52 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +138 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +618 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.d.ts +25 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +28 -0
- package/dist/lib.js.map +1 -0
- package/dist/mode-selector.d.ts +7 -0
- package/dist/mode-selector.d.ts.map +1 -0
- package/dist/mode-selector.js +138 -0
- package/dist/mode-selector.js.map +1 -0
- package/dist/prompts/index.d.ts +3 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +79 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/recording/index.d.ts +4 -0
- package/dist/recording/index.d.ts.map +1 -0
- package/dist/recording/index.js +3 -0
- package/dist/recording/index.js.map +1 -0
- package/dist/recording/manager.d.ts +62 -0
- package/dist/recording/manager.d.ts.map +1 -0
- package/dist/recording/manager.js +123 -0
- package/dist/recording/manager.js.map +1 -0
- package/dist/recording/recorder.d.ts +95 -0
- package/dist/recording/recorder.d.ts.map +1 -0
- package/dist/recording/recorder.js +330 -0
- package/dist/recording/recorder.js.map +1 -0
- package/dist/recording/types.d.ts +65 -0
- package/dist/recording/types.d.ts.map +1 -0
- package/dist/recording/types.js +2 -0
- package/dist/recording/types.js.map +1 -0
- package/dist/sandbox/ModeSelector.d.ts +2 -0
- package/dist/sandbox/ModeSelector.d.ts.map +1 -0
- package/dist/sandbox/ModeSelector.js +2 -0
- package/dist/sandbox/ModeSelector.js.map +1 -0
- package/dist/sandbox/config.d.ts +46 -0
- package/dist/sandbox/config.d.ts.map +1 -0
- package/dist/sandbox/config.js +144 -0
- package/dist/sandbox/config.js.map +1 -0
- package/dist/sandbox/controller.d.ts +72 -0
- package/dist/sandbox/controller.d.ts.map +1 -0
- package/dist/sandbox/controller.js +208 -0
- package/dist/sandbox/controller.js.map +1 -0
- package/dist/sandbox/index.d.ts +6 -0
- package/dist/sandbox/index.d.ts.map +1 -0
- package/dist/sandbox/index.js +4 -0
- package/dist/sandbox/index.js.map +1 -0
- package/dist/sandbox/mode-prompt.d.ts +10 -0
- package/dist/sandbox/mode-prompt.d.ts.map +1 -0
- package/dist/sandbox/mode-prompt.js +130 -0
- package/dist/sandbox/mode-prompt.js.map +1 -0
- package/dist/sandbox/prompt.d.ts +10 -0
- package/dist/sandbox/prompt.d.ts.map +1 -0
- package/dist/sandbox/prompt.js +434 -0
- package/dist/sandbox/prompt.js.map +1 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +59 -0
- package/dist/server.js.map +1 -0
- package/dist/terminal/index.d.ts +5 -0
- package/dist/terminal/index.d.ts.map +1 -0
- package/dist/terminal/index.js +3 -0
- package/dist/terminal/index.js.map +1 -0
- package/dist/terminal/manager.d.ts +153 -0
- package/dist/terminal/manager.d.ts.map +1 -0
- package/dist/terminal/manager.js +276 -0
- package/dist/terminal/manager.js.map +1 -0
- package/dist/terminal/session.d.ts +137 -0
- package/dist/terminal/session.d.ts.map +1 -0
- package/dist/terminal/session.js +752 -0
- package/dist/terminal/session.js.map +1 -0
- package/dist/tools/definitions.d.ts +18 -0
- package/dist/tools/definitions.d.ts.map +1 -0
- package/dist/tools/definitions.js +114 -0
- package/dist/tools/definitions.js.map +1 -0
- package/dist/tools/getContent.d.ts +32 -0
- package/dist/tools/getContent.d.ts.map +1 -0
- package/dist/tools/getContent.js +38 -0
- package/dist/tools/getContent.js.map +1 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +49 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/screenshot.d.ts +20 -0
- package/dist/tools/screenshot.d.ts.map +1 -0
- package/dist/tools/screenshot.js +28 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/sendKey.d.ts +31 -0
- package/dist/tools/sendKey.d.ts.map +1 -0
- package/dist/tools/sendKey.js +38 -0
- package/dist/tools/sendKey.js.map +1 -0
- package/dist/tools/startRecording.d.ts +68 -0
- package/dist/tools/startRecording.d.ts.map +1 -0
- package/dist/tools/startRecording.js +111 -0
- package/dist/tools/startRecording.js.map +1 -0
- package/dist/tools/stopRecording.d.ts +31 -0
- package/dist/tools/stopRecording.d.ts.map +1 -0
- package/dist/tools/stopRecording.js +76 -0
- package/dist/tools/stopRecording.js.map +1 -0
- package/dist/tools/type.d.ts +31 -0
- package/dist/tools/type.d.ts.map +1 -0
- package/dist/tools/type.js +31 -0
- package/dist/tools/type.js.map +1 -0
- package/dist/transport/gui-protocol.d.ts +163 -0
- package/dist/transport/gui-protocol.d.ts.map +1 -0
- package/dist/transport/gui-protocol.js +68 -0
- package/dist/transport/gui-protocol.js.map +1 -0
- package/dist/transport/gui-stream.d.ts +139 -0
- package/dist/transport/gui-stream.d.ts.map +1 -0
- package/dist/transport/gui-stream.js +440 -0
- package/dist/transport/gui-stream.js.map +1 -0
- package/dist/transport/index.d.ts +6 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +6 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/transport/socket.d.ts +46 -0
- package/dist/transport/socket.d.ts.map +1 -0
- package/dist/transport/socket.js +310 -0
- package/dist/transport/socket.js.map +1 -0
- package/dist/types/mcp-client-info.d.ts +226 -0
- package/dist/types/mcp-client-info.d.ts.map +1 -0
- package/dist/types/mcp-client-info.js +62 -0
- package/dist/types/mcp-client-info.js.map +1 -0
- package/dist/ui/index.d.ts +12 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +84 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/utils/env.d.ts +17 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +35 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/keys.d.ts +16 -0
- package/dist/utils/keys.d.ts.map +1 -0
- package/dist/utils/keys.js +155 -0
- package/dist/utils/keys.js.map +1 -0
- package/dist/utils/platform.d.ts +16 -0
- package/dist/utils/platform.d.ts.map +1 -0
- package/dist/utils/platform.js +41 -0
- package/dist/utils/platform.js.map +1 -0
- package/dist/utils/session-logger.d.ts +31 -0
- package/dist/utils/session-logger.d.ts.map +1 -0
- package/dist/utils/session-logger.js +125 -0
- package/dist/utils/session-logger.js.map +1 -0
- package/dist/utils/stats.d.ts +46 -0
- package/dist/utils/stats.d.ts.map +1 -0
- package/dist/utils/stats.js +89 -0
- package/dist/utils/stats.js.map +1 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/version.d.ts.map +1 -0
- package/dist/utils/version.js +9 -0
- package/dist/utils/version.js.map +1 -0
- package/logo.png +0 -0
- package/package.json +61 -0
- package/packages/desktop-electron/THIRD-PARTY-NOTICES +56 -0
- package/packages/desktop-electron/build/afterPack.cjs +147 -0
- package/packages/desktop-electron/package-lock.json +10071 -0
- package/packages/desktop-electron/package.json +170 -0
- package/packages/desktop-electron/resources/icons/mac/icon.icns +0 -0
- package/packages/desktop-electron/resources/icons/png/1024x1024.png +0 -0
- package/packages/desktop-electron/resources/icons/png/128x128.png +0 -0
- package/packages/desktop-electron/resources/icons/png/16x16.png +0 -0
- package/packages/desktop-electron/resources/icons/png/24x24.png +0 -0
- package/packages/desktop-electron/resources/icons/png/256x256.png +0 -0
- package/packages/desktop-electron/resources/icons/png/32x32.png +0 -0
- package/packages/desktop-electron/resources/icons/png/48x48.png +0 -0
- package/packages/desktop-electron/resources/icons/png/512x512.png +0 -0
- package/packages/desktop-electron/resources/icons/png/64x64.png +0 -0
- package/packages/desktop-electron/resources/icons/win/icon.ico +0 -0
- package/packages/desktop-electron/scripts/download-models.js +97 -0
- package/packages/desktop-electron/scripts/prepare-sandbox-bins.js +186 -0
- package/packages/desktop-electron/tests/main/ai-detection/additionalFunctions.test.ts +224 -0
- package/packages/desktop-electron/tests/main/ai-detection/checkOverridePrefix.test.ts +162 -0
- package/packages/desktop-electron/tests/main/ai-detection/classifyInput.test.ts +132 -0
- package/packages/desktop-electron/tests/main/ai-detection/detectTypos.test.ts +342 -0
- package/packages/desktop-electron/tests/main/ai-detection/fixtures/commands.ts +134 -0
- package/packages/desktop-electron/tests/main/ai-detection/fixtures/natural-language.ts +133 -0
- package/packages/desktop-electron/tests/main/ai-detection/fixtures/typos.ts +123 -0
- package/packages/desktop-electron/tests/main/ai-detection/hasValidSubcommand.test.ts +218 -0
- package/packages/desktop-electron/tests/main/ai-detection/isCommandNotFound.test.ts +117 -0
- package/packages/desktop-electron/tests/main/error-triage/buildTriagePrompt.test.ts +133 -0
- package/packages/desktop-electron/tests/main/error-triage/parseTriageResponse.test.ts +123 -0
- package/packages/desktop-electron/tests/main/terminal-bridge/battery-optimization.test.ts +243 -0
- package/packages/desktop-electron/tests/main/terminal-bridge/command-fast-track.test.ts +292 -0
- package/packages/desktop-electron/tests/main/terminal-bridge/default-cwd.test.ts +70 -0
- package/packages/desktop-electron/tests/setup.ts +274 -0
- package/packages/desktop-electron/tsconfig.json +18 -0
- package/packages/desktop-electron/tsconfig.main.json +20 -0
- package/packages/desktop-electron/vite.config.ts +19 -0
- package/packages/desktop-electron/vitest.config.ts +18 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for triage response parsing.
|
|
5
|
+
*
|
|
6
|
+
* parseTriageResponse is not exported directly, so we test it
|
|
7
|
+
* through the module's behavior by examining the expected parsing patterns.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Since parseTriageResponse is private, test the expected parsing patterns
|
|
11
|
+
describe('Triage Response Parsing Patterns', () => {
|
|
12
|
+
describe('envelope format (claude --output-format json)', () => {
|
|
13
|
+
it('should parse standard envelope with result string', () => {
|
|
14
|
+
const envelope = {
|
|
15
|
+
result: '{"shouldNotify": true, "message": "Module not found: express"}',
|
|
16
|
+
};
|
|
17
|
+
const innerJson = envelope.result;
|
|
18
|
+
const parsed = JSON.parse(innerJson);
|
|
19
|
+
|
|
20
|
+
expect(parsed.shouldNotify).toBe(true);
|
|
21
|
+
expect(parsed.message).toBe('Module not found: express');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should handle direct format (no envelope)', () => {
|
|
25
|
+
const direct = {
|
|
26
|
+
shouldNotify: false,
|
|
27
|
+
message: '',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
expect(typeof direct.shouldNotify).toBe('boolean');
|
|
31
|
+
expect(direct.shouldNotify).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('inner JSON parsing', () => {
|
|
36
|
+
it('should parse clean JSON', () => {
|
|
37
|
+
const json = '{"shouldNotify": true, "message": "Permission denied on /etc/config"}';
|
|
38
|
+
const parsed = JSON.parse(json);
|
|
39
|
+
|
|
40
|
+
expect(parsed.shouldNotify).toBe(true);
|
|
41
|
+
expect(parsed.message).toContain('Permission denied');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should handle markdown code fence wrapping', () => {
|
|
45
|
+
const wrapped = '```json\n{"shouldNotify": true, "message": "Syntax error"}\n```';
|
|
46
|
+
const cleaned = wrapped
|
|
47
|
+
.replace(/^```(?:json)?\s*\n?/, '')
|
|
48
|
+
.replace(/\n?```\s*$/, '');
|
|
49
|
+
const parsed = JSON.parse(cleaned);
|
|
50
|
+
|
|
51
|
+
expect(parsed.shouldNotify).toBe(true);
|
|
52
|
+
expect(parsed.message).toBe('Syntax error');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should handle code fence without language tag', () => {
|
|
56
|
+
const wrapped = '```\n{"shouldNotify": false, "message": ""}\n```';
|
|
57
|
+
const cleaned = wrapped
|
|
58
|
+
.replace(/^```(?:json)?\s*\n?/, '')
|
|
59
|
+
.replace(/\n?```\s*$/, '');
|
|
60
|
+
const parsed = JSON.parse(cleaned);
|
|
61
|
+
|
|
62
|
+
expect(parsed.shouldNotify).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should reject missing shouldNotify field', () => {
|
|
66
|
+
const json = '{"message": "some error"}';
|
|
67
|
+
const parsed = JSON.parse(json);
|
|
68
|
+
|
|
69
|
+
expect(typeof parsed.shouldNotify).not.toBe('boolean');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should handle empty message for shouldNotify=false', () => {
|
|
73
|
+
const json = '{"shouldNotify": false, "message": ""}';
|
|
74
|
+
const parsed = JSON.parse(json);
|
|
75
|
+
|
|
76
|
+
expect(parsed.shouldNotify).toBe(false);
|
|
77
|
+
expect(parsed.message).toBe('');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('error scenarios', () => {
|
|
82
|
+
it('should handle empty stdout', () => {
|
|
83
|
+
const trimmed = ''.trim();
|
|
84
|
+
expect(trimmed).toBe('');
|
|
85
|
+
// parseTriageResponse returns null for empty input
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should handle malformed JSON', () => {
|
|
89
|
+
const malformed = '{"shouldNotify": true, message: broken}';
|
|
90
|
+
expect(() => JSON.parse(malformed)).toThrow();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should handle non-JSON responses', () => {
|
|
94
|
+
const text = 'The command failed because the module was not found.';
|
|
95
|
+
expect(() => JSON.parse(text)).toThrow();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('TriageResult contract', () => {
|
|
100
|
+
it('should always have boolean shouldNotify', () => {
|
|
101
|
+
const results = [
|
|
102
|
+
{ shouldNotify: true, message: 'Error occurred' },
|
|
103
|
+
{ shouldNotify: false, message: '' },
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
for (const result of results) {
|
|
107
|
+
expect(typeof result.shouldNotify).toBe('boolean');
|
|
108
|
+
expect(typeof result.message).toBe('string');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should coerce message to string', () => {
|
|
113
|
+
// The actual code does String(parsed.message || "")
|
|
114
|
+
const coerce = (val: unknown) => String(val || '');
|
|
115
|
+
|
|
116
|
+
expect(coerce('hello')).toBe('hello');
|
|
117
|
+
expect(coerce(undefined)).toBe('');
|
|
118
|
+
expect(coerce(null)).toBe('');
|
|
119
|
+
expect(coerce('')).toBe('');
|
|
120
|
+
expect(coerce(42)).toBe('42');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for battery optimization features in terminal-bridge.
|
|
5
|
+
*
|
|
6
|
+
* These tests verify the state management logic for:
|
|
7
|
+
* - Window focus/blur handling
|
|
8
|
+
* - System suspend/resume handling
|
|
9
|
+
* - Spinner throttling when window is not focused
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
describe('Battery Optimization', () => {
|
|
13
|
+
// Since TerminalBridge is tightly coupled to Electron's BrowserWindow,
|
|
14
|
+
// we test the state management logic by creating a minimal implementation
|
|
15
|
+
// that mirrors the actual behavior.
|
|
16
|
+
|
|
17
|
+
// Mock state that mirrors TerminalBridge internals
|
|
18
|
+
let windowFocused: boolean;
|
|
19
|
+
let systemSuspended: boolean;
|
|
20
|
+
let sessionAISpinner: Map<string, { timer: ReturnType<typeof setInterval>; frameIdx: number }>;
|
|
21
|
+
let sessionAIActive: Map<string, { cancel: () => void }>;
|
|
22
|
+
let startLoadingAnimationCalls: string[];
|
|
23
|
+
let clearedIntervals: ReturnType<typeof setInterval>[];
|
|
24
|
+
|
|
25
|
+
// Mock functions that mirror TerminalBridge methods
|
|
26
|
+
function setWindowFocused(focused: boolean): void {
|
|
27
|
+
windowFocused = focused;
|
|
28
|
+
if (!focused) {
|
|
29
|
+
// Pause AI spinners when window loses focus to save CPU
|
|
30
|
+
for (const spinner of sessionAISpinner.values()) {
|
|
31
|
+
clearInterval(spinner.timer);
|
|
32
|
+
clearedIntervals.push(spinner.timer);
|
|
33
|
+
}
|
|
34
|
+
} else if (!systemSuspended) {
|
|
35
|
+
// Resume spinners for active AI sessions when window regains focus
|
|
36
|
+
for (const sessionId of sessionAIActive.keys()) {
|
|
37
|
+
if (!sessionAISpinner.has(sessionId)) {
|
|
38
|
+
startLoadingAnimationCalls.push(sessionId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function handleSystemSuspend(): void {
|
|
45
|
+
systemSuspended = true;
|
|
46
|
+
// Stop all AI spinners during system sleep
|
|
47
|
+
for (const spinner of sessionAISpinner.values()) {
|
|
48
|
+
clearInterval(spinner.timer);
|
|
49
|
+
clearedIntervals.push(spinner.timer);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function handleSystemResume(): void {
|
|
54
|
+
systemSuspended = false;
|
|
55
|
+
if (windowFocused) {
|
|
56
|
+
// Resume spinners for active AI sessions
|
|
57
|
+
for (const sessionId of sessionAIActive.keys()) {
|
|
58
|
+
if (!sessionAISpinner.has(sessionId)) {
|
|
59
|
+
startLoadingAnimationCalls.push(sessionId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
windowFocused = true;
|
|
67
|
+
systemSuspended = false;
|
|
68
|
+
sessionAISpinner = new Map();
|
|
69
|
+
sessionAIActive = new Map();
|
|
70
|
+
startLoadingAnimationCalls = [];
|
|
71
|
+
clearedIntervals = [];
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('setWindowFocused', () => {
|
|
75
|
+
it('should update windowFocused state to true', () => {
|
|
76
|
+
windowFocused = false;
|
|
77
|
+
setWindowFocused(true);
|
|
78
|
+
expect(windowFocused).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should update windowFocused state to false', () => {
|
|
82
|
+
windowFocused = true;
|
|
83
|
+
setWindowFocused(false);
|
|
84
|
+
expect(windowFocused).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should clear all spinner intervals when window loses focus', () => {
|
|
88
|
+
// Set up active spinners
|
|
89
|
+
const timer1 = setInterval(() => {}, 1000);
|
|
90
|
+
const timer2 = setInterval(() => {}, 1000);
|
|
91
|
+
sessionAISpinner.set('session-1', { timer: timer1, frameIdx: 0 });
|
|
92
|
+
sessionAISpinner.set('session-2', { timer: timer2, frameIdx: 0 });
|
|
93
|
+
|
|
94
|
+
setWindowFocused(false);
|
|
95
|
+
|
|
96
|
+
expect(clearedIntervals).toContain(timer1);
|
|
97
|
+
expect(clearedIntervals).toContain(timer2);
|
|
98
|
+
|
|
99
|
+
// Clean up
|
|
100
|
+
clearInterval(timer1);
|
|
101
|
+
clearInterval(timer2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should restart spinners for active AI sessions when window regains focus', () => {
|
|
105
|
+
windowFocused = false;
|
|
106
|
+
sessionAIActive.set('session-1', { cancel: () => {} });
|
|
107
|
+
sessionAIActive.set('session-2', { cancel: () => {} });
|
|
108
|
+
|
|
109
|
+
setWindowFocused(true);
|
|
110
|
+
|
|
111
|
+
expect(startLoadingAnimationCalls).toContain('session-1');
|
|
112
|
+
expect(startLoadingAnimationCalls).toContain('session-2');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should not restart spinners if system is suspended', () => {
|
|
116
|
+
windowFocused = false;
|
|
117
|
+
systemSuspended = true;
|
|
118
|
+
sessionAIActive.set('session-1', { cancel: () => {} });
|
|
119
|
+
|
|
120
|
+
setWindowFocused(true);
|
|
121
|
+
|
|
122
|
+
expect(startLoadingAnimationCalls).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should not restart spinners that already exist', () => {
|
|
126
|
+
windowFocused = false;
|
|
127
|
+
sessionAIActive.set('session-1', { cancel: () => {} });
|
|
128
|
+
const timer = setInterval(() => {}, 1000);
|
|
129
|
+
sessionAISpinner.set('session-1', { timer, frameIdx: 0 });
|
|
130
|
+
|
|
131
|
+
setWindowFocused(true);
|
|
132
|
+
|
|
133
|
+
expect(startLoadingAnimationCalls).not.toContain('session-1');
|
|
134
|
+
|
|
135
|
+
// Clean up
|
|
136
|
+
clearInterval(timer);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('handleSystemSuspend', () => {
|
|
141
|
+
it('should set systemSuspended to true', () => {
|
|
142
|
+
expect(systemSuspended).toBe(false);
|
|
143
|
+
handleSystemSuspend();
|
|
144
|
+
expect(systemSuspended).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should clear all spinner intervals', () => {
|
|
148
|
+
const timer1 = setInterval(() => {}, 1000);
|
|
149
|
+
const timer2 = setInterval(() => {}, 1000);
|
|
150
|
+
sessionAISpinner.set('session-1', { timer: timer1, frameIdx: 0 });
|
|
151
|
+
sessionAISpinner.set('session-2', { timer: timer2, frameIdx: 0 });
|
|
152
|
+
|
|
153
|
+
handleSystemSuspend();
|
|
154
|
+
|
|
155
|
+
expect(clearedIntervals).toContain(timer1);
|
|
156
|
+
expect(clearedIntervals).toContain(timer2);
|
|
157
|
+
|
|
158
|
+
// Clean up
|
|
159
|
+
clearInterval(timer1);
|
|
160
|
+
clearInterval(timer2);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('handleSystemResume', () => {
|
|
165
|
+
it('should set systemSuspended to false', () => {
|
|
166
|
+
systemSuspended = true;
|
|
167
|
+
handleSystemResume();
|
|
168
|
+
expect(systemSuspended).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should restart spinners for active sessions when window is focused', () => {
|
|
172
|
+
systemSuspended = true;
|
|
173
|
+
windowFocused = true;
|
|
174
|
+
sessionAIActive.set('session-1', { cancel: () => {} });
|
|
175
|
+
sessionAIActive.set('session-2', { cancel: () => {} });
|
|
176
|
+
|
|
177
|
+
handleSystemResume();
|
|
178
|
+
|
|
179
|
+
expect(startLoadingAnimationCalls).toContain('session-1');
|
|
180
|
+
expect(startLoadingAnimationCalls).toContain('session-2');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should not restart spinners when window is not focused', () => {
|
|
184
|
+
systemSuspended = true;
|
|
185
|
+
windowFocused = false;
|
|
186
|
+
sessionAIActive.set('session-1', { cancel: () => {} });
|
|
187
|
+
|
|
188
|
+
handleSystemResume();
|
|
189
|
+
|
|
190
|
+
expect(startLoadingAnimationCalls).toHaveLength(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should not restart spinners that already exist', () => {
|
|
194
|
+
systemSuspended = true;
|
|
195
|
+
windowFocused = true;
|
|
196
|
+
sessionAIActive.set('session-1', { cancel: () => {} });
|
|
197
|
+
const timer = setInterval(() => {}, 1000);
|
|
198
|
+
sessionAISpinner.set('session-1', { timer, frameIdx: 0 });
|
|
199
|
+
|
|
200
|
+
handleSystemResume();
|
|
201
|
+
|
|
202
|
+
expect(startLoadingAnimationCalls).not.toContain('session-1');
|
|
203
|
+
|
|
204
|
+
// Clean up
|
|
205
|
+
clearInterval(timer);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('interaction between focus and suspend states', () => {
|
|
210
|
+
it('should not restart spinners when resuming from sleep if window was blurred', () => {
|
|
211
|
+
// Simulate: window blurred -> system sleeps -> system wakes
|
|
212
|
+
windowFocused = true;
|
|
213
|
+
sessionAIActive.set('session-1', { cancel: () => {} });
|
|
214
|
+
|
|
215
|
+
// Window loses focus
|
|
216
|
+
setWindowFocused(false);
|
|
217
|
+
startLoadingAnimationCalls = [];
|
|
218
|
+
|
|
219
|
+
// System goes to sleep
|
|
220
|
+
handleSystemSuspend();
|
|
221
|
+
|
|
222
|
+
// System wakes up (window still not focused)
|
|
223
|
+
handleSystemResume();
|
|
224
|
+
|
|
225
|
+
expect(startLoadingAnimationCalls).toHaveLength(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should restart spinners when window is focused after wake', () => {
|
|
229
|
+
// Simulate: system sleeps -> system wakes -> window focused
|
|
230
|
+
systemSuspended = true;
|
|
231
|
+
windowFocused = false;
|
|
232
|
+
sessionAIActive.set('session-1', { cancel: () => {} });
|
|
233
|
+
|
|
234
|
+
// System wakes up
|
|
235
|
+
handleSystemResume();
|
|
236
|
+
expect(startLoadingAnimationCalls).toHaveLength(0);
|
|
237
|
+
|
|
238
|
+
// Window gets focus
|
|
239
|
+
setWindowFocused(true);
|
|
240
|
+
expect(startLoadingAnimationCalls).toContain('session-1');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
classifyInput,
|
|
4
|
+
isKnownCommand,
|
|
5
|
+
initializeDetection,
|
|
6
|
+
} from '../../../src/main/ai-detection.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tests for command-first input classification and retroactive NL detection.
|
|
10
|
+
*
|
|
11
|
+
* The fast-track flow:
|
|
12
|
+
* Enter → denylist → isKnownCommand? → YES → shell (skip ML)
|
|
13
|
+
* → NO → ML classifier
|
|
14
|
+
*
|
|
15
|
+
* When a fast-tracked command fails (non-zero exit):
|
|
16
|
+
* Re-classify with ML → NL? → invoke AI retroactively
|
|
17
|
+
* → CMD? → normal error triage
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
describe('Command Fast-Track Classification', () => {
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
await initializeDetection({ preloadML: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('isKnownCommand', () => {
|
|
26
|
+
it('should recognize common Unix commands', () => {
|
|
27
|
+
const knownCommands = ['ls', 'cd', 'cat', 'grep', 'find', 'mkdir', 'rm', 'cp', 'mv', 'chmod'];
|
|
28
|
+
for (const cmd of knownCommands) {
|
|
29
|
+
expect(isKnownCommand(cmd)).toBe(true);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should recognize package managers', () => {
|
|
34
|
+
const packageManagers = ['npm', 'yarn', 'pip', 'brew', 'cargo'];
|
|
35
|
+
for (const cmd of packageManagers) {
|
|
36
|
+
expect(isKnownCommand(cmd)).toBe(true);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should recognize language runtimes', () => {
|
|
41
|
+
const runtimes = ['node', 'python', 'ruby', 'java', 'go'];
|
|
42
|
+
for (const cmd of runtimes) {
|
|
43
|
+
expect(isKnownCommand(cmd)).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should recognize version control tools', () => {
|
|
48
|
+
expect(isKnownCommand('git')).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should recognize container tools', () => {
|
|
52
|
+
const containers = ['docker', 'kubectl'];
|
|
53
|
+
for (const cmd of containers) {
|
|
54
|
+
expect(isKnownCommand(cmd)).toBe(true);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should NOT recognize natural language words', () => {
|
|
59
|
+
const nlWords = ['how', 'what', 'list', 'show', 'explain', 'help', 'create', 'delete'];
|
|
60
|
+
for (const word of nlWords) {
|
|
61
|
+
// Some of these might happen to be commands (like 'help')
|
|
62
|
+
// but most NL starter words should not be known commands
|
|
63
|
+
if (word === 'help') continue; // shell builtin
|
|
64
|
+
expect(isKnownCommand(word)).toBe(false);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should NOT recognize gibberish', () => {
|
|
69
|
+
expect(isKnownCommand('asdfgh')).toBe(false);
|
|
70
|
+
expect(isKnownCommand('xyzzy')).toBe(false);
|
|
71
|
+
expect(isKnownCommand('foobar123')).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should be case-sensitive (commands are lowercase)', () => {
|
|
75
|
+
// isKnownCommand expects lowercase input (caller normalizes)
|
|
76
|
+
expect(isKnownCommand('ls')).toBe(true);
|
|
77
|
+
expect(isKnownCommand('node')).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('fast-track decision logic', () => {
|
|
82
|
+
// Mirrors the logic in terminal-bridge.ts handleInputWithAIDetection:
|
|
83
|
+
// 1. Extract firstWord from trimmedLine
|
|
84
|
+
// 2. If isKnownCommand(firstWord) → skip ML, send to shell
|
|
85
|
+
// 3. Else → run ML classifyInput
|
|
86
|
+
|
|
87
|
+
function shouldFastTrack(input: string): boolean {
|
|
88
|
+
const trimmed = input.trim();
|
|
89
|
+
if (!trimmed) return false;
|
|
90
|
+
const firstWord = trimmed.split(/\s+/)[0].toLowerCase();
|
|
91
|
+
return isKnownCommand(firstWord);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
it('should fast-track simple commands', () => {
|
|
95
|
+
expect(shouldFastTrack('ls')).toBe(true);
|
|
96
|
+
expect(shouldFastTrack('ls -la')).toBe(true);
|
|
97
|
+
expect(shouldFastTrack('git status')).toBe(true);
|
|
98
|
+
expect(shouldFastTrack('npm install')).toBe(true);
|
|
99
|
+
expect(shouldFastTrack('docker ps')).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should fast-track commands where first word is a known command', () => {
|
|
103
|
+
// Even if the rest looks like NL, fast-track based on first word
|
|
104
|
+
expect(shouldFastTrack('node has how many letters')).toBe(true);
|
|
105
|
+
expect(shouldFastTrack('python is a great language')).toBe(true);
|
|
106
|
+
expect(shouldFastTrack('git is version control')).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should NOT fast-track natural language', () => {
|
|
110
|
+
expect(shouldFastTrack('list all files')).toBe(false);
|
|
111
|
+
expect(shouldFastTrack('show me the logs')).toBe(false);
|
|
112
|
+
expect(shouldFastTrack('how do I install node')).toBe(false);
|
|
113
|
+
expect(shouldFastTrack('what is my ip address')).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should NOT fast-track unknown first words', () => {
|
|
117
|
+
expect(shouldFastTrack('thisis a prompt')).toBe(false);
|
|
118
|
+
expect(shouldFastTrack('asdfgh')).toBe(false);
|
|
119
|
+
expect(shouldFastTrack('please run the tests')).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should NOT fast-track empty input', () => {
|
|
123
|
+
expect(shouldFastTrack('')).toBe(false);
|
|
124
|
+
expect(shouldFastTrack(' ')).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('retroactive ML re-classification', () => {
|
|
129
|
+
// When a fast-tracked command fails, we re-classify with ML.
|
|
130
|
+
// If ML says NL with high confidence → invoke AI
|
|
131
|
+
// If ML says CMD → normal error handling
|
|
132
|
+
|
|
133
|
+
it('should re-classify "node has how many letters" as NL', async () => {
|
|
134
|
+
// First word is known (node), so it gets fast-tracked
|
|
135
|
+
expect(isKnownCommand('node')).toBe(true);
|
|
136
|
+
|
|
137
|
+
// But ML should classify the full input as NL
|
|
138
|
+
const result = await classifyInput('node has how many letters');
|
|
139
|
+
// The mock ML classifier should see this as a question-like statement
|
|
140
|
+
// The actual ML model classifies this as NL with high confidence
|
|
141
|
+
// With the mock, this depends on the heuristics in setup.ts
|
|
142
|
+
expect(result).toHaveProperty('classification');
|
|
143
|
+
expect(result).toHaveProperty('confidence');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should re-classify "node -e require(missing)" as CMD', async () => {
|
|
147
|
+
expect(isKnownCommand('node')).toBe(true);
|
|
148
|
+
|
|
149
|
+
// This is a real command that happened to fail
|
|
150
|
+
const result = await classifyInput('node -e "require(\'missing\')"');
|
|
151
|
+
expect(result.classification).toBe('COMMAND');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should re-classify "git is version control" as NL', async () => {
|
|
155
|
+
expect(isKnownCommand('git')).toBe(true);
|
|
156
|
+
|
|
157
|
+
// NL starting with a known command
|
|
158
|
+
const result = await classifyInput('git is version control');
|
|
159
|
+
// This should be classified based on ML heuristics
|
|
160
|
+
expect(result).toHaveProperty('classification');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should re-classify real commands with flags as CMD', async () => {
|
|
164
|
+
const realCommands = [
|
|
165
|
+
'node --version',
|
|
166
|
+
'node -e "console.log(1)"',
|
|
167
|
+
'python -m pytest',
|
|
168
|
+
'git log --oneline',
|
|
169
|
+
'npm run build',
|
|
170
|
+
];
|
|
171
|
+
for (const cmd of realCommands) {
|
|
172
|
+
const result = await classifyInput(cmd);
|
|
173
|
+
expect(result.classification).toBe('COMMAND');
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('fast-track state management', () => {
|
|
179
|
+
// Mirrors sessionFastTracked / sessionFastTrackLines Map behavior
|
|
180
|
+
|
|
181
|
+
it('should track fast-track state per session', () => {
|
|
182
|
+
const sessionFastTracked = new Map<string, boolean>();
|
|
183
|
+
const sessionFastTrackLines = new Map<string, number>();
|
|
184
|
+
|
|
185
|
+
// Session 1: fast-tracked command
|
|
186
|
+
sessionFastTracked.set('session-1', true);
|
|
187
|
+
sessionFastTrackLines.set('session-1', 0);
|
|
188
|
+
|
|
189
|
+
// Session 2: ML-classified command
|
|
190
|
+
sessionFastTracked.set('session-2', false);
|
|
191
|
+
|
|
192
|
+
expect(sessionFastTracked.get('session-1')).toBe(true);
|
|
193
|
+
expect(sessionFastTracked.get('session-2')).toBe(false);
|
|
194
|
+
expect(sessionFastTrackLines.get('session-1')).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should count output lines for fast-tracked commands', () => {
|
|
198
|
+
const sessionFastTrackLines = new Map<string, number>();
|
|
199
|
+
sessionFastTrackLines.set('session-1', 0);
|
|
200
|
+
|
|
201
|
+
// Simulate PTY output chunks with newlines
|
|
202
|
+
function countNewlines(data: string, sessionId: string) {
|
|
203
|
+
const lines = sessionFastTrackLines.get(sessionId) || 0;
|
|
204
|
+
let newlines = 0;
|
|
205
|
+
for (let i = 0; i < data.length; i++) {
|
|
206
|
+
if (data[i] === '\n') newlines++;
|
|
207
|
+
}
|
|
208
|
+
if (newlines > 0) {
|
|
209
|
+
sessionFastTrackLines.set(sessionId, lines + newlines);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
countNewlines('\r\n', 'session-1'); // Enter echo
|
|
214
|
+
countNewlines('error line 1\r\n', 'session-1');
|
|
215
|
+
countNewlines('error line 2\r\n', 'session-1');
|
|
216
|
+
countNewlines('error line 3\r\n', 'session-1');
|
|
217
|
+
|
|
218
|
+
expect(sessionFastTrackLines.get('session-1')).toBe(4);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should calculate correct erase line count', () => {
|
|
222
|
+
// outputLines includes the Enter-echo \n, so -1 to preserve original prompt
|
|
223
|
+
const outputLines = 19; // e.g., 1 (enter echo) + 18 (error output)
|
|
224
|
+
const linesToErase = outputLines - 1;
|
|
225
|
+
expect(linesToErase).toBe(18);
|
|
226
|
+
|
|
227
|
+
// Edge case: only Enter echo, no error output
|
|
228
|
+
const minOutputLines = 1;
|
|
229
|
+
const minErase = minOutputLines - 1;
|
|
230
|
+
expect(minErase).toBe(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should not erase when outputLines <= 1', () => {
|
|
234
|
+
// Guard condition from handleFailedFastTrack
|
|
235
|
+
const shouldErase = (outputLines: number) => outputLines > 1;
|
|
236
|
+
|
|
237
|
+
expect(shouldErase(0)).toBe(false);
|
|
238
|
+
expect(shouldErase(1)).toBe(false);
|
|
239
|
+
expect(shouldErase(2)).toBe(true);
|
|
240
|
+
expect(shouldErase(19)).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should generate correct escape sequence for erasure', () => {
|
|
244
|
+
const outputLines = 19;
|
|
245
|
+
const linesToErase = outputLines - 1;
|
|
246
|
+
const escapeSeq = `\r\x1b[${linesToErase}A\x1b[J`;
|
|
247
|
+
|
|
248
|
+
// \r moves to column 0 (fixes partial line remnant)
|
|
249
|
+
expect(escapeSeq).toContain('\r');
|
|
250
|
+
// \x1b[18A moves cursor up 18 lines
|
|
251
|
+
expect(escapeSeq).toContain('\x1b[18A');
|
|
252
|
+
// \x1b[J clears from cursor to end of screen
|
|
253
|
+
expect(escapeSeq).toContain('\x1b[J');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should clean up state on session close', () => {
|
|
257
|
+
const sessionFastTracked = new Map<string, boolean>();
|
|
258
|
+
const sessionFastTrackLines = new Map<string, number>();
|
|
259
|
+
|
|
260
|
+
sessionFastTracked.set('session-1', true);
|
|
261
|
+
sessionFastTrackLines.set('session-1', 15);
|
|
262
|
+
|
|
263
|
+
// Simulate closeSession cleanup
|
|
264
|
+
sessionFastTracked.delete('session-1');
|
|
265
|
+
sessionFastTrackLines.delete('session-1');
|
|
266
|
+
|
|
267
|
+
expect(sessionFastTracked.has('session-1')).toBe(false);
|
|
268
|
+
expect(sessionFastTrackLines.has('session-1')).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('edge cases from plan', () => {
|
|
273
|
+
// Test cases from the implementation plan's edge case table
|
|
274
|
+
|
|
275
|
+
it('clear → fast-tracked, exits 0, no retroactive check', () => {
|
|
276
|
+
expect(isKnownCommand('clear')).toBe(true);
|
|
277
|
+
// exit 0 → no retroactive ML check needed
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('node --help → fast-tracked, exits 0', () => {
|
|
281
|
+
expect(isKnownCommand('node')).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('"list all files" → NOT fast-tracked, goes to ML', () => {
|
|
285
|
+
expect(isKnownCommand('list')).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('"thisis a prompt" → NOT fast-tracked, goes to ML', () => {
|
|
289
|
+
expect(isKnownCommand('thisis')).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|