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