export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); export function assert(condition, message) { if (!condition) throw new Error(message); } export function equal(actual, expected, label = '返回值不匹配') { // Compare JSON objects independently of property insertion order. const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value; assert(JSON.stringify(canonical(actual)) === JSON.stringify(canonical(expected)), `${label}: ${JSON.stringify(actual)}`); } export async function expectError(action, expected) { try { await action(); } catch (error) { assert(error.code === expected, `预期 ${expected},实际 ${error.code ?? error.message}`); return { expected, actual: error.code, message: error.message }; } throw new Error(`预期 ${expected},调用却成功了`); } export function hostChecks(client) { const call = (service, method, params = {}) => client.call(service, method, params); return [ ['SDK 与运行上下文', async () => { const context = await call('host.runtime', 'getContext'); equal(context.curioId, client.context.curioId); equal(context.curioVersion, client.context.curioVersion); equal(context.apiVersion, 1); return context; }], ['Host 信息与数据目录', async () => { const info = await call('host.runtime', 'getHostInfo'); const paths = await call('host.runtime', 'getPaths'); for (const key of ['data', 'cache', 'logs', 'temp']) assert(paths[key]?.startsWith('/'), `缺少绝对路径 ${key}`); equal(info.protocol, 1); return { info, paths }; }], ['JSON 与 Unicode 往返', async () => { const value = { text: '中文 / emoji 🧪 / e\u0301 / \n换行', number: 12.5, null: null, array: [false, {}, ''] }; equal(await call('host.runtime', 'echo', value), value); return value; }], ['32 路并发响应匹配', async () => { const values = await Promise.all(Array.from({ length: 32 }, (_, index) => call('host.runtime', 'echo', { index }))); values.forEach((value, index) => equal(value, { index })); return { responses: values.length }; }], ['服务发现、描述、解析与状态', async () => { const { services } = await call('host.services', 'list', { prefix: 'host.' }); for (const id of ['host.runtime', 'host.storage', 'host.fs']) assert(services.some(item => item.id === id), `缺少 ${id}`); const description = await call('host.services', 'describe', { service: 'host.storage' }); assert(description.contract.methods.set, '缺少 storage.set 契约'); await call('host.services', 'resolve', { service: 'host.storage', version: '^1.0.0' }); equal((await call('host.services', 'getStatus', { service: 'host.storage' })).state, 'Available'); return services; }], ['独立存储、null 与不存在、前缀查询、删除', async () => { const key = `qa.temporary.${crypto.randomUUID()}`; try { equal((await call('host.storage', 'get', { key })).found, false); await call('host.storage', 'set', { key, value: null }); equal(await call('host.storage', 'get', { key }), { found: true, value: null }); const value = { owner: client.context.curioId, unicode: '存储 🧪', array: [1, false] }; await call('host.storage', 'set', { key, value }); equal((await call('host.storage', 'get', { key })).value, value); assert((await call('host.storage', 'list', { prefix: key })).keys.includes(key), '前缀查询缺少测试键'); await call('host.storage', 'delete', { key }); equal((await call('host.storage', 'get', { key })).found, false); return { namespace: client.context.curioId }; } finally { await call('host.storage', 'delete', { key }); } }], ['持久化与同名键隔离标记', async () => { const key = 'qa.namespace-owner'; const previous = await call('host.storage', 'get', { key }); if (previous.found) equal(previous.value, client.context.curioId, '存储命名空间混用'); await call('host.storage', 'set', { key, value: client.context.curioId }); return { previous, stored: client.context.curioId, next: '在对照插件运行检查,然后关闭并重新打开本插件再运行。' }; }], ['存储单值大小限制', () => expectError(() => call('host.storage', 'set', { key: 'qa.oversize', value: 'x'.repeat(1024 * 1024 + 1) }), 'host.storage.value_too_large')], ['文件 CRUD、二进制、复制与移动', async () => { const paths = await call('host.runtime', 'getPaths'); const directory = `${paths.temp}/qa-${crypto.randomUUID()}`; const text = `${directory}/中文 空格.txt`; await call('host.fs', 'createDirectory', { path: directory }); try { await call('host.fs', 'writeText', { path: text, content: 'Trove 文件 🧪\n第二行' }); equal((await call('host.fs', 'readText', { path: text })).content, 'Trove 文件 🧪\n第二行'); equal((await call('host.fs', 'stat', { path: text })).type, 'file'); assert((await call('host.fs', 'list', { path: directory })).entries.some(item => item.name === '中文 空格.txt'), '目录列表缺少文件'); const binary = `${directory}/bytes.bin`; await call('host.fs', 'writeBinary', { path: binary, data: 'AAECA/7/' }); equal((await call('host.fs', 'readBinary', { path: binary })).data, 'AAECA/7/'); await call('host.fs', 'copy', { from: binary, to: `${directory}/copy.bin` }); await call('host.fs', 'move', { from: `${directory}/copy.bin`, to: `${directory}/moved.bin` }); equal((await call('host.fs', 'exists', { path: `${directory}/copy.bin` })).exists, false); equal((await call('host.fs', 'readBinary', { path: `${directory}/moved.bin` })).data, 'AAECA/7/'); const temporary = await call('host.fs', 'createTempFile'); await call('host.fs', 'remove', temporary); return { unicodeFilename: true, binaryBytes: 6, directory }; } finally { await call('host.fs', 'remove', { path: directory, recursive: true }); } }], ['未知服务错误', () => expectError(() => call('dev.trove.qa.absent.probe', 'echo'), 'service.not_found')], ['未知方法错误', () => expectError(() => call('host.runtime', 'unknownQaMethod'), 'service.method_not_found')], ['Host 参数契约校验', () => expectError(() => call('host.storage', 'get', { key: 123 }), 'request.invalid_params')], ['服务版本约束', () => expectError(() => call('host.services', 'resolve', { service: 'host.storage', version: '^99.0.0' }), 'service.version_mismatch')], ]; } export function backendChecks(client, service) { const call = (method, params = {}, options) => client.call(service, method, params, options); return [ ['后端启动、身份、环境与参数', async () => { const info = await call('info'); assert(info.pid > 0 && info.curioId.startsWith('dev.trove.qa.'), '后端身份缺失'); assert(info.label.startsWith('test-'), 'Manifest 环境变量未传入'); return info; }], ['原生 Unicode 与超过 200 KiB 消息', async () => { const value = { text: '中🧪'.repeat(32768), null: null, nested: [false, { n: 3 }] }; equal(await call('echo', value), value); return { utf8Bytes: new TextEncoder().encode(JSON.stringify(value)).length }; }], ['24 路乱序并发响应匹配', async () => { const responses = await Promise.all(Array.from({ length: 24 }, (_, index) => call('delay', { ms: (23 - index) * 8, value: { index } }))); responses.forEach((value, index) => equal(value, { index })); return { responses: responses.length }; }], ['结构化业务错误', () => expectError(() => call('fail'), 'qa.intentional_error')], ['未知后端方法', () => expectError(() => call('unknownQaMethod'), 'service.method_not_found')], ['请求超时与后续可用性', async () => { const error = await expectError(() => call('delay', { ms: 1500 }, { timeoutMs: 80 }), 'request.timeout'); equal(await call('echo', { recovered: true }), { recovered: true }); return error; }], ['AbortSignal 取消与后续可用性', async () => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 60); try { const error = await expectError(() => call('delay', { ms: 1500 }, { signal: controller.signal }), 'request.cancelled'); equal(await call('echo', { recovered: true }), { recovered: true }); return error; } finally { clearTimeout(timer); } }], ['后端 → Host 存储调用', async () => { const key = 'qa.backend-roundtrip'; const value = { unicode: '双向 🧪', caller: client.context.curioId }; const response = await call('storageRoundtrip', { key, value }); equal(response, { found: true, value }); // Backend writes to its own namespace. Fixed small probes are retained there for inspection. return { key, value, note: '存储所有者为后端提供方;可在插件数据目录检查。' }; }], ['事件订阅、顺序、取消订阅', async () => { const events = []; const taskId = crypto.randomUUID(); const subscription = await client.subscribe(service, 'progress', { taskId }, data => events.push(data)); try { equal(await call('uppercase', { text: 'hello 中文' }), { text: 'HELLO 中文' }); const deadline = Date.now() + 1500; while (!events.some(item => item.progress === 1) && Date.now() < deadline) await sleep(20); assert(events.length >= 2 && events[0].progress === 0 && events.at(-1).progress === 1, '未收到完整进度序列'); assert(events.every((item, i) => item.taskId === taskId && (i === 0 || item.progress >= events[i - 1].progress)), '事件身份或顺序不正确'); } finally { await subscription.close(); } const before = events.length; await call('uppercase', { text: 'after unsubscribe' }); await sleep(100); equal(events.length, before, '取消订阅后仍收到事件'); return { events }; }], ...(service === '@self' ? [] : [ ['公共服务参数契约', () => expectError(() => call('uppercase', { text: 123 }), 'request.invalid_params')], ['公共服务返回值契约', () => expectError(() => call('invalidResult'), 'service.contract_violation')], ]), ]; }