mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
Revert "feat(shell): add $.trace for analyzing shell commands without execution (#25667)"
This reverts commit 6b5de25d8a.
This commit is contained in:
@@ -16,7 +16,6 @@ pub const BunObject = struct {
|
||||
pub const connect = toJSCallback(host_fn.wrapStaticMethod(api.Listener, "connect", false));
|
||||
pub const createParsedShellScript = toJSCallback(bun.shell.ParsedShellScript.createParsedShellScript);
|
||||
pub const createShellInterpreter = toJSCallback(bun.shell.Interpreter.createShellInterpreter);
|
||||
pub const traceShellScript = toJSCallback(bun.shell.TraceInterpreter.traceShellScript);
|
||||
pub const deflateSync = toJSCallback(JSZlib.deflateSync);
|
||||
pub const file = toJSCallback(WebCore.Blob.constructBunFile);
|
||||
pub const gunzipSync = toJSCallback(JSZlib.gunzipSync);
|
||||
@@ -158,7 +157,6 @@ pub const BunObject = struct {
|
||||
@export(&BunObject.connect, .{ .name = callbackName("connect") });
|
||||
@export(&BunObject.createParsedShellScript, .{ .name = callbackName("createParsedShellScript") });
|
||||
@export(&BunObject.createShellInterpreter, .{ .name = callbackName("createShellInterpreter") });
|
||||
@export(&BunObject.traceShellScript, .{ .name = callbackName("traceShellScript") });
|
||||
@export(&BunObject.deflateSync, .{ .name = callbackName("deflateSync") });
|
||||
@export(&BunObject.file, .{ .name = callbackName("file") });
|
||||
@export(&BunObject.gunzipSync, .{ .name = callbackName("gunzipSync") });
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
macro(spawn) \
|
||||
macro(spawnSync) \
|
||||
macro(stringWidth) \
|
||||
macro(traceShellScript) \
|
||||
macro(udpSocket) \
|
||||
macro(which) \
|
||||
macro(write) \
|
||||
|
||||
@@ -354,14 +354,12 @@ static JSValue constructBunShell(VM& vm, JSObject* bunObject)
|
||||
auto* globalObject = jsCast<Zig::GlobalObject*>(bunObject->globalObject());
|
||||
JSFunction* createParsedShellScript = JSFunction::create(vm, bunObject->globalObject(), 2, "createParsedShellScript"_s, BunObject_callback_createParsedShellScript, ImplementationVisibility::Private, NoIntrinsic);
|
||||
JSFunction* createShellInterpreterFunction = JSFunction::create(vm, bunObject->globalObject(), 1, "createShellInterpreter"_s, BunObject_callback_createShellInterpreter, ImplementationVisibility::Private, NoIntrinsic);
|
||||
JSFunction* traceShellScriptFunction = JSFunction::create(vm, bunObject->globalObject(), 1, "traceShellScript"_s, BunObject_callback_traceShellScript, ImplementationVisibility::Private, NoIntrinsic);
|
||||
JSC::JSFunction* createShellFn = JSC::JSFunction::create(vm, globalObject, shellCreateBunShellTemplateFunctionCodeGenerator(vm), globalObject);
|
||||
|
||||
auto scope = DECLARE_THROW_SCOPE(vm);
|
||||
auto args = JSC::MarkedArgumentBuffer();
|
||||
args.append(createShellInterpreterFunction);
|
||||
args.append(createParsedShellScript);
|
||||
args.append(traceShellScriptFunction);
|
||||
JSC::JSValue shell = JSC::call(globalObject, createShellFn, args, "BunShell"_s);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
|
||||
|
||||
@@ -1,35 +1,4 @@
|
||||
// Note: ShellTraceFlags interface documents the permission flag values returned
|
||||
// by $.trace operations. These are intentionally not exported as runtime values
|
||||
// to keep the trace API simple - users compare against numeric constants directly.
|
||||
// The values mirror standard Unix open(2) and access(2) flags.
|
||||
|
||||
interface ShellTraceOperation {
|
||||
/** Permission flags (octal integer, can be combined with |) */
|
||||
flags: number;
|
||||
/** Working directory at time of operation */
|
||||
cwd: string;
|
||||
/** Absolute path that would be accessed (for file/execute operations) */
|
||||
path?: string;
|
||||
/** Command name (for execute operations) */
|
||||
command?: string;
|
||||
/** Accumulated environment variables at this point in execution */
|
||||
env?: Record<string, string>;
|
||||
/** Which standard stream is being redirected: "stdin", "stdout", or "stderr" */
|
||||
stream?: "stdin" | "stdout" | "stderr";
|
||||
/** Command arguments for external commands (excluding command name) */
|
||||
args?: string[];
|
||||
/** True if operation contains non-statically-analyzable values (command substitution, $1, etc.) */
|
||||
dynamic?: true;
|
||||
}
|
||||
|
||||
interface ShellTraceResult {
|
||||
operations: ShellTraceOperation[];
|
||||
cwd: string;
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function createBunShellTemplateFunction(createShellInterpreter_, createParsedShellScript_, traceShellScript_) {
|
||||
export function createBunShellTemplateFunction(createShellInterpreter_, createParsedShellScript_) {
|
||||
const createShellInterpreter = createShellInterpreter_ as (
|
||||
resolve: (code: number, stdout: Buffer, stderr: Buffer) => void,
|
||||
reject: (code: number, stdout: Buffer, stderr: Buffer) => void,
|
||||
@@ -39,7 +8,6 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
|
||||
raw: string,
|
||||
args: string[],
|
||||
) => $ZigGeneratedClasses.ParsedShellScript;
|
||||
const traceShellScript = traceShellScript_ as (args: $ZigGeneratedClasses.ParsedShellScript) => ShellTraceResult;
|
||||
|
||||
function lazyBufferToHumanReadableString(this: Buffer) {
|
||||
return this.toString();
|
||||
@@ -380,22 +348,6 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
|
||||
BunShell[envSymbol] = defaultEnv;
|
||||
BunShell[throwsSymbol] = true;
|
||||
|
||||
// Trace function - analyzes shell script without running it
|
||||
function trace(first, ...rest): ShellTraceResult {
|
||||
if (first?.raw === undefined)
|
||||
throw new Error("Please use '$.trace' as a tagged template function: $.trace`cmd arg1 arg2`");
|
||||
const parsed_shell_script = createParsedShellScript(first.raw, rest);
|
||||
|
||||
const cwd = BunShell[cwdSymbol];
|
||||
const env = BunShell[envSymbol];
|
||||
|
||||
// cwd must be set before env or else it will be injected into env as "PWD=/"
|
||||
if (cwd) parsed_shell_script.setCwd(cwd);
|
||||
if (env) parsed_shell_script.setEnv(env);
|
||||
|
||||
return traceShellScript(parsed_shell_script);
|
||||
}
|
||||
|
||||
Object.defineProperties(BunShell, {
|
||||
Shell: {
|
||||
value: Shell,
|
||||
@@ -409,10 +361,6 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
|
||||
value: ShellError,
|
||||
enumerable: true,
|
||||
},
|
||||
trace: {
|
||||
value: trace,
|
||||
enumerable: true,
|
||||
},
|
||||
});
|
||||
|
||||
return BunShell;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -723,68 +723,6 @@ test Lexer {
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level helper that expands brace patterns in a string.
|
||||
/// Returns a list of expanded strings. Caller owns the returned memory.
|
||||
/// On error or if no expansion is needed, returns the input as a single-element list.
|
||||
pub fn expandBracesAlloc(input: []const u8, allocator: Allocator) std.ArrayListUnmanaged([]const u8) {
|
||||
var out: std.ArrayListUnmanaged([]const u8) = .{};
|
||||
|
||||
// Use arena for temporary tokenization
|
||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena.deinit();
|
||||
const arena_alloc = arena.allocator();
|
||||
|
||||
// Tokenize - use appropriate lexer based on content
|
||||
const lexer_output = if (bun.strings.isAllASCII(input))
|
||||
Lexer.tokenize(arena_alloc, input) catch {
|
||||
out.append(allocator, allocator.dupe(u8, input) catch return out) catch {};
|
||||
return out;
|
||||
}
|
||||
else
|
||||
NewLexer(.wtf8).tokenize(arena_alloc, input) catch {
|
||||
out.append(allocator, allocator.dupe(u8, input) catch return out) catch {};
|
||||
return out;
|
||||
};
|
||||
|
||||
const expansion_count = calculateExpandedAmount(lexer_output.tokens.items[0..]);
|
||||
if (expansion_count == 0) {
|
||||
out.append(allocator, allocator.dupe(u8, input) catch return out) catch {};
|
||||
return out;
|
||||
}
|
||||
|
||||
// Allocate expanded strings
|
||||
const expanded_strings = arena_alloc.alloc(std.array_list.Managed(u8), expansion_count) catch {
|
||||
out.append(allocator, allocator.dupe(u8, input) catch return out) catch {};
|
||||
return out;
|
||||
};
|
||||
|
||||
for (0..expansion_count) |i| {
|
||||
expanded_strings[i] = std.array_list.Managed(u8).init(allocator);
|
||||
}
|
||||
|
||||
// Perform brace expansion
|
||||
expand(
|
||||
arena_alloc,
|
||||
lexer_output.tokens.items[0..],
|
||||
expanded_strings,
|
||||
lexer_output.contains_nested,
|
||||
) catch {
|
||||
for (expanded_strings) |*s| s.deinit();
|
||||
out.append(allocator, allocator.dupe(u8, input) catch return out) catch {};
|
||||
return out;
|
||||
};
|
||||
|
||||
// Collect results
|
||||
for (expanded_strings) |*s| {
|
||||
const slice = s.toOwnedSlice() catch "";
|
||||
if (slice.len > 0) {
|
||||
out.append(allocator, slice) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const SmolStr = @import("../string.zig").SmolStr;
|
||||
|
||||
const Encoding = @import("./shell.zig").StringEncoding;
|
||||
|
||||
@@ -2,7 +2,6 @@ pub const interpret = @import("./interpreter.zig");
|
||||
pub const subproc = @import("./subproc.zig");
|
||||
|
||||
pub const AllocScope = @import("./AllocScope.zig");
|
||||
pub const TraceInterpreter = @import("./TraceInterpreter.zig");
|
||||
|
||||
pub const EnvMap = interpret.EnvMap;
|
||||
pub const EnvStr = interpret.EnvStr;
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
import { $ } from "bun";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { tempDir } from "harness";
|
||||
|
||||
// Normalize path separators for cross-platform tests
|
||||
const normalizePath = (p: string) => p.replaceAll("\\", "/");
|
||||
|
||||
// Permission flags (octal) - mirrors the Zig constants
|
||||
const Permission = {
|
||||
O_RDONLY: 0o0,
|
||||
O_WRONLY: 0o1,
|
||||
O_RDWR: 0o2,
|
||||
O_CREAT: 0o100,
|
||||
O_EXCL: 0o200,
|
||||
O_TRUNC: 0o1000,
|
||||
O_APPEND: 0o2000,
|
||||
X_OK: 0o100000,
|
||||
DELETE: 0o200000,
|
||||
MKDIR: 0o400000,
|
||||
CHDIR: 0o1000000,
|
||||
ENV: 0o2000000,
|
||||
} as const;
|
||||
|
||||
// Convenience combinations
|
||||
const READ = Permission.O_RDONLY;
|
||||
const WRITE = Permission.O_WRONLY;
|
||||
const CREATE = Permission.O_CREAT | Permission.O_WRONLY;
|
||||
const CREATE_TRUNC = Permission.O_CREAT | Permission.O_TRUNC | Permission.O_WRONLY;
|
||||
const APPEND = Permission.O_APPEND | Permission.O_WRONLY;
|
||||
const EXECUTE = Permission.X_OK;
|
||||
|
||||
describe("Bun.$.trace", () => {
|
||||
test("returns trace result object", () => {
|
||||
const result = $.trace`echo hello`;
|
||||
expect(result).toHaveProperty("operations");
|
||||
expect(result).toHaveProperty("cwd");
|
||||
expect(result).toHaveProperty("success");
|
||||
expect(result).toHaveProperty("error");
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeNull();
|
||||
expect(Array.isArray(result.operations)).toBe(true);
|
||||
});
|
||||
|
||||
test("traces echo command (builtin, no file access)", () => {
|
||||
const result = $.trace`echo hello world`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// echo is a builtin that runs in-process - no file access, no operations
|
||||
// It just writes to stdout (terminal) which doesn't require any permissions
|
||||
expect(result.operations.length).toBe(0);
|
||||
});
|
||||
|
||||
test("traces cat command with file read", () => {
|
||||
const result = $.trace`cat /tmp/test.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// cat is a builtin - it reads files but runs in-process (no EXECUTE)
|
||||
const readOps = result.operations.filter(op => op.flags === READ && op.path?.endsWith("test.txt"));
|
||||
expect(readOps.length).toBe(1);
|
||||
expect(normalizePath(readOps[0].path!)).toBe("/tmp/test.txt");
|
||||
});
|
||||
|
||||
test("traces rm command with delete permission", () => {
|
||||
const result = $.trace`rm /tmp/to-delete.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should have delete for the file
|
||||
const deleteOps = result.operations.filter(op => op.flags === Permission.DELETE);
|
||||
expect(deleteOps.length).toBe(1);
|
||||
expect(normalizePath(deleteOps[0].path!)).toBe("/tmp/to-delete.txt");
|
||||
});
|
||||
|
||||
test("traces mkdir command", () => {
|
||||
const result = $.trace`mkdir /tmp/newdir`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should have mkdir permission
|
||||
const mkdirOps = result.operations.filter(op => op.flags === Permission.MKDIR);
|
||||
expect(mkdirOps.length).toBe(1);
|
||||
expect(normalizePath(mkdirOps[0].path!)).toBe("/tmp/newdir");
|
||||
});
|
||||
|
||||
test("traces touch command with create permission", () => {
|
||||
const result = $.trace`touch /tmp/newfile.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should have create permission
|
||||
const createOps = result.operations.filter(op => op.flags === CREATE);
|
||||
expect(createOps.length).toBe(1);
|
||||
expect(normalizePath(createOps[0].path!)).toBe("/tmp/newfile.txt");
|
||||
});
|
||||
|
||||
test("traces cp command with read and write", () => {
|
||||
const result = $.trace`cp /tmp/src.txt /tmp/dst.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should have read for source
|
||||
const readOps = result.operations.filter(op => op.flags === READ && op.path?.endsWith("src.txt"));
|
||||
expect(readOps.length).toBe(1);
|
||||
|
||||
// Should have create for destination
|
||||
const writeOps = result.operations.filter(op => op.flags === CREATE && op.path?.endsWith("dst.txt"));
|
||||
expect(writeOps.length).toBe(1);
|
||||
});
|
||||
|
||||
test("traces mv command with read, delete, and write", () => {
|
||||
const result = $.trace`mv /tmp/old.txt /tmp/new.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should have read+delete for source (combined in one operation)
|
||||
const srcOps = result.operations.filter(
|
||||
op => op.flags === (READ | Permission.DELETE) && op.path?.endsWith("old.txt"),
|
||||
);
|
||||
expect(srcOps.length).toBe(1);
|
||||
|
||||
// Should have create for destination
|
||||
const dstOps = result.operations.filter(op => op.flags === CREATE && op.path?.endsWith("new.txt"));
|
||||
expect(dstOps.length).toBe(1);
|
||||
});
|
||||
|
||||
test("traces cd command with chdir permission", () => {
|
||||
const result = $.trace`cd /tmp`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const chdirOps = result.operations.filter(op => op.flags === Permission.CHDIR);
|
||||
expect(chdirOps.length).toBe(1);
|
||||
expect(normalizePath(chdirOps[0].path!)).toBe("/tmp");
|
||||
});
|
||||
|
||||
test("traces environment variable assignments with accumulated env", () => {
|
||||
const result = $.trace`FOO=1 BAR=2 echo test`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const envOps = result.operations.filter(op => op.flags === Permission.ENV);
|
||||
expect(envOps.length).toBe(2);
|
||||
// First op has FOO
|
||||
expect(envOps[0].env).toEqual({ FOO: "1" });
|
||||
// Second op has both FOO and BAR
|
||||
expect(envOps[1].env?.FOO).toBe("1");
|
||||
expect(envOps[1].env?.BAR).toBe("2");
|
||||
});
|
||||
|
||||
test("traces export with env values", () => {
|
||||
const result = $.trace`export FOO=hello BAR=world`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const envOps = result.operations.filter(op => op.flags === Permission.ENV);
|
||||
expect(envOps.length).toBe(1);
|
||||
expect(envOps[0].env?.FOO).toBe("hello");
|
||||
expect(envOps[0].env?.BAR).toBe("world");
|
||||
});
|
||||
|
||||
test("traces output redirection combined with command", () => {
|
||||
const result = $.trace`echo hello > /tmp/output.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// echo is a builtin - redirect creates the output file (CREATE_TRUNC, no EXECUTE)
|
||||
const redirectOps = result.operations.filter(op => op.flags === CREATE_TRUNC && op.path?.endsWith("output.txt"));
|
||||
expect(redirectOps.length).toBe(1);
|
||||
});
|
||||
|
||||
test("traces append redirection combined with command", () => {
|
||||
const result = $.trace`echo hello >> /tmp/append.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// echo is a builtin - append redirect opens file for appending (no EXECUTE)
|
||||
const appendOps = result.operations.filter(op => op.flags === APPEND && op.path?.endsWith("append.txt"));
|
||||
expect(appendOps.length).toBe(1);
|
||||
});
|
||||
|
||||
test("traces input redirection with read and stdin stream", () => {
|
||||
const result = $.trace`cat < /tmp/input.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should have read for input file with stdin stream marker
|
||||
const stdinOps = result.operations.filter(
|
||||
op => op.flags === READ && op.path?.endsWith("input.txt") && op.stream === "stdin",
|
||||
);
|
||||
expect(stdinOps.length).toBe(1);
|
||||
});
|
||||
|
||||
test("traces stderr redirection with stream marker", () => {
|
||||
const result = $.trace`cat /nonexistent 2> /tmp/err.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should have stderr stream for error redirect
|
||||
const stderrOps = result.operations.filter(op => op.stream === "stderr" && op.path?.endsWith("err.txt"));
|
||||
expect(stderrOps.length).toBe(1);
|
||||
expect(stderrOps[0].flags).toBe(CREATE_TRUNC);
|
||||
});
|
||||
|
||||
test("stdout redirect has stream marker", () => {
|
||||
const result = $.trace`echo hello > /tmp/out.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const stdoutOps = result.operations.filter(op => op.stream === "stdout");
|
||||
expect(stdoutOps.length).toBe(1);
|
||||
expect(normalizePath(stdoutOps[0].path!)).toBe("/tmp/out.txt");
|
||||
});
|
||||
|
||||
test("traces export command with env permission", () => {
|
||||
const result = $.trace`export FOO=bar`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const envOps = result.operations.filter(op => op.flags === Permission.ENV);
|
||||
expect(envOps.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("traces variable assignment with env permission", () => {
|
||||
const result = $.trace`FOO=bar echo $FOO`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const envOps = result.operations.filter(op => op.flags === Permission.ENV);
|
||||
expect(envOps.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("traces pipeline", () => {
|
||||
const result = $.trace`cat /tmp/file.txt | grep pattern`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// cat is a builtin - reads file (no EXECUTE, no command field)
|
||||
const readOps = result.operations.filter(op => op.flags === READ && op.path?.endsWith("file.txt"));
|
||||
expect(readOps.length).toBe(1);
|
||||
|
||||
// grep is external, should have execute permission and command field
|
||||
const grepOps = result.operations.filter(op => op.command === "grep" && (op.flags & EXECUTE) !== 0);
|
||||
expect(grepOps.length).toBe(1);
|
||||
});
|
||||
|
||||
test("traces ls with directory read", () => {
|
||||
const result = $.trace`ls /tmp`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const readOps = result.operations.filter(op => op.flags === READ && normalizePath(op.path || "") === "/tmp");
|
||||
expect(readOps.length).toBe(1);
|
||||
});
|
||||
|
||||
test("traces ls without args (current dir)", () => {
|
||||
const result = $.trace`ls`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should read current directory (.)
|
||||
const readOps = result.operations.filter(op => op.flags === READ);
|
||||
expect(readOps.length).toBe(1);
|
||||
});
|
||||
|
||||
test("includes cwd in result", () => {
|
||||
const result = $.trace`echo test`;
|
||||
expect(result.cwd).toBeTruthy();
|
||||
expect(typeof result.cwd).toBe("string");
|
||||
});
|
||||
|
||||
test("includes cwd in each operation", () => {
|
||||
const result = $.trace`cat /tmp/test.txt`;
|
||||
for (const op of result.operations) {
|
||||
expect(op.cwd).toBeTruthy();
|
||||
expect(typeof op.cwd).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("handles template literal interpolation", () => {
|
||||
const filename = "test.txt";
|
||||
const result = $.trace`cat /tmp/${filename}`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const readOps = result.operations.filter(op => op.flags === READ && op.path?.endsWith("test.txt"));
|
||||
expect(readOps.length).toBe(1);
|
||||
});
|
||||
|
||||
test("does not actually execute commands", () => {
|
||||
// This would fail if it actually ran, since the file doesn't exist
|
||||
const result = $.trace`cat /nonexistent/path/that/does/not/exist.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.operations.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("external command resolves path when available", () => {
|
||||
// Use a cross-platform external command
|
||||
const cmd = process.platform === "win32" ? "cmd" : "/bin/ls";
|
||||
const result = $.trace`${cmd} --version`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const execOps = result.operations.filter(op => op.flags === EXECUTE);
|
||||
expect(execOps.length).toBeGreaterThan(0);
|
||||
// Command name should be captured
|
||||
expect(execOps[0].command).toBe(cmd);
|
||||
});
|
||||
|
||||
test("external commands include args array", () => {
|
||||
const result = $.trace`grep -r 'pattern' src/`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const execOps = result.operations.filter(op => op.flags === EXECUTE);
|
||||
expect(execOps.length).toBe(1);
|
||||
expect(execOps[0].command).toBe("grep");
|
||||
expect(execOps[0].args).toEqual(["-r", "pattern", "src/"]);
|
||||
});
|
||||
|
||||
test("pipeline commands each have their own args", () => {
|
||||
const result = $.trace`git diff HEAD^ -- src/ | head -100`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const execOps = result.operations.filter(op => op.flags === EXECUTE);
|
||||
expect(execOps.length).toBe(2);
|
||||
|
||||
expect(execOps[0].command).toBe("git");
|
||||
expect(execOps[0].args).toEqual(["diff", "HEAD^", "--", "src/"]);
|
||||
|
||||
expect(execOps[1].command).toBe("head");
|
||||
expect(execOps[1].args).toEqual(["-100"]);
|
||||
});
|
||||
|
||||
test("builtins do not have args (tracked as file operations)", () => {
|
||||
const result = $.trace`cat file1.txt file2.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Builtins track files, not args
|
||||
const readOps = result.operations.filter(op => op.flags === READ);
|
||||
expect(readOps.length).toBe(2);
|
||||
expect(readOps[0].args).toBeUndefined();
|
||||
expect(readOps[1].args).toBeUndefined();
|
||||
});
|
||||
|
||||
test("traces && (and) operator", () => {
|
||||
const result = $.trace`cat /tmp/a.txt && cat /tmp/b.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Both commands should be traced
|
||||
const readOps = result.operations.filter(op => op.flags === READ);
|
||||
expect(readOps.length).toBe(2);
|
||||
expect(normalizePath(readOps[0].path!)).toBe("/tmp/a.txt");
|
||||
expect(normalizePath(readOps[1].path!)).toBe("/tmp/b.txt");
|
||||
});
|
||||
|
||||
test("traces || (or) operator", () => {
|
||||
const result = $.trace`cat /tmp/a.txt || cat /tmp/b.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Both commands should be traced
|
||||
const readOps = result.operations.filter(op => op.flags === READ);
|
||||
expect(readOps.length).toBe(2);
|
||||
});
|
||||
|
||||
test("traces subshell with cwd isolation", () => {
|
||||
const result = $.trace`(cd /tmp && ls) && ls`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should have: CHDIR /tmp, READ /tmp (inside subshell), READ . (outside subshell)
|
||||
const chdirOps = result.operations.filter(op => op.flags === Permission.CHDIR);
|
||||
expect(chdirOps.length).toBe(1);
|
||||
expect(normalizePath(chdirOps[0].path!)).toBe("/tmp");
|
||||
|
||||
const readOps = result.operations.filter(op => op.flags === READ);
|
||||
expect(readOps.length).toBe(2);
|
||||
// First ls inside subshell should see /tmp
|
||||
expect(normalizePath(readOps[0].cwd!)).toBe("/tmp");
|
||||
// Second ls outside subshell should see original cwd (subshell cwd is restored)
|
||||
expect(normalizePath(readOps[1].cwd!)).not.toBe("/tmp");
|
||||
});
|
||||
|
||||
test("cd updates cwd for subsequent commands", () => {
|
||||
const result = $.trace`cd /tmp && ls`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const readOps = result.operations.filter(op => op.flags === READ);
|
||||
expect(readOps.length).toBe(1);
|
||||
expect(normalizePath(readOps[0].cwd!)).toBe("/tmp");
|
||||
expect(normalizePath(readOps[0].path!)).toBe("/tmp"); // ls reads cwd
|
||||
});
|
||||
|
||||
test("expands brace patterns", () => {
|
||||
const result = $.trace`cat /tmp/{a,b,c}.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const readOps = result.operations.filter(op => op.flags === READ);
|
||||
expect(readOps.length).toBe(3);
|
||||
expect(normalizePath(readOps[0].path!)).toBe("/tmp/a.txt");
|
||||
expect(normalizePath(readOps[1].path!)).toBe("/tmp/b.txt");
|
||||
expect(normalizePath(readOps[2].path!)).toBe("/tmp/c.txt");
|
||||
});
|
||||
|
||||
test("expands tilde to home directory", () => {
|
||||
const result = $.trace`cat ~/.config/test.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const readOps = result.operations.filter(op => op.flags === READ);
|
||||
expect(readOps.length).toBe(1);
|
||||
expect(readOps[0].path).not.toContain("~");
|
||||
// Home directory path varies by platform
|
||||
if (process.platform === "win32") {
|
||||
// Windows uses USERPROFILE which expands to something like C:\Users\username
|
||||
expect(readOps[0].path).toMatch(/\.config[/\\]test\.txt$/);
|
||||
} else {
|
||||
expect(readOps[0].path).toContain(".config/test.txt");
|
||||
}
|
||||
});
|
||||
|
||||
test("expands glob patterns to matching files", () => {
|
||||
// Create test files for glob expansion using tempDir helper
|
||||
const { join } = require("path");
|
||||
using dir = tempDir("trace-glob-test", {
|
||||
"a.txt": "",
|
||||
"b.txt": "",
|
||||
"c.txt": "",
|
||||
});
|
||||
const testDir = String(dir);
|
||||
|
||||
const result = $.trace`cat ${testDir}/*.txt`;
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const readOps = result.operations.filter(op => op.flags === READ);
|
||||
expect(readOps.length).toBe(3);
|
||||
const paths = readOps.map(op => normalizePath(op.path!)).sort();
|
||||
const expected = [join(testDir, "a.txt"), join(testDir, "b.txt"), join(testDir, "c.txt")].map(normalizePath);
|
||||
expect(paths).toEqual(expected);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user