diff --git a/package.json b/package.json index d2f842d..42bdb0a 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "@capsule-run/cli": "^0.8.7", - "@capsule-run/sdk": "^0.8.7" + "@capsule-run/cli": "^0.8.8", + "@capsule-run/sdk": "^0.8.8" }, "devDependencies": { "esbuild": "^0.28.0", diff --git a/packages/bash-wasm/package.json b/packages/bash-wasm/package.json index 93c13e9..24f4428 100644 --- a/packages/bash-wasm/package.json +++ b/packages/bash-wasm/package.json @@ -19,8 +19,8 @@ "tsup": "^8.0.0" }, "dependencies": { - "@capsule-run/cli": "^0.8.7", - "@capsule-run/sdk": "^0.8.7", + "@capsule-run/cli": "^0.8.8", + "@capsule-run/sdk": "^0.8.8", "@capsule-run/bash-types": "workspace:*" } } diff --git a/packages/bash-wasm/sandboxes/python/__test__/sandbox.test.ts b/packages/bash-wasm/sandboxes/python/__test__/sandbox.test.ts index 57c2bc0..96b3fb7 100644 --- a/packages/bash-wasm/sandboxes/python/__test__/sandbox.test.ts +++ b/packages/bash-wasm/sandboxes/python/__test__/sandbox.test.ts @@ -83,24 +83,6 @@ describe('sandbox.py – EXECUTE_CODE', () => { expect(error.message).toContain('boom'); }); - -// it('url request test', async () => { -// const result = await run({ -// file: SANDBOX, -// args: ['EXECUTE_CODE', baseState, `import urllib.request -// import json - -// url = "https://jsonplaceholder.typicode.com/posts/1" - -// with urllib.request.urlopen(url) as response: -// print(json.loads(response.read().decode("utf-8"))) -// `], -// mounts: [`${WORKSPACE}::/`], -// }); - -// console.log(result) - -// }); }); describe('sandbox.py – EXECUTE_FILE', () => { diff --git a/packages/bash/src/commands/cat/cat.test.ts b/packages/bash/src/commands/cat/cat.test.ts index 838ba11..ad84d1b 100644 --- a/packages/bash/src/commands/cat/cat.test.ts +++ b/packages/bash/src/commands/cat/cat.test.ts @@ -1,7 +1,75 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./cat.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('cat command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should read file content successfully', async () => { + const resolvePathMock = vi.fn().mockResolvedValue('/workspace/file.txt'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return false; + if (code.includes('readFileSync')) return 'hello world'; + return null; + }); + + const ctx = createMockContext(['file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello world'); + expect(result.stderr).toBe(''); + }); + + it('should concatenate multiple files', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, arg) => `/workspace/${arg}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return false; + if (code.includes('file1.txt')) return 'hello'; + if (code.includes('file2.txt')) return 'world'; + return null; + }); + + const ctx = createMockContext(['file1.txt', 'file2.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + // Because Promise.all might execute out of order in handler, + // the output order could theoretically be non-deterministic, + // but assuming it respects map array order in pushing if evaluated sequentially + // Wait, Promise.all runs concurrently. They push to stdout concurrently. + // We can just verify it contains both parts. + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('hello'); + expect(result.stdout).toContain('world'); + }); + + it('should return error if file does not exist', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + const ctx = createMockContext(['nonexistent.txt'], {}, { resolvePath: resolvePathMock }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('bash: cat: nonexistent.txt: No such file or directory'); + }); + + it('should return error if path is a directory', async () => { + const resolvePathMock = vi.fn().mockResolvedValue('/workspace/dir'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return true; + return null; + }); + + const ctx = createMockContext(['dir'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('bash: cat: dir: Is a directory'); + }); +}); diff --git a/packages/bash/src/commands/cd/cd.handler.ts b/packages/bash/src/commands/cd/cd.handler.ts index 8248696..bf185cb 100644 --- a/packages/bash/src/commands/cd/cd.handler.ts +++ b/packages/bash/src/commands/cd/cd.handler.ts @@ -14,7 +14,7 @@ export const handler: CommandHandler = async ({ opts, state }: CommandContext) = return { stdout: '', stderr: `bash: cd: too many arguments`, exitCode: 1 }; } - if(opts.args[0] && opts.args[0] !== "~") { + if(opts.args.length > 0 && opts.args[0] !== "~") { targetPath = opts.args[0]; } @@ -24,5 +24,5 @@ export const handler: CommandHandler = async ({ opts, state }: CommandContext) = return { stdout: '', stderr: `bash: cd: ${targetPath}: No such file or directory`, exitCode: 1 }; } - return { stdout: '', stderr: '', exitCode: 0 }; + return { stdout: `Directory changed to ${targetPath} ✔`, stderr: '', exitCode: 0 }; }; diff --git a/packages/bash/src/commands/cd/cd.test.ts b/packages/bash/src/commands/cd/cd.test.ts index 5340782..203c661 100644 --- a/packages/bash/src/commands/cd/cd.test.ts +++ b/packages/bash/src/commands/cd/cd.test.ts @@ -10,6 +10,7 @@ describe('cd command', () => { const result = await handler(ctx); expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Directory changed to /workspace'); expect(changeDirectoryMock).toHaveBeenCalledWith('/workspace'); }); diff --git a/packages/bash/src/commands/cp/cp.handler.ts b/packages/bash/src/commands/cp/cp.handler.ts index 78f506d..8f9069a 100644 --- a/packages/bash/src/commands/cp/cp.handler.ts +++ b/packages/bash/src/commands/cp/cp.handler.ts @@ -20,7 +20,8 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC const sourceFileName = source.split('/').pop() || source; - const parentDestinationFolder = destination.split('/').slice(-1).join('/'); + const parts = destination.split('/'); + const parentDestinationFolder = parts.length > 1 ? parts.slice(0, -1).join('/') : '.'; const sourceAbsolutePath = await runtime.resolvePath(state, source); const parentDestinationAbsolutePath = await runtime.resolvePath(state, parentDestinationFolder) @@ -34,11 +35,11 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC } if(isDestinationFolder && !isSourceFolder) { - const destinationPath = path.join(destination, sourceFileName); + const destinationPath = path.join(destinationAbsolutePath as string, sourceFileName); await runtime.executeCode(state, `require('fs').copyFileSync('${sourceAbsolutePath}', '${destinationPath}');`); - return { stdout: '', stderr: '', exitCode: 0 }; + return { stdout: 'File copied ✔', stderr: '', exitCode: 0 }; } if(!destinationAbsolutePath && parentDestinationAbsolutePath && !isSourceFolder) { @@ -46,12 +47,12 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC const destinationPath = path.join(parentDestinationAbsolutePath, destinationFileName) await runtime.executeCode(state, `require('fs').copyFileSync('${sourceAbsolutePath}', '${destinationPath}');`); - return { stdout: '', stderr: '', exitCode: 0 }; + return { stdout: 'File copied ✔', stderr: '', exitCode: 0 }; } if(opts.hasFlag('r') && isSourceFolder) { await runtime.executeCode(state, `(async () => await require('fs').cp('${sourceAbsolutePath}', '${destinationAbsolutePath || destination}', { recursive: true }))()`) - return { stdout: '', stderr: '', exitCode: 0 }; + return { stdout: 'Folder copied ✔', stderr: '', exitCode: 0 }; } diff --git a/packages/bash/src/commands/cp/cp.test.ts b/packages/bash/src/commands/cp/cp.test.ts index cff3d36..0c73b66 100644 --- a/packages/bash/src/commands/cp/cp.test.ts +++ b/packages/bash/src/commands/cp/cp.test.ts @@ -1,7 +1,108 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./cp.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('cp command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return error if missing operands', async () => { + const ctx = createMockContext(['file1']); + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('missing file operand'); + }); + + it('should return error if source does not exist', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + const ctx = createMockContext(['nonexistent', 'dest'], {}, { resolvePath: resolvePathMock }); + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No such file or directory'); + }); + + it('should copy file to another file successfully', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => { + if (path === 'file1.txt') return '/workspace/file1.txt'; + if (path === 'newname.txt') return undefined; // Destination file doesn't exist + if (path === '.') return '/workspace'; // Parent folder + return undefined; + }); + + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return false; + return ''; + }); + + const ctx = createMockContext(['file1.txt', 'newname.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('File copied ✔'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("require('fs').copyFileSync('/workspace/file1.txt'") + ); + }); + + it('should copy file into a directory successfully', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => { + if (path === 'file1.txt') return '/workspace/file1.txt'; + if (path === 'dir1') return '/workspace/dir1'; + return undefined; + }); + + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) { + if (code.includes('dir1')) return true; + return false; + } + return ''; + }); + + const ctx = createMockContext(['file1.txt', 'dir1'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('File copied ✔'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("copyFileSync('/workspace/file1.txt'") + ); + }); + + it('should copy directory recursively with -r flag', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => { + if (path === 'dir1') return '/workspace/dir1'; + if (path === 'dir2') return undefined; + if (path === '.') return '/workspace'; + return undefined; + }); + + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) { + if (code.includes('dir1')) return true; + return false; + } + return ''; + }); + + const ctx = createMockContext(['-r', 'dir1', 'dir2'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Folder copied ✔'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("cp('/workspace/dir1', 'dir2', { recursive: true })") + ); + }); +}); diff --git a/packages/bash/src/commands/curl/curl.handler.ts b/packages/bash/src/commands/curl/curl.handler.ts index 1375fd1..fdf3fce 100644 --- a/packages/bash/src/commands/curl/curl.handler.ts +++ b/packages/bash/src/commands/curl/curl.handler.ts @@ -56,7 +56,6 @@ function parseRawArgs(raw: string[]): CurlArgs { } } else if (arg === '-d' && raw[i + 1]) { result.body = raw[++i]; - // -d implies POST if no -X was given if (result.method === 'GET') result.method = 'POST'; } else if (!arg.startsWith('-')) { result.url = arg; @@ -83,6 +82,36 @@ export const handler: CommandHandler = async ({ opts, state, runtime }: CommandC return { stdout: '', stderr: 'bash: curl: no URL specified', exitCode: 1 }; } + if (args.saveToFile) { + const filename = filenameFromUrl(args.url); + const absolutePath = await runtime.resolvePath(state, filename); + const targetPath = absolutePath ?? filename; + + const saveResult = await runtime.executeCode(state, ` + (async function() { + try { + const response = await fetch(${JSON.stringify(args.url)}, { + method: ${JSON.stringify(args.method)}, + headers: ${JSON.stringify(args.headers)}, + ${args.body !== null ? `body: ${JSON.stringify(args.body)},` : ''} + redirect: ${JSON.stringify(args.followRedirects ? 'follow' : 'manual')}, + }); + const buffer = await response.arrayBuffer(); + require('fs').writeFileSync('${targetPath}', new Uint8Array(buffer)); + return { ok: true }; + } catch (e) { + return { ok: false, error: String(e) }; + } + })() + `) as { ok: boolean; error?: string }; + + if (!saveResult.ok) { + return { stdout: '', stderr: args.silent ? '' : `bash: curl: ${args.url}: ${saveResult.error}`, exitCode: 1 }; + } + + return { stdout: 'File downloaded ✔', stderr: '', exitCode: 0 }; + } + const result = await runtime.executeCode(state, ` (async function() { try { @@ -92,7 +121,6 @@ export const handler: CommandHandler = async ({ opts, state, runtime }: CommandC ${args.body !== null ? `body: ${JSON.stringify(args.body)},` : ''} redirect: ${JSON.stringify(args.followRedirects ? 'follow' : 'manual')}, }); - const text = await response.text(); return { ok: true, status: response.status, body: text }; } catch (e) { @@ -106,15 +134,5 @@ export const handler: CommandHandler = async ({ opts, state, runtime }: CommandC return { stdout: '', stderr: args.silent ? '' : msg, exitCode: 1 }; } - if (args.saveToFile) { - const filename = filenameFromUrl(args.url); - const absolutePath = await runtime.resolvePath(state, filename); - const targetPath = absolutePath ?? filename; - - await runtime.executeCode(state, `require('fs').writeFileSync('${targetPath}', ${JSON.stringify(result.body ?? '')});`); - - return { stdout: '', stderr: args.silent ? '' : ` % Total\n100 ${(result.body ?? '').length} Saved to: ${filename}`, exitCode: 0 }; - } - return { stdout: result.body ?? '', stderr: '', exitCode: 0 }; }; diff --git a/packages/bash/src/commands/curl/curl.test.ts b/packages/bash/src/commands/curl/curl.test.ts index 49c4bdc..56e7ef8 100644 --- a/packages/bash/src/commands/curl/curl.test.ts +++ b/packages/bash/src/commands/curl/curl.test.ts @@ -1,8 +1,109 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./curl.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('curl command', () => { - // todo - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return error if no URL specified', async () => { + const ctx = createMockContext(['-s']); // missing URL + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('no URL specified'); + }); + + it('should construct correct fetch call and return body', async () => { + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('fetch')) { + return { ok: true, status: 200, body: 'mocked response' }; + } + return null; + }); + + const ctx = createMockContext(['https://example.com'], {}, { executeCode: executeCodeMock }); + // NOTE: we need to pass opts.raw for curl parser + ctx.opts.raw = ['https://example.com']; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('mocked response'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('"https://example.com"') + ); + }); + + it('should parse headers, method and body correctly', async () => { + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + return { ok: true, status: 201, body: 'created' }; + }); + + const ctx = createMockContext(['-X', 'POST', '-H', 'Content-Type: application/json', '-d', '{"a":1}', 'https://api.example.com'], {}, { executeCode: executeCodeMock }); + ctx.opts.raw = ['-X', 'POST', '-H', 'Content-Type: application/json', '-d', '{"a":1}', 'https://api.example.com']; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('method: "POST"') + ); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('{"Content-Type":"application/json"}') + ); + }); + + it('should save to file with -O flag', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/index.html'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('fetch')) { + return { ok: true }; + } + return null; + }); + + const ctx = createMockContext(['-O', 'https://example.com/index.html'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + ctx.opts.raw = ['-O', 'https://example.com/index.html']; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('File downloaded ✔'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("writeFileSync('/workspace/index.html'") + ); + }); + + it('should return network error without silent flag', async () => { + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + return { ok: false, error: 'Network disconnected' }; + }); + + const ctx = createMockContext(['https://example.com'], {}, { executeCode: executeCodeMock }); + ctx.opts.raw = ['https://example.com']; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('bash: curl: https://example.com: Network disconnected'); + }); + + it('should not output error with silent flag', async () => { + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + return { ok: false, error: 'Network disconnected' }; + }); + + const ctx = createMockContext(['-s', 'https://example.com'], {}, { executeCode: executeCodeMock }); + ctx.opts.raw = ['-s', 'https://example.com']; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(''); + }); +}); diff --git a/packages/bash/src/commands/echo/echo.handler.ts b/packages/bash/src/commands/echo/echo.handler.ts index c49db19..6cef5b1 100644 --- a/packages/bash/src/commands/echo/echo.handler.ts +++ b/packages/bash/src/commands/echo/echo.handler.ts @@ -10,15 +10,16 @@ export const manual: CommandManual = { } }; -export const handler: CommandHandler = async ({ opts, state, runtime }: CommandContext) => { - const stdout: string[] = []; +export const handler: CommandHandler = async ({ opts }: CommandContext) => { + let output = opts.args.join(' '); - await Promise.all(opts.args.map(async (arg) => { - let str = !opts.hasFlag("n") ? `${arg}\n` : arg; - str = !opts.hasFlag("e") ? str.replace(/\\n/g, "\n") : str; + if (opts.hasFlag("e")) { + output = output.replace(/\\n/g, "\n").replace(/\\t/g, "\t"); + } - stdout.push(str); - })) + if (!opts.hasFlag("n")) { + output += "\n"; + } - return { stdout: stdout.join(' '), stderr: '', exitCode: 0 }; + return { stdout: output, stderr: '', exitCode: 0 }; }; diff --git a/packages/bash/src/commands/echo/echo.test.ts b/packages/bash/src/commands/echo/echo.test.ts index 9fe01ae..4d859ee 100644 --- a/packages/bash/src/commands/echo/echo.test.ts +++ b/packages/bash/src/commands/echo/echo.test.ts @@ -1,7 +1,45 @@ import { describe, it, expect } from "vitest"; +import { handler } from "./echo.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('echo command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should output text with trailing newline', async () => { + const ctx = createMockContext(['hello', 'world']); + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello world\n'); + }); + + it('should output without trailing newline if -n is passed', async () => { + const ctx = createMockContext(['-n', 'hello', 'world']); + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello world'); + }); + + it('should not interpret escapes by default', async () => { + const ctx = createMockContext(['hello\\nworld']); + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello\\nworld\n'); + }); + + it('should interpret escapes if -e is passed', async () => { + const ctx = createMockContext(['-e', 'hello\\nworld']); + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello\nworld\n'); + }); + + it('should combine flags -n and -e', async () => { + const ctx = createMockContext(['-e', '-n', 'hello\\tworld']); + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello\tworld'); + }); +}); diff --git a/packages/bash/src/commands/env/env.handler.ts b/packages/bash/src/commands/env/env.handler.ts new file mode 100644 index 0000000..ed0ca98 --- /dev/null +++ b/packages/bash/src/commands/env/env.handler.ts @@ -0,0 +1,17 @@ +import type { CommandContext, CommandHandler, CommandManual } from "@capsule-run/bash-types"; + +export const manual: CommandManual = { + name: "env", + description: "Display environment variables.", + usage: "env" +}; + +export const handler: CommandHandler = async ({ opts, state, runtime }: CommandContext) => { + const stdout: string[] = []; + + Object.entries(state.env).forEach(([key, value]) => { + stdout.push(`${key}=${value}`); + }); + + return { stdout: stdout.join('\n'), stderr: '', exitCode: 0 }; +}; diff --git a/packages/bash/src/commands/env/env.test.ts b/packages/bash/src/commands/env/env.test.ts new file mode 100644 index 0000000..c06a457 --- /dev/null +++ b/packages/bash/src/commands/env/env.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { handler } from "./env.handler"; +import { createMockContext } from "../../helpers/testUtils"; + +describe('env command', () => { + it('should output all environment variables in KEY=VALUE format', async () => { + const ctx = createMockContext([], { + env: { + USER: 'testuser', + PATH: '/usr/local/bin:/usr/bin' + } + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('USER=testuser'); + expect(result.stdout).toContain('PATH=/usr/local/bin:/usr/bin'); + }); + + it('should output empty string if no env variables exist', async () => { + const ctx = createMockContext([], { env: {} }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(''); + }); +}); diff --git a/packages/bash/src/commands/export/export.handler.ts b/packages/bash/src/commands/export/export.handler.ts new file mode 100644 index 0000000..9eff142 --- /dev/null +++ b/packages/bash/src/commands/export/export.handler.ts @@ -0,0 +1,25 @@ +import type { CommandContext, CommandHandler, CommandManual } from "@capsule-run/bash-types"; + +export const manual: CommandManual = { + name: "export", + description: "Set environment variables.", + usage: "export" +}; + +export const handler: CommandHandler = async ({ opts, state }: CommandContext) => { + for (const arg of opts.args) { + const equalIdx = arg.indexOf('='); + if (equalIdx > 0) { + const key = arg.slice(0, equalIdx); + const value = arg.slice(equalIdx + 1); + state.setEnv(key, value); + } else if (arg.length > 0) { + + if (state.env[arg] === undefined) { + state.setEnv(arg, ''); + } + } + } + + return { stdout: `Environment variables exported ✔`, stderr: '', exitCode: 0 }; +}; diff --git a/packages/bash/src/commands/export/export.test.ts b/packages/bash/src/commands/export/export.test.ts new file mode 100644 index 0000000..90ae860 --- /dev/null +++ b/packages/bash/src/commands/export/export.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./export.handler"; +import { createMockContext } from "../../helpers/testUtils"; + +describe('export command', () => { + it('should export a single variable with key=value format', async () => { + const setEnvMock = vi.fn(); + const ctx = createMockContext(['FOO=bar'], { setEnv: setEnvMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Environment variables exported'); + expect(setEnvMock).toHaveBeenCalledWith('FOO', 'bar'); + }); + + it('should export multiple variables correctly', async () => { + const setEnvMock = vi.fn(); + const ctx = createMockContext(['FOO=bar', 'BAZ=qux'], { setEnv: setEnvMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(setEnvMock).toHaveBeenCalledWith('FOO', 'bar'); + expect(setEnvMock).toHaveBeenCalledWith('BAZ', 'qux'); + }); + + it('should set undefined variable to empty string if no equals is provided', async () => { + const setEnvMock = vi.fn(); + const ctx = createMockContext(['FOO'], { setEnv: setEnvMock, env: {} }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(setEnvMock).toHaveBeenCalledWith('FOO', ''); + }); + + it('should handle values with equals signs in them', async () => { + const setEnvMock = vi.fn(); + const ctx = createMockContext(['SECRET=base64=='], { setEnv: setEnvMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(setEnvMock).toHaveBeenCalledWith('SECRET', 'base64=='); + }); +}); diff --git a/packages/bash/src/commands/find/find.test.ts b/packages/bash/src/commands/find/find.test.ts index 382c052..a9fb1a8 100644 --- a/packages/bash/src/commands/find/find.test.ts +++ b/packages/bash/src/commands/find/find.test.ts @@ -1,7 +1,86 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./find.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('find command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return error if search path does not exist', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + const ctx = createMockContext(['nonexistent'], {}, { resolvePath: resolvePathMock }); + ctx.opts.raw = ['nonexistent']; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("No such file or directory"); + }); + + it('should traverse directories and list all files if no flags are provided', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('statSync(\'/workspace/dir\')')) { + return { isDirectory: true, entries: ['file1.txt', 'subdir'] }; + } + if (code.includes('statSync(\'/workspace/dir/subdir\')')) { + return { isDirectory: true, entries: ['file2.js'] }; + } + return { isDirectory: false }; + }); + + const ctx = createMockContext(['dir'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + ctx.opts.raw = ['dir']; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('dir'); + expect(result.stdout).toContain('dir/file1.txt'); + expect(result.stdout).toContain('dir/subdir'); + expect(result.stdout).toContain('dir/subdir/file2.js'); + }); + + it('should filter by -type f', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('statSync(\'/workspace/dir\')')) return { isDirectory: true, entries: ['f1.txt'] }; + return { isDirectory: false }; + }); + + const ctx = createMockContext(['dir', '-type', 'f'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + ctx.opts.raw = ['dir', '-type', 'f']; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('dir\n'); + expect(result.stdout).toContain('dir/f1.txt'); + }); + + it('should filter by -name pattern', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('statSync(\'/workspace/dir\')')) return { isDirectory: true, entries: ['match.ts', 'ignore.js'] }; + return { isDirectory: false }; + }); + + const ctx = createMockContext(['dir', '-name', '*.ts'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + ctx.opts.raw = ['dir', '-name', '*.ts']; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('dir/match.ts'); + expect(result.stdout).not.toContain('dir/ignore.js'); + }); +}); diff --git a/packages/bash/src/commands/grep/grep.handler.ts b/packages/bash/src/commands/grep/grep.handler.ts index 1aa6442..047a0e7 100644 --- a/packages/bash/src/commands/grep/grep.handler.ts +++ b/packages/bash/src/commands/grep/grep.handler.ts @@ -75,7 +75,6 @@ export const handler: CommandHandler = async ({ opts, state, runtime, stdin }: C return; } - // Single executeCode call: returns { isDirectory, entries?, content? } const info = await runtime.executeCode(state, ` (function() { const fs = require('fs'); diff --git a/packages/bash/src/commands/grep/grep.test.ts b/packages/bash/src/commands/grep/grep.test.ts index f124ae3..e9aed16 100644 --- a/packages/bash/src/commands/grep/grep.test.ts +++ b/packages/bash/src/commands/grep/grep.test.ts @@ -1,7 +1,96 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./grep.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('grep command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return error if missing pattern', async () => { + const ctx = createMockContext([]); + const result = await handler(ctx); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('missing pattern'); + }); + + it('should search from stdin if no file args provided', async () => { + const ctx = createMockContext(['hello']); + ctx.stdin = 'hello world\nignore this\nsay hello'; + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello world\nsay hello'); + }); + + it('should search in a single file', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + return { isDirectory: false, content: 'hello world\nignore this\nsay hello' }; + }); + + const ctx = createMockContext(['hello', 'file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello world\nsay hello'); + }); + + it('should append prefix for multiple files', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + return { isDirectory: false, content: 'hello world\nignore this\nsay hello' }; + }); + + const ctx = createMockContext(['hello', 'file1.txt', 'file2.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('file1.txt:hello world'); + expect(result.stdout).toContain('file2.txt:say hello'); + }); + + it('should apply -i ignore case and -n line number flags', async () => { + const ctx = createMockContext(['-i', '-n', 'HELLO']); + ctx.stdin = 'Hello world\nignore this\nsay hello'; + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('1:Hello world\n3:say hello'); + }); + + it('should return error for directories without -r', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + return { isDirectory: true, entries: ['file.txt'] }; + }); + + const ctx = createMockContext(['hello', 'dir'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('bash: grep: dir: Is a directory'); + }); + + it('should recurse with -r flag', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('/workspace/dir\'')) return { isDirectory: true, entries: ['file'] }; + return { isDirectory: false, content: 'hello inside!' }; + }); + + const ctx = createMockContext(['-r', 'hello', 'dir'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('dir/file:hello inside!'); + }); +}); diff --git a/packages/bash/src/commands/head/head.handler.ts b/packages/bash/src/commands/head/head.handler.ts index fe00663..be73925 100644 --- a/packages/bash/src/commands/head/head.handler.ts +++ b/packages/bash/src/commands/head/head.handler.ts @@ -15,9 +15,10 @@ export const handler: CommandHandler = async ({ opts, state, runtime }: CommandC const stdout: string[] = []; const lineNumber = opts.hasFlag("n") ? opts.args[0] : 10; - const multipleFiles = opts.args.length > 1; + const fileArgs = opts.hasFlag("n") ? opts.args.slice(1) : opts.args; + const multipleFiles = fileArgs.length > 1; - await Promise.all(opts.args.map(async (arg) => { + await Promise.all(fileArgs.map(async (arg) => { const destinationAbsolutePath = await runtime.resolvePath(state, arg) if(!destinationAbsolutePath) { diff --git a/packages/bash/src/commands/head/head.test.ts b/packages/bash/src/commands/head/head.test.ts index 3322844..9c2de25 100644 --- a/packages/bash/src/commands/head/head.test.ts +++ b/packages/bash/src/commands/head/head.test.ts @@ -1,7 +1,93 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./head.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('head command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return error if file does not exist', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + const ctx = createMockContext(['nonexistent.txt'], {}, { resolvePath: resolvePathMock }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No such file or directory'); + }); + + it('should output default 10 lines for single file', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/file.txt'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return false; + if (code.includes('readFileSync')) return '1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11'; + return ''; + }); + + const ctx = createMockContext(['file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('slice(0, 10)') + ); + }); + + it('should slice custom number of lines with -n', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/file.txt'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return false; + if (code.includes('readFileSync')) return '1\n2\n3\n4\n5'; + return ''; + }); + + const ctx = createMockContext(['-n', '3', 'file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + ctx.opts.raw = ['-n', '3', 'file.txt']; + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('slice(0, 3)') + ); + expect(result.stdout).toBe('1\n2\n3\n4\n5'); + }); + + it('should print headers for multiple files', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return false; + return 'content'; + }); + + const ctx = createMockContext(['file1.txt', 'file2.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('==> file1.txt <=='); + expect(result.stdout).toContain('==> file2.txt <=='); + expect(result.stdout).toContain('content'); + }); + + it('should return error if target is a directory', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/dir'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return true; + }); + + const ctx = createMockContext(['dir'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Is a directory'); + }); +}); diff --git a/packages/bash/src/commands/ls/ls.handler.ts b/packages/bash/src/commands/ls/ls.handler.ts index 504a1e8..871c23a 100644 --- a/packages/bash/src/commands/ls/ls.handler.ts +++ b/packages/bash/src/commands/ls/ls.handler.ts @@ -1,5 +1,4 @@ import type { CommandContext, CommandHandler, CommandManual } from "@capsule-run/bash-types"; -import fs from "fs"; import path from "path"; const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; @@ -65,17 +64,17 @@ export const handler: CommandHandler = async ({ opts, state, runtime }: CommandC const filepath = path.join(sandboxAbsolutePath, filename); try { - const unsafeGlobalStats = fs.statSync(path.join(runtime.hostWorkspace as string, filepath)); - const wasmSafeStats = await runtime.executeCode(state, `return require('fs').statSync('${filepath}');`) as Record; + const wasmSafeStats = await runtime.executeCode(state, `return require('fs').statSync('${filepath}');`) as any; - const isDirectory = wasmSafeStats.mode === 0o40755; + // Use arbitrary simple permission layout + const isDirectory = wasmSafeStats.mode === 0o40755 || (wasmSafeStats.mode & 0o40000); const permissions = (isDirectory ? "d" : "-") + "rwxr-xr-x"; - const hardlink = unsafeGlobalStats.nlink || 1; + const hardlink = wasmSafeStats.nlink || 1; const user = "Agent"; const group = "staff"; const size = wasmSafeStats.size || 0; - const date = new Date(unsafeGlobalStats.mtime || Date.now()); + const date = new Date(wasmSafeStats.mtime || Date.now()); const padDate = date.getDate().toString().padStart(2, ' '); const padHours = date.getHours().toString().padStart(2, '0'); diff --git a/packages/bash/src/commands/ls/ls.test.ts b/packages/bash/src/commands/ls/ls.test.ts index c5adf4f..891ee17 100644 --- a/packages/bash/src/commands/ls/ls.test.ts +++ b/packages/bash/src/commands/ls/ls.test.ts @@ -1,7 +1,84 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./ls.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('ls command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should list files excluding hidden ones by default', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('readdirSync')) return ['file1.txt', '.hidden', 'dir1']; + return {}; + }); + + const ctx = createMockContext(['dir'], {}, { resolvePath: resolvePathMock, executeCode: executeCodeMock }); + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('.hidden'); + expect(result.stdout).not.toContain('..'); + expect(result.stdout).toContain('dir1 file1.txt'); + }); + + it('should list hidden files with -a', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('readdirSync')) return ['file1.txt', '.hidden']; + return {}; + }); + + const ctx = createMockContext(['-a', 'dir'], {}, { resolvePath: resolvePathMock, executeCode: executeCodeMock }); + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('.hidden'); + expect(result.stdout).toContain('..'); + expect(result.stdout).toContain('.'); + expect(result.stdout).toContain('file1.txt'); + }); + + it('should use long format with -l', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('readdirSync')) return ['file1.txt']; + if (code.includes('statSync')) return { mode: 0o100644, size: 1024, nlink: 1, mtime: '2023-01-01T00:00:00.000Z' }; + return {}; + }); + + const ctx = createMockContext(['-l', 'dir'], {}, { resolvePath: resolvePathMock, executeCode: executeCodeMock }); + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('total'); + expect(result.stdout).toContain('1024'); + expect(result.stdout).toContain('-rwxr-xr-x'); + expect(result.stdout).toContain('file1.txt'); + }); + + it('should support multiple directories', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('dir1')) return ['file_a']; + if (code.includes('dir2')) return ['file_b']; + return []; + }); + + const ctx = createMockContext(['dir1', 'dir2'], {}, { resolvePath: resolvePathMock, executeCode: executeCodeMock }); + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('dir1:\n'); + expect(result.stdout).toContain('file_a'); + expect(result.stdout).toContain('dir2:\n'); + expect(result.stdout).toContain('file_b'); + }); + + it('should return error for nonexistent directory', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + + const ctx = createMockContext(['nonexistent'], {}, { resolvePath: resolvePathMock }); + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No such file or directory'); + }); +}); diff --git a/packages/bash/src/commands/mkdir/mkdir.handler.ts b/packages/bash/src/commands/mkdir/mkdir.handler.ts index a0628ad..ddd3785 100644 --- a/packages/bash/src/commands/mkdir/mkdir.handler.ts +++ b/packages/bash/src/commands/mkdir/mkdir.handler.ts @@ -16,12 +16,11 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC const stderr: string[] = []; await Promise.all(opts.args.map(async (arg) => { - const parentFolder = arg.split('/').slice(-1).join('/'); + const segments = arg.split('/'); + const parentFolder = segments.length > 1 ? segments.slice(0, -1).join('/') : '.'; - console.log(parentFolder) const parentFolderAbsolutePath = (await runtime.resolvePath(state, parentFolder)); - console.log(parentFolderAbsolutePath, arg) if(!parentFolderAbsolutePath && arg.includes('..')) { stderr.push(`bash: mkdir: '${arg}': Permission denied`); return; @@ -40,5 +39,5 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC })) - return { stdout: '', stderr: stderr.join('\n'), exitCode: stderr.length > 0 ? 1 : 0 }; + return { stdout: 'Folder created ✔', stderr: stderr.join('\n'), exitCode: stderr.length > 0 ? 1 : 0 }; } diff --git a/packages/bash/src/commands/mkdir/mkdir.test.ts b/packages/bash/src/commands/mkdir/mkdir.test.ts index 84cd6bf..e36af5f 100644 --- a/packages/bash/src/commands/mkdir/mkdir.test.ts +++ b/packages/bash/src/commands/mkdir/mkdir.test.ts @@ -1,7 +1,68 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./mkdir.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('mkdir command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should create a directory successfully', async () => { + const executeCodeMock = vi.fn().mockResolvedValue(''); + const resolvePathMock = vi.fn().mockResolvedValue('/workspace'); + + const ctx = createMockContext(['test_dir'], {}, { + executeCode: executeCodeMock, + resolvePath: resolvePathMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Folder created ✔'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("require('fs').mkdirSync('test_dir');") + ); + }); + + it('should return error if parent directory does not exist and -p is not provided', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + + const ctx = createMockContext(['nonexistent/test_dir'], {}, { + resolvePath: resolvePathMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("bash: mkdir: 'nonexistent/test_dir': No such file or directory"); + }); + + it('should create parent directories if -p flag is provided', async () => { + const executeCodeMock = vi.fn().mockResolvedValue(''); + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + + const ctx = createMockContext(['-p', 'nonexistent/test_dir'], {}, { + executeCode: executeCodeMock, + resolvePath: resolvePathMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("mkdirSync('nonexistent/test_dir', { recursive: true })") + ); + }); + + it('should deny permission when traversing out of bounds', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + + const ctx = createMockContext(['../../test_dir'], {}, { + resolvePath: resolvePathMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Permission denied"); + }); +}); diff --git a/packages/bash/src/commands/mv/mv.handler.ts b/packages/bash/src/commands/mv/mv.handler.ts index 88a769d..c0c9922 100644 --- a/packages/bash/src/commands/mv/mv.handler.ts +++ b/packages/bash/src/commands/mv/mv.handler.ts @@ -34,8 +34,6 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC } if(isDestinationFolder) { - console.log(sourceAbsolutePath, destinationAbsolutePath) - console.log(isSourceFolder) await runtime.executeCode(state, ` const fs = require('fs'); (async () => { @@ -46,7 +44,7 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC fs.rmSync('${sourceAbsolutePath}', ${isSourceFolder ? '{ recursive: true }' : '{}'}); })() `); - return { stdout: '', stderr: '', exitCode: 0 }; + return { stdout: `${isSourceFolder ? 'Folder' : 'File'} moved ✔`, stderr: '', exitCode: 0 }; } if(!isDestinationFolder && destinationAbsolutePath) { @@ -57,14 +55,14 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC await fs.rm('${sourceAbsolutePath}'); })() `); - return { stdout: '', stderr: '', exitCode: 0 }; + return { stdout: 'File moved ✔', stderr: '', exitCode: 0 }; } if(!isDestinationFolder && !destinationAbsolutePath) { await runtime.executeCode(state, `const fs = require('fs'); fs.renameSync('${sourceAbsolutePath}', '${destination}'); `); - return { stdout: '', stderr: '', exitCode: 0 }; + return { stdout: `${isSourceFolder ? 'Folder' : 'File'} moved ✔`, stderr: '', exitCode: 0 }; } return { stdout: '', stderr: `bash: mv: ${destination}: No such file or directory`, exitCode: 1 }; diff --git a/packages/bash/src/commands/mv/mv.test.ts b/packages/bash/src/commands/mv/mv.test.ts index 716cea7..9a7a43b 100644 --- a/packages/bash/src/commands/mv/mv.test.ts +++ b/packages/bash/src/commands/mv/mv.test.ts @@ -1,7 +1,109 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./mv.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('mv command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return error if missing operands', async () => { + const ctx = createMockContext(['file1']); + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('missing file operand'); + }); + + it('should return error if source does not exist', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => { + if (path === 'nonexistent') return undefined; + return `/workspace/${path}`; + }); + + const ctx = createMockContext(['nonexistent', 'dest'], {}, { resolvePath: resolvePathMock }); + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No such file or directory'); + }); + + it('should rename a file successfully', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => { + if (path === 'file1.txt') return '/workspace/file1.txt'; + if (path === 'newname.txt') return undefined; + return undefined; + }); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return false; + return ''; + }); + + const ctx = createMockContext(['file1.txt', 'newname.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('File moved ✔'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("fs.renameSync('/workspace/file1.txt', 'newname.txt')") + ); + }); + + it('should move a file into a directory', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => { + if (path === 'file1.txt') return '/workspace/file1.txt'; + if (path === 'dir1') return '/workspace/dir1'; + return undefined; + }); + + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes("'isDirectory'") || code.includes('isDirectory()')) { + if (code.includes('dir1')) return true; + return false; + } + return ''; + }); + + const ctx = createMockContext(['file1.txt', 'dir1'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('File moved ✔'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("fs.copyFile('/workspace/file1.txt'") + ); + }); + + it('should overwrite destination file if it exists and is not a folder', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => { + if (path === 'file1.txt') return '/workspace/file1.txt'; + if (path === 'file2.txt') return '/workspace/file2.txt'; + return undefined; + }); + + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory()')) return false; + return ''; + }); + + const ctx = createMockContext(['file1.txt', 'file2.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('File moved ✔'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("await fs.rm('/workspace/file2.txt'") + ); + }); +}); diff --git a/packages/bash/src/commands/node/node.test.ts b/packages/bash/src/commands/node/node.test.ts index a1ae0e1..b863f61 100644 --- a/packages/bash/src/commands/node/node.test.ts +++ b/packages/bash/src/commands/node/node.test.ts @@ -1,7 +1,71 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./node.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('node command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return error if no file or code specified', async () => { + const ctx = createMockContext([]); + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('no script specified'); + }); + + it('should evaluate inline code correctly with -e', async () => { + const executeCodeMock = vi.fn().mockResolvedValue('inline output'); + const ctx = createMockContext(['-e', 'console.log("inline output")'], {}, { executeCode: executeCodeMock }); + ctx.opts.raw = ['-e', 'console.log("inline output")']; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('inline output'); + expect(executeCodeMock).toHaveBeenCalledWith(expect.anything(), 'console.log("inline output")'); + }); + + it('should return error if -e is used without code', async () => { + const ctx = createMockContext(['-e']); + ctx.opts.raw = ['-e']; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('requires a code argument'); + }); + + it('should execute a script file with executeFile', async () => { + const resolvePathMock = vi.fn().mockResolvedValue('/workspace/script.js'); + const executeFileMock = vi.fn().mockResolvedValue('file output'); + + const ctx = createMockContext(['script.js', 'arg1', 'arg2'], {}, { + resolvePath: resolvePathMock, + executeFile: executeFileMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('file output'); + expect(executeFileMock).toHaveBeenCalledWith(expect.anything(), '/workspace/script.js', ['arg1', 'arg2']); + }); + + it('should return error if script file does not exist', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + const ctx = createMockContext(['nonexistent.js'], {}, { resolvePath: resolvePathMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No such file or directory'); + }); + + it('should capture sandbox execution errors properly', async () => { + const executeCodeMock = vi.fn().mockRejectedValue(new Error('Syntax Error')); + const ctx = createMockContext(['-e', 'bad code'], {}, { executeCode: executeCodeMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Syntax Error'); + }); +}); diff --git a/packages/bash/src/commands/pwd/pwd.test.ts b/packages/bash/src/commands/pwd/pwd.test.ts index c801db7..bc527e5 100644 --- a/packages/bash/src/commands/pwd/pwd.test.ts +++ b/packages/bash/src/commands/pwd/pwd.test.ts @@ -1,7 +1,21 @@ import { describe, it, expect } from "vitest"; +import { handler } from "./pwd.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('pwd command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return the current working directory', async () => { + const ctx = createMockContext([], { cwd: '/workspace/my-dir' }); + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('/workspace/my-dir'); + }); + + it('should return error if arguments are passed', async () => { + const ctx = createMockContext(['extra-arg']); + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('too many arguments'); + }); +}); diff --git a/packages/bash/src/commands/python3/python3.test.ts b/packages/bash/src/commands/python3/python3.test.ts index 1bf268d..fbe670c 100644 --- a/packages/bash/src/commands/python3/python3.test.ts +++ b/packages/bash/src/commands/python3/python3.test.ts @@ -1,7 +1,69 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./python3.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('python3 command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return error if no script specified', async () => { + const ctx = createMockContext([]); + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('no script specified'); + }); + + it('should evaluate inline code correctly with -c', async () => { + const executeCodeMock = vi.fn().mockResolvedValue('inline output'); + const ctx = createMockContext(['-c', 'print("inline output")'], {}, { executeCode: executeCodeMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('inline output'); + expect(executeCodeMock).toHaveBeenCalledWith(expect.anything(), 'print("inline output")', 'python'); + }); + + it('should return error if -c is used without code', async () => { + const ctx = createMockContext(['-c']); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('requires a code argument'); + }); + + it('should execute a script file with executeFile', async () => { + const resolvePathMock = vi.fn().mockResolvedValue('/workspace/script.py'); + const executeFileMock = vi.fn().mockResolvedValue('file output'); + + const ctx = createMockContext(['script.py', 'arg1', 'arg2'], {}, { + resolvePath: resolvePathMock, + executeFile: executeFileMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('file output'); + expect(executeFileMock).toHaveBeenCalledWith(expect.anything(), '/workspace/script.py', ['arg1', 'arg2'], 'python'); + }); + + it('should return error if script file does not exist', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + const ctx = createMockContext(['nonexistent.py'], {}, { resolvePath: resolvePathMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No such file or directory'); + }); + + it('should capture sandbox execution errors properly', async () => { + const executeCodeMock = vi.fn().mockRejectedValue(new Error('SyntaxError')); + const ctx = createMockContext(['-c', 'bad code'], {}, { executeCode: executeCodeMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('SyntaxError'); + }); +}); diff --git a/packages/bash/src/commands/rm/rm.handler.ts b/packages/bash/src/commands/rm/rm.handler.ts index bf1b236..c427811 100644 --- a/packages/bash/src/commands/rm/rm.handler.ts +++ b/packages/bash/src/commands/rm/rm.handler.ts @@ -15,6 +15,10 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC const stdout: string[] = []; const stderr: string[] = []; + if (opts.args.length === 0) { + return { stdout: '', stderr: 'bash: rm: missing operand', exitCode: 1 }; + } + await Promise.all(opts.args.map(async (target) => { if(!target) { stderr.push(`bash: rm: missing file operand`); @@ -28,7 +32,7 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC return; } - const isDirectory = await runtime.executeCode(state, `require('fs').statSync('${targetAbsolutePath}').isDirectory();`) + const isDirectory = await runtime.executeCode(state, `return require('fs').statSync('${targetAbsolutePath}').isDirectory();`) const isFile = !isDirectory; const isEmpty = isDirectory ? (await runtime.executeCode(state, `return require('fs').readdirSync('${targetAbsolutePath}');`) as string[]).length === 0 : false; @@ -44,19 +48,22 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC if(isDirectory && isEmpty && !opts.hasFlag("r")) { await runtime.executeCode(state, `require('fs').rmdirSync('${targetAbsolutePath}', { recursive: true });`); + stdout.push(`Folder ${target} removed ✔`); return; } if(isDirectory && opts.hasFlag("r") && opts.hasFlag("f")) { await runtime.executeCode(state, `(async () => { await require('fs').rm('${targetAbsolutePath}', { recursive: true }); })();`); + stdout.push(`Folder ${target} removed ✔`) return; } if(isFile) { await runtime.executeCode(state, `require('fs').unlinkSync('${targetAbsolutePath}');`); + stdout.push(`File ${target} removed ✔`) return; } })) - return { stdout: '', stderr: stderr.join("\n"), exitCode: stderr.length > 0 ? 1 : 0 }; + return { stdout: stdout.join('\n'), stderr: stderr.join("\n"), exitCode: stderr.length > 0 ? 1 : 0 }; }; diff --git a/packages/bash/src/commands/rm/rm.test.ts b/packages/bash/src/commands/rm/rm.test.ts index a308d0b..a6716d9 100644 --- a/packages/bash/src/commands/rm/rm.test.ts +++ b/packages/bash/src/commands/rm/rm.test.ts @@ -1,7 +1,87 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./rm.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('rm command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return error if no targets specified', async () => { + const ctx = createMockContext([]); + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('missing operand'); + }); + + it('should remove a file successfully', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/file.txt'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return false; + return ''; + }); + + const ctx = createMockContext(['file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('File file.txt removed ✔'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("unlinkSync('/workspace/file.txt')") + ); + }); + + it('should return error if target does not exist', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + const ctx = createMockContext(['nonexistent'], {}, { resolvePath: resolvePathMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No such file or directory'); + }); + + it('should forbid directory removal without flags', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/dir'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return true; + if (code.includes('readdirSync')) return []; + return ''; + }); + + const ctx = createMockContext(['dir'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('dir: Is a directory'); + }); + + it('should remove non-empty directory with -r flag', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/dir'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('statSync') && code.includes('isDirectory')) return true; + if (code.includes('readdirSync')) return ['file.txt']; + return ''; + }); + + const ctx = createMockContext(['-r', '-f', 'dir'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Folder dir removed ✔'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("require('fs').rm('/workspace/dir', { recursive: true })") + ); + }); +}); diff --git a/packages/bash/src/commands/sed/sed.test.ts b/packages/bash/src/commands/sed/sed.test.ts index 07fe417..aaffadb 100644 --- a/packages/bash/src/commands/sed/sed.test.ts +++ b/packages/bash/src/commands/sed/sed.test.ts @@ -1,7 +1,87 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./sed.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('sed command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return error if missing expression', async () => { + const ctx = createMockContext([]); + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('missing expression'); + }); + + it('should process from stdin if no files are provided', async () => { + const ctx = createMockContext(['s/foo/bar/']); + ctx.stdin = 'foo baz\nhello foo'; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('bar baz\nhello foo'); + }); + + it('should support global flag (g) and custom delimiters', async () => { + const ctx = createMockContext(['s#foo#bar#g']); + ctx.stdin = 'foo foo foo'; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('bar bar bar'); + }); + + it('should support in-place editing with -i', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/file.txt'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return { isDirectory: false, content: 'edit me' }; + return ''; + }); + + const ctx = createMockContext(['-i', 's/edit/fixed/g', 'file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(''); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('writeFileSync(\'/workspace/file.txt\', "fixed me")') + ); + }); + + it('should output transformed file content to stdout if not in-place', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/file.txt'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return { isDirectory: false, content: 'edit me' }; + return ''; + }); + + const ctx = createMockContext(['s/edit/fixed/g', 'file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('fixed me'); + // Didn't call writeFileSync + expect(executeCodeMock).not.toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('writeFileSync') + ); + }); + + it('should return error for invalid regex expression', async () => { + const ctx = createMockContext(['s/some(']); // open parenthesis without closure + ctx.stdin = 'content'; + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('invalid expression'); + }); +}); diff --git a/packages/bash/src/commands/tail/tail.handler.ts b/packages/bash/src/commands/tail/tail.handler.ts index 3d91dce..8d4ed9a 100644 --- a/packages/bash/src/commands/tail/tail.handler.ts +++ b/packages/bash/src/commands/tail/tail.handler.ts @@ -15,9 +15,10 @@ export const handler: CommandHandler = async ({ opts, state, runtime }: CommandC const stdout: string[] = []; const lineNumber = opts.hasFlag("n") ? opts.args[0] : 10; - const multipleFiles = opts.args.length > (opts.hasFlag("n") ? 2 : 1); + const fileArgs = opts.hasFlag("n") ? opts.args.slice(1) : opts.args; + const multipleFiles = fileArgs.length > 1; - await Promise.all(opts.args.map(async (arg) => { + await Promise.all(fileArgs.map(async (arg) => { const destinationAbsolutePath = await runtime.resolvePath(state, arg) if(!destinationAbsolutePath) { diff --git a/packages/bash/src/commands/tail/tail.test.ts b/packages/bash/src/commands/tail/tail.test.ts index 3d4446e..95b26d0 100644 --- a/packages/bash/src/commands/tail/tail.test.ts +++ b/packages/bash/src/commands/tail/tail.test.ts @@ -1,7 +1,94 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./tail.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('tail command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should return error if file does not exist', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + const ctx = createMockContext(['nonexistent.txt'], {}, { resolvePath: resolvePathMock }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No such file or directory'); + }); + + it('should output default 10 lines for single file', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/file.txt'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return false; + if (code.includes('readFileSync')) return '1\n2\n3\n4\n5\n6\n7\n8\n9\n10'; + return ''; + }); + + const ctx = createMockContext(['file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('slice(-10)') + ); + expect(result.stdout).toBe('1\n2\n3\n4\n5\n6\n7\n8\n9\n10'); + }); + + it('should slice custom number of lines with -n', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/file.txt'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return false; + if (code.includes('readFileSync')) return '4\n5'; + return ''; + }); + + const ctx = createMockContext(['-n', '2', 'file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + ctx.opts.raw = ['-n', '2', 'file.txt']; + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('slice(-2)') + ); + expect(result.stdout).toBe('4\n5'); + }); + + it('should print headers for multiple files', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return false; + return 'content'; + }); + + const ctx = createMockContext(['file1.txt', 'file2.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('==> file1.txt <=='); + expect(result.stdout).toContain('==> file2.txt <=='); + expect(result.stdout).toContain('content'); + }); + + it('should return error if target is a directory', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace/dir'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return true; + }); + + const ctx = createMockContext(['dir'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Is a directory'); + }); +}); diff --git a/packages/bash/src/commands/touch/touch.handler.ts b/packages/bash/src/commands/touch/touch.handler.ts index a6896a6..e76b0fc 100644 --- a/packages/bash/src/commands/touch/touch.handler.ts +++ b/packages/bash/src/commands/touch/touch.handler.ts @@ -11,8 +11,11 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC const stderr: string[] = []; await Promise.all(opts.args.map(async (arg) => { - const parentFolder = arg.split('/').slice(-1).join('/'); + if (!arg) return; + const segments = arg.split('/'); + const parentFolder = segments.length > 1 ? segments.slice(0, -1).join('/') : '.'; + const parentFolderAbsolutePath = (await runtime.resolvePath(state, parentFolder)); if (!parentFolderAbsolutePath) { @@ -20,10 +23,14 @@ export const handler: CommandHandler = async ({ state, opts, runtime }: CommandC return; } - if (!await runtime.executeCode(state, `require('fs').existsSync('${arg}');`)) { - await runtime.executeCode(state, `require('fs').writeFileSync('${arg}', '');`) + const filename = segments[segments.length - 1]; + const absolutePath = `${parentFolderAbsolutePath}/${filename}`.replace('//', '/'); + + const exists = await runtime.executeCode(state, `return require('fs').existsSync('${absolutePath}');`) as boolean; + if (!exists) { + await runtime.executeCode(state, `require('fs').writeFileSync('${absolutePath}', '');`); } })) - return { stdout: '', stderr: stderr.join('\n'), exitCode: stderr.length > 0 ? 1 : 0 }; + return { stdout: 'File created ✔', stderr: stderr.join('\n'), exitCode: stderr.length > 0 ? 1 : 0 }; } diff --git a/packages/bash/src/commands/touch/touch.test.ts b/packages/bash/src/commands/touch/touch.test.ts index 868794b..0817263 100644 --- a/packages/bash/src/commands/touch/touch.test.ts +++ b/packages/bash/src/commands/touch/touch.test.ts @@ -1,7 +1,80 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./touch.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('touch command', () => { - it('should create a file', async () => { - expect(0).toBe(0); - }) -}) + it('should create a new file if it does not exist', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('existsSync')) return false; + return ''; + }); + + const ctx = createMockContext(['file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('File created'); + expect(executeCodeMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("writeFileSync('/workspace/file.txt', '')") + ); + }); + + it('should not overwrite an existing file', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('existsSync')) return true; + return ''; + }); + + const ctx = createMockContext(['file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(executeCodeMock).not.toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("writeFileSync('/workspace/file.txt'") + ); + }); + + it('should return error if parent directory does not exist', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + + const ctx = createMockContext(['bad/file.txt'], {}, { + resolvePath: resolvePathMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No such file or directory'); + }); + + it('should create multiple files correctly', async () => { + const resolvePathMock = vi.fn().mockImplementation(async () => '/workspace'); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('existsSync')) return false; + return ''; + }); + + const ctx = createMockContext(['file1.txt', 'file2.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(executeCodeMock).toHaveBeenCalledWith(expect.anything(), expect.stringContaining("writeFileSync('/workspace/file1.txt'")); + expect(executeCodeMock).toHaveBeenCalledWith(expect.anything(), expect.stringContaining("writeFileSync('/workspace/file2.txt'")); + }); +}); diff --git a/packages/bash/src/commands/wc/wc.handler.ts b/packages/bash/src/commands/wc/wc.handler.ts index 95b713e..ac97be9 100644 --- a/packages/bash/src/commands/wc/wc.handler.ts +++ b/packages/bash/src/commands/wc/wc.handler.ts @@ -18,8 +18,9 @@ interface Counts { } function count(content: string): Counts { + const lines = content.split('\n'); return { - lines: content === '' ? 0 : content.split('\n').length, + lines: lines.length - 1, words: content.trim() === '' ? 0 : content.trim().split(/\s+/).length, bytes: new TextEncoder().encode(content).length, }; @@ -37,7 +38,7 @@ export const handler: CommandHandler = async ({ opts, state, runtime, stdin }: C const showLines = opts.hasFlag('l'); const showWords = opts.hasFlag('w'); const showBytes = opts.hasFlag('c'); - // No flags = show all + const flags = { lines: showLines || (!showLines && !showWords && !showBytes), words: showWords || (!showLines && !showWords && !showBytes), @@ -48,7 +49,6 @@ export const handler: CommandHandler = async ({ opts, state, runtime, stdin }: C const stdout: string[] = []; const stderr: string[] = []; - // No files: count stdin if (fileArgs.length === 0) { const content = stdin ?? ''; stdout.push(format(count(content), flags, '')); diff --git a/packages/bash/src/commands/wc/wc.test.ts b/packages/bash/src/commands/wc/wc.test.ts index a736414..dc78ffc 100644 --- a/packages/bash/src/commands/wc/wc.test.ts +++ b/packages/bash/src/commands/wc/wc.test.ts @@ -1,7 +1,82 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { handler } from "./wc.handler"; +import { createMockContext } from "../../helpers/testUtils"; describe('wc command', () => { - it('placeholder for real tests', async () => { - expect(0).toBe(0); - }) -}) + it('should count from stdin if no file args provided', async () => { + const ctx = createMockContext([]); + ctx.stdin = 'hello world\nline 2\n'; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(' 2'); + expect(result.stdout).toContain(' 4'); + expect(result.stdout).toContain(' 19'); + }); + + it('should respect flags (-l, -w, -c)', async () => { + const ctx = createMockContext(['-l']); + ctx.stdin = 'hello world\nline 2\n'; + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(' 2'); + expect(result.stdout).not.toContain(' 4'); + expect(result.stdout).not.toContain(' 19'); + }); + + it('should count from a file', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('isDirectory')) return { isDirectory: false, content: 'file content\n' }; + return {}; + }); + + const ctx = createMockContext(['file.txt'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(' 1'); // 1 line + expect(result.stdout).toContain(' 2'); // 2 words + expect(result.stdout).toContain(' 13'); // 13 bytes + expect(result.stdout).toContain('file.txt'); + }); + + it('should handle multiple files and show total', async () => { + const resolvePathMock = vi.fn().mockImplementation(async (state, path) => `/workspace/${path}`); + const executeCodeMock = vi.fn().mockImplementation(async (state, code) => { + if (code.includes('file1')) return { isDirectory: false, content: 'content1\n' }; + if (code.includes('file2')) return { isDirectory: false, content: 'content2\nlonger\n' }; + return { isDirectory: false, content: '' }; + }); + + const ctx = createMockContext(['file1', 'file2'], {}, { + resolvePath: resolvePathMock, + executeCode: executeCodeMock + }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('file1'); + expect(result.stdout).toContain('file2'); + expect(result.stdout).toContain('total'); + expect(result.stdout).toContain(' 3 3 25 total'); + }); + + it('should return error if file does not exist', async () => { + const resolvePathMock = vi.fn().mockResolvedValue(undefined); + const ctx = createMockContext(['nonexistent'], {}, { resolvePath: resolvePathMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No such file or directory'); + }); +}); diff --git a/packages/bash/src/core/executor.ts b/packages/bash/src/core/executor.ts index 6b73dba..30e8601 100644 --- a/packages/bash/src/core/executor.ts +++ b/packages/bash/src/core/executor.ts @@ -10,6 +10,7 @@ import { parsedCommandOptions } from '../helpers/commandOptions'; import { displayCommandManual } from '../helpers/commandManual'; import type { BaseRuntime, CommandHandler, CommandManual, CommandResult, CustomCommand, State } from '@capsule-run/bash-types'; +import { Parser } from './parser'; import type { ASTNode, CommandNode } from './parser'; @@ -72,10 +73,60 @@ export class Executor { } } + private async executeScript(filePath: string, scriptArgs: string[]): Promise { + const absolutePath = await this.runtime.resolvePath(this.state, filePath); + if (!absolutePath) { + return { stdout: '', stderr: `bash: ${filePath}: No such file or directory`, exitCode: 1 }; + } + + let content: string; + try { + content = await this.runtime.executeCode(this.state, `require('fs').readFileSync('${absolutePath}', 'utf8');`) as string; + } catch { + return { stdout: '', stderr: `bash: sh: ${filePath}: No such file or directory`, exitCode: 1 }; + } + + const lines = content.split('\n').filter(line => !line.startsWith('#!')); + let script = lines.join('\n'); + + scriptArgs.forEach((arg, i) => { + script = script.replace(new RegExp(`\\$${i + 1}`, 'g'), arg); + }); + + script = script.replace(/\$\d+/g, ''); + + const parser = new Parser(); + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode = 0; + + for (const line of script.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + try { + const ast = parser.parse(trimmed); + const result = await this.execute(ast); + if (result.stdout) stdout.push(result.stdout); + if (result.stderr) stderr.push(result.stderr); + exitCode = result.exitCode; + if (exitCode !== 0) break; + } catch {} + } + + return { stdout: stdout.join('\n'), stderr: stderr.join('\n'), exitCode }; + } + private async executeCommand(node: CommandNode, stdin: string): Promise { const [name, ...args] = node.args; let result: CommandResult; + if (name === 'sh' || name === 'bash') { + const [file, ...scriptArgs] = args; + if (!file) return { stdout: '', stderr: `bash: ${name}: missing script operand`, exitCode: 1 }; + return this.executeScript(file, scriptArgs); + } + for (const r of node.redirects) { if (r.op === '<') { if (r.file === '/dev/null') { @@ -171,10 +222,8 @@ export class Executor { continue; } - console.log("writing to file", r.file) - try { - console.log(await this.runtime.executeCode(this.state, ` + await this.runtime.executeCode(this.state, ` const fs = require('fs'); const path = require('path'); const filePath = path.resolve(${JSON.stringify(r.file)}); @@ -182,9 +231,9 @@ export class Executor { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.${r.op === '>>' ? 'appendFileSync' : 'writeFileSync'}(filePath, ${JSON.stringify(currentStdout)}); return filePath; - `)); + `); - currentStdout = ''; + currentStdout = 'File created ✔'; } catch { return { stdout: '', stderr: `bash: ${r.file}: No such file or directory`, exitCode: 1 }; } diff --git a/packages/bash/vitest.config.ts b/packages/bash/vitest.config.ts index e5982de..96a55f4 100644 --- a/packages/bash/vitest.config.ts +++ b/packages/bash/vitest.config.ts @@ -1,9 +1,10 @@ -import { defineConfig, mergeConfig } from 'vitest/config' +import { defineConfig } from 'vitest/config' export default defineConfig({ test: { name: 'bash', environment: 'node', + testTimeout: 60_000, include: ['src/**/*.test.ts'], }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4782d48..4a41cf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: dependencies: '@capsule-run/cli': - specifier: ^0.8.7 - version: 0.8.7 + specifier: ^0.8.8 + version: 0.8.8 '@capsule-run/sdk': - specifier: ^0.8.7 - version: 0.8.7(@types/node@25.6.0) + specifier: ^0.8.8 + version: 0.8.8(@types/node@25.6.0) devDependencies: '@types/node': specifier: ^25.2.3 @@ -72,11 +72,11 @@ importers: specifier: workspace:* version: link:../bash-types '@capsule-run/cli': - specifier: ^0.8.7 - version: 0.8.7 + specifier: ^0.8.8 + version: 0.8.8 '@capsule-run/sdk': - specifier: ^0.8.7 - version: 0.8.7(@types/node@25.6.0) + specifier: ^0.8.8 + version: 0.8.8(@types/node@25.6.0) devDependencies: tsup: specifier: ^8.0.0 @@ -139,37 +139,37 @@ packages: engines: {node: '>=16'} hasBin: true - '@capsule-run/cli-darwin-arm64@0.8.7': - resolution: {integrity: sha512-sney8KObCF0FCWWUUGCGK0Y81wl6Gj/TNPf8oXoutzB8SJDjrb62U0jevDnD0yXq6NccGNhu8WxxKk7IAkt3xA==} + '@capsule-run/cli-darwin-arm64@0.8.8': + resolution: {integrity: sha512-7ymjs6KRw9rVtmnJVDIrDTivFCylCoaR5Ube86PmJCoofI9GN7NDW/09S/II3gEjqvL8s1l9RTs9vaOturJY/w==} cpu: [arm64] os: [darwin] hasBin: true - '@capsule-run/cli-darwin-x64@0.8.7': - resolution: {integrity: sha512-/WvwOw/BB024TGcV45g031v122HEBNapKlFRYaykTcSWHTQlhqOB1NrohZr5HWmrQXIrMmwrpM+yvtnXI/Ne/A==} + '@capsule-run/cli-darwin-x64@0.8.8': + resolution: {integrity: sha512-KaAOcxzJyqo0ySn7uYSvPgBYJ+d3O62bF6FJWSPui86UVdfOz3d6qPo5JTsUUF3OgU05hF8+0ZvYIYqpa1zpEg==} cpu: [x64] os: [darwin] hasBin: true - '@capsule-run/cli-linux-x64@0.8.7': - resolution: {integrity: sha512-axd4ejpbHearOaa5yf1nVWCBW0LOvMYK5IX14v9oQSRrIWgVDwQlszcmQqO/yvI6J6C7m6NrkVNHnsoPOX4blw==} + '@capsule-run/cli-linux-x64@0.8.8': + resolution: {integrity: sha512-03Z9cO4j/uukUk1M6ox62nQMDXVwuJ8DFYlTbUjLs+WKq1H3w4T+xjwLGS7NRJkVd+Gw8ZtcBRFVEcYMai7NMA==} cpu: [x64] os: [linux] hasBin: true - '@capsule-run/cli-win32-x64@0.8.7': - resolution: {integrity: sha512-v0ZFRumwrUnOG2JCnuSRYMhtKZT07PNkiE8tWk5Wixhm1Yzftpt8+xp4/Ia+VuujtXUSlRJImEotrBiLnezHqw==} + '@capsule-run/cli-win32-x64@0.8.8': + resolution: {integrity: sha512-RlQcfgeqR5fPqf0UOaVm8/jtPtMSoScMNnkk3KB9FFOELC59zHHgfkAkm11mOWzIUSCoJJNWHdCim8qOMSpTyg==} cpu: [x64] os: [win32] hasBin: true - '@capsule-run/cli@0.8.7': - resolution: {integrity: sha512-VlwPhG/+PP6faRsGLEGTvFr16FFL9VfXDdNel+u3/DsRTgKHZ6nuSZ55mqITrhbfUhekMCRSZi5nF3ZVXLBaIA==} + '@capsule-run/cli@0.8.8': + resolution: {integrity: sha512-DLd2202D/Iq2frgYejzCh/+qKsYWXKR+ZmhWcPKV08+arf5uPj9fQcUZ6elyu0qKi9HlfpJf0uSgMrP2JtEKnA==} engines: {node: '>=18'} hasBin: true - '@capsule-run/sdk@0.8.7': - resolution: {integrity: sha512-k/+d8DhdAuGL9BpC4PP9R15cA6uzVa+wa0uNZNJhXwc+wRx0S7yBrGiW2MQQjUWlvWnuv6pyZ5QYSOGOUkI1fw==} + '@capsule-run/sdk@0.8.8': + resolution: {integrity: sha512-ClZDyIlv3LnZw0C17ekVWNeBau8GTEz+XwEzX9YyKZeumuf+K7niLFtwleLKtp6M19NJuZl3c8BbJIWKY0WlOA==} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -1500,26 +1500,26 @@ snapshots: '@bytecodealliance/wizer-linux-x64': 10.0.0 '@bytecodealliance/wizer-win32-x64': 10.0.0 - '@capsule-run/cli-darwin-arm64@0.8.7': + '@capsule-run/cli-darwin-arm64@0.8.8': optional: true - '@capsule-run/cli-darwin-x64@0.8.7': + '@capsule-run/cli-darwin-x64@0.8.8': optional: true - '@capsule-run/cli-linux-x64@0.8.7': + '@capsule-run/cli-linux-x64@0.8.8': optional: true - '@capsule-run/cli-win32-x64@0.8.7': + '@capsule-run/cli-win32-x64@0.8.8': optional: true - '@capsule-run/cli@0.8.7': + '@capsule-run/cli@0.8.8': optionalDependencies: - '@capsule-run/cli-darwin-arm64': 0.8.7 - '@capsule-run/cli-darwin-x64': 0.8.7 - '@capsule-run/cli-linux-x64': 0.8.7 - '@capsule-run/cli-win32-x64': 0.8.7 + '@capsule-run/cli-darwin-arm64': 0.8.8 + '@capsule-run/cli-darwin-x64': 0.8.8 + '@capsule-run/cli-linux-x64': 0.8.8 + '@capsule-run/cli-win32-x64': 0.8.8 - '@capsule-run/sdk@0.8.7(@types/node@25.6.0)': + '@capsule-run/sdk@0.8.8(@types/node@25.6.0)': dependencies: '@bytecodealliance/jco': 1.17.6 esbuild: 0.27.7 diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 2b119bc..1cdceff 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - projects: ['packages/*', 'sandboxes/js'], + projects: ['packages/*'], + exclude: ['**/.capsule', ], }, })