tuplet 2.7.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/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/agent.d.ts +46 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +393 -0
- package/dist/agent.js.map +1 -0
- package/dist/built-in-agents/explore.d.ts +9 -0
- package/dist/built-in-agents/explore.d.ts.map +1 -0
- package/dist/built-in-agents/explore.js +40 -0
- package/dist/built-in-agents/explore.js.map +1 -0
- package/dist/built-in-agents/index.d.ts +15 -0
- package/dist/built-in-agents/index.d.ts.map +1 -0
- package/dist/built-in-agents/index.js +19 -0
- package/dist/built-in-agents/index.js.map +1 -0
- package/dist/built-in-agents/plan.d.ts +10 -0
- package/dist/built-in-agents/plan.d.ts.map +1 -0
- package/dist/built-in-agents/plan.js +62 -0
- package/dist/built-in-agents/plan.js.map +1 -0
- package/dist/built-in-agents/worker.d.ts +9 -0
- package/dist/built-in-agents/worker.d.ts.map +1 -0
- package/dist/built-in-agents/worker.js +53 -0
- package/dist/built-in-agents/worker.js.map +1 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +18 -0
- package/dist/constants.js.map +1 -0
- package/dist/context-manager.d.ts +65 -0
- package/dist/context-manager.d.ts.map +1 -0
- package/dist/context-manager.js +272 -0
- package/dist/context-manager.js.map +1 -0
- package/dist/context-manager.test.d.ts +2 -0
- package/dist/context-manager.test.d.ts.map +1 -0
- package/dist/context-manager.test.js +394 -0
- package/dist/context-manager.test.js.map +1 -0
- package/dist/executor.d.ts +29 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +399 -0
- package/dist/executor.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/prompt/index.d.ts +9 -0
- package/dist/prompt/index.d.ts.map +1 -0
- package/dist/prompt/index.js +10 -0
- package/dist/prompt/index.js.map +1 -0
- package/dist/prompt/main-agent-builder.d.ts +81 -0
- package/dist/prompt/main-agent-builder.d.ts.map +1 -0
- package/dist/prompt/main-agent-builder.js +287 -0
- package/dist/prompt/main-agent-builder.js.map +1 -0
- package/dist/prompt/sub-agent-builder.d.ts +133 -0
- package/dist/prompt/sub-agent-builder.d.ts.map +1 -0
- package/dist/prompt/sub-agent-builder.js +337 -0
- package/dist/prompt/sub-agent-builder.js.map +1 -0
- package/dist/prompt/templates.d.ts +87 -0
- package/dist/prompt/templates.d.ts.map +1 -0
- package/dist/prompt/templates.js +343 -0
- package/dist/prompt/templates.js.map +1 -0
- package/dist/prompt/types.d.ts +159 -0
- package/dist/prompt/types.d.ts.map +1 -0
- package/dist/prompt/types.js +5 -0
- package/dist/prompt/types.js.map +1 -0
- package/dist/prompt.d.ts +32 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +86 -0
- package/dist/prompt.js.map +1 -0
- package/dist/providers/dataset/base.d.ts +74 -0
- package/dist/providers/dataset/base.d.ts.map +1 -0
- package/dist/providers/dataset/base.js +7 -0
- package/dist/providers/dataset/base.js.map +1 -0
- package/dist/providers/dataset/index.d.ts +8 -0
- package/dist/providers/dataset/index.d.ts.map +1 -0
- package/dist/providers/dataset/index.js +8 -0
- package/dist/providers/dataset/index.js.map +1 -0
- package/dist/providers/dataset/recorder.d.ts +46 -0
- package/dist/providers/dataset/recorder.d.ts.map +1 -0
- package/dist/providers/dataset/recorder.js +105 -0
- package/dist/providers/dataset/recorder.js.map +1 -0
- package/dist/providers/dataset/replayer.d.ts +46 -0
- package/dist/providers/dataset/replayer.d.ts.map +1 -0
- package/dist/providers/dataset/replayer.js +163 -0
- package/dist/providers/dataset/replayer.js.map +1 -0
- package/dist/providers/dataset/tester.d.ts +89 -0
- package/dist/providers/dataset/tester.d.ts.map +1 -0
- package/dist/providers/dataset/tester.js +143 -0
- package/dist/providers/dataset/tester.js.map +1 -0
- package/dist/providers/env/memory.d.ts +14 -0
- package/dist/providers/env/memory.d.ts.map +1 -0
- package/dist/providers/env/memory.js +19 -0
- package/dist/providers/env/memory.js.map +1 -0
- package/dist/providers/index.d.ts +12 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +10 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/llm/base.d.ts +7 -0
- package/dist/providers/llm/base.d.ts.map +1 -0
- package/dist/providers/llm/base.js +7 -0
- package/dist/providers/llm/base.js.map +1 -0
- package/dist/providers/llm/claude.d.ts +32 -0
- package/dist/providers/llm/claude.d.ts.map +1 -0
- package/dist/providers/llm/claude.js +171 -0
- package/dist/providers/llm/claude.js.map +1 -0
- package/dist/providers/llm/openai.d.ts +26 -0
- package/dist/providers/llm/openai.d.ts.map +1 -0
- package/dist/providers/llm/openai.js +174 -0
- package/dist/providers/llm/openai.js.map +1 -0
- package/dist/providers/llm/openrouter.d.ts +43 -0
- package/dist/providers/llm/openrouter.d.ts.map +1 -0
- package/dist/providers/llm/openrouter.js +288 -0
- package/dist/providers/llm/openrouter.js.map +1 -0
- package/dist/providers/logger/base.d.ts +7 -0
- package/dist/providers/logger/base.d.ts.map +1 -0
- package/dist/providers/logger/base.js +7 -0
- package/dist/providers/logger/base.js.map +1 -0
- package/dist/providers/logger/console.d.ts +29 -0
- package/dist/providers/logger/console.d.ts.map +1 -0
- package/dist/providers/logger/console.js +70 -0
- package/dist/providers/logger/console.js.map +1 -0
- package/dist/providers/repository/base.d.ts +7 -0
- package/dist/providers/repository/base.d.ts.map +1 -0
- package/dist/providers/repository/base.js +7 -0
- package/dist/providers/repository/base.js.map +1 -0
- package/dist/providers/repository/memory.d.ts +21 -0
- package/dist/providers/repository/memory.d.ts.map +1 -0
- package/dist/providers/repository/memory.js +50 -0
- package/dist/providers/repository/memory.js.map +1 -0
- package/dist/providers/workspace/file.d.ts +26 -0
- package/dist/providers/workspace/file.d.ts.map +1 -0
- package/dist/providers/workspace/file.js +151 -0
- package/dist/providers/workspace/file.js.map +1 -0
- package/dist/providers/workspace/index.d.ts +7 -0
- package/dist/providers/workspace/index.d.ts.map +1 -0
- package/dist/providers/workspace/index.js +6 -0
- package/dist/providers/workspace/index.js.map +1 -0
- package/dist/providers/workspace/memory.d.ts +26 -0
- package/dist/providers/workspace/memory.d.ts.map +1 -0
- package/dist/providers/workspace/memory.js +136 -0
- package/dist/providers/workspace/memory.js.map +1 -0
- package/dist/providers/workspace/types.d.ts +27 -0
- package/dist/providers/workspace/types.d.ts.map +1 -0
- package/dist/providers/workspace/types.js +8 -0
- package/dist/providers/workspace/types.js.map +1 -0
- package/dist/providers/workspace/workspace-provider.test.d.ts +2 -0
- package/dist/providers/workspace/workspace-provider.test.d.ts.map +1 -0
- package/dist/providers/workspace/workspace-provider.test.js +250 -0
- package/dist/providers/workspace/workspace-provider.test.js.map +1 -0
- package/dist/shell/commands/browse.d.ts +6 -0
- package/dist/shell/commands/browse.d.ts.map +1 -0
- package/dist/shell/commands/browse.js +158 -0
- package/dist/shell/commands/browse.js.map +1 -0
- package/dist/shell/commands/cat.d.ts +6 -0
- package/dist/shell/commands/cat.d.ts.map +1 -0
- package/dist/shell/commands/cat.js +104 -0
- package/dist/shell/commands/cat.js.map +1 -0
- package/dist/shell/commands/curl.d.ts +6 -0
- package/dist/shell/commands/curl.d.ts.map +1 -0
- package/dist/shell/commands/curl.js +190 -0
- package/dist/shell/commands/curl.js.map +1 -0
- package/dist/shell/commands/date.d.ts +6 -0
- package/dist/shell/commands/date.d.ts.map +1 -0
- package/dist/shell/commands/date.js +151 -0
- package/dist/shell/commands/date.js.map +1 -0
- package/dist/shell/commands/echo.d.ts +6 -0
- package/dist/shell/commands/echo.d.ts.map +1 -0
- package/dist/shell/commands/echo.js +48 -0
- package/dist/shell/commands/echo.js.map +1 -0
- package/dist/shell/commands/env.d.ts +8 -0
- package/dist/shell/commands/env.d.ts.map +1 -0
- package/dist/shell/commands/env.js +41 -0
- package/dist/shell/commands/env.js.map +1 -0
- package/dist/shell/commands/file.d.ts +6 -0
- package/dist/shell/commands/file.d.ts.map +1 -0
- package/dist/shell/commands/file.js +213 -0
- package/dist/shell/commands/file.js.map +1 -0
- package/dist/shell/commands/find.d.ts +6 -0
- package/dist/shell/commands/find.d.ts.map +1 -0
- package/dist/shell/commands/find.js +100 -0
- package/dist/shell/commands/find.js.map +1 -0
- package/dist/shell/commands/grep.d.ts +6 -0
- package/dist/shell/commands/grep.d.ts.map +1 -0
- package/dist/shell/commands/grep.js +229 -0
- package/dist/shell/commands/grep.js.map +1 -0
- package/dist/shell/commands/head.d.ts +6 -0
- package/dist/shell/commands/head.d.ts.map +1 -0
- package/dist/shell/commands/head.js +88 -0
- package/dist/shell/commands/head.js.map +1 -0
- package/dist/shell/commands/index.d.ts +25 -0
- package/dist/shell/commands/index.d.ts.map +1 -0
- package/dist/shell/commands/index.js +43 -0
- package/dist/shell/commands/index.js.map +1 -0
- package/dist/shell/commands/jq.d.ts +8 -0
- package/dist/shell/commands/jq.d.ts.map +1 -0
- package/dist/shell/commands/jq.js +233 -0
- package/dist/shell/commands/jq.js.map +1 -0
- package/dist/shell/commands/ls.d.ts +6 -0
- package/dist/shell/commands/ls.d.ts.map +1 -0
- package/dist/shell/commands/ls.js +88 -0
- package/dist/shell/commands/ls.js.map +1 -0
- package/dist/shell/commands/mkdir.d.ts +6 -0
- package/dist/shell/commands/mkdir.d.ts.map +1 -0
- package/dist/shell/commands/mkdir.js +43 -0
- package/dist/shell/commands/mkdir.js.map +1 -0
- package/dist/shell/commands/rm.d.ts +6 -0
- package/dist/shell/commands/rm.d.ts.map +1 -0
- package/dist/shell/commands/rm.js +64 -0
- package/dist/shell/commands/rm.js.map +1 -0
- package/dist/shell/commands/sed.d.ts +6 -0
- package/dist/shell/commands/sed.d.ts.map +1 -0
- package/dist/shell/commands/sed.js +414 -0
- package/dist/shell/commands/sed.js.map +1 -0
- package/dist/shell/commands/sort.d.ts +6 -0
- package/dist/shell/commands/sort.d.ts.map +1 -0
- package/dist/shell/commands/sort.js +109 -0
- package/dist/shell/commands/sort.js.map +1 -0
- package/dist/shell/commands/tail.d.ts +6 -0
- package/dist/shell/commands/tail.d.ts.map +1 -0
- package/dist/shell/commands/tail.js +68 -0
- package/dist/shell/commands/tail.js.map +1 -0
- package/dist/shell/commands/wc.d.ts +6 -0
- package/dist/shell/commands/wc.d.ts.map +1 -0
- package/dist/shell/commands/wc.js +86 -0
- package/dist/shell/commands/wc.js.map +1 -0
- package/dist/shell/index.d.ts +10 -0
- package/dist/shell/index.d.ts.map +1 -0
- package/dist/shell/index.js +10 -0
- package/dist/shell/index.js.map +1 -0
- package/dist/shell/limits.d.ts +5 -0
- package/dist/shell/limits.d.ts.map +1 -0
- package/dist/shell/limits.js +5 -0
- package/dist/shell/limits.js.map +1 -0
- package/dist/shell/parser.d.ts +8 -0
- package/dist/shell/parser.d.ts.map +1 -0
- package/dist/shell/parser.js +307 -0
- package/dist/shell/parser.js.map +1 -0
- package/dist/shell/path-validation.d.ts +35 -0
- package/dist/shell/path-validation.d.ts.map +1 -0
- package/dist/shell/path-validation.js +81 -0
- package/dist/shell/path-validation.js.map +1 -0
- package/dist/shell/shell.d.ts +66 -0
- package/dist/shell/shell.d.ts.map +1 -0
- package/dist/shell/shell.js +301 -0
- package/dist/shell/shell.js.map +1 -0
- package/dist/shell/shell.test.d.ts +2 -0
- package/dist/shell/shell.test.d.ts.map +1 -0
- package/dist/shell/shell.test.js +1088 -0
- package/dist/shell/shell.test.js.map +1 -0
- package/dist/shell/types.d.ts +82 -0
- package/dist/shell/types.d.ts.map +1 -0
- package/dist/shell/types.js +5 -0
- package/dist/shell/types.js.map +1 -0
- package/dist/summarizer.d.ts +28 -0
- package/dist/summarizer.d.ts.map +1 -0
- package/dist/summarizer.js +136 -0
- package/dist/summarizer.js.map +1 -0
- package/dist/summarizer.test.d.ts +2 -0
- package/dist/summarizer.test.d.ts.map +1 -0
- package/dist/summarizer.test.js +192 -0
- package/dist/summarizer.test.js.map +1 -0
- package/dist/tools/ask-user.d.ts +11 -0
- package/dist/tools/ask-user.d.ts.map +1 -0
- package/dist/tools/ask-user.js +35 -0
- package/dist/tools/ask-user.js.map +1 -0
- package/dist/tools/index.d.ts +11 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +18 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/output.d.ts +15 -0
- package/dist/tools/output.d.ts.map +1 -0
- package/dist/tools/output.js +40 -0
- package/dist/tools/output.js.map +1 -0
- package/dist/tools/shell.d.ts +13 -0
- package/dist/tools/shell.d.ts.map +1 -0
- package/dist/tools/shell.js +166 -0
- package/dist/tools/shell.js.map +1 -0
- package/dist/tools/sub-agent.d.ts +25 -0
- package/dist/tools/sub-agent.d.ts.map +1 -0
- package/dist/tools/sub-agent.js +312 -0
- package/dist/tools/sub-agent.js.map +1 -0
- package/dist/tools/tasks.d.ts +170 -0
- package/dist/tools/tasks.d.ts.map +1 -0
- package/dist/tools/tasks.js +947 -0
- package/dist/tools/tasks.js.map +1 -0
- package/dist/trace/builder.d.ts +54 -0
- package/dist/trace/builder.d.ts.map +1 -0
- package/dist/trace/builder.js +229 -0
- package/dist/trace/builder.js.map +1 -0
- package/dist/trace/console.d.ts +45 -0
- package/dist/trace/console.d.ts.map +1 -0
- package/dist/trace/console.js +143 -0
- package/dist/trace/console.js.map +1 -0
- package/dist/trace/index.d.ts +11 -0
- package/dist/trace/index.d.ts.map +1 -0
- package/dist/trace/index.js +11 -0
- package/dist/trace/index.js.map +1 -0
- package/dist/trace/openrouter-pricing.d.ts +9 -0
- package/dist/trace/openrouter-pricing.d.ts.map +1 -0
- package/dist/trace/openrouter-pricing.js +321 -0
- package/dist/trace/openrouter-pricing.js.map +1 -0
- package/dist/trace/pricing.d.ts +13 -0
- package/dist/trace/pricing.d.ts.map +1 -0
- package/dist/trace/pricing.js +41 -0
- package/dist/trace/pricing.js.map +1 -0
- package/dist/trace/types.d.ts +142 -0
- package/dist/trace/types.d.ts.map +1 -0
- package/dist/trace/types.js +16 -0
- package/dist/trace/types.js.map +1 -0
- package/dist/trace.d.ts +5 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +5 -0
- package/dist/trace.js.map +1 -0
- package/dist/types.d.ts +382 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace.d.ts +287 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +560 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { Shell } from './shell.js';
|
|
3
|
+
import { commands } from './commands/index.js';
|
|
4
|
+
import { MAX_FILE_SIZE, MAX_LINE_LENGTH } from './limits.js';
|
|
5
|
+
import { createShellTool } from '../tools/shell.js';
|
|
6
|
+
describe('Shell', () => {
|
|
7
|
+
let shell;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
shell = new Shell();
|
|
10
|
+
});
|
|
11
|
+
describe('constructor', () => {
|
|
12
|
+
it('creates shell with default options', () => {
|
|
13
|
+
expect(shell.getFS()).toBeDefined();
|
|
14
|
+
expect(shell.getEnv()).toEqual({});
|
|
15
|
+
});
|
|
16
|
+
it('accepts initial context data', async () => {
|
|
17
|
+
shell = new Shell({ initialContext: { name: 'Alice' } });
|
|
18
|
+
const fs = shell.getFS();
|
|
19
|
+
expect(await fs.read('/name')).toBe('Alice');
|
|
20
|
+
});
|
|
21
|
+
it('accepts an external WorkspaceProvider', async () => {
|
|
22
|
+
const original = new Shell({ initialContext: { key: 'value' } });
|
|
23
|
+
const shared = new Shell({ fs: original.getFS() });
|
|
24
|
+
const result = await shared.execute('cat key');
|
|
25
|
+
expect(result.stdout).toBe('value');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('execute', () => {
|
|
29
|
+
it('returns empty result for empty input', async () => {
|
|
30
|
+
const result = await shell.execute('');
|
|
31
|
+
expect(result).toEqual({ exitCode: 0, stdout: '', stderr: '' });
|
|
32
|
+
});
|
|
33
|
+
it('returns error for unknown commands with list of available ones', async () => {
|
|
34
|
+
const result = await shell.execute('nonexistent arg1');
|
|
35
|
+
expect(result.exitCode).toBe(127);
|
|
36
|
+
expect(result.stderr).toContain('command not found: nonexistent');
|
|
37
|
+
expect(result.stderr).toContain('Available commands:');
|
|
38
|
+
expect(result.stderr).toContain('cat');
|
|
39
|
+
expect(result.stderr).toContain('echo');
|
|
40
|
+
expect(result.stderr).toContain('grep');
|
|
41
|
+
});
|
|
42
|
+
it('catches and returns errors as stderr', async () => {
|
|
43
|
+
// cat with no args and no stdin produces an error
|
|
44
|
+
const result = await shell.execute('cat');
|
|
45
|
+
expect(result.exitCode).toBe(1);
|
|
46
|
+
expect(result.stderr).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('built-in commands', () => {
|
|
50
|
+
describe('echo', () => {
|
|
51
|
+
it('echoes text with trailing newline', async () => {
|
|
52
|
+
const result = await shell.execute('echo hello world');
|
|
53
|
+
expect(result.exitCode).toBe(0);
|
|
54
|
+
expect(result.stdout).toBe('hello world\n');
|
|
55
|
+
});
|
|
56
|
+
it('supports -n flag to suppress newline', async () => {
|
|
57
|
+
const result = await shell.execute('echo -n hello');
|
|
58
|
+
expect(result.stdout).toBe('hello');
|
|
59
|
+
});
|
|
60
|
+
it('supports -e flag for escape sequences', async () => {
|
|
61
|
+
// Single quotes preserve literal backslash through the parser
|
|
62
|
+
const result = await shell.execute("echo -e 'hello\\nworld'");
|
|
63
|
+
expect(result.stdout).toBe('hello\nworld\n');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('cat', () => {
|
|
67
|
+
it('reads file content', async () => {
|
|
68
|
+
await shell.getFS().write('/data', 'file content');
|
|
69
|
+
const result = await shell.execute('cat data');
|
|
70
|
+
expect(result.exitCode).toBe(0);
|
|
71
|
+
expect(result.stdout).toBe('file content');
|
|
72
|
+
});
|
|
73
|
+
it('returns error for missing file', async () => {
|
|
74
|
+
const result = await shell.execute('cat missing');
|
|
75
|
+
expect(result.exitCode).toBe(1);
|
|
76
|
+
expect(result.stderr).toContain('No such file');
|
|
77
|
+
});
|
|
78
|
+
it('concatenates multiple files', async () => {
|
|
79
|
+
await shell.getFS().write('/a', 'AAA');
|
|
80
|
+
await shell.getFS().write('/b', 'BBB');
|
|
81
|
+
const result = await shell.execute('cat a b');
|
|
82
|
+
expect(result.stdout).toBe('AAABBB');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('grep', () => {
|
|
86
|
+
beforeEach(async () => {
|
|
87
|
+
await shell.getFS().write('/log', 'INFO: started\nERROR: failed\nINFO: done\n');
|
|
88
|
+
});
|
|
89
|
+
it('filters lines by pattern', async () => {
|
|
90
|
+
const result = await shell.execute('grep ERROR log');
|
|
91
|
+
expect(result.exitCode).toBe(0);
|
|
92
|
+
expect(result.stdout).toContain('ERROR: failed');
|
|
93
|
+
});
|
|
94
|
+
it('returns exit code 1 when no match', async () => {
|
|
95
|
+
const result = await shell.execute('grep WARN log');
|
|
96
|
+
expect(result.exitCode).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
it('supports -i for case-insensitive search', async () => {
|
|
99
|
+
const result = await shell.execute('grep -i error log');
|
|
100
|
+
expect(result.exitCode).toBe(0);
|
|
101
|
+
expect(result.stdout).toContain('ERROR: failed');
|
|
102
|
+
});
|
|
103
|
+
it('supports -n for line numbers', async () => {
|
|
104
|
+
const result = await shell.execute('grep -n ERROR log');
|
|
105
|
+
expect(result.stdout).toContain('2:ERROR: failed');
|
|
106
|
+
});
|
|
107
|
+
it('supports -v for inverted match', async () => {
|
|
108
|
+
const result = await shell.execute('grep -v ERROR log');
|
|
109
|
+
expect(result.stdout).toContain('INFO: started');
|
|
110
|
+
expect(result.stdout).toContain('INFO: done');
|
|
111
|
+
expect(result.stdout).not.toContain('ERROR');
|
|
112
|
+
});
|
|
113
|
+
it('searches stdin when no file given', async () => {
|
|
114
|
+
const result = await shell.execute('echo -e "foo\\nbar\\nbaz" | grep bar');
|
|
115
|
+
expect(result.exitCode).toBe(0);
|
|
116
|
+
expect(result.stdout).toContain('bar');
|
|
117
|
+
});
|
|
118
|
+
it('supports -o for only-matching output', async () => {
|
|
119
|
+
await shell.getFS().write('/prices', 'Revenue was $50M last quarter\nRaised $10B in funding\nNo amount here\n');
|
|
120
|
+
const result = await shell.execute("grep -o '\\$[0-9]*\\(M\\|B\\)' prices");
|
|
121
|
+
expect(result.exitCode).toBe(0);
|
|
122
|
+
expect(result.stdout).toContain('$50M');
|
|
123
|
+
expect(result.stdout).toContain('$10B');
|
|
124
|
+
expect(result.stdout).not.toContain('Revenue');
|
|
125
|
+
expect(result.stdout).not.toContain('No amount');
|
|
126
|
+
});
|
|
127
|
+
it('supports -o with piped input and head', async () => {
|
|
128
|
+
await shell.getFS().write('/article', '<p>Company raised $20M in Series A</p>\n<p>Another got $5M seed</p>\n<p>Big corp raised $3B</p>\n');
|
|
129
|
+
const result = await shell.execute("cat article | grep -o '\\$[0-9]*\\(M\\|B\\)' | head -2");
|
|
130
|
+
expect(result.exitCode).toBe(0);
|
|
131
|
+
expect(result.stdout).toContain('$20M');
|
|
132
|
+
expect(result.stdout).toContain('$5M');
|
|
133
|
+
expect(result.stdout).not.toContain('$3B');
|
|
134
|
+
});
|
|
135
|
+
it('extracts dollar amounts from curl output piped to grep -o and head', async () => {
|
|
136
|
+
const result = await shell.execute("curl -s 'https://techcrunch.com/2026/02/11/former-founders-fund-vc-sam-blond-launches-ai-sales-startup-to-upend-salesforce/' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 2>/dev/null | grep -o '\\$[0-9]*\\(M\\|B\\)' | head -5");
|
|
137
|
+
// curl may succeed or page may not have amounts — either way the pipeline should not error on parsing
|
|
138
|
+
expect(result.stderr).not.toContain('missing file operand');
|
|
139
|
+
expect(result.stderr).not.toContain('No such file');
|
|
140
|
+
if (result.exitCode === 0) {
|
|
141
|
+
const lines = result.stdout.trim().split('\n');
|
|
142
|
+
expect(lines.length).toBeLessThanOrEqual(5);
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
expect(line).toMatch(/^\$[0-9]+(M|B)$/);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}, 15000);
|
|
148
|
+
it('supports combined flags like -oE and -oP', async () => {
|
|
149
|
+
await shell.getFS().write('/data', 'foo123bar\nbaz456qux\n');
|
|
150
|
+
const result = await shell.execute("grep -oE '[0-9]+' data");
|
|
151
|
+
expect(result.exitCode).toBe(0);
|
|
152
|
+
expect(result.stdout).toContain('123');
|
|
153
|
+
expect(result.stdout).toContain('456');
|
|
154
|
+
expect(result.stdout).not.toContain('foo');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('pipes', () => {
|
|
159
|
+
it('pipes stdout of one command into stdin of the next', async () => {
|
|
160
|
+
await shell.getFS().write('/data', 'line1\nline2\nline3\n');
|
|
161
|
+
const result = await shell.execute('cat data | grep line2');
|
|
162
|
+
expect(result.exitCode).toBe(0);
|
|
163
|
+
expect(result.stdout).toContain('line2');
|
|
164
|
+
expect(result.stdout).not.toContain('line1');
|
|
165
|
+
});
|
|
166
|
+
it('chains multiple pipes', async () => {
|
|
167
|
+
await shell.getFS().write('/data', 'apple\nbanana\napricot\nblueberry\n');
|
|
168
|
+
const result = await shell.execute('cat data | grep a | grep p');
|
|
169
|
+
expect(result.exitCode).toBe(0);
|
|
170
|
+
expect(result.stdout).toContain('apple');
|
|
171
|
+
expect(result.stdout).toContain('apricot');
|
|
172
|
+
expect(result.stdout).not.toContain('banana');
|
|
173
|
+
});
|
|
174
|
+
it('stops pipe chain on non-zero exit code', async () => {
|
|
175
|
+
const result = await shell.execute('cat missing | grep foo');
|
|
176
|
+
expect(result.exitCode).toBe(1);
|
|
177
|
+
expect(result.stderr).toContain('No such file');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
describe('redirections', () => {
|
|
181
|
+
it('supports output redirection with >', async () => {
|
|
182
|
+
const result = await shell.execute('echo hello > out');
|
|
183
|
+
expect(result.exitCode).toBe(0);
|
|
184
|
+
expect(result.stdout).toBe('');
|
|
185
|
+
expect(await shell.getFS().read('/out')).toBe('hello\n');
|
|
186
|
+
});
|
|
187
|
+
it('supports append redirection with >>', async () => {
|
|
188
|
+
await shell.getFS().write('/out', 'first\n');
|
|
189
|
+
await shell.execute('echo second >> out');
|
|
190
|
+
expect(await shell.getFS().read('/out')).toBe('first\nsecond\n');
|
|
191
|
+
});
|
|
192
|
+
it('supports input redirection with <', async () => {
|
|
193
|
+
await shell.getFS().write('/input', 'hello from file');
|
|
194
|
+
const result = await shell.execute('cat < input');
|
|
195
|
+
expect(result.exitCode).toBe(0);
|
|
196
|
+
expect(result.stdout).toBe('hello from file');
|
|
197
|
+
});
|
|
198
|
+
it('returns error for input redirection from missing file', async () => {
|
|
199
|
+
const result = await shell.execute('cat < missing');
|
|
200
|
+
expect(result.exitCode).toBe(1);
|
|
201
|
+
expect(result.stderr).toContain('No such file');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe('comments and multiline', () => {
|
|
205
|
+
it('ignores # comment lines', async () => {
|
|
206
|
+
const result = await shell.execute('# this is a comment\necho hello');
|
|
207
|
+
expect(result.exitCode).toBe(0);
|
|
208
|
+
expect(result.stdout).toBe('hello\n');
|
|
209
|
+
});
|
|
210
|
+
it('ignores multiple comment lines', async () => {
|
|
211
|
+
const result = await shell.execute('# comment 1\n# comment 2\necho ok');
|
|
212
|
+
expect(result.exitCode).toBe(0);
|
|
213
|
+
expect(result.stdout).toBe('ok\n');
|
|
214
|
+
});
|
|
215
|
+
it('ignores inline blank lines', async () => {
|
|
216
|
+
const result = await shell.execute('\n\necho hi\n\n');
|
|
217
|
+
expect(result.exitCode).toBe(0);
|
|
218
|
+
expect(result.stdout).toBe('hi\n');
|
|
219
|
+
});
|
|
220
|
+
it('handles comment-only input as empty', async () => {
|
|
221
|
+
const result = await shell.execute('# just a comment');
|
|
222
|
+
expect(result).toEqual({ exitCode: 0, stdout: '', stderr: '' });
|
|
223
|
+
});
|
|
224
|
+
it('does not strip # inside quoted strings', async () => {
|
|
225
|
+
const result = await shell.execute("echo '# not a comment'");
|
|
226
|
+
expect(result.exitCode).toBe(0);
|
|
227
|
+
expect(result.stdout).toBe('# not a comment\n');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
describe('sequential commands', () => {
|
|
231
|
+
it('executes multiple lines sequentially', async () => {
|
|
232
|
+
const result = await shell.execute('mkdir data\necho hello > data/file.txt');
|
|
233
|
+
expect(result.exitCode).toBe(0);
|
|
234
|
+
expect(await shell.getFS().read('/data/file.txt')).toBe('hello\n');
|
|
235
|
+
});
|
|
236
|
+
it('stops on first error', async () => {
|
|
237
|
+
const result = await shell.execute('cat missing\necho should-not-run > out');
|
|
238
|
+
expect(result.exitCode).toBe(1);
|
|
239
|
+
expect(result.stderr).toContain('No such file');
|
|
240
|
+
expect(await shell.getFS().exists('/out')).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
it('concatenates stdout from multiple commands', async () => {
|
|
243
|
+
const result = await shell.execute('echo first\necho second');
|
|
244
|
+
expect(result.exitCode).toBe(0);
|
|
245
|
+
expect(result.stdout).toBe('first\nsecond\n');
|
|
246
|
+
});
|
|
247
|
+
it('handles comments between sequential commands', async () => {
|
|
248
|
+
const result = await shell.execute('echo one\n# skip this\necho two');
|
|
249
|
+
expect(result.exitCode).toBe(0);
|
|
250
|
+
expect(result.stdout).toBe('one\ntwo\n');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
describe('heredoc', () => {
|
|
254
|
+
it('supports basic heredoc with cat', async () => {
|
|
255
|
+
const input = `cat << EOF > data.json\n{"name": "Alice"}\nEOF`;
|
|
256
|
+
const result = await shell.execute(input);
|
|
257
|
+
expect(result.exitCode).toBe(0);
|
|
258
|
+
expect(await shell.getFS().read('/data.json')).toBe('{"name": "Alice"}');
|
|
259
|
+
});
|
|
260
|
+
it('supports multi-line heredoc content', async () => {
|
|
261
|
+
const input = [
|
|
262
|
+
'cat << EOF > plan.json',
|
|
263
|
+
'{',
|
|
264
|
+
' "title": "My Plan",',
|
|
265
|
+
' "days": [1, 2, 3]',
|
|
266
|
+
'}',
|
|
267
|
+
'EOF'
|
|
268
|
+
].join('\n');
|
|
269
|
+
const result = await shell.execute(input);
|
|
270
|
+
expect(result.exitCode).toBe(0);
|
|
271
|
+
const content = await shell.getFS().read('/plan.json');
|
|
272
|
+
expect(content).toContain('"title": "My Plan"');
|
|
273
|
+
expect(content).toContain('"days": [1, 2, 3]');
|
|
274
|
+
});
|
|
275
|
+
it('supports heredoc with append redirection', async () => {
|
|
276
|
+
await shell.getFS().write('/log', 'line1\n');
|
|
277
|
+
const input = `cat << EOF >> log\nline2\nline3\nEOF`;
|
|
278
|
+
const result = await shell.execute(input);
|
|
279
|
+
expect(result.exitCode).toBe(0);
|
|
280
|
+
expect(await shell.getFS().read('/log')).toBe('line1\nline2\nline3');
|
|
281
|
+
});
|
|
282
|
+
it('supports heredoc without quotes around delimiter', async () => {
|
|
283
|
+
const input = `cat <<EOF > out\nhello heredoc\nEOF`;
|
|
284
|
+
const result = await shell.execute(input);
|
|
285
|
+
expect(result.exitCode).toBe(0);
|
|
286
|
+
expect(await shell.getFS().read('/out')).toBe('hello heredoc');
|
|
287
|
+
});
|
|
288
|
+
it('supports commands before heredoc', async () => {
|
|
289
|
+
const input = [
|
|
290
|
+
'mkdir meals',
|
|
291
|
+
'cat << EOF > meals/day1.json',
|
|
292
|
+
'{"day": "Monday", "calories": 1800}',
|
|
293
|
+
'EOF'
|
|
294
|
+
].join('\n');
|
|
295
|
+
const result = await shell.execute(input);
|
|
296
|
+
expect(result.exitCode).toBe(0);
|
|
297
|
+
expect(await shell.getFS().read('/meals/day1.json')).toBe('{"day": "Monday", "calories": 1800}');
|
|
298
|
+
});
|
|
299
|
+
it('supports comments before heredoc', async () => {
|
|
300
|
+
const input = [
|
|
301
|
+
'# Create meal plan',
|
|
302
|
+
'cat << EOF > plan.json',
|
|
303
|
+
'{"plan": true}',
|
|
304
|
+
'EOF'
|
|
305
|
+
].join('\n');
|
|
306
|
+
const result = await shell.execute(input);
|
|
307
|
+
expect(result.exitCode).toBe(0);
|
|
308
|
+
expect(await shell.getFS().read('/plan.json')).toBe('{"plan": true}');
|
|
309
|
+
});
|
|
310
|
+
it('pipes heredoc content through commands', async () => {
|
|
311
|
+
const input = `cat << EOF | grep apple\napple\nbanana\napricot\nEOF`;
|
|
312
|
+
const result = await shell.execute(input);
|
|
313
|
+
expect(result.exitCode).toBe(0);
|
|
314
|
+
expect(result.stdout).toContain('apple');
|
|
315
|
+
expect(result.stdout).not.toContain('banana');
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
describe('register', () => {
|
|
319
|
+
it('registers a custom command handler', async () => {
|
|
320
|
+
shell.register({
|
|
321
|
+
name: 'greet',
|
|
322
|
+
async execute(args) {
|
|
323
|
+
return { exitCode: 0, stdout: `Hello, ${args[0] || 'world'}!\n`, stderr: '' };
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
const result = await shell.execute('greet Alice');
|
|
327
|
+
expect(result.exitCode).toBe(0);
|
|
328
|
+
expect(result.stdout).toBe('Hello, Alice!\n');
|
|
329
|
+
});
|
|
330
|
+
it('overrides built-in commands', async () => {
|
|
331
|
+
shell.register({
|
|
332
|
+
name: 'echo',
|
|
333
|
+
async execute(args) {
|
|
334
|
+
return { exitCode: 0, stdout: args.join('-'), stderr: '' };
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
const result = await shell.execute('echo a b c');
|
|
338
|
+
expect(result.stdout).toBe('a-b-c');
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
describe('environment', () => {
|
|
342
|
+
it('sets and gets environment variables', () => {
|
|
343
|
+
shell.setEnv('FOO', 'bar');
|
|
344
|
+
expect(shell.getEnv()).toEqual({ FOO: 'bar' });
|
|
345
|
+
});
|
|
346
|
+
it('expands $VAR in echo arguments', async () => {
|
|
347
|
+
shell.setEnv('NAME', 'Alice');
|
|
348
|
+
const result = await shell.execute('echo hello $NAME');
|
|
349
|
+
expect(result.exitCode).toBe(0);
|
|
350
|
+
expect(result.stdout).toBe('hello Alice\n');
|
|
351
|
+
});
|
|
352
|
+
it('expands ${VAR} syntax', async () => {
|
|
353
|
+
shell.setEnv('GREETING', 'hi');
|
|
354
|
+
const result = await shell.execute('echo ${GREETING} there');
|
|
355
|
+
expect(result.exitCode).toBe(0);
|
|
356
|
+
expect(result.stdout).toBe('hi there\n');
|
|
357
|
+
});
|
|
358
|
+
it('expands unknown variables to empty string', async () => {
|
|
359
|
+
const result = await shell.execute('echo hello $MISSING world');
|
|
360
|
+
expect(result.exitCode).toBe(0);
|
|
361
|
+
expect(result.stdout).toBe('hello world\n');
|
|
362
|
+
});
|
|
363
|
+
it('expands variables in redirections', async () => {
|
|
364
|
+
shell.setEnv('FILE', 'output.txt');
|
|
365
|
+
await shell.execute('echo data > $FILE');
|
|
366
|
+
const read = await shell.execute('cat output.txt');
|
|
367
|
+
expect(read.stdout).toBe('data\n');
|
|
368
|
+
});
|
|
369
|
+
it('expands variables piped to jq', async () => {
|
|
370
|
+
shell.setEnv('JSON_DATA', '{"name":"Alice","age":30}');
|
|
371
|
+
const result = await shell.execute('echo $JSON_DATA | jq .name');
|
|
372
|
+
expect(result.exitCode).toBe(0);
|
|
373
|
+
expect(result.stdout).toBe('"Alice"\n');
|
|
374
|
+
});
|
|
375
|
+
it('supports VAR=value assignment syntax', async () => {
|
|
376
|
+
await shell.execute('MY_VAR=hello');
|
|
377
|
+
expect(shell.getEnv()['MY_VAR']).toBe('hello');
|
|
378
|
+
const result = await shell.execute('echo $MY_VAR');
|
|
379
|
+
expect(result.stdout).toBe('hello\n');
|
|
380
|
+
});
|
|
381
|
+
it('expands variables in heredoc content', async () => {
|
|
382
|
+
shell.setEnv('TITLE', 'My Plan');
|
|
383
|
+
const result = await shell.execute('cat << EOF\nTitle: $TITLE\nEOF');
|
|
384
|
+
expect(result.exitCode).toBe(0);
|
|
385
|
+
expect(result.stdout).toBe('Title: My Plan');
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
describe('help command', () => {
|
|
389
|
+
it('lists all commands with descriptions when no args', async () => {
|
|
390
|
+
const result = await shell.execute('help');
|
|
391
|
+
expect(result.exitCode).toBe(0);
|
|
392
|
+
expect(result.stdout).toContain('Available commands:');
|
|
393
|
+
expect(result.stdout).toContain('cat');
|
|
394
|
+
expect(result.stdout).toContain('curl');
|
|
395
|
+
expect(result.stdout).toContain('grep');
|
|
396
|
+
expect(result.stdout).toContain('browse');
|
|
397
|
+
expect(result.stdout).toContain('help');
|
|
398
|
+
expect(result.stdout).toContain('help <command>');
|
|
399
|
+
});
|
|
400
|
+
it('shows detailed help for a specific command', async () => {
|
|
401
|
+
const result = await shell.execute('help curl');
|
|
402
|
+
expect(result.exitCode).toBe(0);
|
|
403
|
+
expect(result.stdout).toContain('curl - Transfer data from or to a server');
|
|
404
|
+
expect(result.stdout).toContain('Usage: curl [OPTIONS] URL');
|
|
405
|
+
expect(result.stdout).toContain('Flags:');
|
|
406
|
+
expect(result.stdout).toContain('-X METHOD');
|
|
407
|
+
expect(result.stdout).toContain('Examples:');
|
|
408
|
+
expect(result.stdout).toContain('GET request');
|
|
409
|
+
});
|
|
410
|
+
it('returns error for unknown command', async () => {
|
|
411
|
+
const result = await shell.execute('help nonexistent');
|
|
412
|
+
expect(result.exitCode).toBe(1);
|
|
413
|
+
expect(result.stderr).toContain("unknown command 'nonexistent'");
|
|
414
|
+
});
|
|
415
|
+
it('shows notes when available', async () => {
|
|
416
|
+
const result = await shell.execute('help grep');
|
|
417
|
+
expect(result.exitCode).toBe(0);
|
|
418
|
+
expect(result.stdout).toContain('Notes:');
|
|
419
|
+
expect(result.stdout).toContain('JavaScript regex syntax');
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
describe('browse command', () => {
|
|
423
|
+
it('fetches a URL and converts HTML to text', async () => {
|
|
424
|
+
const mockHtml = '<html><body><h1>Title</h1><p>Hello world. This is a real web page with enough content to pass quality checks for the browse command.</p></body></html>';
|
|
425
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(mockHtml, { status: 200, headers: { 'Content-Type': 'text/html' } }));
|
|
426
|
+
const result = await shell.execute('browse https://example.com');
|
|
427
|
+
expect(result.exitCode).toBe(0);
|
|
428
|
+
expect(result.stdout).toContain('# Title');
|
|
429
|
+
expect(result.stdout).toContain('Hello world');
|
|
430
|
+
});
|
|
431
|
+
it('returns raw HTML with --raw flag', async () => {
|
|
432
|
+
const mockHtml = '<html><body><h1>Title</h1></body></html>';
|
|
433
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(mockHtml, { status: 200, headers: { 'Content-Type': 'text/html' } }));
|
|
434
|
+
const result = await shell.execute('browse --raw https://example.com');
|
|
435
|
+
expect(result.exitCode).toBe(0);
|
|
436
|
+
expect(result.stdout).toContain('<html>');
|
|
437
|
+
expect(result.stdout).toContain('<h1>Title</h1>');
|
|
438
|
+
});
|
|
439
|
+
it('strips script and style tags', async () => {
|
|
440
|
+
const mockHtml = '<html><body><script>alert("x")</script><style>.x{}</style><p>This is a paragraph with enough content to pass the quality check for browse command.</p></body></html>';
|
|
441
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(mockHtml, { status: 200, headers: { 'Content-Type': 'text/html' } }));
|
|
442
|
+
const result = await shell.execute('browse https://example.com');
|
|
443
|
+
expect(result.exitCode).toBe(0);
|
|
444
|
+
expect(result.stdout).not.toContain('alert');
|
|
445
|
+
expect(result.stdout).not.toContain('.x{}');
|
|
446
|
+
expect(result.stdout).toContain('enough content');
|
|
447
|
+
});
|
|
448
|
+
it('converts links to markdown format', async () => {
|
|
449
|
+
const mockHtml = '<html><body><p>Here is a page with a link: <a href="https://test.com">Link Text</a> and enough surrounding content to be valid.</p></body></html>';
|
|
450
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(mockHtml, { status: 200, headers: { 'Content-Type': 'text/html' } }));
|
|
451
|
+
const result = await shell.execute('browse https://example.com');
|
|
452
|
+
expect(result.exitCode).toBe(0);
|
|
453
|
+
expect(result.stdout).toContain('[Link Text](https://test.com)');
|
|
454
|
+
});
|
|
455
|
+
it('returns error when no URL specified', async () => {
|
|
456
|
+
const result = await shell.execute('browse');
|
|
457
|
+
expect(result.exitCode).toBe(1);
|
|
458
|
+
expect(result.stderr).toContain('no URL specified');
|
|
459
|
+
});
|
|
460
|
+
it('returns error on HTTP failure', async () => {
|
|
461
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('Not Found', { status: 404, statusText: 'Not Found' }));
|
|
462
|
+
const result = await shell.execute('browse https://example.com/missing');
|
|
463
|
+
expect(result.exitCode).toBe(1);
|
|
464
|
+
expect(result.stderr).toContain('HTTP 404');
|
|
465
|
+
});
|
|
466
|
+
it('detects JavaScript-required pages and returns error', async () => {
|
|
467
|
+
const mockHtml = '<html><body><p>Please click <a href="/retry">here</a> if you are not redirected within a few seconds.</p></body></html>';
|
|
468
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(mockHtml, { status: 200, headers: { 'Content-Type': 'text/html' } }));
|
|
469
|
+
const result = await shell.execute('browse https://www.google.com/search?q=test');
|
|
470
|
+
expect(result.exitCode).toBe(1);
|
|
471
|
+
expect(result.stderr).toContain('require JavaScript or blocked the request');
|
|
472
|
+
expect(result.stdout).toBeTruthy(); // still returns the content
|
|
473
|
+
});
|
|
474
|
+
it('detects CAPTCHA / bot-blocking pages', async () => {
|
|
475
|
+
const mockHtml = '<html><body><h1>Checking your browser</h1><p>Please wait while we verify you are a human. This unusual traffic check is required.</p></body></html>';
|
|
476
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(mockHtml, { status: 200, headers: { 'Content-Type': 'text/html' } }));
|
|
477
|
+
const result = await shell.execute('browse https://example.com');
|
|
478
|
+
expect(result.exitCode).toBe(1);
|
|
479
|
+
expect(result.stderr).toContain('require JavaScript or blocked the request');
|
|
480
|
+
});
|
|
481
|
+
it('detects very short / empty content', async () => {
|
|
482
|
+
const mockHtml = '<html><body></body></html>';
|
|
483
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(mockHtml, { status: 200, headers: { 'Content-Type': 'text/html' } }));
|
|
484
|
+
const result = await shell.execute('browse https://example.com');
|
|
485
|
+
expect(result.exitCode).toBe(1);
|
|
486
|
+
expect(result.stderr).toContain('very little content');
|
|
487
|
+
});
|
|
488
|
+
it('skips quality check in --raw mode', async () => {
|
|
489
|
+
const mockHtml = '<html><body></body></html>';
|
|
490
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(mockHtml, { status: 200, headers: { 'Content-Type': 'text/html' } }));
|
|
491
|
+
const result = await shell.execute('browse --raw https://example.com');
|
|
492
|
+
expect(result.exitCode).toBe(0);
|
|
493
|
+
expect(result.stderr).toBe('');
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
describe('read-only mode', () => {
|
|
497
|
+
beforeEach(() => {
|
|
498
|
+
shell.setReadOnly(true);
|
|
499
|
+
});
|
|
500
|
+
it('blocks rm command in read-only mode', async () => {
|
|
501
|
+
await shell.getFS().write('/file.txt', 'data');
|
|
502
|
+
const result = await shell.execute('rm file.txt');
|
|
503
|
+
expect(result.exitCode).toBe(1);
|
|
504
|
+
expect(result.stderr).toContain("read-only mode");
|
|
505
|
+
expect(result.stderr).toContain("'rm' is not allowed");
|
|
506
|
+
// File should still exist
|
|
507
|
+
expect(await shell.getFS().read('/file.txt')).toBe('data');
|
|
508
|
+
});
|
|
509
|
+
it('blocks mkdir command in read-only mode', async () => {
|
|
510
|
+
const result = await shell.execute('mkdir newdir');
|
|
511
|
+
expect(result.exitCode).toBe(1);
|
|
512
|
+
expect(result.stderr).toContain("read-only mode");
|
|
513
|
+
expect(result.stderr).toContain("'mkdir' is not allowed");
|
|
514
|
+
});
|
|
515
|
+
it('blocks output redirection in read-only mode', async () => {
|
|
516
|
+
const result = await shell.execute('echo hello > file.txt');
|
|
517
|
+
expect(result.exitCode).toBe(1);
|
|
518
|
+
expect(result.stderr).toContain("read-only mode");
|
|
519
|
+
expect(result.stderr).toContain("cannot write to");
|
|
520
|
+
expect(await shell.getFS().exists('/file.txt')).toBe(false);
|
|
521
|
+
});
|
|
522
|
+
it('blocks append redirection in read-only mode', async () => {
|
|
523
|
+
await shell.getFS().write('/file.txt', 'existing');
|
|
524
|
+
const result = await shell.execute('echo more >> file.txt');
|
|
525
|
+
expect(result.exitCode).toBe(1);
|
|
526
|
+
expect(result.stderr).toContain("read-only mode");
|
|
527
|
+
expect(result.stderr).toContain("cannot write to");
|
|
528
|
+
expect(await shell.getFS().read('/file.txt')).toBe('existing');
|
|
529
|
+
});
|
|
530
|
+
it('allows writing to writable paths', async () => {
|
|
531
|
+
shell.setReadOnly(true, ['.tuplet/plan.md']);
|
|
532
|
+
await shell.getFS().mkdir('/.tuplet');
|
|
533
|
+
const result = await shell.execute('echo "# Plan" > .tuplet/plan.md');
|
|
534
|
+
expect(result.exitCode).toBe(0);
|
|
535
|
+
expect(await shell.getFS().read('/.tuplet/plan.md')).toBe('# Plan\n');
|
|
536
|
+
});
|
|
537
|
+
it('allows read commands (ls) in read-only mode', async () => {
|
|
538
|
+
await shell.getFS().write('/data.txt', 'hello');
|
|
539
|
+
const result = await shell.execute('ls');
|
|
540
|
+
expect(result.exitCode).toBe(0);
|
|
541
|
+
expect(result.stdout).toContain('data.txt');
|
|
542
|
+
});
|
|
543
|
+
it('allows read commands (cat) in read-only mode', async () => {
|
|
544
|
+
await shell.getFS().write('/data.txt', 'hello');
|
|
545
|
+
const result = await shell.execute('cat data.txt');
|
|
546
|
+
expect(result.exitCode).toBe(0);
|
|
547
|
+
expect(result.stdout).toBe('hello');
|
|
548
|
+
});
|
|
549
|
+
it('allows read commands (grep) in read-only mode', async () => {
|
|
550
|
+
await shell.getFS().write('/data.txt', 'hello world\nfoo bar\n');
|
|
551
|
+
const result = await shell.execute('grep hello data.txt');
|
|
552
|
+
expect(result.exitCode).toBe(0);
|
|
553
|
+
expect(result.stdout).toContain('hello world');
|
|
554
|
+
});
|
|
555
|
+
it('allows echo without redirect in read-only mode', async () => {
|
|
556
|
+
const result = await shell.execute('echo hello world');
|
|
557
|
+
expect(result.exitCode).toBe(0);
|
|
558
|
+
expect(result.stdout).toBe('hello world\n');
|
|
559
|
+
});
|
|
560
|
+
it('can be disabled after enabling', async () => {
|
|
561
|
+
shell.setReadOnly(false);
|
|
562
|
+
const result = await shell.execute('echo hello > file.txt');
|
|
563
|
+
expect(result.exitCode).toBe(0);
|
|
564
|
+
expect(await shell.getFS().read('/file.txt')).toBe('hello\n');
|
|
565
|
+
});
|
|
566
|
+
it('reports read-only status via isReadOnly()', () => {
|
|
567
|
+
expect(shell.isReadOnly()).toBe(true);
|
|
568
|
+
shell.setReadOnly(false);
|
|
569
|
+
expect(shell.isReadOnly()).toBe(false);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
describe('command help metadata', () => {
|
|
573
|
+
it('all registered commands have help property', () => {
|
|
574
|
+
for (const cmd of commands) {
|
|
575
|
+
expect(cmd.help, `${cmd.name} should have help metadata`).toBeDefined();
|
|
576
|
+
expect(cmd.help.usage).toBeTruthy();
|
|
577
|
+
expect(cmd.help.description).toBeTruthy();
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
describe('large file handling', () => {
|
|
582
|
+
describe('cat size gate', () => {
|
|
583
|
+
it('rejects files over 256KB without offset/limit', async () => {
|
|
584
|
+
await shell.getFS().write('/big.txt', 'x'.repeat(MAX_FILE_SIZE + 1));
|
|
585
|
+
const result = await shell.execute('cat big.txt');
|
|
586
|
+
expect(result.exitCode).toBe(1);
|
|
587
|
+
expect(result.stderr).toContain('exceeds max size');
|
|
588
|
+
expect(result.stderr).toContain('head -n 2000');
|
|
589
|
+
});
|
|
590
|
+
it('allows paginated access to large files with --offset/--limit', async () => {
|
|
591
|
+
const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`);
|
|
592
|
+
const bigContent = lines.join('\n');
|
|
593
|
+
// Make it exceed MAX_FILE_SIZE by padding lines
|
|
594
|
+
const padding = 'x'.repeat(Math.ceil(MAX_FILE_SIZE / 100));
|
|
595
|
+
const paddedLines = Array.from({ length: 100 }, (_, i) => `line ${i + 1} ${padding}`);
|
|
596
|
+
await shell.getFS().write('/big.txt', paddedLines.join('\n'));
|
|
597
|
+
const result = await shell.execute('cat --offset 0 --limit 10 big.txt');
|
|
598
|
+
expect(result.exitCode).toBe(0);
|
|
599
|
+
expect(result.stdout).toContain('[Showing lines 1-10 of');
|
|
600
|
+
expect(result.stdout).toContain('line 1');
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
describe('cat pagination', () => {
|
|
604
|
+
it('paginates with --offset and --limit', async () => {
|
|
605
|
+
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
|
|
606
|
+
await shell.getFS().write('/data.txt', lines.join('\n'));
|
|
607
|
+
const result = await shell.execute('cat --offset 5 --limit 5 data.txt');
|
|
608
|
+
expect(result.exitCode).toBe(0);
|
|
609
|
+
expect(result.stdout).toContain('[Showing lines 6-10 of 20]');
|
|
610
|
+
expect(result.stdout).toContain('line 6');
|
|
611
|
+
expect(result.stdout).toContain('line 10');
|
|
612
|
+
expect(result.stdout).not.toContain('line 5\n');
|
|
613
|
+
expect(result.stdout).not.toContain('line 11');
|
|
614
|
+
});
|
|
615
|
+
it('defaults to 2000-line limit', async () => {
|
|
616
|
+
const lines = Array.from({ length: 2500 }, (_, i) => `L${i + 1}`);
|
|
617
|
+
await shell.getFS().write('/data.txt', lines.join('\n'));
|
|
618
|
+
const result = await shell.execute('cat data.txt');
|
|
619
|
+
expect(result.exitCode).toBe(0);
|
|
620
|
+
// Should contain line 2000 but not line 2001
|
|
621
|
+
expect(result.stdout).toContain('L2000');
|
|
622
|
+
expect(result.stdout).not.toContain('L2001');
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
describe('cat line numbers', () => {
|
|
626
|
+
it('shows line numbers with -n flag', async () => {
|
|
627
|
+
await shell.getFS().write('/data.txt', 'alpha\nbeta\ngamma\n');
|
|
628
|
+
const result = await shell.execute('cat -n data.txt');
|
|
629
|
+
expect(result.exitCode).toBe(0);
|
|
630
|
+
expect(result.stdout).toContain('1\talpha');
|
|
631
|
+
expect(result.stdout).toContain('2\tbeta');
|
|
632
|
+
expect(result.stdout).toContain('3\tgamma');
|
|
633
|
+
});
|
|
634
|
+
it('shows correct line numbers with offset', async () => {
|
|
635
|
+
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
|
|
636
|
+
await shell.getFS().write('/data.txt', lines.join('\n'));
|
|
637
|
+
const result = await shell.execute('cat -n --offset 10 --limit 3 data.txt');
|
|
638
|
+
expect(result.exitCode).toBe(0);
|
|
639
|
+
expect(result.stdout).toContain('11\tline 11');
|
|
640
|
+
expect(result.stdout).toContain('12\tline 12');
|
|
641
|
+
expect(result.stdout).toContain('13\tline 13');
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
describe('line truncation', () => {
|
|
645
|
+
it('cat truncates long lines', async () => {
|
|
646
|
+
const longLine = 'a'.repeat(MAX_LINE_LENGTH + 500);
|
|
647
|
+
await shell.getFS().write('/data.txt', longLine);
|
|
648
|
+
const result = await shell.execute('cat data.txt');
|
|
649
|
+
expect(result.exitCode).toBe(0);
|
|
650
|
+
expect(result.stdout.length).toBeLessThan(longLine.length);
|
|
651
|
+
expect(result.stdout).toContain('...');
|
|
652
|
+
});
|
|
653
|
+
it('head truncates long lines', async () => {
|
|
654
|
+
const longLine = 'b'.repeat(MAX_LINE_LENGTH + 500);
|
|
655
|
+
await shell.getFS().write('/data.txt', `short\n${longLine}\nshort2`);
|
|
656
|
+
const result = await shell.execute('head -n 3 data.txt');
|
|
657
|
+
expect(result.exitCode).toBe(0);
|
|
658
|
+
expect(result.stdout).toContain('short');
|
|
659
|
+
expect(result.stdout).toContain('...');
|
|
660
|
+
expect(result.stdout).not.toContain('b'.repeat(MAX_LINE_LENGTH + 1));
|
|
661
|
+
});
|
|
662
|
+
it('tail truncates long lines', async () => {
|
|
663
|
+
const longLine = 'c'.repeat(MAX_LINE_LENGTH + 500);
|
|
664
|
+
await shell.getFS().write('/data.txt', `short\n${longLine}\nshort2`);
|
|
665
|
+
const result = await shell.execute('tail -n 3 data.txt');
|
|
666
|
+
expect(result.exitCode).toBe(0);
|
|
667
|
+
expect(result.stdout).toContain('...');
|
|
668
|
+
expect(result.stdout).not.toContain('c'.repeat(MAX_LINE_LENGTH + 1));
|
|
669
|
+
});
|
|
670
|
+
it('grep truncates long matching lines', async () => {
|
|
671
|
+
const longLine = 'MATCH' + 'd'.repeat(MAX_LINE_LENGTH + 500);
|
|
672
|
+
await shell.getFS().write('/data.txt', `short\n${longLine}\nshort2`);
|
|
673
|
+
const result = await shell.execute('grep MATCH data.txt');
|
|
674
|
+
expect(result.exitCode).toBe(0);
|
|
675
|
+
expect(result.stdout).toContain('MATCH');
|
|
676
|
+
expect(result.stdout).toContain('...');
|
|
677
|
+
expect(result.stdout.length).toBeLessThan(longLine.length);
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
describe('grep output cap', () => {
|
|
681
|
+
it('caps output and appends truncation note', async () => {
|
|
682
|
+
// Create a file with many matching lines
|
|
683
|
+
const lines = Array.from({ length: 5000 }, (_, i) => `match line ${i + 1} with some extra content to fill space`);
|
|
684
|
+
await shell.getFS().write('/data.txt', lines.join('\n'));
|
|
685
|
+
const result = await shell.execute('grep match data.txt');
|
|
686
|
+
expect(result.exitCode).toBe(0);
|
|
687
|
+
expect(result.stdout).toContain('[Output truncated at');
|
|
688
|
+
expect(result.stdout).toContain('Narrow your search');
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
describe('shell tool output truncation', () => {
|
|
692
|
+
it('spills large output to workspace file', async () => {
|
|
693
|
+
const tool = createShellTool(shell);
|
|
694
|
+
const ctx = { remainingTokens: 10000 };
|
|
695
|
+
// Build a big output via cat with --limit high enough
|
|
696
|
+
const bigLines = Array.from({ length: 500 }, (_, i) => `output line ${i + 1} ${'x'.repeat(100)}`);
|
|
697
|
+
await shell.getFS().write('/big-output.txt', bigLines.join('\n'));
|
|
698
|
+
const result = await tool.execute({ command: 'cat --limit 500 big-output.txt' }, ctx);
|
|
699
|
+
expect(result.error).toContain('exceeds maximum');
|
|
700
|
+
expect(result.error).toContain('Saved to');
|
|
701
|
+
const data = result.data;
|
|
702
|
+
expect(data.spillPath).toBeTruthy();
|
|
703
|
+
// Verify the file was written (spillPath is relative, raw provider needs '/' prefix)
|
|
704
|
+
const spillContent = await shell.getFS().read('/' + data.spillPath);
|
|
705
|
+
expect(spillContent).toBeTruthy();
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
describe('log3 regression tests', () => {
|
|
710
|
+
describe('grep -o with angle brackets in pattern', () => {
|
|
711
|
+
it('handles < in single-quoted grep -o pattern without treating as redirection', async () => {
|
|
712
|
+
const html = '<html><head><title>My Page Title</title></head><body>hello</body></html>';
|
|
713
|
+
await shell.getFS().write('/page.html', html);
|
|
714
|
+
const result = await shell.execute("cat page.html | grep -o '<title>[^<]*'");
|
|
715
|
+
expect(result.exitCode).toBe(0);
|
|
716
|
+
expect(result.stdout).toContain('<title>My Page Title');
|
|
717
|
+
});
|
|
718
|
+
it('handles curl | grep -o with angle bracket pattern | head pipeline', async () => {
|
|
719
|
+
const html = '<html><title>Test Title</title><title>Second</title></html>';
|
|
720
|
+
await shell.getFS().write('/page.html', html);
|
|
721
|
+
const result = await shell.execute("cat page.html | grep -o '<title>[^<]*' | head -5");
|
|
722
|
+
expect(result.exitCode).toBe(0);
|
|
723
|
+
expect(result.stdout).toContain('<title>Test Title');
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
describe('&& operator support', () => {
|
|
727
|
+
it('executes second command when first succeeds', async () => {
|
|
728
|
+
await shell.getFS().write('/data.txt', 'hello world');
|
|
729
|
+
const result = await shell.execute('echo "done" > flag.txt && cat flag.txt');
|
|
730
|
+
expect(result.exitCode).toBe(0);
|
|
731
|
+
expect(result.stdout).toContain('done');
|
|
732
|
+
});
|
|
733
|
+
it('does not execute second command when first fails', async () => {
|
|
734
|
+
const result = await shell.execute('cat nonexistent.txt && echo "should not run" > flag.txt');
|
|
735
|
+
expect(result.exitCode).not.toBe(0);
|
|
736
|
+
expect(await shell.getFS().exists('/flag.txt')).toBe(false);
|
|
737
|
+
});
|
|
738
|
+
it('supports curl -o FILE && grep pattern FILE', async () => {
|
|
739
|
+
// Simulate what curl -o should do: write output to a file, then grep it
|
|
740
|
+
await shell.getFS().write('/page.html', '<html><h1>Funding: $10M raised</h1></html>');
|
|
741
|
+
const result = await shell.execute("grep -i funding page.html && echo 'found'");
|
|
742
|
+
expect(result.exitCode).toBe(0);
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
describe('wc command', () => {
|
|
746
|
+
it('counts lines with wc -l', async () => {
|
|
747
|
+
await shell.getFS().write('/data.txt', 'line1\nline2\nline3\n');
|
|
748
|
+
const result = await shell.execute('cat data.txt | wc -l');
|
|
749
|
+
expect(result.exitCode).toBe(0);
|
|
750
|
+
expect(result.stdout.trim()).toContain('3');
|
|
751
|
+
});
|
|
752
|
+
it('counts words with wc -w', async () => {
|
|
753
|
+
await shell.getFS().write('/data.txt', 'hello world\nfoo bar baz\n');
|
|
754
|
+
const result = await shell.execute('cat data.txt | wc -w');
|
|
755
|
+
expect(result.exitCode).toBe(0);
|
|
756
|
+
expect(result.stdout.trim()).toContain('5');
|
|
757
|
+
});
|
|
758
|
+
it('counts characters with wc -c', async () => {
|
|
759
|
+
await shell.getFS().write('/data.txt', 'abc');
|
|
760
|
+
const result = await shell.execute('cat data.txt | wc -c');
|
|
761
|
+
expect(result.exitCode).toBe(0);
|
|
762
|
+
expect(result.stdout.trim()).toContain('3');
|
|
763
|
+
});
|
|
764
|
+
it('shows all counts by default (no flags)', async () => {
|
|
765
|
+
await shell.getFS().write('/data.txt', 'hello world\nfoo\n');
|
|
766
|
+
const result = await shell.execute('cat data.txt | wc');
|
|
767
|
+
expect(result.exitCode).toBe(0);
|
|
768
|
+
// Should contain line count, word count, char count
|
|
769
|
+
expect(result.stdout).toContain('2');
|
|
770
|
+
expect(result.stdout).toContain('3');
|
|
771
|
+
});
|
|
772
|
+
it('counts lines from file argument', async () => {
|
|
773
|
+
await shell.getFS().write('/data.txt', 'a\nb\nc\nd\ne\n');
|
|
774
|
+
const result = await shell.execute('wc -l data.txt');
|
|
775
|
+
expect(result.exitCode).toBe(0);
|
|
776
|
+
expect(result.stdout).toContain('5');
|
|
777
|
+
expect(result.stdout).toContain('data.txt');
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
describe('find with -o (OR) for multiple name patterns', () => {
|
|
781
|
+
it('matches files with multiple -name patterns joined by -o', async () => {
|
|
782
|
+
await shell.getFS().write('/data/file1.json', '{}');
|
|
783
|
+
await shell.getFS().write('/data/file2.csv', 'a,b');
|
|
784
|
+
await shell.getFS().write('/data/file3.yaml', 'key: val');
|
|
785
|
+
await shell.getFS().write('/data/file4.txt', 'text');
|
|
786
|
+
const result = await shell.execute('find data -name "*.json" -o -name "*.csv"');
|
|
787
|
+
expect(result.exitCode).toBe(0);
|
|
788
|
+
expect(result.stdout).toContain('file1.json');
|
|
789
|
+
expect(result.stdout).toContain('file2.csv');
|
|
790
|
+
expect(result.stdout).not.toContain('file4.txt');
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
describe('heredoc with quoted delimiter suppresses variable expansion', () => {
|
|
794
|
+
it('does not expand $VAR in heredoc with quoted delimiter', async () => {
|
|
795
|
+
shell.setEnv('PRICE', 'should-not-appear');
|
|
796
|
+
const cmd = `cat > data.json << 'EOF'\n{"amount": "$17 million"}\nEOF`;
|
|
797
|
+
const result = await shell.execute(cmd);
|
|
798
|
+
expect(result.exitCode).toBe(0);
|
|
799
|
+
const content = await shell.getFS().read('/data.json');
|
|
800
|
+
expect(content).toContain('$17 million');
|
|
801
|
+
});
|
|
802
|
+
it('still expands $VAR in heredoc with unquoted delimiter', async () => {
|
|
803
|
+
shell.setEnv('NAME', 'Alice');
|
|
804
|
+
const cmd = `cat > data.txt << EOF\nHello $NAME\nEOF`;
|
|
805
|
+
const result = await shell.execute(cmd);
|
|
806
|
+
expect(result.exitCode).toBe(0);
|
|
807
|
+
const content = await shell.getFS().read('/data.txt');
|
|
808
|
+
expect(content).toContain('Hello Alice');
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
describe('head output cap', () => {
|
|
812
|
+
it('caps total output like grep does to prevent cascading spills', async () => {
|
|
813
|
+
// Create file with many long lines
|
|
814
|
+
const longLines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}: ${'x'.repeat(500)}`);
|
|
815
|
+
await shell.getFS().write('/huge.txt', longLines.join('\n'));
|
|
816
|
+
const result = await shell.execute('head -n 100 huge.txt');
|
|
817
|
+
expect(result.exitCode).toBe(0);
|
|
818
|
+
// Output should not exceed MAX_OUTPUT_CHARS
|
|
819
|
+
expect(result.stdout.length).toBeLessThanOrEqual(35000); // some margin for truncation note
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
describe('curl -H flag (alternate to -A for User-Agent)', () => {
|
|
823
|
+
it('accepts -H User-Agent header', async () => {
|
|
824
|
+
// This pattern is used in logs: curl -s URL -H 'User-Agent: Mozilla/5.0'
|
|
825
|
+
// Ensure -H flag works correctly (not treating header value as URL)
|
|
826
|
+
const result = await shell.execute("curl -s 'https://httpbin.org/user-agent' -H 'User-Agent: TestBot/1.0'");
|
|
827
|
+
expect(result.exitCode).toBe(0);
|
|
828
|
+
// Should get a response (not an error about URL parsing)
|
|
829
|
+
expect(result.stderr).toBe('');
|
|
830
|
+
}, 15000);
|
|
831
|
+
});
|
|
832
|
+
describe('multi-pipe grep chains', () => {
|
|
833
|
+
it('handles curl | grep -oE pattern | sort -u | head pipeline', async () => {
|
|
834
|
+
const html = `
|
|
835
|
+
<a href="https://example.com/2026/01/article-one">one</a>
|
|
836
|
+
<a href="https://example.com/2026/01/article-two">two</a>
|
|
837
|
+
<a href="https://example.com/2026/01/article-one">one duplicate</a>
|
|
838
|
+
<a href="https://example.com/2025/12/old-article">old</a>
|
|
839
|
+
`;
|
|
840
|
+
await shell.getFS().write('/page.html', html);
|
|
841
|
+
const result = await shell.execute("cat page.html | grep -oE 'https://example.com/2026/01/[^\"]+' | sort -u | head -5");
|
|
842
|
+
expect(result.exitCode).toBe(0);
|
|
843
|
+
expect(result.stdout).toContain('article-one');
|
|
844
|
+
expect(result.stdout).toContain('article-two');
|
|
845
|
+
// sort -u should deduplicate
|
|
846
|
+
const lines = result.stdout.trim().split('\n');
|
|
847
|
+
expect(lines.length).toBe(2);
|
|
848
|
+
});
|
|
849
|
+
it('returns exit code 1 when grep finds no matches in a pipe chain', async () => {
|
|
850
|
+
await shell.getFS().write('/page.html', '<html>no matches here</html>');
|
|
851
|
+
const result = await shell.execute("cat page.html | grep -iE 'funding|million' | head -30");
|
|
852
|
+
expect(result.exitCode).toBe(1);
|
|
853
|
+
expect(result.stdout).toBe('');
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
describe('date command', () => {
|
|
857
|
+
it('outputs current date with default format', async () => {
|
|
858
|
+
const result = await shell.execute('date');
|
|
859
|
+
expect(result.exitCode).toBe(0);
|
|
860
|
+
// Default format: "Tue Feb 17 14:30:00 PST 2026"
|
|
861
|
+
const year = new Date().getFullYear().toString();
|
|
862
|
+
expect(result.stdout).toContain(year);
|
|
863
|
+
expect(result.stdout.trim().length).toBeGreaterThan(10);
|
|
864
|
+
});
|
|
865
|
+
it('supports +%Y%m%d format', async () => {
|
|
866
|
+
const result = await shell.execute('date +%Y%m%d');
|
|
867
|
+
expect(result.exitCode).toBe(0);
|
|
868
|
+
const now = new Date();
|
|
869
|
+
const expected = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
|
870
|
+
expect(result.stdout.trim()).toBe(expected);
|
|
871
|
+
});
|
|
872
|
+
it('supports +%Y-%m-%d format', async () => {
|
|
873
|
+
const result = await shell.execute('date +%Y-%m-%d');
|
|
874
|
+
expect(result.exitCode).toBe(0);
|
|
875
|
+
const iso = new Date().toISOString().split('T')[0];
|
|
876
|
+
expect(result.stdout.trim()).toBe(iso);
|
|
877
|
+
});
|
|
878
|
+
it('supports +%s for Unix timestamp', async () => {
|
|
879
|
+
const before = Math.floor(Date.now() / 1000);
|
|
880
|
+
const result = await shell.execute('date +%s');
|
|
881
|
+
const after = Math.floor(Date.now() / 1000);
|
|
882
|
+
expect(result.exitCode).toBe(0);
|
|
883
|
+
const ts = parseInt(result.stdout.trim(), 10);
|
|
884
|
+
expect(ts).toBeGreaterThanOrEqual(before);
|
|
885
|
+
expect(ts).toBeLessThanOrEqual(after);
|
|
886
|
+
});
|
|
887
|
+
it('supports -u for UTC', async () => {
|
|
888
|
+
const result = await shell.execute('date -u +%Z');
|
|
889
|
+
expect(result.exitCode).toBe(0);
|
|
890
|
+
expect(result.stdout.trim()).toBe('UTC');
|
|
891
|
+
});
|
|
892
|
+
it('supports -d for custom date', async () => {
|
|
893
|
+
const result = await shell.execute("date -d '2024-07-04' +%Y-%m-%d");
|
|
894
|
+
expect(result.exitCode).toBe(0);
|
|
895
|
+
expect(result.stdout.trim()).toBe('2024-07-04');
|
|
896
|
+
});
|
|
897
|
+
it('returns error for invalid date', async () => {
|
|
898
|
+
const result = await shell.execute("date -d 'not-a-date'");
|
|
899
|
+
expect(result.exitCode).toBe(1);
|
|
900
|
+
expect(result.stderr).toContain('invalid date');
|
|
901
|
+
});
|
|
902
|
+
it('supports -I for ISO 8601 format', async () => {
|
|
903
|
+
const result = await shell.execute('date -I');
|
|
904
|
+
expect(result.exitCode).toBe(0);
|
|
905
|
+
// Should match pattern like 2026-02-17T14:30:00+0300
|
|
906
|
+
expect(result.stdout.trim()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4}$/);
|
|
907
|
+
});
|
|
908
|
+
it('supports compound specifiers %F and %T', async () => {
|
|
909
|
+
const result = await shell.execute("date -d '2024-12-25T10:30:45' +%F_%T");
|
|
910
|
+
expect(result.exitCode).toBe(0);
|
|
911
|
+
expect(result.stdout.trim()).toBe('2024-12-25_10:30:45');
|
|
912
|
+
});
|
|
913
|
+
it('pipes output to other commands', async () => {
|
|
914
|
+
const result = await shell.execute('date +%Y | grep -o "^[0-9]\\{4\\}"');
|
|
915
|
+
expect(result.exitCode).toBe(0);
|
|
916
|
+
expect(result.stdout.trim()).toBe(new Date().getFullYear().toString());
|
|
917
|
+
});
|
|
918
|
+
it('output can be saved via redirection', async () => {
|
|
919
|
+
await shell.execute('date +%Y-%m-%d > today.txt');
|
|
920
|
+
const content = await shell.getFS().read('/today.txt');
|
|
921
|
+
const iso = new Date().toISOString().split('T')[0];
|
|
922
|
+
expect(content.trim()).toBe(iso);
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
describe('sed command', () => {
|
|
926
|
+
it('basic substitution on piped input', async () => {
|
|
927
|
+
const result = await shell.execute("echo 'hello world' | sed 's/world/earth/'");
|
|
928
|
+
expect(result.exitCode).toBe(0);
|
|
929
|
+
expect(result.stdout).toBe('hello earth\n');
|
|
930
|
+
});
|
|
931
|
+
it('global substitution with g flag', async () => {
|
|
932
|
+
const result = await shell.execute("echo 'aaa bbb aaa' | sed 's/aaa/xxx/g'");
|
|
933
|
+
expect(result.exitCode).toBe(0);
|
|
934
|
+
expect(result.stdout).toBe('xxx bbb xxx\n');
|
|
935
|
+
});
|
|
936
|
+
it('multiple substitutions separated by semicolons', async () => {
|
|
937
|
+
const result = await shell.execute("echo '<title>Hello</title>' | sed 's/<title>//;s/<\\/title>//'");
|
|
938
|
+
expect(result.exitCode).toBe(0);
|
|
939
|
+
expect(result.stdout).toBe('Hello\n');
|
|
940
|
+
});
|
|
941
|
+
it('handles the SEC feed use case: grep + sed + head pipeline', async () => {
|
|
942
|
+
const xml = [
|
|
943
|
+
'<title>Document A: Annual Report</title>',
|
|
944
|
+
'<title>Document B: Quarterly Filing</title>',
|
|
945
|
+
'<title>Other: Not matching</title>',
|
|
946
|
+
'<title>Document C: Proxy Statement</title>',
|
|
947
|
+
].join('\n');
|
|
948
|
+
await shell.getFS().write('/sec_feed.xml', xml);
|
|
949
|
+
const result = await shell.execute("cat sec_feed.xml | grep '<title>D' | sed 's/<title>//;s/<\\/title>//' | head -30");
|
|
950
|
+
expect(result.exitCode).toBe(0);
|
|
951
|
+
expect(result.stdout).toContain('Document A: Annual Report');
|
|
952
|
+
expect(result.stdout).toContain('Document B: Quarterly Filing');
|
|
953
|
+
expect(result.stdout).toContain('Document C: Proxy Statement');
|
|
954
|
+
expect(result.stdout).not.toContain('Other');
|
|
955
|
+
expect(result.stdout).not.toContain('<title>');
|
|
956
|
+
expect(result.stdout).not.toContain('</title>');
|
|
957
|
+
});
|
|
958
|
+
it('substitution on file argument', async () => {
|
|
959
|
+
await shell.getFS().write('/data.txt', 'foo bar baz\nfoo qux\n');
|
|
960
|
+
const result = await shell.execute("sed 's/foo/FOO/' data.txt");
|
|
961
|
+
expect(result.exitCode).toBe(0);
|
|
962
|
+
expect(result.stdout).toBe('FOO bar baz\nFOO qux\n');
|
|
963
|
+
});
|
|
964
|
+
it('global substitution on file', async () => {
|
|
965
|
+
await shell.getFS().write('/data.txt', 'a.b.c.d\n');
|
|
966
|
+
const result = await shell.execute("sed 's/\\./,/g' data.txt");
|
|
967
|
+
expect(result.exitCode).toBe(0);
|
|
968
|
+
expect(result.stdout).toBe('a,b,c,d\n');
|
|
969
|
+
});
|
|
970
|
+
it('delete lines with d command', async () => {
|
|
971
|
+
await shell.getFS().write('/data.txt', 'line1\nline2\nline3\n');
|
|
972
|
+
const result = await shell.execute("sed '2d' data.txt");
|
|
973
|
+
expect(result.exitCode).toBe(0);
|
|
974
|
+
expect(result.stdout).toBe('line1\nline3\n');
|
|
975
|
+
});
|
|
976
|
+
it('delete lines matching a pattern', async () => {
|
|
977
|
+
await shell.getFS().write('/data.txt', 'keep\nremove this\nkeep too\n');
|
|
978
|
+
const result = await shell.execute("sed '/remove/d' data.txt");
|
|
979
|
+
expect(result.exitCode).toBe(0);
|
|
980
|
+
expect(result.stdout).toBe('keep\nkeep too\n');
|
|
981
|
+
});
|
|
982
|
+
it('print only matching lines with -n and p', async () => {
|
|
983
|
+
await shell.getFS().write('/data.txt', 'apple\nbanana\napricot\n');
|
|
984
|
+
const result = await shell.execute("sed -n '/^a/p' data.txt");
|
|
985
|
+
expect(result.exitCode).toBe(0);
|
|
986
|
+
expect(result.stdout).toBe('apple\napricot\n');
|
|
987
|
+
});
|
|
988
|
+
it('multiple -e expressions', async () => {
|
|
989
|
+
const result = await shell.execute("echo 'hello world' | sed -e 's/hello/hi/' -e 's/world/earth/'");
|
|
990
|
+
expect(result.exitCode).toBe(0);
|
|
991
|
+
expect(result.stdout).toBe('hi earth\n');
|
|
992
|
+
});
|
|
993
|
+
it('in-place editing with -i', async () => {
|
|
994
|
+
await shell.getFS().write('/data.txt', 'old text\n');
|
|
995
|
+
const result = await shell.execute("sed -i 's/old/new/' data.txt");
|
|
996
|
+
expect(result.exitCode).toBe(0);
|
|
997
|
+
expect(result.stdout).toBe('');
|
|
998
|
+
const content = await shell.getFS().read('/data.txt');
|
|
999
|
+
expect(content).toBe('new text\n');
|
|
1000
|
+
});
|
|
1001
|
+
it('delete a range of lines', async () => {
|
|
1002
|
+
await shell.getFS().write('/data.txt', 'line1\nline2\nline3\nline4\nline5\n');
|
|
1003
|
+
const result = await shell.execute("sed '2,4d' data.txt");
|
|
1004
|
+
expect(result.exitCode).toBe(0);
|
|
1005
|
+
expect(result.stdout).toBe('line1\nline5\n');
|
|
1006
|
+
});
|
|
1007
|
+
it('substitution with address range', async () => {
|
|
1008
|
+
await shell.getFS().write('/data.txt', 'aaa\nbbb\nccc\n');
|
|
1009
|
+
const result = await shell.execute("sed '2s/bbb/BBB/' data.txt");
|
|
1010
|
+
expect(result.exitCode).toBe(0);
|
|
1011
|
+
expect(result.stdout).toBe('aaa\nBBB\nccc\n');
|
|
1012
|
+
});
|
|
1013
|
+
it('alternative delimiter in substitution', async () => {
|
|
1014
|
+
const result = await shell.execute("echo '/usr/local/bin' | sed 's|/usr/local|/opt|'");
|
|
1015
|
+
expect(result.exitCode).toBe(0);
|
|
1016
|
+
expect(result.stdout).toBe('/opt/bin\n');
|
|
1017
|
+
});
|
|
1018
|
+
it('returns error for missing file', async () => {
|
|
1019
|
+
const result = await shell.execute("sed 's/a/b/' missing.txt");
|
|
1020
|
+
expect(result.exitCode).toBe(1);
|
|
1021
|
+
expect(result.stderr).toContain('No such file');
|
|
1022
|
+
});
|
|
1023
|
+
it('returns error for no script', async () => {
|
|
1024
|
+
const result = await shell.execute('sed');
|
|
1025
|
+
expect(result.exitCode).toBe(1);
|
|
1026
|
+
expect(result.stderr).toContain('no script');
|
|
1027
|
+
});
|
|
1028
|
+
it('output can be redirected to file', async () => {
|
|
1029
|
+
await shell.getFS().write('/input.txt', 'hello world\n');
|
|
1030
|
+
await shell.execute("sed 's/world/earth/' input.txt > output.txt");
|
|
1031
|
+
const content = await shell.getFS().read('/output.txt');
|
|
1032
|
+
expect(content).toBe('hello earth\n');
|
|
1033
|
+
});
|
|
1034
|
+
it('replaces only first occurrence without g flag', async () => {
|
|
1035
|
+
const result = await shell.execute("echo 'aaa' | sed 's/a/b/'");
|
|
1036
|
+
expect(result.exitCode).toBe(0);
|
|
1037
|
+
expect(result.stdout).toBe('baa\n');
|
|
1038
|
+
});
|
|
1039
|
+
it('handles empty replacement (deletion)', async () => {
|
|
1040
|
+
const result = await shell.execute("echo 'prefix_value' | sed 's/prefix_//'");
|
|
1041
|
+
expect(result.exitCode).toBe(0);
|
|
1042
|
+
expect(result.stdout).toBe('value\n');
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
describe('curl -w write-out and -o /dev/null', () => {
|
|
1046
|
+
it('returns http status code with -w "%{http_code}" and -o /dev/null', async () => {
|
|
1047
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('response body', { status: 200, statusText: 'OK' }));
|
|
1048
|
+
const result = await shell.execute('curl -s \'https://api.example.com/data\' -o /dev/null -w "%{http_code}"');
|
|
1049
|
+
expect(result.exitCode).toBe(0);
|
|
1050
|
+
expect(result.stdout).toBe('200');
|
|
1051
|
+
expect(result.stdout).not.toContain('response body');
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
describe('path validation', () => {
|
|
1056
|
+
it('rejects absolute paths in commands', async () => {
|
|
1057
|
+
await shell.getFS().write('/data.txt', 'hello');
|
|
1058
|
+
const result = await shell.execute('cat /data.txt');
|
|
1059
|
+
expect(result.exitCode).toBe(1);
|
|
1060
|
+
expect(result.stderr).toContain('Absolute paths are not allowed');
|
|
1061
|
+
expect(result.stderr).toContain('data.txt');
|
|
1062
|
+
});
|
|
1063
|
+
it('rejects absolute paths in output redirection', async () => {
|
|
1064
|
+
const result = await shell.execute('echo hello > /out.txt');
|
|
1065
|
+
expect(result.exitCode).toBe(1);
|
|
1066
|
+
expect(result.stderr).toContain('Absolute paths are not allowed');
|
|
1067
|
+
});
|
|
1068
|
+
it('rejects path traversal with ..', async () => {
|
|
1069
|
+
const result = await shell.execute('cat ../secret');
|
|
1070
|
+
expect(result.exitCode).toBe(1);
|
|
1071
|
+
expect(result.stderr).toContain("'..'");
|
|
1072
|
+
expect(result.stderr).toContain('not allowed');
|
|
1073
|
+
});
|
|
1074
|
+
it('allows relative paths', async () => {
|
|
1075
|
+
await shell.getFS().write('/data.txt', 'hello');
|
|
1076
|
+
const result = await shell.execute('cat data.txt');
|
|
1077
|
+
expect(result.exitCode).toBe(0);
|
|
1078
|
+
expect(result.stdout).toBe('hello');
|
|
1079
|
+
});
|
|
1080
|
+
it('allows ./ prefix paths', async () => {
|
|
1081
|
+
await shell.getFS().write('/data.txt', 'hello');
|
|
1082
|
+
const result = await shell.execute('cat ./data.txt');
|
|
1083
|
+
expect(result.exitCode).toBe(0);
|
|
1084
|
+
expect(result.stdout).toBe('hello');
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
});
|
|
1088
|
+
//# sourceMappingURL=shell.test.js.map
|