298 lines
16 KiB
JavaScript
298 lines
16 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process';
|
|
import { parseArgs } from 'node:util';
|
|
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.
|
|
const { values: args } = parseArgs({ options: {
|
|
'remote-source': { type: 'string' },
|
|
'source-id': { type: 'string', default: 'trove-official' },
|
|
'source-kind': { type: 'string', default: 'http' },
|
|
} });
|
|
if (!['git', 'http'].includes(args['source-kind'])) throw new Error('--source-kind must be git or http');
|
|
const reportPath = path.join(output, args['remote-source'] ? 'remote-verification.json' : 'verification.json');
|
|
await rm(reportPath, { 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.');
|
|
if (!args['remote-source']) 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]);
|
|
if (args['remote-source']) {
|
|
const result = spawnSync(path.join(target, 'debug/trove-qa-host-check'), ['--remote-source', args['remote-source'], args['source-id'], args['source-kind']], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], timeout: 180000 });
|
|
if (result.error) throw result.error;
|
|
const report = JSON.parse(result.stdout);
|
|
report.generatedAt = new Date().toISOString();
|
|
await writeFile(reportPath, JSON.stringify(report, null, 2) + '\n');
|
|
console.log(`${report.passed} passed, ${report.failed} failed. Report: ${reportPath}`);
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
run(troveBin, ['protocol', 'test', '--command', path.join(root, 'shared/backend/target/release/trove-qa-backend')]);
|
|
} catch (error) {
|
|
await writeFile(reportPath, 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;
|
|
}
|