From 53d80ab662d99cbbfe8a4b8cd5c499e62da94d0d Mon Sep 17 00:00:00 2001 From: vee Date: Mon, 7 Sep 2026 17:54:15 +0800 Subject: [PATCH] test: cover assistant stream deployment failures --- Makefile | 1 + tests/e2e/test_assistant_web_api.py | 33 +++ tests/unit/assistant_stream_runtime.test.cjs | 236 +++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 tests/unit/assistant_stream_runtime.test.cjs diff --git a/Makefile b/Makefile index e4c3925..e9334e6 100755 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ validate: can-protocol-check python tools/validate_repository.py test-frontend: + python tools/run_node.py --test tests/unit/assistant_stream_runtime.test.cjs python tools/run_node.py --test tests/unit/speech_input_runtime.test.cjs python -m pytest tests/unit/test_energy_monitor_frontend_contract.py -q diff --git a/tests/e2e/test_assistant_web_api.py b/tests/e2e/test_assistant_web_api.py index 563161e..768fcc3 100644 --- a/tests/e2e/test_assistant_web_api.py +++ b/tests/e2e/test_assistant_web_api.py @@ -7,12 +7,14 @@ import urllib.error import urllib.request from collections.abc import Callable from http.server import ThreadingHTTPServer +from unittest.mock import patch from cognitive_agent.application.codex_assistant import ( ActiveCodexTurn, CodexAssistantService, ) from cognitive_agent.auth import AuthConfig, AuthRuntime +from cognitive_agent.infrastructure.codex.app_server import CodexAppServerError from cognitive_agent.server import SeatDemoHandler, VehicleDisplaySync COOLING_INSTRUCTION = "打开空调,将全车温度设置为23度,风量设置为2档" @@ -498,6 +500,37 @@ class TestCodexAssistantWebApi: assert not AssistantApiHandler.service.calls assert not FAKE_CODEX.bridge_results + def test_model_auth_failure_is_a_safe_terminal_ndjson_event(self) -> None: + secret = "deployment-secret-must-never-reach-the-browser" + provider_error = CodexAppServerError( + f"provider rejected Authorization: Bearer {secret}" + ) + + with patch.object(FAKE_CODEX, "start_thread", side_effect=provider_error): + events = self.post_assistant( + { + "message": "解释一下 CAN FD", + "conversation_id": "conv-api-provider-auth-failure", + "history": [], + } + ) + + assert [event["type"] for event in events] == [ + "message.accepted", + "work.started", + "error", + ] + assert events[-1] == { + "type": "error", + "error": "assistant_model_failed", + "message": "Codex AI 助手暂时没有响应,请稍后再试。", + } + serialized = json.dumps(events, ensure_ascii=False) + assert secret not in serialized + assert "Bearer" not in serialized + assert not AssistantApiHandler.service.calls + assert not FAKE_CODEX.bridge_results + def test_hot_cabin_uses_internal_bridge_and_explicit_trusted_cooling_instruction(self) -> None: events = self.post_assistant( { diff --git a/tests/unit/assistant_stream_runtime.test.cjs b/tests/unit/assistant_stream_runtime.test.cjs new file mode 100644 index 0000000..68718e2 --- /dev/null +++ b/tests/unit/assistant_stream_runtime.test.cjs @@ -0,0 +1,236 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const ROOT = path.resolve(__dirname, "../.."); +const ASSISTANT_SOURCE = fs.readFileSync( + path.join(ROOT, "apps/web-console/assistant-chat.js"), + "utf8", +); + +class MockClassList { + constructor() { + this.values = new Set(); + } + + add(...names) { names.forEach((name) => this.values.add(name)); } + remove(...names) { names.forEach((name) => this.values.delete(name)); } + contains(name) { return this.values.has(name); } + toggle(name, force) { + const enabled = force === undefined ? !this.values.has(name) : Boolean(force); + if (enabled) this.values.add(name); + else this.values.delete(name); + return enabled; + } +} + +class MockEventTarget { + constructor() { + this.listeners = new Map(); + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + this.listeners.set(type, listeners.filter((candidate) => candidate !== listener)); + } + + dispatchEvent(event) { + if (!event?.type) throw new TypeError("event.type is required"); + if (!event.target) event.target = this; + for (const listener of this.listeners.get(event.type) || []) listener.call(this, event); + return event.defaultPrevented !== true; + } +} + +class MockElement extends MockEventTarget { + constructor(tagName = "div") { + super(); + this.attributes = new Map(); + this.children = []; + this.classList = new MockClassList(); + this.dataset = {}; + this.disabled = false; + this.hidden = false; + this.id = ""; + this.innerHTML = ""; + this.parentElement = null; + this.scrollHeight = 100; + this.scrollTop = 100; + this.clientHeight = 100; + this.style = {}; + this.tagName = tagName.toUpperCase(); + this.textContent = ""; + this.title = ""; + this.value = ""; + } + + append(...nodes) { + nodes.forEach((node) => { + this.children.push(node); + if (node && typeof node === "object") node.parentElement = this; + }); + } + + focus() {} + querySelector() { return null; } + replaceChildren(...nodes) { + this.children = []; + this.append(...nodes); + } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + toggleAttribute(name, force) { this[name] = Boolean(force); } +} + +function ndjsonResponse(events) { + const chunks = events.map((event) => Buffer.from(`${JSON.stringify(event)}\n`, "utf8")); + let index = 0; + return { + ok: true, + status: 200, + body: { + getReader() { + return { + async cancel() {}, + async read() { + if (index >= chunks.length) return { done: true, value: undefined }; + return { done: false, value: chunks[index++] }; + }, + releaseLock() {}, + }; + }, + }, + }; +} + +function createHarness(events) { + const document = new MockEventTarget(); + const windowEvents = new MockEventTarget(); + const body = new MockElement("body"); + const input = new MockElement("textarea"); + const messages = new MockElement("div"); + const runButton = new MockElement("button"); + const transcript = new MockElement("section"); + input.id = "utterance"; + messages.id = "assistantMessages"; + runButton.id = "runButton"; + transcript.id = "assistantTranscript"; + const selectors = new Map([ + ["#utterance", input], + ["#assistantMessages", messages], + ["#runButton", runButton], + ["#assistantTranscript", transcript], + ]); + document.body = body; + document.createElement = (tagName) => new MockElement(tagName); + document.createTextNode = (text) => ({ nodeType: 3, textContent: String(text) }); + document.querySelector = (selector) => selectors.get(selector) || null; + document.querySelectorAll = (selector) => ( + selector === "#assistantMessages [data-message-role]" ? [...messages.children] : [] + ); + + const storage = new Map(); + const requestCalls = []; + const completed = []; + windowEvents.addEventListener("cognitive-agent:request-complete", (event) => { + completed.push(event.detail); + }); + const sandbox = { + AbortController, + Buffer, + CSS: { escape: (value) => String(value) }, + CustomEvent: class CustomEvent { + constructor(type, options = {}) { this.type = type; this.detail = options.detail; } + }, + Element: MockElement, + Event: class Event { + constructor(type, options = {}) { this.type = type; this.bubbles = options.bubbles === true; } + }, + HTMLFormElement: class HTMLFormElement extends MockElement {}, + JSON, + Math, + Promise, + TextDecoder, + Uint8Array, + clearTimeout, + console, + crypto: { randomUUID: () => `runtime-${requestCalls.length}-${messages.children.length}` }, + document, + localStorage: { getItem: () => null, removeItem() {}, setItem() {} }, + requestAnimationFrame: (callback) => callback(), + sessionStorage: { + getItem: (key) => storage.get(key) || null, + removeItem: (key) => storage.delete(key), + setItem: (key, value) => storage.set(key, String(value)), + }, + setTimeout, + __N7WEB_ASSISTANT_TEST__: true, + }; + sandbox.window = sandbox; + sandbox.addEventListener = windowEvents.addEventListener.bind(windowEvents); + sandbox.removeEventListener = windowEvents.removeEventListener.bind(windowEvents); + sandbox.dispatchEvent = windowEvents.dispatchEvent.bind(windowEvents); + sandbox.CognitiveAgentConsole = { getExecutionMode: () => "dry_run" }; + sandbox.CognitiveAgentNetwork = { + async request(url, options) { + requestCalls.push({ url, options }); + return ndjsonResponse(events); + }, + }; + vm.createContext(sandbox); + vm.runInContext(ASSISTANT_SOURCE, sandbox, { filename: "assistant-chat.js" }); + return { completed, input, messages, requestCalls, sandbox }; +} + +test("a late NDJSON model error is rendered after an HTTP 200 stream", async () => { + const publicMessage = "Codex AI 助手暂时没有响应,请稍后再试。"; + const harness = createHarness([ + { type: "message.accepted", route: "agent", engine: "codex-app-server" }, + { type: "work.started", display_text: "Codex 正在理解并处理……" }, + { type: "work.progress", display_text: "Codex 正在重新连接……" }, + { type: "error", error: "assistant_model_failed", message: publicMessage }, + ]); + harness.input.value = "解释一下 CAN FD"; + + await harness.sandbox.CognitiveAgentAssistant.submit(); + + assert.equal(harness.requestCalls.length, 1); + assert.equal(harness.requestCalls[0].url, "/api/assistant/stream"); + assert.equal(harness.requestCalls[0].options.method, "POST"); + assert.equal(harness.requestCalls[0].options.headers.Accept, "application/x-ndjson"); + assert.equal(harness.messages.children.length, 2); + const assistantArticle = harness.messages.children[1]; + assert.equal(assistantArticle.dataset.messageRole, "assistant"); + assert.equal(assistantArticle.dataset.messageContent, publicMessage); + const actionStatus = assistantArticle.children.at(-1).children.at(-1); + assert.equal(actionStatus.className, "assistant-action-status failed"); + assert.equal(actionStatus.children.at(-1).textContent, "本次请求未完成"); + assert.equal(harness.completed.length, 1); + assert.equal(harness.completed[0].error.code, "assistant_model_failed"); + assert.equal(harness.completed[0].error.message, publicMessage); + assert.equal(harness.completed[0].result, null); +}); + +test("a completed NDJSON answer remains the authoritative visible response", async () => { + const harness = createHarness([ + { type: "message.accepted", route: "agent", engine: "codex-app-server" }, + { type: "answer.delta", delta: "流式草稿" }, + { type: "work.completed", success: true, display_text: "Codex 已完成" }, + { type: "answer.completed", route: "agent", answer: "最终回答" }, + ]); + harness.input.value = "普通问题"; + + await harness.sandbox.CognitiveAgentAssistant.submit(); + + const assistantArticle = harness.messages.children[1]; + assert.equal(assistantArticle.dataset.messageContent, "最终回答"); + assert.equal(harness.completed.length, 1); + assert.equal(harness.completed[0].error, null); +});