test: add comprehensive Curio installation and runtime fixtures
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
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})`);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
//! 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 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(())
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"""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,37 @@
|
||||
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')}`);
|
||||
@@ -0,0 +1,51 @@
|
||||
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(); });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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,280 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
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.
|
||||
await rm(path.join(output, 'verification.json'), { 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.');
|
||||
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]);
|
||||
run(troveBin, ['protocol', 'test', '--command', path.join(root, 'shared/backend/target/release/trove-qa-backend')]);
|
||||
} catch (error) {
|
||||
await writeFile(path.join(output, 'verification.json'), 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