Files
aigc-home/e2e/import.mjs
施工员andmultica-agent bc24370233 feat(style): 全站无彩灰阶收尾——清理 legacy 别名与硬编码色漂移 (HEL-685 S6)
- app.css 删除 8 个无引用 legacy 别名,新增 --id-1..5 身份灰阶实色阶
- 画布去紫罗兰二级 palette(tokens.css 删除,改用全站 token)
- 画布/镜头台/智能体/omni/视频组件硬编码色全部归位 token
- agentPalette 彩色身份色改为显影灰阶 --id 阶;修复 3 处相纸白底白字不可读
- WorkflowLibrary 试运行抽屉焦点还原(异步禁用触发键丢焦)修复
- e2e: fpages/import 色值断言对齐现行 token;新增 hel685 走查脚本
  (1440/1024/390 截图 + 控制台错误 + 减动效 + 键盘焦点 + 对比度探针);
  pagination 空库自举;fpages 增加逐页控制台错误检查

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 17:26:12 +08:00

589 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);
});