merge: sync feature/N7Web into feature/N7Web-Tech

# Conflicts:
#	apps/web-console/app.js
#	apps/web-console/assistant-chat.js
#	apps/web-console/index.html
#	apps/web-console/service-worker.js
#	src/cognitive_agent/server.py
#	tests/e2e/test_seat_web_api.py
#	tests/unit/test_assistant_frontend_contract.py
#	tests/unit/test_energy_monitor_frontend_contract.py
#	tests/unit/test_frontend_theme_contract.py
#	tests/unit/test_mode_scene_catalog.py
This commit is contained in:
Miayui
2026-09-16 15:03:54 +08:00
68 changed files with 7292 additions and 123 deletions
+178
View File
@@ -0,0 +1,178 @@
const assert = require("node:assert/strict");
const path = require("node:path");
const { chromium } = require("playwright");
const [baseURL, evidenceDir] = process.argv.slice(2);
const desktop = { width: 1600, height: 1000 };
async function main() {
const browser = await chromium.launch({
headless: true,
...(process.env.N7WEB_BROWSER_CHANNEL
? { channel: process.env.N7WEB_BROWSER_CHANNEL }
: process.platform === "win32" ? { channel: "msedge" } : {}),
});
const context = await browser.newContext({ viewport: desktop, serviceWorkers: "block" });
const page = await context.newPage();
const requests = [];
const errors = [];
const unexpectedWrites = [];
let heldResponse = null;
let nextReply = "success";
const answer = (success) => [
{ type: "action.completed", success, payload: {
status: success ? "succeeded" : "failed", execution_mode: "dry_run",
} },
{ type: "answer.completed", answer: success ? "模式执行测试完成" : "模式执行测试失败" },
].map((item) => JSON.stringify(item)).join("\n") + "\n";
const fulfill = (route, success = true) => route.fulfill({
status: 200, contentType: "application/x-ndjson", body: answer(success),
});
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ''}`);
});
async function handleApiRoute(route) {
const pathname = new URL(route.request().url()).pathname;
if (['/api/hardware/seat-state', '/api/hardware/vehicle-state'].includes(pathname)) {
// This legacy GET may attempt automatic firmware recovery. Never touch
// the physical gateway while verifying browser interactions.
await route.fulfill({json:{status:'unavailable',hardware_execution_allowed:false}});
return;
}
// The isolated server deliberately does not launch Codex or publish a
// physical display mirror. Keep these background integration probes local.
if (pathname === '/api/health' || pathname === '/api/extension/display-sync') {
await route.fulfill({json:{status:'ok',assistant:{status:'ready',available:true}}});
return;
}
if (pathname === "/api/assistant/stream") {
requests.push(route.request().postDataJSON());
if (nextReply === "hold") { heldResponse = route; return; }
const success = nextReply !== "fail";
nextReply = "success";
await fulfill(route, success);
return;
}
if (route.request().method() !== "GET") {
unexpectedWrites.push(route.request().url());
await route.fulfill({ status: 403, body: "test forbids vehicle writes" });
return;
}
await route.continue();
}
await context.route("**/api/**", handleApiRoute);
const rail = page.locator(".mode-chips");
const office = rail.locator('button[data-mode-profile-id="office"]');
const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending());
const waitState = (id, state) => page.waitForFunction(
({ id, state }) => document.querySelector(`.mode-chips [data-mode-profile-id="${id}"]`)?.dataset.modeState === state,
{ id, state },
);
async function expectOneCommand(action, command) {
const before = requests.length;
await action();
await page.waitForFunction(() => document.querySelector('#utterance').getAttribute('aria-busy') === 'false');
await waitIdle();
assert.equal(requests.length, before + 1, "gesture must submit exactly once");
assert.equal(requests.at(-1).message, command);
}
try {
await page.goto(baseURL, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3);
await page.waitForFunction(() => document.body.classList.contains('assistant-chat-enabled'));
assert.equal(await page.title(), "511工程-智能座舱");
assert.equal(new URL(page.url()).pathname, "/");
assert.ok((await page.locator('body').innerText()).includes("整车孪生界面"));
assert.ok(await rail.evaluate((element) => element.scrollWidth > element.clientWidth));
// This is a real mouse sequence: pointerdown -> pointerup -> click. The
// prior pointer capture bug cannot be detected by button.dispatchEvent().
await page.evaluate(() => window.CognitiveAgentAssistant.close());
await expectOneCommand(() => office.click(), "进入办公模式");
await waitState("office", "succeeded");
assert.equal(await page.locator('#assistantDrawerToggle').getAttribute('aria-expanded'), 'true');
assert.ok((await page.locator('#assistantMessages').innerText()).includes('进入办公模式'));
// A small physical hand movement is still a click.
const box = await office.boundingBox();
await expectOneCommand(async () => {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2);
await page.mouse.up();
}, "进入办公模式");
// Dragging from a card must scroll, without accidentally running it.
const beforeDrag = requests.length;
const startScroll = await rail.evaluate((element) => element.scrollLeft);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 - 90, box.y + box.height / 2, { steps: 8 });
await page.mouse.up();
assert.ok(await rail.evaluate((element) => element.scrollLeft) > startScroll);
assert.equal(requests.length, beforeDrag);
await rail.evaluate((element) => { element.scrollLeft = 0; });
await expectOneCommand(() => office.click(), "进入办公模式");
// Keyboard activation and management cards share the same submission path.
await office.focus();
await expectOneCommand(() => page.keyboard.press('Enter'), "进入办公模式");
await office.focus();
await expectOneCommand(() => page.keyboard.press('Space'), "进入办公模式");
await page.locator('#modeGalleryToggle').click();
await expectOneCommand(() => page.locator('#modeGalleryGroups button[data-mode-profile-id="nap"]').click(), "进入小憩模式");
await waitState("nap", "succeeded");
// Busy requests are not duplicated, nor does another card cancel them.
nextReply = 'hold';
const beforeBusy = requests.length;
await office.click();
await waitState('office', 'running');
await office.click();
assert.equal(requests.length, beforeBusy + 1);
assert.equal(await page.evaluate(() => window.CognitiveAgentAssistant.isPending()), true);
await fulfill(heldResponse);
nextReply = 'success';
await waitIdle();
await waitState('office', 'succeeded');
nextReply = 'fail';
await expectOneCommand(() => office.click(), "进入办公模式");
await waitState('office', 'failed');
// A catalog with few visible cards must also work without overflow.
await rail.evaluate(element => {
for (const child of element.children) {
if (child.dataset.modeProfileId !== 'office') child.style.display = 'none';
}
});
await page.waitForFunction(() => {const e=document.querySelector('.mode-chips');return e.scrollWidth <= e.clientWidth;});
await expectOneCommand(() => office.click(), "进入办公模式");
await rail.evaluate(element => {
for (const child of element.children) child.style.removeProperty('display');
});
await page.setViewportSize(desktop);
await page.screenshot({path:path.join(evidenceDir,'mode-rail-desktop.png')});
assert.deepEqual(errors, []);
assert.deepEqual(unexpectedWrites, []);
// Touch input uses native scrolling and also submits once when tapped.
const touchContext = await browser.newContext({viewport:{width:720,height:1000},hasTouch:true,serviceWorkers:'block'});
await touchContext.route('**/api/**', handleApiRoute);
const touchPage = await touchContext.newPage();
await touchPage.goto(baseURL, {waitUntil:'domcontentloaded'});
await touchPage.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3);
const beforeTouch = requests.length;
await touchPage.locator('.mode-chips button[data-mode-profile-id="office"]').tap();
await touchPage.waitForFunction(() => document.querySelector('.mode-chips [data-mode-profile-id="office"]').dataset.modeState === 'succeeded');
assert.equal(requests.length,beforeTouch+1);
assert.equal(requests.at(-1).message,'进入办公模式');
await touchPage.screenshot({path:path.join(evidenceDir,'mode-rail-touch.png')});
await touchContext.close();
console.log('PASS: click, jitter, drag, Enter, Space, gallery, busy, failure, resize, touch; no runtime errors');
} finally {
await browser.close();
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });
+256
View File
@@ -0,0 +1,256 @@
"use strict";
const assert = require("node:assert/strict");
const path = require("node:path");
const { chromium } = require("playwright");
const [baseURL, evidenceDir] = process.argv.slice(2);
async function main() {
const browser = await chromium.launch({
headless: true,
...(process.env.N7WEB_BROWSER_CHANNEL ? { channel: process.env.N7WEB_BROWSER_CHANNEL }
: process.platform === "win32" ? { channel: "msedge" } : {}),
});
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, serviceWorkers: "block" });
const page = await context.newPage();
const errors = [];
const posted = [];
const responses = [];
let mockedExecution = null;
let statusReads = 0;
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ""}`);
});
await context.route("**/api/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (["/api/hardware/seat-state", "/api/hardware/vehicle-state"].includes(pathname)) {
await route.fulfill({ json: { status: "unavailable", hardware_execution_allowed: false } });
return;
}
if (request.method() !== "GET") posted.push({ pathname, payload: request.postDataJSON() });
if (mockedExecution && pathname === "/api/assistant/stream") {
await route.fulfill({ contentType: "application/x-ndjson", body: [
{ type: "action.completed", success: true, payload: mockedExecution },
{ type: "answer.completed", answer: "动态时间轴已启动,后续阶段会按计划执行。" },
].map((event) => JSON.stringify(event)).join("\n") + "\n" });
return;
}
if (mockedExecution && pathname === "/api/modes/schedule/status") {
statusReads += 1;
assert.equal(new URL(request.url()).searchParams.get("vehicle_id"), "denza_n7");
await route.fulfill({ json: {
...mockedExecution.schedule_execution, vehicle_id: "denza_n7",
phase_index: 1, repeat_index: 0, completed: false,
status: statusReads === 1 ? "running" : "failed",
error: statusReads === 1 ? null : "QA阶段模拟失败",
} });
return;
}
if (["/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"].includes(pathname)) {
// Chromium can discard completed streaming response bodies from CDP.
// Fetch the real isolated endpoint and retain its exact response bytes.
const response = await route.fetch();
const text = await response.text();
responses.push({ pathname, text, status: response.status() });
await route.fulfill({ response, body: text });
return;
}
await route.continue();
});
const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending());
const responseFor = (pathname) => page.waitForResponse((response) => new URL(response.url()).pathname === pathname);
try {
await page.goto(baseURL, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => document.body.classList.contains("assistant-chat-enabled")
&& document.querySelectorAll(".mode-chips button").length > 3);
assert.equal(await page.title(), "511工程-智能座舱");
assert.equal(new URL(page.url()).origin, baseURL);
assert.match(await page.locator("body").innerText(), /整车孪生界面/);
if (!await page.locator('[data-execution-mode="dry_run"]').isVisible()) {
await page.locator('#v2SidebarToggle').click();
}
await page.locator('[data-execution-mode="dry_run"]').click();
assert.equal(await page.evaluate(() => window.CognitiveAgentConsole.getExecutionMode()), "dry_run");
// Exercise the same real assistant endpoint and rendered cards for both
// implicit passenger selection and an explicit driver request. These
// previews stay unsaved, so the timeline save/trial tests remain isolated.
for (const [index, testCase] of [
{ text: "构建一个阅读模式", seat: "passenger", label: "副驾", excluded: "主驾" },
{ text: "构建一个主驾阅读模式", seat: "driver", label: "主驾", excluded: "副驾" },
{ text: "构建一个主驾座椅动态模式", seat: "driver", label: "主驾", excluded: "副驾" },
].entries()) {
const responsePromise = responseFor("/api/assistant/stream");
await page.locator("#utterance").fill(testCase.text);
await page.locator("#utterance").press("Enter");
const response = await responsePromise;
assert.equal(response.status(), 200);
const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1)
.text.trim().split("\n").map((line) => JSON.parse(line));
await waitIdle();
const payload = events.find((event) => event.payload?.mode_profile_draft)?.payload;
assert.ok(payload, `Missing selected-seat draft for ${testCase.text}: ${JSON.stringify(events)}`);
const draft = payload.mode_profile_draft;
const actions = draft.schedule ? draft.schedule.phases.flatMap((phase) => phase.operations) : payload.actions;
const seats = actions.filter((action) => action.capability?.startsWith("seat."))
.map((action) => action.target_scope || action.target?.instance || action.target?.zone);
assert.ok(seats.length, `Missing selected-seat actions for ${testCase.text}`);
assert.deepEqual([...new Set(seats)], [testCase.seat]);
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
await card.waitFor({ state: "visible" });
const actionText = await card.locator(".assistant-mode-draft-row").allTextContents();
assert.ok(actionText.some((text) => text.includes(testCase.label)), actionText.join("\n"));
assert.ok(actionText.every((text) => !text.includes(testCase.excluded)), actionText.join("\n"));
await card.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(evidenceDir, `seat-scope-preview-${index + 1}.png`) });
}
const savedModes = [];
for (const [index, text] of ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"].entries()) {
const responsePromise = responseFor("/api/assistant/stream");
await page.locator("#utterance").fill(text);
await page.locator("#utterance").press("Enter");
const response = await responsePromise;
assert.equal(response.status(), 200);
const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text.trim().split("\n").map((line) => JSON.parse(line));
await waitIdle();
const draftEvent = events.find((event) => event.payload?.mode_profile_draft);
assert.ok(draftEvent, `Missing schedule draft for ${text}: ${JSON.stringify(events)}`);
const draft = draftEvent.payload.mode_profile_draft;
assert.equal(draft.schedule?.schema, "cognitive-agent-mode-schedule/v1");
assert.ok(draft.schedule.phases.length >= 2);
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
assert.equal(await card.locator(".assistant-mode-schedule-phase").count(), draft.schedule.phases.length);
assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count);
assert.match(await card.innerText(), /重复 \d+ 次 · 总时长/);
assert.match(await card.innerText(), /停止动态模式/);
if (index === 0) {
assert.match(await card.innerText(), /副驾/);
assert.doesNotMatch(await card.innerText(), /主驾/);
} else {
const temperatures = draft.schedule.phases.flatMap((phase) => phase.operations)
.filter((operation) => operation.capability === "climate.set_temperature")
.map((operation) => operation.parameters.temperature_c);
assert.ok(new Set(temperatures).size >= 2, "preheat must change temperature over time");
for (const value of new Set(temperatures)) assert.ok((await card.innerText()).includes(`${value}℃`));
}
await card.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(evidenceDir, `schedule-preview-${index + 1}.png`) });
const trialPromise = responseFor("/api/mode-profile-drafts/trial");
await card.locator(".assistant-mode-draft-trial").click();
const trialResponse = await trialPromise;
const trial = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profile-drafts/trial").at(-1).text);
assert.equal(trialResponse.status(), 200, JSON.stringify(trial));
assert.equal(trial.status, "succeeded", JSON.stringify(trial));
assert.equal(trial.schedule_execution?.status, "completed");
await page.waitForFunction((requestId) => {
const status = document.querySelector(`[data-assistant-mode-draft="${requestId}"] .assistant-mode-draft-status`);
return status?.classList.contains("success") || status?.classList.contains("failed");
}, draft.source_request_id);
assert.match(await card.locator(".assistant-mode-draft-status").innerText(), /试运行通过/);
const savePromise = responseFor("/api/mode-profiles");
await card.locator(".assistant-mode-draft-save").click();
const saveResponse = await savePromise;
const saved = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profiles").at(-1).text);
assert.ok([200, 201].includes(saveResponse.status()), JSON.stringify(saved));
assert.ok(saved.mode_profile?.id);
savedModes.push(saved.mode_profile);
await page.locator(".assistant-mode-created").filter({ hasText: saved.mode_profile.name }).waitFor({ state: "visible" });
const button = page.locator(`.mode-chips button[data-mode-profile-id="${saved.mode_profile.id}"]`);
await button.waitFor({ state: "visible" });
// Saving reveals the card with smooth scrolling. A tooltip correctly
// closes while its rail is still moving, so wait for that motion to end.
await page.evaluate(() => new Promise((resolve) => {
const rail = document.querySelector('.mode-chips');
let previous = rail.scrollLeft;
let stable = 0;
const sample = () => {
const current = rail.scrollLeft;
stable = Math.abs(current - previous) < 0.1 ? stable + 1 : 0;
previous = current;
if (stable >= 12) resolve();
else requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}));
await page.mouse.move(1, 1);
const buttonBox = await button.boundingBox();
const railBox = await page.locator('.mode-chips').boundingBox();
const visibleLeft = Math.max(buttonBox.x, railBox.x);
const visibleRight = Math.min(buttonBox.x + buttonBox.width, railBox.x + railBox.width);
assert.ok(visibleRight > visibleLeft, 'saved card is visible in the mode rail');
// A native mouse move does not request another smooth scroll, unlike
// locator.hover() which scrolls the target to the center first.
await page.mouse.move((visibleLeft + visibleRight) / 2, buttonBox.y + buttonBox.height / 2);
const tooltip = page.locator("#modeChipActionPopover");
await tooltip.waitFor({ state: "visible" });
assert.match(await tooltip.innerText(), /重复 \d+ 次 · 总时长/);
assert.equal(await tooltip.locator(".mode-schedule-popover-phase").count(), draft.operation_count);
for (const phase of draft.schedule.phases) assert.ok((await tooltip.innerText()).includes(phase.name));
await tooltip.screenshot({ path: path.join(evidenceDir, `schedule-tooltip-${index + 1}.png`), timeout: 4000 });
await page.mouse.move(1, 1);
}
await page.locator("#modeGalleryToggle").click();
for (const mode of savedModes) {
const shell = page.locator(`#modeGalleryGroups .mode-gallery-card-shell`).filter({ has: page.locator(`button[data-mode-profile-id="${mode.id}"]`) });
const details = shell.locator(".mode-gallery-schedule-details");
await details.locator("summary").click();
assert.match(await details.innerText(), /开始 · 持续/);
assert.match(await details.innerText(), /不自动回滚/);
}
await page.locator("#modeGalleryGroups").evaluate((element) => { element.scrollTop = 0; });
await page.screenshot({ path: path.join(evidenceDir, "schedule-management-desktop.png") });
await page.locator("#modeGalleryClose").click();
await page.setViewportSize({ width: 720, height: 1000 });
if (await page.locator('#v2SidebarToggle').getAttribute('aria-expanded') === 'true') {
await page.locator('#v2SidebarToggle').click();
}
await page.locator(".assistant-mode-draft-scheduled").last().scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(evidenceDir, "schedule-preview-mobile.png") });
assert.equal(await page.locator("body").evaluate((body) => body.scrollWidth > window.innerWidth + 1), false);
// The backend sequence above is real and strictly dry-run. Separately
// verify the asynchronous UI contract with a synthetic running/failed
// response; it must never launch an actual timed hardware execution.
await page.setViewportSize({ width: 1600, height: 1000 });
const mode = savedModes[1];
mockedExecution = {
request_id: "browser-running-qa", status: "running", execution_mode: "dry_run",
orchestration_route: "mode_schedule_execute", vehicle_id: "denza_n7", mode_profile: mode,
schedule_execution: { status: "running", run_id: "qa-run", session_id: "qa-session" },
};
const messagesBefore = await page.locator('#assistantMessages [data-message-role]').count();
await page.locator(`.mode-chips button[data-mode-profile-id="${mode.id}"]`).click();
await waitIdle();
await page.waitForFunction((id) => document.querySelector(`.mode-chips button[data-mode-profile-id="${id}"]`)?.dataset.modeState === "started", mode.id);
const actionStatus = page.locator('.assistant-action-status').last();
assert.match(await actionStatus.innerText(), /已启动动态时间轴/);
await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('阶段 2'));
await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('QA阶段模拟失败'));
assert.equal(await actionStatus.getAttribute('class'), 'assistant-action-status failed');
assert.equal(statusReads, 2);
assert.equal(await page.locator('#assistantMessages [data-message-role]').count(), messagesBefore + 2);
await page.screenshot({ path: path.join(evidenceDir, "schedule-background-failure.png") });
assert.deepEqual(errors, []);
assert.ok(posted.filter((request) => request.pathname === "/api/assistant/stream").every((request) => request.payload.execution_mode === "dry_run"));
assert.equal(posted.filter((request) => request.pathname === "/api/mode-profile-drafts/trial").length, 2);
assert.equal(posted.filter((request) => request.pathname === "/api/mode-profiles").length, 2);
console.log(`PASS ${baseURL}: default passenger and explicit driver static/dynamic previews -> both exact user phrases -> phased preview -> virtual trial -> save -> top tooltip -> management; desktop 1600x1000 and 720x1000; no JS errors; no hardware requests forwarded`);
console.log(`Evidence: ${evidenceDir}`);
} catch (error) {
await page.screenshot({ path: path.join(evidenceDir, "schedule-browser-failure.png") });
console.error(`Page text: ${(await page.locator("body").innerText()).slice(-5000)}`);
throw error;
} finally {
await context.close();
await browser.close();
}
}
main().catch((error) => { console.error(error); process.exitCode = 1; });
+93
View File
@@ -0,0 +1,93 @@
"""Exercise real pointer/click dispatch on the complete console in Chromium."""
from __future__ import annotations
import os
import shutil
import subprocess
import threading
from http import HTTPStatus
from http.server import ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch
from urllib.parse import urlparse
import pytest
from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.auth import AuthConfig, AuthRuntime
from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler
def test_mode_rail_real_browser_interactions(tmp_path: Path) -> None:
node = shutil.which("node")
if not node:
pytest.skip("Node.js is required for the browser interaction regression")
env = dict(os.environ)
bundled_modules = Path(node).resolve().parent.parent / "node_modules"
if bundled_modules.is_dir():
env["NODE_PATH"] = os.pathsep.join(
item for item in (str(bundled_modules), env.get("NODE_PATH")) if item
)
available = subprocess.run(
[node, "-e", "require.resolve('playwright')"],
env=env,
capture_output=True,
encoding="utf-8",
check=False,
)
if available.returncode:
pytest.skip("Playwright is required for the real browser regression")
with patch.dict(
os.environ,
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
},
):
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = tmp_path / "artifacts"
class BrowserHandler(SeatDemoHandler):
auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
def do_GET(self) -> None:
if urlparse(self.path).path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
self._json({"status": "unavailable", "hardware_execution_allowed": False})
return
super().do_GET()
def do_POST(self) -> None:
# Only GET reaches this test server. Browser requests to the
# assistant are intercepted; unexpected writes fail closed.
self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN)
def log_message(self, *_args: object) -> None:
pass
BrowserHandler.service = service
server = ThreadingHTTPServer(("127.0.0.1", 0), BrowserHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
completed = subprocess.run(
[
node,
str(Path(__file__).with_name("mode_rail_browser.cjs")),
f"http://127.0.0.1:{server.server_port}",
str(tmp_path),
],
env=env,
capture_output=True,
encoding="utf-8",
timeout=120,
check=False,
)
assert completed.returncode == 0, completed.stdout + completed.stderr
finally:
server.shutdown()
server.server_close()
thread.join(timeout=3)
service.close()
+109
View File
@@ -0,0 +1,109 @@
"""Full browser -> deterministic assistant -> schedule trial/save, without hardware."""
from __future__ import annotations
import os
import shutil
import subprocess
import threading
from http import HTTPStatus
from http.server import ThreadingHTTPServer
from pathlib import Path
from unittest.mock import MagicMock, patch
from urllib.parse import urlparse
import pytest
from cognitive_agent.application.codex_assistant import CodexAssistantService
from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.auth import AuthConfig, AuthRuntime
from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler
def test_mode_schedule_real_browser_preview_trial_and_save(tmp_path: Path) -> None:
node = shutil.which("node")
if not node:
runtime = Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime" / "dependencies" / "node" / "bin"
node = next((str(path) for path in (runtime / "node.exe", runtime / "node") if path.is_file()), None)
if not node:
pytest.skip("Node.js is required for the schedule browser regression")
env = dict(os.environ)
modules = Path(node).resolve().parent.parent / "node_modules"
if modules.is_dir():
env["NODE_PATH"] = os.pathsep.join(value for value in (str(modules), env.get("NODE_PATH")) if value)
probe = subprocess.run([node, "-e", "require.resolve('playwright')"], env=env, capture_output=True, encoding="utf-8", check=False)
if probe.returncode:
pytest.skip("Playwright is required for the schedule browser regression")
with patch.dict(os.environ, {
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
"COGNITIVE_AGENT_EXECUTION_MODE": "dry_run",
"COGNITIVE_AGENT_MODE_PROFILE_ROOT": str(tmp_path / "user-modes"),
}):
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = tmp_path / "artifacts"
# A failed deterministic route must fail visibly, never launch a model or
# make network calls to an external assistant during the browser test.
client = MagicMock()
client.available = False
client.status = "test-offline"
assistant = CodexAssistantService(client, fast_implicit_controls=True)
forbidden = []
class BrowserHandler(SeatDemoHandler):
auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
# Both legacy GET paths can touch physical adapters or firmware.
self._json({"status": "unavailable", "hardware_execution_allowed": False})
return
if path == "/api/health":
self._json({"status": "ok", "assistant": {"status": "ready", "available": True}})
return
super().do_GET()
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/api/extension/display-sync":
self._json({"status": "ok"})
return
if path not in {"/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"}:
forbidden.append(path)
self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN)
return
super().do_POST()
def _read_json_body(self, **kwargs):
payload = super()._read_json_body(**kwargs)
if payload.get("execution_mode", "dry_run") != "dry_run":
forbidden.append(f"execution_mode:{payload.get('execution_mode')}")
raise ValueError("Browser regression permits dry_run only")
return payload
def log_message(self, *_args: object) -> None:
pass
BrowserHandler.service = service
BrowserHandler.assistant = assistant
server = ThreadingHTTPServer(("127.0.0.1", 0), BrowserHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
completed = subprocess.run([
node, str(Path(__file__).with_name("mode_schedule_browser.cjs")),
f"http://127.0.0.1:{server.server_port}", str(tmp_path),
], env=env, capture_output=True, encoding="utf-8", timeout=180, check=False)
assert completed.returncode == 0, completed.stdout + completed.stderr
assert not forbidden, forbidden
assert len(list((tmp_path / "user-modes").rglob("*.yaml"))) == 2
finally:
server.shutdown()
server.server_close()
thread.join(timeout=3)
assistant.close()
service.close()
+70 -2
View File
@@ -52,6 +52,7 @@ class SeatWebApiTests(unittest.TestCase):
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
},
clear=False,
):
@@ -306,6 +307,37 @@ class SeatWebApiTests(unittest.TestCase):
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
self.assertIn("GET", response.headers["Access-Control-Allow-Methods"])
def test_schedule_status_stays_authenticated_and_sanitized_beside_public_catalog(self) -> None:
endpoint = f"{self.base_url}/api/modes/schedule/status?vehicle_id=denza_n7"
unauthenticated = urllib.request.build_opener()
with self.assertRaises(urllib.error.HTTPError) as caught:
unauthenticated.open(endpoint, timeout=3)
self.assertEqual(caught.exception.code, 401)
state = {
"status": "running", "vehicle_id": "denza_n7", "session_id": "conv-mode-schedule-api",
"run_id": "schedule-private-safe-id", "phase_index": 0, "repeat_index": 0,
"phase_count": 2, "repeat_count": 2, "executed_phases": 0,
"elapsed_ms": 250, "total_duration_ms": 6000, "completed": False,
"phase_results": [{"raw_can": "private-command-bytes"}],
"hardware_client_id": "private-bench-client",
"error": "private transport stack or secret",
}
with (
patch.object(self.service.mode_schedule_runner, "status", return_value=state) as status,
self.opener.open(endpoint, timeout=3) as response,
):
payload = json.loads(response.read().decode("utf-8"))
status.assert_called_once_with("denza_n7")
self.assertEqual(payload["status"], "running")
self.assertEqual(payload["phase_count"], 2)
self.assertEqual(payload["total_duration_ms"], 6000)
self.assertEqual(payload["completed"], False)
self.assertIn("后续阶段已停止", payload["error"])
self.assertNotIn("phase_results", payload)
self.assertNotIn("hardware_client_id", payload)
self.assertNotIn("private-command-bytes", json.dumps(payload))
self.assertNotIn("private transport", json.dumps(payload))
def test_user_mode_delete_endpoint_removes_only_user_profiles(self) -> None:
original_registry = self.service.mode_profiles
with tempfile.TemporaryDirectory() as directory:
@@ -2489,6 +2521,9 @@ operations:
if item["type"] == "validated_commands"
]
self.assertTrue(validated_payloads)
# The local-first stage publishes known IR controls before CAN parsing;
# subsequent evidence must include the complete combined command set.
self.assertEqual(set(validated_payloads[0]["function_configs"]), {"self_extension"})
self.assertTrue(
any(
{"native_climate", "self_extension"}.issubset(
@@ -2497,6 +2532,9 @@ operations:
for payload in validated_payloads
)
)
self.assertTrue(
all("self_extension" in payload["function_configs"] for payload in validated_payloads)
)
first_validated = next(
payload
for payload in validated_payloads
@@ -2749,11 +2787,16 @@ operations:
self.assertEqual(events[-1]["type"], "error")
self.assertEqual(events[-1]["error"], "can_transport_failed")
validated = [
validated_payloads = [
item["payload"]
for item in events
if item["type"] == "validated_commands"
][-1]
]
self.assertEqual(set(validated_payloads[0]["function_configs"]), {"self_extension"})
validated = next(
payload for payload in validated_payloads
if {"native_climate", "self_extension"}.issubset(payload["function_configs"])
)
self.assertEqual(validated["status"], "processing")
self.assertEqual(
set(validated["function_configs"]),
@@ -3126,6 +3169,31 @@ operations:
planner.assert_called_once()
self.assertEqual(planner.call_args.args[0]["utterance"], utterance)
def test_peripheral_timing_aliases_preserve_sequence_before_local_action_execution(self) -> None:
handler = object.__new__(SeatDemoHandler)
handler.service = SimpleNamespace(is_mode_profile_configuration_request=lambda _text: False)
capabilities = [
{"id": identity, "status": "learned", "device_id": "third-party-light",
"device_name": "第三方氛围灯", "card_uid": "1A53DD95", "action": action, "slot": slot}
for identity, action, slot in (("on", "开启灯光", 1), ("red", "红色灯光", 2), ("off", "关闭灯光", 3))
]
handler.extension = SimpleNamespace(list_peripheral_capabilities=lambda: capabilities)
handler.nfc = SimpleNamespace(snapshot=lambda: {
"reader": {"online": True}, "card": {"action": "present", "uid": "1A53DD95"},
})
for duration in ("5s", "5S", "500ms", "500MS", "1.5s", "5秒", "半分钟"):
with (
self.subTest(duration=duration),
patch.object(handler, "_execute_active_peripheral_request", return_value={"status": "succeeded"}) as planner,
patch.object(handler, "_execute_local_peripheral_actions", side_effect=AssertionError("sequence flattened")),
):
result = handler._execute_request_with_peripherals("/api/requests", {
"utterance": f"打开第三方氛围灯,红灯闪烁{duration}后,最后关闭第三方氛围灯",
"provider": "local", "execution_mode": "dry_run",
})
self.assertEqual(result["status"], "succeeded")
planner.assert_called_once()
def test_post_request_returns_complete_dry_run(self) -> None:
request = urllib.request.Request(
f"{self.base_url}/api/requests",