test: add comprehensive Curio installation and runtime fixtures
This commit is contained in:
Generated
+112
@@ -0,0 +1,112 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trove-qa-backend"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "trove-qa-backend"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,492 @@
|
||||
// Framing and request lifecycle adapted from Trove app/examples/backend-rust (Wire v1).
|
||||
// This diagnostic backend is test-only; fault modes require explicit manifest args.
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
env, fs,
|
||||
io::{Read, Write},
|
||||
os::unix::net::UnixStream,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
|
||||
mpsc, Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
const MAX_FRAME: usize = 8 * 1024 * 1024;
|
||||
const MAX_IN_FLIGHT: usize = 256;
|
||||
static ACTIVE_REQUESTS: AtomicUsize = AtomicUsize::new(0);
|
||||
fn active_count() -> usize {
|
||||
ACTIVE_REQUESTS.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
type WireResult = std::result::Result<Value, Value>;
|
||||
type Pending = Arc<Mutex<HashMap<String, mpsc::Sender<Value>>>>;
|
||||
type Active = Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>;
|
||||
type Subscriptions = Arc<Mutex<HashMap<String, Subscription>>>;
|
||||
struct Subscription {
|
||||
task_id: String,
|
||||
caller: Value,
|
||||
seq: u64,
|
||||
}
|
||||
struct Queued {
|
||||
bytes: Vec<u8>,
|
||||
flushed: Option<mpsc::Sender<()>>,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Writer {
|
||||
sender: mpsc::SyncSender<Queued>,
|
||||
bytes: Arc<AtomicUsize>,
|
||||
}
|
||||
impl Writer {
|
||||
fn new(mut socket: UnixStream) -> Self {
|
||||
let (sender, receiver) = mpsc::sync_channel::<Queued>(512);
|
||||
let bytes = Arc::new(AtomicUsize::new(0));
|
||||
let budget = bytes.clone();
|
||||
thread::spawn(move || {
|
||||
for next in receiver {
|
||||
let result = socket.write_all(&next.bytes).and_then(|_| socket.flush());
|
||||
budget.fetch_sub(next.bytes.len(), Ordering::Relaxed);
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
if let Some(flushed) = next.flushed {
|
||||
let _ = flushed.send(());
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { sender, bytes }
|
||||
}
|
||||
fn send(&self, message: Value) -> Result<()> {
|
||||
let payload = serde_json::to_vec(&message)?;
|
||||
if payload.is_empty() || payload.len() > MAX_FRAME {
|
||||
return Err(anyhow!("Frame exceeds 8 MiB"));
|
||||
}
|
||||
let size = payload.len() + 4;
|
||||
if self
|
||||
.bytes
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| {
|
||||
(used + size <= 16 * 1024 * 1024).then_some(used + size)
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return Err(anyhow!("Outbound byte queue is full"));
|
||||
}
|
||||
let mut bytes = (payload.len() as u32).to_be_bytes().to_vec();
|
||||
bytes.extend(payload);
|
||||
if let Err(error) = self.sender.try_send(Queued {
|
||||
bytes,
|
||||
flushed: None,
|
||||
}) {
|
||||
self.bytes.fetch_sub(size, Ordering::Relaxed);
|
||||
return Err(anyhow!("Outbound queue unavailable: {error}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn flush(&self) {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
if self
|
||||
.sender
|
||||
.try_send(Queued {
|
||||
bytes: vec![],
|
||||
flushed: Some(tx),
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
let _ = rx.recv_timeout(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
fn response(&self, id: Value, result: WireResult) {
|
||||
let message = match result {
|
||||
Ok(result) => json!({"v":1,"type":"response","id":id,"result":result}),
|
||||
Err(error) => json!({"v":1,"type":"response","id":id,"error":error}),
|
||||
};
|
||||
let _ = self.send(message);
|
||||
}
|
||||
}
|
||||
fn error(code: &str, message: impl ToString) -> Value {
|
||||
json!({"code":code,"message":message.to_string(),"retryable":false})
|
||||
}
|
||||
fn key(id: &Value) -> String {
|
||||
id.to_string()
|
||||
}
|
||||
fn read(reader: &mut UnixStream) -> Result<Value> {
|
||||
let mut header = [0; 4];
|
||||
reader.read_exact(&mut header)?;
|
||||
let length = u32::from_be_bytes(header) as usize;
|
||||
if length == 0 || length > MAX_FRAME {
|
||||
return Err(anyhow!("Invalid frame length"));
|
||||
}
|
||||
let mut payload = vec![0; length];
|
||||
reader.read_exact(&mut payload)?;
|
||||
let message: Value = serde_json::from_slice(&payload)?;
|
||||
if !message.is_object() || message["v"] != 1 {
|
||||
return Err(anyhow!("Invalid message version"));
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
fn progress(writer: &Writer, subscriptions: &Subscriptions, context: &Value, amount: f64) {
|
||||
for (id, sub) in subscriptions.lock().unwrap().iter_mut() {
|
||||
if sub.caller == context["caller"] {
|
||||
sub.seq += 1;
|
||||
let _ = writer.send(json!({"v":1,"type":"event","subscriptionId":id,"event":"progress","seq":sub.seq,"data":{"taskId":sub.task_id,"progress":amount}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn call_host(
|
||||
writer: &Writer,
|
||||
pending: &Pending,
|
||||
ids: &AtomicU64,
|
||||
service: &str,
|
||||
method: &str,
|
||||
params: Value,
|
||||
context: &Value,
|
||||
cancelled: &AtomicBool,
|
||||
) -> WireResult {
|
||||
let id = format!("rust_{}", ids.fetch_add(1, Ordering::Relaxed));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
{
|
||||
let mut pending = pending.lock().unwrap();
|
||||
if pending.len() >= MAX_IN_FLIGHT {
|
||||
return Err(error(
|
||||
"request.too_many_in_flight",
|
||||
"Host request limit reached",
|
||||
));
|
||||
}
|
||||
pending.insert(id.clone(), tx);
|
||||
}
|
||||
let timeout_ms = context["deadlineUnixMs"]
|
||||
.as_u64()
|
||||
.unwrap_or(now_ms() + 30_000)
|
||||
.saturating_sub(now_ms())
|
||||
.min(30_000);
|
||||
if let Err(problem) = writer.send(json!({"v":1,"type":"request","id":id,"service":service,"method":method,"params":params,"options":{"timeoutMs":timeout_ms},"context":context})) {
|
||||
pending.lock().unwrap().remove(&id); return Err(error("transport.backpressure", problem));
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
|
||||
let result = loop {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
break Err(error("request.cancelled", "Request cancelled"));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break Err(error("request.timeout", "Host request timed out"));
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(response) => {
|
||||
break match response.get("error") {
|
||||
Some(error) => Err(error.clone()),
|
||||
None => Ok(response["result"].clone()),
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(_) => break Err(error("provider.disconnected", "Host disconnected")),
|
||||
}
|
||||
};
|
||||
if pending.lock().unwrap().remove(&id).is_some() {
|
||||
let _ = writer.send(json!({"v":1,"type":"cancel","id":id}));
|
||||
}
|
||||
result
|
||||
}
|
||||
fn request(
|
||||
message: &Value,
|
||||
writer: &Writer,
|
||||
pending: &Pending,
|
||||
ids: &AtomicU64,
|
||||
subscriptions: &Subscriptions,
|
||||
cancelled: &AtomicBool,
|
||||
) -> WireResult {
|
||||
if !matches!(
|
||||
message["service"].as_str(),
|
||||
Some(service) if service == "@self" || service == format!("{}.probe", env::var("TROVE_CURIO_ID").unwrap_or_default())
|
||||
) {
|
||||
return Err(error("service.not_found", "Unknown service"));
|
||||
}
|
||||
let context = &message["context"];
|
||||
match message["method"].as_str() {
|
||||
Some("echo") => return Ok(message["params"].clone()),
|
||||
Some("info") => {
|
||||
return Ok(json!({
|
||||
"pid":std::process::id(), "curioId":env::var("TROVE_CURIO_ID").unwrap_or_default(),
|
||||
"version":env::var("TROVE_CURIO_VERSION").unwrap_or_default(),
|
||||
"label":env::var("QA_LABEL").unwrap_or_default(), "args":env::args().skip(1).collect::<Vec<_>>(),
|
||||
"activeRequests":active_count(), "subscriptions":subscriptions.lock().unwrap().len()
|
||||
}))
|
||||
}
|
||||
Some("delay") => {
|
||||
let ms = message["params"]["ms"]
|
||||
.as_u64()
|
||||
.filter(|ms| *ms <= 10_000)
|
||||
.ok_or_else(|| {
|
||||
error(
|
||||
"request.invalid_params",
|
||||
"ms must be an integer in 0..10000",
|
||||
)
|
||||
})?;
|
||||
let deadline = Instant::now() + Duration::from_millis(ms);
|
||||
while Instant::now() < deadline {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Err(error("request.cancelled", "Delay cancelled"));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
return Ok(message["params"]["value"].clone());
|
||||
}
|
||||
Some("fail") => {
|
||||
return Err(
|
||||
json!({"code":"qa.intentional_error","message":"Expected QA failure","retryable":false,"data":{"fixture":true}}),
|
||||
)
|
||||
}
|
||||
Some("invalidResult") => return Ok(json!({"intentionally":"not the contract string"})),
|
||||
Some("storageRoundtrip") => {
|
||||
let key = message["params"]["key"]
|
||||
.as_str()
|
||||
.filter(|key| key.starts_with("qa."))
|
||||
.ok_or_else(|| error("request.invalid_params", "key must start with qa."))?;
|
||||
call_host(
|
||||
writer,
|
||||
pending,
|
||||
ids,
|
||||
"host.storage",
|
||||
"set",
|
||||
message["params"].clone(),
|
||||
context,
|
||||
cancelled,
|
||||
)?;
|
||||
return call_host(
|
||||
writer,
|
||||
pending,
|
||||
ids,
|
||||
"host.storage",
|
||||
"get",
|
||||
json!({"key":key}),
|
||||
context,
|
||||
cancelled,
|
||||
);
|
||||
}
|
||||
Some("crash") if env::args().any(|arg| arg == "--allow-crash") => {
|
||||
eprintln!("qa: deliberate exit 42");
|
||||
std::process::exit(42);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let text = match message["method"].as_str() {
|
||||
Some("uppercase") => message["params"]["text"]
|
||||
.as_str()
|
||||
.ok_or_else(|| error("request.invalid_params", "text must be a string"))?
|
||||
.to_owned(),
|
||||
_ => return Err(error("service.method_not_found", "Unknown method")),
|
||||
};
|
||||
progress(writer, subscriptions, context, 0.0);
|
||||
let total = text.chars().count();
|
||||
let mut output = String::new();
|
||||
for (index, character) in text.chars().enumerate() {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Err(error("request.cancelled", "Request cancelled"));
|
||||
}
|
||||
output.extend(character.to_uppercase());
|
||||
if index % 4096 == 0 {
|
||||
progress(
|
||||
writer,
|
||||
subscriptions,
|
||||
context,
|
||||
index as f64 / total.max(1) as f64,
|
||||
);
|
||||
thread::yield_now();
|
||||
}
|
||||
}
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Err(error("request.cancelled", "Request cancelled"));
|
||||
}
|
||||
progress(writer, subscriptions, context, 1.0);
|
||||
Ok(json!({"text":output}))
|
||||
}
|
||||
fn session() -> Result<(String, String)> {
|
||||
if let Ok(path) = env::var("TROVE_DEV_SESSION_FILE") {
|
||||
let value: Value = serde_json::from_slice(&fs::read(path)?)?;
|
||||
return Ok((
|
||||
value["socketPath"]
|
||||
.as_str()
|
||||
.context("missing socketPath")?
|
||||
.into(),
|
||||
value["sessionToken"]
|
||||
.as_str()
|
||||
.context("missing sessionToken")?
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
env::var("TROVE_SOCKET_PATH")?,
|
||||
env::var("TROVE_SESSION_TOKEN")?,
|
||||
))
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let (path, mut token) = session()?;
|
||||
if env::args().any(|arg| arg == "--bad-token") {
|
||||
token = "qa-deliberately-invalid-token".into();
|
||||
}
|
||||
println!("qa: backend started pid={}", std::process::id());
|
||||
eprintln!("qa: label={}", env::var("QA_LABEL").unwrap_or_default());
|
||||
let mut reader = UnixStream::connect(path)?;
|
||||
let writer = Writer::new(reader.try_clone()?);
|
||||
let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
|
||||
let active: Active = Arc::new(Mutex::new(HashMap::new()));
|
||||
let subscriptions: Subscriptions = Arc::new(Mutex::new(HashMap::new()));
|
||||
let ids = Arc::new(AtomicU64::new(1));
|
||||
writer.send(json!({"v":1,"type":"hello","client":"backend","sessionToken":token,"protocol":{"min":1,"max":1},"features":["call","cancel","subscribe"],"implementation":{"name":"trove-qa-backend","version":"1.0.0","language":"rust"}}))?;
|
||||
let welcome = read(&mut reader)?;
|
||||
if welcome["type"] != "welcome" || welcome["protocol"] != 1 {
|
||||
return Err(anyhow!("Expected welcome"));
|
||||
}
|
||||
if env::args().any(|arg| arg == "--no-ready") {
|
||||
// Stay connected but never become ready; the Host must terminate this process.
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
writer.send(json!({"v":1,"type":"ready"}))?;
|
||||
while let Ok(message) = read(&mut reader) {
|
||||
let id = message["id"].clone();
|
||||
match message["type"].as_str() {
|
||||
Some("request") => {
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let mut calls = active.lock().unwrap();
|
||||
if calls.contains_key(&key(&id)) {
|
||||
writer.send(json!({"v":1,"type":"protocolError","error":error("protocol.duplicate_id","Duplicate active request ID")}))?;
|
||||
continue;
|
||||
}
|
||||
if calls.len() >= MAX_IN_FLIGHT {
|
||||
writer.response(
|
||||
id,
|
||||
Err(error(
|
||||
"request.too_many_in_flight",
|
||||
"Backend request limit reached",
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
calls.insert(key(&id), cancelled.clone());
|
||||
}
|
||||
let (writer, pending, ids, subscriptions, active) = (
|
||||
writer.clone(),
|
||||
pending.clone(),
|
||||
ids.clone(),
|
||||
subscriptions.clone(),
|
||||
active.clone(),
|
||||
);
|
||||
ACTIVE_REQUESTS.fetch_add(1, Ordering::Relaxed);
|
||||
thread::spawn(move || {
|
||||
let result = request(
|
||||
&message,
|
||||
&writer,
|
||||
&pending,
|
||||
&ids,
|
||||
&subscriptions,
|
||||
&cancelled,
|
||||
);
|
||||
active.lock().unwrap().remove(&key(&id));
|
||||
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
|
||||
writer.response(id, result);
|
||||
});
|
||||
}
|
||||
Some("response") => {
|
||||
if let Some(id) = id.as_str() {
|
||||
if let Some(tx) = pending.lock().unwrap().remove(id) {
|
||||
let _ = tx.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("cancel") => {
|
||||
if let Some(cancelled) = active.lock().unwrap().get(&key(&id)) {
|
||||
cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Some("subscribe") => {
|
||||
if !matches!(
|
||||
message["service"].as_str(),
|
||||
Some(service) if service == "@self" || service == format!("{}.probe", env::var("TROVE_CURIO_ID").unwrap_or_default())
|
||||
) || message["topic"] != "progress"
|
||||
{
|
||||
writer.response(
|
||||
id,
|
||||
Err(error("service.method_not_found", "Unknown event topic")),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let Some(task_id) = message["params"]["taskId"]
|
||||
.as_str()
|
||||
.filter(|id| !id.is_empty())
|
||||
else {
|
||||
writer.response(
|
||||
id,
|
||||
Err(error(
|
||||
"request.invalid_params",
|
||||
"taskId must be a nonempty string",
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let mut subs = subscriptions.lock().unwrap();
|
||||
if subs.len() >= MAX_IN_FLIGHT {
|
||||
writer.response(
|
||||
id,
|
||||
Err(error(
|
||||
"request.too_many_in_flight",
|
||||
"Subscription limit reached",
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let sub_id = format!("rust_sub_{}", ids.fetch_add(1, Ordering::Relaxed));
|
||||
subs.insert(
|
||||
sub_id.clone(),
|
||||
Subscription {
|
||||
task_id: task_id.into(),
|
||||
caller: message["context"]["caller"].clone(),
|
||||
seq: 0,
|
||||
},
|
||||
);
|
||||
writer.response(id, Ok(json!({"subscriptionId":sub_id})));
|
||||
}
|
||||
Some("unsubscribe") => {
|
||||
let closed = message["subscriptionId"]
|
||||
.as_str()
|
||||
.is_some_and(|id| subscriptions.lock().unwrap().remove(id).is_some());
|
||||
writer.response(id, Ok(json!({"closed":closed})));
|
||||
}
|
||||
Some("shutdown") => {
|
||||
if env::args().any(|arg| arg == "--ignore-shutdown") {
|
||||
eprintln!("qa: deliberately ignoring shutdown");
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
eprintln!("qa: graceful shutdown");
|
||||
writer.send(json!({"v":1,"type":"goodbye"}))?;
|
||||
writer.flush();
|
||||
// Keep the read half alive while the Host finishes its side of shutdown.
|
||||
// The grace bound also allows exit if the Host never sends EOF.
|
||||
reader.set_read_timeout(Some(Duration::from_millis(250)))?;
|
||||
let mut eof = [0u8; 1];
|
||||
let _ = reader.read(&mut eof);
|
||||
break;
|
||||
}
|
||||
Some("protocolError") => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for cancelled in active.lock().unwrap().values() {
|
||||
cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
subscriptions.lock().unwrap().clear();
|
||||
pending.lock().unwrap().clear();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
export function assert(condition, message) { if (!condition) throw new Error(message); }
|
||||
export function equal(actual, expected, label = '返回值不匹配') {
|
||||
// Compare JSON objects independently of property insertion order.
|
||||
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
|
||||
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
||||
assert(JSON.stringify(canonical(actual)) === JSON.stringify(canonical(expected)), `${label}: ${JSON.stringify(actual)}`);
|
||||
}
|
||||
export async function expectError(action, expected) {
|
||||
try { await action(); } catch (error) {
|
||||
assert(error.code === expected, `预期 ${expected},实际 ${error.code ?? error.message}`);
|
||||
return { expected, actual: error.code, message: error.message };
|
||||
}
|
||||
throw new Error(`预期 ${expected},调用却成功了`);
|
||||
}
|
||||
|
||||
export function hostChecks(client) {
|
||||
const call = (service, method, params = {}) => client.call(service, method, params);
|
||||
return [
|
||||
['SDK 与运行上下文', async () => {
|
||||
const context = await call('host.runtime', 'getContext');
|
||||
equal(context.curioId, client.context.curioId);
|
||||
equal(context.curioVersion, client.context.curioVersion);
|
||||
equal(context.apiVersion, 1);
|
||||
return context;
|
||||
}],
|
||||
['Host 信息与数据目录', async () => {
|
||||
const info = await call('host.runtime', 'getHostInfo');
|
||||
const paths = await call('host.runtime', 'getPaths');
|
||||
for (const key of ['data', 'cache', 'logs', 'temp']) assert(paths[key]?.startsWith('/'), `缺少绝对路径 ${key}`);
|
||||
equal(info.protocol, 1);
|
||||
return { info, paths };
|
||||
}],
|
||||
['JSON 与 Unicode 往返', async () => {
|
||||
const value = { text: '中文 / emoji 🧪 / e\u0301 / \n换行', number: 12.5, null: null, array: [false, {}, ''] };
|
||||
equal(await call('host.runtime', 'echo', value), value);
|
||||
return value;
|
||||
}],
|
||||
['32 路并发响应匹配', async () => {
|
||||
const values = await Promise.all(Array.from({ length: 32 }, (_, index) => call('host.runtime', 'echo', { index })));
|
||||
values.forEach((value, index) => equal(value, { index }));
|
||||
return { responses: values.length };
|
||||
}],
|
||||
['服务发现、描述、解析与状态', async () => {
|
||||
const { services } = await call('host.services', 'list', { prefix: 'host.' });
|
||||
for (const id of ['host.runtime', 'host.storage', 'host.fs']) assert(services.some(item => item.id === id), `缺少 ${id}`);
|
||||
const description = await call('host.services', 'describe', { service: 'host.storage' });
|
||||
assert(description.contract.methods.set, '缺少 storage.set 契约');
|
||||
await call('host.services', 'resolve', { service: 'host.storage', version: '^1.0.0' });
|
||||
equal((await call('host.services', 'getStatus', { service: 'host.storage' })).state, 'Available');
|
||||
return services;
|
||||
}],
|
||||
['独立存储、null 与不存在、前缀查询、删除', async () => {
|
||||
const key = `qa.temporary.${crypto.randomUUID()}`;
|
||||
try {
|
||||
equal((await call('host.storage', 'get', { key })).found, false);
|
||||
await call('host.storage', 'set', { key, value: null });
|
||||
equal(await call('host.storage', 'get', { key }), { found: true, value: null });
|
||||
const value = { owner: client.context.curioId, unicode: '存储 🧪', array: [1, false] };
|
||||
await call('host.storage', 'set', { key, value });
|
||||
equal((await call('host.storage', 'get', { key })).value, value);
|
||||
assert((await call('host.storage', 'list', { prefix: key })).keys.includes(key), '前缀查询缺少测试键');
|
||||
await call('host.storage', 'delete', { key });
|
||||
equal((await call('host.storage', 'get', { key })).found, false);
|
||||
return { namespace: client.context.curioId };
|
||||
} finally { await call('host.storage', 'delete', { key }); }
|
||||
}],
|
||||
['持久化与同名键隔离标记', async () => {
|
||||
const key = 'qa.namespace-owner';
|
||||
const previous = await call('host.storage', 'get', { key });
|
||||
if (previous.found) equal(previous.value, client.context.curioId, '存储命名空间混用');
|
||||
await call('host.storage', 'set', { key, value: client.context.curioId });
|
||||
return { previous, stored: client.context.curioId, next: '在对照插件运行检查,然后关闭并重新打开本插件再运行。' };
|
||||
}],
|
||||
['存储单值大小限制', () => expectError(() => call('host.storage', 'set', { key: 'qa.oversize', value: 'x'.repeat(1024 * 1024 + 1) }), 'host.storage.value_too_large')],
|
||||
['文件 CRUD、二进制、复制与移动', async () => {
|
||||
const paths = await call('host.runtime', 'getPaths');
|
||||
const directory = `${paths.temp}/qa-${crypto.randomUUID()}`;
|
||||
const text = `${directory}/中文 空格.txt`;
|
||||
await call('host.fs', 'createDirectory', { path: directory });
|
||||
try {
|
||||
await call('host.fs', 'writeText', { path: text, content: 'Trove 文件 🧪\n第二行' });
|
||||
equal((await call('host.fs', 'readText', { path: text })).content, 'Trove 文件 🧪\n第二行');
|
||||
equal((await call('host.fs', 'stat', { path: text })).type, 'file');
|
||||
assert((await call('host.fs', 'list', { path: directory })).entries.some(item => item.name === '中文 空格.txt'), '目录列表缺少文件');
|
||||
const binary = `${directory}/bytes.bin`;
|
||||
await call('host.fs', 'writeBinary', { path: binary, data: 'AAECA/7/' });
|
||||
equal((await call('host.fs', 'readBinary', { path: binary })).data, 'AAECA/7/');
|
||||
await call('host.fs', 'copy', { from: binary, to: `${directory}/copy.bin` });
|
||||
await call('host.fs', 'move', { from: `${directory}/copy.bin`, to: `${directory}/moved.bin` });
|
||||
equal((await call('host.fs', 'exists', { path: `${directory}/copy.bin` })).exists, false);
|
||||
equal((await call('host.fs', 'readBinary', { path: `${directory}/moved.bin` })).data, 'AAECA/7/');
|
||||
const temporary = await call('host.fs', 'createTempFile');
|
||||
await call('host.fs', 'remove', temporary);
|
||||
return { unicodeFilename: true, binaryBytes: 6, directory };
|
||||
} finally { await call('host.fs', 'remove', { path: directory, recursive: true }); }
|
||||
}],
|
||||
['未知服务错误', () => expectError(() => call('dev.trove.qa.absent.probe', 'echo'), 'service.not_found')],
|
||||
['未知方法错误', () => expectError(() => call('host.runtime', 'unknownQaMethod'), 'service.method_not_found')],
|
||||
['Host 参数契约校验', () => expectError(() => call('host.storage', 'get', { key: 123 }), 'request.invalid_params')],
|
||||
['服务版本约束', () => expectError(() => call('host.services', 'resolve', { service: 'host.storage', version: '^99.0.0' }), 'service.version_mismatch')],
|
||||
];
|
||||
}
|
||||
|
||||
export function backendChecks(client, service) {
|
||||
const call = (method, params = {}, options) => client.call(service, method, params, options);
|
||||
return [
|
||||
['后端启动、身份、环境与参数', async () => {
|
||||
const info = await call('info');
|
||||
assert(info.pid > 0 && info.curioId.startsWith('dev.trove.qa.'), '后端身份缺失');
|
||||
assert(info.label.startsWith('test-'), 'Manifest 环境变量未传入');
|
||||
return info;
|
||||
}],
|
||||
['原生 Unicode 与超过 200 KiB 消息', async () => {
|
||||
const value = { text: '中🧪'.repeat(32768), null: null, nested: [false, { n: 3 }] };
|
||||
equal(await call('echo', value), value);
|
||||
return { utf8Bytes: new TextEncoder().encode(JSON.stringify(value)).length };
|
||||
}],
|
||||
['24 路乱序并发响应匹配', async () => {
|
||||
const responses = await Promise.all(Array.from({ length: 24 }, (_, index) => call('delay', { ms: (23 - index) * 8, value: { index } })));
|
||||
responses.forEach((value, index) => equal(value, { index }));
|
||||
return { responses: responses.length };
|
||||
}],
|
||||
['结构化业务错误', () => expectError(() => call('fail'), 'qa.intentional_error')],
|
||||
['未知后端方法', () => expectError(() => call('unknownQaMethod'), 'service.method_not_found')],
|
||||
['请求超时与后续可用性', async () => {
|
||||
const error = await expectError(() => call('delay', { ms: 1500 }, { timeoutMs: 80 }), 'request.timeout');
|
||||
equal(await call('echo', { recovered: true }), { recovered: true });
|
||||
return error;
|
||||
}],
|
||||
['AbortSignal 取消与后续可用性', async () => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 60);
|
||||
try {
|
||||
const error = await expectError(() => call('delay', { ms: 1500 }, { signal: controller.signal }), 'request.cancelled');
|
||||
equal(await call('echo', { recovered: true }), { recovered: true });
|
||||
return error;
|
||||
} finally { clearTimeout(timer); }
|
||||
}],
|
||||
['后端 → Host 存储调用', async () => {
|
||||
const key = 'qa.backend-roundtrip';
|
||||
const value = { unicode: '双向 🧪', caller: client.context.curioId };
|
||||
const response = await call('storageRoundtrip', { key, value });
|
||||
equal(response, { found: true, value });
|
||||
// Backend writes to its own namespace. Fixed small probes are retained there for inspection.
|
||||
return { key, value, note: '存储所有者为后端提供方;可在插件数据目录检查。' };
|
||||
}],
|
||||
['事件订阅、顺序、取消订阅', async () => {
|
||||
const events = [];
|
||||
const taskId = crypto.randomUUID();
|
||||
const subscription = await client.subscribe(service, 'progress', { taskId }, data => events.push(data));
|
||||
try {
|
||||
equal(await call('uppercase', { text: 'hello 中文' }), { text: 'HELLO 中文' });
|
||||
const deadline = Date.now() + 1500;
|
||||
while (!events.some(item => item.progress === 1) && Date.now() < deadline) await sleep(20);
|
||||
assert(events.length >= 2 && events[0].progress === 0 && events.at(-1).progress === 1, '未收到完整进度序列');
|
||||
assert(events.every((item, i) => item.taskId === taskId && (i === 0 || item.progress >= events[i - 1].progress)), '事件身份或顺序不正确');
|
||||
} finally { await subscription.close(); }
|
||||
const before = events.length;
|
||||
await call('uppercase', { text: 'after unsubscribe' });
|
||||
await sleep(100);
|
||||
equal(events.length, before, '取消订阅后仍收到事件');
|
||||
return { events };
|
||||
}],
|
||||
...(service === '@self' ? [] : [
|
||||
['公共服务参数契约', () => expectError(() => call('uppercase', { text: 123 }), 'request.invalid_params')],
|
||||
['公共服务返回值契约', () => expectError(() => call('invalidResult'), 'service.contract_violation')],
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Trove 插件测试台</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<p class="eyebrow">TROVE / PLUGIN TEST LAB</p>
|
||||
<h1 id="title">插件测试台</h1>
|
||||
<p id="context">正在连接 Trove…</p>
|
||||
</header>
|
||||
<main>
|
||||
<section aria-label="测试控制">
|
||||
<p id="description"></p>
|
||||
<div class="actions">
|
||||
<button id="run" disabled>运行常规检查</button>
|
||||
<button id="save-report" disabled>保存 JSON 报告</button>
|
||||
</div>
|
||||
<p id="status" role="status">等待连接。</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>检查结果</h2>
|
||||
<p class="hint">通过代表断言已满足;需观察的系统交互不会计入自动通过。</p>
|
||||
<ol id="results" aria-live="polite"></ol>
|
||||
</section>
|
||||
<section id="manual-section">
|
||||
<h2>手动检查</h2>
|
||||
<p class="hint">这些操作可能打开窗口、改写剪贴板、注册快捷键或终止本插件后端。按需逐项执行。</p>
|
||||
<div id="manual" class="actions"></div>
|
||||
</section>
|
||||
<details>
|
||||
<summary>原始报告</summary>
|
||||
<pre id="report">[]</pre>
|
||||
</details>
|
||||
</main>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,184 @@
|
||||
import { trove } from './sdk/index.js';
|
||||
import config from './config.js';
|
||||
import { assert, equal, expectError, hostChecks, backendChecks } from './checks.js';
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const results = [];
|
||||
let running = false;
|
||||
const call = (service, method, params = {}, options) => trove.call(service, method, params, options);
|
||||
const report = () => ({ schemaVersion: 1, fixture: config, context: trove.context, generatedAt: new Date().toISOString(), results });
|
||||
const serializeError = error => ({ code: error.code ?? 'qa.assertion_failed', message: error.message ?? String(error), data: error.data });
|
||||
|
||||
function refresh() {
|
||||
$('#results').replaceChildren(...results.map(result => {
|
||||
const row = document.createElement('li');
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'result-heading';
|
||||
const title = document.createElement('strong');
|
||||
title.className = result.status;
|
||||
const labels = { pass: '通过', fail: '失败', observed: '已执行 · 请观察', skip: '跳过' };
|
||||
title.textContent = `${labels[result.status]} · ${result.name}`;
|
||||
const duration = document.createElement('small');
|
||||
duration.textContent = `${result.durationMs} ms`;
|
||||
heading.append(title, duration);
|
||||
const detail = document.createElement('details');
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = result.error?.message || '查看返回值';
|
||||
const pre = document.createElement('pre');
|
||||
pre.textContent = JSON.stringify(result.error || result.value, null, 2);
|
||||
detail.append(summary, pre);
|
||||
row.append(heading, detail);
|
||||
return row;
|
||||
}));
|
||||
$('#report').textContent = JSON.stringify(report(), null, 2);
|
||||
const counts = status => results.filter(result => result.status === status).length;
|
||||
$('#status').textContent = `${running ? '运行中' : '已完成'} · 通过 ${counts('pass')} / 失败 ${counts('fail')} / 待观察 ${counts('observed')} / 跳过 ${counts('skip')}`;
|
||||
}
|
||||
|
||||
async function check(name, action, status = 'pass') {
|
||||
const start = performance.now();
|
||||
try { results.push({ name, status, value: await action(), durationMs: Math.round(performance.now() - start) }); }
|
||||
catch (error) { results.push({ name, status: 'fail', error: serializeError(error), durationMs: Math.round(performance.now() - start) }); }
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function exclusive(action) {
|
||||
if (running) return;
|
||||
running = true;
|
||||
document.querySelectorAll('button').forEach(button => { button.disabled = true; });
|
||||
try { await action(); }
|
||||
finally {
|
||||
running = false;
|
||||
document.querySelectorAll('button').forEach(button => { button.disabled = false; });
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function manual(name, action, status = 'observed') {
|
||||
const button = document.createElement('button');
|
||||
button.textContent = name;
|
||||
button.onclick = () => exclusive(() => check(name, action, status));
|
||||
$('#manual').append(button);
|
||||
}
|
||||
|
||||
const descriptions = {
|
||||
host: '检查真实 Host 桥接与数据操作。常规检查只在本插件命名空间和临时目录写入测试数据;隔离标记会保留以便复测。',
|
||||
backend: `检查 ${config.service}:原生进程、并发、超时、取消、Host 回调和事件。跨插件测试需先安装公共服务提供方。公共服务返回值校验要求底座开启严格契约模式;默认未开启时,该项会报告失败。`,
|
||||
dependency: `此插件声明了 ${config.service}。预期调用错误为 ${config.expectedError};“service.not_found”不能代替版本不兼容错误。`,
|
||||
fault: '此后端故意无法完成启动。点击运行检查记录实际错误码;恢复与重试应在底座管理界面操作。',
|
||||
upgrade: '先安装 1.0.0 并写入基线标记,再安装 1.1.0。关闭后重新打开,检查运行版本与数据保留;也可在底座回滚。',
|
||||
};
|
||||
$('#title').textContent = config.name;
|
||||
document.title = config.name;
|
||||
$('#description').textContent = descriptions[config.mode];
|
||||
|
||||
function configureManualActions() {
|
||||
manual('读取持久化标记', () => call('host.storage', 'get', { key: config.mode === 'upgrade' ? 'qa.upgrade-marker' : 'qa.namespace-owner' }), 'pass');
|
||||
if (config.mode === 'host') {
|
||||
manual('读取本窗口尺寸', () => call('host.window', 'getBounds'), 'pass');
|
||||
manual('设置窗口标题', () => call('host.window', 'setTitle', { title: `${config.name} · 标题测试成功` }));
|
||||
manual('窗口居中', () => call('host.window', 'center'));
|
||||
manual('文件选择(可取消)', () => call('host.dialog', 'openFile', { title: 'Trove QA:选择文件或取消' }, { timeoutMs: 120000 }));
|
||||
manual('目录选择(可取消)', () => call('host.dialog', 'openDirectory', { title: 'Trove QA:选择目录或取消' }, { timeoutMs: 120000 }));
|
||||
manual('保存对话框(仅选择路径)', () => call('host.dialog', 'saveFile', { title: 'Trove QA:只选择路径,不写入文件' }, { timeoutMs: 120000 }));
|
||||
manual('确认对话框', () => call('host.dialog', 'confirm', { title: 'Trove QA', message: '确认和取消都应返回明确结果。' }, { timeoutMs: 120000 }));
|
||||
manual('写入并读取测试剪贴板', async () => {
|
||||
const text = 'Trove QA 剪贴板 🧪';
|
||||
await call('host.clipboard', 'writeText', { text });
|
||||
equal((await call('host.clipboard', 'readText')).text, text);
|
||||
return { text, note: '系统剪贴板已被替换为测试文本。' };
|
||||
}, 'pass');
|
||||
manual('发送测试通知', () => call('host.notification', 'show', { title: 'Trove QA', body: `来自 ${config.id} 的测试通知` }));
|
||||
manual('快捷键触发(15 秒)', async () => {
|
||||
const events = [];
|
||||
let registration;
|
||||
const subscription = await trove.subscribe('host.shortcut', 'triggered', {}, event => events.push(event));
|
||||
try {
|
||||
registration = await call('host.shortcut', 'register', { shortcut: 'CommandOrControl+Shift+Alt+F9' });
|
||||
$('#status').textContent = '请在 15 秒内按 Cmd+Shift+Option+F9(部分键盘需要 Fn)。';
|
||||
const end = Date.now() + 15000;
|
||||
while (!events.length && Date.now() < end) await new Promise(resolve => setTimeout(resolve, 50));
|
||||
assert(events.some(event => event.registrationId === registration.registrationId), '15 秒内没有收到匹配的快捷键事件');
|
||||
return events;
|
||||
} finally {
|
||||
try { if (registration) await call('host.shortcut', 'unregister', registration); }
|
||||
finally { await subscription.close(); }
|
||||
}
|
||||
}, 'pass');
|
||||
manual('列出正在运行的应用', () => call('host.application', 'listRunning'), 'pass');
|
||||
manual('检查随底座启动的服务', async () => {
|
||||
const service = 'dev.trove.qa.autostart.probe';
|
||||
// Discovery does not start the backend; keep this separate from info().
|
||||
return call('host.services', 'getStatus', { service });
|
||||
});
|
||||
manual('关闭本窗口', () => call('host.window', 'close'));
|
||||
}
|
||||
if (config.mode === 'backend') {
|
||||
manual('读取后端 PID 与活动数', () => call(config.service, 'info'), 'pass');
|
||||
if (config.service !== '@self') manual('查询提供方状态(不启动)', () => call('host.services', 'getStatus', { service: config.service }));
|
||||
if (config.directory === 'test-crash') manual('触发本后端崩溃(exit 42)', async () => {
|
||||
try { await call('@self', 'crash', {}, { timeoutMs: 6000 }); }
|
||||
catch (error) {
|
||||
assert(['provider.disconnected', 'provider.crashed'].includes(error.code), `预期进程断开,实际 ${error.code}`);
|
||||
return { error: serializeError(error), next: '再次读取 PID 验证恢复;短时间反复触发可观察熔断。' };
|
||||
}
|
||||
throw new Error('后端未断开');
|
||||
});
|
||||
}
|
||||
if (config.mode === 'upgrade') {
|
||||
manual('写入升级基线标记', async () => {
|
||||
const value = { token: crypto.randomUUID(), writtenByVersion: trove.context.curioVersion, writtenAt: new Date().toISOString() };
|
||||
await call('host.storage', 'set', { key: 'qa.upgrade-marker', value });
|
||||
return value;
|
||||
}, 'pass');
|
||||
}
|
||||
}
|
||||
|
||||
$('#run').onclick = () => exclusive(async () => {
|
||||
results.length = 0;
|
||||
if (config.mode === 'host') for (const [name, action] of hostChecks(trove)) await check(name, action);
|
||||
if (config.mode === 'backend') for (const [name, action] of backendChecks(trove, config.service)) await check(name, action);
|
||||
if (config.mode === 'dependency') {
|
||||
await check('缺失依赖时 Host 仍可用', async () => equal(await call('host.runtime', 'echo', { alive: true }), { alive: true }));
|
||||
await check('依赖调用返回预期错误', () => expectError(() => call(config.service, 'echo'), config.expectedError));
|
||||
}
|
||||
if (config.mode === 'fault') {
|
||||
await check('启动失败被限制在截止时间内', async () => {
|
||||
try { await call('@self', 'info', {}, { timeoutMs: 6000 }); }
|
||||
catch (error) {
|
||||
const expected = config.directory === 'test-start-timeout'
|
||||
? ['provider.start_timeout']
|
||||
: ['provider.start_failed', 'provider.disconnected', 'provider.crashed', 'provider.start_timeout'];
|
||||
assert(expected.includes(error.code), `非预期错误 ${error.code}`);
|
||||
return { actual: serializeError(error), next: '查看底座后端日志确认具体启动原因。' };
|
||||
}
|
||||
throw new Error('故障后端意外启动成功');
|
||||
});
|
||||
}
|
||||
if (config.mode === 'upgrade') {
|
||||
await check('运行版本与打包版本一致', async () => { equal(trove.context.curioVersion, config.version); return trove.context; });
|
||||
await check('升级基线标记保留', async () => {
|
||||
const marker = await call('host.storage', 'get', { key: 'qa.upgrade-marker' });
|
||||
assert(marker.found, '尚无基线标记,请在 1.0.0 中写入后再验证升级');
|
||||
equal(marker.value.writtenByVersion, '1.0.0', '标记不是由基线版本写入');
|
||||
return marker.value;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('#save-report').onclick = () => exclusive(() => check('保存 JSON 报告', async () => {
|
||||
const paths = await call('host.runtime', 'getPaths');
|
||||
const path = `${paths.data}/qa-report-${Date.now()}.json`;
|
||||
await call('host.fs', 'writeText', { path, content: JSON.stringify(report(), null, 2) });
|
||||
return { path };
|
||||
}));
|
||||
|
||||
try {
|
||||
await trove.ready();
|
||||
$('#context').textContent = `${trove.context.curioId} · v${trove.context.curioVersion} · ${trove.context.mode} · ${trove.context.instanceId}`;
|
||||
configureManualActions();
|
||||
document.querySelectorAll('button').forEach(button => { button.disabled = false; });
|
||||
$('#status').textContent = '已连接真实 Trove Host,点击运行常规检查。';
|
||||
} catch (error) {
|
||||
$('#status').textContent = `连接失败:${error.code ?? ''} ${error.message}。请从 Trove 安装并打开本插件。`;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
:root { color-scheme: light dark; font: 14px/1.6 -apple-system, BlinkMacSystemFont, sans-serif; color: #203129; background: #f3f5f1; }
|
||||
* { box-sizing: border-box; }
|
||||
body { max-width: 1080px; margin: auto; padding: 28px; }
|
||||
header { margin-bottom: 24px; }
|
||||
.eyebrow { font-size: 11px; font-weight: 650; letter-spacing: 2px; color: #53745e; }
|
||||
h1 { font-size: 27px; line-height: 1.3; margin: 8px 0; }
|
||||
h2 { font-size: 16px; margin: 0 0 8px; }
|
||||
p { margin: 8px 0; }
|
||||
#context, .hint { color: #626f66; overflow-wrap: anywhere; }
|
||||
section, details { padding: 20px; background: #fff; border: 1px solid #dce3db; border-radius: 12px; margin-bottom: 16px; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0; }
|
||||
button { font: inherit; color: inherit; background: #f1f5f0; border: 1px solid #cbd7ca; border-radius: 7px; padding: 8px 12px; cursor: pointer; }
|
||||
#run { background: #286047; border-color: #286047; color: #fff; }
|
||||
button:hover { filter: brightness(.94); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button:focus-visible, summary:focus-visible { outline: 3px solid #629d78; outline-offset: 3px; }
|
||||
ol { list-style: none; margin: 0; padding: 0; }
|
||||
li { padding: 12px 0; border-bottom: 1px solid #e7ece5; }
|
||||
li:last-child { border: 0; }
|
||||
.pass { color: #236b3b; } .fail { color: #ba352d; } .observed, .skip { color: #8c5e15; }
|
||||
.result-heading { display: flex; gap: 12px; justify-content: space-between; }
|
||||
small { color: #68746b; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 ui-monospace, monospace; max-height: 360px; overflow: auto; }
|
||||
summary { cursor: pointer; }
|
||||
@media (max-width: 640px) { body { padding: 14px; } section { padding: 14px; } .result-heading { display: block; } }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { color: #e1e9df; background: #172019; }
|
||||
section, details { background: #202b23; border-color: #37473a; }
|
||||
button { background: #2d3c30; border-color: #506153; }
|
||||
#context, .hint, small { color: #a4b4a6; }
|
||||
li { border-color: #37473a; }
|
||||
.pass { color: #8adb9e; } .fail { color: #ff9c92; } .observed, .skip { color: #edc579; }
|
||||
}
|
||||
Reference in New Issue
Block a user