154 lines
7.1 KiB
JavaScript
154 lines
7.1 KiB
JavaScript
const assert = require("node:assert/strict");
|
|
const test = require("node:test");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const vm = require("node:vm");
|
|
const ts = require("typescript");
|
|
|
|
// Execute the actual renderer with a small DOM/IPC fixture; no network or audio.
|
|
const source = fs.readFileSync(path.join(__dirname, "../src/hardware.ts"), "utf8");
|
|
const compiled = ts.transpileModule(source, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
|
|
}).outputText;
|
|
|
|
function fixture(overrides = {}) {
|
|
const elements = new Map();
|
|
const element = () => ({
|
|
style: {}, value: "", textContent: "", disabled: false, children: [],
|
|
append(...items) { this.children.push(...items); },
|
|
prepend(...items) { this.children.unshift(...items); },
|
|
replaceChildren(...items) { this.children = items; },
|
|
querySelector() { return null; }, addEventListener() {},
|
|
});
|
|
const get = (id) => {
|
|
if (!elements.has(id)) elements.set(id, element());
|
|
return elements.get(id);
|
|
};
|
|
const events = [];
|
|
const closes = [];
|
|
const texts = [];
|
|
const created = {
|
|
conversation_id: "test-conversation", session: {},
|
|
runtime: { transport: "low_load", config_digest: "digest", session_nonce: "nonce", provider_session_ref: "ref", bot_id: "test-bot" },
|
|
session_config: { authorized_context: "test", prompt_bundle_version: "test-v1" },
|
|
};
|
|
const ipc = {
|
|
getRuntimeDefaults: async () => ({}), getHardwareRuntimeDefaults: async () => ({}),
|
|
onHardwareProviderEvent: () => () => {},
|
|
createHardwareAgentSession: async () => created,
|
|
closeHardwareAgent: async (input) => { closes.push(input); },
|
|
hardwareAgentEvent: async (input) => { events.push(input); return { status: "FAILED" }; },
|
|
sendHardwareAgentText: async (input) => { texts.push(input); },
|
|
...overrides,
|
|
};
|
|
const context = vm.createContext({
|
|
exports: {}, require: () => ({}), console, URL,
|
|
document: { querySelector: () => get("app"), getElementById: get, createElement: element },
|
|
window: { deviceSimulator: ipc, addEventListener() {}, clearInterval() {}, setTimeout, setInterval() {} },
|
|
});
|
|
vm.runInContext(compiled + `\nglobalThis.renderer = { state, controls, setPhase, setCheck, resetSessionState, failHardwareConversation, stopHardwareConversation, startHardwareConversation, handleProviderEvent, sendTextQuestion, buildReport };`, context);
|
|
return { r: context.renderer, events, closes, texts, created, get, ipc };
|
|
}
|
|
|
|
test("provider error plus connect rejection sends one failure, including late duplicates", async () => {
|
|
const f = fixture();
|
|
const r = f.r;
|
|
f.ipc.connectHardwareAgent = async () => {
|
|
void r.handleProviderEvent({ type: "error", error: { code: "internal_error", message: "StartVoiceChat failed" } });
|
|
throw new Error("wrapped connect rejection");
|
|
};
|
|
r.setPhase("ready", "ready");
|
|
await r.startHardwareConversation();
|
|
await r.failHardwareConversation("SECOND_FAILURE");
|
|
await r.stopHardwareConversation();
|
|
await r.handleProviderEvent({ type: "error", error: { message: "late error" } });
|
|
assert.equal(f.events.length, 1);
|
|
assert.equal(f.events[0].eventType, "session_failed");
|
|
assert.equal(f.events[0].errorCode, "VOLC_HARDWARE_AGENT_ERROR");
|
|
assert.equal(f.closes.length, 1);
|
|
assert.equal(r.state.failure, "internal_error: StartVoiceChat failed");
|
|
assert.equal(r.state.phase, "error");
|
|
assert.equal(r.controls.stop.disabled, true);
|
|
assert.equal(r.controls.check.disabled, false);
|
|
});
|
|
|
|
test("concurrent failure callers share pending cleanup; late events cannot reactivate", async () => {
|
|
let release;
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
const f = fixture({ closeHardwareAgent: () => gate });
|
|
f.r.state.platformSession = f.created;
|
|
f.r.state.failure = "first cause";
|
|
f.r.setPhase("active", "active");
|
|
const first = f.r.failHardwareConversation("FIRST");
|
|
const second = f.r.failHardwareConversation("SECOND");
|
|
assert.equal(first, second);
|
|
assert.equal(f.r.state.phase, "stopping");
|
|
await f.r.handleProviderEvent({ type: "response.done" });
|
|
assert.equal(f.r.state.phase, "stopping");
|
|
release();
|
|
await first;
|
|
assert.equal(f.events.length, 1);
|
|
assert.equal(f.events[0].errorCode, "FIRST");
|
|
await f.r.handleProviderEvent({ type: "transport.close", code: 1011 });
|
|
assert.equal(f.r.state.checks.get("provider_closed").status, "failed");
|
|
f.r.controls.textQuestion.value = "should not send";
|
|
await f.r.sendTextQuestion();
|
|
assert.equal(f.texts.length, 0);
|
|
});
|
|
|
|
test("new call clears stale checks, duration and failure while retaining device preparation", async () => {
|
|
const f = fixture();
|
|
f.r.state.platformSession = f.created;
|
|
f.r.state.failure = "old failure";
|
|
await f.r.failHardwareConversation("OLD");
|
|
for (const id of f.r.state.checks.keys()) f.r.setCheck(id, "passed", "old");
|
|
f.get("duration").textContent = "48:15";
|
|
f.r.resetSessionState();
|
|
assert.equal(f.r.state.failure, undefined);
|
|
assert.equal(f.r.state.failureTask, undefined);
|
|
assert.equal(f.get("duration").textContent, "00:00");
|
|
assert.equal(f.r.state.checks.get("platform_authenticated").status, "passed");
|
|
assert.equal(f.r.state.checks.get("credential_resolved").status, "passed");
|
|
assert.equal(f.r.state.checks.get("platform_completed").status, "pending");
|
|
f.r.state.platformSession = { ...f.created, conversation_id: "next-conversation" };
|
|
await f.r.failHardwareConversation("NEW");
|
|
assert.equal(f.events.length, 2);
|
|
assert.equal(f.events[1].conversationID, "next-conversation");
|
|
assert.equal(f.events[1].sequenceNo, 1);
|
|
});
|
|
|
|
test("failed platform receipt does not trigger a new terminal event on hangup", async () => {
|
|
let attempts = 0;
|
|
const f = fixture({ hardwareAgentEvent: async () => { attempts += 1; throw new Error("network unavailable"); } });
|
|
f.r.state.platformSession = f.created;
|
|
await f.r.failHardwareConversation("FIRST");
|
|
await f.r.stopHardwareConversation();
|
|
assert.equal(attempts, 1);
|
|
assert.equal(f.r.state.phase, "error");
|
|
assert.equal(f.r.buildReport().checks.find((c) => c.id === "platform_completed").status, "failed");
|
|
});
|
|
|
|
test("connection rejection without a provider event still reports failure once", async () => {
|
|
const f = fixture({ connectHardwareAgent: async () => { throw new Error("connect timeout"); } });
|
|
f.r.setPhase("ready", "ready");
|
|
await f.r.startHardwareConversation();
|
|
assert.equal(f.events.length, 1);
|
|
assert.equal(f.events[0].errorCode, "WINDOWS_HARDWARE_START_FAILED");
|
|
assert.match(f.r.state.failure, /connect timeout/);
|
|
assert.equal(f.r.state.phase, "error");
|
|
});
|
|
|
|
test("normal hangup still reports completed and closes normally", async () => {
|
|
const f = fixture();
|
|
f.ipc.hardwareAgentEvent = async (input) => { f.events.push(input); return { status: "COMPLETED" }; };
|
|
f.r.state.platformSession = f.created;
|
|
f.r.setPhase("active", "active");
|
|
await f.r.stopHardwareConversation();
|
|
assert.equal(f.events.length, 1);
|
|
assert.equal(f.events[0].eventType, "session_ended");
|
|
assert.equal(f.closes[0].code, 1000);
|
|
assert.equal(f.r.state.phase, "completed");
|
|
assert.equal(f.r.state.failureTask, undefined);
|
|
assert.equal(f.r.controls.start.disabled, false);
|
|
});
|