c812a1a595
Integrate the remote firmware workbench, OTA delivery, and timed mode schedules while preserving the local N7 scene and runtime changes.
327 lines
20 KiB
JavaScript
327 lines
20 KiB
JavaScript
"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; });
|