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(); }