| import { describe, it, expect, vi, beforeEach } from 'vitest'; |
|
|
| let writtenContent = ''; |
|
|
| const mockVfs = { |
| init: vi.fn(), |
| readFile: vi.fn(), |
| writeFile: vi.fn(), |
| createFile: vi.fn(), |
| updateFile: vi.fn().mockImplementation((_pid: string, _path: string, content: string) => { |
| writtenContent = content; |
| }), |
| listFiles: vi.fn().mockResolvedValue([]), |
| listDirectories: vi.fn().mockResolvedValue([]), |
| deleteFile: vi.fn(), |
| renameFile: vi.fn(), |
| getFileTree: vi.fn().mockResolvedValue([]), |
| getAllFilesAndDirectories: vi.fn().mockResolvedValue([]), |
| }; |
|
|
| vi.mock('@/lib/vfs', () => ({ |
| getActiveVFS: () => mockVfs, |
| vfs: mockVfs, |
| })); |
|
|
| vi.mock('@/lib/utils', () => ({ |
| logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, |
| })); |
|
|
| async function exec(cmd: string[], stdin?: string) { |
| const { vfsShell } = await import('../cli-shell'); |
| return vfsShell.execute('test', cmd, stdin); |
| } |
|
|
| beforeEach(() => { |
| vi.clearAllMocks(); |
| writtenContent = ''; |
| }); |
|
|
| |
|
|
| describe('grep -o (only matching)', () => { |
| it('outputs only the matched portion from stdin', async () => { |
| const result = await exec( |
| ['grep', '-o', 'href="[^"]*"'], |
| '<a href="page1.html">Link</a>\n<a href="page2.html">Other</a>' |
| ); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toBe('href="page1.html"\nhref="page2.html"'); |
| }); |
|
|
| it('outputs multiple matches per line separately', async () => { |
| const result = await exec( |
| ['grep', '-o', '\\d+'], |
| 'abc 123 def 456 ghi' |
| ); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toBe('123\n456'); |
| }); |
|
|
| it('combines -o with -n to show line numbers', async () => { |
| const result = await exec( |
| ['grep', '-on', '\\d+'], |
| 'no numbers\nabc 42 def\nxyz' |
| ); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toBe('2:42'); |
| }); |
|
|
| it('outputs matched portions from files', async () => { |
| mockVfs.getAllFilesAndDirectories.mockResolvedValueOnce([ |
| { path: '/test.html', content: '<img src="a.png"> and <img src="b.jpg">' }, |
| ]); |
| const result = await exec(['grep', '-o', 'src="[^"]*"', '/test.html']); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toBe('/test.html:src="a.png"\n/test.html:src="b.jpg"'); |
| }); |
|
|
| it('returns empty output when -o finds no matches', async () => { |
| const result = await exec(['grep', '-o', 'zzz'], 'abc def'); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toBe(''); |
| }); |
| }); |
|
|
| |
|
|
| describe('grep -P (PCRE no-op)', () => { |
| it('accepts -P without error', async () => { |
| const result = await exec(['grep', '-P', '\\d+'], 'abc 123\ndef'); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toBe('abc 123'); |
| }); |
| }); |
|
|
| |
|
|
| describe('sed ! negate modifier', () => { |
| it('single-address !d deletes lines NOT matching pattern', async () => { |
| const result = await exec( |
| ['sed', '/keep/!d'], |
| 'drop this\nkeep this\ndrop that\nkeep that' |
| ); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toBe('keep this\nkeep that'); |
| }); |
|
|
| it('single-address !p with -n prints non-matching lines', async () => { |
| const result = await exec( |
| ['sed', '-n', '/skip/!p'], |
| 'show\nskip\nshow too' |
| ); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toBe('show\nshow too'); |
| }); |
|
|
| it('range !d deletes lines OUTSIDE the range', async () => { |
| const result = await exec( |
| ['sed', '2,4!d'], |
| 'line1\nline2\nline3\nline4\nline5' |
| ); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toBe('line2\nline3\nline4'); |
| }); |
| }); |
|
|
| |
|
|
| describe('sed {...} command grouping', () => { |
| it('applies sub-command within a range', async () => { |
| const input = [ |
| '<section>', |
| ' <p>content</p>', |
| ' <p>more</p>', |
| '</section>', |
| 'after', |
| ].join('\n'); |
|
|
| const result = await exec( |
| ['sed', '/<section>/,/<\\/section>/{/<\\/section>/!d}'], |
| input |
| ); |
| expect(result.success).toBe(true); |
| |
| |
| expect(result.stdout).toBe('</section>\nafter'); |
| }); |
|
|
| it('group with substitution inside a range', async () => { |
| const input = 'AAA\nstart\nBBB\nend\nCCC'; |
| const result = await exec( |
| ['sed', '/start/,/end/{s/BBB/XXX/}'], |
| input |
| ); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toBe('AAA\nstart\nXXX\nend\nCCC'); |
| }); |
|
|
| it('single-address group applies to matching line', async () => { |
| const result = await exec( |
| ['sed', '/target/{s/old/new/}'], |
| 'target old\nother old' |
| ); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toBe('target new\nother old'); |
| }); |
| }); |
|
|
| |
|
|
| describe('sed substitution feedback', () => { |
| it('reports substitution count on -i success', async () => { |
| mockVfs.readFile.mockResolvedValueOnce({ content: 'hello world\nhello there' }); |
| const result = await exec(['sed', '-i', 's/hello/hi/g', '/test.txt']); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toContain('2 substitutions'); |
| }); |
|
|
| it('reports zero substitutions when pattern does not match', async () => { |
| mockVfs.readFile.mockResolvedValueOnce({ content: 'no match here' }); |
| const result = await exec(['sed', '-i', 's/zzz/yyy/', '/test.txt']); |
| expect(result.success).toBe(true); |
| expect(result.stdout).toContain('0 substitutions'); |
| expect(result.stdout).toContain('did not match'); |
| |
| expect(mockVfs.updateFile).not.toHaveBeenCalled(); |
| }); |
|
|
| }); |
|
|
| |
|
|
| describe('sed negate + group integration', () => { |
| it('real-world pattern: delete section content keeping closing tag', async () => { |
| mockVfs.readFile.mockResolvedValueOnce({ |
| content: [ |
| '<html>', |
| '<section id="hero">', |
| ' <h1>Old Title</h1>', |
| ' <p>Old text</p>', |
| '</section>', |
| '<footer>Keep</footer>', |
| ].join('\n'), |
| }); |
|
|
| const result = await exec([ |
| 'sed', '-i', |
| '/<section id="hero">/,/<\\/section>/{/<\\/section>/!d}', |
| '/index.html', |
| ]); |
|
|
| expect(result.success).toBe(true); |
| expect(writtenContent).toBe( |
| '<html>\n</section>\n<footer>Keep</footer>' |
| ); |
| }); |
| }); |
|
|
| |
|
|
| describe('sed backreferences', () => { |
| it('translates \\1 \\2 to $1 $2 in replacement', async () => { |
| mockVfs.readFile.mockResolvedValue({ content: 'hello world' }); |
| const result = await exec([ |
| 'sed', '-i', 's/\\(hello\\) \\(world\\)/\\2 \\1/', '/test.txt' |
| ]); |
| expect(result.success).toBe(true); |
| expect(writtenContent).toBe('world hello'); |
| }); |
|
|
| it('translates & to $& (whole match) in replacement', async () => { |
| mockVfs.readFile.mockResolvedValue({ content: 'foo bar' }); |
| const result = await exec([ |
| 'sed', '-i', 's/foo/[&]/', '/test.txt' |
| ]); |
| expect(result.success).toBe(true); |
| expect(writtenContent).toBe('[foo] bar'); |
| }); |
|
|
| it('preserves literal \\& (escaped ampersand)', async () => { |
| mockVfs.readFile.mockResolvedValue({ content: 'foo bar' }); |
| const result = await exec([ |
| 'sed', '-i', 's/foo/a\\&b/', '/test.txt' |
| ]); |
| expect(result.success).toBe(true); |
| expect(writtenContent).toBe('a&b bar'); |
| }); |
| }); |
|
|
| |
|
|
| describe('sed multiline error message', () => { |
| it('suggests ======= separator (not ===) when rejecting \\n patterns', async () => { |
| mockVfs.readFile.mockResolvedValue({ content: 'test' }); |
| const result = await exec([ |
| 'sed', '-i', 's/line1\\nline2/replaced/', '/test.txt' |
| ]); |
| expect(result.success).toBe(false); |
| expect(result.stderr).toContain('======='); |
| expect(result.stderr).not.toMatch(/[^=]===[^=]/); |
| }); |
| }); |
|
|
| |
|
|
| describe('ss separator format', () => { |
| it('splits on ======= (7 equals)', async () => { |
| mockVfs.readFile.mockResolvedValue({ content: 'old text here' }); |
| const stdin = 'old text\n=======\nnew text'; |
| const result = await exec(['ss', '/test.txt'], stdin); |
| expect(result.success).toBe(true); |
| expect(writtenContent).toBe('new text here'); |
| }); |
|
|
| it('rejects === (3 equals) as separator', async () => { |
| mockVfs.readFile.mockResolvedValue({ content: 'old text here' }); |
| const stdin = 'old text\n===\nnew text'; |
| const result = await exec(['ss', '/test.txt'], stdin); |
| expect(result.success).toBe(false); |
| }); |
|
|
| it('handles JS code with === in content without collision', async () => { |
| const fileContent = 'if (x === 5) { return true; }'; |
| mockVfs.readFile.mockResolvedValue({ content: fileContent }); |
| const stdin = 'if (x === 5) { return true; }\n=======\nif (x === 10) { return false; }'; |
| const result = await exec(['ss', '/test.txt'], stdin); |
| expect(result.success).toBe(true); |
| expect(writtenContent).toBe('if (x === 10) { return false; }'); |
| }); |
| }); |
|
|
| describe('ss --entity without separator', () => { |
| it('auto-extracts selector from first line when no separator given', async () => { |
| const fileContent = 'function foo() {\n return 1;\n}\n\nfunction bar() {\n return 2;\n}'; |
| mockVfs.readFile.mockResolvedValue({ content: fileContent }); |
| const stdin = 'function foo() {\n return 42;\n}'; |
| const result = await exec(['ss', '--entity', '/test.txt'], stdin); |
| expect(result.success).toBe(true); |
| expect(writtenContent).toContain('return 42'); |
| expect(writtenContent).toContain('function bar'); |
| }); |
| }); |
|
|