import { trove } from './sdk/index.js'; import config from './config.js'; import { assert, equal, expectError, hostChecks, backendChecks } from './checks.js'; const $ = selector => document.querySelector(selector); const results = []; let running = false; const call = (service, method, params = {}, options) => trove.call(service, method, params, options); const report = () => ({ schemaVersion: 1, fixture: config, context: trove.context, generatedAt: new Date().toISOString(), results }); const serializeError = error => ({ code: error.code ?? 'qa.assertion_failed', message: error.message ?? String(error), data: error.data }); function refresh() { $('#results').replaceChildren(...results.map(result => { const row = document.createElement('li'); const heading = document.createElement('div'); heading.className = 'result-heading'; const title = document.createElement('strong'); title.className = result.status; const labels = { pass: '通过', fail: '失败', observed: '已执行 · 请观察', skip: '跳过' }; title.textContent = `${labels[result.status]} · ${result.name}`; const duration = document.createElement('small'); duration.textContent = `${result.durationMs} ms`; heading.append(title, duration); const detail = document.createElement('details'); const summary = document.createElement('summary'); summary.textContent = result.error?.message || '查看返回值'; const pre = document.createElement('pre'); pre.textContent = JSON.stringify(result.error || result.value, null, 2); detail.append(summary, pre); row.append(heading, detail); return row; })); $('#report').textContent = JSON.stringify(report(), null, 2); const counts = status => results.filter(result => result.status === status).length; $('#status').textContent = `${running ? '运行中' : '已完成'} · 通过 ${counts('pass')} / 失败 ${counts('fail')} / 待观察 ${counts('observed')} / 跳过 ${counts('skip')}`; } 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: serializeError(error), durationMs: Math.round(performance.now() - start) }); } refresh(); } async function exclusive(action) { if (running) return; running = true; document.querySelectorAll('button').forEach(button => { button.disabled = true; }); try { await action(); } finally { running = false; document.querySelectorAll('button').forEach(button => { button.disabled = false; }); refresh(); } } function manual(name, action, status = 'observed') { const button = document.createElement('button'); button.textContent = name; button.onclick = () => exclusive(() => check(name, action, status)); $('#manual').append(button); } const descriptions = { host: '检查真实 Host 桥接与数据操作。常规检查只在本插件命名空间和临时目录写入测试数据;隔离标记会保留以便复测。', backend: `检查 ${config.service}:原生进程、并发、超时、取消、Host 回调和事件。跨插件测试需先安装公共服务提供方。公共服务返回值校验要求底座开启严格契约模式;默认未开启时,该项会报告失败。`, dependency: `此插件声明了 ${config.service}。预期调用错误为 ${config.expectedError};“service.not_found”不能代替版本不兼容错误。`, fault: '此后端故意无法完成启动。点击运行检查记录实际错误码;恢复与重试应在底座管理界面操作。', upgrade: '先安装 1.0.0 并写入基线标记,再安装 1.1.0。关闭后重新打开,检查运行版本与数据保留;也可在底座回滚。', }; $('#title').textContent = config.name; document.title = config.name; $('#description').textContent = descriptions[config.mode]; function configureManualActions() { manual('读取持久化标记', () => call('host.storage', 'get', { key: config.mode === 'upgrade' ? 'qa.upgrade-marker' : 'qa.namespace-owner' }), 'pass'); if (config.mode === 'host') { manual('读取本窗口尺寸', () => call('host.window', 'getBounds'), 'pass'); manual('设置窗口标题', () => call('host.window', 'setTitle', { title: `${config.name} · 标题测试成功` })); manual('窗口居中', () => call('host.window', 'center')); manual('文件选择(可取消)', () => call('host.dialog', 'openFile', { title: 'Trove QA:选择文件或取消' }, { timeoutMs: 120000 })); manual('目录选择(可取消)', () => call('host.dialog', 'openDirectory', { title: 'Trove QA:选择目录或取消' }, { timeoutMs: 120000 })); manual('保存对话框(仅选择路径)', () => call('host.dialog', 'saveFile', { title: 'Trove QA:只选择路径,不写入文件' }, { timeoutMs: 120000 })); manual('确认对话框', () => call('host.dialog', 'confirm', { title: 'Trove QA', message: '确认和取消都应返回明确结果。' }, { timeoutMs: 120000 })); manual('写入并读取测试剪贴板', async () => { const text = 'Trove QA 剪贴板 🧪'; await call('host.clipboard', 'writeText', { text }); equal((await call('host.clipboard', 'readText')).text, text); return { text, note: '系统剪贴板已被替换为测试文本。' }; }, 'pass'); manual('发送测试通知', () => call('host.notification', 'show', { title: 'Trove QA', body: `来自 ${config.id} 的测试通知` })); manual('快捷键触发(15 秒)', async () => { const events = []; let registration; const subscription = await trove.subscribe('host.shortcut', 'triggered', {}, event => events.push(event)); try { registration = await call('host.shortcut', 'register', { shortcut: 'CommandOrControl+Shift+Alt+F9' }); $('#status').textContent = '请在 15 秒内按 Cmd+Shift+Option+F9(部分键盘需要 Fn)。'; const end = Date.now() + 15000; while (!events.length && Date.now() < end) await new Promise(resolve => setTimeout(resolve, 50)); assert(events.some(event => event.registrationId === registration.registrationId), '15 秒内没有收到匹配的快捷键事件'); return events; } finally { try { if (registration) await call('host.shortcut', 'unregister', registration); } finally { await subscription.close(); } } }, 'pass'); manual('列出正在运行的应用', () => call('host.application', 'listRunning'), 'pass'); manual('检查随底座启动的服务', async () => { const service = 'dev.trove.qa.autostart.probe'; // Discovery does not start the backend; keep this separate from info(). return call('host.services', 'getStatus', { service }); }); manual('关闭本窗口', () => call('host.window', 'close')); } if (config.mode === 'backend') { manual('读取后端 PID 与活动数', () => call(config.service, 'info'), 'pass'); if (config.service !== '@self') manual('查询提供方状态(不启动)', () => call('host.services', 'getStatus', { service: config.service })); if (config.directory === 'test-crash') manual('触发本后端崩溃(exit 42)', async () => { try { await call('@self', 'crash', {}, { timeoutMs: 6000 }); } catch (error) { assert(['provider.disconnected', 'provider.crashed'].includes(error.code), `预期进程断开,实际 ${error.code}`); return { error: serializeError(error), next: '再次读取 PID 验证恢复;短时间反复触发可观察熔断。' }; } throw new Error('后端未断开'); }); } if (config.mode === 'upgrade') { manual('写入升级基线标记', async () => { const value = { token: crypto.randomUUID(), writtenByVersion: trove.context.curioVersion, writtenAt: new Date().toISOString() }; await call('host.storage', 'set', { key: 'qa.upgrade-marker', value }); return value; }, 'pass'); } } $('#run').onclick = () => exclusive(async () => { results.length = 0; if (config.mode === 'host') for (const [name, action] of hostChecks(trove)) await check(name, action); if (config.mode === 'backend') for (const [name, action] of backendChecks(trove, config.service)) await check(name, action); if (config.mode === 'dependency') { await check('缺失依赖时 Host 仍可用', async () => equal(await call('host.runtime', 'echo', { alive: true }), { alive: true })); await check('依赖调用返回预期错误', () => expectError(() => call(config.service, 'echo'), config.expectedError)); } if (config.mode === 'fault') { await check('启动失败被限制在截止时间内', async () => { try { await call('@self', 'info', {}, { timeoutMs: 6000 }); } catch (error) { const expected = config.directory === 'test-start-timeout' ? ['provider.start_timeout'] : ['provider.start_failed', 'provider.disconnected', 'provider.crashed', 'provider.start_timeout']; assert(expected.includes(error.code), `非预期错误 ${error.code}`); return { actual: serializeError(error), next: '查看底座后端日志确认具体启动原因。' }; } throw new Error('故障后端意外启动成功'); }); } if (config.mode === 'upgrade') { await check('运行版本与打包版本一致', async () => { equal(trove.context.curioVersion, config.version); return trove.context; }); await check('升级基线标记保留', async () => { const marker = await call('host.storage', 'get', { key: 'qa.upgrade-marker' }); assert(marker.found, '尚无基线标记,请在 1.0.0 中写入后再验证升级'); equal(marker.value.writtenByVersion, '1.0.0', '标记不是由基线版本写入'); return marker.value; }); } }); $('#save-report').onclick = () => exclusive(() => check('保存 JSON 报告', async () => { const paths = await call('host.runtime', 'getPaths'); const path = `${paths.data}/qa-report-${Date.now()}.json`; await call('host.fs', 'writeText', { path, content: JSON.stringify(report(), null, 2) }); return { path }; })); try { await trove.ready(); $('#context').textContent = `${trove.context.curioId} · v${trove.context.curioVersion} · ${trove.context.mode} · ${trove.context.instanceId}`; configureManualActions(); document.querySelectorAll('button').forEach(button => { button.disabled = false; }); $('#status').textContent = '已连接真实 Trove Host,点击运行常规检查。'; } catch (error) { $('#status').textContent = `连接失败:${error.code ?? ''} ${error.message}。请从 Trove 安装并打开本插件。`; }