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