Merge origin/feature/N7Web into feature/N7Web

Integrate the remote firmware workbench, OTA delivery, and timed mode schedules while preserving the local N7 scene and runtime changes.
This commit is contained in:
Miayui
2026-09-17 12:46:51 +08:00
135 changed files with 30180 additions and 513 deletions
+226
View File
@@ -0,0 +1,226 @@
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 assistantAnswer = (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) => {
const pathname = new URL(route.request().url()).pathname;
if (/^\/api\/mode-profiles\/[^/]+\/execute$/.test(pathname)) {
return route.fulfill({ json: {
status: success ? "succeeded" : "failed",
execution_mode: "dry_run",
operation_results: [],
} });
}
return route.fulfill({
status: 200, contentType: "application/x-ndjson", body: assistantAnswer(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;
}
const directModeExecution = /^\/api\/mode-profiles\/[^/]+\/execute$/.test(pathname);
if (pathname === "/api/assistant/stream" || directModeExecution) {
requests.push({ pathname, payload: route.request().postDataJSON() });
if (nextReply === "hold") { heldResponse = route; return; }
const success = nextReply !== "fail";
nextReply = "success";
if (directModeExecution && success) {
await route.continue();
return;
}
if (directModeExecution) {
const response = await route.fetch();
const payload = await response.json();
await route.fulfill({ status: 200, json: { ...payload, status: "failed" } });
return;
}
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 = async (id, expectedState) => {
try {
await page.waitForFunction(
({ id, expectedState }) => document.querySelector(`.mode-chips [data-mode-profile-id="${id}"]`)?.dataset.modeState === expectedState,
{ id, expectedState },
);
} catch (error) {
const snapshot = await page.evaluate((modeId) => ({
modeState: document.querySelector(`.mode-chips [data-mode-profile-id="${modeId}"]`)?.dataset.modeState,
resultTitle: document.querySelector('#resultTitle')?.textContent,
runStatus: document.querySelector('#runStatus')?.textContent,
toast: document.querySelector('#toast')?.textContent,
}), id);
throw new Error(`mode ${id} did not reach ${expectedState}: ${JSON.stringify(snapshot)}; browser errors: ${JSON.stringify(errors)}`, { cause: error });
}
};
async function expectOneCommand(action, modeId) {
const before = requests.length;
await action();
await page.waitForFunction(() => document.querySelector('#utterance').getAttribute('aria-busy') === 'false');
await waitIdle();
await page.waitForFunction(
(id) => document.querySelector(`.mode-chips [data-mode-profile-id="${id}"]`)?.disabled === false,
modeId,
);
assert.equal(requests.length, before + 1, "gesture must submit exactly once");
assert.equal(requests.at(-1).pathname, `/api/mode-profiles/${modeId}/execute`);
}
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'));
await page.locator('[data-execution-mode="dry_run"]').evaluate((button) => button.click());
await page.waitForFunction(() => window.CognitiveAgentConsole.getExecutionMode() === 'dry_run');
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));
const firmwareToggle = page.locator('#firmwareWorkbenchToggle');
await firmwareToggle.waitFor({ state: 'attached' });
assert.equal(
await firmwareToggle.evaluate((element) => element.parentElement?.id),
'v2PanelControlDock',
'firmware workbench must mount into the dock created after Vue renders',
);
// 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(), "office");
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();
}, "office");
// 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(), "office");
// Keyboard activation and management cards share the same submission path.
await office.focus();
await expectOneCommand(() => page.keyboard.press('Enter'), "office");
await office.focus();
await expectOneCommand(() => page.keyboard.press('Space'), "office");
await page.locator('#modeGalleryToggle').click();
await expectOneCommand(() => page.locator('#modeGalleryGroups button[data-mode-profile-id="nap"]').click(), "nap");
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.evaluate((button) => button.click());
assert.equal(requests.length, beforeBusy + 1);
assert.equal(await office.isDisabled(), true);
await heldResponse.continue();
nextReply = 'success';
await waitIdle();
await waitState('office', 'succeeded');
nextReply = 'fail';
await expectOneCommand(() => office.click(), "office");
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(), "office");
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);
await touchPage.locator('[data-execution-mode="dry_run"]').evaluate((button) => button.click());
await touchPage.waitForFunction(() => window.CognitiveAgentConsole.getExecutionMode() === 'dry_run');
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).pathname,'/api/mode-profiles/office/execute');
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; });
+326
View File
@@ -0,0 +1,326 @@
"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 && /^\/api\/mode-profiles\/[^/]+\/execute$/.test(pathname)) {
assert.equal(request.postDataJSON()?.execution_mode, "dry_run");
await route.fulfill({ json: mockedExecution });
return;
}
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");
async function submitDraft(text) {
const responsePromise = responseFor("/api/assistant/stream");
await page.locator("#utterance").fill(text);
await page.locator("#utterance").press("Enter");
const response = await responsePromise;
const body = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text;
assert.equal(response.status(), 200, `${text}: ${body}`);
const events = body.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 draft for ${text}: ${body}`);
assert.equal(payload.status, "succeeded");
assert.equal(payload.execution_mode, "dry_run");
const draft = payload.mode_profile_draft;
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
await card.waitFor({ state: "visible" });
assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count);
return { payload, draft, card, request: posted.filter((item) => item.pathname === "/api/assistant/stream").at(-1).payload };
}
// The original failing two-turn request must revise the server-owned
// draft, not attempt immediate hardware execution or replace its seats.
const recline = await submitDraft("构建一个躺平模式");
const originalActions = recline.payload.actions;
await recline.card.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(evidenceDir, "mode-device-addition-before.png") });
const expanded = await submitDraft("在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作");
assert.deepEqual(expanded.request.mode_profile_draft_context, {
name: recline.draft.name,
source_request_id: recline.draft.source_request_id,
vehicle_id: recline.draft.vehicle_id,
});
assert.notEqual(expanded.draft.source_request_id, recline.draft.source_request_id);
assert.equal(expanded.draft.draft_revision, 2);
const actionKey = (action) => `${action.capability}:${action.target?.instance || action.target?.zone || ""}`;
const expandedActions = new Map(expanded.payload.actions.map((action) => [actionKey(action), action]));
for (const original of originalActions) {
assert.deepEqual(expandedActions.get(actionKey(original))?.parameters, original.parameters,
"adding devices must preserve all existing action values");
}
for (const capability of ["climate.set_temperature", "interior_light.set_reading_power", "window.set_position"]) {
assert.ok(expanded.payload.actions.some((action) => action.capability === capability), capability);
}
assert.ok(expanded.payload.actions.some((action) => action.domain === "ambient_light"));
assert.ok(expanded.payload.actions.every((action) => action.domain !== "seat"
|| (action.target?.instance || action.target?.zone) === "passenger"));
// The current registered lay-flat template already contains these device
// groups. Repeating the append must be idempotent and must not duplicate
// any existing operation while still producing a new draft revision.
assert.equal(expanded.draft.operation_count, recline.draft.operation_count);
assert.equal(expandedActions.size, expanded.payload.actions.length);
assert.equal(await recline.card.evaluate((card) => card.classList.contains("is-superseded")), true);
assert.equal(await recline.card.locator(".assistant-mode-draft-save").isDisabled(), true);
assert.equal(posted.filter((item) => item.pathname === "/api/mode-profiles").length, 0);
await expanded.card.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(evidenceDir, "mode-device-addition-after.png") });
const temperatureEdit = await submitDraft("把躺平模式的空调温度调整到26度");
assert.equal(temperatureEdit.request.mode_profile_draft_context.source_request_id, expanded.draft.source_request_id);
assert.equal(temperatureEdit.draft.draft_revision, 3);
assert.equal(temperatureEdit.draft.operation_count, expanded.draft.operation_count);
assert.ok(temperatureEdit.payload.actions.some((action) => action.capability === "climate.set_temperature"
&& action.parameters.temperature_c === 26));
// 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,
decision_summary: [], trace_events: [], validation: [], model_requests: [],
function_configs: {}, command_plans: {}, saved_presets: [],
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; });
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import hashlib
import json
import threading
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from cognitive_agent.firmware_ota_server import FirmwareOtaHandler, FirmwareOtaRepository
class TestFirmwareOtaServer:
def test_latest_download_head_and_resume_are_read_only(self, tmp_path: Path) -> None:
release_id = "fw-rel-123456789abc"
filename = "PassengerSeat_test.zip"
package = (b"vehicle-firmware-package" * 128) + b"end"
release_root = tmp_path / "releases" / release_id
release_root.mkdir(parents=True)
package_path = release_root / filename
package_path.write_bytes(package)
manifest = {
"schema": "n7-ota-release/v1",
"release_id": release_id,
"firmware_filename": filename,
"size_bytes": len(package),
"sha256": hashlib.sha256(package).hexdigest(),
"capabilities": ["时间控制", "座椅加热"],
"published_at": "2026-09-16T13:17:17+08:00",
"status": "published",
"download_url": "http://stale-address.invalid/download",
}
(release_root / "release.json").write_text(
json.dumps(manifest, ensure_ascii=False), encoding="utf-8"
)
(tmp_path / "latest.json").write_text(
json.dumps(manifest, ensure_ascii=False), encoding="utf-8"
)
older_release_id = "fw-rel-000000000001"
older_root = tmp_path / "releases" / older_release_id
older_root.mkdir(parents=True)
older_filename = "PassengerSeat_older.zip"
older_package = b"older-firmware-package"
(older_root / older_filename).write_bytes(older_package)
older_manifest = {
**manifest,
"release_id": older_release_id,
"firmware_filename": older_filename,
"size_bytes": len(older_package),
"sha256": hashlib.sha256(older_package).hexdigest(),
"published_at": "2026-09-15T13:17:17+08:00",
}
(older_root / "release.json").write_text(
json.dumps(older_manifest, ensure_ascii=False), encoding="utf-8"
)
class Handler(FirmwareOtaHandler):
repository = FirmwareOtaRepository(
tmp_path, "https://n7-ota.example.ts.net/api/firmware-workbench/releases"
)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base_url = f"http://127.0.0.1:{server.server_port}"
try:
with urllib.request.urlopen(
f"{base_url}/api/firmware-workbench/releases/latest", timeout=3
) as response:
latest = json.loads(response.read().decode("utf-8"))
assert latest["download_url"] == (
f"https://n7-ota.example.ts.net/api/firmware-workbench/releases/"
f"{release_id}/download"
)
with urllib.request.urlopen(
f"{base_url}/api/firmware-workbench/releases", timeout=3
) as response:
releases = json.loads(response.read().decode("utf-8"))
assert releases["schema"] == "n7-ota-release-list/v1"
assert releases["count"] == 2
assert releases["latest_release_id"] == release_id
assert [item["release_id"] for item in releases["releases"]] == [
release_id,
older_release_id,
]
assert releases["releases"][1]["download_url"].endswith(
f"/{older_release_id}/download"
)
request = urllib.request.Request(
f"{base_url}/api/firmware-workbench/releases/{release_id}/download",
headers={"Range": "bytes=8-31"},
)
with urllib.request.urlopen(request, timeout=3) as response:
assert response.status == 206
assert response.headers["Content-Range"] == f"bytes 8-31/{len(package)}"
assert response.headers["X-Firmware-SHA256"] == manifest["sha256"]
assert response.read() == package[8:32]
head = urllib.request.Request(
f"{base_url}/api/firmware-workbench/releases/{release_id}/download",
method="HEAD",
)
with urllib.request.urlopen(head, timeout=3) as response:
assert response.status == 200
assert int(response.headers["Content-Length"]) == len(package)
assert response.read() == b""
finally:
server.shutdown()
server.server_close()
thread.join(timeout=3)
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import json
import tempfile
import threading
import time
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from cognitive_agent.application.firmware_workbench import FirmwareWorkbenchService
from cognitive_agent.auth import AuthConfig, AuthRuntime
from cognitive_agent.server import SeatDemoHandler
ROOT = Path(__file__).resolve().parents[2]
class TestFirmwareWorkbenchApi:
@classmethod
def setup_class(cls) -> None:
cls.temporary_directory = tempfile.TemporaryDirectory()
root = Path(cls.temporary_directory.name)
image = root / "Seat_Controller_demo.hex"
image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80)
def build_runner(task_id: str, log: object, cancel: threading.Event) -> Path:
log("info", f"IAR task {task_id}: 0 errors, 0 warnings")
return image
cls.workbench = FirmwareWorkbenchService(
ROOT,
artifact_root=root / "workbench",
ota_root=root / "ota",
build_runner=build_runner,
)
cls.original_workbench = SeatDemoHandler.firmware_workbench
cls.original_auth = SeatDemoHandler.auth
SeatDemoHandler.firmware_workbench = cls.workbench
SeatDemoHandler.auth = AuthRuntime(
AuthConfig(
enabled=False,
username="",
password_hash="",
session_secret=b"t" * 64,
session_ttl_seconds=3600,
login_attempts=5,
login_window_seconds=300,
requests_per_minute=120,
trust_proxy_headers=False,
)
)
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), SeatDemoHandler)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
cls.base_url = f"http://127.0.0.1:{cls.server.server_port}"
@classmethod
def teardown_class(cls) -> None:
cls.server.shutdown()
cls.server.server_close()
cls.thread.join(timeout=2)
cls.workbench.close()
SeatDemoHandler.firmware_workbench = cls.original_workbench
SeatDemoHandler.auth = cls.original_auth
cls.temporary_directory.cleanup()
@classmethod
def post(cls, path: str, payload: dict[str, object]) -> tuple[int, dict[str, object]]:
request = urllib.request.Request(
f"{cls.base_url}{path}",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(payload, ensure_ascii=False).encode(),
)
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, json.loads(response.read().decode())
def test_real_http_task_publish_detail_and_download_flow(self) -> None:
instruction = "增加位置控制、座椅通风和座椅加热功能"
status, created = self.post(
"/api/firmware-workbench/tasks", {"instruction": instruction}
)
assert status == 202
task_id = str(created["task"]["id"])
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/tasks/{task_id}", timeout=5
) as response:
task = json.loads(response.read().decode())
if task["status"] not in {"waiting", "running", "pending_confirmation"}:
break
time.sleep(0.03)
assert task["status"] == "success"
assert task["message"] == "已发布,待车机端下载升级"
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/releases/latest", timeout=5
) as response:
latest = json.loads(response.read().decode())
assert latest["release_id"] == task["release_id"]
assert latest["download_url"].endswith(f"/{task['release_id']}/download")
repeated_status, repeated = self.post(
"/api/firmware-workbench/tasks", {"instruction": instruction}
)
assert repeated_status == 202
assert repeated["task"]["id"] != task_id
repeated_task_id = str(repeated["task"]["id"])
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/tasks/{repeated_task_id}", timeout=5
) as response:
repeated_task = json.loads(response.read().decode())
if repeated_task["status"] not in {"waiting", "running", "pending_confirmation"}:
break
time.sleep(0.03)
assert repeated_task["status"] == "success"
artifact_id = task["artifact_id"]
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}", timeout=5
) as response:
artifact = json.loads(response.read().decode())
assert artifact["status"] == "published"
assert artifact["sha256"]
release_id = task["release_id"]
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/releases/{release_id}/download",
timeout=5,
) as response:
package = response.read()
assert response.headers["Content-Type"] == "application/zip"
assert "attachment" in response.headers["Content-Disposition"]
assert len(package) == artifact["size_bytes"]
delete_request = urllib.request.Request(
f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}",
method="DELETE",
)
with urllib.request.urlopen(delete_request, timeout=5) as response:
deleted = json.loads(response.read().decode())
assert deleted["deleted"] is True
repeated_artifact_id = str(repeated_task["artifact_id"])
repeated_delete_request = urllib.request.Request(
f"{self.base_url}/api/firmware-workbench/artifacts/{repeated_artifact_id}",
method="DELETE",
)
with urllib.request.urlopen(repeated_delete_request, timeout=5) as response:
assert json.loads(response.read().decode())["deleted"] is True
with urllib.request.urlopen(
f"{self.base_url}/api/firmware-workbench/state", timeout=5
) as response:
state = json.loads(response.read().decode())
assert [item["id"] for item in state["artifacts"]] == [
"fw-art-time-only-baseline"
]
with urllib.request.urlopen(
f"{self.base_url}/firmware-workbench.html", timeout=5
) as response:
page = response.read().decode()
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
assert "frame-ancestors 'self'" in response.headers["Content-Security-Policy"]
assert "座椅固件开发工作台" in page
+30
View File
@@ -89,6 +89,13 @@ class TestMutationRequestSecurity:
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read().decode("utf-8"))
def get(self, path: str) -> tuple[int, dict[str, object]]:
try:
with urllib.request.urlopen(f"{self.base_url}{path}", timeout=3) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read().decode("utf-8"))
def test_same_origin_json_assistant_request_is_accepted(self) -> None:
status, body = self.post(
"/api/assistant/stream",
@@ -147,6 +154,29 @@ class TestMutationRequestSecurity:
assert status == 200
assert MutationSecurityHandler.last_hardware_bypass is False
def test_vehicle_ota_download_is_public_but_workbench_state_remains_private(self) -> None:
MutationSecurityHandler.auth = _auth(enabled=True)
try:
download_status, download_body = self.get(
"/api/firmware-workbench/releases/fw-rel-missing1234/download"
)
latest_status, latest_body = self.get(
"/api/firmware-workbench/releases/latest"
)
state_status, state_body = self.get("/api/firmware-workbench/state")
finally:
MutationSecurityHandler.auth = _auth(enabled=False)
assert download_status == 404
assert download_body["error"] == "not_found"
assert latest_status in {200, 404}
if latest_status == 404:
assert latest_body["error"] == "not_found"
else:
assert latest_body["download_url"]
assert state_status == 401
assert state_body["error"] == "authentication_required"
def test_hardware_bypass_requires_auth_enabled_and_direct_loopback(self) -> None:
handler = object.__new__(SeatDemoHandler)
handler.client_address = ("127.0.0.1", 12345)
+104
View File
@@ -0,0 +1,104 @@
"""Exercise real pointer/click dispatch on the complete console in Chromium."""
from __future__ import annotations
import os
import re
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",
"COGNITIVE_AGENT_EXECUTION_MODE": "dry_run",
},
):
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:
path = urlparse(self.path).path
if path == "/api/runtime":
runtime = self.service.runtime_info()
runtime["execution_mode"] = "dry_run"
self._json(runtime)
return
if 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:
path = urlparse(self.path).path
if re.fullmatch(r"/api/mode-profiles/[a-z0-9_-]+/execute", path):
super().do_POST()
return
# Assistant requests 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()
+77 -3
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,
):
@@ -328,6 +329,37 @@ class SeatWebApiTests(unittest.TestCase):
self.assertIn("imported_count", payload["mode_scene_ingress"])
self.assertNotIn("modes", payload)
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:
@@ -1353,7 +1385,7 @@ operations:
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn(
'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"',
'const CACHE_VERSION = "cognitive-agent-pad-v322-n7-firmware-merge"',
service_worker,
)
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
@@ -1363,6 +1395,9 @@ operations:
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker)
self.assertIn('/energy-monitor.js?v=energy-monitor-5-performance', service_worker)
self.assertIn('"/app.js?v=dual-seat-145-n7-dispatch"', service_worker)
self.assertIn('/firmware-workbench-entry.css?v=1', service_worker)
self.assertIn('/firmware-workbench-entry.js?v=2', service_worker)
self.assertIn('/firmware-workbench.html', service_worker)
self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker)
self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker)
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker)
@@ -2601,6 +2636,15 @@ 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(
all(
"self_extension" in payload["function_configs"]
for payload in validated_payloads
)
)
self.assertTrue(
any(
{"native_climate", "self_extension"}.issubset(
@@ -2861,11 +2905,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"]),
@@ -3238,6 +3287,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",