Files
curios/shared/ui/checks.js
T

171 lines
10 KiB
JavaScript

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')],
]),
];
}