refactor: separate plugin repositories and release-based catalog
This commit is contained in:
@@ -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})`);
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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}')
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
@@ -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
@@ -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.`);
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
@@ -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,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`;
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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) };
|
||||
|
||||
}
|
||||
@@ -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(); });
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user