import { afterAll, describe, expect, it } from 'vitest'; import { readdir, readFile, rm } from 'fs/promises'; import path from 'path'; import { buildStreamInvokeResponse, DEBUG_UPLOAD_DIR, safeUploadName, streamUploadToDisk, uploadDestPath, } from '../src/webui/debug-tools'; const written: string[] = []; afterAll(async () => { await Promise.all(written.map((p) => rm(p, { force: true }).catch(() => {}))); }); function streamOf(...chunks: Uint8Array[]): ReadableStream { let i = 0; return new ReadableStream({ pull(controller) { if (i < chunks.length) { controller.enqueue(chunks[i++]); } else { controller.close(); } }, }); } async function readSse(res: Response): Promise { const text = await res.text(); return text .split('\n\n') .filter((s) => s.startsWith('data: ')) .map((s) => s.slice('data: '.length)); } describe('safeUploadName', () => { it('strips path traversal and separators to a bare basename', () => { expect(safeUploadName('../../etc/passwd')).toBe('passwd'); expect(safeUploadName('/abs/secret.key')).toBe('secret.key'); expect(safeUploadName('a\\b\\c.png')).toBe('c.png'); expect(safeUploadName('..')).toBe('file'); // leading dots stripped → empty → fallback expect(safeUploadName('...hidden')).toBe('hidden'); }); it('collapses exotic chars and falls back for empties', () => { expect(safeUploadName('my pic!@#.jpg')).toBe('my_pic_.jpg'); expect(safeUploadName('')).toBe('file'); expect(safeUploadName(undefined)).toBe('file'); }); it('keeps the extension and caps length', () => { const long = 'x'.repeat(500) + '.mp4'; const out = safeUploadName(long); expect(out.length).toBeLessThanOrEqual(120); expect(out.startsWith('x')).toBe(true); }); }); describe('uploadDestPath', () => { it('uses a neutral temporary directory name', () => { expect(path.basename(DEBUG_UPLOAD_DIR)).toBe('webui-upload'); }); it('produces a unique path inside the upload dir', () => { const a = uploadDestPath('pic.png'); const b = uploadDestPath('pic.png'); expect(a).not.toBe(b); // random id prefix expect(path.dirname(a)).toBe(DEBUG_UPLOAD_DIR); expect(a.endsWith('__pic.png')).toBe(true); const rel = path.relative(DEBUG_UPLOAD_DIR, a); expect(rel.startsWith('..')).toBe(false); expect(path.isAbsolute(rel)).toBe(false); }); }); describe('streamUploadToDisk', () => { it('streams bytes to disk and reports the size', async () => { const data = new TextEncoder().encode('hello debug upload'); const res = await streamUploadToDisk(streamOf(data.slice(0, 5), data.slice(5)), 'note.txt'); written.push(res.path); expect(res.size).toBe(data.byteLength); expect(path.dirname(res.path)).toBe(DEBUG_UPLOAD_DIR); expect(await readFile(res.path, 'utf8')).toBe('hello debug upload'); }); it('rejects and removes the partial file when the cap is exceeded', async () => { const big = new Uint8Array(64); await expect( streamUploadToDisk(streamOf(big, big), 'big.bin', { maxBytes: 100 }), ).rejects.toThrow(/大小上限/); // the partial must not survive — no leftover `__big.bin` in the dir const names = await readdir(DEBUG_UPLOAD_DIR).catch(() => [] as string[]); expect(names.filter((n) => n.endsWith('__big.bin'))).toEqual([]); }); it('rejects an empty body', async () => { await expect(streamUploadToDisk(null, 'x')).rejects.toThrow(/empty/); await expect(streamUploadToDisk(streamOf(), 'x')).rejects.toThrow(/empty/); }); it('propagates a mid-transfer source error and removes the partial', async () => { const failing = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('partial-bytes')); controller.error(new Error('source aborted')); }, }); await expect(streamUploadToDisk(failing, 'oops.bin')).rejects.toThrow(/source aborted/); const names = await readdir(DEBUG_UPLOAD_DIR).catch(() => [] as string[]); expect(names.filter((n) => n.endsWith('__oops.bin'))).toEqual([]); }); }); describe('buildStreamInvokeResponse', () => { const noDelay = async () => {}; it('cancels delivery while the action is still pending', async () => { let finish!: () => void; const pending = new Promise((resolve) => { finish = resolve; }); let isAlive!: () => boolean; const response = buildStreamInvokeResponse( async (_request, _emit, alive) => { isAlive = alive; await pending; }, '{}', new AbortController().signal, ); const canceled = response.body!.cancel(); try { expect(isAlive()).toBe(false); } finally { finish(); await canceled; } }); it('does not invoke an action for an already aborted request', async () => { const controller = new AbortController(); controller.abort(); let invoked = false; const response = buildStreamInvokeResponse( async () => { invoked = true; }, '{}', controller.signal, ); await readSse(response); expect(invoked).toBe(false); }); it('relays each emitted frame as one SSE event, in order', async () => { const ac = new AbortController(); const driver = async (_rq: string, emit: (j: string) => Promise) => { await emit(JSON.stringify({ data: { type: 'stream', i: 1 } })); await emit(JSON.stringify({ data: { type: 'stream', i: 2 } })); await emit(JSON.stringify({ status: 'ok', data: { type: 'response' } })); }; const res = buildStreamInvokeResponse(driver, '{"action":"x"}', ac.signal, noDelay); const frames = (await readSse(res)).map((s) => JSON.parse(s)); expect(frames).toHaveLength(3); expect(frames[0].data.i).toBe(1); expect(frames[2].data.type).toBe('response'); }); it('turns a driver error into a terminal failed frame', async () => { const ac = new AbortController(); const driver = async (_rq: string, emit: (j: string) => Promise) => { await emit(JSON.stringify({ data: { type: 'stream' } })); throw new Error('disk exploded'); }; const res = buildStreamInvokeResponse(driver, '{}', ac.signal, noDelay); const frames = (await readSse(res)).map((s) => JSON.parse(s)); expect(frames).toHaveLength(2); expect(frames[1].status).toBe('failed'); expect(frames[1].message).toMatch(/disk exploded/); }); it('passes the raw request through to the driver', async () => { const ac = new AbortController(); let seen = ''; const driver = async (rq: string, emit: (j: string) => Promise) => { seen = rq; await emit('{"data":{"type":"response"}}'); }; const res = buildStreamInvokeResponse( driver, '{"action":"test_download_stream"}', ac.signal, noDelay, ); await readSse(res); expect(seen).toBe('{"action":"test_download_stream"}'); }); it('exposes an SSE content-type', () => { const ac = new AbortController(); const res = buildStreamInvokeResponse(async () => {}, '{}', ac.signal, noDelay); expect(res.headers.get('Content-Type')).toBe('text/event-stream'); }); });