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:
Miayui
2026-09-17 12:46:51 +08:00
135 changed files with 30180 additions and 513 deletions
+226
View File
@@ -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; });