245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
|
|
// Responsibility: Regression tests for pure Workbench realtime runtime helpers before Vue/store integration.
|
|
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { AsyncQueue, work } from "../src/utils/scheduler/async-queue.ts";
|
|
import { createCoalescedEventQueue } from "../src/utils/scheduler/coalesced-event-queue.ts";
|
|
import { createKeyedSingleflight } from "../src/utils/scheduler/keyed-singleflight.ts";
|
|
import { createScopedCache } from "../src/utils/scoped-cache.ts";
|
|
import { composeWorkbenchScopedKey, splitWorkbenchScopedKey, workbenchPathKey, workbenchRealtimeScopeKey } from "../src/utils/workbench-key.ts";
|
|
import { createSafeStorageRuntime, isStorageQuotaError, migrateLegacyStorage, normalizePersistedValue, readJsonStorage, removePersistedTarget, removeStorageKey, writeJsonStorage, type StorageLike } from "../src/utils/safe-storage.ts";
|
|
import { checkWorkbenchHealth, createWorkbenchHealthProbeCache } from "../src/utils/workbench-health.ts";
|
|
import { buildWorkbenchTimelineRows, normalizeWorkbenchTimelineMessages, workbenchTimelineSignature } from "../src/stores/workbench-timeline-model.ts";
|
|
import { trimWorkbenchSessionCache } from "../src/stores/workbench-session-cache.ts";
|
|
|
|
test("Workbench scoped keys encode delimiter characters", () => {
|
|
assert.deepEqual(splitWorkbenchScopedKey(workbenchRealtimeScopeKey("ses|one", "trc/two")), ["workbench.realtime", "ses|one", "trc/two"]);
|
|
assert.deepEqual(splitWorkbenchScopedKey(composeWorkbenchScopedKey("queue", null, "", "a b")), ["queue", "~", "~", "a b"]);
|
|
assert.throws(() => composeWorkbenchScopedKey("queue", "bad\0part"), /null bytes/u);
|
|
assert.equal(workbenchPathKey("C:\\Users\\foo\\"), "C:/Users/foo");
|
|
});
|
|
|
|
test("scoped cache keeps OpenCode LRU and TTL semantics", () => {
|
|
const disposed: string[] = [];
|
|
let clock = 0;
|
|
let count = 0;
|
|
const cache = createScopedCache((key) => ({ key, count: ++count }), {
|
|
maxEntries: 2,
|
|
ttlMs: 10,
|
|
now: () => clock,
|
|
dispose: (value) => disposed.push(`${value.key}:${value.count}`)
|
|
});
|
|
|
|
assert.equal(cache.get("a").count, 1);
|
|
assert.equal(cache.get("b").count, 2);
|
|
cache.get("a");
|
|
assert.equal(cache.get("c").count, 3);
|
|
assert.equal(cache.peek("b"), undefined);
|
|
assert.deepEqual(disposed, ["b:2"]);
|
|
|
|
clock = 11;
|
|
assert.equal(cache.peek("a"), undefined);
|
|
assert.deepEqual(disposed, ["b:2", "a:1", "c:3"]);
|
|
});
|
|
|
|
test("async queue and bounded work preserve OpenCode utility behavior", async () => {
|
|
const queue = new AsyncQueue<number>();
|
|
const first = queue.next();
|
|
queue.push(1);
|
|
assert.equal(await first, 1);
|
|
queue.push(2);
|
|
assert.equal(await queue.next(), 2);
|
|
|
|
const visited: number[] = [];
|
|
await work(2, [1, 2, 3], async (item) => {
|
|
visited.push(item);
|
|
});
|
|
assert.deepEqual(visited.sort(), [1, 2, 3]);
|
|
});
|
|
|
|
test("coalesced event queue replaces keyed snapshots and preserves unkeyed events", () => {
|
|
const flushed: string[][] = [];
|
|
const queue = createCoalescedEventQueue<{ key?: string; value: string }>({
|
|
keyOf: (item) => item.key,
|
|
schedule: () => undefined,
|
|
onFlush: (items) => flushed.push(items.map((item) => item.value))
|
|
});
|
|
|
|
queue.push({ key: "snapshot:trc", value: "old" });
|
|
queue.push({ value: "event-1" });
|
|
queue.push({ key: "snapshot:trc", value: "new" });
|
|
queue.push({ value: "event-2" });
|
|
|
|
assert.equal(queue.size, 3);
|
|
queue.flush();
|
|
assert.deepEqual(flushed, [["new", "event-1", "event-2"]]);
|
|
});
|
|
|
|
test("keyed single-flight coalesces duplicate work and supports replacement", async () => {
|
|
const flight = createKeyedSingleflight<number>();
|
|
let calls = 0;
|
|
const first = flight.run("sessions", () => {
|
|
calls += 1;
|
|
return 7;
|
|
});
|
|
const second = flight.run("sessions", () => {
|
|
calls += 1;
|
|
return 9;
|
|
});
|
|
|
|
assert.equal(await first, 7);
|
|
assert.equal(await second, 7);
|
|
assert.equal(calls, 1);
|
|
|
|
assert.equal(await flight.run("sessions", () => 11, { replace: true, reason: "manual-refresh" }), 11);
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test("safe storage reports failures instead of throwing", () => {
|
|
const storage = new MemoryStorage();
|
|
assert.equal(writeJsonStorage(storage, "k", { a: 1 }).ok, true);
|
|
assert.deepEqual(readJsonStorage(storage, "k", { a: 0 }).value, { a: 1 });
|
|
assert.equal(removeStorageKey(storage, "k").ok, true);
|
|
|
|
const broken: StorageLike = {
|
|
getItem: () => "{not-json",
|
|
setItem: () => { throw new Error("quota"); },
|
|
removeItem: () => { throw new Error("security"); }
|
|
};
|
|
assert.equal(readJsonStorage(broken, "bad", { safe: true }).status, "parse_error");
|
|
assert.equal(writeJsonStorage(broken, "bad", { safe: true }).status, "write_error");
|
|
assert.equal(removeStorageKey(broken, "bad").status, "remove_error");
|
|
});
|
|
|
|
test("safe storage preserves OpenCode quota fallback, prefix isolation and legacy migration", () => {
|
|
const storage = new MemoryStorage();
|
|
const runtime = createSafeStorageRuntime({ cacheMaxEntries: 3, cacheMaxBytes: 200, evictPrefix: "hwlab." });
|
|
const bad = runtime.localStorageWithPrefix("hwlab.throw.scope", storage);
|
|
bad.setItem("value", '{"value":1}');
|
|
const before = storage.calls.set;
|
|
bad.setItem("value", '{"value":2}');
|
|
assert.equal(storage.calls.set, before);
|
|
|
|
const safe = runtime.localStorageWithPrefix("hwlab.safe.scope", storage);
|
|
safe.setItem("value", '{"value":3}');
|
|
assert.equal(storage.getItem("hwlab.safe.scope:value"), '{"value":3}');
|
|
|
|
assert.equal(isStorageQuotaError(new DOMException("quota", "QuotaExceededError")), true);
|
|
assert.equal(normalizePersistedValue({ value: 1, nested: { a: true } }, '{"nested":{"b":2}}'), '{"value":1,"nested":{"a":true,"b":2}}');
|
|
assert.equal(normalizePersistedValue({ value: "ok" }, '{"value":"\\x"}'), undefined);
|
|
|
|
storage.setItem("legacy.workspace", '{"value":2}');
|
|
const current = runtime.localStorageWithPrefix("hwlab.current", storage);
|
|
const migrated = migrateLegacyStorage({ current, legacyStore: runtime.localStorageDirect(storage), stores: [], keys: ["legacy.workspace"], key: "workspace:demo", defaults: { value: 1 } });
|
|
assert.equal(migrated, '{"value":2}');
|
|
assert.equal(storage.getItem("hwlab.current:workspace:demo"), '{"value":2}');
|
|
assert.equal(storage.getItem("legacy.workspace"), null);
|
|
|
|
storage.setItem("hwlab.current:workspace:demo", '{"value":2}');
|
|
storage.setItem("hwlab.legacy:workspace:demo", '{"value":3}');
|
|
removePersistedTarget({ storage: "hwlab.current", legacyStorageNames: ["hwlab.legacy"], key: "workspace:demo" }, runtime, storage);
|
|
assert.equal(storage.getItem("hwlab.current:workspace:demo"), null);
|
|
assert.equal(storage.getItem("hwlab.legacy:workspace:demo"), null);
|
|
});
|
|
|
|
test("timeline model deduplicates authoritative messages with stable row keys", () => {
|
|
const rows = buildWorkbenchTimelineRows([
|
|
{ id: "msg_user", messageId: "msg_user", role: "user", title: "用户", text: "hi", status: "sent", createdAt: "2026-06-30T00:00:00.000Z", sessionId: "ses_a", traceId: "trc_a" },
|
|
{ id: "msg_agent_old", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_a", traceId: "trc_a" },
|
|
{ id: "msg_agent_new", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "done", status: "completed", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_a", traceId: "trc_a" }
|
|
]);
|
|
|
|
assert.equal(rows.length, 2);
|
|
assert.deepEqual(rows.map((row) => `${row.role}:${row.message.text}`), ["user:hi", "agent:done"]);
|
|
assert.match(workbenchTimelineSignature(rows), /completed:4/u);
|
|
assert.deepEqual(normalizeWorkbenchTimelineMessages(rows.map((row) => row.message)).map((message) => message.messageId), ["msg_user", "msg_agent"]);
|
|
});
|
|
|
|
test("session cache trim keeps retained sessions and trims messages by explicit policy", () => {
|
|
const result = trimWorkbenchSessionCache({
|
|
sessionOrder: ["ses_1", "ses_2", "ses_3"],
|
|
sessionsById: {
|
|
ses_1: { sessionId: "ses_1" },
|
|
ses_2: { sessionId: "ses_2" },
|
|
ses_3: { sessionId: "ses_3" }
|
|
},
|
|
messagesBySessionId: {
|
|
ses_1: [{ id: "m1", role: "user", title: "用户", text: "1", status: "sent", createdAt: "2026-06-30T00:00:00.000Z" }],
|
|
ses_2: [
|
|
{ id: "m2", role: "user", title: "用户", text: "2", status: "sent", createdAt: "2026-06-30T00:00:00.000Z" },
|
|
{ id: "m3", role: "agent", title: "Code Agent", text: "3", status: "completed", createdAt: "2026-06-30T00:00:01.000Z" }
|
|
],
|
|
ses_3: [{ id: "m4", role: "user", title: "用户", text: "4", status: "sent", createdAt: "2026-06-30T00:00:00.000Z" }]
|
|
}
|
|
}, { retainSessionIds: ["ses_3"], maxSessions: 1, maxMessagesPerSession: 1 });
|
|
|
|
assert.deepEqual(result.state.sessionOrder, ["ses_1", "ses_3"]);
|
|
assert.deepEqual(result.evictedSessionIds, ["ses_2"]);
|
|
assert.deepEqual(Object.keys(result.state.sessionsById).sort(), ["ses_1", "ses_3"]);
|
|
});
|
|
|
|
test("health probe cache records ok and unavailable states", async () => {
|
|
const cache = createWorkbenchHealthProbeCache({ cacheMs: 100 });
|
|
const ok = await cache.probe({ key: "workbench", fetcher: async () => ({ ready: true }), classify: (value) => value.ready ? "ok" : "degraded" });
|
|
assert.equal(ok.state, "ok");
|
|
assert.deepEqual(cache.get("workbench")?.value, { ready: true });
|
|
|
|
cache.clear("workbench");
|
|
const failed = await cache.probe({ key: "workbench", fetcher: async () => { throw new Error("offline"); } });
|
|
assert.equal(failed.state, "unavailable");
|
|
assert.ok(failed.error instanceof Error);
|
|
});
|
|
|
|
test("health probe retries transient failures with caller supplied policy", async () => {
|
|
let calls = 0;
|
|
const result = await checkWorkbenchHealth({
|
|
key: "workbench",
|
|
retryCount: 2,
|
|
retryDelayMs: 1,
|
|
fetcher: async () => {
|
|
calls += 1;
|
|
if (calls < 3) throw new TypeError("network");
|
|
return { ready: true, version: "v" };
|
|
},
|
|
classify: (value) => value.ready ? "ok" : "degraded"
|
|
});
|
|
assert.equal(calls, 3);
|
|
assert.equal(result.state, "ok");
|
|
assert.deepEqual(result.value, { ready: true, version: "v" });
|
|
});
|
|
|
|
class MemoryStorage implements StorageLike {
|
|
private readonly values = new Map<string, string>();
|
|
readonly calls = { get: 0, set: 0, remove: 0 };
|
|
|
|
get length(): number {
|
|
return this.values.size;
|
|
}
|
|
|
|
key(index: number): string | null {
|
|
return Array.from(this.values.keys())[index] ?? null;
|
|
}
|
|
|
|
getItem(key: string): string | null {
|
|
this.calls.get += 1;
|
|
if (key.startsWith("hwlab.throw")) throw new Error("storage get failed");
|
|
return this.values.get(key) ?? null;
|
|
}
|
|
|
|
setItem(key: string, value: string): void {
|
|
this.calls.set += 1;
|
|
if (key.startsWith("hwlab.quota")) throw new DOMException("quota", "QuotaExceededError");
|
|
if (key.startsWith("hwlab.throw")) throw new Error("storage set failed");
|
|
this.values.set(key, value);
|
|
}
|
|
|
|
removeItem(key: string): void {
|
|
this.calls.remove += 1;
|
|
if (key.startsWith("hwlab.throw")) throw new Error("storage remove failed");
|
|
this.values.delete(key);
|
|
}
|
|
}
|
|
|