const assert = require("node:assert/strict"); const path = require("node:path"); const { chromium } = require("playwright"); const [baseURL, evidenceDir] = process.argv.slice(2); const desktop = { width: 1600, height: 1000 }; async function main() { const browser = await chromium.launch({ headless: true, ...(process.env.N7WEB_BROWSER_CHANNEL ? { channel: process.env.N7WEB_BROWSER_CHANNEL } : process.platform === "win32" ? { channel: "msedge" } : {}), }); const context = await browser.newContext({ viewport: desktop, serviceWorkers: "block" }); const page = await context.newPage(); const requests = []; const errors = []; const unexpectedWrites = []; let heldResponse = null; let nextReply = "success"; const answer = (success) => [ { type: "action.completed", success, payload: { status: success ? "succeeded" : "failed", execution_mode: "dry_run", } }, { type: "answer.completed", answer: success ? "模式执行测试完成" : "模式执行测试失败" }, ].map((item) => JSON.stringify(item)).join("\n") + "\n"; const fulfill = (route, success = true) => route.fulfill({ status: 200, contentType: "application/x-ndjson", body: answer(success), }); page.on("pageerror", (error) => errors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ''}`); }); async function handleApiRoute(route) { const pathname = new URL(route.request().url()).pathname; if (pathname === '/api/hardware/seat-state') { // This legacy GET may attempt automatic firmware recovery. Never touch // the physical gateway while verifying browser interactions. await route.fulfill({json:{status:'unavailable',hardware_execution_allowed:false}}); return; } // The isolated server deliberately does not launch Codex or publish a // physical display mirror. Keep these background integration probes local. if (pathname === '/api/health' || pathname === '/api/extension/display-sync') { await route.fulfill({json:{status:'ok',assistant:{status:'ready',available:true}}}); return; } if (pathname === "/api/assistant/stream") { requests.push(route.request().postDataJSON()); if (nextReply === "hold") { heldResponse = route; return; } const success = nextReply !== "fail"; nextReply = "success"; await fulfill(route, success); return; } if (route.request().method() !== "GET") { unexpectedWrites.push(route.request().url()); await route.fulfill({ status: 403, body: "test forbids vehicle writes" }); return; } await route.continue(); } await context.route("**/api/**", handleApiRoute); const rail = page.locator(".mode-chips"); const office = rail.locator('button[data-mode-profile-id="office"]'); const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending()); const waitState = (id, state) => page.waitForFunction( ({ id, state }) => document.querySelector(`.mode-chips [data-mode-profile-id="${id}"]`)?.dataset.modeState === state, { id, state }, ); async function expectOneCommand(action, command) { const before = requests.length; await action(); await page.waitForFunction(() => document.querySelector('#utterance').getAttribute('aria-busy') === 'false'); await waitIdle(); assert.equal(requests.length, before + 1, "gesture must submit exactly once"); assert.equal(requests.at(-1).message, command); } try { await page.goto(baseURL, { waitUntil: "domcontentloaded" }); await page.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3); await page.waitForFunction(() => document.body.classList.contains('assistant-chat-enabled')); assert.equal(await page.title(), "511工程-智能座舱"); assert.equal(new URL(page.url()).pathname, "/"); assert.ok((await page.locator('body').innerText()).includes("整车孪生界面")); assert.ok(await rail.evaluate((element) => element.scrollWidth > element.clientWidth)); // This is a real mouse sequence: pointerdown -> pointerup -> click. The // prior pointer capture bug cannot be detected by button.dispatchEvent(). await page.evaluate(() => window.CognitiveAgentAssistant.close()); await expectOneCommand(() => office.click(), "进入办公模式"); await waitState("office", "succeeded"); assert.equal(await page.locator('#assistantDrawerToggle').getAttribute('aria-expanded'), 'true'); assert.ok((await page.locator('#assistantMessages').innerText()).includes('进入办公模式')); // A small physical hand movement is still a click. const box = await office.boundingBox(); await expectOneCommand(async () => { await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2); await page.mouse.up(); }, "进入办公模式"); // Dragging from a card must scroll, without accidentally running it. const beforeDrag = requests.length; const startScroll = await rail.evaluate((element) => element.scrollLeft); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(box.x + box.width / 2 - 90, box.y + box.height / 2, { steps: 8 }); await page.mouse.up(); assert.ok(await rail.evaluate((element) => element.scrollLeft) > startScroll); assert.equal(requests.length, beforeDrag); await rail.evaluate((element) => { element.scrollLeft = 0; }); await expectOneCommand(() => office.click(), "进入办公模式"); // Keyboard activation and management cards share the same submission path. await office.focus(); await expectOneCommand(() => page.keyboard.press('Enter'), "进入办公模式"); await office.focus(); await expectOneCommand(() => page.keyboard.press('Space'), "进入办公模式"); await page.locator('#modeGalleryToggle').click(); await expectOneCommand(() => page.locator('#modeGalleryGroups button[data-mode-profile-id="nap"]').click(), "进入小憩模式"); await waitState("nap", "succeeded"); // Busy requests are not duplicated, nor does another card cancel them. nextReply = 'hold'; const beforeBusy = requests.length; await office.click(); await waitState('office', 'running'); await office.click(); assert.equal(requests.length, beforeBusy + 1); assert.equal(await page.evaluate(() => window.CognitiveAgentAssistant.isPending()), true); await fulfill(heldResponse); nextReply = 'success'; await waitIdle(); await waitState('office', 'succeeded'); nextReply = 'fail'; await expectOneCommand(() => office.click(), "进入办公模式"); await waitState('office', 'failed'); // A catalog with few visible cards must also work without overflow. await rail.evaluate(element => { for (const child of element.children) { if (child.dataset.modeProfileId !== 'office') child.style.display = 'none'; } }); await page.waitForFunction(() => {const e=document.querySelector('.mode-chips');return e.scrollWidth <= e.clientWidth;}); await expectOneCommand(() => office.click(), "进入办公模式"); await rail.evaluate(element => { for (const child of element.children) child.style.removeProperty('display'); }); await page.setViewportSize(desktop); await page.screenshot({path:path.join(evidenceDir,'mode-rail-desktop.png')}); assert.deepEqual(errors, []); assert.deepEqual(unexpectedWrites, []); // Touch input uses native scrolling and also submits once when tapped. const touchContext = await browser.newContext({viewport:{width:720,height:1000},hasTouch:true,serviceWorkers:'block'}); await touchContext.route('**/api/**', handleApiRoute); const touchPage = await touchContext.newPage(); await touchPage.goto(baseURL, {waitUntil:'domcontentloaded'}); await touchPage.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3); const beforeTouch = requests.length; await touchPage.locator('.mode-chips button[data-mode-profile-id="office"]').tap(); await touchPage.waitForFunction(() => document.querySelector('.mode-chips [data-mode-profile-id="office"]').dataset.modeState === 'succeeded'); assert.equal(requests.length,beforeTouch+1); assert.equal(requests.at(-1).message,'进入办公模式'); await touchPage.screenshot({path:path.join(evidenceDir,'mode-rail-touch.png')}); await touchContext.close(); console.log('PASS: click, jitter, drag, Enter, Space, gallery, busy, failure, resize, touch; no runtime errors'); } finally { await browser.close(); } } main().catch(error => { console.error(error); process.exitCode = 1; });