Files
story/test/e2e/visual-ops-config-version-transition.mjs
T

136 lines
11 KiB
JavaScript
Raw Normal View History

2026-08-26 06:41:07 +08:00
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();
}