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:
@@ -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; });
|
||||
@@ -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; });
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user