/* HEL-655 E 批 browser validation against the isolated stack: * vite dev (5173) → backend (8247) → fake ComfyUI (18188). * Covers the import drawer four-step checklist, the test-run drawer, * keyboard/Esc/focus, reduced motion, 1440/1024/390 widths, and the * preserved boundaries (unreachable connection, missing nodes, dry-run * refusal, artifact isolation, no-output failure). * Prints PASS/FAIL per check; screenshots land in e2e/shots/hel655-*.png. */ import { chromium } from "playwright"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = path.dirname(fileURLToPath(import.meta.url)); const SHOTS = path.join(ROOT, "shots"); fs.mkdirSync(SHOTS, { recursive: true }); const API = "http://127.0.0.1:8247"; const MODE_FILE = path.join(ROOT, "fake_mode.json"); const setMode = (m) => fs.writeFileSync(MODE_FILE, JSON.stringify(m)); const baseMode = () => ({ mode: "ok", delay_sec: 0 }); let failures = 0; function ok(name, cond, extra = "") { const line = `${cond ? "PASS" : "FAIL"} ${name}${extra ? " — " + extra : ""}`; console.log(line); if (!cond) failures++; } const api = async (p, init) => { const r = await fetch(API + p, { headers: { "Content-Type": "application/json" }, ...init, }); const body = await r.text(); try { return JSON.parse(body); } catch { return { raw: body, status: r.status }; } }; /* ---------------- workflow fixtures ---------------- */ const WF_NAME = `E批·文生图回归 ${Date.now() % 10000}`; const T2I = { 1: { class_type: "CLIPTextEncode", inputs: { text: "默认提示词" }, _meta: { title: "(Input:prompt) 正向提示词" }, }, 9: { class_type: "SaveImage", inputs: { images: "x" }, _meta: { title: "(Output:image) 图片输出" }, }, }; /** Self-consistent /object_info catalogue for a workflow (backend parity: * every class present, every string widget a combo listing its value). */ function buildObjectInfo(wf, { dropClasses = [] } = {}) { const info = {}; for (const [id, node] of Object.entries(wf)) { const cls = node.class_type; if (!cls || dropClasses.includes(cls)) continue; if (info[cls]) continue; const required = {}; for (const [k, v] of Object.entries(node.inputs ?? {})) { if (typeof v === "string") required[k] = [[v], {}]; else if (typeof v === "number") required[k] = Number.isInteger(v) ? ["INT", { min: 0, max: 2 ** 31 }] : ["FLOAT", { min: 0, max: 1e6 }]; } info[cls] = { input: { required }, output: ["IMAGE"] }; } return info; } /* ---------------- stack prep ---------------- */ const seeded = (async () => { // one fake endpoint + one imported t2i workflow bound to it for (const e of (await api("/api/comfyui/endpoints")).endpoints ?? []) { if (e.base_url === "http://127.0.0.1:18188") await api(`/api/comfyui/endpoints/${e.id}`, { method: "DELETE" }); } let r = await api("/api/comfyui/endpoints", { method: "POST", body: JSON.stringify({ name: "假 ComfyUI", base_url: "http://127.0.0.1:18188", auth_type: "none", }), }); const ep = r.endpoints.find((e) => e.base_url === "http://127.0.0.1:18188"); await api(`/api/comfyui/endpoints/${ep.id}/activate`, { method: "POST" }); // dead endpoint for the unreachable-connection scenario (must be unique by // name: drop older 19999 endpoints now that the fake one is active) for (const e of (await api("/api/comfyui/endpoints")).endpoints ?? []) { if (e.base_url === "http://127.0.0.1:19999") await api(`/api/comfyui/endpoints/${e.id}`, { method: "DELETE" }); } await api("/api/comfyui/endpoints", { method: "POST", body: JSON.stringify({ name: "断开的连接", base_url: "http://127.0.0.1:19999", auth_type: "none", }), }); return { endpointId: ep.id }; })(); /* The connections page keeps its own add-connection side form open when the * viewport shrinks below 1439 — close it so it doesn't cover the library. */ async function closeSideFormIfOpen(page) { const close = page.locator(".form-col.open .form-head .icon-btn"); if ((await page.locator(".form-col.open").count()) === 0) return; if ((await close.count()) > 0) { await close.first().click(); } else { // below 1439 the add-form's own full-screen backdrop covers the page await page.mouse.click(20, 400); } await page.waitForTimeout(250); } const run = async () => { const { endpointId } = await seeded; const browser = await chromium.launch(); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, }); const page = await ctx.newPage(); page.on("pageerror", (e) => console.log("PAGEERROR:", e.message)); await page.goto("http://127.0.0.1:5173/connections"); await page.waitForSelector(".wf-panel", { timeout: 15000 }); // ---------- 1. 导入入口统一:按钮打开右侧抽屉 ---------- await page .getByRole("button", { name: /导入工作流/ }) .first() .click(); await page.waitForSelector(".drawer-panel"); const panel = page.locator(".drawer-panel"); ok( "导入抽屉从右侧滑入(挂载即向左滑动)", await panel.evaluate((el) => { const name = getComputedStyle(el).animationName; return name.includes("drawer-slide") || name === "none"; }), ); await page.waitForTimeout(360); // let the 280ms drawer settle const box = await panel.boundingBox(); ok("抽屉宽度 380px", Math.round(box.width) === 380, `w=${box.width}`); ok("抽屉贴右缘", Math.abs(box.x + box.width - 1440) < 2, `x=${box.x}`); await page.screenshot({ path: `${SHOTS}/hel655-01-import-input-1440.png` }); // ---------- 2. Step1:无效 JSON 的中文结论 ---------- await page .locator(".drawer-panel textarea.a-textarea") .fill('{"不是":"API 格式"}'); ok( "无效 JSON 显示中文错误", await page .locator(".drawer-panel .field-error") .textContent() .then((s) => s.includes("API 格式")), ); ok( "无效 JSON 时「分析」禁用", await page.locator(".drawer-panel .a-btn.primary").isDisabled(), ); await page .locator(".drawer-panel textarea.a-textarea") .fill(JSON.stringify(T2I)); await page.locator(".drawer-panel input.a-input").fill(WF_NAME); await page .locator(".drawer-panel select.a-select") .selectOption({ label: "假 ComfyUI" }); // ---------- 3. 四步检查 · 全绿 ---------- setMode({ ...baseMode(), object_info: buildObjectInfo(T2I) }); await page.locator(".drawer-panel .a-btn.primary").click(); await page.waitForSelector(".drawer-panel .checks .check"); const t0 = Date.now(); await page.waitForFunction( () => document.querySelectorAll(".drawer-panel .checks .check").length === 4, ); const spread = Date.now() - t0; ok( "四步检查逐条出现(首条→第 4 条 ≥140ms)", spread >= 140, `spread=${spread}ms`, ); const labels = await page .locator(".drawer-panel .checks .check-label") .allTextContents(); ok( "四步顺序:解析 JSON→识别节点→核对模型→建立输入槽", labels[0].includes("解析") && labels[1].includes("识别") && labels[2].includes("核对") && labels[3].includes("输入槽"), labels.join("|"), ); const states = await page .locator(".drawer-panel .checks .check") .evaluateAll((els) => els.map((el) => ({ ok: el.className.includes("state-ok"), color: getComputedStyle(el.querySelector(".mark")).color, })), ); ok( "① 解析 JSON 亮灰 ✓", states[0].ok && states[0].color === "rgb(221, 221, 215)", states[0].color, ); ok( "② 识别节点判定文生图(绿)", states[1].ok, await page .locator(".drawer-panel .checks .check") .nth(1) .locator(".check-line") .textContent(), ); ok( "③ 核对模型全亮灰(假 object_info 装齐)", states[2].ok && states[2].color === "rgb(221, 221, 215)", states[2].color, ); ok("④ 建立输入槽(1 参数 0 媒体)", states[3].ok); await page.screenshot({ path: `${SHOTS}/hel655-02-checks-allgreen-1440.png`, }); // ---------- 4. 缺节点:③ 红 + 可展开错误 + 重试恢复 ---------- setMode({ ...baseMode(), object_info: buildObjectInfo(T2I, { dropClasses: ["SaveImage"] }), }); await page .locator(".drawer-panel .checks .check") .nth(2) .locator(".recheck") .click(); await page.waitForFunction(() => { const el = document.querySelectorAll(".drawer-panel .checks .check")[2]; return el && el.className.includes("state-fail"); }); const failLine = await page .locator(".drawer-panel .checks .check") .nth(2) .locator(".check-line") .textContent(); ok("③ 缺节点显示中文问题数", /1/.test(failLine), failLine); const finding = await page .locator(".drawer-panel .findings li .finding-msg") .first() .textContent(); ok( "错误详情可展开且为中文原因", finding.includes("缺少自定义节点"), finding.slice(0, 40), ); ok( "给出下一步建议", ( await page .locator(".drawer-panel .checks .check .suggest") .allTextContents() ).some((s) => s.includes("重新检查")), ); ok( "缺节点不阻断保存(沿用既有边界)", !(await page.locator(".drawer-panel .a-btn.primary").last().isDisabled()), ); await page.screenshot({ path: `${SHOTS}/hel655-03-missing-node-1440.png` }); setMode({ ...baseMode(), object_info: buildObjectInfo(T2I) }); await page .locator(".drawer-panel .checks .check") .nth(2) .locator(".recheck") .click(); await page.waitForFunction(() => { const el = document.querySelectorAll(".drawer-panel .checks .check")[2]; return el && el.className.includes("state-ok"); }); ok("「重新检查」复查后恢复绿", true); // ---------- 5. 断开的连接:琥珀「无法核查」,不伪装通过 ---------- await page.locator(".drawer-panel .a-btn.secondary").first().click(); // 上一步 await page .locator(".drawer-panel select.a-select") .selectOption({ label: "断开的连接" }); await page.locator(".drawer-panel .a-btn.primary").click(); // 重新分析 await page.waitForFunction( () => document.querySelectorAll(".drawer-panel .checks .check").length === 4, ); await page.waitForFunction(() => { const el = document.querySelectorAll(".drawer-panel .checks .check")[2]; return el && el.className.includes("state-warn"); }); const warnColor = await page .locator(".drawer-panel .checks .check") .nth(2) .locator(".mark") .evaluate((el) => getComputedStyle(el).color); ok( "无法核查 = 琥珀(非绿非红)", warnColor === "rgb(163, 145, 95)", warnColor, ); ok( "无法核查给中文原因与建议", ( await page .locator(".drawer-panel .checks .check") .nth(2) .locator(".check-line") .textContent() ).includes("无法核查"), ); await page.screenshot({ path: `${SHOTS}/hel655-04-unreachable-1440.png` }); // ---------- 6. 保存导入(回到可用连接) ---------- await page.locator(".drawer-panel .a-btn.secondary").first().click(); // 上一步 await page .locator(".drawer-panel select.a-select") .selectOption({ label: "假 ComfyUI" }); await page.locator(".drawer-panel .a-btn.primary").click(); // 分析 await page.waitForFunction( () => document.querySelectorAll(".drawer-panel .checks .check").length === 4, ); await page.locator(".drawer-panel .a-btn.primary").last().click(); // 确认保存 await page.waitForSelector(".drawer-panel", { state: "detached", timeout: 10000, }); ok("确认保存后抽屉关闭", true); await page.waitForFunction( (n) => document.body.textContent.includes(n), WF_NAME, ); ok("工作流入库名称正确", true); // ---------- 7. 试运行抽屉:全链路(进行中→完成→隔离) ---------- const wfId = (await api("/api/workflows")).find( (w) => w.name === WF_NAME, )?.id; ok("已导入工作流可查询", !!wfId); const row = page.locator(".wf-row", { hasText: WF_NAME }); await row.locator(".wf-test-btn").first().click(); await page.waitForSelector(".drawer-panel"); const tbox = await page.locator(".drawer-panel").boundingBox(); ok( "试运行抽屉 420px 右侧", Math.round(tbox.width) === 420, `w=${tbox.width}`, ); await page.screenshot({ path: `${SHOTS}/hel655-05-test-form-1440.png` }); // dry_run 拒绝(边界保留) await api("/api/settings", { method: "POST", body: JSON.stringify({ dry_run: true }), }); await page.locator(".drawer-panel .a-btn.primary").click(); await page.locator(".modal-panel .modal-footer .a-btn.primary").click(); await page.waitForFunction(() => { const els = [...document.querySelectorAll(".drawer-panel .field-error")]; return els.some((e) => e.textContent.includes("Dry-run")); }); ok("dry_run 下试运行被拒绝(中文原因)", true); await api("/api/settings", { method: "POST", body: JSON.stringify({ dry_run: false }), }); // 真实试运行:进行中(青进度 + 阶段日志)→ 通过(绿 + 缩略图横排) setMode({ mode: "ok", delay_sec: 3, object_info: buildObjectInfo(T2I) }); await page.locator(".drawer-panel .a-btn.primary").click(); await page.locator(".modal-panel .modal-footer .a-btn.primary").click(); await page.waitForSelector(".drawer-panel .wft-active", { timeout: 8000 }); await page.waitForSelector(".drawer-panel .wft-active .chip.run", { timeout: 8000, }); const chipColor = await page .locator(".drawer-panel .wft-active .chip.run") .evaluate((el) => getComputedStyle(el).color); ok("进行中状态为运行灰", chipColor === "rgb(184, 182, 172)", chipColor); ok( "进行中显示进度条", (await page.locator(".drawer-panel .wft-active .track").count()) === 1, ); await page.waitForSelector(".drawer-panel .wft-log p", { timeout: 6000 }); const logText = await page.locator(".drawer-panel .wft-log").textContent(); ok( "阶段日志为中文滚动区", /队列|排队|连接|运行|保存|执行|ComfyUI/.test(logText), logText.slice(0, 30), ); ok( "阶段日志为 mono 字体", await page .locator(".drawer-panel .wft-log") .evaluate((el) => getComputedStyle(el).fontFamily.includes("Mono")), ); await page.screenshot({ path: `${SHOTS}/hel655-06-test-running-1440.png` }); await page.waitForSelector(".drawer-panel .wft-result.ok", { timeout: 30000, }); const verdict = await page .locator(".drawer-panel .wft-verdict") .textContent(); ok("只有真实输出才算通过(绿判定)", verdict.includes("测试通过"), verdict); const thumbs = await page .locator(".drawer-panel .wft-outputs .wft-out") .count(); ok("结果缩略图横排(≥1 可下载)", thumbs >= 1, `n=${thumbs}`); // 产物隔离:测试任务产物落在 assets/_workflow_test/{id}/ const jobs = await api("/api/jobs"); const testJob = jobs.find( (j) => j.workflow_id === wfId && j.status === "done" && (j.output_paths ?? []).length, ); ok( "试运行产物与普通素材隔离(_workflow_test/)", !!testJob && (testJob.output_paths ?? [])[0].includes("_workflow_test"), testJob?.output_paths?.[0], ); await page.screenshot({ path: `${SHOTS}/hel655-07-test-passed-1440.png` }); // ---------- 8. 失败路径:执行报错 红 + 中文 ---------- setMode({ mode: "fail" }); await page.waitForSelector(".drawer-panel .wft-form", { timeout: 10000 }); await page.locator(".drawer-panel .a-btn.primary").click(); await page.locator(".modal-panel .modal-footer .a-btn.primary").click(); await page.waitForSelector(".drawer-panel .wft-result.bad", { timeout: 30000, }); const badColor = await page .locator(".drawer-panel .wft-result.bad .wft-verdict") .evaluate((el) => getComputedStyle(el).color); ok( "失败判定为红 + 中文标题", badColor === "rgb(208, 122, 107)", await page .locator(".drawer-panel .wft-result.bad .wft-verdict") .textContent(), ); ok( "失败给出具体原因", ((await page.locator(".drawer-panel .wft-error").textContent()) ?? "") .length > 0, ); await page.screenshot({ path: `${SHOTS}/hel655-08-test-failed-1440.png` }); // ---------- 9. 无输出:不算通过 ---------- setMode({ mode: "ok", delay_sec: 0, no_outputs: true }); await page.waitForSelector(".drawer-panel .wft-form", { timeout: 10000 }); await page.locator(".drawer-panel .a-btn.primary").click(); await page.locator(".modal-panel .modal-footer .a-btn.primary").click(); await page.waitForFunction( () => { const bad = document.querySelector(".drawer-panel .wft-result.bad"); if (!bad) return false; // the previous fail verdict also matches .bad — wait until the error text changes return ( !bad.textContent.includes("boom") || bad.textContent.includes("输出") ); }, { timeout: 30000 }, ); ok("无输出不判通过(红/失败结论)", true); setMode({ ...baseMode() }); // ---------- 10. 键盘:Esc 关闭、焦点圈、焦点回归 ---------- await page.keyboard.press("Escape"); await page.waitForSelector(".drawer-panel", { state: "detached" }); ok("Esc 关闭试运行抽屉", true); await row.locator(".wf-test-btn").first().click(); await page.waitForSelector(".drawer-panel"); await page.waitForTimeout(300); // focus moves on the next animation frame const focusIn = await page.evaluate( () => document.activeElement.closest(".drawer-panel") !== null, ); ok("抽屉打开后焦点进入面板", focusIn); for (let i = 0; i < 12; i++) await page.keyboard.press("Tab"); const stillIn = await page.evaluate( () => document.activeElement.closest(".drawer-panel") !== null, ); ok("Tab 在抽屉内循环(焦点不逃逸)", stillIn); await page.keyboard.press("Escape"); await page.waitForSelector(".drawer-panel", { state: "detached" }); const backOnBtn = await page.evaluate( () => document.activeElement?.getAttribute("title") === "测试工作流" || (document.activeElement?.textContent ?? "").trim().startsWith("测试"), ); ok("关闭后焦点回到触发按钮", backOnBtn === true); // ---------- 11. 减少动态效果:抽屉只留透明度 ---------- await page.evaluate(() => (document.documentElement.dataset.reduce = "1")); await page .getByRole("button", { name: /导入工作流/ }) .first() .click(); await page.waitForSelector(".drawer-panel"); const anim = await page .locator(".drawer-panel") .evaluate((el) => getComputedStyle(el).animationName); ok("减动效时抽屉无位移动画(仅淡变)", anim.includes("drawer-fade"), anim); await page.keyboard.press("Escape"); await page.evaluate(() => delete document.documentElement.dataset.reduce); // ---------- 12. 中屏 1024 / 窄屏 390 ---------- await page.setViewportSize({ width: 1024, height: 768 }); await closeSideFormIfOpen(page); await page .getByRole("button", { name: /导入工作流/ }) .first() .click(); await page.waitForSelector(".drawer-panel"); await page.waitForTimeout(320); const mid = await page.locator(".drawer-panel").boundingBox(); ok( "中屏抽屉仍 380px 贴右", Math.round(mid.width) === 380 && Math.abs(mid.x + mid.width - 1024) < 2, ); await page.screenshot({ path: `${SHOTS}/hel655-09-import-1024.png` }); await page.keyboard.press("Escape"); await page.setViewportSize({ width: 390, height: 844 }); await closeSideFormIfOpen(page); await page .getByRole("button", { name: /导入工作流/ }) .first() .click(); await page.waitForSelector(".drawer-panel"); await page.waitForTimeout(320); const narrow = await page.locator(".drawer-panel").boundingBox(); ok("窄屏抽屉全屏宽", Math.round(narrow.width) === 390, `w=${narrow.width}`); const overflow = await page.evaluate( () => document.scrollingElement.scrollWidth - document.scrollingElement.clientWidth, ); ok("窄屏无横向溢出", overflow <= 0, `overflow=${overflow}px`); await page.screenshot({ path: `${SHOTS}/hel655-10-import-390.png` }); await page.keyboard.press("Escape"); await page.waitForSelector(".drawer-panel", { state: "detached" }); await page.setViewportSize({ width: 1440, height: 900 }); // ---------- 13. 试运行历史与清除 ---------- await row.locator(".wf-test-btn").first().click(); await page.waitForSelector(".drawer-panel"); await page.waitForSelector(".drawer-panel .wft-rows li", { timeout: 8000 }); const histCount = await page.locator(".drawer-panel .wft-rows li").count(); ok("测试记录列表保留", histCount >= 2, `n=${histCount}`); await page.locator(".drawer-panel .link-danger").click(); await page.waitForFunction( () => document.querySelectorAll(".drawer-panel .wft-rows li").length === 0, { timeout: 8000 }, ); ok("清除测试记录生效", true); await page.keyboard.press("Escape"); await browser.close(); console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILURES`); process.exit(failures === 0 ? 0 : 1); }; run().catch(async (e) => { console.error("FATAL", e?.message?.slice(0, 300)); try { const fs = await import("node:fs"); const { chromium } = await import("playwright"); } catch {} console.error("FATAL", e); process.exit(1); });