refactor: separate plugin repositories and release-based catalog

This commit is contained in:
2026-09-11 10:06:30 +08:00
parent 07174e35ab
commit 07cf38b1a4
148 changed files with 377 additions and 19048 deletions
View File
-32
View File
@@ -1,32 +0,0 @@
import { copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { root, run, suite } from './suite.mjs';
const selected = process.argv[2] ? suite.filter(item => item.directory === process.argv[2]) : suite;
if (!selected.length) throw new Error(`Unknown fixture: ${process.argv[2]}`);
let nativeBuilt = false;
for (const item of selected) {
const directory = path.join(root, 'curios', item.directory);
const manifest = JSON.parse(await readFile(path.join(directory, 'curio.json'), 'utf8'));
if (manifest.backend) {
if (process.platform !== 'darwin') throw new Error('Native packages must be built on macOS for the target architecture.');
if (!nativeBuilt) {
run('cargo', ['build', '--manifest-path', 'shared/backend/Cargo.toml', '--release', '--locked']);
nativeBuilt = true;
}
const destination = path.join(directory, manifest.backend.entry);
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(path.join(root, 'shared/backend/target/release/trove-qa-backend'), destination);
}
if (manifest.ui) {
const ui = path.join(directory, 'dist/ui');
await rm(ui, { recursive: true, force: true });
await mkdir(ui, { recursive: true });
await cp(path.join(root, 'shared/ui'), ui, { recursive: true });
const sdk = path.dirname(fileURLToPath(import.meta.resolve('@trove/plugin-sdk')));
await cp(sdk, path.join(ui, 'sdk'), { recursive: true });
await writeFile(path.join(ui, 'config.js'), `export default ${JSON.stringify({ ...item, id: manifest.id, name: manifest.name, version: manifest.version })};\n`);
}
console.log(`Built ${item.directory} (${manifest.version})`);
}
-188
View File
@@ -1,188 +0,0 @@
// Explicit browser-only UI fixture. Never included in a .curio package.
import { createMockBridge } from "./sdk/index.js";
const root = 0xffffffff;
const device = {
id: "fixture",
name: "daniel",
model: "HUAWEI Pura 70 Ultra",
manufacturer: "模拟设备",
};
const storages = [
{ id: 1, name: "内部存储", capacity: 256 * 1024 ** 3, free: 128 * 1024 ** 3 },
];
let attached = true,
connection = null,
job = null,
next = 30;
const entries = [
{
id: 2,
parent: root,
storage: 1,
name: "Documents",
folder: true,
size: 0,
modified: 0,
},
{
id: 3,
parent: root,
storage: 1,
name: "DCIM",
folder: true,
size: 0,
modified: 0,
},
{
id: 4,
parent: root,
storage: 1,
name: "设计稿 🎨 final.zip",
folder: false,
size: 52_423_111,
modified: 1788998400,
},
{
id: 5,
parent: root,
storage: 1,
name: "<script>alert(1)</script>.txt",
folder: false,
size: 48,
modified: 1788998400,
},
{
id: 6,
parent: 2,
storage: 1,
name: "旅行计划.pdf",
folder: false,
size: 1123988,
modified: 1788998400,
},
];
let screenActive = false;
window.__TROVE_CONTEXT__ = {
curioId: "dev.trove.harmony-connector",
version: "0.3.0",
instanceId: "ui-fixture",
platform: "macos",
};
window.__TROVE_BRIDGE__ = createMockBridge({
"@self.hdc.screenWindowOpen": () => ({ active: (screenActive = true), ready: true }),
"@self.hdc.screenWindowStop": () => ({ active: (screenActive = false) }),
"@self.hdc.screenWindowStatus": () => ({ active: screenActive, ready: screenActive }),
"@self.hdc.status": () => ({ busy: false, job: null }),
"@self.hdc.tool": () => ({
available: true,
version: "HDC · 模拟数据",
path: "/fixture/official-sdk/hdc",
}),
"@self.hdc.devices": () => ({
devices: attached
? [{ id: "fixture-device", transport: "USB", state: "Connected" }]
: [],
}),
"@self.hdc.inspect": () => ({
capabilities: Object.fromEntries(
["apps", "launch", "screenshot", "logs", "report"].map((key) => [
key,
{ available: true },
]),
),
info: { text: "HUAWEI Pura 70 Ultra · 界面测试数据" },
}),
"@self.hdc.apps": () => ({
apps: ["com.example.gallery", "com.example.notes", "com.example.reader"],
}),
"@self.hdc.app": ({ bundle }) => ({
bundle,
versionName: "1.2.0",
versionCode: 12,
debug: bundle.endsWith("notes"),
abilities: [{ name: "Home", module: "entry" }],
details: {},
}),
"@self.info": () => ({ connection, busy: false }),
"@self.devices": () => ({ devices: attached ? [device] : [] }),
"@self.monitor": () => ({
connection,
devices: attached ? [device] : [],
busy: job?.state === "running",
}),
"@self.connect": () => (connection = { device, storages }),
"@self.disconnect": () => {
connection = null;
job = null;
return { disconnected: true };
},
"@self.list": ({ parent, offset = 0 }) => {
const all = entries.filter((e) => e.parent === parent);
return { entries: all.slice(offset, offset + 500), total: all.length };
},
"@self.mkdir": ({ name, storage, parent }) => {
entries.push({
id: next++,
name,
storage,
parent,
folder: true,
size: 0,
modified: 0,
});
return { id: next - 1 };
},
"@self.transfer": () => {
job = { id: 1, state: "running", sent: 0, total: 10 * 1024 ** 2 };
return { id: 1 };
},
"@self.transferStatus": () => {
if (job?.state === "running") {
job.sent += 524288;
job.statistics = {
bytesPerSecond: 1310720,
averageBytesPerSecond: 1210720,
elapsedSeconds: job.sent / 1310720,
remainingSeconds: (job.total - job.sent) / 1310720,
filesTotal: 3,
filesCompleted: Math.floor((job.sent / job.total) * 3),
currentFile: "Documents/设计稿.zip",
};
if (job.sent >= job.total) {
job.state = "completed";
job.result = { name: "界面测试文件", bytes: job.total };
}
}
return job;
},
"@self.cancelTransfer": () => {
job.state = "cancelled";
job.error = { message: "模拟取消已完成。" };
connection = null;
return { requested: true };
},
"host.dialog.openFile": () => ({ path: "/ui-fixture/example.txt" }),
"host.dialog.openDirectory": () => ({ path: "/ui-fixture/downloads" }),
});
const badge = document.createElement("div");
badge.textContent = "界面测试 · 模拟设备,不执行真实文件传输";
badge.style.cssText =
"position:fixed;bottom:3px;left:240px;z-index:10;background:#805800;color:white;padding:4px 8px;border-radius:5px;font-size:10px";
document.body.append(badge);
const toggle = document.createElement("button");
toggle.textContent = "模拟拔掉 USB";
toggle.style.cssText =
"position:fixed;bottom:30px;left:16px;z-index:10;background:#805800;color:white";
toggle.addEventListener("click", () => {
attached = !attached;
if (!attached) {
connection = null;
if (job?.state === "running") {
job.state = "failed";
job.error = { message: "设备已断开(模拟)" };
}
}
toggle.textContent = attached ? "模拟拔掉 USB" : "模拟接入 USB";
});
document.body.append(toggle);
await import("./app.js");
+82
View File
@@ -0,0 +1,82 @@
import { execFileSync } from 'node:child_process';
const origin = new URL(process.env.GITEA_SERVER_URL || 'https://git.render.ink');
if (origin.protocol !== 'https:' || origin.username || origin.password) throw new Error('Gitea requires an HTTPS origin without credentials');
export const server = origin.origin;
export const catalogRepository = process.env.CATALOG_REPOSITORY || 'trove/curios';
export function repositoryPath(repository) {
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repository)) throw new Error('Invalid repository name');
return repository.split('/').map(encodeURIComponent).join('/');
}
export async function api(route, { token = process.env.GITEA_TOKEN, method = 'GET', body, missing = false } = {}) {
const auth = authorization(token);
const response = await fetch(`${server}/api/v1${route}`, {
method, redirect: 'error', signal: AbortSignal.timeout(60_000),
headers: { Authorization: auth, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) },
body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body),
});
if (missing && response.status === 404) return null;
if (!response.ok) {
const error = new Error(`Gitea ${method} ${route.split('?')[0]} returned HTTP ${response.status}`);
error.status = response.status;
throw error;
}
if (response.status === 204) return null;
return response.json();
}
// Keep credentials on this Gitea origin; release storage may redirect to a CDN.
export async function download(url, token = process.env.GITEA_TOKEN, limit = 512 * 1024 * 1024) {
let current = new URL(url);
for (let redirects = 0; redirects <= 5; redirects++) {
if (current.protocol !== 'https:' || current.username || current.password) throw new Error('Invalid release download URL');
const response = await fetch(current, {
redirect: 'manual', signal: AbortSignal.timeout(120_000),
headers: current.origin === server ? { Authorization: authorization(token) } : {},
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
await response.body?.cancel();
if (!location) throw new Error('Release redirect has no location');
current = new URL(location, current);
continue;
}
if (!response.ok) throw new Error(`Release download returned HTTP ${response.status}`);
const chunks = []; let size = 0;
for await (const chunk of response.body) {
size += chunk.length;
if (size > limit) throw new Error('Release download exceeds declared size limit');
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
throw new Error('Too many release download redirects');
}
let localAuthorization;
function authorization(token) {
if (token) return `token ${token}`;
if (process.env.GITEA_ACTIONS === 'true') throw new Error('Workflow token is missing');
if (!localAuthorization) {
const output = execFileSync('git', ['-c', 'credential.interactive=false', 'credential', 'fill'], {
input: `url=${server}\n\n`, encoding: 'utf8', timeout: 10_000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, stdio: ['pipe', 'pipe', 'ignore'],
});
const fields = Object.fromEntries(output.trim().split('\n').map(line => {
const separator = line.indexOf('='); return [line.slice(0, separator), line.slice(separator + 1)];
}));
if (!fields.username || !fields.password) throw new Error('Set GITEA_TOKEN or configure Git HTTPS credentials');
localAuthorization = `Basic ${Buffer.from(`${fields.username}:${fields.password}`).toString('base64')}`;
}
return localAuthorization;
}
export async function notifyCatalog(repository, tag) {
await api(`/repos/${repositoryPath(catalogRepository)}/actions/workflows/sync-release.yml/dispatches`, {
token: process.env.CATALOG_TOKEN,
method: 'POST', body: { ref: 'main', inputs: { repository, tag } },
});
console.log(`Requested catalog registration: ${repository}@${tag}`);
}
-506
View File
@@ -1,506 +0,0 @@
import { test, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, cp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import { setImmediate as flush } from "node:timers/promises";
import { JSDOM } from "jsdom";
const root = fileURLToPath(new URL("../", import.meta.url));
let cleanup;
afterEach(async () => {
if (cleanup) {
await cleanup();
cleanup = null;
}
});
async function settle() {
for (let i = 0; i < 6; i++) await flush();
}
async function app(t) {
t.mock.timers.enable({ apis: ["setTimeout"] });
const directory = await mkdtemp(path.join(tmpdir(), "harmony-ui-"));
await cp(path.join(root, "curios/harmony-connector/ui"), directory, {
recursive: true,
});
await cp(
path.dirname(fileURLToPath(import.meta.resolve("@trove/plugin-sdk"))),
path.join(directory, "sdk"),
{ recursive: true },
);
const dom = new JSDOM(
await readFile(path.join(directory, "index.html"), "utf8"),
{ url: "https://curio.test" },
);
globalThis.window = dom.window;
globalThis.document = dom.window.document;
const device = {
id: "7:3",
name: "daniel",
model: "HUAWEI Pura 70 Ultra",
manufacturer: "HUAWEI",
};
const storages = [{ id: 1, name: "内部存储", free: 1e9, capacity: 2e9 }];
const model = {
hdcAvailable: false,
hdcConnected: true,
confirmed: false,
parameters: [],
attached: true,
connection: null,
calls: [],
job: null,
busy: false,
monitorWait: null,
screenActive: false,
screenTarget: "",
screenError: null,
screenOpenWait: null,
openedUrls: [],
selectedDirectory: "/fixture/folder",
configureError: null,
};
const { createMockBridge } = await import(
pathToFileURL(path.join(directory, "sdk/index.js"))
);
const handler = (method, fn) => [
`@self.${method}`,
async (params) => {
model.calls.push(method);
model.parameters.push([method, structuredClone(params)]);
return structuredClone(await fn(params));
},
];
window.__TROVE_CONTEXT__ = {
curioId: "dev.trove.harmony-connector",
version: "0.1.0",
instanceId: "test",
platform: "macos",
};
window.__TROVE_BRIDGE__ = createMockBridge(
Object.fromEntries([
handler("hdc.screenWindowOpen", async ({target}) => {
if (model.screenOpenWait) await model.screenOpenWait;
if (model.screenError) throw model.screenError;
model.screenActive = true;
model.screenTarget = target;
return { active: true, ready: true, target };
}),
handler("hdc.screenWindowStop", () => ({ active: (model.screenActive = false) })),
handler("hdc.screenWindowStatus", () => ({ active: model.screenActive, ready: model.screenActive, target: model.screenTarget, error: model.screenError })),
handler("hdc.status", () => ({ busy: false, job: null })),
handler("hdc.tool", () => ({
available: model.hdcAvailable,
version: "HDC test",
path: "/fixture/hdc",
})),
handler("hdc.configure", () => {
if (model.configureError) throw model.configureError;
model.hdcAvailable = true;
return { available: true, version: "HDC test", path: "/fixture/hdc" };
}),
[
"host.runtime.openUrl",
({ url }) => {
model.openedUrls.push(url);
return { ok: true };
},
],
handler("hdc.devices", () => ({
devices: model.hdcConnected
? [{ id: "phone", transport: "USB", state: "Connected" }]
: [],
})),
handler("hdc.inspect", () => ({
capabilities: Object.fromEntries(
["apps", "launch", "logs", "report", "screenshot"].map((key) => [
key,
{ available: true },
]),
),
info: { text: "Test device" },
})),
handler("hdc.apps", () => ({ apps: ["com.example.app"] })),
handler("hdc.app", () => ({
bundle: "com.example.app",
versionCode: 1,
versionName: "1.0",
debug: false,
abilities: [{ name: "Home", module: "entry" }],
details: {},
})),
handler("hdc.package", () => ({
bundle: "com.example.app",
versionCode: 2,
versionName: "2.0",
sha256: "confirmed-sha256",
size: 100,
})),
handler("hdc.start", () => ({ id: 9 })),
["host.dialog.confirm", () => ({ confirmed: model.confirmed })],
["host.dialog.openDirectory", () => ({ path: model.selectedDirectory })],
handler("info", () => ({ connection: model.connection, busy: false })),
handler("devices", () => ({ devices: model.attached ? [device] : [] })),
handler("connect", () => (model.connection = { device, storages })),
handler("disconnect", () => {
model.connection = null;
return { disconnected: true, busy: model.busy };
}),
handler("list", () => ({
entries: [
{
id: 2,
parent: 0xffffffff,
storage: 1,
name: "旅行.pdf",
size: 100,
folder: false,
},
],
total: 1,
})),
handler("transferStatus", () => model.job),
handler("transfer", () => {
model.job = { id: 1, state: "running", sent: 0, total: 100 };
return { id: 1 };
}),
["host.dialog.openFile", () => ({ path: "/fixture/file.txt" })],
handler("monitor", async () => {
const snapshot = {
connection: model.connection,
devices: model.attached ? [device] : [],
busy: model.busy,
};
if (!model.attached) model.connection = null;
if (model.monitorWait) {
await model.monitorWait;
return snapshot;
}
return { ...snapshot, connection: model.connection };
}),
]),
);
cleanup = async () => {
dom.window.dispatchEvent(new dom.window.Event("pagehide"));
dom.window.close();
delete globalThis.window;
delete globalThis.document;
await rm(directory, { recursive: true, force: true });
};
await import(pathToFileURL(path.join(directory, "app.js")));
await settle();
const $ = (id) => document.getElementById(id);
const click = async (id) => {
$(id).click();
await settle();
};
const tick = async () => {
t.mock.timers.tick(2500);
await settle();
};
const connect = async () => {
await click("scan");
await click("connect");
assert.equal($("browser").hidden, false);
};
return { $, click, tick, connect, model };
}
test("opening the plugin discovers already attached devices without clicking scan", async (t) => {
const { $ } = await app(t);
assert.equal($("device-picker").hidden, false);
assert.equal($("device-select").options.length, 1);
});
test("plugging in and unplugging refreshes the picker without manual scanning", async (t) => {
const { $, tick, model } = await app(t);
model.attached = false;
await tick();
assert.equal($("device-picker").hidden, true);
model.attached = true;
await tick();
assert.equal($("device-picker").hidden, false);
model.attached = false;
await tick();
assert.equal($("device-select").options.length, 0);
});
test("unplugging an idle connected phone clears the browser and connected badge", async (t) => {
const { $, connect, tick, model } = await app(t);
await connect();
model.attached = false;
await tick();
assert.equal($("browser").hidden, true);
assert.equal($("device-dot").classList.contains("connected"), false);
assert.equal($("files").children.length, 0);
assert.equal($("device-select").options.length, 0);
});
test("manual disconnect clears files, filters and completed transfers", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
await click("upload");
model.job = {
id: 1,
state: "completed",
result: { name: "旅行.pdf", bytes: 100 },
};
await tick();
assert.equal($("transfer").hidden, false);
$("search").value = "旅行";
await click("disconnect");
await tick();
assert.equal($("browser").hidden, true);
assert.equal($("files").children.length, 0);
assert.equal($("search").value, "");
assert.equal($("transfer").hidden, true);
assert.equal($("footer-status").textContent.includes("已连接"), false);
});
test("unplugging after a completed transfer clears the old success panel", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
await click("upload");
model.job = {
id: 1,
state: "completed",
result: { name: "旅行.pdf", bytes: 100 },
};
await tick();
assert.equal($("transfer").hidden, false);
model.attached = false;
await tick();
assert.equal($("transfer").hidden, true);
});
test("connected device displays the friendly name and actual model separately", async (t) => {
const { $, connect } = await app(t);
await connect();
assert.equal($("device-name").textContent, "daniel");
assert.equal($("device-model").textContent, "HUAWEI Pura 70 Ultra");
assert.equal($("device-model").hidden, false);
});
test("an in-flight USB snapshot cannot restore a manually disconnected session", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
let resolve;
model.monitorWait = new Promise((done) => {
resolve = done;
});
await tick();
const disconnect = click("disconnect");
resolve();
await disconnect;
await settle();
assert.equal($("browser").hidden, true);
assert.equal($("files").children.length, 0);
});
test("closing the UI stops automatic scanning", async (t) => {
const { tick, model } = await app(t);
const before = model.calls.length;
window.dispatchEvent(new window.Event("pagehide"));
await tick();
await tick();
assert.equal(model.calls.length, before);
});
test("USB disappearance during a transfer clears the browser while cleanup is pending", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
await click("upload");
model.attached = false;
model.busy = true;
await tick();
assert.equal($("browser").hidden, true);
assert.equal($("device-dot").classList.contains("connected"), false);
assert.equal($("scan").disabled, true);
assert.equal($("footer-status").textContent.includes("设备已断开"), true);
});
test("manual disconnect keeps the UI responsive while USB cleanup is pending", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
model.busy = true;
const scans = model.calls.filter((method) => method === "devices").length;
await click("disconnect");
assert.equal($("browser").hidden, true);
assert.equal($("files").children.length, 0);
assert.equal($("scan").disabled, true);
assert.equal($("footer-status").textContent.includes("设备已断开"), true);
assert.equal(
model.calls.filter((method) => method === "devices").length,
scans,
);
model.busy = false;
await tick();
assert.equal($("connect").disabled, false);
assert.equal($("browser").hidden, true);
});
test("whole folder upload uses the directory picker and exposes transfer metrics", async (t) => {
const { $, click, connect, model, tick } = await app(t);
await connect();
await click("upload-folder");
assert.equal(
model.parameters.find(([name]) => name === "transfer")[1].path,
"/fixture/folder",
);
model.job.statistics = {
bytesPerSecond: 1024,
averageBytesPerSecond: 1024,
elapsedSeconds: 2,
remainingSeconds: 3,
filesCompleted: 1,
filesTotal: 4,
currentFile: "nested/data.bin",
};
await tick();
assert.match($("transfer-current").textContent, /nested\/data.bin/);
assert.match($("transfer-metrics").textContent, /KB\/s/);
});
test("management is optional and missing HDC does not disable file transfers", async (t) => {
const { $, click, connect, model } = await app(t);
assert.ok(!model.calls.some((name) => name.startsWith("hdc.")));
await click("tab-management");
assert.match($("hdc-tool-status").textContent, /未发现/);
assert.equal($("app-install").disabled, true);
await click("tab-files");
await connect();
assert.equal($("browser").hidden, false);
});
test("missing HDC guides official download and configuring an extracted toolkit", async (t) => {
const { $, click, model } = await app(t);
await click("tab-management");
assert.equal($("hdc-setup")?.hidden, false);
assert.match($("hdc-setup").textContent, /Mac.*ARM64/);
assert.match($("hdc-setup").textContent, /解压/);
assert.match($("hdc-setup").textContent.replace(/\s+/g, " "), /USB 调试/);
await click("hdc-download");
assert.deepEqual(model.openedUrls, [
"https://developer.huawei.com/consumer/cn/download/command-line-tools-for-hmos",
]);
assert.equal($("hdc-setup").hidden, false);
assert.ok(!model.calls.includes("hdc.devices"));
await click("hdc-directory");
assert.deepEqual(
model.parameters.find(([name]) => name === "hdc.configure")[1],
{
path: "/fixture/folder",
},
);
assert.equal($("hdc-setup").hidden, true);
assert.equal($("hdc-target").options.length, 1);
});
test("refresh rechecks tool availability and clears stale devices if the tool disappears", async (t) => {
const { $, click, model } = await app(t);
await click("tab-management");
await click("hdc-refresh");
assert.ok(!model.calls.includes("hdc.devices"));
model.hdcAvailable = true;
await click("hdc-refresh");
assert.equal($("hdc-setup").hidden, true);
assert.equal($("screen-start").disabled, false);
model.hdcAvailable = false;
await click("hdc-refresh");
assert.equal($("hdc-setup").hidden, false);
assert.equal($("hdc-target").options.length, 0);
assert.equal($("screen-start").disabled, true);
});
test("cancelled or invalid toolkit selection preserves installation guidance", async (t) => {
const { $, click, model } = await app(t);
await click("tab-management");
model.selectedDirectory = null;
await click("hdc-directory");
assert.ok(!model.calls.includes("hdc.configure"));
model.selectedDirectory = "/fixture/wrong";
model.configureError = new Error("未找到 HDC,请选择解压后的工具目录。");
await click("hdc-directory");
assert.equal($("hdc-setup").hidden, false);
assert.ok(!model.calls.includes("hdc.devices"));
assert.match(document.body.textContent, /未找到 HDC/);
});
test("application installation requires confirmation and pins the reviewed package hash", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("app-install");
assert.ok(!model.calls.includes("hdc.start"));
model.confirmed = true;
await click("app-install");
const params = model.parameters.find(([name]) => name === "hdc.start")[1];
assert.deepEqual(params, {
kind: "install",
target: "phone",
path: "/fixture/file.txt",
sha256: "confirmed-sha256",
});
assert.equal($("app-install").disabled, true);
});
test("device disconnect clears app details and prevents stale target operations", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
$("apps-list").querySelector("button").click();
await settle();
assert.equal($("app-detail").hidden, false);
assert.equal($("app-export").disabled, true);
model.hdcConnected = false;
t.mock.timers.tick(3500);
await settle();
assert.equal($("app-detail").hidden, true);
assert.equal($("app-install").disabled, true);
assert.equal($("app-launch").disabled, true);
});
test("screen opens a separate window with default pointer control", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
assert.equal(model.screenActive, true);
assert.equal($("screen-start").textContent, "显示投屏窗口");
assert.match($("screen-status").textContent, /默认鼠标控制/);
assert.equal($("screen-stop").disabled, false);
assert.ok(!model.calls.includes("hdc.screenFrame"));
});
test("switching management tabs and hiding the page leaves the independent window open", async (t) => {
const { click, model, tick } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
await click("tab-files");
window.document.dispatchEvent(new window.Event("visibilitychange"));
await tick();
assert.equal(model.screenActive, true);
assert.ok(!model.calls.includes("hdc.screenWindowStop"));
});
test("explicitly stopping a window permits reconnecting without a USB replug", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
await click("screen-stop");
assert.equal(model.screenActive, false);
assert.equal($("screen-start").disabled, false);
await click("screen-start");
assert.equal(model.screenActive, true);
});
test("closing the native window is reflected by the launcher", async (t) => {
const { $, click, model, tick } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
model.screenActive = false;
await tick();
assert.equal($("screen-stop").disabled, true);
assert.equal($("screen-start").textContent, "打开投屏窗口");
});
test("a failed window start leaves the launcher available to retry", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
model.screenError = new Error("视频连接失败");
await click("screen-start");
assert.equal($("screen-start").disabled, false);
assert.equal($("screen-stop").disabled, true);
model.screenError = null;
await click("screen-start");
assert.equal(model.screenActive, true);
});
-616
View File
@@ -1,616 +0,0 @@
//! Test adapter around the real Trove package manager, service bus and UDS supervisor.
//! stdin/stdout carry SDK bridge messages only; all state lives in a temporary directory.
use anyhow::{anyhow, ensure, Context, Result};
use serde_json::{json, Value};
use std::{
collections::HashMap,
future::Future,
path::{Path, PathBuf},
sync::Arc,
time::Instant,
};
use tokio::io::{AsyncBufReadExt, BufReader};
use trove::{
package::{AppPaths, InstalledPackage, PackageManager, SourceConfig, SourceKind},
protocol::ProtocolMessage,
runtime::{BackendRegistration, BackendSupervisor},
service::{
builtin::{fs::FsService, runtime::RuntimeService, storage::Storage},
Caller, CurioRegistration, ProviderRef, ServiceBus, ServiceDescriptor,
},
transport::{uds::UnixGateway, Outbound, Peer},
};
fn emit(value: Value) {
println!("{}", value);
}
fn read_json(path: impl AsRef<Path>) -> Result<Value> {
Ok(serde_json::from_slice(&std::fs::read(path)?)?)
}
fn source(id: &str, index: &Path) -> SourceConfig {
SourceConfig {
id: id.into(),
name: id.into(),
kind: SourceKind::Local,
index: index.display().to_string(),
enabled: true,
priority: 0,
refresh_interval_seconds: 3600,
}
}
fn target() -> &'static str {
if cfg!(target_arch = "aarch64") {
"aarch64-apple-darwin"
} else {
"x86_64-apple-darwin"
}
}
fn expected_error<T>(result: trove::error::Result<T>, code: &str) -> Result<Value> {
match result {
Err(error) => {
ensure!(
error.code == code,
"Expected {code}, got {}: {}",
error.code,
error.message
);
Ok(json!({"code":error.code}))
}
Ok(_) => Err(anyhow!("Expected {code}, operation succeeded")),
}
}
async fn check<F: Future<Output = Result<Value>>>(
rows: &mut Vec<Value>,
name: impl Into<String>,
operation: F,
) {
let name = name.into();
let start = Instant::now();
let row = match operation.await {
Ok(value) => {
json!({"name":name,"status":"pass","value":value,"durationMs":start.elapsed().as_millis()})
}
Err(error) => {
json!({"name":name,"status":"fail","error":{"message":format!("{error:#}")},"durationMs":start.elapsed().as_millis()})
}
};
eprintln!("{} {}", row["status"].as_str().unwrap(), name);
rows.push(row);
}
async fn package_checks(
dist: &Path,
paths: &AppPaths,
packages: &PackageManager,
) -> Result<Vec<Value>> {
let suite = read_json(dist.join("suite.json"))?;
let mut rows = Vec::new();
for entry in suite.as_array().context("suite is an array")? {
if entry["directory"] == "test-upgrade-v2" {
continue;
}
check(
&mut rows,
format!("安装并读取 {}", entry["directory"].as_str().unwrap()),
async {
let package = packages
.install_local(Path::new(entry["file"].as_str().unwrap()), true)
.await?;
ensure!(
package.manifest.id == entry["manifest"]["id"],
"Installed ID changed"
);
ensure!(
package.manifest.development.is_none()
&& package.manifest.build.is_none()
&& package.manifest.package.is_none(),
"Source-only config leaked into package"
);
if let Some(ui) = &package.manifest.ui {
let resolved = trove::runtime::assets::resolve_asset(
&package.root,
&package.manifest.id,
&ui.entry,
&format!("/{}/{}", package.manifest.id, ui.entry),
)?;
ensure!(
std::fs::read_to_string(resolved)?.contains("./main.js"),
"UI entry is not the test console"
);
let base = Path::new(&ui.entry).parent().unwrap();
for relative in [
"main.js",
"config.js",
"checks.js",
"style.css",
"sdk/index.js",
"sdk/bridge.js",
] {
ensure!(
package.root.join(base).join(relative).is_file(),
"Missing UI resource {relative}"
);
}
expected_error(
trove::runtime::assets::resolve_asset(
&package.root,
&package.manifest.id,
&ui.entry,
&format!("/{}/curio.json", package.manifest.id),
),
"asset.forbidden",
)?;
}
if let Some(backend) = &package.manifest.backend {
use std::os::unix::fs::PermissionsExt;
ensure!(
std::fs::metadata(package.root.join(&backend.entry))?
.permissions()
.mode()
& 0o111
!= 0,
"Backend isn't executable after install"
);
}
Ok(json!({"id":package.manifest.id,"version":package.active_version}))
},
)
.await;
}
check(
&mut rows,
"重复安装同版本被拒绝且保留当前状态",
async {
let before = std::fs::read(paths.registry_file())?;
let value = expected_error(
packages
.install_local(&dist.join("packages/dev.trove.qa.host-1.0.0.curio"), true)
.await,
"package.version_exists",
)?;
ensure!(
std::fs::read(paths.registry_file())? == before,
"Duplicate install changed registry"
);
ensure!(
packages.get("dev.trove.qa.host")?.active_version == "1.0.0",
"Duplicate install lost active version"
);
Ok(value)
},
)
.await;
for case in read_json(dist.join("invalid/cases.json"))?
.as_array()
.unwrap()
{
check(
&mut rows,
format!("拒绝无效包 {}", case["name"].as_str().unwrap()),
async {
let before = std::fs::read(paths.registry_file())?;
let result = packages
.install_local(Path::new(case["file"].as_str().unwrap()), true)
.await;
let value = expected_error(result, case["expectedError"].as_str().unwrap())?;
ensure!(
std::fs::read(paths.registry_file())? == before,
"Failed install changed registry"
);
ensure!(
std::fs::read_dir(paths.support.join(".staging"))?
.next()
.is_none(),
"Failed install leaked staging files"
);
Ok(value)
},
)
.await;
}
for case in read_json(dist.join("invalid/source-cases.json"))?
.as_array()
.unwrap()
{
check(
&mut rows,
format!("拒绝异常软件源 {}", case["name"].as_str().unwrap()),
async {
let sandbox = tempfile::tempdir()?;
let manager =
PackageManager::open(AppPaths::under(sandbox.path()), "0.1.0", target())?;
let id = case["id"].as_str().unwrap();
manager.add_source(source(id, Path::new(case["file"].as_str().unwrap())))?;
manager.refresh_source(id).await?;
let result = manager
.install_from_source(
id,
case["curioId"].as_str().unwrap_or("dev.trove.qa.host"),
"1.0.0",
true,
)
.await;
let value = expected_error(result, case["expectedError"].as_str().unwrap())?;
ensure!(
manager.list().is_empty(),
"Invalid source installed a package"
);
Ok(value)
},
)
.await;
}
check(
&mut rows,
"本地软件源刷新、安装、缓存恢复和禁用",
async {
let sandbox = tempfile::tempdir()?;
let source_paths = AppPaths::under(sandbox.path());
let manager = PackageManager::open(source_paths.clone(), "0.1.0", target())?;
manager.add_source(source("trove-qa", &dist.join("index.json")))?;
manager.refresh_source("trove-qa").await?;
let count = manager.catalog().len();
ensure!(count == 8, "Expected 8 normal Curio IDs, got {count}");
let installed = manager
.install_from_source("trove-qa", "dev.trove.qa.host", "1.0.0", true)
.await?;
ensure!(
installed.source_id.as_deref() == Some("trove-qa"),
"Source origin was lost"
);
drop(manager);
let manager = PackageManager::open(source_paths, "0.1.0", target())?;
ensure!(
manager.catalog().len() == count && manager.list().len() == 1,
"Durable source cache or installation lost"
);
let mut config = source("trove-qa", &dist.join("index.json"));
config.enabled = false;
manager.add_source(config)?;
expected_error(
manager
.install_from_source("trove-qa", "dev.trove.qa.isolation", "1.0.0", true)
.await,
"source.disabled",
)?;
Ok(json!({"catalogIds":count,"sourceCacheRestored":true}))
},
)
.await;
check(&mut rows, "HTTP 软件源刷新、下载和校验安装", async {
let sandbox = tempfile::tempdir()?;
let manager = PackageManager::open(AppPaths::under(sandbox.path()), "0.1.0", target())?;
let mut config = source("trove-qa", &dist.join("index.json"));
config.kind = SourceKind::Http;
config.index = std::env::var("QA_HTTP_SOURCE_URL")?;
manager.add_source(config)?;
manager.refresh_source("trove-qa").await?;
let package = manager
.install_from_source("trove-qa", "dev.trove.qa.host", "1.0.0", true)
.await?;
ensure!(
package.manifest.id == "dev.trove.qa.host",
"HTTP install identity changed"
);
Ok(json!({"downloaded":true,"hashVerified":true,"version":package.active_version}))
})
.await;
check(&mut rows, "升级暂存、激活、回滚、卸载与数据保留", async {
let id = "dev.trove.qa.upgrade";
let data = paths.data_dir(id);
std::fs::create_dir_all(&data)?;
let marker = data.join("qa-upgrade-marker.txt");
std::fs::write(&marker, "baseline-1.0.0")?;
let file = dist.join("packages/dev.trove.qa.upgrade-1.1.0.curio");
let pending = packages.install_local(&file, false).await?;
ensure!(pending.active_version == "1.0.0" && pending.pending_version.as_deref() == Some("1.1.0"), "Update wasn't staged");
ensure!(packages.activate(id, "1.1.0")?.active_version == "1.1.0", "Activation failed");
ensure!(packages.rollback(id)?.active_version == "1.0.0", "Rollback failed");
ensure!(std::fs::read_to_string(&marker)? == "baseline-1.0.0", "Update changed data");
packages.set_enabled(id, false)?;
ensure!(!packages.get(id)?.enabled, "Disable not persisted");
packages.set_enabled(id, true)?;
packages.uninstall(id, false)?;
ensure!(marker.is_file(), "Keep-data uninstall deleted data");
packages.install_local(&dist.join("packages/dev.trove.qa.upgrade-1.0.0.curio"), true).await?;
ensure!(std::fs::read_to_string(&marker)? == "baseline-1.0.0", "Reinstall changed data");
packages.uninstall(id, true)?;
ensure!(!data.exists(), "Remove-data uninstall retained data");
expected_error(packages.get(id), "package.not_installed")?;
Ok(json!({"staged":true,"activated":true,"rolledBack":true,"reinstalled":true,"dataRemoved":true}))
}).await;
Ok(rows)
}
fn register(
package: &InstalledPackage,
paths: &AppPaths,
runtime: &Arc<BackendSupervisor>,
bus: &Arc<ServiceBus>,
) -> Result<()> {
let manifest = &package.manifest;
bus.register_curio(CurioRegistration {
curio_id: manifest.id.clone(),
version: manifest.version.clone(),
enabled: package.enabled,
provides: package
.contracts
.iter()
.map(|contract| ServiceDescriptor {
id: contract.service.clone(),
version: contract.version.clone(),
provider: ProviderRef::CurioBackend {
curio_id: manifest.id.clone(),
},
contract: contract.clone(),
default_timeout_ms: 5000,
})
.collect(),
requires: manifest
.services
.requires
.iter()
.map(|r| (r.id.clone(), r.version.clone()))
.collect(),
})?;
if let Some(definition) = &manifest.backend {
runtime.register(BackendRegistration {
curio_id: manifest.id.clone(),
version: manifest.version.clone(),
root: package.root.clone(),
definition: definition.clone(),
data_dir: paths.data_dir(&manifest.id),
cache_dir: paths.cache_dir(&manifest.id),
log_dir: paths.log_dir(&manifest.id),
instance_id: None,
enabled: package.enabled,
command: None,
})?;
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(String::as_str) == Some("--remote-source") {
return remote_source_check(
args.get(2).context("remote source URL required")?,
args.get(3).context("remote source ID required")?,
args.get(4).map(String::as_str).unwrap_or("http"),
)
.await;
}
let dist = PathBuf::from(std::env::args().nth(1).context("dist directory required")?)
.canonicalize()?;
let sandbox = tempfile::Builder::new()
.prefix("trove-qa-")
.tempdir_in("/tmp")?;
let paths = AppPaths::under(sandbox.path());
let packages = Arc::new(PackageManager::open(paths.clone(), "0.1.0", target())?);
let package_results = package_checks(&dist, &paths, &packages).await?;
let runtime = BackendSupervisor::new(sandbox.path().join("host.sock"));
let bus = ServiceBus::new(runtime.clone());
bus.register_builtin(Arc::new(RuntimeService {
paths: paths.clone(),
packages: packages.clone(),
}))?;
bus.register_builtin(Arc::new(Arc::new(Storage::new(paths.support.join("data")))))?;
bus.register_builtin(Arc::new(FsService {
paths: paths.clone(),
}))?;
for package in packages.list() {
register(&package, &paths, &runtime, &bus)?;
}
let gateway =
UnixGateway::bind(runtime.socket_path().into(), runtime.clone(), bus.clone()).await?;
emit(json!({"ready":true,"packageResults":package_results,"temporaryRoot":sandbox.path()}));
let mut peers: HashMap<String, Arc<Peer>> = HashMap::new();
let mut lines = BufReader::new(tokio::io::stdin()).lines();
while let Some(line) = lines.next_line().await? {
let envelope: Value = serde_json::from_str(&line)?;
let op = envelope["op"].as_str().unwrap_or("");
if op == "shutdown" {
break;
}
if op == "send" {
let peer = peers
.get(envelope["connection"].as_str().unwrap_or(""))
.context("Unknown connection")?
.clone();
let message = ProtocolMessage::from_value(envelope["message"].clone())?;
let bus = bus.clone();
tokio::spawn(async move {
if let Err(error) = bus.dispatch(peer, message).await {
emit(json!({"fatal":error}));
}
});
continue;
}
let result: Result<Value> = async {
let id = envelope["curioId"].as_str().unwrap_or("");
match op {
"connect" => {
let package = packages.get(id)?;
let connection = envelope["connection"].as_str().context("connection required")?.to_string();
let (outbound, mut receiver) = Outbound::channel(512, 16 * 1024 * 1024);
let peer = Arc::new(Peer { id: connection.clone(), caller: Caller { curio_id: Some(id.into()), instance_id: Some(connection.clone()), endpoint: "ui".into() }, outbound, closed: Default::default(), features: vec!["call".into(),"cancel".into(),"subscribe".into()] });
bus.attach_peer(peer.clone());
peers.insert(connection.clone(), peer);
let key = connection.clone();
tokio::spawn(async move { while let Some(outbound) = receiver.recv().await { emit(json!({"connection":key,"message":outbound.message})); } });
Ok(json!({"curioId":id,"curioVersion":package.active_version,"instanceId":connection,"apiVersion":1,"mode":"production"}))
}
"disconnect" => {
let key = envelope["connection"].as_str().context("connection required")?;
bus.disconnect(key).await; peers.remove(key); Ok(json!({"disconnected":true}))
}
"status" => Ok(runtime.status(id).await),
"diagnostics" => Ok(bus.diagnostics()),
"strictContracts" => {
let enabled = envelope["value"].as_bool().context("strictContracts boolean required")?;
bus.strict_contracts(enabled);
Ok(json!({"strictContracts":enabled}))
}
"start" => { runtime.ensure_connected(id).await?; Ok(runtime.status(id).await) }
"retry" => { runtime.retry(id).await?; Ok(runtime.status(id).await) }
"stop" => { runtime.shutdown(id, "qa-test").await?; Ok(runtime.status(id).await) }
"enabled" => {
let enabled = envelope["value"].as_bool().context("enabled boolean required")?;
runtime.shutdown(id, "qa-toggle").await?;
let package = packages.set_enabled(id, enabled)?;
register(&package, &paths, &runtime, &bus)?;
Ok(json!({"enabled":enabled}))
}
_ => Err(anyhow!("Unknown operation {op}")),
}
}.await;
match result {
Ok(value) => emit(json!({"controlId":envelope["controlId"],"result":value})),
Err(error) => emit(
json!({"controlId":envelope["controlId"],"error":{"message":format!("{error:#}")}}),
),
}
}
bus.shutdown().await;
runtime.shutdown_all().await;
gateway.shutdown().await;
Ok(())
}
async fn remote_source_check(url: &str, source_id: &str, kind: &str) -> Result<()> {
let kind = match kind {
"git" => SourceKind::Git,
"http" => SourceKind::Http,
_ => anyhow::bail!("Remote source kind must be git or http"),
};
let sandbox = tempfile::Builder::new()
.prefix("trove-remote-qa-")
.tempdir_in("/tmp")?;
let paths = AppPaths::under(sandbox.path());
let packages = Arc::new(PackageManager::open(paths.clone(), "0.1.0", target())?);
let runtime = BackendSupervisor::new(sandbox.path().join("host.sock"));
let bus = ServiceBus::new(runtime.clone());
bus.register_builtin(Arc::new(RuntimeService {
paths: paths.clone(),
packages: packages.clone(),
}))?;
bus.register_builtin(Arc::new(Arc::new(Storage::new(paths.support.join("data")))))?;
let gateway =
UnixGateway::bind(runtime.socket_path().into(), runtime.clone(), bus.clone()).await?;
let mut rows = Vec::new();
check(&mut rows, "从真实远程源读取索引", async {
packages.add_source(SourceConfig {
id: source_id.into(),
name: source_id.into(),
kind,
index: url.into(),
enabled: true,
priority: 0,
refresh_interval_seconds: 3600,
})?;
let refreshed = packages.refresh_source(source_id).await?;
ensure!(!refreshed.stale, "Remote source refresh used stale cache");
ensure!(
!packages.catalog().is_empty(),
"Remote source has no installable plugins"
);
Ok(json!({"url":url,"sourceId":source_id,"curioCount":packages.catalog().len()}))
})
.await;
if rows.iter().all(|row| row["status"] == "pass") {
for entry in packages.catalog() {
// Our publication index puts new releases first. Install history oldest
// first so the runtime check below exercises the newest published build.
for version in entry.package.versions.into_iter().rev() {
check(&mut rows, format!("远程安装 {}@{}", entry.package.id, version.version), async {
let package = packages.install_from_source(source_id, &entry.package.id, &version.version, true).await?;
ensure!(package.source_id.as_deref() == Some(source_id), "Installation wasn't bound to the remote source");
if let Some(ui) = &package.manifest.ui {
let asset = trove::runtime::assets::resolve_asset(&package.root, &package.manifest.id, &ui.entry, &format!("/{}/{}", package.manifest.id, ui.entry))?;
ensure!(asset.is_file(), "Installed UI entry is missing");
}
Ok(json!({"id":package.manifest.id,"version":package.active_version,"sourceId":package.source_id}))
}).await;
}
}
for package in packages.list() {
register(&package, &paths, &runtime, &bus)?;
}
for package in packages
.list()
.into_iter()
.filter(|package| package.manifest.backend.is_some())
{
check(
&mut rows,
format!("运行远程安装后端 {}", package.manifest.id),
async {
let context = trove::service::CallContext::new(
Caller {
curio_id: Some(package.manifest.id.clone()),
instance_id: None,
endpoint: "ui".into(),
},
"remote-check",
);
let result = bus
.call(context, "@self", "info", json!({}), Default::default())
.await?;
ensure!(
result["curioId"] == package.manifest.id
&& result["version"] == package.active_version,
"Backend identity doesn't match installed remote version"
);
ensure!(
result["pid"].as_u64().unwrap_or(0) > 0,
"Backend didn't report a process"
);
Ok(result)
},
)
.await;
}
if packages.get("dev.trove.qa.consumer").is_ok()
&& packages.get("dev.trove.qa.provider").is_ok()
{
check(&mut rows, "远程安装插件间的服务调用", async {
let context = trove::service::CallContext::new(
Caller {
curio_id: Some("dev.trove.qa.consumer".into()),
instance_id: None,
endpoint: "ui".into(),
},
"remote-consumer",
);
let result = bus
.call(
context,
"dev.trove.qa.provider.probe",
"echo",
json!({"remote":true,"text":"远程安装 🧪"}),
Default::default(),
)
.await?;
ensure!(
result == json!({"remote":true,"text":"远程安装 🧪"}),
"Cross-plugin response changed"
);
Ok(result)
})
.await;
}
}
bus.shutdown().await;
runtime.shutdown_all().await;
gateway.shutdown().await;
sandbox.close()?;
let failed = rows.iter().filter(|row| row["status"] == "fail").count();
emit(json!({
"schemaVersion":1,"sourceUrl":url,"sourceId":source_id,"sourceKind":kind,"target":target(),
"scope":"Remote source and packages via real PackageManager; installed native backends via real UDS. Desktop UI rendering is not included.",
"passed":rows.len()-failed,"failed":failed,"temporaryDataRemoved":true,"results":rows,
}));
ensure!(failed == 0, "Remote source verification failed");
Ok(())
}
-104
View File
@@ -1,104 +0,0 @@
"""Generate deliberately invalid .curio archives. Never publish these in the normal source."""
import copy
import json
from pathlib import Path
import stat
import zipfile
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / 'dist' / 'invalid'
OUT.mkdir(parents=True, exist_ok=True)
BASE = {
'schemaVersion': 1, 'id': 'dev.trove.qa.invalid', 'name': '故意无效的测试包',
'version': '1.0.0', 'engine': {'trove': '>=0.1.0 <1.0.0'},
'ui': {'entry': 'ui/index.html'},
}
cases = []
def archive(name, code, mutate=None, entries=None, raw=None):
manifest = copy.deepcopy(BASE)
if mutate:
mutate(manifest)
file = OUT / f'{name}.curio'
with zipfile.ZipFile(file, 'w', zipfile.ZIP_DEFLATED) as bundle:
bundle.writestr('curio.json', raw if raw is not None else json.dumps(manifest))
bundle.writestr('ui/index.html', '<!doctype html><title>Invalid fixture</title>')
for entry, content in entries or []:
bundle.writestr(entry, content)
cases.append({'name': name, 'file': str(file), 'expectedError': code})
archive('malformed-manifest', 'package.invalid_manifest', raw='{broken')
archive('unknown-manifest-field', 'package.invalid_manifest', lambda m: m.update(unknown=True))
archive('unsupported-schema', 'package.invalid_manifest', lambda m: m.update(schemaVersion=2))
archive('reserved-id', 'package.invalid_id', lambda m: m.update(id='host.qa.invalid'))
archive('invalid-version', 'package.invalid_manifest', lambda m: m.update(version='not-semver'))
archive('incompatible-engine', 'package.engine_incompatible', lambda m: m.update(engine={'trove': '>=99.0.0'}))
archive('missing-ui', 'package.missing_entry', lambda m: m['ui'].update(entry='ui/missing.html'))
archive('no-ui-or-backend', 'package.invalid_manifest', lambda m: m.pop('ui'))
archive('manifest-path-traversal', 'package.invalid_manifest', lambda m: m['ui'].update(entry='../escape.html'))
archive('reserved-backend-env', 'package.invalid_manifest', lambda m: m.update(backend={
'entry': 'backend/probe', 'lifecycle': 'on-demand', 'transport': 'unix', 'env': {'TROVE_SESSION_TOKEN': 'override'}
}))
archive('service-without-backend', 'package.invalid_manifest', lambda m: m.update(services={'provides': [
{'id': m['id'] + '.probe', 'version': '1.0.0', 'contract': 'contracts/probe.json'}
]}))
def provider(m):
m['backend'] = {'entry': 'backend/probe', 'lifecycle': 'on-demand', 'transport': 'unix'}
m['services'] = {'provides': [{'id': m['id'] + '.probe', 'version': '1.0.0', 'contract': 'contracts/probe.json'}]}
def wrong_namespace(m):
provider(m)
m['services']['provides'][0]['id'] = 'dev.someone.else.probe'
archive('service-namespace', 'package.invalid_manifest', wrong_namespace)
archive('missing-backend', 'package.missing_entry', provider)
archive('missing-contract', 'package.missing_entry', provider, [('backend/probe', 'placeholder')])
contract = {'schemaVersion': 1, 'service': BASE['id'] + '.probe', 'version': '2.0.0', 'methods': {}}
archive('contract-version-mismatch', 'package.contract_mismatch', provider,
[('backend/probe', 'placeholder'), ('contracts/probe.json', json.dumps(contract))])
contract['version'] = '1.0.0'
contract['methods'] = {'probe': {'params': {'type': 'not-a-json-type'}, 'result': {}}}
archive('invalid-contract-schema', 'package.invalid_contract', provider,
[('backend/probe', 'placeholder'), ('contracts/probe.json', json.dumps(contract))])
archive('zip-path-traversal', 'package.unsafe_path', entries=[('../qa-escape.txt', 'must not escape')])
archive('zip-absolute-path', 'package.unsafe_path', entries=[('/tmp/trove-qa-escape.txt', 'must not escape')])
archive('zip-backslash-path', 'package.unsafe_path', entries=[('ui\\escape.txt', 'must not be accepted')])
archive('zip-case-collision', 'package.unsafe_archive', entries=[('UI/INDEX.HTML', 'collides on macOS')])
symlink = zipfile.ZipInfo('ui/link')
symlink.create_system = 3
symlink.external_attr = (stat.S_IFLNK | 0o777) << 16
archive('zip-symlink', 'package.unsafe_archive', entries=[(symlink, '../../outside')])
truncated = OUT / 'truncated-archive.curio'
truncated.write_bytes(b'PK\x03\x04broken')
cases.append({'name': 'truncated-archive', 'file': str(truncated), 'expectedError': 'package.invalid_archive'})
(OUT / 'cases.json').write_text(json.dumps(cases, ensure_ascii=False, indent=2) + '\n')
# Valid source schemas carrying wrong download metadata test checks after index parsing.
baseline = json.loads((ROOT / 'dist/index.json').read_text())
baseline['curios'] = [next(item for item in baseline['curios'] if item['id'] == 'dev.trove.qa.host')]
source_cases = []
for name, field, value, expected in [
('hash-mismatch', 'sha256', '0' * 64, 'package.hash_mismatch'),
('size-mismatch', 'size', 1, 'package.size_mismatch'),
('wrong-target', 'target', 'x86_64-apple-darwin' if __import__('platform').machine() == 'arm64' else 'aarch64-apple-darwin', 'package.target_mismatch'),
]:
index = copy.deepcopy(baseline)
index['id'] = 'qa-' + name
index['curios'][0]['versions'][0]['artifacts'][0][field] = value
file = OUT / f'{name}-index.json'
file.write_text(json.dumps(index, ensure_ascii=False, indent=2) + '\n')
source_cases.append({'name': name, 'id': index['id'], 'file': str(file), 'expectedError': expected})
index = copy.deepcopy(baseline)
index['id'] = 'qa-identity-mismatch'
index['curios'][0]['id'] = 'dev.trove.qa.wrong-identity'
file = OUT / 'identity-mismatch-index.json'
file.write_text(json.dumps(index, ensure_ascii=False, indent=2) + '\n')
source_cases.append({'name': 'identity-mismatch', 'id': index['id'], 'file': str(file), 'expectedError': 'package.identity_mismatch', 'curioId': 'dev.trove.qa.wrong-identity'})
(OUT / 'source-cases.json').write_text(json.dumps(source_cases, ensure_ascii=False, indent=2) + '\n')
print(f'Generated {len(cases)} invalid archives and {len(source_cases)} adverse sources in {OUT}')
+7
View File
@@ -0,0 +1,7 @@
import { api, notifyCatalog, repositoryPath } from './gitea.mjs';
const repository = process.env.GITEA_REPOSITORY;
const tag = process.env.RELEASE_TAG;
if (!tag) throw new Error('RELEASE_TAG is required');
const release = await api(`/repos/${repositoryPath(repository)}/releases/tags/${encodeURIComponent(tag)}`);
if (release.draft || !release.assets.some(asset => asset.name === 'trove-release.json')) throw new Error('A completed release with trove-release.json is required');
await notifyCatalog(repository, tag);
-47
View File
@@ -1,47 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";
import path from "node:path";
import { root, output, run, troveBin } from "./suite.mjs";
run(process.execPath, ["curios/harmony-connector/build.mjs"]);
const directory = path.join(root, "curios/harmony-connector");
const manifest = JSON.parse(
await readFile(path.join(directory, "curio.json"), "utf8"),
);
const file = path.join(
output,
"packages",
`${manifest.id}-${manifest.version}-${process.arch}.curio`,
);
await mkdir(path.dirname(file), { recursive: true });
run(troveBin, ["curio", "check", directory, "--json"]);
run(troveBin, [
"curio",
"pack",
directory,
"--out",
file,
"--overwrite",
"--no-input",
"--json",
]);
const data = await readFile(file);
const entries = [
{
directory: "harmony-connector",
manifest,
file,
artifact: {
target: `${process.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`,
url: pathToFileURL(file).href,
sha256: createHash("sha256").update(data).digest("hex"),
size: data.length,
},
},
];
await writeFile(
path.join(output, "harmony-release.json"),
JSON.stringify(entries, null, 2) + "\n",
);
console.log("Release input: dist/harmony-release.json");
-37
View File
@@ -1,37 +0,0 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import { root, run, suite, output, troveBin } from './suite.mjs';
run(process.execPath, ['tools/build.mjs']);
await mkdir(path.join(output, 'packages'), { recursive: true });
const entries = [];
for (const item of suite) {
const directory = path.join(root, 'curios', item.directory);
const manifest = JSON.parse(await readFile(path.join(directory, 'curio.json'), 'utf8'));
run(troveBin, ['curio', 'check', directory, '--json']);
const file = path.join(output, 'packages', `${manifest.id}-${manifest.version}.curio`);
run(troveBin, ['curio', 'pack', directory, '--out', file, '--overwrite', '--no-input', '--json']);
const bytes = await readFile(file);
entries.push({ ...item, manifest, file, artifact: {
target: manifest.backend ? `${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}-apple-darwin` : 'any',
url: pathToFileURL(file).href,
sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length,
} });
}
const date = new Date().toISOString();
function index(id, name, selected) {
const curios = [];
for (const { manifest: m, artifact } of selected) {
let curio = curios.find(curio => curio.id === m.id);
if (!curio) { curio = { id: m.id, name: m.name, description: m.description, versions: [] }; curios.push(curio); }
curio.versions.unshift({ version: m.version, engine: m.engine, releasedAt: date, artifacts: [artifact] });
}
return { schemaVersion: 1, id, name, generatedAt: date, curios };
}
await writeFile(path.join(output, 'index.json'), JSON.stringify(index('trove-qa', 'Trove 常规测试插件', entries.filter(item => !item.fault)), null, 2) + '\n');
await writeFile(path.join(output, 'faults-index.json'), JSON.stringify(index('trove-qa-faults', 'Trove 故障注入插件(手动安装)', entries.filter(item => item.fault)), null, 2) + '\n');
await writeFile(path.join(output, 'suite.json'), JSON.stringify(entries, null, 2) + '\n');
run('python3', ['tools/invalid-fixtures.py']);
console.log(`\n常规软件源:${path.join(output, 'index.json')}\n故障软件源:${path.join(output, 'faults-index.json')}`);
+10 -86
View File
@@ -1,87 +1,11 @@
import { readFile, writeFile, mkdir, copyFile, rename, rm } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { parseArgs } from 'node:util';
import path from 'node:path';
import { root, output } from './suite.mjs';
import { appendRelease, artifactPath } from './release-catalog.mjs';
const { values } = parseArgs({ options: {
'base-url': { type: 'string' },
catalog: { type: 'string', default: 'source' },
entries: { type: 'string', default: 'dist/suite.json' },
} });
const base = values['base-url'] ? new URL(values['base-url']) : null;
if (base && (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash)) {
throw new Error('Publication base must be an HTTPS directory URL without credentials, query or fragment');
}
if (base && !base.pathname.endsWith('/')) base.pathname += '/';
const catalogDirectory = path.resolve(root, values.catalog);
const publishDirectory = path.join(output, 'release');
const timestamp = new Date().toISOString();
const specifications = [
{ file: 'index.json', id: 'trove-official', name: 'Trove 官方软件源' },
];
const catalogs = new Map();
for (const spec of specifications) {
let index;
try { index = JSON.parse(await readFile(path.join(catalogDirectory, spec.file), 'utf8')); }
catch (error) {
if (error.code !== 'ENOENT') throw error;
index = { schemaVersion: 1, id: spec.id, name: spec.name, generatedAt: timestamp, curios: [] };
}
if (index.id !== spec.id || index.schemaVersion !== 1 || !Array.isArray(index.curios)) throw new Error(`Unexpected catalog identity: ${spec.file}`);
catalogs.set(spec.file, index);
}
const entries = JSON.parse(await readFile(path.resolve(root, values.entries), 'utf8'));
const files = [];
// Validate every proposed addition before changing any catalog or copying artifacts.
for (const entry of entries) {
if (entry.fault) continue;
const bytes = await readFile(entry.file);
const sha256 = createHash('sha256').update(bytes).digest('hex');
if (sha256 !== entry.artifact.sha256 || bytes.length !== entry.artifact.size) throw new Error(`Build metadata doesn't match ${entry.file}; run pack again`);
const relative = artifactPath(entry.manifest, entry.artifact.target);
const url = base ? new URL(relative.split('/').map(encodeURIComponent).join('/'), base).href : relative;
const spec = specifications[0];
const artifact = { ...entry.artifact, url };
catalogs.set(spec.file, appendRelease(catalogs.get(spec.file), entry.manifest, artifact, timestamp));
files.push({ from: entry.file, relative, sha256, size: bytes.length, url });
}
// Git publication keeps immutable packages alongside the canonical index.
if (!base) {
for (const file of files) {
try {
const existing = await readFile(path.join(catalogDirectory, file.relative));
if (existing.length !== file.size || createHash('sha256').update(existing).digest('hex') !== file.sha256) {
throw new Error(`Published artifact differs: ${file.relative}; bump the plugin version`);
}
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
}
// Only the current approved publication plan belongs in the generated upload directory.
await rm(publishDirectory, { recursive: true, force: true });
for (const file of files) {
const destination = path.join(publishDirectory, file.relative);
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(file.from, destination);
if (!base) {
const published = path.join(catalogDirectory, file.relative);
await mkdir(path.dirname(published), { recursive: true });
await copyFile(file.from, published);
}
}
await mkdir(catalogDirectory, { recursive: true });
await mkdir(publishDirectory, { recursive: true });
for (const [file, index] of catalogs) {
const text = JSON.stringify(index, null, 2) + '\n';
await writeFile(path.join(publishDirectory, file), text);
const temporary = path.join(catalogDirectory, `${file}.tmp`);
await writeFile(temporary, text);
await rename(temporary, path.join(catalogDirectory, file));
}
await writeFile(path.join(publishDirectory, 'upload-plan.json'), JSON.stringify({ kind: base ? 'http' : 'git', baseUrl: base?.href, files }, null, 2) + '\n');
console.log(`Prepared ${files.length} immutable artifacts in ${publishDirectory}`);
console.log(`Catalog history: ${catalogDirectory}`);
console.log(base ? 'Next: upload package files without overwrite, verify remote bytes, then atomically publish index.json.' : 'Next: commit source/index.json and source/packages/ together, push the repository, then verify the Git source.');
import { releasePlan } from './release-plan.mjs';
const { values } = parseArgs({ options: { entries: { type: 'string' }, tag: { type: 'string' } } });
if (!values.entries) throw new Error('--entries is required');
const { files, descriptor } = await releasePlan(values.entries, values.tag);
await rm('dist/release', { recursive: true, force: true });
await mkdir('dist/release', { recursive: true });
for (const [name, bytes] of files) await writeFile(`dist/release/${name}`, bytes);
console.log(`Prepared ${files.size} release files in dist/release/`);
console.log(`Create draft release ${descriptor.tag} at commit ${descriptor.commit}, upload every file, then publish.`);
-45
View File
@@ -1,45 +0,0 @@
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { root, output } from "./suite.mjs";
const directory = path.join(output, "harmony-ui-preview");
await mkdir(directory, { recursive: true });
await cp(path.join(root, "curios/harmony-connector/dist/ui"), directory, {
recursive: true,
});
await cp(
path.join(root, "tools/fixtures/harmony-ui.mjs"),
path.join(directory, "fixture.mjs"),
);
const html = (
await readFile(path.join(directory, "index.html"), "utf8")
).replace('src="./app.js"', 'src="./fixture.mjs"');
await writeFile(path.join(directory, "index.html"), html);
createServer(async (request, response) => {
try {
const pathname = new URL(request.url, "http://localhost").pathname;
const file = path.resolve(
directory,
"." + decodeURIComponent(pathname === "/" ? "/index.html" : pathname),
);
if (!file.startsWith(directory + path.sep)) {
response.writeHead(403).end();
return;
}
const data = await readFile(file);
response.setHeader(
"Content-Type",
{
".html": "text/html",
".css": "text/css",
".js": "text/javascript",
".mjs": "text/javascript",
}[path.extname(file)] || "application/octet-stream",
);
response.end(data);
} catch {
response.writeHead(404).end();
}
}).listen(18770, "127.0.0.1", () =>
console.log("UI fixture only: http://127.0.0.1:18770"),
);
+40
View File
@@ -0,0 +1,40 @@
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { api, download, repositoryPath } from './gitea.mjs';
const config = JSON.parse(await readFile('release.json', 'utf8'));
const descriptorBytes = await readFile('dist/release/trove-release.json');
const descriptor = JSON.parse(descriptorBytes);
const { repository, tag } = descriptor;
if (descriptor.schemaVersion !== 1 || repository !== config.repository) throw new Error('Unexpected release descriptor');
const route = `/repos/${repositoryPath(repository)}`;
const files = new Map();
for (const plugin of descriptor.plugins) {
for (const artifact of plugin.artifacts) {
if (!/^[a-zA-Z0-9._+-]+\.curio$/.test(artifact.asset)) throw new Error('Invalid asset filename');
const bytes = await readFile(`dist/release/${artifact.asset}`);
if (bytes.length !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) throw new Error('Package changed after release preparation');
files.set(artifact.asset, bytes);
}
}
files.set('trove-release.json', descriptorBytes);
let release = await api(`${route}/releases/tags/${encodeURIComponent(tag)}`, { missing: true });
if (!release) release = await api(`${route}/releases`, { method: 'POST', body: {
tag_name: tag, target_commitish: descriptor.commit, name: tag, draft: true,
prerelease: config.suite ? false : tag.slice(1).split('+')[0].includes('-'),
body: `${config.name} ${tag}\n\n通过 Trove 软件源安装和更新。`,
} });
for (const [name, bytes] of files) {
const existing = release.assets.find(asset => asset.name === name);
if (existing) {
const published = await download(existing.browser_download_url, undefined, bytes.length);
if (!published.equals(bytes)) throw new Error(`Release asset is immutable: ${name}; publish a new version`);
continue;
}
if (!release.draft) throw new Error(`Published release is incomplete: ${name}; publish a new version`);
const form = new FormData(); form.append('attachment', new Blob([bytes]), name);
const asset = await api(`${route}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', body: form });
release.assets.push(asset);
}
if (release.draft) await api(`${route}/releases/${release.id}`, { method: 'PATCH', body: { draft: false } });
console.log(`Published ${repository}@${tag}`);
console.log('The release workflow will register these packages in the catalog.');
+3 -10
View File
@@ -3,7 +3,7 @@ export function appendRelease(catalog, manifest, artifact, releasedAt) {
const next = structuredClone(catalog);
let curio = next.curios.find(item => item.id === manifest.id);
if (!curio) {
curio = { id: manifest.id, name: manifest.name, description: manifest.description ?? '', versions: [] };
curio = { id: manifest.id, name: manifest.name, description: manifest.description ?? '', ...(manifest.icon ? { icon: manifest.icon } : {}), versions: [] };
next.curios.push(curio);
}
let version = curio.versions.find(item => item.version === manifest.version);
@@ -22,16 +22,9 @@ export function appendRelease(catalog, manifest, artifact, releasedAt) {
curio.versions.unshift(version);
curio.name = manifest.name;
curio.description = manifest.description ?? '';
if (manifest.icon) curio.icon = manifest.icon;
}
version.artifacts.push(structuredClone(artifact));
next.generatedAt = releasedAt;
next.generatedAt = next.generatedAt > releasedAt ? next.generatedAt : releasedAt;
return next;
}
export function artifactPath(manifest, target) {
// Validate path components before they become a publication path.
if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(manifest.id)) throw new Error('Invalid Curio ID');
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(manifest.version)) throw new Error('Invalid plugin version');
if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(target)) throw new Error('Unsupported artifact target');
return `packages/${manifest.id}/${manifest.version}/${target}.curio`;
}
-46
View File
@@ -1,46 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { appendRelease, artifactPath } from './release-catalog.mjs';
const initial = { schemaVersion: 1, id: 'trove-qa', name: 'QA', generatedAt: '2026-09-01T00:00:00Z', curios: [] };
const manifest = { id: 'dev.trove.qa.example', name: 'Example', version: '1.0.0', engine: { trove: '^0.1.0' } };
const arm = { target: 'aarch64-apple-darwin', url: 'https://example.org/1.0.0/arm.curio', sha256: 'a'.repeat(64), size: 100 };
const timestamp = '2026-09-09T00:00:00Z';
const released = () => appendRelease(initial, manifest, arm, timestamp);
test('publishing a new version retains all earlier releases', () => {
const original = released();
const updated = appendRelease(original, { ...manifest, version: '1.1.0' }, { ...arm, url: 'https://example.org/1.1.0/arm.curio', sha256: 'b'.repeat(64) }, '2026-09-10T00:00:00Z');
assert.equal(updated.curios[0].versions.length, 2);
assert.deepEqual(updated.curios[0].versions[1], original.curios[0].versions[0]);
assert.equal(initial.curios.length, 0);
});
test('repeating an identical release does not change dates or metadata', () => {
const original = released();
assert.deepEqual(appendRelease(original, manifest, arm, '2026-10-01T00:00:00Z'), original);
});
test('one version can add a second architecture without replacing the first', () => {
const original = released();
const updated = appendRelease(original, manifest, { ...arm, target: 'x86_64-apple-darwin', url: 'https://example.org/1.0.0/intel.curio', sha256: 'c'.repeat(64) }, '2026-09-10T00:00:00Z');
assert.equal(updated.curios[0].versions.length, 1);
assert.equal(updated.curios[0].versions[0].artifacts.length, 2);
assert.deepEqual(updated.curios[0].versions[0].artifacts[0], arm);
assert.equal(updated.curios[0].versions[0].releasedAt, timestamp);
});
for (const [key, value] of [['sha256', 'd'.repeat(64)], ['size', 101], ['url', 'https://example.org/replaced.curio']]) {
test(`same version and architecture cannot replace ${key}`, () => {
const original = released();
assert.throws(() => appendRelease(original, manifest, { ...arm, [key]: value }, timestamp), /bump the plugin version/);
assert.deepEqual(original.curios[0].versions[0].artifacts[0], arm);
});
}
test('published engine compatibility cannot change silently', () => {
assert.throws(() => appendRelease(released(), { ...manifest, engine: { trove: '^0.2.0' } }, arm, timestamp), /engine range cannot change/);
});
test('artifact paths distinguish architecture, ID and version and reject traversal', () => {
assert.equal(artifactPath(manifest, 'any'), 'packages/dev.trove.qa.example/1.0.0/any.curio');
assert.notEqual(artifactPath(manifest, 'aarch64-apple-darwin'), artifactPath(manifest, 'x86_64-apple-darwin'));
assert.throws(() => artifactPath({ ...manifest, id: '../outside' }, 'any'));
assert.throws(() => artifactPath({ ...manifest, version: '../outside' }, 'any'));
assert.throws(() => artifactPath(manifest, '../outside'));
});
+45
View File
@@ -0,0 +1,45 @@
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { valid } from 'semver';
export async function releasePlan(entriesFile, tag) {
const config = JSON.parse(await readFile('release.json', 'utf8'));
const repository = config.repository;
if (!tag || !/^[a-zA-Z0-9][a-zA-Z0-9._+-]*$/.test(tag)) throw new Error('A release tag is required');
const commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
const entries = JSON.parse(await readFile(entriesFile, 'utf8')).filter(entry => !entry.fault);
if (!entries.length) throw new Error('No packages to publish');
const plugins = new Map(); const files = new Map();
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
for (const entry of entries) {
const manifest = entry.manifest;
if (!config.ids.includes(manifest.id) || !valid(manifest.version)) throw new Error('Unexpected plugin identity/version');
if (!config.suite && tag !== `v${manifest.version}`) throw new Error('Release tag must match curio.json');
if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(entry.artifact.target)) throw new Error('Unsupported package target');
const bytes = await readFile(entry.file);
if (digest(bytes) !== entry.artifact.sha256 || bytes.length !== entry.artifact.size) throw new Error('Package differs from build metadata');
const asset = `${manifest.id}-${manifest.version}-${entry.artifact.target}.curio`;
if (files.has(asset)) throw new Error(`Duplicate release asset: ${asset}`);
files.set(asset, bytes);
const key = `${manifest.id}@${manifest.version}`;
if (!plugins.has(key)) {
let icon;
if (manifest.icon) {
const file = path.resolve(entry.directory, manifest.icon);
if (!file.startsWith(path.resolve(entry.directory) + path.sep)) throw new Error('Icon escapes package directory');
const data = await readFile(file);
const type = { '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[path.extname(file)];
if (type && data.length <= 64 * 1024) icon = `data:${type};base64,${data.toString('base64')}`;
}
plugins.set(key, { id: manifest.id, name: manifest.name, description: manifest.description ?? '', icon,
version: manifest.version, engine: manifest.engine, artifacts: [] });
}
plugins.get(key).artifacts.push({ target: entry.artifact.target, asset, sha256: entry.artifact.sha256, size: bytes.length });
}
const descriptor = Buffer.from(JSON.stringify({ schemaVersion: 1, repository, tag, commit, plugins: [...plugins.values()] }, null, 2) + '\n');
files.set('trove-release.json', descriptor);
return { files, descriptor: JSON.parse(descriptor) };
}
-51
View File
@@ -1,51 +0,0 @@
import { createServer } from 'node:http';
import { createReadStream } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import { output } from './suite.mjs';
// A local-only source server; only the two indexes and package files are exposed.
export async function serve(port = 0) {
let base;
const server = createServer(async (request, response) => {
try {
if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405).end(); return; }
const pathname = new URL(request.url, 'http://localhost').pathname;
if (['/index.json', '/faults-index.json'].includes(pathname)) {
const index = JSON.parse(await readFile(path.join(output, pathname.slice(1)), 'utf8'));
for (const curio of index.curios) for (const version of curio.versions) for (const artifact of version.artifacts) {
artifact.url = `${base}/packages/${encodeURIComponent(path.basename(new URL(artifact.url).pathname))}`;
}
const body = JSON.stringify(index);
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store' });
response.end(request.method === 'HEAD' ? undefined : body);
} else if (/^\/packages\/[a-z0-9][a-z0-9.+-]*\.curio$/.test(pathname)) {
const file = path.join(output, pathname.slice(1));
const metadata = await stat(file);
response.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': metadata.size });
if (request.method === 'HEAD') response.end();
else await pipeline(createReadStream(file), response);
} else { response.writeHead(404).end(); }
} catch (error) {
if (!response.headersSent) response.writeHead(error.code === 'ENOENT' ? 404 : 500);
response.end();
}
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', resolve);
});
base = `http://127.0.0.1:${server.address().port}`;
return { base, close: () => new Promise(resolve => server.close(resolve)) };
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const port = Number(process.env.QA_HTTP_PORT || 8765);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('QA_HTTP_PORT must be 1..65535');
const server = await serve(port);
console.log(`常规 HTTP 源:${server.base}/index.json\n故障 HTTP 源:${server.base}/faults-index.json\nCtrl-C 停止服务。`);
process.once('SIGINT', () => { void server.close(); });
process.once('SIGTERM', () => { void server.close(); });
}
-33
View File
@@ -1,33 +0,0 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
export const root = fileURLToPath(new URL('../', import.meta.url));
export const suite = [
{ directory: 'test-host', mode: 'host' },
{ directory: 'test-isolation', mode: 'host' },
{ directory: 'test-self', mode: 'backend', service: '@self' },
{ directory: 'test-provider' },
{ directory: 'test-consumer', mode: 'backend', service: 'dev.trove.qa.provider.probe' },
{ directory: 'test-optional', mode: 'dependency', service: 'dev.trove.qa.absent.probe', expectedError: 'service.not_found' },
{ directory: 'test-required', mode: 'dependency', service: 'dev.trove.qa.absent.probe', expectedError: 'service.not_found', fault: true },
{ directory: 'test-incompatible', mode: 'dependency', service: 'dev.trove.qa.provider.probe', expectedError: 'service.version_mismatch', fault: true },
{ directory: 'test-autostart' },
{ directory: 'test-crash', mode: 'backend', service: '@self', fault: true },
{ directory: 'test-start-timeout', mode: 'fault', service: '@self', fault: true },
{ directory: 'test-bad-handshake', mode: 'fault', service: '@self', fault: true },
{ directory: 'test-stubborn', mode: 'backend', service: '@self', fault: true },
{ directory: 'test-upgrade-v1', mode: 'upgrade' },
{ directory: 'test-upgrade-v2', mode: 'upgrade' },
];
export function run(command, args, options = {}) {
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', ...options });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`${command} exited with ${result.status ?? result.signal}`);
return result;
}
// Tooling accepts the installed CLI or an explicit build; plugin packages are self-contained.
export const troveBin = process.env.TROVE_BIN || 'trove';
export const output = path.join(root, 'dist');
+80
View File
@@ -0,0 +1,80 @@
import { readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { parseArgs } from 'node:util';
import { valid, validRange, rcompare } from 'semver';
import { api, download, server, catalogRepository, repositoryPath } from './gitea.mjs';
import { appendRelease } from './release-catalog.mjs';
const { values } = parseArgs({ options: {
repository: { type: 'string' }, tag: { type: 'string' }, publish: { type: 'boolean', default: false },
} });
const repository = values.repository || process.env.RELEASE_REPOSITORY;
const tag = values.tag || process.env.RELEASE_TAG;
const config = JSON.parse(await readFile(new URL('../source/repositories.json', import.meta.url), 'utf8'));
const publisher = config.repositories.find(item => item.repository === repository);
if (!publisher || !tag) throw new Error('Repository is not registered in this catalog, or tag is missing');
const release = await api(`/repos/${repositoryPath(repository)}/releases/tags/${encodeURIComponent(tag)}`);
if (release.draft) throw new Error('Draft releases cannot enter the catalog');
const descriptorAsset = release.assets.find(asset => asset.name === 'trove-release.json');
if (!descriptorAsset) throw new Error('Release has no trove-release.json attachment');
const descriptor = JSON.parse(await download(descriptorAsset.browser_download_url, undefined, 1024 * 1024));
if (descriptor.schemaVersion !== 1 || descriptor.repository !== repository || descriptor.tag !== tag || !Array.isArray(descriptor.plugins) || !descriptor.plugins.length) throw new Error('Invalid release descriptor');
if (!/^[a-f0-9]{40,64}$/.test(descriptor.commit)) throw new Error('Invalid release commit');
const refs = await api(`/repos/${repositoryPath(repository)}/git/refs/tags/${encodeURIComponent(tag)}`);
let object = (Array.isArray(refs) ? refs.find(ref => ref.ref === `refs/tags/${tag}`) : refs)?.object;
for (let depth = 0; object?.type === 'tag' && depth < 5; depth++) {
object = (await api(`/repos/${repositoryPath(repository)}/git/tags/${object.sha}`)).object;
}
if (object?.type !== 'commit' || object.sha !== descriptor.commit) throw new Error('Release tag differs from package source commit');
const additions = []; const identities = new Set();
for (const plugin of descriptor.plugins) {
if (!publisher.ids.includes(plugin.id) || !valid(plugin.version) || !validRange(plugin.engine?.trove) || typeof plugin.name !== 'string' || !plugin.name.trim()) throw new Error('Unexpected plugin identity or compatibility range');
if (!publisher.suite && tag !== `v${plugin.version}`) throw new Error('Plugin version differs from release tag');
if (!Array.isArray(plugin.artifacts) || !plugin.artifacts.length) throw new Error('Plugin has no artifacts');
for (const artifact of plugin.artifacts) {
if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(artifact.target) || !/^[a-f0-9]{64}$/.test(artifact.sha256) || !Number.isSafeInteger(artifact.size) || artifact.size < 1 || artifact.size > 512 * 1024 * 1024) throw new Error('Invalid artifact metadata');
const identity = `${plugin.id}@${plugin.version}/${artifact.target}`;
if (identities.has(identity)) throw new Error('Duplicate artifact identity');
identities.add(identity);
const asset = release.assets.find(asset => asset.name === artifact.asset);
if (!asset || asset.size !== artifact.size || new URL(asset.browser_download_url).origin !== server) throw new Error('Artifact is not a matching release attachment');
const bytes = await download(asset.browser_download_url, undefined, artifact.size);
if (bytes.length !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) throw new Error(`Release checksum mismatch: ${identity}`);
additions.push({ plugin, artifact: { target: artifact.target, url: asset.browser_download_url, sha256: artifact.sha256, size: artifact.size } });
}
}
function merge(index) {
if (index.schemaVersion !== 1 || index.id !== config.id || !Array.isArray(index.curios)) throw new Error('Unexpected source identity');
for (const { plugin, artifact } of additions) {
index = appendRelease(index, plugin, artifact, release.published_at || release.created_at);
}
index.curios.sort((a, b) => a.id.localeCompare(b.id));
for (const plugin of index.curios) plugin.versions.sort((a, b) => rcompare(a.version, b.version));
return JSON.stringify(index, null, 2) + '\n';
}
if (!values.publish) {
const file = new URL('../source/index.json', import.meta.url);
await writeFile(file, merge(JSON.parse(await readFile(file, 'utf8'))));
console.log(`Updated local catalog with ${repository}@${tag}`);
} else {
// Gitea's contents SHA provides compare-and-swap. On concurrent publications,
// re-read and merge the latest index instead of overwriting another release.
const route = `/repos/${repositoryPath(catalogRepository)}/contents/source/index.json`;
for (let attempt = 0; attempt < 6; attempt++) {
const file = await api(`${route}?ref=main`, { token: process.env.CATALOG_WRITE_TOKEN });
const before = Buffer.from(file.content, 'base64').toString('utf8');
const content = merge(JSON.parse(before));
if (content === before) { console.log('Release already registered'); break; }
try {
await api(route, { token: process.env.CATALOG_WRITE_TOKEN, method: 'PUT', body: {
branch: 'main', sha: file.sha, content: Buffer.from(content).toString('base64'),
message: `chore(catalog): register ${repository}@${tag}`,
} });
console.log(`Registered ${repository}@${tag}`); break;
} catch (error) {
if (![409, 422].includes(error.status) || attempt === 5) throw error;
}
}
}
-43
View File
@@ -1,43 +0,0 @@
import path from "node:path";
import { root, run, troveBin } from "./suite.mjs";
const plugin = path.join(root, "curios/harmony-connector");
run(process.execPath, [path.join(plugin, "build.mjs")]);
run(
"cargo",
[
"test",
"--locked",
"--manifest-path",
path.join(plugin, "native/Cargo.toml"),
],
{
env: {
...process.env,
HARMONY_NATIVE_PREFIX: path.join(plugin, "dist/native-deps/install"),
MACOSX_DEPLOYMENT_TARGET: "13.0",
},
},
);
run(process.execPath, ["--test", path.join(plugin, "ui/format.test.js")]);
run(process.execPath, ["--test", path.join(plugin, "ui/screen-geometry.test.js")]);
run(process.execPath, ["--test", path.join(root, "tools/harmony-ui.test.mjs")]);
const prefix = path.join(plugin, "dist/native-deps/install");
const bridgeTest = path.join(plugin, "dist/mtp-bridge-test");
run("cc", [
"-I" + path.join(prefix, "include"),
path.join(plugin, "native/tests/mtp_bridge_test.c"),
path.join(plugin, "native/src/mtp_bridge.c"),
"-L" + path.join(prefix, "lib"),
"-lmtp",
"-lusb-1.0",
"-o",
bridgeTest,
]);
run(bridgeTest, []);
run(troveBin, [
"protocol",
"test",
"--command",
path.join(plugin, "backend/harmony-connector"),
]);
run(path.join(plugin, "backend/harmony-connector"), ["--diagnose"]);
-297
View File
@@ -1,297 +0,0 @@
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;
}