86 lines
4.9 KiB
JavaScript
86 lines
4.9 KiB
JavaScript
const path = require("path");
|
|||
|
|
const fs = require("fs");
|
||
|
|
const { execFileSync } = require("child_process");
|
||
|
|
const { randomUUID } = require("crypto");
|
||
|
|
|
||
|
|
const dependencyRoot = process.env.PLAYWRIGHT_REQUIRE_FROM;
|
||
|
|
if (!dependencyRoot) throw new Error("PLAYWRIGHT_REQUIRE_FROM is required");
|
||
|
|
const { chromium } = require(path.join(dependencyRoot, "playwright"));
|
||
|
|
const chromePath = process.env.CHROME_PATH || "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
||
|
|
const outputDir = path.resolve(process.env.UI_SCREENSHOT_DIR || "test/e2e/screenshots/operations");
|
||
|
|
const marker = `sse_${randomUUID().replaceAll("-", "").slice(0, 16)}`;
|
||
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
||
|
|
|
||
|
|
function psql(sql) {
|
||
|
|
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-U", "voice", "-d", "voice_platform", "-v", "ON_ERROR_STOP=1", "-q", "-c", sql], {
|
||
|
|
cwd: path.resolve(__dirname, ".."), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function insertEvent() {
|
||
|
|
psql(`INSERT INTO web_event(tenant_id,household_id,subject_id,event_type,resource_type,resource_public_id,status)
|
||
|
|
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','10000000-0000-0000-0000-000000000401',
|
||
|
|
'device.presence.changed','device',gen_random_uuid(),'${marker}')`);
|
||
|
|
}
|
||
|
|
|
||
|
|
(async () => {
|
||
|
|
const browser = await chromium.launch({ executablePath: chromePath, headless: true });
|
||
|
|
try {
|
||
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||
|
|
const failures = [];
|
||
|
|
const routeCounts = new Map();
|
||
|
|
let countResponses = false;
|
||
|
|
page.on("pageerror", (error) => failures.push(`pageerror: ${error.message}`));
|
||
|
|
page.on("console", (message) => { if (message.type() === "error") failures.push(`console: ${message.text()}`); });
|
||
|
|
page.on("response", (response) => {
|
||
|
|
if (!countResponses) return;
|
||
|
|
const url = new URL(response.url());
|
||
|
|
if (!url.pathname.startsWith("/api/v1/") || url.pathname === "/api/v1/events") return;
|
||
|
|
routeCounts.set(url.pathname, (routeCounts.get(url.pathname) || 0) + 1);
|
||
|
|
if (response.status() === 429) failures.push(`rate limited: ${url.pathname}`);
|
||
|
|
else if (response.status() >= 400) failures.push(`http ${response.status()}: ${url.pathname}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto("http://localhost:3100", { waitUntil: "networkidle" });
|
||
|
|
const login = page.getByRole("button", { name: "登录研发环境" });
|
||
|
|
if (await login.isVisible()) {
|
||
|
|
await login.click();
|
||
|
|
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 });
|
||
|
|
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor({ state: "visible", timeout: 15000 });
|
||
|
|
await page.getByText("状态实时更新", { exact: true }).waitFor({ state: "visible", timeout: 15000 });
|
||
|
|
|
||
|
|
// Let the login/navigation burst leave the local user token bucket before
|
||
|
|
// measuring only the refreshes caused by a sustained event stream.
|
||
|
|
await page.waitForTimeout(6000);
|
||
|
|
countResponses = true;
|
||
|
|
for (let index = 0; index < 8; index += 1) {
|
||
|
|
insertEvent();
|
||
|
|
await page.waitForTimeout(650);
|
||
|
|
}
|
||
|
|
await page.waitForTimeout(2500);
|
||
|
|
countResponses = false;
|
||
|
|
|
||
|
|
const factRefreshes = [...routeCounts.entries()].filter(([route]) => route.endsWith("/facts")).reduce((sum, [, count]) => sum + count, 0);
|
||
|
|
const totalBusinessResponses = [...routeCounts.values()].reduce((sum, count) => sum + count, 0);
|
||
|
|
if (factRefreshes < 2) failures.push(`event refresh did not run enough times to verify coalescing: ${factRefreshes}`);
|
||
|
|
// The first event may refresh immediately; subsequent refreshes are capped
|
||
|
|
// at one per two seconds. The measured window includes eight Docker/psql
|
||
|
|
// inserts and therefore permits at most five batches.
|
||
|
|
if (factRefreshes > 5) failures.push(`event refresh was not coalesced: facts loaded ${factRefreshes} times`);
|
||
|
|
if (totalBusinessResponses > 90) failures.push(`event stream amplified into ${totalBusinessResponses} business requests`);
|
||
|
|
const eventStatus = page.locator(".event-stream-status");
|
||
|
|
const title = await eventStatus.getAttribute("title");
|
||
|
|
if (!title || title === "等待状态事件") failures.push("the browser did not observe the injected events");
|
||
|
|
await page.screenshot({ path: path.join(outputDir, "sse-refresh-coalescing.png"), fullPage: true });
|
||
|
|
if (failures.length) throw new Error(failures.join("\n"));
|
||
|
|
process.stdout.write(`SSE refresh coalescing passed: 8 events -> ${factRefreshes} refresh batches, ${totalBusinessResponses} business responses.\n`);
|
||
|
|
} finally {
|
||
|
|
try { psql(`DELETE FROM web_event WHERE status='${marker}'`); } catch (error) { process.stderr.write(`cleanup failed: ${error.message}\n`); }
|
||
|
|
await browser.close();
|
||
|
|
}
|
||
|
|
})().catch((error) => { process.stderr.write(`${error.stack || error}\n`); process.exitCode = 1; });
|