227 lines
7.0 KiB
TypeScript
227 lines
7.0 KiB
TypeScript
import { PassThrough, Readable } from 'node:stream'
|
|
import { describe, expect, test } from 'vitest'
|
|
|
|
import {
|
|
buildCommand,
|
|
chunkBytesBySize,
|
|
isPipedStdin,
|
|
readStdinIfPiped,
|
|
readStdinFrom,
|
|
streamStdinChunks,
|
|
shellQuote,
|
|
} from '../../../src/commands/sandbox/exec_helpers'
|
|
|
|
describe('exec helpers', () => {
|
|
test('shellQuote leaves safe args untouched', () => {
|
|
expect(shellQuote('python3')).toBe('python3')
|
|
expect(shellQuote('-c')).toBe('-c')
|
|
expect(shellQuote('path/to/file.txt')).toBe('path/to/file.txt')
|
|
})
|
|
|
|
test('shellQuote wraps special chars and spaces', () => {
|
|
expect(shellQuote('print(input())')).toBe("'print(input())'")
|
|
expect(shellQuote('hello world')).toBe("'hello world'")
|
|
expect(shellQuote("it's ok")).toBe("'it'\"'\"'s ok'")
|
|
expect(shellQuote('')).toBe("''")
|
|
})
|
|
|
|
test('buildCommand returns a single command as-is', () => {
|
|
const cmd = 'python3 -c "print(input())"'
|
|
expect(buildCommand([cmd])).toBe(cmd)
|
|
})
|
|
|
|
test('buildCommand quotes args that need shell escaping', () => {
|
|
expect(buildCommand(['python3', '-c', 'print(input())'])).toBe(
|
|
"python3 -c 'print(input())'"
|
|
)
|
|
expect(buildCommand(['echo', 'hello world'])).toBe("echo 'hello world'")
|
|
expect(buildCommand(['echo', "it's ok"])).toBe("echo 'it'\"'\"'s ok'")
|
|
})
|
|
|
|
test('readStdinFrom reads full input and resolves on EOF', async () => {
|
|
const stream = Readable.from(['foo', 'bar'])
|
|
await expect(readStdinFrom(stream)).resolves.toEqual(Buffer.from('foobar'))
|
|
})
|
|
|
|
test('readStdinFrom handles EOF without trailing newline', async () => {
|
|
const stream = Readable.from(['no-newline'])
|
|
await expect(readStdinFrom(stream)).resolves.toEqual(
|
|
Buffer.from('no-newline')
|
|
)
|
|
})
|
|
|
|
test('readStdinIfPiped returns undefined when stdin is not a pipe', async () => {
|
|
const fsMock = {
|
|
fstatSync: () => ({ isFIFO: () => false }),
|
|
}
|
|
const stream = new PassThrough()
|
|
stream.end('data')
|
|
await expect(readStdinIfPiped({ fsModule: fsMock, stream })).resolves.toBe(
|
|
undefined
|
|
)
|
|
})
|
|
|
|
test('readStdinIfPiped reads from provided stream when piped', async () => {
|
|
const fsMock = {
|
|
fstatSync: () => ({ isFIFO: () => true }),
|
|
}
|
|
const stream = new PassThrough()
|
|
const promise = readStdinIfPiped({ fsModule: fsMock, stream })
|
|
stream.write(Buffer.from([0xe2, 0x98]))
|
|
stream.write(Buffer.from([0x83]))
|
|
stream.end(Buffer.from([0x21]))
|
|
await expect(promise).resolves.toEqual(Buffer.from([0xe2, 0x98, 0x83, 0x21]))
|
|
})
|
|
|
|
test('isPipedStdin returns true for FIFO', () => {
|
|
const fsMock = {
|
|
fstatSync: () => ({ isFIFO: () => true, isCharacterDevice: () => false }),
|
|
}
|
|
expect(isPipedStdin(0, fsMock)).toBe(true)
|
|
})
|
|
|
|
test('isPipedStdin returns true for file redirection', () => {
|
|
const fsMock = {
|
|
fstatSync: () => ({ isFile: () => true, isCharacterDevice: () => false }),
|
|
}
|
|
expect(isPipedStdin(0, fsMock)).toBe(true)
|
|
})
|
|
|
|
test('isPipedStdin returns false for interactive terminal', () => {
|
|
const fsMock = {
|
|
fstatSync: () => ({
|
|
isCharacterDevice: () => true,
|
|
isFIFO: () => false,
|
|
isFile: () => false,
|
|
}),
|
|
}
|
|
expect(isPipedStdin(0, fsMock)).toBe(false)
|
|
})
|
|
|
|
test('isPipedStdin returns false for non-FIFO or errors', () => {
|
|
const fsMockFalse = {
|
|
fstatSync: () => ({ isFIFO: () => false, isCharacterDevice: () => false }),
|
|
}
|
|
expect(isPipedStdin(0, fsMockFalse)).toBe(false)
|
|
|
|
const fsMockThrow = {
|
|
fstatSync: () => {
|
|
throw new Error('fail')
|
|
},
|
|
}
|
|
expect(isPipedStdin(0, fsMockThrow)).toBe(false)
|
|
})
|
|
|
|
test('chunkBytesBySize splits large input into byte-sized chunks', () => {
|
|
const maxBytes = 64 * 1024
|
|
const data = Buffer.from('a'.repeat(maxBytes * 2 + 1))
|
|
const chunks = chunkBytesBySize(data, maxBytes)
|
|
|
|
expect(chunks).toHaveLength(3)
|
|
expect(chunks[0].byteLength).toBe(maxBytes)
|
|
expect(chunks[1].byteLength).toBe(maxBytes)
|
|
expect(chunks[2].byteLength).toBe(1)
|
|
expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual(data)
|
|
})
|
|
|
|
test('chunkBytesBySize keeps byte content intact', () => {
|
|
const maxBytes = 64 * 1024
|
|
const data = Buffer.from('\u{1F600}'.repeat(20000)) // 😀 (4 bytes each)
|
|
const chunks = chunkBytesBySize(data, maxBytes)
|
|
|
|
for (const chunk of chunks) {
|
|
expect(chunk.byteLength).toBeLessThanOrEqual(maxBytes)
|
|
}
|
|
expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual(data)
|
|
})
|
|
|
|
test('chunkBytesBySize returns empty array for empty input', () => {
|
|
const chunks = chunkBytesBySize(Buffer.alloc(0), 64 * 1024)
|
|
expect(chunks).toHaveLength(0)
|
|
})
|
|
|
|
test('chunkBytesBySize returns single chunk for small input', () => {
|
|
const data = Buffer.from('hello')
|
|
const chunks = chunkBytesBySize(data, 64 * 1024)
|
|
expect(chunks).toHaveLength(1)
|
|
expect(Buffer.from(chunks[0])).toEqual(data)
|
|
})
|
|
|
|
test('chunkBytesBySize throws on invalid maxBytes', () => {
|
|
expect(() => chunkBytesBySize(Buffer.from('data'), 0)).toThrow()
|
|
expect(() => chunkBytesBySize(Buffer.from('data'), -1)).toThrow()
|
|
})
|
|
|
|
test('readStdinFrom resolves with empty buffer on immediate EOF', async () => {
|
|
const stream = Readable.from([])
|
|
await expect(readStdinFrom(stream)).resolves.toEqual(Buffer.alloc(0))
|
|
})
|
|
|
|
test('streamStdinChunks delivers chunks incrementally before EOF', async () => {
|
|
const stream = new PassThrough()
|
|
const seen: Buffer[] = []
|
|
let resolveFirstChunk: (() => void) | undefined
|
|
const firstChunkSeen = new Promise<void>((resolve) => {
|
|
resolveFirstChunk = resolve
|
|
})
|
|
const done = streamStdinChunks(
|
|
stream,
|
|
async (chunk) => {
|
|
seen.push(Buffer.from(chunk))
|
|
if (resolveFirstChunk) {
|
|
resolveFirstChunk()
|
|
resolveFirstChunk = undefined
|
|
}
|
|
},
|
|
64 * 1024
|
|
)
|
|
|
|
stream.write('first')
|
|
await firstChunkSeen
|
|
|
|
expect(seen).toEqual([Buffer.from('first')])
|
|
|
|
stream.end('second')
|
|
await done
|
|
|
|
expect(seen).toEqual([Buffer.from('first'), Buffer.from('second')])
|
|
})
|
|
|
|
test('streamStdinChunks splits oversized stream chunks by max bytes', async () => {
|
|
const stream = Readable.from([Buffer.from('a'.repeat(64 * 1024 + 3))])
|
|
const chunks: Uint8Array[] = []
|
|
|
|
await streamStdinChunks(
|
|
stream,
|
|
async (chunk) => {
|
|
chunks.push(chunk)
|
|
},
|
|
64 * 1024
|
|
)
|
|
|
|
expect(chunks).toHaveLength(2)
|
|
expect(chunks[0].byteLength).toBe(64 * 1024)
|
|
expect(chunks[1].byteLength).toBe(3)
|
|
expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual(
|
|
Buffer.from('a'.repeat(64 * 1024 + 3))
|
|
)
|
|
})
|
|
|
|
test('streamStdinChunks stops early when onChunk returns false', async () => {
|
|
const stream = Readable.from([Buffer.from('first'), Buffer.from('second')])
|
|
const chunks: Uint8Array[] = []
|
|
|
|
await streamStdinChunks(
|
|
stream,
|
|
async (chunk) => {
|
|
chunks.push(chunk)
|
|
return false
|
|
},
|
|
64 * 1024
|
|
)
|
|
|
|
expect(chunks).toHaveLength(1)
|
|
expect(Buffer.from(chunks[0])).toEqual(Buffer.from('first'))
|
|
})
|
|
})
|