feat: add seat-scoped static and dynamic assistant modes
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const [baseURL, evidenceDir] = process.argv.slice(2);
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.N7WEB_BROWSER_CHANNEL ? { channel: process.env.N7WEB_BROWSER_CHANNEL }
|
||||
: process.platform === "win32" ? { channel: "msedge" } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
const posted = [];
|
||||
const responses = [];
|
||||
let mockedExecution = null;
|
||||
let statusReads = 0;
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ""}`);
|
||||
});
|
||||
await context.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (["/api/hardware/seat-state", "/api/hardware/vehicle-state"].includes(pathname)) {
|
||||
await route.fulfill({ json: { status: "unavailable", hardware_execution_allowed: false } });
|
||||
return;
|
||||
}
|
||||
if (request.method() !== "GET") posted.push({ pathname, payload: request.postDataJSON() });
|
||||
if (mockedExecution && pathname === "/api/assistant/stream") {
|
||||
await route.fulfill({ contentType: "application/x-ndjson", body: [
|
||||
{ type: "action.completed", success: true, payload: mockedExecution },
|
||||
{ type: "answer.completed", answer: "动态时间轴已启动,后续阶段会按计划执行。" },
|
||||
].map((event) => JSON.stringify(event)).join("\n") + "\n" });
|
||||
return;
|
||||
}
|
||||
if (mockedExecution && pathname === "/api/modes/schedule/status") {
|
||||
statusReads += 1;
|
||||
assert.equal(new URL(request.url()).searchParams.get("vehicle_id"), "denza_n7");
|
||||
await route.fulfill({ json: {
|
||||
...mockedExecution.schedule_execution, vehicle_id: "denza_n7",
|
||||
phase_index: 1, repeat_index: 0, completed: false,
|
||||
status: statusReads === 1 ? "running" : "failed",
|
||||
error: statusReads === 1 ? null : "QA阶段模拟失败",
|
||||
} });
|
||||
return;
|
||||
}
|
||||
if (["/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"].includes(pathname)) {
|
||||
// Chromium can discard completed streaming response bodies from CDP.
|
||||
// Fetch the real isolated endpoint and retain its exact response bytes.
|
||||
const response = await route.fetch();
|
||||
const text = await response.text();
|
||||
responses.push({ pathname, text, status: response.status() });
|
||||
await route.fulfill({ response, body: text });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending());
|
||||
const responseFor = (pathname) => page.waitForResponse((response) => new URL(response.url()).pathname === pathname);
|
||||
try {
|
||||
await page.goto(baseURL, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => document.body.classList.contains("assistant-chat-enabled")
|
||||
&& document.querySelectorAll(".mode-chips button").length > 3);
|
||||
assert.equal(await page.title(), "511工程-智能座舱");
|
||||
assert.equal(new URL(page.url()).origin, baseURL);
|
||||
assert.match(await page.locator("body").innerText(), /整车孪生界面/);
|
||||
if (!await page.locator('[data-execution-mode="dry_run"]').isVisible()) {
|
||||
await page.locator('#v2SidebarToggle').click();
|
||||
}
|
||||
await page.locator('[data-execution-mode="dry_run"]').click();
|
||||
assert.equal(await page.evaluate(() => window.CognitiveAgentConsole.getExecutionMode()), "dry_run");
|
||||
|
||||
// Exercise the same real assistant endpoint and rendered cards for both
|
||||
// implicit passenger selection and an explicit driver request. These
|
||||
// previews stay unsaved, so the timeline save/trial tests remain isolated.
|
||||
for (const [index, testCase] of [
|
||||
{ text: "构建一个阅读模式", seat: "passenger", label: "副驾", excluded: "主驾" },
|
||||
{ text: "构建一个主驾阅读模式", seat: "driver", label: "主驾", excluded: "副驾" },
|
||||
{ text: "构建一个主驾座椅动态模式", seat: "driver", label: "主驾", excluded: "副驾" },
|
||||
].entries()) {
|
||||
const responsePromise = responseFor("/api/assistant/stream");
|
||||
await page.locator("#utterance").fill(testCase.text);
|
||||
await page.locator("#utterance").press("Enter");
|
||||
const response = await responsePromise;
|
||||
assert.equal(response.status(), 200);
|
||||
const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1)
|
||||
.text.trim().split("\n").map((line) => JSON.parse(line));
|
||||
await waitIdle();
|
||||
const payload = events.find((event) => event.payload?.mode_profile_draft)?.payload;
|
||||
assert.ok(payload, `Missing selected-seat draft for ${testCase.text}: ${JSON.stringify(events)}`);
|
||||
const draft = payload.mode_profile_draft;
|
||||
const actions = draft.schedule ? draft.schedule.phases.flatMap((phase) => phase.operations) : payload.actions;
|
||||
const seats = actions.filter((action) => action.capability?.startsWith("seat."))
|
||||
.map((action) => action.target_scope || action.target?.instance || action.target?.zone);
|
||||
assert.ok(seats.length, `Missing selected-seat actions for ${testCase.text}`);
|
||||
assert.deepEqual([...new Set(seats)], [testCase.seat]);
|
||||
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
|
||||
await card.waitFor({ state: "visible" });
|
||||
const actionText = await card.locator(".assistant-mode-draft-row").allTextContents();
|
||||
assert.ok(actionText.some((text) => text.includes(testCase.label)), actionText.join("\n"));
|
||||
assert.ok(actionText.every((text) => !text.includes(testCase.excluded)), actionText.join("\n"));
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(evidenceDir, `seat-scope-preview-${index + 1}.png`) });
|
||||
}
|
||||
|
||||
const savedModes = [];
|
||||
for (const [index, text] of ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"].entries()) {
|
||||
const responsePromise = responseFor("/api/assistant/stream");
|
||||
await page.locator("#utterance").fill(text);
|
||||
await page.locator("#utterance").press("Enter");
|
||||
const response = await responsePromise;
|
||||
assert.equal(response.status(), 200);
|
||||
const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text.trim().split("\n").map((line) => JSON.parse(line));
|
||||
await waitIdle();
|
||||
const draftEvent = events.find((event) => event.payload?.mode_profile_draft);
|
||||
assert.ok(draftEvent, `Missing schedule draft for ${text}: ${JSON.stringify(events)}`);
|
||||
const draft = draftEvent.payload.mode_profile_draft;
|
||||
assert.equal(draft.schedule?.schema, "cognitive-agent-mode-schedule/v1");
|
||||
assert.ok(draft.schedule.phases.length >= 2);
|
||||
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
|
||||
assert.equal(await card.locator(".assistant-mode-schedule-phase").count(), draft.schedule.phases.length);
|
||||
assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count);
|
||||
assert.match(await card.innerText(), /重复 \d+ 次 · 总时长/);
|
||||
assert.match(await card.innerText(), /停止动态模式/);
|
||||
if (index === 0) {
|
||||
assert.match(await card.innerText(), /副驾/);
|
||||
assert.doesNotMatch(await card.innerText(), /主驾/);
|
||||
} else {
|
||||
const temperatures = draft.schedule.phases.flatMap((phase) => phase.operations)
|
||||
.filter((operation) => operation.capability === "climate.set_temperature")
|
||||
.map((operation) => operation.parameters.temperature_c);
|
||||
assert.ok(new Set(temperatures).size >= 2, "preheat must change temperature over time");
|
||||
for (const value of new Set(temperatures)) assert.ok((await card.innerText()).includes(`${value}℃`));
|
||||
}
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(evidenceDir, `schedule-preview-${index + 1}.png`) });
|
||||
|
||||
const trialPromise = responseFor("/api/mode-profile-drafts/trial");
|
||||
await card.locator(".assistant-mode-draft-trial").click();
|
||||
const trialResponse = await trialPromise;
|
||||
const trial = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profile-drafts/trial").at(-1).text);
|
||||
assert.equal(trialResponse.status(), 200, JSON.stringify(trial));
|
||||
assert.equal(trial.status, "succeeded", JSON.stringify(trial));
|
||||
assert.equal(trial.schedule_execution?.status, "completed");
|
||||
await page.waitForFunction((requestId) => {
|
||||
const status = document.querySelector(`[data-assistant-mode-draft="${requestId}"] .assistant-mode-draft-status`);
|
||||
return status?.classList.contains("success") || status?.classList.contains("failed");
|
||||
}, draft.source_request_id);
|
||||
assert.match(await card.locator(".assistant-mode-draft-status").innerText(), /试运行通过/);
|
||||
|
||||
const savePromise = responseFor("/api/mode-profiles");
|
||||
await card.locator(".assistant-mode-draft-save").click();
|
||||
const saveResponse = await savePromise;
|
||||
const saved = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profiles").at(-1).text);
|
||||
assert.ok([200, 201].includes(saveResponse.status()), JSON.stringify(saved));
|
||||
assert.ok(saved.mode_profile?.id);
|
||||
savedModes.push(saved.mode_profile);
|
||||
await page.locator(".assistant-mode-created").filter({ hasText: saved.mode_profile.name }).waitFor({ state: "visible" });
|
||||
const button = page.locator(`.mode-chips button[data-mode-profile-id="${saved.mode_profile.id}"]`);
|
||||
await button.waitFor({ state: "visible" });
|
||||
// Saving reveals the card with smooth scrolling. A tooltip correctly
|
||||
// closes while its rail is still moving, so wait for that motion to end.
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
const rail = document.querySelector('.mode-chips');
|
||||
let previous = rail.scrollLeft;
|
||||
let stable = 0;
|
||||
const sample = () => {
|
||||
const current = rail.scrollLeft;
|
||||
stable = Math.abs(current - previous) < 0.1 ? stable + 1 : 0;
|
||||
previous = current;
|
||||
if (stable >= 12) resolve();
|
||||
else requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}));
|
||||
await page.mouse.move(1, 1);
|
||||
const buttonBox = await button.boundingBox();
|
||||
const railBox = await page.locator('.mode-chips').boundingBox();
|
||||
const visibleLeft = Math.max(buttonBox.x, railBox.x);
|
||||
const visibleRight = Math.min(buttonBox.x + buttonBox.width, railBox.x + railBox.width);
|
||||
assert.ok(visibleRight > visibleLeft, 'saved card is visible in the mode rail');
|
||||
// A native mouse move does not request another smooth scroll, unlike
|
||||
// locator.hover() which scrolls the target to the center first.
|
||||
await page.mouse.move((visibleLeft + visibleRight) / 2, buttonBox.y + buttonBox.height / 2);
|
||||
const tooltip = page.locator("#modeChipActionPopover");
|
||||
await tooltip.waitFor({ state: "visible" });
|
||||
assert.match(await tooltip.innerText(), /重复 \d+ 次 · 总时长/);
|
||||
assert.equal(await tooltip.locator(".mode-schedule-popover-phase").count(), draft.operation_count);
|
||||
for (const phase of draft.schedule.phases) assert.ok((await tooltip.innerText()).includes(phase.name));
|
||||
await tooltip.screenshot({ path: path.join(evidenceDir, `schedule-tooltip-${index + 1}.png`), timeout: 4000 });
|
||||
await page.mouse.move(1, 1);
|
||||
}
|
||||
|
||||
await page.locator("#modeGalleryToggle").click();
|
||||
for (const mode of savedModes) {
|
||||
const shell = page.locator(`#modeGalleryGroups .mode-gallery-card-shell`).filter({ has: page.locator(`button[data-mode-profile-id="${mode.id}"]`) });
|
||||
const details = shell.locator(".mode-gallery-schedule-details");
|
||||
await details.locator("summary").click();
|
||||
assert.match(await details.innerText(), /开始 · 持续/);
|
||||
assert.match(await details.innerText(), /不自动回滚/);
|
||||
}
|
||||
await page.locator("#modeGalleryGroups").evaluate((element) => { element.scrollTop = 0; });
|
||||
await page.screenshot({ path: path.join(evidenceDir, "schedule-management-desktop.png") });
|
||||
await page.locator("#modeGalleryClose").click();
|
||||
await page.setViewportSize({ width: 720, height: 1000 });
|
||||
if (await page.locator('#v2SidebarToggle').getAttribute('aria-expanded') === 'true') {
|
||||
await page.locator('#v2SidebarToggle').click();
|
||||
}
|
||||
await page.locator(".assistant-mode-draft-scheduled").last().scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(evidenceDir, "schedule-preview-mobile.png") });
|
||||
assert.equal(await page.locator("body").evaluate((body) => body.scrollWidth > window.innerWidth + 1), false);
|
||||
|
||||
// The backend sequence above is real and strictly dry-run. Separately
|
||||
// verify the asynchronous UI contract with a synthetic running/failed
|
||||
// response; it must never launch an actual timed hardware execution.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
const mode = savedModes[1];
|
||||
mockedExecution = {
|
||||
request_id: "browser-running-qa", status: "running", execution_mode: "dry_run",
|
||||
orchestration_route: "mode_schedule_execute", vehicle_id: "denza_n7", mode_profile: mode,
|
||||
schedule_execution: { status: "running", run_id: "qa-run", session_id: "qa-session" },
|
||||
};
|
||||
const messagesBefore = await page.locator('#assistantMessages [data-message-role]').count();
|
||||
await page.locator(`.mode-chips button[data-mode-profile-id="${mode.id}"]`).click();
|
||||
await waitIdle();
|
||||
await page.waitForFunction((id) => document.querySelector(`.mode-chips button[data-mode-profile-id="${id}"]`)?.dataset.modeState === "started", mode.id);
|
||||
const actionStatus = page.locator('.assistant-action-status').last();
|
||||
assert.match(await actionStatus.innerText(), /已启动动态时间轴/);
|
||||
await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('阶段 2'));
|
||||
await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('QA阶段模拟失败'));
|
||||
assert.equal(await actionStatus.getAttribute('class'), 'assistant-action-status failed');
|
||||
assert.equal(statusReads, 2);
|
||||
assert.equal(await page.locator('#assistantMessages [data-message-role]').count(), messagesBefore + 2);
|
||||
await page.screenshot({ path: path.join(evidenceDir, "schedule-background-failure.png") });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(posted.filter((request) => request.pathname === "/api/assistant/stream").every((request) => request.payload.execution_mode === "dry_run"));
|
||||
assert.equal(posted.filter((request) => request.pathname === "/api/mode-profile-drafts/trial").length, 2);
|
||||
assert.equal(posted.filter((request) => request.pathname === "/api/mode-profiles").length, 2);
|
||||
console.log(`PASS ${baseURL}: default passenger and explicit driver static/dynamic previews -> both exact user phrases -> phased preview -> virtual trial -> save -> top tooltip -> management; desktop 1600x1000 and 720x1000; no JS errors; no hardware requests forwarded`);
|
||||
console.log(`Evidence: ${evidenceDir}`);
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(evidenceDir, "schedule-browser-failure.png") });
|
||||
console.error(`Page text: ${(await page.locator("body").innerText()).slice(-5000)}`);
|
||||
throw error;
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user