chore: establish local platform baseline

This commit is contained in:
Codex Local
2026-08-26 06:41:54 +08:00
commit 29bdf4900f
575 changed files with 95644 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
import { createRequire } from "node:module";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const browserName = process.env.BROWSER_NAME ?? "chrome";
const executablePath = process.env.BROWSER_PATH;
if (!executablePath) throw new Error("BROWSER_PATH is required");
const outputDirectory = new URL(`./screenshots/compatibility/${browserName}/`, import.meta.url);
const outputPath = decodeURIComponent(outputDirectory.pathname.replace(/^\/(.:)/, "$1"));
await mkdir(outputPath, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`));
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
if (request.url().includes("/api/v1/events") && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
const scanOverflow = async (label) => ({
label,
documentOverflow: await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2),
elements: await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`)),
});
try {
await page.goto("http://localhost:3100", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(/localhost:58180/);
await page.locator("#username").fill("owner");
await page.locator("#password").fill("LocalOnly123!");
await page.locator("#kc-login").click();
await page.waitForURL(/^http:\/\/localhost:3100(?:\/|$)/, { timeout: 15000 });
for (const heading of ["配置发布与版本控制", "用量、费用与额度", "告警监测与处置闭环", "家庭业务工作台"]) {
await page.getByRole("heading", { name: heading }).waitFor({ timeout: 15000 });
}
await page.getByText("状态实时更新", { exact: true }).waitFor({ timeout: 15000 });
const checks = [];
checks.push(await scanOverflow("desktop-overview"));
const tabs = ["家庭成员", "长者与授权", "设备管理", "固件发布", "通话记录", "故事与记忆", "资源分享", "隐私请求"];
for (const tab of tabs) {
await page.getByRole("button", { name: tab, exact: true }).click();
await page.waitForTimeout(250);
checks.push(await scanOverflow(`desktop-${tab}`));
}
await page.screenshot({ path: join(outputPath, "desktop.png"), fullPage: true });
await page.setViewportSize({ width: 1024, height: 900 });
for (const tab of tabs) {
await page.getByRole("button", { name: tab, exact: true }).click();
await page.waitForTimeout(250);
checks.push(await scanOverflow(`tablet-${tab}`));
}
await page.screenshot({ path: join(outputPath, "tablet.png"), fullPage: true });
const firstContentfulPaint = await page.evaluate(() => performance.getEntriesByName("first-contentful-paint")[0]?.startTime ?? null);
const version = await browser.version();
const failures = checks.filter((item) => item.documentOverflow || item.elements.length);
const result = {
browser: browserName,
version,
firstContentfulPaintMs: firstContentfulPaint,
viewports: ["1440x1000", "1024x900"],
tabsChecked: tabs,
overflowFailures: failures,
consoleErrors,
failedRequests,
realtimeConnected: await page.getByText("状态实时更新", { exact: true }).isVisible(),
};
console.log(JSON.stringify(result, null, 2));
if (failures.length || consoleErrors.length || failedRequests.length || !result.realtimeConnected || firstContentfulPaint === null || firstContentfulPaint >= 3000) {
throw new Error("Browser compatibility gate failed");
}
} finally {
await browser.close();
}
@@ -0,0 +1,83 @@
import { createRequire } from "node:module";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
await mkdir(outputDirectory, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const expectedSessionAbortRequests = [];
const injectedUnauthorizedResponses = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
if (injectedUnauthorizedResponses.length && requestURL.pathname.startsWith("/api/v1/") && failure.includes("ERR_ABORTED")) {
expectedSessionAbortRequests.push(`${request.method()} ${request.url()} ${failure}`);
return;
}
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
let injectUnauthorized = true;
await page.route("**/api/v1/households", async (route) => {
if (!injectUnauthorized || route.request().method() !== "GET") {
await route.continue();
return;
}
injectUnauthorized = false;
injectedUnauthorizedResponses.push(route.request().url());
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ request_id: "visual-auth-expired", error: { code: "AUTHENTICATION_REQUIRED", message: "登录状态已经失效,请重新登录" } })
});
});
await page.getByRole("button", { name: "刷新数据", exact: true }).click();
await page.getByRole("button", { name: "重新登录", exact: true }).waitFor();
const authFailureVisible = await page.getByText("登录状态已经失效,请重新登录", { exact: true }).isVisible();
const workspaceUnmounted = await page.getByRole("heading", { name: "家庭业务工作台" }).count() === 0;
const oidcSessionCountAfter401 = await page.evaluate(() => Object.keys(sessionStorage).filter((key) => key.startsWith("oidc.user:")).length);
await page.screenshot({ path: fileURLToPath(new URL("authentication-required.png", outputDirectory)), fullPage: true });
await page.getByRole("button", { name: "重新登录", exact: true }).click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor({ timeout: 15000 });
const accountRestored = await page.getByRole("button", { name: "退出登录", exact: true }).isVisible();
const oidcSessionCountAfterRelogin = await page.evaluate(() => Object.keys(sessionStorage).filter((key) => key.startsWith("oidc.user:")).length);
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const unexpectedConsoleErrors = consoleErrors.filter((message) => !message.includes("401 (Unauthorized)"));
const result = { authFailureVisible, workspaceUnmounted, oidcSessionCountAfter401, accountRestored, oidcSessionCountAfterRelogin, injectedUnauthorizedResponses, expectedSessionAbortRequests, overflow, consoleErrors, unexpectedConsoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (!authFailureVisible || !workspaceUnmounted || oidcSessionCountAfter401 !== 0 || !accountRestored || oidcSessionCountAfterRelogin !== 1 || injectedUnauthorizedResponses.length !== 1 || overflow.length || unexpectedConsoleErrors.length || failedRequests.length) {
throw new Error("Authentication recovery visual smoke failed");
}
} finally {
await browser.close();
}
+125
View File
@@ -0,0 +1,125 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const contextID = randomUUID();
const contextPublicID = randomUUID();
const conversationID = randomUUID();
const conversationPublicID = randomUUID();
const turnID = randomUUID();
const turnPublicID = randomUUID();
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
function cleanup() {
psql(`BEGIN;
DELETE FROM conversation_turn WHERE id='${turnID}'::uuid;
DELETE FROM conversation_state_history WHERE conversation_id='${conversationID}'::uuid;
DELETE FROM conversation WHERE id='${conversationID}'::uuid;
DELETE FROM context_snapshot WHERE id='${contextID}'::uuid;
COMMIT;`);
}
psql(`BEGIN;
INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary,preparation_status)
VALUES('${contextID}'::uuid,'${contextPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,'10000000-0000-0000-0000-000000000701'::uuid,'[]'::jsonb,'visual-conversation-v1','s2s_hybrid','0000000000000000000000000000000000000000000000000000000000000000','浏览器通话详情恢复夹具','READY');
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason)
VALUES('${conversationID}'::uuid,'${conversationPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000201'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,'10000000-0000-0000-0000-000000000701'::uuid,'${contextID}'::uuid,'visual-conversation-${conversationPublicID}','s2s_hybrid','transcript_only','COMPLETED',now()+interval '5 minutes',now()-interval '2 minutes',now()-interval '1 minute','user_hangup');
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,occurred_at)
VALUES('${conversationID}'::uuid,NULL,'ROOM_ISSUED','visual fixture','platform',now()-interval '2 minutes'),('${conversationID}'::uuid,'ROOM_ISSUED','ACTIVE','visual fixture','provider',now()-interval '110 seconds'),('${conversationID}'::uuid,'ACTIVE','COMPLETED','visual fixture','device',now()-interval '1 minute');
INSERT INTO conversation_turn(id,public_id,tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,interrupted,source_type)
VALUES('${turnID}'::uuid,'${turnPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'${conversationID}'::uuid,'visual-turn-${turnPublicID}',1,'subject',now()-interval '100 seconds',now()-interval '98 seconds','今天想聊聊以前的工作。',false,'device');
COMMIT;`);
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const expectedDetailFailures = [];
const expectedFailureURLs = new Set();
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
if (expectedFailureURLs.has(request.url())) {
expectedDetailFailures.push(`${request.method()} ${request.url()} ${failure}`);
return;
}
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "通话记录", exact: true }).click();
const firstRow = page.locator(".conversation-row").first();
await firstRow.waitFor();
const rowText = await firstRow.innerText();
const rowUsesBusinessLabels = /端到端语音|端到端混合|识别·理解·合成级联|背景资料准备失败/.test(rowText)
&& !/s2s_pure|s2s_hybrid|cascade|transcript_only|transcript_and_audio/.test(rowText);
let failNextDetail = true;
await page.route("**/api/v1/conversations/**", async (route) => {
const request = route.request();
const requestURL = new URL(request.url());
if (failNextDetail && request.method() === "GET" && /^\/api\/v1\/conversations\/[^/]+$/.test(requestURL.pathname)) {
failNextDetail = false;
expectedFailureURLs.add(request.url());
await route.abort("failed");
return;
}
await route.continue();
});
await firstRow.click();
const detailFailure = page.getByText("通话详情读取失败", { exact: true });
await detailFailure.waitFor();
const retryVisible = await page.getByRole("button", { name: "重新加载" }).isVisible();
const staleDetailHidden = await page.locator(".conversation-detail .resource-detail__head").count() === 0;
await page.getByRole("button", { name: "重新加载" }).click();
await page.locator(".conversation-detail .resource-detail__head").waitFor();
const detailText = await page.locator(".conversation-detail").innerText();
const detailUsesBusinessLabels = /端到端语音|端到端混合|识别·理解·合成级联/.test(detailText)
&& /不保存字幕和录音|仅保存字幕|保存字幕和双轨录音/.test(detailText)
&& !/s2s_pure|s2s_hybrid|cascade|transcript_only|transcript_and_audio/.test(detailText);
const selectedRowVisible = await firstRow.evaluate((element) => element.classList.contains("active"));
await page.screenshot({ path: fileURLToPath(new URL("conversation-detail-recovery.png", outputDirectory)), fullPage: true });
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const unexpectedConsoleErrors = consoleErrors.filter((message) => !message.includes("net::ERR_FAILED"));
const result = { rowUsesBusinessLabels, retryVisible, staleDetailHidden, detailUsesBusinessLabels, selectedRowVisible, expectedDetailFailures, overflow, consoleErrors, unexpectedConsoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (!rowUsesBusinessLabels || !retryVisible || !staleDetailHidden || !detailUsesBusinessLabels || !selectedRowVisible || expectedDetailFailures.length !== 1 || overflow.length || unexpectedConsoleErrors.length || failedRequests.length) {
throw new Error("Conversation detail visual smoke failed");
}
} finally {
await browser.close();
cleanup();
}
+70
View File
@@ -0,0 +1,70 @@
import { createRequire } from "node:module";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
await mkdir(outputDirectory, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const twinMutations = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
if (request.method() === "PATCH" && /\/api\/v1\/devices\/[^/]+\/twin$/.test(request.url())) twinMutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "设备管理", exact: true }).click();
const card = page.locator(".device-card").first();
await card.waitFor();
const slider = card.getByRole("slider", { name: /扬声器音量/ });
const saveButton = card.getByRole("button", { name: "保存音量配置" });
const initialVolume = Number(await slider.inputValue());
const initialSaveDisabled = await saveButton.isDisabled();
const desiredStateText = await card.getByText(/云端期望:/).textContent();
const desiredVolumeConfigured = !desiredStateText?.includes("未设置");
const initialActionCorrect = desiredVolumeConfigured ? initialSaveDisabled : !initialSaveDisabled;
const changedVolume = initialVolume === 100 ? 99 : initialVolume + 1;
await slider.fill(String(changedVolume));
const changedSaveEnabled = await saveButton.isEnabled();
const valueLabelVisible = await card.getByText(`${changedVolume}%`, { exact: true }).isVisible();
const desiredStateVisible = await card.getByText(/云端期望:/).isVisible();
const reportedStateVisible = await card.getByText(/设备上报:/).isVisible();
await page.screenshot({ path: fileURLToPath(new URL("device-volume-configuration.png", outputDirectory)), fullPage: true });
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { initialVolume, changedVolume, desiredStateText, desiredVolumeConfigured, initialSaveDisabled, initialActionCorrect, changedSaveEnabled, valueLabelVisible, desiredStateVisible, reportedStateVisible, twinMutations, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (!Number.isInteger(initialVolume) || initialVolume < 0 || initialVolume > 100 || !initialActionCorrect || !changedSaveEnabled || !valueLabelVisible || !desiredStateVisible || !reportedStateVisible || twinMutations.length || overflow.length || consoleErrors.length || failedRequests.length) {
throw new Error("Device volume visual smoke failed");
}
} finally {
await browser.close();
}
@@ -0,0 +1,203 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const contextID = randomUUID();
const contextPublicID = randomUUID();
const conversationID = randomUUID();
const conversationPublicID = randomUUID();
const segmentID = randomUUID();
const segmentPublicID = randomUUID();
const playGrantID = randomUUID();
const playGrantPublicID = randomUUID();
const downloadGrantID = randomUUID();
const downloadGrantPublicID = randomUUID();
const consentID = randomUUID();
const consentPublicID = randomUUID();
const storyID = randomUUID();
const storyPublicID = randomUUID();
const revisionID = randomUUID();
const revisionPublicID = randomUUID();
const marker = storyPublicID.slice(0, 8);
const subjectPublicID = "10000000-0000-0000-0000-000000000411";
const editorPublicID = "10000000-0000-0000-0000-000000000112";
const storyTitle = `受控分享故事-${marker}`;
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
function cleanup() {
psql(`BEGIN;
UPDATE story_draft SET current_revision_id=NULL WHERE id='${storyID}'::uuid;
DELETE FROM story_revision WHERE id='${revisionID}'::uuid;
DELETE FROM story_draft WHERE id='${storyID}'::uuid;
DELETE FROM media_access_log WHERE media_segment_id='${segmentID}'::uuid;
DELETE FROM media_segment WHERE id='${segmentID}'::uuid;
DELETE FROM recording_access_grant WHERE id IN ('${playGrantID}'::uuid,'${downloadGrantID}'::uuid);
DELETE FROM consent_record WHERE id='${consentID}'::uuid;
DELETE FROM conversation_state_history WHERE conversation_id='${conversationID}'::uuid;
DELETE FROM conversation WHERE id='${conversationID}'::uuid;
DELETE FROM context_snapshot WHERE id='${contextID}'::uuid;
COMMIT;`);
}
psql(`BEGIN;
INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary,preparation_status)
VALUES('${contextID}'::uuid,'${contextPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,'10000000-0000-0000-0000-000000000701'::uuid,'[]'::jsonb,'visual-grant-create-v1','s2s_hybrid',repeat('0',64),'浏览器敏感授权创建夹具','READY');
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason,created_at)
VALUES('${conversationID}'::uuid,'${conversationPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000201'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,'10000000-0000-0000-0000-000000000701'::uuid,'${contextID}'::uuid,'visual-grant-create-${marker}','s2s_hybrid','transcript_and_audio','COMPLETED',now()+interval '5 minutes',now()-interval '3 minutes',now()-interval '2 minutes','user_hangup',now()+interval '1 second');
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,occurred_at)
VALUES('${conversationID}'::uuid,NULL,'ROOM_ISSUED','visual fixture','platform',now()-interval '3 minutes'),('${conversationID}'::uuid,'ROOM_ISSUED','ACTIVE','visual fixture','provider',now()-interval '170 seconds'),('${conversationID}'::uuid,'ACTIVE','COMPLETED','visual fixture','device',now()-interval '2 minutes');
INSERT INTO consent_record(id,public_id,tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref,granted_at)
VALUES('${consentID}'::uuid,'${consentPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'raw_audio_storage','{}'::jsonb,'visual-grant-create-v1',repeat('a',64),'10000000-0000-0000-0000-000000000101'::uuid,'visual://grant-create',now()-interval '10 minutes');
INSERT INTO recording_access_grant(id,public_id,tenant_id,subject_id,grantee_user_id,permission,purpose,granted_by,granted_at,expires_at)
VALUES
('${playGrantID}'::uuid,'${playGrantPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,'PLAY','页面授权创建辅助-${marker}','10000000-0000-0000-0000-000000000101'::uuid,now()-interval '10 minutes',now()+interval '2 hours'),
('${downloadGrantID}'::uuid,'${downloadGrantPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,'DOWNLOAD','页面授权创建辅助-${marker}','10000000-0000-0000-0000-000000000101'::uuid,now()-interval '10 minutes',now()+interval '2 hours');
INSERT INTO media_segment(id,public_id,tenant_id,conversation_id,device_id,track,sequence_no,codec,content_type,duration_ms,expected_size,expected_digest,object_ref,status,verified_at,local_delete_after)
VALUES('${segmentID}'::uuid,'${segmentPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${conversationID}'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,'far',876543,'pcm_s16le','audio/wav',11400,87654,repeat('b',64),'visual/grant-create/${segmentPublicID}.wav','VERIFIED',now()-interval '1 minute',now()+interval '1 hour');
INSERT INTO story_draft(id,public_id,tenant_id,subject_id,conversation_id,title,status,confidence,sensitivity,generation_version,created_at,updated_at)
VALUES('${storyID}'::uuid,'${storyPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'${conversationID}'::uuid,'${storyTitle}','CONFIRMED',0.9500,'sensitive',1,now(),now());
INSERT INTO story_revision(id,public_id,story_id,revision_no,content,author_type,edit_reason)
VALUES('${revisionID}'::uuid,'${revisionPublicID}'::uuid,'${storyID}'::uuid,1,'用于验证具体故事资源分享确认区的合成正文。','user','页面受控操作夹具');
UPDATE story_draft SET current_revision_id='${revisionID}'::uuid WHERE id='${storyID}'::uuid;
COMMIT;`);
const recordingGrantCountBefore = Number(psql("SELECT count(*) FROM recording_access_grant;"));
const sharingGrantCountBefore = Number(psql("SELECT count(*) FROM sharing_grant;"));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1050 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const createRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const requestURL = new URL(request.url());
if (request.method() === "POST" && (/^\/api\/v1\/subjects\/[^/]+\/recording-access-grants$/.test(requestURL.pathname) || requestURL.pathname === "/api/v1/sharing-grants")) createRequests.push(`${request.method()} ${requestURL.pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "通话记录", exact: true }).click();
const conversationRow = page.locator(".conversation-row").first();
await conversationRow.waitFor();
await conversationRow.click();
await page.locator(".recording-segment").filter({ hasText: "#876543" }).waitFor();
const recordingGrantForm = page.locator("form.recording-grant-form");
await recordingGrantForm.getByLabel("家庭成员").selectOption(editorPublicID);
await recordingGrantForm.getByLabel("权限").selectOption("DOWNLOAD");
await recordingGrantForm.getByLabel("有效期").selectOption("8");
await recordingGrantForm.getByLabel("使用目的").fill(`照护复盘-${marker}`);
await recordingGrantForm.getByRole("button", { name: "确认授权" }).click();
let control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
let text = await control.textContent();
const recordingGrantTargetBound = text?.includes(subjectPublicID) === true && text?.includes(editorPublicID) === true && text?.includes("本地编辑者") === true && text?.includes("下载") === true && text?.includes(`照护复盘-${marker}`) === true && text?.includes("当前及后续可访问的已验证录音") === true;
let action = control.getByRole("button", { name: "确认授予录音能力" });
const recordingGrantNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/该能力覆盖当前长者在有效期内可访问的全部已验证原录音/).check();
const recordingGrantEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-recording-grant-create-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const recordingGrantCancelClosed = await control.count() === 0;
await page.getByRole("button", { name: "资源分享", exact: true }).click();
const sharingForm = page.locator("form.tool-form").filter({ hasText: "创建具体资源分享" });
const sharingGrantee = sharingForm.locator("label").filter({ hasText: "接收人" }).locator("select");
await sharingForm.waitFor();
await sharingForm.getByLabel("资源类型").selectOption("STORY");
await sharingForm.getByLabel("具体资源").selectOption(storyPublicID);
await sharingGrantee.selectOption(editorPublicID);
await sharingForm.getByLabel("权限").selectOption("VIEW_TEXT");
await sharingForm.getByLabel("有效小时").fill("24");
await sharingForm.getByLabel("分享用途").fill(`家人阅读-${marker}`);
await sharingForm.getByLabel(/仅允许指定接收人登录后访问/).uncheck();
await sharingForm.getByRole("button", { name: "创建分享" }).click();
control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
text = await control.textContent();
const storyShareTargetBound = text?.includes(subjectPublicID) === true && text?.includes(storyPublicID) === true && text?.includes(storyTitle) === true && text?.includes(editorPublicID) === true && text?.includes("查看文字") === true && text?.includes("只显示一次的匿名访问口令") === true;
action = control.getByRole("button", { name: "确认创建资源分享" });
const storyShareNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/匿名口令在有效期内等同访问凭据/).check();
const storyShareEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-anonymous-story-share-create-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const storyShareCancelClosed = await control.count() === 0;
await sharingForm.getByLabel("资源类型").selectOption("TRANSCRIPT");
await sharingForm.getByLabel("具体资源").selectOption(conversationPublicID);
await sharingGrantee.selectOption(editorPublicID);
await sharingForm.getByLabel("分享用途").fill(`文字核对-${marker}`);
await sharingForm.getByLabel(/仅允许指定接收人登录后访问/).check();
await sharingForm.getByRole("button", { name: "创建分享" }).click();
control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
text = await control.textContent();
const transcriptShareTargetBound = text?.includes(conversationPublicID) === true && text?.includes("通话文字") === true && text?.includes("仅指定接收人登录后访问") === true && text?.includes(`文字核对-${marker}`) === true;
action = control.getByRole("button", { name: "确认创建资源分享" });
const transcriptShareNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/只允许指定接收人登录后访问固定资源/).check();
const transcriptShareEnabled = await action.isEnabled();
await control.getByRole("button", { name: "取消" }).click();
const transcriptShareCancelClosed = await control.count() === 0;
await sharingForm.getByLabel("资源类型").selectOption("RECORDING");
await sharingForm.getByLabel("具体资源").selectOption(segmentPublicID);
await sharingGrantee.selectOption(editorPublicID);
await sharingForm.getByLabel("权限").selectOption("DOWNLOAD");
await sharingForm.getByLabel("分享用途").fill(`录音交付-${marker}`);
await sharingForm.getByLabel(/仅允许指定接收人登录后访问/).check();
await sharingForm.getByRole("button", { name: "创建分享" }).click();
control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
text = await control.textContent();
const recordingShareTargetBound = text?.includes(segmentPublicID) === true && text?.includes("录音分片:智能体轨道 #876543") === true && text?.includes("下载") === true && text?.includes(`录音交付-${marker}`) === true;
action = control.getByRole("button", { name: "确认创建资源分享" });
const recordingShareNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/只允许指定接收人登录后访问固定资源/).check();
const recordingShareEnabled = await action.isEnabled();
await control.getByRole("button", { name: "取消" }).click();
const recordingShareCancelClosed = await control.count() === 0;
const recordingGrantsUnchanged = Number(psql("SELECT count(*) FROM recording_access_grant;")) === recordingGrantCountBefore;
const sharingGrantsUnchanged = Number(psql("SELECT count(*) FROM sharing_grant;")) === sharingGrantCountBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { recordingGrantTargetBound, recordingGrantNeedsAcknowledgement, recordingGrantEnabled, recordingGrantCancelClosed, storyShareTargetBound, storyShareNeedsAcknowledgement, storyShareEnabled, storyShareCancelClosed, transcriptShareTargetBound, transcriptShareNeedsAcknowledgement, transcriptShareEnabled, transcriptShareCancelClosed, recordingShareTargetBound, recordingShareNeedsAcknowledgement, recordingShareEnabled, recordingShareCancelClosed, recordingGrantsUnchanged, sharingGrantsUnchanged, createRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["createRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || createRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Family access grant create controls visual smoke failed");
} finally {
await browser.close();
cleanup();
}
@@ -0,0 +1,133 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const marker = randomUUID().slice(0, 8);
const keyID = `visual-rollout-key-${marker}`;
const keyInternalID = randomUUID();
const releaseInternalID = randomUUID();
const releasePublicID = randomUUID();
const releaseVersion = `9.9.${Number.parseInt(marker.slice(0, 2), 16)}`;
const releaseDigest = "ab".repeat(32);
const rolloutName = `受控灰度-${marker}`;
const householdPublicID = "10000000-0000-0000-0000-000000000211";
const subjectPublicID = "10000000-0000-0000-0000-000000000411";
const devicePublicID = "10000000-0000-0000-0000-000000000511";
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
psql(`
INSERT INTO firmware_signing_key(id,tenant_id,key_id,public_key_base64,status,created_by)
VALUES('${keyInternalID}','10000000-0000-0000-0000-000000000001','${keyID}','AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=','ACTIVE','10000000-0000-0000-0000-000000000101');
INSERT INTO firmware_release(id,public_id,tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,status,created_by,verified_at)
VALUES('${releaseInternalID}','${releasePublicID}','10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','simulated-terminal','sim-rev-a','${releaseVersion}','visual/firmware/${releasePublicID}.bin',4096,'${releaseDigest}','visual-signature','${keyID}','1.0.0','页面受控灰度夹具','READY','10000000-0000-0000-0000-000000000101',now());
`);
const consentCountBefore = Number(psql("SELECT count(*) FROM consent_record;"));
const rolloutCountBefore = Number(psql("SELECT count(*) FROM firmware_rollout;"));
const commandCountBefore = Number(psql("SELECT count(*) FROM device_command;"));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutationRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (request.method() === "POST" && (/^\/api\/v1\/subjects\/[^/]+\/consents$/.test(pathname) || /^\/api\/v1\/households\/[^/]+\/firmware-rollouts$/.test(pathname))) mutationRequests.push(`${request.method()} ${pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
const consentForm = page.locator("form.tool-form").filter({ hasText: "新增分项授权" });
const consentType = consentForm.getByLabel("授权项目");
const noticeCheckbox = consentForm.locator("label.consent-confirm input");
const consentButton = consentForm.getByRole("button", { name: "核对并授权" });
await consentForm.getByText("当前长者已有同类有效授权,请先查看或按正式流程撤回。").waitFor();
const duplicateConsentShown = (await consentForm.textContent())?.includes("已有同类有效授权") === true;
await noticeCheckbox.check();
const duplicateConsentBlocked = await consentButton.isDisabled();
await consentType.selectOption("raw_audio_storage");
const typeChangeClearedNotice = await noticeCheckbox.isChecked() === false;
await noticeCheckbox.check();
const newConsentEnabled = await consentButton.isEnabled();
await consentButton.click();
let control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
let text = await control.textContent();
const consentTargetBound = text?.includes("测试长者") === true && text?.includes(subjectPublicID) === true && text?.includes("原录音保存") === true && text?.includes("后续新通话") === true && text?.includes("local-v1") === true && text?.includes("family-web-confirmation") === true && text?.includes("持续有效,直至单独撤回") === true && text?.includes("双轨原始录音") === true && text?.includes("播放和下载仍需显式录音访问能力") === true;
let action = control.getByRole("button", { name: "确认新增分项授权" });
const consentNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/说明“原录音保存”的数据范围/).check();
const consentEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-consent-grant-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const consentCancelClosed = await control.count() === 0;
const consentCancelClearedNotice = await noticeCheckbox.isChecked() === false;
await page.getByRole("button", { name: "固件发布", exact: true }).click();
const rolloutForm = page.locator("form.firmware-rollout-form");
await rolloutForm.getByLabel("就绪版本").selectOption(releasePublicID);
await rolloutForm.getByLabel("批次名称").fill(rolloutName);
await rolloutForm.getByLabel(/灰度比例/).fill("25");
const deviceChoice = rolloutForm.getByLabel(new RegExp(`simulated-terminal.*${devicePublicID.slice(0, 8)}`));
const compatibleDeviceEnabled = await deviceChoice.isEnabled();
await deviceChoice.check();
const rolloutButton = rolloutForm.getByRole("button", { name: "核对并启动灰度发布" });
const rolloutReady = await rolloutButton.isEnabled();
await rolloutButton.click();
control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
text = await control.textContent();
const rolloutTargetBound = text?.includes(householdPublicID) === true && text?.includes(rolloutName) === true && text?.includes(releasePublicID) === true && text?.includes(releaseVersion) === true && text?.includes(releaseDigest.slice(0, 12)) === true && text?.includes("25%") === true && text?.includes(devicePublicID) === true && text?.includes("0.1.0-sim") === true && text?.includes("确定实际命中设备") === true && text?.includes("24小时可靠升级命令") === true;
action = control.getByRole("button", { name: "确认启动灰度批次" });
const rolloutNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/所选1台候选设备与已验证固件的型号、主板和注册状态匹配/).check();
const rolloutEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-firmware-rollout-start-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const rolloutCancelClosed = await control.count() === 0;
const consentsUnchanged = Number(psql("SELECT count(*) FROM consent_record;")) === consentCountBefore;
const rolloutsUnchanged = Number(psql("SELECT count(*) FROM firmware_rollout;")) === rolloutCountBefore;
const commandsUnchanged = Number(psql("SELECT count(*) FROM device_command;")) === commandCountBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { duplicateConsentShown, duplicateConsentBlocked, typeChangeClearedNotice, newConsentEnabled, consentTargetBound, consentNeedsAcknowledgement, consentEnabled, consentCancelClosed, consentCancelClearedNotice, compatibleDeviceEnabled, rolloutReady, rolloutTargetBound, rolloutNeedsAcknowledgement, rolloutEnabled, rolloutCancelClosed, consentsUnchanged, rolloutsUnchanged, commandsUnchanged, mutationRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutationRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || mutationRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Family consent/rollout create controls visual smoke failed");
} finally {
await browser.close();
psql(`DELETE FROM firmware_release WHERE id='${releaseInternalID}'; DELETE FROM firmware_signing_key WHERE id='${keyInternalID}';`);
}
@@ -0,0 +1,154 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const marker = randomUUID().slice(0, 8);
const keyID = `visual-key-${marker}`;
const keyInternalID = randomUUID();
const currentReleaseID = randomUUID();
const currentReleasePublicID = randomUUID();
const rollbackReleaseID = randomUUID();
const rollbackReleasePublicID = randomUUID();
const rolloutID = randomUUID();
const rolloutPublicID = randomUUID();
const grantID = randomUUID();
const grantPublicID = randomUUID();
const resourcePublicID = randomUUID();
const rolloutName = `页面固件控制夹具-${marker}`;
const sharePurpose = `页面分享撤销夹具-${marker}`;
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], { cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
}
function cleanup() {
psql(`BEGIN; DELETE FROM sharing_grant WHERE id='${grantID}'::uuid; DELETE FROM firmware_rollout WHERE id='${rolloutID}'::uuid; DELETE FROM firmware_release WHERE id IN ('${currentReleaseID}'::uuid,'${rollbackReleaseID}'::uuid); DELETE FROM firmware_signing_key WHERE id='${keyInternalID}'::uuid; COMMIT;`);
}
psql(`BEGIN;
INSERT INTO firmware_signing_key(id,tenant_id,key_id,public_key_base64,status,created_by)
VALUES('${keyInternalID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${keyID}','AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=','ACTIVE','10000000-0000-0000-0000-000000000101'::uuid);
INSERT INTO firmware_release(id,public_id,tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,status,created_by,verified_at)
VALUES
('${currentReleaseID}'::uuid,'${currentReleasePublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,(SELECT id FROM household WHERE public_id='10000000-0000-0000-0000-000000000211'::uuid),'visual-terminal','rev-a','9.9.2','object://visual/current-${marker}',1024,repeat('a',64),'visual-signature','${keyID}','1.0.0','页面暂停夹具','READY','10000000-0000-0000-0000-000000000101'::uuid,now()),
('${rollbackReleaseID}'::uuid,'${rollbackReleasePublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,(SELECT id FROM household WHERE public_id='10000000-0000-0000-0000-000000000211'::uuid),'visual-terminal','rev-a','9.9.1','object://visual/rollback-${marker}',1024,repeat('b',64),'visual-signature','${keyID}','1.0.0','页面回滚夹具','READY','10000000-0000-0000-0000-000000000101'::uuid,now());
INSERT INTO firmware_rollout(id,public_id,tenant_id,household_id,release_id,name,status,target_percentage,created_by,started_at)
VALUES('${rolloutID}'::uuid,'${rolloutPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,(SELECT id FROM household WHERE public_id='10000000-0000-0000-0000-000000000211'::uuid),'${currentReleaseID}'::uuid,'${rolloutName}','RUNNING',25,'10000000-0000-0000-0000-000000000101'::uuid,now()-interval '10 minutes');
INSERT INTO sharing_grant(id,public_id,tenant_id,household_id,subject_id,resource_type,resource_public_id,grantee_user_id,permission,purpose,login_required,granted_by,expires_at,version)
VALUES('${grantID}'::uuid,'${grantPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,(SELECT id FROM household WHERE public_id='10000000-0000-0000-0000-000000000211'::uuid),(SELECT id FROM subject_profile WHERE public_id='10000000-0000-0000-0000-000000000411'::uuid),'STORY','${resourcePublicID}'::uuid,'10000000-0000-0000-0000-000000000102'::uuid,'VIEW_TEXT','${sharePurpose}',true,'10000000-0000-0000-0000-000000000101'::uuid,now()+interval '1 day',3);
COMMIT;`);
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutations = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
if ((request.method() === "POST" && /\/(consents\/[^/]+\/withdraw|ownership|firmware-rollouts\/[^/]+\/(pause|rollback))$/.test(request.url())) || (request.method() === "DELETE" && /\/(households\/[^/]+\/members\/[^/]+|sharing-grants\/[^/]+)$/.test(request.url()))) mutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(process.env.VISUAL_USERNAME ?? "owner");
await page.locator("#password").fill(process.env.VISUAL_PASSWORD ?? "LocalOnly123!");
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
const memoryConsent = page.locator(".data-row").filter({ hasText: "个性化记忆" });
await memoryConsent.getByRole("button", { name: "撤回" }).click();
let form = page.getByRole("form", { name: "家庭业务受控操作" });
let text = await form.textContent();
const consentTargetBound = text?.includes("10000000-0000-0000-0000-000000000813") === true && text?.includes("测试长者") === true && text?.includes("记录版本 v1") === true;
let submit = form.getByRole("button", { name: "确认撤回授权" });
const consentDisabled = await submit.isDisabled();
await form.getByLabel(/我确认该授权将立即失效/).check();
const consentEnabled = await submit.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
await page.getByRole("button", { name: "家庭成员" }).click();
const memberRow = page.locator(".member-row").filter({ hasText: "本地编辑者" });
await memberRow.getByRole("button", { name: "转移所有权" }).click();
form = page.getByRole("form", { name: "家庭业务受控操作" });
text = await form.textContent();
const ownershipTargetBound = text?.includes("10000000-0000-0000-0000-000000000312") === true && text?.includes("合成数据一号家庭") === true && text?.includes("记录版本 v1") === true;
submit = form.getByRole("button", { name: "确认转移所有权" });
await form.getByLabel("输入成员姓名确认").fill("本地编辑");
const ownershipWrongDisabled = await submit.isDisabled();
await form.getByLabel("输入成员姓名确认").fill("本地编辑者");
const ownershipEnabled = await submit.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
await memberRow.getByRole("button", { name: "移除" }).click();
form = page.getByRole("form", { name: "家庭业务受控操作" });
text = await form.textContent();
const removeTargetBound = text?.includes("10000000-0000-0000-0000-000000000312") === true && text?.includes("失去家庭、长者、设备和内容访问能力") === true;
submit = form.getByRole("button", { name: "确认移出成员" });
await form.getByLabel("输入成员姓名确认").fill("本地编辑者");
const removeEnabled = await submit.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
await page.getByRole("button", { name: "固件发布" }).click();
await page.locator(".stack-card--button").filter({ hasText: rolloutName }).click();
const detail = page.locator(".firmware-detail").filter({ hasText: rolloutName });
await detail.waitFor();
await detail.getByRole("button", { name: "暂停批次" }).click();
form = page.getByRole("form", { name: "家庭业务受控操作" });
text = await form.textContent();
const pauseTargetBound = text?.includes(rolloutPublicID) === true && text?.includes("灰度 25%") === true && text?.includes("当前发布中") === true;
submit = form.getByRole("button", { name: "确认暂停批次" });
const pauseDisabled = await submit.isDisabled();
await form.getByLabel(/我确认尚未投递的设备命令将被取消/).check();
const pauseEnabled = await submit.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
await detail.getByLabel("回滚到已验证版本").selectOption(rollbackReleasePublicID);
await detail.getByRole("button", { name: "创建回滚批次" }).click();
form = page.getByRole("form", { name: "家庭业务受控操作" });
text = await form.textContent();
const rollbackTargetBound = text?.includes(rolloutPublicID) === true && text?.includes(rollbackReleasePublicID) === true && text?.includes("visual-terminal / 9.9.1") === true;
submit = form.getByRole("button", { name: "确认创建回滚" });
const rollbackDisabled = await submit.isDisabled();
await form.getByLabel(/我确认所选版本已经验证/).check();
const rollbackEnabled = await submit.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
await page.getByRole("button", { name: "资源分享" }).click();
const shareRow = page.locator(".data-row").filter({ hasText: sharePurpose });
await shareRow.getByRole("button", { name: "撤销" }).click();
form = page.getByRole("form", { name: "家庭业务受控操作" });
text = await form.textContent();
const shareTargetBound = text?.includes(grantPublicID) === true && text?.includes(resourcePublicID) === true && text?.includes("记录版本 v3") === true;
submit = form.getByRole("button", { name: "确认撤销分享" });
const shareDisabled = await submit.isDisabled();
await form.getByLabel(/我确认后续使用该分享凭据/).check();
const shareEnabled = await submit.isEnabled();
await page.screenshot({ path: fileURLToPath(new URL("family-controlled-actions.png", outputDirectory)), fullPage: true });
await form.getByRole("button", { name: "取消" }).click();
const cancelClosed = await form.count() === 0;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")].filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible").slice(0, 20).map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { consentTargetBound, consentDisabled, consentEnabled, ownershipTargetBound, ownershipWrongDisabled, ownershipEnabled, removeTargetBound, removeEnabled, pauseTargetBound, pauseDisabled, pauseEnabled, rollbackTargetBound, rollbackDisabled, rollbackEnabled, shareTargetBound, shareDisabled, shareEnabled, cancelClosed, mutations, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (!consentTargetBound || !consentDisabled || !consentEnabled || !ownershipTargetBound || !ownershipWrongDisabled || !ownershipEnabled || !removeTargetBound || !removeEnabled || !pauseTargetBound || !pauseDisabled || !pauseEnabled || !rollbackTargetBound || !rollbackDisabled || !rollbackEnabled || !shareTargetBound || !shareDisabled || !shareEnabled || !cancelClosed || mutations.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Family controlled actions visual smoke failed");
} finally {
await browser.close();
cleanup();
}
@@ -0,0 +1,151 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const marker = randomUUID().slice(0, 8);
const householdPublicID = "10000000-0000-0000-0000-000000000211";
const tenantID = "10000000-0000-0000-0000-000000000001";
const householdID = "10000000-0000-0000-0000-000000000201";
const ownerID = "10000000-0000-0000-0000-000000000101";
const activeKey = `visual-lifecycle-active-${marker}`;
const revokedKey = `visual-lifecycle-revoked-${marker}`;
const activeKeyID = randomUUID();
const revokedKeyID = randomUUID();
const readyVersion = `7.1.${Number.parseInt(marker.slice(0, 2), 16)}`;
const runningVersion = `7.2.${Number.parseInt(marker.slice(2, 4), 16)}`;
const pausedVersion = `7.3.${Number.parseInt(marker.slice(4, 6), 16)}`;
const blockedResumeVersion = `7.4.${Number.parseInt(marker.slice(6, 8), 16)}`;
const releases = [readyVersion, runningVersion, pausedVersion, blockedResumeVersion].map((version) => ({ id: randomUUID(), publicID: randomUUID(), version }));
const rolloutID = randomUUID();
const rolloutPublicID = randomUUID();
const queuedCommandID = randomUUID();
const acknowledgedCommandID = randomUUID();
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
const releaseValues = releases.map((release, index) => `('${release.id}','${release.publicID}','${tenantID}','${householdID}','simulated-terminal','sim-rev-a','${release.version}','visual/lifecycle/${release.publicID}.bin',32,'${(index + 1).toString(16).padStart(2, "0").repeat(32)}','${Buffer.alloc(64, index).toString("base64")}','${index === 3 ? revokedKey : activeKey}','1.0.0','版本生命周期页面夹具','${index < 2 ? "READY" : "PAUSED"}','${ownerID}',now())`).join(",\n");
psql(`
INSERT INTO firmware_signing_key(id,tenant_id,key_id,public_key_base64,status,created_by,revoked_at)
VALUES
('${activeKeyID}','${tenantID}','${activeKey}','${Buffer.alloc(32, 3).toString("base64")}','ACTIVE','${ownerID}',NULL),
('${revokedKeyID}','${tenantID}','${revokedKey}','${Buffer.alloc(32, 4).toString("base64")}','REVOKED','${ownerID}',now());
INSERT INTO firmware_release(id,public_id,tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,status,created_by,verified_at)
VALUES ${releaseValues};
INSERT INTO firmware_rollout(id,public_id,tenant_id,household_id,release_id,name,status,target_percentage,created_by,started_at)
VALUES ('${rolloutID}','${rolloutPublicID}','${tenantID}','${householdID}','${releases[1].id}','生命周期运行批次','RUNNING',25,'${ownerID}',now());
INSERT INTO device_command(id,tenant_id,device_id,command_type,payload,status,requested_by,idempotency_key,request_digest,expires_at,delivered_at,acknowledged_at)
VALUES
('${queuedCommandID}','${tenantID}','10000000-0000-0000-0000-000000000501','firmware_update',jsonb_build_object('release_id','${releases[1].publicID}'),'QUEUED','${ownerID}','visual-lifecycle-queued-${marker}',repeat('a',64),now()+interval '1 hour',NULL,NULL),
('${acknowledgedCommandID}','${tenantID}','10000000-0000-0000-0000-000000000502','firmware_update',jsonb_build_object('release_id','${releases[1].publicID}'),'ACKNOWLEDGED','${ownerID}','visual-lifecycle-ack-${marker}',repeat('b',64),now()+interval '1 hour',now(),now());
INSERT INTO firmware_assignment(rollout_id,tenant_id,device_id,command_id,status,progress_percent,last_result,last_reported_at)
VALUES
('${rolloutID}','${tenantID}','10000000-0000-0000-0000-000000000501','${queuedCommandID}','ASSIGNED',NULL,'{}'::jsonb,NULL),
('${rolloutID}','${tenantID}','10000000-0000-0000-0000-000000000502','${acknowledgedCommandID}','INSTALLING',72,'{"phase":"installing","progress_percent":72}'::jsonb,now());
`);
const auditCountBefore = Number(psql("SELECT count(*) FROM audit_event;"));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1200 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutationRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (request.method() === "POST" && new RegExp(`^/api/v1/firmware-releases/[^/]+/(pause|resume|retire)$`).test(pathname)) mutationRequests.push(`${request.method()} ${pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "固件发布", exact: true }).click();
const releaseSection = page.locator("section.panel-section").filter({ has: page.getByRole("heading", { name: "固件版本", exact: true }) });
const readyCard = releaseSection.locator("article.stack-card").filter({ hasText: readyVersion });
const runningCard = releaseSection.locator("article.stack-card").filter({ hasText: runningVersion });
const pausedCard = releaseSection.locator("article.stack-card").filter({ hasText: pausedVersion });
const blockedResumeCard = releaseSection.locator("article.stack-card").filter({ hasText: blockedResumeVersion });
await readyCard.waitFor();
const readyPauseEnabled = await readyCard.getByRole("button", { name: "暂停分发" }).isEnabled();
const readyRetireEnabled = await readyCard.getByRole("button", { name: "退役版本" }).isEnabled();
const runningRetireDisabled = await runningCard.getByRole("button", { name: "退役版本" }).isDisabled();
const runningRetireExplained = (await runningCard.getByRole("button", { name: "退役版本" }).getAttribute("title"))?.includes("先暂停版本") === true;
const blockedResumeDisabled = await blockedResumeCard.getByRole("button", { name: "恢复分发" }).isDisabled();
const blockedResumeExplained = (await blockedResumeCard.getByRole("button", { name: "恢复分发" }).getAttribute("title"))?.includes("签名密钥已吊销") === true;
await runningCard.getByRole("button", { name: "查看影响" }).click();
const impactPanel = page.getByRole("region", { name: "固件版本影响与处置结果" });
await impactPanel.waitFor();
const impactText = await impactPanel.textContent();
const impactSummaryShown = impactText?.includes("关联批次1") === true && impactText?.includes("运行 1 · 暂停 0") === true && impactText?.includes("目标设备2") === true && impactText?.includes("分配 1 · 下载 0 · 安装 1") === true && impactText?.includes("取消未投递 · 排队 1") === true;
const observationShown = impactText?.includes("1台待观察") === true && impactText?.includes("10000000-0000-0000-0000-000000000512") === true && impactText?.includes("安装中,继续观察") === true && impactText?.includes("正在安装 · 72%") === true && impactText?.includes("命令 ACKNOWLEDGED") === true;
await impactPanel.screenshot({ path: fileURLToPath(new URL("family-firmware-release-impact.png", outputDirectory)) });
await runningCard.getByRole("button", { name: "暂停分发" }).click();
const control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
let text = await control.textContent();
const pauseTargetBound = text?.includes(householdPublicID) === true && text?.includes(runningVersion) === true && text?.includes("关联运行中灰度 1个") === true && text?.includes("取消尚未投递") === true;
const pauseAction = control.getByRole("button", { name: "确认暂停版本分发" });
const pauseNeedsAcknowledgement = await pauseAction.isDisabled();
await control.getByLabel(/我确认立即暂停版本/).check();
const pauseActionEnabled = await pauseAction.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-firmware-release-pause-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
await pausedCard.getByRole("button", { name: "恢复分发" }).click();
await control.waitFor();
text = await control.textContent();
const resumeTargetBound = text?.includes(pausedVersion) === true && text?.includes(activeKey) === true && text?.includes("不恢复任何旧批次") === true;
await control.getByRole("button", { name: "取消" }).click();
await readyCard.getByRole("button", { name: "退役版本" }).click();
await control.waitFor();
text = await control.textContent();
const retireTargetBound = text?.includes(readyVersion) === true && text?.includes("已验证对象和历史记录不会被删除") === true && text?.includes("原5分钟窗口") === true;
await control.getByRole("button", { name: "取消" }).click();
const statusSnapshot = psql(`SELECT string_agg(version||':'||status,',' ORDER BY version) FROM firmware_release WHERE id IN (${releases.map((item) => `'${item.id}'`).join(",")});`);
const expectedSnapshot = [readyVersion, runningVersion].sort().map((version) => `${version}:READY`).concat([pausedVersion, blockedResumeVersion].sort().map((version) => `${version}:PAUSED`)).sort().join(",");
const statusesUnchanged = statusSnapshot === expectedSnapshot;
const rolloutUnchanged = psql(`SELECT status FROM firmware_rollout WHERE id='${rolloutID}';`) === "RUNNING";
const auditUnchanged = Number(psql("SELECT count(*) FROM audit_event;")) === auditCountBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { readyPauseEnabled, readyRetireEnabled, runningRetireDisabled, runningRetireExplained, blockedResumeDisabled, blockedResumeExplained, impactSummaryShown, observationShown, pauseTargetBound, pauseNeedsAcknowledgement, pauseActionEnabled, resumeTargetBound, retireTargetBound, statusesUnchanged, rolloutUnchanged, auditUnchanged, mutationRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutationRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || mutationRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Firmware release lifecycle visual smoke failed");
} finally {
await browser.close();
psql(`DELETE FROM firmware_rollout WHERE id='${rolloutID}'; DELETE FROM device_command WHERE id IN ('${queuedCommandID}','${acknowledgedCommandID}'); DELETE FROM firmware_release WHERE id IN (${releases.map((item) => `'${item.id}'`).join(",")}); DELETE FROM firmware_signing_key WHERE id IN ('${activeKeyID}','${revokedKeyID}');`);
}
@@ -0,0 +1,128 @@
import { createRequire } from "node:module";
import { createHash, randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const marker = randomUUID().slice(0, 8);
const householdPublicID = "10000000-0000-0000-0000-000000000211";
const freeKey = `visual-free-key-${marker}`;
const blockedKey = `visual-blocked-key-${marker}`;
const revokedKey = `visual-revoked-key-${marker}`;
const freePublicBytes = Buffer.alloc(32, 0);
const blockedPublicBytes = Buffer.alloc(32, 1);
const revokedPublicBytes = Buffer.alloc(32, 2);
const freeFingerprint = createHash("sha256").update(freePublicBytes).digest("hex");
const freeKeyInternalID = randomUUID();
const blockedKeyInternalID = randomUUID();
const revokedKeyInternalID = randomUUID();
const readyReleaseInternalID = randomUUID();
const readyReleasePublicID = randomUUID();
const uploadingReleaseInternalID = randomUUID();
const uploadingReleasePublicID = randomUUID();
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
psql(`
INSERT INTO firmware_signing_key(id,tenant_id,key_id,public_key_base64,status,created_by)
VALUES
('${freeKeyInternalID}','10000000-0000-0000-0000-000000000001','${freeKey}','${freePublicBytes.toString("base64")}','ACTIVE','10000000-0000-0000-0000-000000000101'),
('${blockedKeyInternalID}','10000000-0000-0000-0000-000000000001','${blockedKey}','${blockedPublicBytes.toString("base64")}','ACTIVE','10000000-0000-0000-0000-000000000101'),
('${revokedKeyInternalID}','10000000-0000-0000-0000-000000000001','${revokedKey}','${revokedPublicBytes.toString("base64")}','REVOKED','10000000-0000-0000-0000-000000000101');
UPDATE firmware_signing_key SET revoked_at=now() WHERE id='${revokedKeyInternalID}';
INSERT INTO firmware_release(id,public_id,tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,status,created_by,verified_at)
VALUES
('${readyReleaseInternalID}','${readyReleasePublicID}','10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','simulated-terminal','sim-rev-a','6.6.${Number.parseInt(marker.slice(0, 2), 16)}','visual/keys/${readyReleasePublicID}.bin',16,'${"ab".repeat(32)}','${Buffer.alloc(64).toString("base64")}','${freeKey}','1.0.0','页面公钥台账已验证夹具','READY','10000000-0000-0000-0000-000000000101',now()),
('${uploadingReleaseInternalID}','${uploadingReleasePublicID}','10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','simulated-terminal','sim-rev-a','6.5.${Number.parseInt(marker.slice(2, 4), 16)}','visual/keys/${uploadingReleasePublicID}.bin',16,'${"cd".repeat(32)}','${Buffer.alloc(64, 1).toString("base64")}','${blockedKey}','1.0.0','页面公钥台账上传中夹具','UPLOADING','10000000-0000-0000-0000-000000000101',NULL);
`);
const keyCountBefore = Number(psql("SELECT count(*) FROM firmware_signing_key;"));
const auditCountBefore = Number(psql("SELECT count(*) FROM audit_event;"));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1150 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutationRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (request.method() === "POST" && (/^\/api\/v1\/households\/[^/]+\/firmware-signing-keys$/.test(pathname) || /^\/api\/v1\/households\/[^/]+\/firmware-signing-keys\/revoke$/.test(pathname))) mutationRequests.push(`${request.method()} ${pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "固件发布", exact: true }).click();
const ledger = page.getByRole("region", { name: "可信固件公钥台账" });
await ledger.getByText("2把活动公钥").waitFor();
const freeCard = ledger.locator("article.firmware-key-card").filter({ hasText: freeKey });
const blockedCard = ledger.locator("article.firmware-key-card").filter({ hasText: blockedKey });
const revokedCard = ledger.locator("article.firmware-key-card").filter({ hasText: revokedKey });
const freeReferencesShown = (await freeCard.textContent())?.includes(freeFingerprint) === true && (await freeCard.textContent())?.includes("上传中 0 · 已验证 1 · 运行中灰度 0") === true;
const blockedReferencesShown = (await blockedCard.textContent())?.includes("上传中 1 · 已验证 0 · 运行中灰度 0") === true;
const blockedRevokeDisabled = await blockedCard.getByRole("button", { name: "吊销公钥" }).isDisabled();
const revokedStatusShown = (await revokedCard.textContent())?.includes("已吊销") === true;
const keyForm = page.locator("form.tool-form").filter({ hasText: "登记可信签名公钥" });
await keyForm.getByLabel("密钥编号").fill(revokedKey);
await keyForm.getByLabel("Ed25519公钥(Base64)").fill(revokedPublicBytes.toString("base64"));
const revokedRegistrationBlocked = await keyForm.getByRole("button", { name: "核对并登记公钥" }).isDisabled();
const revokedRegistrationExplained = (await keyForm.textContent())?.includes("该编号已经吊销,必须使用新的轮换编号") === true;
await keyForm.getByLabel("密钥编号").fill(freeKey);
await keyForm.getByLabel("Ed25519公钥(Base64)").fill(Buffer.alloc(32, 9).toString("base64"));
await keyForm.getByRole("button", { name: "核对并登记公钥" }).click();
await page.getByRole("alert").filter({ hasText: "该密钥编号已绑定另一把公钥,不能静默替换" }).waitFor();
const replacementBlockedBeforeControl = await page.getByRole("form", { name: "家庭业务受控操作" }).count() === 0;
await freeCard.getByRole("button", { name: "吊销公钥" }).click();
const control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
const text = await control.textContent();
const revokeTargetBound = text?.includes(householdPublicID) === true && text?.includes(freeKey) === true && text?.includes(freeFingerprint) === true && text?.includes("上传中版本 0") === true && text?.includes("已验证版本 1") === true && text?.includes("运行中灰度 0") === true && text?.includes("不能再创建新灰度") === true;
const action = control.getByRole("button", { name: "确认吊销可信公钥" });
const revokeNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/我确认吊销密钥/).check();
const revokeActionEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-firmware-signing-key-revoke-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const revokeCancelClosed = await control.count() === 0;
const keysUnchanged = Number(psql("SELECT count(*) FROM firmware_signing_key;")) === keyCountBefore && psql(`SELECT status FROM firmware_signing_key WHERE id='${freeKeyInternalID}';`) === "ACTIVE";
const auditUnchanged = Number(psql("SELECT count(*) FROM audit_event;")) === auditCountBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { freeReferencesShown, blockedReferencesShown, blockedRevokeDisabled, revokedStatusShown, revokedRegistrationBlocked, revokedRegistrationExplained, replacementBlockedBeforeControl, revokeTargetBound, revokeNeedsAcknowledgement, revokeActionEnabled, revokeCancelClosed, keysUnchanged, auditUnchanged, mutationRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutationRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || mutationRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Firmware signing key lifecycle visual smoke failed");
} finally {
await browser.close();
psql(`DELETE FROM firmware_release WHERE id IN ('${readyReleaseInternalID}','${uploadingReleaseInternalID}'); DELETE FROM firmware_signing_key WHERE id IN ('${freeKeyInternalID}','${blockedKeyInternalID}','${revokedKeyInternalID}');`);
}
@@ -0,0 +1,124 @@
import { createRequire } from "node:module";
import { createHash, randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const marker = randomUUID().slice(0, 8);
const householdPublicID = "10000000-0000-0000-0000-000000000211";
const keyID = `visual-trust-key-${marker}`;
const publicKeyBytes = Buffer.alloc(32);
const publicKey = publicKeyBytes.toString("base64");
const fingerprint = createHash("sha256").update(publicKeyBytes).digest("hex");
const firmwareBytes = Buffer.from(`controlled-firmware-artifact-${marker}`, "utf8");
const firmwareDigest = createHash("sha256").update(firmwareBytes).digest("hex");
const signature = Buffer.alloc(64).toString("base64");
const firmwareVersion = `8.8.${Number.parseInt(marker.slice(0, 2), 16)}`;
const releaseNotes = `受控固件制品-${marker}`;
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
const keyCountBefore = Number(psql("SELECT count(*) FROM firmware_signing_key;"));
const releaseCountBefore = Number(psql("SELECT count(*) FROM firmware_release;"));
const auditCountBefore = Number(psql("SELECT count(*) FROM audit_event;"));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1150 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutationRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if ((request.method() === "POST" && (/^\/api\/v1\/households\/[^/]+\/firmware-signing-keys$/.test(pathname) || /^\/api\/v1\/households\/[^/]+\/firmware-releases$/.test(pathname) || /^\/api\/v1\/firmware-releases\/[^/]+\/finalize$/.test(pathname))) || request.method() === "PUT") mutationRequests.push(`${request.method()} ${pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "固件发布", exact: true }).click();
const keyForm = page.locator("form.tool-form").filter({ hasText: "登记可信签名公钥" });
await keyForm.getByLabel("密钥编号").fill(keyID);
await keyForm.getByLabel("Ed25519公钥(Base64)").fill("not-base64");
const invalidKeyBlocked = await keyForm.getByRole("button", { name: "核对并登记公钥" }).isDisabled();
const invalidKeyExplained = (await keyForm.textContent())?.includes("必须是标准Base64编码的32字节Ed25519公钥") === true;
await keyForm.getByLabel("Ed25519公钥(Base64)").fill(publicKey);
const validKeyEnabled = await keyForm.getByRole("button", { name: "核对并登记公钥" }).isEnabled();
await keyForm.getByRole("button", { name: "核对并登记公钥" }).click();
let control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
let text = await control.textContent();
const keyTargetBound = text?.includes(householdPublicID) === true && text?.includes(keyID) === true && text?.includes("32字节") === true && text?.includes(fingerprint) === true && text?.includes("复用租户内相同编号会更新") === true;
let action = control.getByRole("button", { name: "确认登记可信公钥" });
const keyNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/我已通过受控渠道核对密钥编号/).check();
const keyActionEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-firmware-key-register-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const keyCancelClosed = await control.count() === 0;
const releaseForm = page.locator("form.firmware-publish-form");
await releaseForm.getByLabel("设备型号").fill("simulated-terminal");
await releaseForm.getByLabel("主板版本").fill("sim-rev-a");
await releaseForm.getByLabel("固件版本").fill(firmwareVersion);
await releaseForm.getByLabel("签名密钥编号").fill(keyID);
await releaseForm.getByLabel("最低安全基线").fill("1.0.0");
await releaseForm.getByLabel("发布说明").fill(releaseNotes);
await releaseForm.getByLabel("Ed25519签名(Base64)").fill("invalid-signature");
await releaseForm.getByLabel("固件文件").setInputFiles({ name: `firmware-${marker}.bin`, mimeType: "application/octet-stream", buffer: firmwareBytes });
await releaseForm.getByText(firmwareDigest).waitFor();
const invalidSignatureBlocked = await releaseForm.getByRole("button", { name: "核对并上传校验" }).isDisabled();
const invalidSignatureExplained = (await releaseForm.textContent())?.includes("签名必须是标准Base64编码的64字节Ed25519结果") === true;
await releaseForm.getByLabel("Ed25519签名(Base64)").fill(signature);
const releaseReady = await releaseForm.getByRole("button", { name: "核对并上传校验" }).isEnabled();
await releaseForm.getByRole("button", { name: "核对并上传校验" }).click();
control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
text = await control.textContent();
const releaseTargetBound = text?.includes(householdPublicID) === true && text?.includes("simulated-terminal / sim-rev-a") === true && text?.includes(firmwareVersion) === true && text?.includes(`firmware-${marker}.bin`) === true && text?.includes(firmwareDigest) === true && text?.includes(keyID) === true && text?.includes("1.0.0") === true && text?.includes(releaseNotes) === true && text?.includes("firmware-manifest-v1") === true && text?.includes("15分钟上传地址") === true;
action = control.getByRole("button", { name: "确认上传并验证制品" });
const releaseNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/我已核对设备型号、主板、版本、文件大小和SHA-256/).check();
const releaseActionEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-firmware-release-publish-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const releaseCancelClosed = await control.count() === 0;
const keysUnchanged = Number(psql("SELECT count(*) FROM firmware_signing_key;")) === keyCountBefore;
const releasesUnchanged = Number(psql("SELECT count(*) FROM firmware_release;")) === releaseCountBefore;
const auditUnchanged = Number(psql("SELECT count(*) FROM audit_event;")) === auditCountBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { invalidKeyBlocked, invalidKeyExplained, validKeyEnabled, keyTargetBound, keyNeedsAcknowledgement, keyActionEnabled, keyCancelClosed, invalidSignatureBlocked, invalidSignatureExplained, releaseReady, releaseTargetBound, releaseNeedsAcknowledgement, releaseActionEnabled, releaseCancelClosed, keysUnchanged, releasesUnchanged, auditUnchanged, mutationRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutationRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || mutationRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Firmware trust/artifact controls visual smoke failed");
} finally {
await browser.close();
}
@@ -0,0 +1,120 @@
import { createRequire } from "node:module";
import { createHash, randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const marker = randomUUID().slice(0, 8);
const keyID = `visual-recovery-key-${marker}`;
const keyInternalID = randomUUID();
const releaseInternalID = randomUUID();
const releasePublicID = randomUUID();
const releaseVersion = `7.7.${Number.parseInt(marker.slice(0, 2), 16)}`;
const firmwareBytes = Buffer.from(`recoverable-firmware-${marker}`, "utf8");
const firmwareDigest = createHash("sha256").update(firmwareBytes).digest("hex");
const householdPublicID = "10000000-0000-0000-0000-000000000211";
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
psql(`
INSERT INTO firmware_signing_key(id,tenant_id,key_id,public_key_base64,status,created_by)
VALUES('${keyInternalID}','10000000-0000-0000-0000-000000000001','${keyID}','AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=','ACTIVE','10000000-0000-0000-0000-000000000101');
INSERT INTO firmware_release(id,public_id,tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,status,created_by)
VALUES('${releaseInternalID}','${releasePublicID}','10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','simulated-terminal','sim-rev-a','${releaseVersion}','visual/recovery/${releasePublicID}.bin',${firmwareBytes.length},'${firmwareDigest}','${Buffer.alloc(64).toString("base64")}','${keyID}','1.0.0','页面上传恢复夹具','UPLOADING','10000000-0000-0000-0000-000000000101');
`);
const releaseCountBefore = Number(psql("SELECT count(*) FROM firmware_release;"));
const auditCountBefore = Number(psql("SELECT count(*) FROM audit_event;"));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1150 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutationRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if ((request.method() === "POST" && (/^\/api\/v1\/firmware-releases\/[^/]+\/(upload-session|finalize|retire)$/.test(pathname))) || request.method() === "PUT") mutationRequests.push(`${request.method()} ${pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "固件发布", exact: true }).click();
const releaseCard = page.locator("article.stack-card").filter({ hasText: releaseVersion });
await releaseCard.waitFor();
const uploadingVisible = (await releaseCard.textContent())?.includes("等待校验") === true;
await releaseCard.getByRole("button", { name: "恢复上传" }).click();
const recoveryForm = page.getByRole("form", { name: "恢复未完成固件上传" });
await recoveryForm.waitFor();
const recoveryTargetShown = (await recoveryForm.textContent())?.includes(releasePublicID) === true && (await recoveryForm.textContent())?.includes(firmwareDigest) === true;
await recoveryForm.getByLabel("重新选择原始固件文件").setInputFiles({ name: "wrong.bin", mimeType: "application/octet-stream", buffer: Buffer.from("wrong-firmware", "utf8") });
await recoveryForm.getByText("本地文件与原版本的大小或SHA-256不一致,禁止恢复上传。").waitFor();
const mismatchBlocked = await recoveryForm.getByRole("button", { name: "核对并恢复上传" }).isDisabled();
await recoveryForm.getByLabel("重新选择原始固件文件").setInputFiles({ name: `firmware-${marker}.bin`, mimeType: "application/octet-stream", buffer: firmwareBytes });
await recoveryForm.getByText("文件大小和摘要与未完成版本完全一致,可以继续上传。").waitFor();
const exactFileEnabled = await recoveryForm.getByRole("button", { name: "核对并恢复上传" }).isEnabled();
await recoveryForm.getByRole("button", { name: "核对并恢复上传" }).click();
let control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
let text = await control.textContent();
const resumeTargetBound = text?.includes(householdPublicID) === true && text?.includes(releasePublicID) === true && text?.includes(releaseVersion) === true && text?.includes(firmwareDigest) === true && text?.includes(`firmware-${marker}.bin`) === true && text?.includes("15分钟私有PUT地址") === true;
let action = control.getByRole("button", { name: "确认续传并重新校验" });
const resumeNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/我确认重新选择的本地文件与版本/).check();
const resumeActionEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-firmware-upload-resume-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const resumeCancelClosed = await control.count() === 0;
await releaseCard.getByRole("button", { name: "退役未完成版本" }).click();
control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
text = await control.textContent();
const retireTargetBound = text?.includes(householdPublicID) === true && text?.includes(releasePublicID) === true && text?.includes(releaseVersion) === true && text?.includes(firmwareDigest) === true && text?.includes(keyID) === true && text?.includes("只有仍处于上传中的版本才能转为退役") === true;
action = control.getByRole("button", { name: "确认退役未完成版本" });
const retireNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/我确认版本.*的上传或签名校验无法继续/).check();
const retireActionEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-firmware-upload-retire-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const retireCancelClosed = await control.count() === 0;
const releaseUnchanged = psql(`SELECT status FROM firmware_release WHERE public_id='${releasePublicID}'::uuid;`) === "UPLOADING" && Number(psql("SELECT count(*) FROM firmware_release;")) === releaseCountBefore;
const auditUnchanged = Number(psql("SELECT count(*) FROM audit_event;")) === auditCountBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { uploadingVisible, recoveryTargetShown, mismatchBlocked, exactFileEnabled, resumeTargetBound, resumeNeedsAcknowledgement, resumeActionEnabled, resumeCancelClosed, retireTargetBound, retireNeedsAcknowledgement, retireActionEnabled, retireCancelClosed, releaseUnchanged, auditUnchanged, mutationRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutationRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || mutationRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Firmware upload recovery controls visual smoke failed");
} finally {
await browser.close();
psql(`DELETE FROM firmware_release WHERE id='${releaseInternalID}'; DELETE FROM firmware_signing_key WHERE id='${keyInternalID}';`);
}
@@ -0,0 +1,107 @@
import { createRequire } from "node:module";
import { createHash, randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const invitationInternalID = randomUUID();
const invitationPublicID = randomUUID();
const marker = invitationPublicID.slice(0, 8);
const invitationHint = `页面邀请撤销夹具-${marker}`;
const tokenDigest = createHash("sha256").update(`visual-invitation-${invitationPublicID}`).digest("hex");
const deviceID = "10000000-0000-0000-0000-000000000511";
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
function cleanup() {
psql(`DELETE FROM member_invitation WHERE id='${invitationInternalID}'::uuid;`);
}
psql(`INSERT INTO member_invitation(id,public_id,tenant_id,household_id,invitee_hint,token_digest,role,expires_at,created_by)
VALUES('${invitationInternalID}'::uuid,'${invitationPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,(SELECT id FROM household WHERE public_id='10000000-0000-0000-0000-000000000211'::uuid),'${invitationHint}','${tokenDigest}','viewer',now()+interval '1 day','10000000-0000-0000-0000-000000000101'::uuid);`);
const diagnosticCountBefore = Number(psql(`SELECT count(*) FROM device_command WHERE device_id=(SELECT id FROM device WHERE public_id='${deviceID}'::uuid) AND command_type='diagnostics';`));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1050 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutations = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
if (request.method() === "POST" && (/\/api\/v1\/households\/[^/]+\/invitations\/[^/]+\/revoke$/.test(request.url()) || /\/api\/v1\/devices\/[^/]+\/commands$/.test(request.url()))) mutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "家庭成员" }).click();
const invitationRow = page.locator(".member-row").filter({ hasText: invitationHint });
await invitationRow.waitFor();
await invitationRow.getByRole("button", { name: "撤销" }).click();
let form = page.getByRole("form", { name: "家庭业务受控操作" });
await form.waitFor();
let text = await form.textContent();
const invitationTargetBound = text?.includes(invitationPublicID) === true && text?.includes(invitationHint) === true && text?.includes("查看者") === true && text?.includes("原一次性凭据立即失效") === true;
let action = form.getByRole("button", { name: "确认撤销邀请" });
const invitationNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/我确认该待接受邀请的一次性凭据将立即失效/).check();
const invitationEnabled = await action.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
const invitationCancelClosed = await form.count() === 0;
await page.getByRole("button", { name: "设备管理" }).click();
const deviceCard = page.locator(".device-card").filter({ hasText: "simulated-terminal" });
await deviceCard.waitFor();
await deviceCard.getByRole("button", { name: "下发设备诊断" }).click();
form = page.getByRole("form", { name: "家庭业务受控操作" });
await form.waitFor();
text = await form.textContent();
const deviceTargetBound = text?.includes(deviceID) === true && text?.includes("测试长者") === true && text?.includes("sim-rev-a / 0.1.0-sim") === true && text?.includes("期望配置 v1") === true && text?.includes("音频、网络和存储") === true;
action = form.getByRole("button", { name: "确认下发诊断" });
const deviceNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/我确认向该设备下发音频、网络和存储三项诊断命令/).check();
const deviceEnabled = await action.isEnabled();
await form.screenshot({ path: fileURLToPath(new URL("family-device-diagnostics-control.png", outputDirectory)) });
await form.getByRole("button", { name: "取消" }).click();
const deviceCancelClosed = await form.count() === 0;
const invitationUnchanged = psql(`SELECT (revoked_at IS NULL AND used_at IS NULL)::text FROM member_invitation WHERE id='${invitationInternalID}'::uuid;`) === "true";
const diagnosticCountAfter = Number(psql(`SELECT count(*) FROM device_command WHERE device_id=(SELECT id FROM device WHERE public_id='${deviceID}'::uuid) AND command_type='diagnostics';`));
const deviceCommandsUnchanged = diagnosticCountAfter === diagnosticCountBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { invitationTargetBound, invitationNeedsAcknowledgement, invitationEnabled, invitationCancelClosed, deviceTargetBound, deviceNeedsAcknowledgement, deviceEnabled, deviceCancelClosed, invitationUnchanged, deviceCommandsUnchanged, mutations, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutations", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || mutations.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Family invitation and device control visual smoke failed");
} finally {
await browser.close();
cleanup();
}
@@ -0,0 +1,110 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const marker = randomUUID().slice(0, 8);
const inviteeHint = `受控邀请-${marker}`;
const claimCode = `DEVICE-CLAIM-${marker}-9X7K`;
const householdPublicID = "10000000-0000-0000-0000-000000000211";
const subjectPublicID = "10000000-0000-0000-0000-000000000411";
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
const invitationCountBefore = Number(psql("SELECT count(*) FROM member_invitation;"));
const bindingCountBefore = Number(psql("SELECT count(*) FROM device_binding;"));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1050 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const createRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const requestURL = new URL(request.url());
if (request.method() === "POST" && (/^\/api\/v1\/households\/[^/]+\/invitations$/.test(requestURL.pathname) || requestURL.pathname === "/api/v1/device-claims")) createRequests.push(`${request.method()} ${requestURL.pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "家庭成员", exact: true }).click();
const invitationForm = page.locator("form.tool-form").filter({ hasText: "创建成员邀请" });
await invitationForm.waitFor();
await invitationForm.getByLabel("接收人提示").fill(inviteeHint);
await invitationForm.getByLabel("加入角色").selectOption("admin");
await invitationForm.getByRole("button", { name: "生成邀请" }).click();
let control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
let text = await control.textContent();
const invitationTargetBound = text?.includes(householdPublicID) === true && text?.includes(inviteeHint) === true && text?.includes("管理员") === true && text?.includes("固定有效期 7天") === true && text?.includes("接收人提示仅用于人工识别,不校验接受者身份") === true && text?.includes("明文只在创建成功后显示一次") === true;
let action = control.getByRole("button", { name: "确认生成成员邀请" });
const invitationNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/接收人提示不绑定真实身份/).check();
const invitationEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-member-invitation-create-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const invitationCancelClosed = await control.count() === 0;
const noInvitationSecret = await page.getByLabel("一次性邀请凭据").count() === 0;
await page.getByRole("button", { name: "设备管理", exact: true }).click();
const claimForm = page.locator("form.workspace__claim");
const claimInput = claimForm.getByLabel("设备绑定码");
const claimButton = claimForm.getByRole("button", { name: "绑定设备" });
await claimInput.fill("123456789012345");
const shortClaimBlocked = await claimButton.isDisabled();
await claimInput.fill(claimCode);
const validClaimEnabled = await claimButton.isEnabled();
await claimButton.click();
control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
text = await control.textContent();
const claimTargetBound = text?.includes(householdPublicID) === true && text?.includes(subjectPublicID) === true && text?.includes("测试长者") === true && text?.includes(claimCode.slice(-4)) === true && text?.includes(`共${claimCode.length}字符`) === true && text?.includes("平台不会生成或回显完整设备码") === true;
const claimSecretMasked = text?.includes(claimCode) === false;
action = control.getByRole("button", { name: "确认绑定该设备" });
const claimNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/已当面核对设备屏幕、设备型号和使用人/).check();
const claimEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-device-claim-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const claimCancelClosed = await control.count() === 0;
const claimInputCleared = await claimInput.inputValue() === "";
const invitationsUnchanged = Number(psql("SELECT count(*) FROM member_invitation;")) === invitationCountBefore;
const bindingsUnchanged = Number(psql("SELECT count(*) FROM device_binding;")) === bindingCountBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { invitationTargetBound, invitationNeedsAcknowledgement, invitationEnabled, invitationCancelClosed, noInvitationSecret, shortClaimBlocked, validClaimEnabled, claimTargetBound, claimSecretMasked, claimNeedsAcknowledgement, claimEnabled, claimCancelClosed, claimInputCleared, invitationsUnchanged, bindingsUnchanged, createRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["createRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || createRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Family one-time credential controls visual smoke failed");
} finally {
await browser.close();
}
@@ -0,0 +1,107 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const marker = randomUUID().slice(0, 8);
const householdPublicID = "10000000-0000-0000-0000-000000000211";
const subjectPublicID = "10000000-0000-0000-0000-000000000411";
const subjectName = `测试长者-${marker}`;
const background = `长期居住在杭州,喜欢与家人回忆往事-${marker}`;
const goal = `了解第一次参加工作的经历-${marker}`;
const factContent = `近期需要提醒放慢说话速度-${marker}`;
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
const subjectCountBefore = Number(psql("SELECT count(*) FROM subject_profile;"));
const factCountBefore = Number(psql("SELECT count(*) FROM subject_fact;"));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutationRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (request.method() === "POST" && (/^\/api\/v1\/households\/[^/]+\/subjects$/.test(pathname) || /^\/api\/v1\/subjects\/[^/]+\/facts$/.test(pathname))) mutationRequests.push(`${request.method()} ${pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
const subjectForm = page.locator("form.subject-create-form");
await subjectForm.getByLabel("称呼").fill(subjectName);
await subjectForm.getByLabel("交流节奏").selectOption("slow");
await subjectForm.getByLabel("背景资料").fill(background);
await subjectForm.getByLabel("本次交流目标").fill(goal);
await subjectForm.getByLabel("兴趣(逗号分隔)").fill("老照片,家乡变化");
await subjectForm.getByLabel("优先话题(逗号分隔)").fill("工作经历,家庭往事");
await subjectForm.getByRole("button", { name: "创建档案" }).click();
let control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
let text = await control.textContent();
const subjectTargetBound = text?.includes(householdPublicID) === true && text?.includes(subjectName) === true && text?.includes(background) === true && text?.includes(goal) === true && text?.includes("老照片、家乡变化") === true && text?.includes("工作经历、家庭往事") === true && text?.includes("授权、设备绑定和通话需分别完成") === true;
let action = control.getByRole("button", { name: "确认创建长期档案" });
const subjectNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/确认这些个人资料可以作为长期档案保存/).check();
const subjectEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-subject-create-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const subjectCancelClosed = await control.count() === 0;
const factForm = page.locator("form.tool-form").filter({ hasText: "登记稳定事实" });
await factForm.getByLabel("事实类型").selectOption("health_context");
const healthDefaultsSensitive = await factForm.getByLabel("敏感等级").inputValue() === "sensitive";
await factForm.getByLabel("事实内容").fill(factContent);
await factForm.getByRole("button", { name: "登记候选" }).click();
control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
text = await control.textContent();
const factTargetBound = text?.includes("测试长者") === true && text?.includes(subjectPublicID) === true && text?.includes("健康背景") === true && text?.includes(factContent) === true && text?.includes("家庭成员录入") === true && text?.includes("敏感") === true && text?.includes("待核对") === true && text?.includes("不会自动确认、创建记忆或改写历史通话") === true;
action = control.getByRole("button", { name: "确认登记待核对事实" });
const factNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/类型和敏感等级已正确标注/).check();
const factEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-fact-create-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const factCancelClosed = await control.count() === 0;
const subjectsUnchanged = Number(psql("SELECT count(*) FROM subject_profile;")) === subjectCountBefore;
const factsUnchanged = Number(psql("SELECT count(*) FROM subject_fact;")) === factCountBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { subjectTargetBound, subjectNeedsAcknowledgement, subjectEnabled, subjectCancelClosed, healthDefaultsSensitive, factTargetBound, factNeedsAcknowledgement, factEnabled, factCancelClosed, subjectsUnchanged, factsUnchanged, mutationRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutationRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || mutationRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Family personal-data create controls visual smoke failed");
} finally {
await browser.close();
}
@@ -0,0 +1,107 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const marker = randomUUID().slice(0, 8);
const subjectPublicID = "10000000-0000-0000-0000-000000000411";
const devicePublicID = "10000000-0000-0000-0000-000000000511";
const revisedBackground = `长期生活在杭州并熟悉铁路工作-${marker}`;
const revisedGoal = `下一次交流了解第一次独立值班-${marker}`;
const targetVolume = 73;
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
const subjectStateBefore = psql(`SELECT profile_version || '|' || profile_data::text || '|' || conversation_preferences::text FROM subject_profile WHERE public_id='${subjectPublicID}';`);
const twinStateBefore = psql(`SELECT dt.desired_revision || '|' || dt.desired_config::text FROM device_twin dt JOIN device d ON d.id=dt.device_id WHERE d.public_id='${devicePublicID}';`);
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutationRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (request.method() === "PATCH" && (/^\/api\/v1\/subjects\/[^/]+$/.test(pathname) || /^\/api\/v1\/devices\/[^/]+\/twin$/.test(pathname))) mutationRequests.push(`${request.method()} ${pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
const profileForm = page.locator("form.profile-editor");
const profileButton = profileForm.getByRole("button", { name: "核对并保存新版本" });
const unchangedProfileBlocked = await profileButton.isDisabled();
await profileForm.getByLabel("背景资料").fill(revisedBackground);
await profileForm.getByLabel("交流目标").fill(revisedGoal);
await profileForm.getByLabel("交流节奏").selectOption("normal");
await profileForm.getByLabel("兴趣(逗号分隔)").fill("老照片,铁路往事");
await profileForm.getByLabel("优先话题(逗号分隔)").fill("第一次值班,工作伙伴");
await profileButton.click();
let control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
let text = await control.textContent();
const profileDiffBound = text?.includes(subjectPublicID) === true && text?.includes("当前档案 v1 → 新档案 v2") === true && text?.includes(revisedBackground) === true && text?.includes(revisedGoal) === true && text?.includes("放慢语速") === true && text?.includes("自然语速") === true && text?.includes("老照片、铁路往事") === true && text?.includes("第一次值班、工作伙伴") === true && text?.includes("强ETag") === true;
let action = control.getByRole("button", { name: "确认保存档案新版本" });
const profileNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/逐项核对从档案v1到v2的变化/).check();
const profileEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-subject-update-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const profileCancelClosed = await control.count() === 0;
await page.getByRole("button", { name: "设备管理", exact: true }).click();
const deviceCard = page.locator("article.device-card").filter({ hasText: "测试长者" }).first();
const volumeSlider = deviceCard.getByRole("slider", { name: /扬声器音量/ });
await volumeSlider.fill(String(targetVolume));
await deviceCard.getByRole("button", { name: "保存音量配置" }).click();
control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
text = await control.textContent();
const deviceDiffBound = text?.includes(devicePublicID) === true && text?.includes(subjectPublicID) === true && text?.includes(`未设置 → ${targetVolume}%`) === true && text?.includes("设备上报实际值 未上报") === true && text?.includes("期望配置 v1 → v2") === true && text?.includes("强ETag") === true && text?.includes("等待设备领取和上报") === true;
action = control.getByRole("button", { name: "确认下发期望配置" });
const deviceNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/把设备扬声器期望值从未设置调整为73%/).check();
const deviceEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-device-twin-update-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const deviceCancelClosed = await control.count() === 0;
const subjectUnchanged = psql(`SELECT profile_version || '|' || profile_data::text || '|' || conversation_preferences::text FROM subject_profile WHERE public_id='${subjectPublicID}';`) === subjectStateBefore;
const twinUnchanged = psql(`SELECT dt.desired_revision || '|' || dt.desired_config::text FROM device_twin dt JOIN device d ON d.id=dt.device_id WHERE d.public_id='${devicePublicID}';`) === twinStateBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { unchangedProfileBlocked, profileDiffBound, profileNeedsAcknowledgement, profileEnabled, profileCancelClosed, deviceDiffBound, deviceNeedsAcknowledgement, deviceEnabled, deviceCancelClosed, subjectUnchanged, twinUnchanged, mutationRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutationRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || mutationRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Family profile/device update controls visual smoke failed");
} finally {
await browser.close();
}
@@ -0,0 +1,145 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const contextID = randomUUID();
const contextPublicID = randomUUID();
const conversationID = randomUUID();
const conversationPublicID = randomUUID();
const grantID = randomUUID();
const grantPublicID = randomUUID();
const grantPurpose = `页面录音撤销夹具-${grantPublicID.slice(0, 8)}`;
const subjectID = "10000000-0000-0000-0000-000000000411";
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
function cleanup() {
psql(`BEGIN;
DELETE FROM recording_access_grant WHERE id='${grantID}'::uuid;
DELETE FROM conversation_state_history WHERE conversation_id='${conversationID}'::uuid;
DELETE FROM conversation WHERE id='${conversationID}'::uuid;
DELETE FROM context_snapshot WHERE id='${contextID}'::uuid;
COMMIT;`);
}
psql(`BEGIN;
INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary,preparation_status)
VALUES('${contextID}'::uuid,'${contextPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,'10000000-0000-0000-0000-000000000701'::uuid,'[]'::jsonb,'visual-recording-control-v1','s2s_hybrid',repeat('0',64),'浏览器录音授权撤销夹具','READY');
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason)
VALUES('${conversationID}'::uuid,'${conversationPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000201'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,'10000000-0000-0000-0000-000000000701'::uuid,'${contextID}'::uuid,'visual-recording-control-${conversationPublicID}','s2s_hybrid','transcript_and_audio','COMPLETED',now()+interval '5 minutes',now()-interval '3 minutes',now()-interval '2 minutes','user_hangup');
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,occurred_at)
VALUES('${conversationID}'::uuid,NULL,'ROOM_ISSUED','visual fixture','platform',now()-interval '3 minutes'),('${conversationID}'::uuid,'ROOM_ISSUED','ACTIVE','visual fixture','provider',now()-interval '170 seconds'),('${conversationID}'::uuid,'ACTIVE','COMPLETED','visual fixture','device',now()-interval '2 minutes');
INSERT INTO recording_access_grant(id,public_id,tenant_id,subject_id,grantee_user_id,permission,purpose,granted_by,granted_at,expires_at)
VALUES('${grantID}'::uuid,'${grantPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000102'::uuid,'DOWNLOAD','${grantPurpose}','10000000-0000-0000-0000-000000000101'::uuid,now()-interval '10 minutes',now()+interval '2 hours');
COMMIT;`);
const privacyCountBefore = Number(psql(`SELECT count(*) FROM privacy_request WHERE subject_id='10000000-0000-0000-0000-000000000401'::uuid;`));
const exportCountBefore = Number(psql(`SELECT count(*) FROM export_task WHERE subject_id='10000000-0000-0000-0000-000000000401'::uuid;`));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1050 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutations = [];
let expectedMediaRestriction = false;
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("response", (response) => {
const responseURL = new URL(response.url());
if (response.status() === 403 && /^\/api\/v1\/conversations\/[^/]+\/media$/.test(responseURL.pathname)) expectedMediaRestriction = true;
});
page.on("request", (request) => {
if (request.method() === "POST" && (/\/api\/v1\/recording-access-grants\/[^/]+\/revoke$/.test(request.url()) || /\/api\/v1\/subjects\/[^/]+\/(privacy-requests|exports)$/.test(request.url()))) mutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "通话记录", exact: true }).click();
const conversationRow = page.locator(".conversation-row").filter({ hasText: "端到端混合" }).first();
await conversationRow.waitFor();
await conversationRow.click();
const recordingBlock = page.locator(".recording-block");
await recordingBlock.waitFor();
const grantRow = page.locator(".recording-grants article").filter({ hasText: grantPurpose });
try { await grantRow.waitFor({ timeout: 10000 }); }
catch { throw new Error(`Recording grant fixture not visible. Recording block: ${await recordingBlock.innerText()}`); }
await grantRow.getByRole("button", { name: "立即撤销" }).click();
let form = page.getByRole("form", { name: "家庭业务受控操作" });
await form.waitFor();
let text = await form.textContent();
const recordingTargetBound = text?.includes(grantPublicID) === true && text?.includes("本地编辑者") === true && text?.includes("下载权限") === true && text?.includes(grantPurpose) === true && text?.includes(subjectID) === true;
let action = form.getByRole("button", { name: "确认撤销录音授权" });
const recordingNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/我确认后续不会再为该成员签发新的录音播放或下载地址/).check();
const recordingEnabled = await action.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
const recordingCancelClosed = await form.count() === 0;
await page.getByRole("button", { name: "隐私请求" }).click();
await page.getByRole("button", { name: "提交全量盘点" }).click();
form = page.getByRole("form", { name: "家庭业务受控操作" });
await form.waitFor();
text = await form.textContent();
const accessTargetBound = text?.includes(subjectID) === true && text?.includes("共8类") === true && text?.includes("长者档案、授权记录、设备绑定、通话、话轮、摘要、故事、记忆") === true && text?.includes("不包含个人正文") === true;
action = form.getByRole("button", { name: "确认提交盘点" });
const accessNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/我确认盘点范围包含当前长者的八类数据/).check();
const accessEnabled = await action.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
const accessCancelClosed = await form.count() === 0;
await page.getByRole("button", { name: "生成私有导出" }).click();
form = page.getByRole("form", { name: "家庭业务受控操作" });
await form.waitFor();
text = await form.textContent();
const exportTargetBound = text?.includes(subjectID) === true && text?.includes("共8类") === true && text?.includes("包含个人正文的私有JSON文件") === true && text?.includes("短期下载地址") === true;
action = form.getByRole("button", { name: "确认生成私有导出" });
const exportNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/我确认导出文件将包含当前长者八类数据中的私密正文/).check();
const exportEnabled = await action.isEnabled();
await form.screenshot({ path: fileURLToPath(new URL("family-privacy-export-control.png", outputDirectory)) });
await form.getByRole("button", { name: "取消" }).click();
const exportCancelClosed = await form.count() === 0;
const grantUnchanged = psql(`SELECT (revoked_at IS NULL)::text FROM recording_access_grant WHERE id='${grantID}'::uuid;`) === "true";
const privacyRequestsUnchanged = Number(psql(`SELECT count(*) FROM privacy_request WHERE subject_id='10000000-0000-0000-0000-000000000401'::uuid;`)) === privacyCountBefore;
const exportsUnchanged = Number(psql(`SELECT count(*) FROM export_task WHERE subject_id='10000000-0000-0000-0000-000000000401'::uuid;`)) === exportCountBefore;
const unexpectedConsoleErrors = consoleErrors.filter((message) => !(expectedMediaRestriction && message.includes("Failed to load resource") && message.includes("403")));
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { recordingTargetBound, recordingNeedsAcknowledgement, recordingEnabled, recordingCancelClosed, accessTargetBound, accessNeedsAcknowledgement, accessEnabled, accessCancelClosed, exportTargetBound, exportNeedsAcknowledgement, exportEnabled, exportCancelClosed, grantUnchanged, privacyRequestsUnchanged, exportsUnchanged, expectedMediaRestriction, mutations, overflow, unexpectedConsoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutations", "overflow", "unexpectedConsoleErrors", "failedRequests"].includes(key) && value !== true) || mutations.length || overflow.length || unexpectedConsoleErrors.length || failedRequests.length) throw new Error("Family recording and privacy controls visual smoke failed");
} finally {
await browser.close();
cleanup();
}
@@ -0,0 +1,132 @@
import { createHash, randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const marker = randomUUID().slice(0, 8);
const newHouseholdName = `受控新家庭-${marker}`;
const targetHouseholdName = `邀请目标家庭-${marker}`;
const targetHouseholdID = randomUUID();
const targetHouseholdPublicID = randomUUID();
const targetMemberID = randomUUID();
const targetMemberPublicID = randomUUID();
const invitationID = randomUUID();
const invitationPublicID = randomUUID();
const invitationToken = `INVITE-RELATIONSHIP-${randomUUID()}-${marker}`;
const tokenDigest = createHash("sha256").update(invitationToken).digest("hex");
const tenantPublicID = "10000000-0000-0000-0000-000000000010";
const tenantName = "合成数据一号客户组织";
const inviteeHint = `本地所有者-${marker}`;
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
psql(`
INSERT INTO household (id,public_id,tenant_id,name,owner_user_id)
VALUES ('${targetHouseholdID}','${targetHouseholdPublicID}','10000000-0000-0000-0000-000000000001','${targetHouseholdName}','10000000-0000-0000-0000-000000000102');
INSERT INTO household_member (id,public_id,tenant_id,household_id,user_id,role)
VALUES ('${targetMemberID}','${targetMemberPublicID}','10000000-0000-0000-0000-000000000001','${targetHouseholdID}','10000000-0000-0000-0000-000000000102','owner');
INSERT INTO member_invitation (id,public_id,tenant_id,household_id,invitee_hint,token_digest,role,expires_at,created_by)
VALUES ('${invitationID}','${invitationPublicID}','10000000-0000-0000-0000-000000000001','${targetHouseholdID}','${inviteeHint}','${tokenDigest}','viewer',now()+interval '2 hours','10000000-0000-0000-0000-000000000102');
`);
const householdCountBefore = Number(psql("SELECT count(*) FROM household;"));
const memberCountBefore = Number(psql("SELECT count(*) FROM household_member;"));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1050 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutationRequests = [];
const previewRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (request.method() === "POST" && pathname === "/api/v1/member-invitations/preview") previewRequests.push(`${request.method()} ${pathname}`);
if (request.method() === "POST" && (pathname === "/api/v1/households" || pathname === "/api/v1/member-invitations/accept")) mutationRequests.push(`${request.method()} ${pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
const householdDetails = page.locator("details.household-create");
await householdDetails.locator("summary").click();
const householdForm = householdDetails.locator("form");
await householdForm.getByLabel("家庭名称").fill(newHouseholdName);
await householdForm.getByRole("button", { name: "创建家庭" }).click();
let control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
let text = await control.textContent();
const createTargetBound = text?.includes(tenantName) === true && text?.includes(tenantPublicID) === true && text?.includes("本地所有者") === true && text?.includes(newHouseholdName) === true && text?.includes("不包含长者、授权、设备或通话") === true;
let action = control.getByRole("button", { name: "确认创建并成为所有者" });
const createNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/将在所选客户组织内创建独立家庭空间/).check();
const createEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-household-create-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const createCancelClosed = await control.count() === 0;
const acceptForm = page.locator("form.accept-invitation");
const tokenInput = acceptForm.getByLabel("收到家庭邀请?");
const previewButton = acceptForm.getByRole("button", { name: "预览并接受" });
await tokenInput.fill("1234567890123456789012345678901");
const shortInvitationBlocked = await previewButton.isDisabled();
await tokenInput.fill(invitationToken);
await previewButton.click();
control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
text = await control.textContent();
const invitationTargetBound = text?.includes(tenantName) === true && text?.includes(tenantPublicID) === true && text?.includes(targetHouseholdName) === true && text?.includes(targetHouseholdPublicID) === true && text?.includes(inviteeHint) === true && text?.includes("查看者") === true && text?.includes("本次预览不创建账号或成员关系") === true;
const invitationSecretMasked = text?.includes(invitationToken) === false && text?.includes(invitationToken.slice(-4)) === true;
action = control.getByRole("button", { name: "确认加入该家庭" });
const invitationNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/加入预览所示客户组织和家庭/).check();
const invitationEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-invitation-accept-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const invitationCancelClosed = await control.count() === 0;
const tokenCleared = await tokenInput.inputValue() === "";
const householdsUnchanged = Number(psql("SELECT count(*) FROM household;")) === householdCountBefore;
const membersUnchanged = Number(psql("SELECT count(*) FROM household_member;")) === memberCountBefore;
const invitationUnused = psql(`SELECT (used_at IS NULL AND revoked_at IS NULL)::text FROM member_invitation WHERE id='${invitationID}';`) === "true";
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { createTargetBound, createNeedsAcknowledgement, createEnabled, createCancelClosed, shortInvitationBlocked, invitationTargetBound, invitationSecretMasked, invitationNeedsAcknowledgement, invitationEnabled, invitationCancelClosed, tokenCleared, householdsUnchanged, membersUnchanged, invitationUnused, previewRequests, mutationRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["previewRequests", "mutationRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || previewRequests.length !== 1 || mutationRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Family relationship create controls visual smoke failed");
} finally {
await browser.close();
psql(`
DELETE FROM member_invitation WHERE id='${invitationID}';
DELETE FROM household_member WHERE id='${targetMemberID}';
DELETE FROM household WHERE id='${targetHouseholdID}';
`);
}
@@ -0,0 +1,111 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const factInternalID = randomUUID();
const factPublicID = randomUUID();
const factContent = `每周三下午喜欢听越剧(页面事实确认夹具-${factPublicID.slice(0, 8)})`;
const factSourceRef = `family-interview://visual/${factPublicID}`;
const memberID = "10000000-0000-0000-0000-000000000312";
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
function cleanup() {
psql(`DELETE FROM subject_fact WHERE id='${factInternalID}'::uuid;`);
}
psql(`INSERT INTO subject_fact(id,public_id,tenant_id,subject_id,fact_type,content,source_type,source_ref,confirmation_status,sensitivity,version,created_by,updated_by)
VALUES('${factInternalID}'::uuid,'${factPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,(SELECT id FROM subject_profile WHERE public_id='10000000-0000-0000-0000-000000000411'::uuid),'兴趣偏好','${factContent}','family_entry','${factSourceRef}','pending','sensitive',7,'10000000-0000-0000-0000-000000000101'::uuid,'10000000-0000-0000-0000-000000000101'::uuid);`);
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1050 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutations = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
if (request.method() === "PATCH" && (/\/api\/v1\/households\/[^/]+\/members\/[^/]+$/.test(request.url()) || /\/api\/v1\/subjects\/[^/]+\/facts\/[^/]+$/.test(request.url()))) mutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "家庭成员" }).click();
const memberRow = page.locator(".member-row").filter({ hasText: "本地编辑者" });
await memberRow.getByRole("button", { name: "调整角色" }).click();
let form = page.getByRole("form", { name: "家庭业务受控操作" });
await form.waitFor();
let text = await form.textContent();
const roleTargetBound = text?.includes(memberID) === true && text?.includes("当前编辑者") === true && text?.includes("记录版本 v1") === true && text?.includes("不会隐式授予录音播放、下载") === true;
let action = form.getByRole("button", { name: "确认调整角色" });
const sameRoleDisabled = await action.isDisabled();
await form.getByLabel("调整后的家庭角色").selectOption("viewer");
const roleNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/我确认把本地编辑者从编辑者调整为查看者/).check();
const viewerEnabled = await action.isEnabled();
await form.getByLabel("调整后的家庭角色").selectOption("admin");
const changedRoleNeedsNewAcknowledgement = await action.isDisabled();
text = await form.textContent();
const adminImpactVisible = text?.includes("管理员可以管理家庭成员、长者资料、普通设备配置和故事审核") === true;
await form.getByLabel(/我确认把本地编辑者从编辑者调整为管理员/).check();
const adminEnabled = await action.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
const roleCancelClosed = await form.count() === 0;
await page.getByRole("button", { name: "长者与授权" }).click();
const factRow = page.locator(".data-row").filter({ hasText: factContent });
await factRow.waitFor();
await factRow.getByRole("button", { name: "确认" }).click();
form = page.getByRole("form", { name: "家庭业务受控操作" });
await form.waitFor();
text = await form.textContent();
const factTargetBound = text?.includes(factPublicID) === true && text?.includes(factContent) === true && text?.includes(factSourceRef) === true && text?.includes("家庭录入") === true && text?.includes("敏感") === true && text?.includes("记录版本 v7") === true;
action = form.getByRole("button", { name: "确认该事实" });
const factNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/我已核对事实内容、来源和敏感级别/).check();
const factEnabled = await action.isEnabled();
await form.screenshot({ path: fileURLToPath(new URL("family-fact-confirm-control.png", outputDirectory)) });
await form.getByRole("button", { name: "取消" }).click();
const factCancelClosed = await form.count() === 0;
const memberUnchanged = psql(`SELECT role||'|'||version FROM household_member WHERE public_id='${memberID}'::uuid;`) === "editor|1";
const factUnchanged = psql(`SELECT confirmation_status||'|'||version FROM subject_fact WHERE id='${factInternalID}'::uuid;`) === "pending|7";
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { roleTargetBound, sameRoleDisabled, roleNeedsAcknowledgement, viewerEnabled, changedRoleNeedsNewAcknowledgement, adminImpactVisible, adminEnabled, roleCancelClosed, factTargetBound, factNeedsAcknowledgement, factEnabled, factCancelClosed, memberUnchanged, factUnchanged, mutations, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutations", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || mutations.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Family role and fact control visual smoke failed");
} finally {
await browser.close();
cleanup();
}
@@ -0,0 +1,161 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const contextID = randomUUID();
const contextPublicID = randomUUID();
const conversationID = randomUUID();
const conversationPublicID = randomUUID();
const segmentID = randomUUID();
const segmentPublicID = randomUUID();
const playGrantID = randomUUID();
const playGrantPublicID = randomUUID();
const downloadGrantID = randomUUID();
const downloadGrantPublicID = randomUUID();
const consentID = randomUUID();
const consentPublicID = randomUUID();
const exportID = randomUUID();
const exportPublicID = randomUUID();
const fixtureMarker = segmentPublicID.slice(0, 8);
const subjectPublicID = "10000000-0000-0000-0000-000000000411";
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
function cleanup() {
psql(`BEGIN;
DELETE FROM media_access_log WHERE media_segment_id='${segmentID}'::uuid;
DELETE FROM media_segment WHERE id='${segmentID}'::uuid;
DELETE FROM recording_access_grant WHERE id IN ('${playGrantID}'::uuid,'${downloadGrantID}'::uuid);
DELETE FROM consent_record WHERE id='${consentID}'::uuid;
DELETE FROM conversation_state_history WHERE conversation_id='${conversationID}'::uuid;
DELETE FROM conversation WHERE id='${conversationID}'::uuid;
DELETE FROM context_snapshot WHERE id='${contextID}'::uuid;
DELETE FROM export_task WHERE id='${exportID}'::uuid;
COMMIT;`);
}
psql(`BEGIN;
INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary,preparation_status)
VALUES('${contextID}'::uuid,'${contextPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,'10000000-0000-0000-0000-000000000701'::uuid,'[]'::jsonb,'visual-sensitive-address-v1','s2s_hybrid',repeat('0',64),'浏览器敏感地址受控操作夹具','READY');
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason,created_at)
VALUES('${conversationID}'::uuid,'${conversationPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000201'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,'10000000-0000-0000-0000-000000000701'::uuid,'${contextID}'::uuid,'visual-sensitive-address-${fixtureMarker}','s2s_hybrid','transcript_and_audio','COMPLETED',now()+interval '5 minutes',now()-interval '3 minutes',now()-interval '2 minutes','user_hangup',now()+interval '1 second');
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,occurred_at)
VALUES('${conversationID}'::uuid,NULL,'ROOM_ISSUED','visual fixture','platform',now()-interval '3 minutes'),('${conversationID}'::uuid,'ROOM_ISSUED','ACTIVE','visual fixture','provider',now()-interval '170 seconds'),('${conversationID}'::uuid,'ACTIVE','COMPLETED','visual fixture','device',now()-interval '2 minutes');
INSERT INTO consent_record(id,public_id,tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref,granted_at)
VALUES('${consentID}'::uuid,'${consentPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'raw_audio_storage','{}'::jsonb,'visual-sensitive-address-v1',repeat('a',64),'10000000-0000-0000-0000-000000000101'::uuid,'visual://sensitive-address',now()-interval '10 minutes');
INSERT INTO recording_access_grant(id,public_id,tenant_id,subject_id,grantee_user_id,permission,purpose,granted_by,granted_at,expires_at)
VALUES
('${playGrantID}'::uuid,'${playGrantPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,'PLAY','页面敏感地址检查-${fixtureMarker}','10000000-0000-0000-0000-000000000101'::uuid,now()-interval '10 minutes',now()+interval '2 hours'),
('${downloadGrantID}'::uuid,'${downloadGrantPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,'DOWNLOAD','页面敏感地址检查-${fixtureMarker}','10000000-0000-0000-0000-000000000101'::uuid,now()-interval '10 minutes',now()+interval '2 hours');
INSERT INTO media_segment(id,public_id,tenant_id,conversation_id,device_id,track,sequence_no,codec,content_type,duration_ms,expected_size,expected_digest,object_ref,status,verified_at,local_delete_after)
VALUES('${segmentID}'::uuid,'${segmentPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${conversationID}'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,'near',987654,'pcm_s16le','audio/wav',12300,98765,repeat('b',64),'visual/sensitive-address/${segmentPublicID}.wav','VERIFIED',now()-interval '1 minute',now()+interval '1 hour');
INSERT INTO export_task(id,public_id,tenant_id,subject_id,requested_by,scope,status,object_ref,object_size,content_digest,expires_at,attempts,idempotency_key,request_digest,completed_at,created_at,updated_at)
VALUES('${exportID}'::uuid,'${exportPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,'{"categories":["profile","consents","device_bindings","conversations","transcripts","summaries","stories","memories"]}'::jsonb,'READY','visual/sensitive-address/${exportPublicID}.json',98765,repeat('c',64),now()+interval '2 hours',1,'visual-sensitive-address-${exportPublicID}',repeat('d',64),now()-interval '1 minute',now()-interval '2 minutes',now()-interval '1 minute');
COMMIT;`);
const mediaLogBefore = Number(psql(`SELECT count(*) FROM media_access_log WHERE media_segment_id='${segmentID}'::uuid;`));
const exportCountBefore = Number(psql(`SELECT count(*) FROM export_task WHERE id='${exportID}'::uuid;`));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1050 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const sensitiveRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const requestURL = new URL(request.url());
if (request.method() === "POST" && /^\/api\/v1\/media-segments\/[^/]+\/access$/.test(requestURL.pathname)) sensitiveRequests.push(`${request.method()} ${requestURL.pathname}`);
if (request.method() === "GET" && requestURL.pathname === `/api/v1/exports/${exportPublicID}`) sensitiveRequests.push(`${request.method()} ${requestURL.pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "通话记录", exact: true }).click();
const conversationRow = page.locator(".conversation-row").first();
await conversationRow.waitFor();
await conversationRow.click();
const segmentRow = page.locator(".recording-segment").filter({ hasText: "#987654" });
await segmentRow.waitFor();
await segmentRow.getByRole("button", { name: "播放", exact: true }).click();
let form = page.getByRole("form", { name: "家庭业务受控操作" });
await form.waitFor();
let text = await form.textContent();
const playTargetBound = text?.includes(subjectPublicID) === true && text?.includes(conversationPublicID) === true && text?.includes(segmentPublicID) === true && text?.includes("近端(长者) #987654") === true && text?.includes("家庭回听") === true && text?.includes("5分钟") === true;
let action = form.getByRole("button", { name: "确认并获取播放地址" });
const playNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/本次只为固定录音分片申请5分钟播放地址/).check();
const playEnabled = await action.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
const playCancelClosed = await form.count() === 0;
await segmentRow.getByRole("button", { name: "下载", exact: true }).click();
form = page.getByRole("form", { name: "家庭业务受控操作" });
await form.waitFor();
text = await form.textContent();
const recordingDownloadTargetBound = text?.includes(subjectPublicID) === true && text?.includes(conversationPublicID) === true && text?.includes(segmentPublicID) === true && text?.includes("文件下载地址") === true && text?.includes("访问审计") === true;
action = form.getByRole("button", { name: "确认并下载录音" });
const recordingDownloadNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/本次将下载固定录音分片/).check();
const recordingDownloadEnabled = await action.isEnabled();
await form.screenshot({ path: fileURLToPath(new URL("family-recording-download-address-control.png", outputDirectory)) });
await form.getByRole("button", { name: "取消" }).click();
const recordingDownloadCancelClosed = await form.count() === 0;
await page.getByRole("button", { name: "隐私请求" }).click();
const exportRow = page.locator(".privacy-exports .data-row").filter({ hasText: "97 KB" }).first();
await exportRow.waitFor();
await exportRow.getByRole("button", { name: "获取短期下载" }).click();
form = page.getByRole("form", { name: "家庭业务受控操作" });
await form.waitFor();
text = await form.textContent();
const exportDownloadTargetBound = text?.includes(subjectPublicID) === true && text?.includes(exportPublicID) === true && text?.includes("共8类") === true && text?.includes("96.5 KB") === true && text?.includes("只有仍为可下载时才签发短期地址") === true;
action = form.getByRole("button", { name: "确认并下载私有导出" });
const exportDownloadNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/固定私有导出任务申请短期下载地址/).check();
const exportDownloadEnabled = await action.isEnabled();
await form.screenshot({ path: fileURLToPath(new URL("family-private-export-download-control.png", outputDirectory)) });
await form.getByRole("button", { name: "取消" }).click();
const exportDownloadCancelClosed = await form.count() === 0;
const mediaLogUnchanged = Number(psql(`SELECT count(*) FROM media_access_log WHERE media_segment_id='${segmentID}'::uuid;`)) === mediaLogBefore;
const exportTaskUnchanged = Number(psql(`SELECT count(*) FROM export_task WHERE id='${exportID}'::uuid;`)) === exportCountBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { playTargetBound, playNeedsAcknowledgement, playEnabled, playCancelClosed, recordingDownloadTargetBound, recordingDownloadNeedsAcknowledgement, recordingDownloadEnabled, recordingDownloadCancelClosed, exportDownloadTargetBound, exportDownloadNeedsAcknowledgement, exportDownloadEnabled, exportDownloadCancelClosed, mediaLogUnchanged, exportTaskUnchanged, sensitiveRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["sensitiveRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || sensitiveRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Family sensitive address controls visual smoke failed");
} finally {
await browser.close();
cleanup();
}
+117
View File
@@ -0,0 +1,117 @@
import { createRequire } from "node:module";
import { createHash, randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const memoryID = randomUUID();
const memoryPublicID = randomUUID();
const versionID = randomUUID();
const versionPublicID = randomUUID();
const indexID = randomUUID();
const indexPublicID = randomUUID();
const content = `浏览器记忆管理夹具 ${memoryPublicID.slice(0, 8)}`;
const digest = createHash("sha256").update(content).digest("hex");
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
function cleanup() {
psql(`BEGIN;
DELETE FROM memory_index_entry WHERE id='${indexID}'::uuid;
UPDATE curated_memory SET current_version_id=NULL WHERE id='${memoryID}'::uuid;
DELETE FROM memory_version WHERE id='${versionID}'::uuid;
DELETE FROM curated_memory WHERE id='${memoryID}'::uuid;
COMMIT;`);
}
psql(`BEGIN;
INSERT INTO curated_memory(id,public_id,tenant_id,subject_id,status,sensitivity,valid_from)
VALUES('${memoryID}'::uuid,'${memoryPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'ACTIVE','normal',now());
INSERT INTO memory_version(id,public_id,memory_id,version_no,content,source_refs,confirmed_by,change_reason,content_digest)
VALUES('${versionID}'::uuid,'${versionPublicID}'::uuid,'${memoryID}'::uuid,1,'${content}',
'[{"source":"visual-memory-management"}]'::jsonb,'10000000-0000-0000-0000-000000000101'::uuid,'浏览器页面可用性检查','${digest}');
UPDATE curated_memory SET current_version_id='${versionID}'::uuid WHERE id='${memoryID}'::uuid;
INSERT INTO memory_index_entry(id,public_id,tenant_id,subject_id,memory_id,memory_version_id,index_type,status)
VALUES('${indexID}'::uuid,'${indexPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'${memoryID}'::uuid,'${versionID}'::uuid,'pgvector','PENDING');
COMMIT;`);
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const memoryMutations = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
if (["PATCH", "DELETE"].includes(request.method()) && /\/api\/v1\/memories\//.test(request.url())) memoryMutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "故事与记忆", exact: true }).click();
const memoryRow = page.locator(".memory-row").filter({ hasText: content });
await memoryRow.waitFor();
await memoryRow.getByRole("button", { name: "管理", exact: true }).click();
const management = page.getByRole("region", { name: "记忆管理" });
await management.waitFor();
const contentEditor = management.getByLabel("修订后的记忆正文");
const editReason = management.getByLabel("修订原因");
const saveButton = management.getByRole("button", { name: "保存为新版本" });
const unchangedSaveDisabled = await saveButton.isDisabled();
await contentEditor.fill(`${content},补充页面修订内容`);
await editReason.fill("验证页面内修订表单");
const changedSaveEnabled = await saveButton.isEnabled();
await management.getByRole("button", { name: "停用记忆" }).click();
const statusButton = management.getByRole("button", { name: "确认停用" });
const emptyStatusReasonDisabled = await statusButton.isDisabled();
await management.getByLabel("停用原因").fill("验证页面内停用确认");
const statusReasonEnabled = await statusButton.isEnabled();
await management.getByRole("button", { name: "永久删除" }).click();
const deleteButton = management.getByRole("button", { name: "确认永久删除" });
await management.getByLabel("删除原因").fill("验证页面内永久删除确认");
const uncheckedDeleteDisabled = await deleteButton.isDisabled();
await management.getByLabel("我已核对来源和家庭要求,确认永久删除这条记忆。").check();
const checkedDeleteEnabled = await deleteButton.isEnabled();
await page.screenshot({ path: fileURLToPath(new URL("memory-management-confirmation.png", outputDirectory)), fullPage: true });
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { unchangedSaveDisabled, changedSaveEnabled, emptyStatusReasonDisabled, statusReasonEnabled, uncheckedDeleteDisabled, checkedDeleteEnabled, memoryMutations, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (!unchangedSaveDisabled || !changedSaveEnabled || !emptyStatusReasonDisabled || !statusReasonEnabled || !uncheckedDeleteDisabled || !checkedDeleteEnabled || memoryMutations.length || overflow.length || consoleErrors.length || failedRequests.length) {
throw new Error("Memory management visual smoke failed");
}
} finally {
await browser.close();
cleanup();
}
@@ -0,0 +1,115 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const marker = randomUUID().slice(0, 8);
const routeID = randomUUID();
const routePublicID = randomUUID();
const silenceID = randomUUID();
const silencePublicID = randomUUID();
const outboxID = randomUUID();
const outboxPublicID = randomUUID();
const testRef = randomUUID();
const receiverGroup = `visual-oncall-${marker}`;
const silenceRule = `VISUAL_SILENCE_${marker.toUpperCase()}`;
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], { cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
}
function cleanup() {
psql(`BEGIN; DELETE FROM outbox_event WHERE id='${outboxID}'::uuid; DELETE FROM alert_silence WHERE id='${silenceID}'::uuid; DELETE FROM alert_routing_policy WHERE id='${routeID}'::uuid; COMMIT;`);
}
psql(`BEGIN;
INSERT INTO alert_routing_policy(id,public_id,tenant_id,category,severity,version_no,status,receiver_group,response_minutes,escalation_after_minutes,escalation_receiver_group,runbook_ref,change_summary,created_by,row_version)
VALUES('${routeID}'::uuid,'${routePublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'COST','LOW',1,'ACTIVE','${receiverGroup}',30,60,'visual-escalation-${marker}','runbook://visual/alert-test','页面联通测试无提交验证','10000000-0000-0000-0000-000000000102'::uuid,4);
INSERT INTO alert_silence(id,public_id,tenant_id,rule_code,matcher,reason,starts_at,ends_at,created_by,row_version)
VALUES('${silenceID}'::uuid,'${silencePublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${silenceRule}','{}'::jsonb,'页面静默撤销无提交验证',now()-interval '10 minutes',now()+interval '2 hours','10000000-0000-0000-0000-000000000101'::uuid,5);
INSERT INTO outbox_event(id,public_id,tenant_id,aggregate_type,aggregate_id,event_type,payload,status,attempt_count,manual_retry_count,last_error_code,dead_at,deduplication_key,payload_hash)
VALUES('${outboxID}'::uuid,'${outboxPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'alert_delivery_test','${testRef}'::uuid,'alert.webhook_test_requested',jsonb_build_object('action','TEST','test_ref','${testRef}','tenant_id','10000000-0000-0000-0000-000000000001','routing_policy_id','${routePublicID}','category','COST','severity','LOW','receiver_group','${receiverGroup}','runbook_ref','runbook://visual/alert-test'),'dead',4,1,'WEBHOOK_CONNECT_FAILED',now(),'visual-alert-test-${marker}',repeat('a',64));
COMMIT;`);
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutations = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
if (request.method() === "POST" && /\/api\/v1\/ops\/(alert-routing-policies\/[^/]+\/delivery-tests|diagnostics\/jobs\/[^/]+\/retry|alert-silences\/[^/]+\/revoke)$/.test(request.url())) mutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(`${webURL}/?workspace=alerts`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(process.env.VISUAL_USERNAME ?? "owner");
await page.locator("#password").fill(process.env.VISUAL_PASSWORD ?? "LocalOnly123!");
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "告警监测与处置闭环" }).waitFor();
const routeRow = page.locator(".alert-ledger-grid section").filter({ hasText: "活动路由" }).locator("li").filter({ hasText: receiverGroup });
await routeRow.getByRole("button", { name: "测试通知" }).click();
let form = page.getByRole("form", { name: "告警受控操作" });
let text = await form.textContent();
const testTargetBound = text?.includes(routePublicID) === true && text?.includes(receiverGroup) === true && text?.includes("行版本 v4") === true;
let submit = form.getByRole("button", { name: "确认发送测试通知" });
const testEmptyDisabled = await submit.isDisabled();
await form.getByLabel("测试原因").fill("验证当前值班接收组和Webhook签名投递链路");
const testNeedsAcknowledgement = await submit.isDisabled();
await form.getByLabel(/我确认接收组知晓本次测试/).check();
const testEnabled = await submit.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
const retryRow = page.locator(".alert-ledger-grid section").filter({ hasText: "接收器联通测试" }).locator("li").filter({ hasText: testRef });
await retryRow.getByRole("button", { name: "受控重试" }).click();
form = page.getByRole("form", { name: "告警受控操作" });
text = await form.textContent();
const retryTargetBound = text?.includes(outboxPublicID) === true && text?.includes(testRef) === true && text?.includes("WEBHOOK_CONNECT_FAILED") === true && text?.includes("已尝试 4 次") === true;
submit = form.getByRole("button", { name: "确认受控重试" });
const retryEmptyDisabled = await submit.isDisabled();
await form.getByLabel("重试原因和修复依据").fill("接收器网络配置已修复并完成连通性复核");
const retryNeedsAcknowledgement = await submit.isDisabled();
await form.getByLabel(/我确认接收器配置或网络问题已经处理/).check();
const retryEnabled = await submit.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
const silenceRow = page.locator(".alert-ledger-grid section").filter({ hasText: "静默记录" }).locator("li").filter({ hasText: silenceRule });
await silenceRow.getByRole("button", { name: "提前撤销" }).click();
form = page.getByRole("form", { name: "告警受控操作" });
text = await form.textContent();
const silenceTargetBound = text?.includes(silencePublicID) === true && text?.includes(silenceRule) === true && text?.includes("行版本 v5") === true;
submit = form.getByRole("button", { name: "确认撤销静默" });
const silenceEmptyDisabled = await submit.isDisabled();
await form.getByLabel("提前撤销原因").fill("维护窗口已结束,恢复正常告警处置");
const silenceNeedsAcknowledgement = await submit.isDisabled();
await form.getByLabel(/我确认静默条件已经解除/).check();
const silenceEnabled = await submit.isEnabled();
await page.screenshot({ path: fileURLToPath(new URL("ops-alert-controlled-actions.png", outputDirectory)), fullPage: true });
await form.getByRole("button", { name: "取消" }).click();
const cancelClosed = await form.count() === 0;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")].filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible").slice(0, 20).map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { testTargetBound, testEmptyDisabled, testNeedsAcknowledgement, testEnabled, retryTargetBound, retryEmptyDisabled, retryNeedsAcknowledgement, retryEnabled, silenceTargetBound, silenceEmptyDisabled, silenceNeedsAcknowledgement, silenceEnabled, cancelClosed, mutations, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (!testTargetBound || !testEmptyDisabled || !testNeedsAcknowledgement || !testEnabled || !retryTargetBound || !retryEmptyDisabled || !retryNeedsAcknowledgement || !retryEnabled || !silenceTargetBound || !silenceEmptyDisabled || !silenceNeedsAcknowledgement || !silenceEnabled || !cancelClosed || mutations.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Ops alert controlled actions visual smoke failed");
} finally {
await browser.close();
cleanup();
}
+107
View File
@@ -0,0 +1,107 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const ids = [randomUUID(), randomUUID(), randomUUID()];
const publicIDs = [randomUUID(), randomUUID(), randomUUID()];
const marker = randomUUID().slice(0, 8);
const titles = [`页面接手告警夹具-${marker}`, `页面缓解告警夹具-${marker}`, `页面关闭告警夹具-${marker}`];
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], { cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
}
function cleanup() { psql(`DELETE FROM alert_incident WHERE id IN ('${ids[0]}'::uuid,'${ids[1]}'::uuid,'${ids[2]}'::uuid);`); }
psql(`INSERT INTO alert_incident(id,public_id,tenant_id,rule_code,category,severity,status,fingerprint,title,summary,first_detected_at,last_detected_at,occurrence_count,acknowledged_at,mitigated_at,row_version)
VALUES
('${ids[0]}'::uuid,'${publicIDs[0]}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'VISUAL_ACK_${marker.toUpperCase()}','TECHNICAL','HIGH','OPEN','visual-ack-${marker}','${titles[0]}','验证页面内确认接手门禁',now()-interval '10 minutes',now(),2,NULL,NULL,4),
('${ids[1]}'::uuid,'${publicIDs[1]}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'VISUAL_MIT_${marker.toUpperCase()}','BUSINESS','MEDIUM','ACKNOWLEDGED','visual-mit-${marker}','${titles[1]}','验证页面内记录缓解门禁',now()-interval '20 minutes',now(),3,now()-interval '15 minutes',NULL,5),
('${ids[2]}'::uuid,'${publicIDs[2]}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'VISUAL_RES_${marker.toUpperCase()}','SECURITY','CRITICAL','MITIGATED','visual-res-${marker}','${titles[2]}','验证页面内关闭告警门禁',now()-interval '30 minutes',now(),4,now()-interval '25 minutes',now()-interval '5 minutes',6);`);
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const transitionMutations = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
if (request.method() === "POST" && /\/api\/v1\/ops\/alerts\/[^/]+\/(acknowledge|mitigate|resolve)$/.test(request.url())) transitionMutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
async function openAlert(title) {
await page.locator(".alert-queue li").filter({ hasText: title }).getByRole("button").click();
await page.locator(".alert-detail").getByRole("heading", { name: title }).waitFor();
}
try {
await page.goto(`${webURL}/?workspace=alerts`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(process.env.VISUAL_USERNAME ?? "owner");
await page.locator("#password").fill(process.env.VISUAL_PASSWORD ?? "LocalOnly123!");
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "告警监测与处置闭环" }).waitFor();
await openAlert(titles[0]);
await page.getByRole("button", { name: "确认并接手" }).click();
let form = page.getByRole("form", { name: "告警处置动作" });
let text = await form.textContent();
const acknowledgeTargetBound = text?.includes(publicIDs[0]) === true && text?.includes("行版本 v4") === true && text?.includes("当前待确认") === true;
let submit = form.getByRole("button", { name: "确认接手" });
const acknowledgeEmptyDisabled = await submit.isDisabled();
await form.getByLabel("接手说明").fill("已按运行手册确认影响范围并接手后续处置");
const acknowledgeEnabled = await submit.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
await openAlert(titles[1]);
await page.getByRole("button", { name: "记录缓解" }).click();
form = page.getByRole("form", { name: "告警处置动作" });
text = await form.textContent();
const mitigateTargetBound = text?.includes(publicIDs[1]) === true && text?.includes("行版本 v5") === true && text?.includes("当前已确认") === true;
submit = form.getByRole("button", { name: "确认记录缓解" });
const mitigateEmptyDisabled = await submit.isDisabled();
await form.getByLabel("缓解说明").fill("已执行隔离措施,影响范围收敛并进入持续观察");
const mitigateEnabled = await submit.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
await openAlert(titles[2]);
await page.getByRole("button", { name: "验证并关闭" }).click();
form = page.getByRole("form", { name: "告警处置动作" });
text = await form.textContent();
const resolveTargetBound = text?.includes(publicIDs[2]) === true && text?.includes("行版本 v6") === true && text?.includes("当前已缓解") === true;
submit = form.getByRole("button", { name: "确认关闭告警" });
const resolveEmptyDisabled = await submit.isDisabled();
await form.getByLabel("关闭验证说明").fill("恢复指标持续正常,关联影响和遗留风险已完成复核");
const resolveNeedsAcknowledgement = await submit.isDisabled();
await form.getByLabel(/我确认缓解措施已经验证/).check();
const resolveEnabled = await submit.isEnabled();
await page.screenshot({ path: fileURLToPath(new URL("ops-alert-transition-form.png", outputDirectory)), fullPage: true });
await form.getByRole("button", { name: "取消" }).click();
const cancelClosed = await form.count() === 0;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20).map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { acknowledgeTargetBound, acknowledgeEmptyDisabled, acknowledgeEnabled, mitigateTargetBound, mitigateEmptyDisabled, mitigateEnabled, resolveTargetBound, resolveEmptyDisabled, resolveNeedsAcknowledgement, resolveEnabled, cancelClosed, transitionMutations, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (!acknowledgeTargetBound || !acknowledgeEmptyDisabled || !acknowledgeEnabled || !mitigateTargetBound || !mitigateEmptyDisabled || !mitigateEnabled || !resolveTargetBound || !resolveEmptyDisabled || !resolveNeedsAcknowledgement || !resolveEnabled || !cancelClosed || transitionMutations.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Ops alert transition visual smoke failed");
} finally {
await browser.close();
cleanup();
}
+93
View File
@@ -0,0 +1,93 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const priceBookID = randomUUID();
const priceBookPublicID = randomUUID();
const rateID = randomUUID();
const provider = `visual-${priceBookPublicID}`.slice(0, 38);
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
function cleanup() {
psql(`DELETE FROM billing_price_book WHERE id='${priceBookID}'::uuid;`);
}
psql(`BEGIN;
INSERT INTO billing_price_book(id,public_id,tenant_id,provider,currency,version_no,status,source_ref,change_summary,effective_from,created_by,row_version)
VALUES('${priceBookID}'::uuid,'${priceBookPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${provider}','CNY',1,'DRAFT','contract://visual/no-submit-v1','页面价格版本审批无提交验证',now()+interval '1 day','10000000-0000-0000-0000-000000000102'::uuid,4);
INSERT INTO billing_price_rate(id,price_book_id,usage_category,metric_code,unit,unit_price_micros)
VALUES('${rateID}'::uuid,'${priceBookID}'::uuid,'END_TO_END_VOICE','agent_minute','minute',22000);
COMMIT;`);
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const publishMutations = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
if (request.method() === "PATCH" && /\/api\/v1\/ops\/price-books\/[^/]+\/publish$/.test(request.url())) publishMutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(`${webURL}/?workspace=billing`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "用量、费用与额度" }).waitFor();
const row = page.locator(".billing-ledger li").filter({ hasText: provider });
await row.waitFor();
await row.getByRole("button", { name: "审批发布" }).click();
const form = page.getByRole("form", { name: "价格版本审批发布" });
await form.waitFor();
const formText = await form.textContent();
const targetBound = formText?.includes(priceBookPublicID) === true && formText?.includes(provider) === true && formText?.includes("行版本 v4") === true && formText?.includes("1项费率") === true;
const submit = form.getByRole("button", { name: "确认审批并发布" });
const emptyReasonDisabled = await submit.isDisabled();
await form.getByLabel("发布依据").fill("报价合同、币种、费率和明日生效时间均已复核");
const acknowledgementRequired = await submit.isDisabled();
await form.getByLabel(/我确认本人不是该版本创建人/).check();
const enabledAfterAcknowledgement = await submit.isEnabled();
await page.screenshot({ path: fileURLToPath(new URL("ops-billing-price-publish.png", outputDirectory)), fullPage: true });
await form.getByRole("button", { name: "取消" }).click();
const cancelClosed = await form.count() === 0;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { targetBound, emptyReasonDisabled, acknowledgementRequired, enabledAfterAcknowledgement, cancelClosed, publishMutations, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (!targetBound || !emptyReasonDisabled || !acknowledgementRequired || !enabledAfterAcknowledgement || !cancelClosed || publishMutations.length || overflow.length || consoleErrors.length || failedRequests.length) {
throw new Error("Ops billing price publish visual smoke failed");
}
} finally {
await browser.close();
cleanup();
}
+171
View File
@@ -0,0 +1,171 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const configID = randomUUID();
const configPublicID = randomUUID();
const configKey = `visual-evaluation-${configPublicID}`.slice(0, 90);
const activeConfigID = randomUUID();
const activeConfigPublicID = randomUUID();
const activeConfigKey = `visual-active-${activeConfigPublicID}`.slice(0, 90);
const pausedConfigID = randomUUID();
const pausedConfigPublicID = randomUUID();
const pausedConfigKey = `visual-paused-${pausedConfigPublicID}`.slice(0, 90);
const activeRolloutID = randomUUID();
const activeRolloutPublicID = randomUUID();
const pausedRolloutID = randomUUID();
const pausedRolloutPublicID = randomUUID();
const resourceID = randomUUID();
const resourcePublicID = randomUUID();
const resourceReference = `kms://visual/${resourcePublicID}`;
const resourceDisplayName = `页面资源撤销夹具-${resourcePublicID.slice(0, 8)}`;
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
function cleanup() {
psql(`BEGIN;
DELETE FROM ops_config_evaluation WHERE config_version_id='${configID}'::uuid;
DELETE FROM runtime_config_rollout WHERE id IN ('${activeRolloutID}'::uuid,'${pausedRolloutID}'::uuid);
DELETE FROM ops_resource_reference WHERE id='${resourceID}'::uuid;
DELETE FROM runtime_config_version WHERE id IN ('${configID}'::uuid,'${activeConfigID}'::uuid,'${pausedConfigID}'::uuid);
COMMIT;`);
}
psql(`BEGIN;
INSERT INTO runtime_config_version(id,public_id,tenant_id,config_key,display_name,config_kind,version_no,status,payload,secret_refs,change_summary,validation_result,created_by,row_version)
VALUES('${configID}'::uuid,'${configPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${configKey}','页面评测登记夹具','ROUTING',1,'DRAFT','{"default_engine_mode":"s2s_hybrid","routes":[{"goal":"open_conversation","engine_mode":"s2s_hybrid"}]}'::jsonb,'{}'::jsonb,'验证页面内通用离线评测登记门禁','{}'::jsonb,'10000000-0000-0000-0000-000000000101'::uuid,4);
INSERT INTO runtime_config_version(id,public_id,tenant_id,config_key,display_name,config_kind,version_no,status,payload,secret_refs,change_summary,validation_result,created_by,submitted_by,submitted_at,approved_by,approved_at,published_by,published_at,row_version)
VALUES
('${activeConfigID}'::uuid,'${activeConfigPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${activeConfigKey}','页面暂停投放夹具','ROUTING',1,'PUBLISHED','{"default_engine_mode":"s2s_hybrid"}'::jsonb,'{}'::jsonb,'验证页面内暂停投放门禁','{}'::jsonb,'10000000-0000-0000-0000-000000000101'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,now()-interval '3 hours','10000000-0000-0000-0000-000000000102'::uuid,now()-interval '2 hours','10000000-0000-0000-0000-000000000102'::uuid,now()-interval '2 hours',5),
('${pausedConfigID}'::uuid,'${pausedConfigPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${pausedConfigKey}','页面恢复投放夹具','ROUTING',1,'PUBLISHED','{"default_engine_mode":"s2s_hybrid"}'::jsonb,'{}'::jsonb,'验证页面内恢复投放门禁','{}'::jsonb,'10000000-0000-0000-0000-000000000101'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,now()-interval '3 hours','10000000-0000-0000-0000-000000000102'::uuid,now()-interval '2 hours','10000000-0000-0000-0000-000000000102'::uuid,now()-interval '2 hours',6);
INSERT INTO runtime_config_rollout(id,public_id,tenant_id,config_version_id,config_key,scope_type,rollout_percent,max_concurrent_calls,status,reason,activated_by,activated_at,paused_at,row_version)
VALUES
('${activeRolloutID}'::uuid,'${activeRolloutPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${activeConfigID}'::uuid,'${activeConfigKey}','TENANT',35,20,'ACTIVE','页面暂停投放无提交验证','10000000-0000-0000-0000-000000000102'::uuid,now()-interval '1 hour',NULL,7),
('${pausedRolloutID}'::uuid,'${pausedRolloutPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${pausedConfigID}'::uuid,'${pausedConfigKey}','TENANT',60,40,'PAUSED','页面恢复投放无提交验证','10000000-0000-0000-0000-000000000102'::uuid,now()-interval '2 hours',now()-interval '30 minutes',8);
INSERT INTO ops_resource_reference(id,public_id,tenant_id,resource_type,reference_name,display_name,status,verified_at,created_by,row_version)
VALUES('${resourceID}'::uuid,'${resourcePublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'PROVIDER_CREDENTIAL','${resourceReference}','${resourceDisplayName}','ACTIVE',now(),'10000000-0000-0000-0000-000000000101'::uuid,9);
COMMIT;`);
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const evaluationMutations = [];
const configControlMutations = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
if (request.method() === "POST" && /\/api\/v1\/ops\/config-versions\/[^/]+\/evaluations$/.test(request.url())) evaluationMutations.push(`${request.method()} ${request.url()}`);
if (request.method() === "PATCH" && /\/api\/v1\/ops\/(resource-references\/[^/]+\/revoke|config-rollouts\/[^/]+\/(pause|resume))$/.test(request.url())) configControlMutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(`${webURL}/?workspace=config`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "配置发布与版本控制" }).waitFor();
const versionGroup = page.locator(".ops-version-group").filter({ hasText: "页面评测登记夹具" });
await versionGroup.waitFor();
const versionCard = versionGroup.locator(".ops-version").first();
await versionCard.getByRole("button", { name: "登记通用离线评测" }).click();
const form = versionCard.getByRole("form", { name: "通用离线评测登记" });
await form.waitFor();
const submit = form.getByRole("button", { name: "登记评测证据" });
const targetBound = (await form.textContent())?.includes(configPublicID) === true && (await form.textContent())?.includes("行版本 v4") === true && (await form.textContent())?.includes(configKey) === true;
const emptyFieldsDisabled = await submit.isDisabled();
await form.getByLabel("评测套件版本").fill("voice-routing-regression-v3");
await form.getByLabel("评测报告引用").fill("report://visual/evaluation/no-submit");
const missingResultDisabled = await submit.isDisabled();
await form.getByLabel("通过").check();
const passedNeedsAcknowledgement = await submit.isDisabled();
const acknowledgement = form.getByLabel("我确认报告内容和指标支持“通过”结论,并理解该记录可能参与当前配置的发布门禁");
await acknowledgement.check();
const passedEnabledAfterAcknowledgement = await submit.isEnabled();
await form.getByLabel("失败").check();
const failedExplicitlyEnabled = await submit.isEnabled();
const acknowledgementRemovedForFailure = await acknowledgement.count() === 0;
await page.screenshot({ path: fileURLToPath(new URL("ops-config-evaluation-form.png", outputDirectory)), fullPage: true });
await form.getByRole("button", { name: "取消" }).click();
const cancelClosedForm = await form.count() === 0;
const resourceRow = page.locator(".ops-ledger").filter({ hasText: "资源引用状态" }).locator("li").filter({ hasText: resourceDisplayName });
await resourceRow.getByRole("button", { name: "撤销" }).click();
let controlForm = page.getByRole("form", { name: "运营配置受控操作" });
await controlForm.waitFor();
const resourceTargetBound = (await controlForm.textContent())?.includes(resourcePublicID) === true && (await controlForm.textContent())?.includes(resourceReference) === true && (await controlForm.textContent())?.includes("行版本 v9") === true;
let controlSubmit = controlForm.getByRole("button", { name: "确认撤销引用" });
const resourceEmptyDisabled = await controlSubmit.isDisabled();
await controlForm.getByLabel("撤销原因").fill("供应商凭据轮换完成,后续发布改用替代引用");
const resourceNeedsAcknowledgement = await controlSubmit.isDisabled();
await controlForm.getByLabel(/我确认后续依赖该引用的配置发布将被阻断/).check();
const resourceEnabledAfterAcknowledgement = await controlSubmit.isEnabled();
await controlForm.getByRole("button", { name: "取消" }).click();
const resourceCancelClosed = await controlForm.count() === 0;
const activeRolloutRow = page.locator(".ops-ledger").filter({ hasText: "配置投放控制" }).locator("li").filter({ hasText: activeConfigKey });
await activeRolloutRow.getByRole("button", { name: "暂停" }).click();
controlForm = page.getByRole("form", { name: "运营配置受控操作" });
await controlForm.waitFor();
const pauseTargetBound = (await controlForm.textContent())?.includes(activeRolloutPublicID) === true && (await controlForm.textContent())?.includes(activeConfigKey) === true && (await controlForm.textContent())?.includes("行版本 v7") === true;
controlSubmit = controlForm.getByRole("button", { name: "确认暂停投放" });
const pauseEmptyDisabled = await controlSubmit.isDisabled();
await controlForm.getByLabel("暂停原因和观察依据").fill("新通话质量出现异常,暂停后观察供应商恢复状态");
const pauseNeedsAcknowledgement = await controlSubmit.isDisabled();
await controlForm.getByLabel(/我确认该投放将不再被后续新建通话选中/).check();
const pauseEnabledAfterAcknowledgement = await controlSubmit.isEnabled();
await controlForm.getByRole("button", { name: "取消" }).click();
const pauseCancelClosed = await controlForm.count() === 0;
const pausedRolloutRow = page.locator(".ops-ledger").filter({ hasText: "配置投放控制" }).locator("li").filter({ hasText: pausedConfigKey });
await pausedRolloutRow.getByRole("button", { name: "恢复" }).click();
controlForm = page.getByRole("form", { name: "运营配置受控操作" });
await controlForm.waitFor();
const resumeTargetBound = (await controlForm.textContent())?.includes(pausedRolloutPublicID) === true && (await controlForm.textContent())?.includes(pausedConfigKey) === true && (await controlForm.textContent())?.includes("行版本 v8") === true;
controlSubmit = controlForm.getByRole("button", { name: "确认恢复投放" });
const resumeEmptyDisabled = await controlSubmit.isDisabled();
await controlForm.getByLabel("恢复原因和确认依据").fill("供应商恢复验证通过,已准备观察和快速回退方案");
const resumeNeedsAcknowledgement = await controlSubmit.isDisabled();
await controlForm.getByLabel(/我确认目标配置仍可发布、同范围没有其他活动投放/).check();
const resumeEnabledAfterAcknowledgement = await controlSubmit.isEnabled();
await page.screenshot({ path: fileURLToPath(new URL("ops-config-controlled-actions.png", outputDirectory)), fullPage: true });
await controlForm.getByRole("button", { name: "取消" }).click();
const resumeCancelClosed = await controlForm.count() === 0;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { targetBound, emptyFieldsDisabled, missingResultDisabled, passedNeedsAcknowledgement, passedEnabledAfterAcknowledgement, failedExplicitlyEnabled, acknowledgementRemovedForFailure, cancelClosedForm, resourceTargetBound, resourceEmptyDisabled, resourceNeedsAcknowledgement, resourceEnabledAfterAcknowledgement, resourceCancelClosed, pauseTargetBound, pauseEmptyDisabled, pauseNeedsAcknowledgement, pauseEnabledAfterAcknowledgement, pauseCancelClosed, resumeTargetBound, resumeEmptyDisabled, resumeNeedsAcknowledgement, resumeEnabledAfterAcknowledgement, resumeCancelClosed, evaluationMutations, configControlMutations, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (!targetBound || !emptyFieldsDisabled || !missingResultDisabled || !passedNeedsAcknowledgement || !passedEnabledAfterAcknowledgement || !failedExplicitlyEnabled || !acknowledgementRemovedForFailure || !cancelClosedForm || !resourceTargetBound || !resourceEmptyDisabled || !resourceNeedsAcknowledgement || !resourceEnabledAfterAcknowledgement || !resourceCancelClosed || !pauseTargetBound || !pauseEmptyDisabled || !pauseNeedsAcknowledgement || !pauseEnabledAfterAcknowledgement || !pauseCancelClosed || !resumeTargetBound || !resumeEmptyDisabled || !resumeNeedsAcknowledgement || !resumeEnabledAfterAcknowledgement || !resumeCancelClosed || evaluationMutations.length || configControlMutations.length || overflow.length || consoleErrors.length || failedRequests.length) {
throw new Error("Ops config evaluation visual smoke failed");
}
} finally {
await browser.close();
cleanup();
}
@@ -0,0 +1,135 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const marker = randomUUID();
const subjectID = "10000000-0000-0000-0000-000000000411";
const fixtures = [
{ internalID: randomUUID(), publicID: randomUUID(), key: `visual-submit-${marker}`.slice(0, 90), name: `页面提交审批夹具-${marker.slice(0, 8)}`, status: "DRAFT", rowVersion: 11 },
{ internalID: randomUUID(), publicID: randomUUID(), key: `visual-publish-${marker}`.slice(0, 90), name: `页面审批发布夹具-${marker.slice(0, 8)}`, status: "PENDING_APPROVAL", rowVersion: 12 },
{ internalID: randomUUID(), publicID: randomUUID(), key: `visual-rollback-${marker}`.slice(0, 90), name: `页面历史回滚夹具-${marker.slice(0, 8)}`, status: "RETIRED", rowVersion: 13 }
];
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
function cleanup() {
psql(`DELETE FROM runtime_config_version WHERE id IN (${fixtures.map((item) => `'${item.internalID}'::uuid`).join(",")});`);
}
psql(`BEGIN;
INSERT INTO runtime_config_version(id,public_id,tenant_id,config_key,display_name,config_kind,version_no,status,payload,secret_refs,change_summary,validation_result,created_by,row_version)
VALUES('${fixtures[0].internalID}'::uuid,'${fixtures[0].publicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${fixtures[0].key}','${fixtures[0].name}','ROUTING',1,'DRAFT','{"default_engine_mode":"s2s_hybrid"}'::jsonb,'{}'::jsonb,'确认草稿提交审批不会直接发布','{}'::jsonb,'10000000-0000-0000-0000-000000000101'::uuid,${fixtures[0].rowVersion});
INSERT INTO runtime_config_version(id,public_id,tenant_id,config_key,display_name,config_kind,version_no,status,payload,secret_refs,change_summary,validation_result,created_by,submitted_by,submitted_at,row_version)
VALUES('${fixtures[1].internalID}'::uuid,'${fixtures[1].publicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${fixtures[1].key}','${fixtures[1].name}','ROUTING',1,'PENDING_APPROVAL','{"default_engine_mode":"s2s_hybrid"}'::jsonb,'{}'::jsonb,'确认审批发布范围和业务影响','{}'::jsonb,'10000000-0000-0000-0000-000000000101'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,now()-interval '1 hour',${fixtures[1].rowVersion});
INSERT INTO runtime_config_version(id,public_id,tenant_id,config_key,display_name,config_kind,version_no,status,payload,secret_refs,change_summary,validation_result,created_by,submitted_by,submitted_at,approved_by,approved_at,published_by,published_at,retired_at,row_version)
VALUES('${fixtures[2].internalID}'::uuid,'${fixtures[2].publicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${fixtures[2].key}','${fixtures[2].name}','ROUTING',1,'RETIRED','{"default_engine_mode":"s2s_hybrid"}'::jsonb,'{}'::jsonb,'确认历史内容回滚目标和范围','{}'::jsonb,'10000000-0000-0000-0000-000000000101'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,now()-interval '4 hours','10000000-0000-0000-0000-000000000102'::uuid,now()-interval '3 hours','10000000-0000-0000-0000-000000000102'::uuid,now()-interval '3 hours',now()-interval '2 hours',${fixtures[2].rowVersion});
COMMIT;`);
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1050 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutations = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
if ((request.method() === "PATCH" && /\/api\/v1\/ops\/config-versions\/[^/]+\/(submit|publish)$/.test(request.url())) || (request.method() === "POST" && /\/api\/v1\/ops\/config-versions\/[^/]+\/rollback$/.test(request.url()))) mutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(`${webURL}/?workspace=config`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "配置发布与版本控制" }).waitFor();
const submitGroup = page.locator(".ops-version-group").filter({ hasText: fixtures[0].name });
await submitGroup.getByRole("button", { name: "提交审批" }).click();
let form = submitGroup.getByRole("form", { name: "配置版本受控流转" });
await form.waitFor();
let text = await form.textContent();
const submitTargetBound = text?.includes(fixtures[0].publicID) === true && text?.includes(`行版本 v${fixtures[0].rowVersion}`) === true && text?.includes("不会发布配置或改变现有投放") === true;
let action = form.getByRole("button", { name: "确认提交审批" });
const submitNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/我确认目标版本、变更说明和配置内容已经复核/).check();
const submitEnabled = await action.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
const submitCancelClosed = await form.count() === 0;
const publishGroup = page.locator(".ops-version-group").filter({ hasText: fixtures[1].name });
await publishGroup.getByRole("button", { name: "审批并发布" }).click();
form = publishGroup.getByRole("form", { name: "配置版本受控流转" });
await form.waitFor();
text = await form.textContent();
const publishTargetBound = text?.includes(fixtures[1].publicID) === true && text?.includes(`行版本 v${fixtures[1].rowVersion}`) === true && text?.includes("双人审批") === true;
action = form.getByRole("button", { name: "确认审批并发布" });
const publishEmptyDisabled = await action.isDisabled();
await form.getByLabel("审批发布依据").fill("离线评测通过,审批职责与观察计划已经复核");
await form.getByLabel("灰度比例(%)").fill("25");
await form.getByLabel("并发影响上限").fill("30");
const publishNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/我确认离线评测、审批职责和发布范围/).check();
const publishTenantEnabled = await action.isEnabled();
await form.getByLabel("范围类型").selectOption("SUBJECT");
const publishSubjectNeedsID = await action.isDisabled();
await form.getByLabel("试验对象公开编号").fill(subjectID);
const publishSubjectNeedsNewAcknowledgement = await action.isDisabled();
await form.getByLabel(/我确认离线评测、审批职责和发布范围/).check();
text = await form.textContent();
const publishRangeBound = text?.includes(subjectID) === true && text?.includes("100%灰度") === true && text?.includes("并发上限30") === true;
const publishSubjectEnabled = await action.isEnabled();
await form.getByRole("button", { name: "取消" }).click();
const publishCancelClosed = await form.count() === 0;
const rollbackGroup = page.locator(".ops-version-group").filter({ hasText: fixtures[2].name });
await rollbackGroup.getByRole("button", { name: "回滚到此内容" }).click();
form = rollbackGroup.getByRole("form", { name: "配置版本受控流转" });
await form.waitFor();
text = await form.textContent();
const rollbackTargetBound = text?.includes(fixtures[2].publicID) === true && text?.includes(`行版本 v${fixtures[2].rowVersion}`) === true && text?.includes("历史内容回滚") === true;
action = form.getByRole("button", { name: "确认创建回滚版本" });
const rollbackEmptyDisabled = await action.isDisabled();
await form.getByLabel("回滚依据和故障说明").fill("新版本出现语音路由异常,选择已验证历史内容恢复服务");
const rollbackNeedsAcknowledgement = await action.isDisabled();
await form.getByLabel(/我确认已核对目标历史内容和回滚范围/).check();
const rollbackEnabled = await action.isEnabled();
await form.screenshot({ path: fileURLToPath(new URL("ops-config-version-transition.png", outputDirectory)) });
await form.getByRole("button", { name: "取消" }).click();
const rollbackCancelClosed = await form.count() === 0;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const states = psql(`SELECT public_id||'|'||status||'|'||row_version FROM runtime_config_version WHERE id IN (${fixtures.map((item) => `'${item.internalID}'::uuid`).join(",")}) ORDER BY status;`).split(/\r?\n/).filter(Boolean);
const unchanged = fixtures.every((item) => states.includes(`${item.publicID}|${item.status}|${item.rowVersion}`));
const result = { submitTargetBound, submitNeedsAcknowledgement, submitEnabled, submitCancelClosed, publishTargetBound, publishEmptyDisabled, publishNeedsAcknowledgement, publishTenantEnabled, publishSubjectNeedsID, publishSubjectNeedsNewAcknowledgement, publishRangeBound, publishSubjectEnabled, publishCancelClosed, rollbackTargetBound, rollbackEmptyDisabled, rollbackNeedsAcknowledgement, rollbackEnabled, rollbackCancelClosed, unchanged, mutations, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutations", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || mutations.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Ops config version transition visual smoke failed");
} finally {
await browser.close();
cleanup();
}
@@ -0,0 +1,279 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const contextID = randomUUID();
const contextPublicID = randomUUID();
const conversationID = randomUUID();
const conversationPublicID = randomUUID();
const jobID = randomUUID();
const jobPublicID = randomUUID();
const jobAggregateID = randomUUID();
const issueOpenID = randomUUID();
const issueOpenPublicID = randomUUID();
const issueProgressID = randomUUID();
const issueProgressPublicID = randomUUID();
const issueResolvedID = randomUUID();
const issueResolvedPublicID = randomUUID();
const issueVerifiedID = randomUUID();
const issueVerifiedPublicID = randomUUID();
const issueClosedID = randomUUID();
const issueClosedPublicID = randomUUID();
const accessReviewID = randomUUID();
const accessReviewPublicID = randomUUID();
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
function cleanup() {
psql(`BEGIN;
DELETE FROM integration_issue WHERE id IN ('${issueOpenID}'::uuid,'${issueProgressID}'::uuid,'${issueResolvedID}'::uuid,'${issueVerifiedID}'::uuid,'${issueClosedID}'::uuid);
DELETE FROM outbox_event WHERE id='${jobID}'::uuid;
DELETE FROM ops_sensitive_access_request WHERE conversation_id='${conversationID}'::uuid;
DELETE FROM conversation_state_history WHERE conversation_id='${conversationID}'::uuid;
DELETE FROM conversation WHERE id='${conversationID}'::uuid;
DELETE FROM context_snapshot WHERE id='${contextID}'::uuid;
COMMIT;`);
}
psql(`BEGIN;
INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary,preparation_status)
VALUES('${contextID}'::uuid,'${contextPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,'10000000-0000-0000-0000-000000000701'::uuid,'[]'::jsonb,'visual-sensitive-request-v1','s2s_hybrid','1111111111111111111111111111111111111111111111111111111111111111','运营敏感访问申请页面夹具','READY');
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason)
VALUES('${conversationID}'::uuid,'${conversationPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000201'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,'10000000-0000-0000-0000-000000000701'::uuid,'${contextID}'::uuid,'visual-sensitive-${conversationPublicID}','s2s_hybrid','transcript_only','ROOM_ISSUED',now()+interval '5 minutes',now()-interval '2 minutes',NULL,NULL);
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,occurred_at)
VALUES('${conversationID}'::uuid,NULL,'ROOM_ISSUED','visual fixture','platform',now()-interval '2 minutes');
INSERT INTO outbox_event(id,public_id,tenant_id,aggregate_type,aggregate_id,event_type,payload,status,attempt_count,last_error_code,dead_at,payload_hash)
VALUES('${jobID}'::uuid,'${jobPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'visual_control','${jobAggregateID}'::uuid,'platform.noop','{}'::jsonb,'dead',8,'VISUAL_DEPENDENCY_FAILURE',now(),repeat('0',64));
INSERT INTO integration_issue(id,public_id,tenant_id,phenomenon,severity,device_id,conversation_id,occurred_at,request_id,responsible_party,status,resolution_result,root_cause,fixed_version,regression_conclusion,regression_evidence,created_by,updated_by,version)
VALUES
('${issueOpenID}'::uuid,'${issueOpenPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'页面流转夹具:待处理问题','MEDIUM','10000000-0000-0000-0000-000000000501'::uuid,'${conversationID}'::uuid,now()-interval '5 minutes','visual-issue-open','硬件团队','OPEN',NULL,NULL,NULL,NULL,NULL,'10000000-0000-0000-0000-000000000101'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,3),
('${issueProgressID}'::uuid,'${issueProgressPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'页面流转夹具:处理中问题','MEDIUM','10000000-0000-0000-0000-000000000501'::uuid,'${conversationID}'::uuid,now()-interval '4 minutes','visual-issue-progress','Agent研发组','IN_PROGRESS',NULL,NULL,NULL,NULL,NULL,'10000000-0000-0000-0000-000000000101'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,4),
('${issueResolvedID}'::uuid,'${issueResolvedPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'页面流转夹具:待回归问题','LOW','10000000-0000-0000-0000-000000000501'::uuid,'${conversationID}'::uuid,now()-interval '3 minutes','visual-issue-resolved','云端研发组','RESOLVED','已修复状态映射','状态映射遗漏','0.56.0-rc.1',NULL,NULL,'10000000-0000-0000-0000-000000000101'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,5),
('${issueVerifiedID}'::uuid,'${issueVerifiedPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'页面流转夹具:已验证问题','LOW','10000000-0000-0000-0000-000000000501'::uuid,'${conversationID}'::uuid,now()-interval '2 minutes','visual-issue-verified','云端研发组','VERIFIED','已修复状态映射','状态映射遗漏','0.56.0-rc.1','连续验证3次均未复现','请求 visual-regression-001','10000000-0000-0000-0000-000000000101'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,6),
('${issueClosedID}'::uuid,'${issueClosedPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'页面流转夹具:已关闭问题','LOW','10000000-0000-0000-0000-000000000501'::uuid,'${conversationID}'::uuid,now()-interval '1 minute','visual-issue-closed','云端研发组','CLOSED','已修复状态映射','状态映射遗漏','0.56.0-rc.1','连续验证3次均未复现','请求 visual-regression-002','10000000-0000-0000-0000-000000000101'::uuid,'10000000-0000-0000-0000-000000000101'::uuid,7);
INSERT INTO ops_sensitive_access_request(id,public_id,tenant_id,conversation_id,requester_user_id,capabilities,reason,requested_expires_at,status,version)
VALUES('${accessReviewID}'::uuid,'${accessReviewPublicID}'::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'${conversationID}'::uuid,'10000000-0000-0000-0000-000000000102'::uuid,ARRAY['RECORDING_PLAY','TRANSCRIPT_READ'],'页面审批夹具:核对转写时间轴和原录音片段',now()+interval '1 hour','PENDING',4);
COMMIT;`);
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const accessMutations = [];
const controlMutations = [];
const issueMutations = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
if (request.method() === "POST" && /\/api\/v1\/ops\/sensitive-access-requests(\/|$)/.test(request.url())) accessMutations.push(`${request.method()} ${request.url()}`);
if (request.method() === "POST" && /\/api\/v1\/ops\/diagnostics\/(conversations|devices|jobs|bundles)(\/|$)/.test(request.url())) controlMutations.push(`${request.method()} ${request.url()}`);
if (request.method() === "PATCH" && /\/api\/v1\/ops\/integration-issues\//.test(request.url())) issueMutations.push(`${request.method()} ${request.url()}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(`${webURL}/?workspace=diagnostics`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "设备与通话联调" }).waitFor();
await page.getByLabel("检索字段").selectOption("conversation_id");
await page.getByLabel("精确编号").fill(conversationPublicID);
await page.getByRole("button", { name: "查询", exact: true }).click();
const resultCard = page.locator(".diagnostic-card").filter({ hasText: conversationPublicID }).first();
await resultCard.waitFor();
await page.getByRole("button", { name: "导出脱敏诊断包" }).click();
const bundleForm = page.getByRole("form", { name: "脱敏诊断包导出" });
await bundleForm.waitFor();
const bundleTargetBound = (await bundleForm.textContent())?.includes(conversationPublicID) === true && (await bundleForm.textContent())?.includes("通话编号") === true;
const bundleEmptyReasonDisabled = await bundleForm.getByRole("button", { name: "生成并下载" }).isDisabled();
await bundleForm.getByLabel("导出原因").fill("用于验证当前联调记录的脱敏字段和状态关联");
const bundleCompletedEnabled = await bundleForm.getByRole("button", { name: "生成并下载" }).isEnabled();
await bundleForm.getByRole("button", { name: "取消" }).click();
const bundleCancelClosedForm = await bundleForm.count() === 0;
const controlForm = page.getByRole("form", { name: "受控联调操作" });
await resultCard.getByRole("button", { name: "刷新配置" }).click();
await controlForm.waitFor();
const refreshEmptyReasonDisabled = await controlForm.getByRole("button", { name: "刷新设备配置" }).isDisabled();
const refreshTargetBound = (await controlForm.textContent())?.includes("10000000-0000-0000-0000-000000000511") === true;
await controlForm.getByLabel("操作原因").fill("设备已在线,核对配置版本后重新触发拉取");
const refreshCompletedEnabled = await controlForm.getByRole("button", { name: "刷新设备配置" }).isEnabled();
await controlForm.getByRole("button", { name: "取消" }).click();
await resultCard.getByRole("button", { name: "状态对账" }).click();
await controlForm.waitFor();
const reconcileTargetBound = (await controlForm.textContent())?.includes(conversationPublicID) === true;
await controlForm.getByLabel("操作原因").fill("云端与供应商任务状态需要重新核对");
const reconcileCompletedEnabled = await controlForm.getByRole("button", { name: "执行状态对账" }).isEnabled();
await controlForm.getByRole("button", { name: "取消" }).click();
await resultCard.getByRole("button", { name: "安全停止" }).click();
await controlForm.waitFor();
await controlForm.getByLabel("操作原因").fill("联调异常已复现,停止当前夹具通话");
const stopBlockedBeforeAcknowledgement = await controlForm.getByRole("button", { name: "安全停止通话" }).isDisabled();
await controlForm.getByLabel("我确认停止操作只针对上方指定通话,并已核对当前状态").check();
const stopEnabledAfterAcknowledgement = await controlForm.getByRole("button", { name: "安全停止通话" }).isEnabled();
await controlForm.getByRole("button", { name: "取消" }).click();
await resultCard.getByRole("button", { name: "吊销设备" }).click();
await controlForm.waitFor();
await controlForm.getByLabel("操作原因").fill("仅验证吊销确认门禁,不执行实际吊销");
const revokeBlockedBeforeAcknowledgement = await controlForm.getByRole("button", { name: "吊销设备身份" }).isDisabled();
await controlForm.getByLabel("我确认吊销后该设备的后续签名请求会被拒绝").check();
const revokeEnabledAfterAcknowledgement = await controlForm.getByRole("button", { name: "吊销设备身份" }).isEnabled();
await page.screenshot({ path: fileURLToPath(new URL("ops-controlled-diagnostic-action.png", outputDirectory)), fullPage: true });
await controlForm.getByRole("button", { name: "取消" }).click();
const controlCancelClosedForm = await controlForm.count() === 0;
await page.getByRole("button", { name: "刷新任务" }).click();
const jobCard = page.locator(".diagnostic-card").filter({ hasText: "platform.noop" }).filter({ hasText: "visual_control" }).first();
await jobCard.waitFor();
await jobCard.getByRole("button", { name: "受控重试" }).click();
const retryForm = page.getByRole("form", { name: "死信任务受控重试" });
await retryForm.waitFor();
const retryTargetBound = (await retryForm.textContent())?.includes(jobPublicID) === true && (await retryForm.textContent())?.includes("VISUAL_DEPENDENCY_FAILURE") === true;
const retryEmptyReasonDisabled = await retryForm.getByRole("button", { name: "确认受控重试" }).isDisabled();
await retryForm.getByLabel("重试原因").fill("依赖恢复情况已复核,本次仅验证页面门禁,不提交重试");
const retryBlockedBeforeAcknowledgement = await retryForm.getByRole("button", { name: "确认受控重试" }).isDisabled();
await retryForm.getByLabel("我已确认根因或依赖问题已经处理,并理解失败后任务仍可能再次进入死信").check();
const retryEnabledAfterAcknowledgement = await retryForm.getByRole("button", { name: "确认受控重试" }).isEnabled();
await page.screenshot({ path: fileURLToPath(new URL("ops-diagnostic-retry-and-export.png", outputDirectory)), fullPage: true });
await retryForm.getByRole("button", { name: "取消" }).click();
const retryCancelClosedForm = await retryForm.count() === 0;
await page.getByLabel("问题状态筛选").selectOption("");
await page.waitForTimeout(400);
await page.getByRole("button", { name: "刷新清单" }).click();
const openIssueCard = page.locator(".integration-issue-card").filter({ hasText: "页面流转夹具:待处理问题" });
const progressIssueCard = page.locator(".integration-issue-card").filter({ hasText: "页面流转夹具:处理中问题" });
const resolvedIssueCard = page.locator(".integration-issue-card").filter({ hasText: "页面流转夹具:待回归问题" });
const verifiedIssueCard = page.locator(".integration-issue-card").filter({ hasText: "页面流转夹具:已验证问题" });
const closedIssueCard = page.locator(".integration-issue-card").filter({ hasText: "页面流转夹具:已关闭问题" });
await Promise.all([openIssueCard.waitFor(), progressIssueCard.waitFor(), resolvedIssueCard.waitFor(), verifiedIssueCard.waitFor(), closedIssueCard.waitFor()]);
const issueForm = page.getByRole("form", { name: "联调问题流转编辑" });
await openIssueCard.getByRole("button", { name: "调整级别" }).click();
await issueForm.waitFor();
const severityTargetBound = (await issueForm.textContent())?.includes(issueOpenPublicID) === true && (await issueForm.textContent())?.includes("ETag版本 3") === true;
const sameSeverityDisabled = await issueForm.getByRole("button", { name: "保存级别" }).isDisabled();
await issueForm.getByLabel("严重级别").selectOption("CRITICAL");
const changedSeverityEnabled = await issueForm.getByRole("button", { name: "保存级别" }).isEnabled();
await issueForm.getByRole("button", { name: "取消" }).click();
await openIssueCard.getByRole("button", { name: "接手处理" }).click();
await issueForm.getByLabel("接手责任方").fill("");
const takeEmptyPartyDisabled = await issueForm.getByRole("button", { name: "确认接手" }).isDisabled();
await issueForm.getByLabel("接手责任方").fill("联调值班组");
const takeCompletedEnabled = await issueForm.getByRole("button", { name: "确认接手" }).isEnabled();
await issueForm.getByRole("button", { name: "取消" }).click();
await progressIssueCard.getByRole("button", { name: "登记解决" }).click();
const resolveEmptyDisabled = await issueForm.getByRole("button", { name: "提交解决记录" }).isDisabled();
await issueForm.getByLabel("根因结论").fill("设备状态映射缺少一个稳定分支");
await issueForm.getByLabel("修复版本或不可变构建标识").fill("0.56.0-rc.2");
await issueForm.getByLabel("解决结果").fill("补齐映射并完成本地复核");
const resolveCompletedEnabled = await issueForm.getByRole("button", { name: "提交解决记录" }).isEnabled();
await issueForm.getByRole("button", { name: "取消" }).click();
await resolvedIssueCard.getByRole("button", { name: "登记回归" }).click();
const verifyEmptyDisabled = await issueForm.getByRole("button", { name: "提交回归记录" }).isDisabled();
await issueForm.getByLabel("脱敏回归证据").fill("请求 visual-regression-003,连续执行3次");
await issueForm.getByLabel("回归结论").fill("三次结果一致,原问题未再复现");
const verifyCompletedEnabled = await issueForm.getByRole("button", { name: "提交回归记录" }).isEnabled();
await issueForm.getByRole("button", { name: "取消" }).click();
await verifiedIssueCard.getByRole("button", { name: "关闭问题" }).click();
const closeBlockedBeforeAcknowledgement = await issueForm.getByRole("button", { name: "确认关闭" }).isDisabled();
await issueForm.getByLabel("我已复核根因、修复版本、解决结果及回归证据,同意关闭该问题").check();
const closeEnabledAfterAcknowledgement = await issueForm.getByRole("button", { name: "确认关闭" }).isEnabled();
await page.screenshot({ path: fileURLToPath(new URL("ops-integration-issue-workflow.png", outputDirectory)), fullPage: true });
await issueForm.getByRole("button", { name: "取消" }).click();
await closedIssueCard.getByRole("button", { name: "重新打开" }).click();
await issueForm.getByLabel("重新处理责任方").fill("");
const reopenEmptyPartyDisabled = await issueForm.getByRole("button", { name: "重新打开" }).isDisabled();
await issueForm.getByLabel("重新处理责任方").fill("回归复核组");
const reopenCompletedEnabled = await issueForm.getByRole("button", { name: "重新打开" }).isEnabled();
await issueForm.getByRole("button", { name: "取消" }).click();
const issueCancelClosedForm = await issueForm.count() === 0;
await page.getByRole("button", { name: "刷新申请" }).click();
const accessReviewCard = page.locator(".diagnostic-card").filter({ hasText: "页面审批夹具:核对转写时间轴和原录音片段" });
await accessReviewCard.waitFor();
await accessReviewCard.getByRole("button", { name: "批准" }).click();
const reviewForm = page.getByRole("form", { name: "临时敏感访问审批" });
await reviewForm.waitFor();
const approvalText = await reviewForm.textContent();
const approvalTargetBound = approvalText?.includes(accessReviewPublicID) === true && approvalText?.includes("10000000-0000-0000-0000-000000000112") === true && approvalText?.includes("读取最终字幕") === true && approvalText?.includes("播放原录音") === true && approvalText?.includes("版本 4") === true;
const approvalEmptyReasonDisabled = await reviewForm.getByRole("button", { name: "确认批准" }).isDisabled();
await reviewForm.getByLabel("批准依据").fill("已复核单次通话、最小能力、问题范围和一小时有效期");
const approvalBlockedBeforeAcknowledgement = await reviewForm.getByRole("button", { name: "确认批准" }).isDisabled();
await reviewForm.getByLabel("我确认申请人不是本人,已复核单次通话、最小能力和到期时间,批准后只有原申请人可在有效期内使用").check();
const approvalEnabledAfterAcknowledgement = await reviewForm.getByRole("button", { name: "确认批准" }).isEnabled();
await page.screenshot({ path: fileURLToPath(new URL("ops-sensitive-access-review.png", outputDirectory)), fullPage: true });
await reviewForm.getByRole("button", { name: "取消" }).click();
await accessReviewCard.getByRole("button", { name: "拒绝" }).click();
const rejectionTargetBound = (await reviewForm.textContent())?.includes(accessReviewPublicID) === true && (await reviewForm.textContent())?.includes("终止本次申请") === true;
const rejectionEmptyReasonDisabled = await reviewForm.getByRole("button", { name: "确认拒绝" }).isDisabled();
await reviewForm.getByLabel("拒绝原因").fill("申请包含原录音播放,但当前排障说明不足以支持该能力");
const rejectionCompletedEnabled = await reviewForm.getByRole("button", { name: "确认拒绝" }).isEnabled();
await reviewForm.getByRole("button", { name: "取消" }).click();
const reviewCancelClosedForm = await reviewForm.count() === 0;
await resultCard.getByRole("button", { name: "申请临时敏感访问" }).click();
const form = page.getByRole("form", { name: "临时敏感访问申请" });
await form.waitFor();
const submit = form.getByRole("button", { name: "提交审批" });
const emptyReasonDisabled = await submit.isDisabled();
const transcript = form.getByLabel("读取本次通话最终字幕");
const transcriptMandatory = await transcript.isChecked() && await transcript.isDisabled();
const recording = form.getByLabel("同时申请本次通话原录音播放");
const recordingInitiallyOptional = !(await recording.isChecked());
await form.getByLabel("排障理由").fill("核对本次转写时间轴与设备错误码是否一致");
await recording.check();
await form.getByLabel("有效期").selectOption("240");
const completedFormEnabled = await submit.isEnabled();
const fourHourSelected = await form.getByLabel("有效期").inputValue() === "240";
await page.screenshot({ path: fileURLToPath(new URL("ops-sensitive-access-request.png", outputDirectory)), fullPage: true });
await form.getByRole("button", { name: "取消" }).click();
const cancelClosedForm = await form.count() === 0;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { bundleTargetBound, bundleEmptyReasonDisabled, bundleCompletedEnabled, bundleCancelClosedForm, refreshEmptyReasonDisabled, refreshTargetBound, refreshCompletedEnabled, reconcileTargetBound, reconcileCompletedEnabled, stopBlockedBeforeAcknowledgement, stopEnabledAfterAcknowledgement, revokeBlockedBeforeAcknowledgement, revokeEnabledAfterAcknowledgement, controlCancelClosedForm, retryTargetBound, retryEmptyReasonDisabled, retryBlockedBeforeAcknowledgement, retryEnabledAfterAcknowledgement, retryCancelClosedForm, severityTargetBound, sameSeverityDisabled, changedSeverityEnabled, takeEmptyPartyDisabled, takeCompletedEnabled, resolveEmptyDisabled, resolveCompletedEnabled, verifyEmptyDisabled, verifyCompletedEnabled, closeBlockedBeforeAcknowledgement, closeEnabledAfterAcknowledgement, reopenEmptyPartyDisabled, reopenCompletedEnabled, issueCancelClosedForm, approvalTargetBound, approvalEmptyReasonDisabled, approvalBlockedBeforeAcknowledgement, approvalEnabledAfterAcknowledgement, rejectionTargetBound, rejectionEmptyReasonDisabled, rejectionCompletedEnabled, reviewCancelClosedForm, emptyReasonDisabled, transcriptMandatory, recordingInitiallyOptional, completedFormEnabled, fourHourSelected, cancelClosedForm, controlMutations, issueMutations, accessMutations, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (!bundleTargetBound || !bundleEmptyReasonDisabled || !bundleCompletedEnabled || !bundleCancelClosedForm || !refreshEmptyReasonDisabled || !refreshTargetBound || !refreshCompletedEnabled || !reconcileTargetBound || !reconcileCompletedEnabled || !stopBlockedBeforeAcknowledgement || !stopEnabledAfterAcknowledgement || !revokeBlockedBeforeAcknowledgement || !revokeEnabledAfterAcknowledgement || !controlCancelClosedForm || !retryTargetBound || !retryEmptyReasonDisabled || !retryBlockedBeforeAcknowledgement || !retryEnabledAfterAcknowledgement || !retryCancelClosedForm || !severityTargetBound || !sameSeverityDisabled || !changedSeverityEnabled || !takeEmptyPartyDisabled || !takeCompletedEnabled || !resolveEmptyDisabled || !resolveCompletedEnabled || !verifyEmptyDisabled || !verifyCompletedEnabled || !closeBlockedBeforeAcknowledgement || !closeEnabledAfterAcknowledgement || !reopenEmptyPartyDisabled || !reopenCompletedEnabled || !issueCancelClosedForm || !approvalTargetBound || !approvalEmptyReasonDisabled || !approvalBlockedBeforeAcknowledgement || !approvalEnabledAfterAcknowledgement || !rejectionTargetBound || !rejectionEmptyReasonDisabled || !rejectionCompletedEnabled || !reviewCancelClosedForm || !emptyReasonDisabled || !transcriptMandatory || !recordingInitiallyOptional || !completedFormEnabled || !fourHourSelected || !cancelClosedForm || controlMutations.length || issueMutations.length || accessMutations.length || overflow.length || consoleErrors.length || failedRequests.length) {
throw new Error("Ops sensitive access request visual smoke failed");
}
} finally {
await browser.close();
cleanup();
}
+89
View File
@@ -0,0 +1,89 @@
import { createRequire } from "node:module";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
await mkdir(outputDirectory, { recursive: true });
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe",
});
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.screenshot({ path: fileURLToPath(new URL("workspace-desktop.png", outputDirectory)), fullPage: true });
await page.getByRole("button", { name: "隐私请求", exact: true }).click();
await page.getByRole("button", { name: "永久删除主体", exact: true }).click();
const deleteForm = page.getByRole("form", { name: "永久删除主体确认" });
await deleteForm.waitFor();
const selectedSubjectName = (await page.locator(".privacy-toolbar select option:checked").textContent())?.trim() ?? "";
await deleteForm.getByLabel("删除原因").fill("核对页面内不可逆操作的双重确认流程");
await deleteForm.getByLabel(/^输入长者称呼/).fill("错误称呼");
const mismatchVisible = await deleteForm.getByRole("alert").isVisible();
const deleteSubmit = deleteForm.getByRole("button", { name: "确认永久删除" });
const mismatchSubmitDisabled = await deleteSubmit.isDisabled();
await deleteForm.getByLabel(/^输入长者称呼/).fill(selectedSubjectName);
const exactMatchSubmitEnabled = await deleteSubmit.isEnabled();
await page.screenshot({ path: fileURLToPath(new URL("privacy-delete-confirmation.png", outputDirectory)), fullPage: true });
await deleteForm.getByRole("button", { name: "取消" }).click();
const deleteFormClosed = await deleteForm.count() === 0;
await page.getByRole("button", { name: "固件发布" }).click();
await page.getByRole("heading", { name: "固件版本" }).waitFor();
await page.screenshot({ path: fileURLToPath(new URL("firmware-desktop.png", outputDirectory)), fullPage: true });
const desktopOverflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
await page.setViewportSize({ width: 1024, height: 900 });
await page.screenshot({ path: fileURLToPath(new URL("firmware-tablet.png", outputDirectory)), fullPage: true });
const tabletOverflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = {
title: await page.title(),
url: page.url(),
desktopOverflow,
tabletOverflow,
consoleErrors,
failedRequests,
firmwareTabVisible: await page.getByText("上传并校验固件制品", { exact: true }).isVisible(),
privacyDeleteConfirmation: { mismatchVisible, mismatchSubmitDisabled, exactMatchSubmitEnabled, deleteFormClosed },
};
console.log(JSON.stringify(result, null, 2));
if (desktopOverflow.length || tabletOverflow.length || consoleErrors.length || failedRequests.length || !result.firmwareTabVisible || !mismatchVisible || !mismatchSubmitDisabled || !exactMatchSubmitEnabled || !deleteFormClosed) {
throw new Error("Visual smoke detected an overflow, browser error, failed request, or missing firmware workspace");
}
} finally {
await browser.close();
}
+82
View File
@@ -0,0 +1,82 @@
import { createRequire } from "node:module";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
await mkdir(outputDirectory, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const rateLimitedResponses = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("response", (response) => { if (response.status() === 429) rateLimitedResponses.push(response.url()); });
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
if (requestURL.pathname.startsWith("/api/v1/") && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: /运行配置/ }).click();
await page.getByRole("heading", { name: "配置发布与版本控制" }).waitFor();
await page.waitForTimeout(400);
const configURLSelected = new URL(page.url()).searchParams.get("workspace") === "config";
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("heading", { name: "配置发布与版本控制" }).waitFor({ timeout: 15000 });
await page.waitForTimeout(400);
const configRestoredAfterReload = new URL(page.url()).searchParams.get("workspace") === "config";
await page.getByRole("button", { name: /费用额度/ }).click();
await page.getByRole("heading", { name: "用量、费用与额度" }).waitFor();
await page.waitForTimeout(400);
const billingURLSelected = new URL(page.url()).searchParams.get("workspace") === "billing";
await page.goBack({ waitUntil: "domcontentloaded" });
await page.getByRole("heading", { name: "配置发布与版本控制" }).waitFor();
await page.waitForTimeout(400);
const backRestoredConfig = new URL(page.url()).searchParams.get("workspace") === "config";
await page.goForward({ waitUntil: "domcontentloaded" });
await page.getByRole("heading", { name: "用量、费用与额度" }).waitFor();
await page.waitForTimeout(400);
const forwardRestoredBilling = new URL(page.url()).searchParams.get("workspace") === "billing";
await page.goto(`${webURL}/?workspace=unknown`, { waitUntil: "domcontentloaded" });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.waitForTimeout(400);
const unknownNormalizedToFamily = new URL(page.url()).searchParams.get("workspace") === null;
await page.goto(`${webURL}/?workspace=alerts`, { waitUntil: "domcontentloaded" });
await page.getByRole("heading", { name: "告警监测与处置闭环" }).waitFor();
const directAlertLinkRestored = new URL(page.url()).searchParams.get("workspace") === "alerts";
await page.screenshot({ path: fileURLToPath(new URL("workspace-navigation-deep-link.png", outputDirectory)), fullPage: true });
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { configURLSelected, configRestoredAfterReload, billingURLSelected, backRestoredConfig, forwardRestoredBilling, unknownNormalizedToFamily, directAlertLinkRestored, rateLimitedResponses, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (!configURLSelected || !configRestoredAfterReload || !billingURLSelected || !backRestoredConfig || !forwardRestoredBilling || !unknownNormalizedToFamily || !directAlertLinkRestored || rateLimitedResponses.length || overflow.length || consoleErrors.length || failedRequests.length) {
throw new Error("Workspace navigation visual smoke failed");
}
} finally {
await browser.close();
}