test: add comprehensive Curio installation and runtime fixtures

This commit is contained in:
2026-09-09 17:14:52 +08:00
parent 0d38ef2959
commit 775d891288
38 changed files with 3609 additions and 4 deletions
+280
View File
@@ -0,0 +1,280 @@
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { mkdir, writeFile, access, rm } from 'node:fs/promises';
import path from 'node:path';
import { createTroveClient } from '@trove/plugin-sdk';
import { root, output, run, troveBin } from './suite.mjs';
import { assert, equal, expectError, hostChecks, backendChecks, sleep } from '../shared/ui/checks.js';
import { serve } from './serve.mjs';
// Only this optional integration adapter depends on the app checkout.
await rm(path.join(output, 'verification.json'), { force: true });
const app = process.env.TROVE_APP_DIR;
if (!app) throw new Error('Set TROVE_APP_DIR to the app repository, then run pnpm run verify.');
await access(path.join(output, 'suite.json')).catch(() => { throw new Error('Run pnpm run pack first.'); });
const harness = path.join(output, 'host-check');
await mkdir(harness, { recursive: true });
const manifest = `[package]
name = "trove-qa-host-check"
version = "0.1.0"
edition = "2021"
publish = false
[workspace]
[[bin]]
name = "trove-qa-host-check"
path = ${JSON.stringify(path.join(root, 'tools/host-check.rs'))}
[dependencies]
trove = { path = ${JSON.stringify(path.join(path.resolve(app), 'src-tauri'))}, default-features = false }
anyhow = "1"
serde_json = "1"
tempfile = "3"
tokio = { version = "1", features = ["full"] }
`;
await writeFile(path.join(harness, 'Cargo.toml'), manifest);
const target = process.env.TROVE_QA_TARGET_DIR || path.join(output, 'host-target');
try {
run('cargo', ['build', '--manifest-path', path.join(harness, 'Cargo.toml'), '--target-dir', target]);
run(troveBin, ['protocol', 'test', '--command', path.join(root, 'shared/backend/target/release/trove-qa-backend')]);
} catch (error) {
await writeFile(path.join(output, 'verification.json'), JSON.stringify({
schemaVersion: 1, generatedAt: new Date().toISOString(), passed: 0, failed: 1, observed: 0,
results: [{ name: '编译适配器 / CLI 协议检查', status: 'fail', error: { message: error.message } }],
}, null, 2) + '\n');
throw error;
}
const sourceServer = await serve();
const child = spawn(path.join(target, 'debug/trove-qa-host-check'), [output], {
cwd: root, stdio: ['pipe', 'pipe', 'inherit'], env: { ...process.env, QA_HTTP_SOURCE_URL: `${sourceServer.base}/index.json` },
});
const controls = new Map();
const listeners = new Map();
const clients = [];
const results = [];
let nextId = 1;
let readyResolve, readyReject;
const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
let exitResolve;
const exited = new Promise(resolve => { exitResolve = resolve; });
let fatal;
let closing = false;
child.once('error', error => { readyReject(error); });
child.once('exit', (code, signal) => {
exitResolve({ code, signal });
if (!closing) {
fatal = new Error(`Host adapter exited unexpectedly: ${code ?? signal}`);
readyReject(fatal);
for (const pending of controls.values()) pending.reject(fatal);
for (const listener of listeners.values()) listener({ v: 1, type: 'goodbye' });
}
});
const lines = createInterface({ input: child.stdout });
lines.on('line', line => {
try {
const message = JSON.parse(line);
if (message.ready) readyResolve(message);
else if (message.controlId) {
const pending = controls.get(message.controlId);
controls.delete(message.controlId);
if (message.error) pending?.reject(new Error(message.error.message));
else pending?.resolve(message.result);
} else if (message.connection) listeners.get(message.connection)?.(message.message);
else if (message.fatal) {
fatal = new Error(JSON.stringify(message.fatal));
for (const listener of listeners.values()) listener({ v: 1, type: 'protocolError', error: message.fatal });
}
} catch (error) { fatal = error; readyReject(error); }
});
function send(value) {
if (fatal) return Promise.reject(fatal);
return new Promise((resolve, reject) => child.stdin.write(JSON.stringify(value) + '\n', error => error ? reject(error) : resolve()));
}
function control(op, params = {}) {
const controlId = String(nextId++);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { controls.delete(controlId); reject(new Error(`Control timeout: ${op}`)); }, 15000);
controls.set(controlId, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
send({ op, ...params, controlId }).catch(error => { controls.get(controlId)?.reject(error); controls.delete(controlId); });
});
}
async function client(curioId) {
const connection = `qa-ui-${crypto.randomUUID()}`;
const context = await control('connect', { curioId, connection });
const instance = createTroveClient({ context, bridge: {
protocolVersion: 1,
connect: async listener => { listeners.set(connection, listener); },
send: message => send({ op: 'send', connection, message }),
disconnect: () => { listeners.delete(connection); void control('disconnect', { connection }).catch(() => {}); },
} });
await instance.ready();
clients.push(instance);
return instance;
}
async function check(name, action, status = 'pass') {
const start = performance.now();
try { results.push({ name, status, value: await action(), durationMs: Math.round(performance.now() - start) }); }
catch (error) { results.push({ name, status: 'fail', error: { code: error.code, message: error.message }, durationMs: Math.round(performance.now() - start) }); }
console.log(`${results.at(-1).status.toUpperCase()} ${name}${results.at(-1).error ? `: ${results.at(-1).error.message}` : ''}`);
}
async function waitFor(action, predicate, timeoutMs = 3000) {
const end = Date.now() + timeoutMs;
do { const value = await action(); if (predicate(value)) return value; await sleep(50); } while (Date.now() < end);
throw new Error(`Condition did not settle within ${timeoutMs} ms`);
}
let temporaryRoot;
try {
const readyTimer = setTimeout(() => readyReject(new Error('Package checks exceeded 60 seconds')), 60000);
let initial;
try { initial = await ready; } finally { clearTimeout(readyTimer); }
temporaryRoot = initial.temporaryRoot;
results.push(...initial.packageResults);
const host = await client('dev.trove.qa.host');
for (const [name, action] of hostChecks(host)) await check(`Host / ${name}`, action);
const isolation = await client('dev.trove.qa.isolation');
for (const [name, action] of hostChecks(isolation).filter(([name]) => name.includes('持久化'))) await check(`隔离 / ${name}`, action);
await check('隔离 / 两插件同名键相互独立', async () => {
equal((await host.call('host.storage', 'get', { key: 'qa.namespace-owner' })).value, host.context.curioId);
equal((await isolation.call('host.storage', 'get', { key: 'qa.namespace-owner' })).value, isolation.context.curioId);
return { isolated: true };
});
await check('持久化 / SDK 断开重连后读取', async () => {
host.disconnect();
const reopened = await client('dev.trove.qa.host');
equal((await reopened.call('host.storage', 'get', { key: 'qa.namespace-owner' })).value, reopened.context.curioId);
return { reopened: true };
});
const self = await client('dev.trove.qa.self');
for (const [name, action] of backendChecks(self, '@self')) await check(`私有后端 / ${name}`, action);
const consumer = await client('dev.trove.qa.consumer');
const service = 'dev.trove.qa.provider.probe';
await check('公共后端 / 第一次调用前尚未启动', async () => {
const status = await control('status', { curioId: 'dev.trove.qa.provider' });
equal(status.state, 'stopped'); return status;
});
await check('公共后端 / 默认返回值契约策略(观察)', async () => {
try {
const result = await consumer.call(service, 'invalidResult');
return { enforced: false, result, note: '默认底座允许不符合返回值契约的结果;后续单独开启严格模式验证。' };
} catch (error) {
equal(error.code, 'service.contract_violation');
return { enforced: true, actualError: error.code };
}
}, 'observed');
for (const [name, action] of backendChecks(consumer, service).filter(([name]) => !name.startsWith('公共服务'))) await check(`公共后端 / ${name}`, action);
await control('strictContracts', { value: true });
try {
for (const [name, action] of backendChecks(consumer, service).filter(([name]) => name.startsWith('公共服务'))) await check(`严格模式 / ${name}`, action);
} finally { await control('strictContracts', { value: false }); }
await check('公共后端 / Host 存储归属于提供方', async () => {
const key = 'qa.provider-namespace';
await consumer.call(service, 'storageRoundtrip', { key, value: 'provider-only' });
equal((await consumer.call('host.storage', 'get', { key })).found, false);
const provider = await client('dev.trove.qa.provider');
equal((await provider.call('host.storage', 'get', { key })).value, 'provider-only');
await provider.call('host.storage', 'delete', { key });
return { namespace: 'dev.trove.qa.provider' };
});
await check('取消 / 后端活动请求最终归零', async () => {
return waitFor(() => consumer.call(service, 'info'), value => value.activeRequests === 1 && value.subscriptions === 0);
});
for (const [id, expected] of [['optional', 'service.not_found'], ['required', 'service.not_found'], ['incompatible', 'service.version_mismatch']]) {
await check(`依赖 / ${id}`, async () => {
const instance = await client(`dev.trove.qa.${id}`);
equal(await instance.call('host.runtime', 'echo', { alive: true }), { alive: true });
return expectError(() => instance.call(id === 'incompatible' ? service : 'dev.trove.qa.absent.probe', 'echo'), expected);
});
}
await check('启动 / 缺失 ready 触发超时', () => expectError(async () => {
const instance = await client('dev.trove.qa.start-timeout'); await instance.self.call('info');
}, 'provider.start_timeout'));
await check('启动 / 错误令牌被拒绝', async () => {
const instance = await client('dev.trove.qa.bad-handshake');
const result = await expectError(() => instance.self.call('info'), 'provider.start_failed');
equal((await control('status', { curioId: instance.context.curioId })).state, 'failed');
return result;
});
await check('禁用 / 提供方拒绝调用并可重新启用', async () => {
await control('enabled', { curioId: 'dev.trove.qa.provider', value: false });
try { await expectError(() => consumer.call(service, 'echo'), 'provider.disabled'); }
finally { await control('enabled', { curioId: 'dev.trove.qa.provider', value: true }); }
equal(await consumer.call(service, 'echo', { restored: true }), { restored: true });
return { enabledAgain: true };
});
await check('退出 / 优雅关闭后可再次启动', async () => {
const before = await self.self.call('info');
const stopped = await control('stop', { curioId: self.context.curioId });
equal(stopped.state, 'stopped');
const after = await self.self.call('info');
assert(after.pid !== before.pid, 'Shutdown retained old process');
return { before: before.pid, after: after.pid };
});
await check('退出 / 忽略 shutdown 的进程被回收', async () => {
const instance = await client('dev.trove.qa.stubborn');
const before = await instance.self.call('info');
const stopped = await control('stop', { curioId: instance.context.curioId });
equal(stopped.state, 'stopped');
let gone = false;
try { process.kill(before.pid, 0); } catch (error) { if (error.code === 'ESRCH') gone = true; else throw error; }
assert(gone, `Process ${before.pid} survived shutdown grace period`);
return { terminatedPid: before.pid };
});
await check('崩溃 / 隔离、重启、熔断和手动重试', async () => {
const instance = await client('dev.trove.qa.crash');
const first = await instance.self.call('info');
for (let i = 0; i < 3; i++) {
await expectError(() => instance.self.call('crash'), 'provider.disconnected');
if (i < 2) {
const restarted = await instance.self.call('info');
assert(restarted.pid !== first.pid, 'Crash recovery retained dead PID');
}
}
await expectError(() => instance.self.call('info'), 'provider.crash_loop');
equal(await self.self.call('echo', { unaffected: true }), { unaffected: true });
await control('retry', { curioId: instance.context.curioId });
const after = await instance.self.call('info');
return { oldPid: first.pid, retriedPid: after.pid };
});
await check('空闲 / 20 秒无调用后退出并重新按需启动', async () => {
const before = await consumer.call(service, 'info');
await waitFor(() => control('status', { curioId: 'dev.trove.qa.provider' }), value => value.state === 'stopped', 24000);
const after = await consumer.call(service, 'info');
assert(before.pid !== after.pid, 'Idle shutdown retained old PID');
return { before: before.pid, after: after.pid };
});
await check('清理 / 无悬挂调用和订阅', async () => {
return waitFor(() => control('diagnostics'), value => value.pendingCalls === 0 && value.incomingCalls === 0 && value.subscriptions === 0);
});
} catch (error) {
results.push({ name: '验证进程', status: 'fail', error: { message: error.stack || error.message } });
} finally {
for (const instance of clients) instance.disconnect();
// A barrier lets all disconnect cleanup finish before shutting down the adapter.
if (!fatal && child.exitCode === null) await control('diagnostics').catch(() => {});
closing = true;
child.stdin.end(JSON.stringify({ op: 'shutdown' }) + '\n');
const killTimer = setTimeout(() => child.kill('SIGTERM'), 10000);
const exit = await exited;
clearTimeout(killTimer);
await sourceServer.close();
if (exit.code !== 0) results.push({ name: 'Host adapter exit', status: 'fail', error: exit });
if (temporaryRoot) {
let removed = false;
try { await access(temporaryRoot); } catch (error) { if (error.code === 'ENOENT') removed = true; else throw error; }
results.push({ name: '隔离数据目录已清理', status: removed ? 'pass' : 'fail', value: { temporaryRoot } });
}
const report = {
schemaVersion: 1, generatedAt: new Date().toISOString(),
environment: { platform: process.platform, arch: process.arch, appRepository: path.resolve(app), sdk: '0.1.0' },
scope: 'Real PackageManager + ServiceBus + BackendSupervisor + UDS + production SDK over a test bridge. Default policy is used except explicitly labeled strict-contract checks. Desktop WebView/native dialogs and app-start orchestration are manual checks.',
passed: results.filter(row => row.status === 'pass').length,
failed: results.filter(row => row.status === 'fail').length,
observed: results.filter(row => row.status === 'observed').length,
results,
};
await writeFile(path.join(output, 'verification.json'), JSON.stringify(report, null, 2) + '\n');
console.log(`\n${report.passed} passed, ${report.failed} failed, ${report.observed} observations. Report: ${path.join(output, 'verification.json')}`);
process.exitCode = report.failed ? 1 : 0;
}