test: add comprehensive Curio installation and runtime fixtures
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user