test: add comprehensive Curio installation and runtime fixtures
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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')],
|
||||
]),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user