HEL-769: 还原主题卡内部预览样本细节 #8
@@ -48,12 +48,12 @@
|
||||
>
|
||||
<span class="preview" data-theme={item.id} aria-hidden="true"
|
||||
><span class="rail"></span><span class="list"
|
||||
><i></i><i></i><i></i></span
|
||||
><i></i><i></i><i></i>{#if tiles}<i></i>{/if}</span
|
||||
><span class="stage"
|
||||
><i></i><b
|
||||
>{#if tiles}<em></em>{/if}</b
|
||||
></span
|
||||
></span
|
||||
>{#if tiles}<span class="preview-text"></span>{/if}</span
|
||||
>
|
||||
<span class="copy"
|
||||
><strong
|
||||
@@ -86,8 +86,10 @@
|
||||
--choice-copy-gap: 6px;
|
||||
--choice-tone-align: stretch;
|
||||
--choice-preview-art: var(--theme-preview-art);
|
||||
--choice-preview-radius: 8px;
|
||||
--choice-art-border: 0;
|
||||
--choice-dock-radius: 3px;
|
||||
--choice-dock-width: 100%;
|
||||
--choice-dock-background: var(--bg-3);
|
||||
--choice-radius: 12px;
|
||||
--choice-name-size: var(--manage-theme-name-size);
|
||||
--choice-name-line: var(--manage-theme-line);
|
||||
@@ -96,6 +98,12 @@
|
||||
--choice-selected: color-mix(in srgb, var(--gold) 60%, transparent);
|
||||
--choice-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 14%, transparent);
|
||||
}
|
||||
.tiles .preview {
|
||||
--choice-art-shadow: 0 0 0 1px var(--line-2);
|
||||
--choice-dock-border: 1px solid var(--line-2);
|
||||
--choice-dock-background: var(--bg-3);
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.tile-check {
|
||||
display: none;
|
||||
}
|
||||
@@ -139,7 +147,8 @@
|
||||
box-sizing: var(--choice-box-sizing, content-box);
|
||||
grid-column: 1 / -1;
|
||||
height: 64px;
|
||||
border-radius: var(--r-sm);
|
||||
border-radius: var(--choice-preview-radius, var(--r-sm));
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 12px 22px 1fr;
|
||||
@@ -179,6 +188,7 @@
|
||||
background: var(--sc-image);
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
padding: 5px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -188,13 +198,16 @@
|
||||
flex: 1;
|
||||
border-radius: 3px;
|
||||
background: var(--choice-preview-art, var(--bg-3));
|
||||
border: 1px solid var(--line-2);
|
||||
border: var(--choice-art-border, 1px solid var(--line-2));
|
||||
box-shadow: var(--choice-art-shadow, none);
|
||||
}
|
||||
.stage b {
|
||||
box-sizing: var(--choice-box-sizing, content-box);
|
||||
border: var(--choice-dock-border, 0);
|
||||
height: 9px;
|
||||
width: var(--choice-dock-width, 18px);
|
||||
align-self: end;
|
||||
border-radius: 2px;
|
||||
border-radius: var(--choice-dock-radius, 2px);
|
||||
background: var(--choice-dock-background, var(--grad-gold));
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -205,6 +218,16 @@
|
||||
border-radius: 2px;
|
||||
background: var(--grad-gold);
|
||||
}
|
||||
.preview-text {
|
||||
position: absolute;
|
||||
left: 40px;
|
||||
top: 6px;
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--tx-1);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.copy {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# HEL-769 · 主题卡内部预览还原
|
||||
|
||||
基于完整 `fix/hel-767-sample-alignment@2004f527ccb689c84219282e3b706a95490c8b07`,隔离分支 `fix/hel-769-theme-preview`,新 PR 目标为 `fix/hel-767-sample-alignment`。不合并、部署或修改既有 PR。
|
||||
|
||||
## 实现
|
||||
|
||||
产品代码只修改 `calliope-web/src/lib/components/shell/ThemeChoices.svelte`:复用原组件与现有主题 token,恢复 tiles 内第四条列表线、20×3px 短文字线、固定 8px 预览圆角、主画面外描边与底条独立边框。底条显式继承 tiles 的 border-box,避免 padding 增加高度。外框、名称和说明的排版来源保持原有值。
|
||||
|
||||
主画面描边和底条颜色相关自定义属性在带 `data-theme` 的预览容器内解析,避免在外层 choices 提前解析为当前页面主题的颜色。预览内部使用对应卡片自己的 `--line-2`、`--bg-3` 和 `--tx-1`。没有新 CSS 补偿覆盖、依赖、检查豁免、业务演示数据或后端修改。compact 继续使用原有默认参数,新增图形只用于 tiles。
|
||||
|
||||
## 原件核实
|
||||
|
||||
原 HTML 由 Multica 附件下载,SHA256 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`,原件未经编辑。实际浏览器测量:主画面高 39px、底条高 9px、角标 14×5px、文字线 20×3px。旧报告角标样本 14×9px 的数字不成立,不照搬。所有尺寸、配色和圆角以本次原件逐主题运行测量为准。
|
||||
|
||||
## 复现与证据
|
||||
|
||||
运行 `npm ci`、`npm run lint`、`npm run check`、`npm test`、`npm run build`。构建完成后从仓库根运行 `python3 scripts/verify-hel769.py`,设置 `PLAYWRIGHT_MODULE` 指向已有 Playwright `index.mjs`,必要时设置浏览器运行库 `LD_LIBRARY_PATH`。原 HTML 放在仓库同级 `materials/夜片场-多主题交互样本.html`。
|
||||
|
||||
验证脚本沿用 HEL-767 的 API 契约隔离方式,临时 SQLite 和媒体夹具在 finally 中删除,动态 localhost 静态服务器和浏览器在 finally 中关闭。所有非本机 origin 拦截,未列出的 API 写操作失败,不访问用户库、真实 GPU 或付费接口。
|
||||
|
||||
完整矩阵保留五管理页 × 四主题 × 三窗口的整页 sample/actual/pair;设置页额外保留内部元素 DOM 原始值、内部近景 sample/actual/3 倍并排图,以及每张卡悬停的预览原图。`dimensions.md` 列出样本/实际/差值。交互沿用既有守护,覆盖主题选择、悬停、切换、键盘、记忆、减少动效、搜索、侧栏和管理页业务。逐文件 SHA256/大小清单与包索引随附件交付。
|
||||
|
||||
真实任务来源文字与样本演示文案不同,自动列宽随内容变化;无输出任务使用媒介图标。空连接、空素材和设置真实字段继续保持现有业务,不把样本虚构记录写入产品。
|
||||
|
||||
## 回滚与边界
|
||||
|
||||
使用 `git revert <HEL-769 head>` 正常推送;不 reset 或强推。没有服务器、用户配置或数据变更。未验证真实 GPU/付费生成、用户库、全站其他历史业务或后续 HEL-731~733;本次只施工和自测,须总管安排独立复核。
|
||||
|
||||
## 本次结果
|
||||
|
||||
- `npm ci`、lint、check(0 errors / 0 warnings)、四组既有前端守护、build 全部通过。build 保留既有 @xyflow 未使用导入和超过 500kB chunk 提示。
|
||||
- 完整矩阵 60 组,11,809 项断言通过;补充交互与布局守护 121 项通过,两个运行均无 pageerror 或意外 API 写请求。四卡 × 四主题 × 三窗口的内部坐标/尺寸最大偏差 0px,主画面、底条、角标、文字线及四列表线的颜色/边框/圆角逐项匹配。外框高度 130.421875px、标题 12.5px/19.375px、说明 11px/17.05px 保持原值。
|
||||
- 原始 PNG 像素核对也保留在 `pixel-results.json`:48 个普通态预览裁切中 40 个逐像素一致,另 8 个存在最大单通道 1/255 的栅格差异;DOM 几何和 computed 绘制值一致。悬停裁切包含圆角边缘与父卡背景的抗锯齿,原始差异如实保留,不将它们改图或声称所有 PNG 为零差异。
|
||||
- 额外检查四主题的设置卡选中、localStorage 保存及刷新恢复;12 组合下工作流三栏与任务整宽八列全部通过。完整/减少动效、520ms 主题显影和 1600ms 队列运行环沿用既有验证并通过。
|
||||
@@ -0,0 +1,996 @@
|
||||
/** Run via verify-hel769.py; explicit API fake, local-only origins, no user data. */
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE || "playwright"
|
||||
);
|
||||
const out = process.env.HEL769_OUT,
|
||||
data = process.env.HEL769_DATA;
|
||||
const fixture = JSON.parse(
|
||||
await fs.readFile(path.join(data, "fixtures.json"), "utf8"),
|
||||
);
|
||||
let jobs = structuredClone(fixture.jobs),
|
||||
apiError = false,
|
||||
imageFixture;
|
||||
const result = {
|
||||
checks: [],
|
||||
measurements: [],
|
||||
errors: [],
|
||||
mutations: [],
|
||||
externalBlocked: [],
|
||||
apiReads: [],
|
||||
};
|
||||
const check = (v, n) => {
|
||||
result.checks.push({ name: n, pass: !!v });
|
||||
if (!v) throw Error(n);
|
||||
};
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const p = new URL(req.url, "http://local").pathname;
|
||||
let f = path.join("calliope-web/build", decodeURIComponent(p));
|
||||
if (!path.extname(f)) f = path.join("calliope-web/build", "index.html");
|
||||
const bytes = await fs.readFile(f);
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
f.endsWith(".js")
|
||||
? "text/javascript"
|
||||
: f.endsWith(".css")
|
||||
? "text/css"
|
||||
: f.endsWith(".html")
|
||||
? "text/html"
|
||||
: "application/octet-stream",
|
||||
);
|
||||
res.end(bytes);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox"],
|
||||
});
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
timezoneId: "Asia/Shanghai",
|
||||
recordVideo: {
|
||||
dir: path.join(out, "recordings"),
|
||||
size: { width: 1440, height: 900 },
|
||||
},
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem("aigc-lang", "zh");
|
||||
localStorage.setItem("aigc-reduce-motion", "1");
|
||||
window.__sse = [];
|
||||
window.EventSource = class {
|
||||
constructor() {
|
||||
window.__sse.push(this);
|
||||
setTimeout(() => this.onopen?.(), 0);
|
||||
}
|
||||
addEventListener(t, f) {
|
||||
(this.handlers ??= {})[t] = f;
|
||||
}
|
||||
close() {}
|
||||
};
|
||||
});
|
||||
await context.route("**/*", async (route) => {
|
||||
const req = route.request(),
|
||||
u = new URL(req.url());
|
||||
if (["data:", "blob:", "file:"].includes(u.protocol))
|
||||
return route.continue();
|
||||
if (u.origin !== base) {
|
||||
result.externalBlocked.push(u.origin);
|
||||
return route.abort();
|
||||
}
|
||||
if (!u.pathname.startsWith("/api/")) return route.continue();
|
||||
const json = (body) => route.fulfill({ json: body });
|
||||
if (u.pathname === "/api/file") {
|
||||
if (u.searchParams.get("path")?.endsWith(".mp4")) {
|
||||
const bytes = await fs.readFile(path.join(data, "video.mp4"));
|
||||
const range = req.headers()["range"]?.match(/bytes=(\d+)-(\d*)/);
|
||||
if (range) {
|
||||
const start = +range[1],
|
||||
end = range[2]
|
||||
? Math.min(+range[2], bytes.length - 1)
|
||||
: bytes.length - 1;
|
||||
return route.fulfill({
|
||||
status: 206,
|
||||
body: bytes.subarray(start, end + 1),
|
||||
contentType: "video/mp4",
|
||||
headers: {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Range": `bytes ${start}-${end}/${bytes.length}`,
|
||||
"Content-Length": String(end - start + 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
body: bytes,
|
||||
contentType: "video/mp4",
|
||||
headers: {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(bytes.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
body:
|
||||
imageFixture ||
|
||||
Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR1kAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
const session = {
|
||||
id: 769,
|
||||
title: "HEL769 isolated session",
|
||||
project_id: null,
|
||||
origin: u.searchParams.get("origin") === "scene" ? "scene" : "chat",
|
||||
messages: [],
|
||||
plan: null,
|
||||
running: false,
|
||||
};
|
||||
const graph = {
|
||||
canvas: {
|
||||
id: 769,
|
||||
title: "HEL769 isolated canvas",
|
||||
project_id: null,
|
||||
agent_session_id: 769,
|
||||
viewport_json: null,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
};
|
||||
if (u.pathname === "/api/agent/sessions" && req.method() === "GET")
|
||||
return json([session]);
|
||||
if (u.pathname === "/api/agent/sessions/769") return json(session);
|
||||
if (u.pathname === "/api/canvas" && req.method() === "POST") {
|
||||
result.mutations.push({
|
||||
path: u.pathname,
|
||||
method: "POST",
|
||||
purpose: "isolated canvas ensure only",
|
||||
});
|
||||
return json(graph);
|
||||
}
|
||||
if (u.pathname === "/api/canvas/769") return json(graph);
|
||||
if (u.pathname === "/api/shots/by-session/769")
|
||||
return json({
|
||||
id: 769,
|
||||
title: "HEL769 isolated composition",
|
||||
scene: { objects: [], cameraTrack: [] },
|
||||
});
|
||||
if (u.pathname === "/api/shots/769/captures") return json([]);
|
||||
if (u.pathname === "/api/shots") return json([]);
|
||||
if (
|
||||
u.pathname === "/api/agent/projects" ||
|
||||
u.pathname === "/api/agent/skills"
|
||||
)
|
||||
return json([]);
|
||||
if (u.pathname === "/api/agent/sessions" && req.method() === "POST")
|
||||
return json(session);
|
||||
if (req.method() !== "GET") {
|
||||
result.mutations.push({ path: u.pathname, method: req.method() });
|
||||
const m = u.pathname.match(/^\/api\/jobs\/(\d+)\/(cancel|retry)$/);
|
||||
if (m) {
|
||||
const j = jobs.find((j) => j.id === +m[1]);
|
||||
j.status = m[2] === "cancel" ? "failed" : "pending";
|
||||
j.error = m[2] === "cancel" ? "cancelled" : null;
|
||||
return json(j);
|
||||
}
|
||||
const d = u.pathname.match(/^\/api\/playground\/jobs\/(\d+)$/);
|
||||
if (d && req.method() === "DELETE") {
|
||||
jobs = jobs.filter((j) => j.id !== +d[1]);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (u.pathname.includes("test")) return json({ ok: true });
|
||||
result.errors.push(`Unexpected mutation ${req.method()} ${u.pathname}`);
|
||||
return route.fulfill({
|
||||
status: 503,
|
||||
body: "Unlisted fixture mutation blocked",
|
||||
});
|
||||
}
|
||||
result.apiReads.push(u.pathname);
|
||||
if (u.pathname === "/api/jobs") {
|
||||
if (apiError)
|
||||
return route.fulfill({ status: 500, body: "HEL769 isolated error" });
|
||||
return json(jobs);
|
||||
}
|
||||
if (u.pathname === "/api/workflows") return json(fixture.workflows);
|
||||
if (u.pathname === "/api/projects")
|
||||
return json([{ id: 1, title: "雾港来信" }]);
|
||||
if (u.pathname === "/api/settings")
|
||||
return json({
|
||||
comfyui_endpoints: [],
|
||||
llm_profiles: [],
|
||||
queue_concurrency: 2,
|
||||
queue_poll_timeout_sec: 1800,
|
||||
queue_poll_interval_sec: 1,
|
||||
queue_max_retries: 1,
|
||||
data_dir: "HEL769-isolated-fixture",
|
||||
dry_run: false,
|
||||
});
|
||||
if (u.pathname === "/api/comfyui/endpoints")
|
||||
return json({ endpoints: [], active_id: null });
|
||||
if (u.pathname.includes("/jobs")) return json([]);
|
||||
if (u.pathname.includes("/sessions")) return json([]);
|
||||
if (
|
||||
u.pathname === "/api/library/media" ||
|
||||
u.pathname === "/api/agent/agents" ||
|
||||
u.pathname === "/api/agent/memories"
|
||||
)
|
||||
return json([]);
|
||||
const wf = u.pathname.match(/^\/api\/workflows\/(\d+)$/);
|
||||
if (wf)
|
||||
return json({
|
||||
...fixture.workflows.find((w) => w.id === +wf[1]),
|
||||
workflow_json: {},
|
||||
output_map: [
|
||||
{
|
||||
nodeId: "2",
|
||||
kind: fixture.workflows.find((w) => w.id === +wf[1]).kind,
|
||||
},
|
||||
],
|
||||
});
|
||||
return json({});
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (e) => result.errors.push(e.message));
|
||||
const ref = await context.newPage();
|
||||
await ref.goto(
|
||||
pathToFileURL(path.resolve("../materials/夜片场-多主题交互样本.html"))
|
||||
.href,
|
||||
);
|
||||
await ref.evaluate(() => {
|
||||
go("tasks");
|
||||
document.documentElement.classList.add("capture", "reduce");
|
||||
});
|
||||
// Reuse the corresponding original synthetic still only inside this API fake.
|
||||
// Canvas avoids capturing a parent page during its opacity entrance animation.
|
||||
imageFixture = Buffer.from(
|
||||
await ref.evaluate(async () => {
|
||||
const img = new Image();
|
||||
img.src = still(64, 96, 64);
|
||||
await img.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 48;
|
||||
canvas.height = 32;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0, 48, 32);
|
||||
return canvas.toDataURL("image/png").split(",")[1];
|
||||
}),
|
||||
"base64",
|
||||
);
|
||||
const progress = () =>
|
||||
page.evaluate(() => {
|
||||
for (const es of window.__sse)
|
||||
for (const [id, pct] of [
|
||||
[1, 62],
|
||||
[2, 35],
|
||||
])
|
||||
es.handlers?.["job.progress"]?.({
|
||||
data: JSON.stringify({
|
||||
type: "job.progress",
|
||||
data: { job_id: id, progress: pct, phase: "comfy_running" },
|
||||
}),
|
||||
});
|
||||
});
|
||||
const measure = (p, selectors) =>
|
||||
p.evaluate(
|
||||
(selectors) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(selectors).map(([k, s]) => {
|
||||
const e = document.querySelector(s);
|
||||
if (!e) return [k, null];
|
||||
const r = e.getBoundingClientRect(),
|
||||
c = getComputedStyle(e);
|
||||
return [
|
||||
k,
|
||||
{
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
font: c.fontSize,
|
||||
line: c.lineHeight,
|
||||
weight: c.fontWeight,
|
||||
border: c.border,
|
||||
borderRight: c.borderRight,
|
||||
backgroundImage: c.backgroundImage,
|
||||
opacity: c.opacity,
|
||||
shadow: c.boxShadow,
|
||||
svg: e.matches("svg") ? e.innerHTML : undefined,
|
||||
radius: c.borderRadius,
|
||||
color: c.color,
|
||||
background: c.backgroundColor,
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
selectors,
|
||||
);
|
||||
for (const [width, height] of process.env.HEL769_SKIP_VISUAL
|
||||
? []
|
||||
: [
|
||||
[1440, 900],
|
||||
[1366, 768],
|
||||
[1280, 720],
|
||||
])
|
||||
for (const theme of ["night", "day", "grade", "bluehour"])
|
||||
for (const [route, sample] of [
|
||||
["workflows", "workflows"],
|
||||
["connections", "models"],
|
||||
["tasks", "tasks"],
|
||||
["library", "library"],
|
||||
["settings", "settings"],
|
||||
]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await ref.setViewportSize({ width, height });
|
||||
await page.goto(base + "/" + route);
|
||||
await page.locator(".management-header").waitFor();
|
||||
if (route === "tasks") {
|
||||
await page.locator(".task-table tbody tr").first().waitFor();
|
||||
await progress();
|
||||
}
|
||||
if (route === "settings")
|
||||
await page.locator(".choices.tiles").waitFor();
|
||||
await page.evaluate((theme) => {
|
||||
localStorage.setItem("aigc-theme", theme);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
|
||||
);
|
||||
}, theme);
|
||||
await ref.evaluate(
|
||||
({ sample, theme }) => {
|
||||
go(sample);
|
||||
setTheme(theme);
|
||||
},
|
||||
{ sample, theme },
|
||||
);
|
||||
await page.waitForTimeout(650);
|
||||
const name = `${route}-${theme}-${width}`;
|
||||
const actualSelectors = {
|
||||
header: ".management-header",
|
||||
brand: ".refract-shell > .sidebar .brand",
|
||||
divider: ".refract-shell > .sidebar hr",
|
||||
foot: ".sidebar-bottom",
|
||||
queue: ".queue-dot",
|
||||
theme: ".sidebar-bottom button",
|
||||
settings: '.sidebar-bottom a[href="/settings"]',
|
||||
themeCard: ".tiles .choice",
|
||||
themePreview: ".tiles .preview",
|
||||
themeName: ".tiles .copy strong",
|
||||
themeTone: ".tiles .copy small",
|
||||
};
|
||||
const sampleSelectors = {
|
||||
header: `#p-${sample} .ph`,
|
||||
brand: ".rail .brand",
|
||||
divider: ".rail hr",
|
||||
foot: ".rail .foot",
|
||||
queue: ".rail .queue-dot",
|
||||
theme: "#themeBtn",
|
||||
settings: '.rail [data-go="settings"]',
|
||||
themeCard: ".ttile",
|
||||
themePreview: ".ttile .tprev",
|
||||
themeName: ".ttile b",
|
||||
themeTone: ".ttile small",
|
||||
};
|
||||
for (let i = 0; i < 9; i++) {
|
||||
actualSelectors[`nav${i}`] =
|
||||
`.refract-shell > .sidebar nav a:nth-child(${i < 6 ? i + 1 : i - 5})`;
|
||||
if (i >= 6)
|
||||
actualSelectors[`nav${i}`] =
|
||||
`.refract-shell > .sidebar .management a:nth-child(${i - 5})`;
|
||||
sampleSelectors[`nav${i}`] = `.rail > .nav:nth-of-type(${i + 1})`;
|
||||
}
|
||||
if (route === "settings")
|
||||
for (let i = 1; i <= 4; i++)
|
||||
for (const [key, a, b] of [
|
||||
["Card", ".choice", ".ttile"],
|
||||
["Preview", ".preview", ".tprev"],
|
||||
["Name", ".copy strong", "b"],
|
||||
["Tone", ".copy small", "small"],
|
||||
]) {
|
||||
actualSelectors[`theme${key}${i}`] =
|
||||
key === "Card"
|
||||
? `.tiles .choice:nth-child(${i})`
|
||||
: `.tiles .choice:nth-child(${i}) ${a}`;
|
||||
sampleSelectors[`theme${key}${i}`] =
|
||||
key === "Card"
|
||||
? `.ttile:nth-child(${i})`
|
||||
: `.ttile:nth-child(${i}) ${b}`;
|
||||
}
|
||||
if (route === "settings") {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
for (const [key, a, b] of [
|
||||
["Rail", ".rail", ".r"], ["List", ".list", ".l"],
|
||||
["Stage", ".stage", ".m"], ["Art", ".stage > i", ".art"],
|
||||
["Dock", ".stage > b", ".dk"], ["Badge", ".stage em", ".dk > span"],
|
||||
["Text", ".preview-text", ".tx"],
|
||||
...[1,2,3,4].map(n => [`Line${n}`, `.list i:nth-child(${n})`, `.l i:nth-child(${n})`]),
|
||||
]) {
|
||||
actualSelectors[`internal${key}${i}`] = `.tiles .choice:nth-child(${i}) .preview ${a}`;
|
||||
sampleSelectors[`internal${key}${i}`] = `.ttile:nth-child(${i}) .tprev ${b}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (route !== "settings")
|
||||
for (const key of [
|
||||
"themeCard",
|
||||
"themePreview",
|
||||
"themeName",
|
||||
"themeTone",
|
||||
]) {
|
||||
delete actualSelectors[key];
|
||||
delete sampleSelectors[key];
|
||||
}
|
||||
if (route === "tasks") {
|
||||
Object.assign(actualSelectors, {
|
||||
table: ".task-table",
|
||||
head: ".task-table thead",
|
||||
row: ".task-table tbody tr",
|
||||
thumbnail: ".task-thumbnail",
|
||||
segment: ".task-tabs",
|
||||
search: ".task-search",
|
||||
progress: ".task-progress",
|
||||
});
|
||||
Object.assign(sampleSelectors, {
|
||||
table: "#p-tasks .tbl",
|
||||
head: "#p-tasks thead",
|
||||
row: "#taskTable tr",
|
||||
thumbnail: "#taskTable td>div",
|
||||
segment: "#p-tasks .seg",
|
||||
search: "#p-tasks .input",
|
||||
progress: "#taskTable .bar",
|
||||
});
|
||||
}
|
||||
const railState = (p, actual) =>
|
||||
p.evaluate((actual) => {
|
||||
const selectors = actual
|
||||
? [
|
||||
".refract-shell > .sidebar nav a",
|
||||
'.sidebar-bottom a[href="/settings"]',
|
||||
".sidebar-bottom button:last-of-type",
|
||||
]
|
||||
: [
|
||||
".rail > .nav",
|
||||
'.rail [data-go="settings"]',
|
||||
'.rail [data-go="spec"]',
|
||||
];
|
||||
const nodes = [
|
||||
...document.querySelectorAll(selectors[0]),
|
||||
...selectors.slice(1).map((x) => document.querySelector(x)),
|
||||
];
|
||||
const geometry = (svg) => {
|
||||
const use = svg.querySelector("use");
|
||||
const root = use
|
||||
? document.querySelector(use.getAttribute("href"))
|
||||
: svg;
|
||||
return [...root.children].map((n) => [
|
||||
n.tagName,
|
||||
[...n.attributes].map((a) => [a.name, a.value]).sort(),
|
||||
]);
|
||||
};
|
||||
const selected = nodes.find((n) =>
|
||||
actual
|
||||
? n.getAttribute("aria-current") === "page"
|
||||
: n.classList.contains("on"),
|
||||
);
|
||||
const pseudo = selected
|
||||
? getComputedStyle(selected, "::before")
|
||||
: null;
|
||||
return {
|
||||
icons: nodes
|
||||
.filter((n) => n?.querySelector("svg"))
|
||||
.map((n) => {
|
||||
const svg = n.querySelector("svg"),
|
||||
c = getComputedStyle(svg);
|
||||
return {
|
||||
geometry: geometry(svg),
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
stroke: c.strokeWidth,
|
||||
color: c.color,
|
||||
};
|
||||
}),
|
||||
selected: pseudo
|
||||
? Object.fromEntries(
|
||||
[
|
||||
"left",
|
||||
"top",
|
||||
"bottom",
|
||||
"width",
|
||||
"borderRadius",
|
||||
"backgroundColor",
|
||||
"boxShadow",
|
||||
].map((k) => [k, pseudo[k]]),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}, actual);
|
||||
const railActual = await railState(page, true),
|
||||
railSample = await railState(ref, false);
|
||||
result.railProbe = { railActual, railSample };
|
||||
check(
|
||||
JSON.stringify(railActual) === JSON.stringify(railSample),
|
||||
`${name}: rail icon paths, sizes, strokes, colors and selected indicator match`,
|
||||
);
|
||||
const measured = {
|
||||
route,
|
||||
theme,
|
||||
width,
|
||||
height,
|
||||
railActual,
|
||||
railSample,
|
||||
actual: await measure(page, actualSelectors),
|
||||
sample: await measure(ref, sampleSelectors),
|
||||
};
|
||||
result.measurements.push(measured);
|
||||
await page.screenshot({ path: path.join(out, name + "-actual.png") });
|
||||
await ref.screenshot({ path: path.join(out, name + "-sample.png") });
|
||||
for (const key of Object.keys(sampleSelectors)) {
|
||||
if (key === "progress") continue; // auto columns depend on honest source text; reported separately
|
||||
for (const axis of ["x", "y", "width", "height"]) {
|
||||
// Table column width distribution is data-dependent, the outer table and thumbnail dimensions are fixed.
|
||||
if (key === "thumbnail" && axis === "x") continue;
|
||||
check(
|
||||
measured.actual[key] &&
|
||||
measured.sample[key] &&
|
||||
Math.abs(
|
||||
measured.actual[key][axis] - measured.sample[key][axis],
|
||||
) < 0.1,
|
||||
`${name}: ${key} ${axis} matches sample`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (route === "settings") {
|
||||
for (const key of Object.keys(sampleSelectors).filter(k => k.startsWith("internal") || k.startsWith("themePreview"))) {
|
||||
for (const prop of ["radius", "shadow", "background", "backgroundImage", "opacity", "borderRight"]) {
|
||||
check(measured.actual[key][prop] === measured.sample[key][prop], `${name}: ${key} ${prop} matches`);
|
||||
}
|
||||
}
|
||||
for (let card = 1; card <= 4; card++) {
|
||||
await page.locator(`.tiles .choice:nth-child(${card})`).hover();
|
||||
await ref.locator(`.ttile:nth-child(${card})`).hover();
|
||||
await page.locator(`.tiles .choice:nth-child(${card}) .preview`).screenshot({path:path.join(out, `${name}-card${card}-hover-actual.png`)});
|
||||
await ref.locator(`.ttile:nth-child(${card}) .tprev`).screenshot({path:path.join(out, `${name}-card${card}-hover-sample.png`)});
|
||||
}
|
||||
await page.mouse.move(1200, 600);
|
||||
await ref.mouse.move(1200, 600);
|
||||
await page.locator(".choices.tiles").screenshot({path:path.join(out, name + "-internal-actual.png")});
|
||||
await ref.locator("#themeTiles").screenshot({path:path.join(out, name + "-internal-sample.png")});
|
||||
}
|
||||
for (const key of [
|
||||
"brand",
|
||||
"queue",
|
||||
"theme",
|
||||
"settings",
|
||||
...Array.from({ length: 9 }, (_, i) => `nav${i}`),
|
||||
])
|
||||
check(
|
||||
measured.actual[key].radius === measured.sample[key].radius,
|
||||
`${name}: ${key} radius matches sample`,
|
||||
);
|
||||
if (route === "settings")
|
||||
for (const key of [
|
||||
"themeName",
|
||||
"themeTone",
|
||||
...Array.from({ length: 4 }, (_, i) => `themeName${i + 1}`),
|
||||
...Array.from({ length: 4 }, (_, i) => `themeTone${i + 1}`),
|
||||
])
|
||||
check(
|
||||
measured.actual[key].line === measured.sample[key].line,
|
||||
`${name}: ${key} line height`,
|
||||
);
|
||||
check(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
`${name}: no overflow`,
|
||||
);
|
||||
}
|
||||
await page.goto(base + "/tasks");
|
||||
await page.locator(".task-table tbody tr").first().waitFor();
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
if (process.env.HEL769_SKIP_VISUAL) {
|
||||
await page.goto(base + "/tasks");
|
||||
await page.locator("tbody tr").first().waitFor();
|
||||
}
|
||||
await page.locator(".task-tabs button").nth(1).click();
|
||||
check((await page.locator("tbody tr").count()) === 3, "active filter");
|
||||
await page.locator(".task-tabs button").nth(2).click();
|
||||
check((await page.locator("tbody tr").count()) === 1, "failed filter");
|
||||
await page.locator(".task-tabs button").nth(3).click();
|
||||
check((await page.locator("tbody tr").count()) === 3, "image filter");
|
||||
await page.locator(".task-tabs button").nth(4).click();
|
||||
check((await page.locator("tbody tr").count()) === 3, "video filter");
|
||||
await page.locator(".task-tabs button").first().click();
|
||||
await page.getByRole("searchbox").fill("镜头 07");
|
||||
await page.waitForTimeout(100);
|
||||
check((await page.locator("tbody tr").count()) === 1, "search by task title");
|
||||
await page.getByRole("searchbox").fill("HEL769 absent");
|
||||
await page.waitForTimeout(100);
|
||||
check((await page.locator("tbody tr").count()) === 0, "search empty");
|
||||
await page.screenshot({ path: path.join(out, "search-empty.png") });
|
||||
await page.getByRole("searchbox").fill("");
|
||||
await page.getByRole("button", { name: "查看任务 #6", exact: true }).click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
const video = page.locator("video");
|
||||
await video.evaluate(
|
||||
(v) =>
|
||||
new Promise((r) =>
|
||||
v.readyState >= 1
|
||||
? r()
|
||||
: v.addEventListener("loadedmetadata", r, { once: true }),
|
||||
),
|
||||
);
|
||||
check(
|
||||
await video.evaluate(
|
||||
(v) =>
|
||||
v.paused && v.controls && !v.autoplay && !v.muted && v.volume === 1,
|
||||
),
|
||||
"video manual playback with sound",
|
||||
);
|
||||
await video.hover();
|
||||
const videoBox = await video.boundingBox();
|
||||
await video.click({ position: { x: 26, y: videoBox.height - 28 } });
|
||||
await page.waitForTimeout(250);
|
||||
check(
|
||||
await video.evaluate((v) => !v.paused && v.currentTime > 0),
|
||||
"explicit video play",
|
||||
);
|
||||
await video.evaluate((v) => {
|
||||
v.pause();
|
||||
v.currentTime = 0.7;
|
||||
v.volume = 0.4;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
result.videoState = await video.evaluate((v) => ({
|
||||
paused: v.paused,
|
||||
time: v.currentTime,
|
||||
volume: v.volume,
|
||||
duration: v.duration,
|
||||
seekable: v.seekable.length,
|
||||
error: v.error?.message,
|
||||
}));
|
||||
check(
|
||||
result.videoState.paused &&
|
||||
result.videoState.time > 0.6 &&
|
||||
Math.abs(result.videoState.volume - 0.4) < 0.001,
|
||||
"pause seek volume",
|
||||
);
|
||||
await page.screenshot({ path: path.join(out, "video-preview.png") });
|
||||
await page.keyboard.press("Tab");
|
||||
check(
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.evaluate((e) => e.contains(document.activeElement)),
|
||||
"modal focus stays inside",
|
||||
);
|
||||
await page.getByRole("dialog").locator(".modal-close").focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
check((await page.locator("video").count()) === 0, "close unmounts video");
|
||||
check(
|
||||
await page
|
||||
.getByRole("button", { name: "查看任务 #6", exact: true })
|
||||
.evaluate((e) => e === document.activeElement),
|
||||
"modal restores trigger focus",
|
||||
);
|
||||
await page.getByRole("button", { name: "查看任务 #5", exact: true }).click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
check(
|
||||
(await page.getByRole("dialog").locator("img").count()) === 1,
|
||||
"image preview",
|
||||
);
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("link", { name: "下载", exact: true }).click(),
|
||||
]);
|
||||
check(!(await download.failure()), "native image download");
|
||||
await download.saveAs(path.join(out, "downloaded-fixture.png"));
|
||||
await page.getByRole("button", { name: "删除", exact: true }).click();
|
||||
check(
|
||||
(await page.locator("tbody tr").count()) === 5,
|
||||
"delete removes row immediately",
|
||||
);
|
||||
await page.getByRole("button", { name: "撤销", exact: true }).click();
|
||||
check((await page.locator("tbody tr").count()) === 6, "undo restores row");
|
||||
check(
|
||||
!result.mutations.some((m) => m.method === "DELETE"),
|
||||
"undo prevents DELETE",
|
||||
);
|
||||
await page.getByRole("button", { name: "查看任务 #5", exact: true }).click();
|
||||
await page.getByRole("button", { name: "删除", exact: true }).click();
|
||||
await page.waitForTimeout(5300);
|
||||
check(
|
||||
result.mutations.some((m) => m.method === "DELETE"),
|
||||
"delete commits after undo window",
|
||||
);
|
||||
await page
|
||||
.locator("tbody tr")
|
||||
.filter({ hasText: "参考生视频 #21" })
|
||||
.getByRole("button", { name: "重试", exact: true })
|
||||
.click();
|
||||
await page.waitForTimeout(150);
|
||||
check(
|
||||
jobs.find((j) => j.id === 4).status === "pending",
|
||||
"retry uses real API contract",
|
||||
);
|
||||
await page
|
||||
.locator("tbody tr")
|
||||
.filter({ hasText: "Qwen 局部重绘" })
|
||||
.getByRole("button", { name: "取消", exact: true })
|
||||
.click();
|
||||
await page.waitForTimeout(150);
|
||||
check(
|
||||
jobs.find((j) => j.id === 3).error === "cancelled",
|
||||
"cancel uses real API contract",
|
||||
);
|
||||
await page.getByRole("button", { name: "查看任务 #1", exact: true }).click();
|
||||
await page.getByRole("button", { name: "创作台", exact: true }).click();
|
||||
check(
|
||||
(await page.locator("tbody tr").count()) === 2,
|
||||
"source filter retained in details",
|
||||
);
|
||||
await page.getByRole("button", { name: "全部来源", exact: true }).click();
|
||||
const testJob = {
|
||||
...structuredClone(fixture.jobs[4]),
|
||||
id: 8,
|
||||
origin: "workflow_test",
|
||||
payload: { source: "workflow_test", title: "HEL769 workflow test" },
|
||||
output_paths: ["HEL769-fixture-4.png"],
|
||||
};
|
||||
jobs.push(testJob);
|
||||
await page.goto(base + "/tasks");
|
||||
await page.getByRole("button", { name: "查看任务 #8", exact: true }).click();
|
||||
await page.getByRole("button", { name: "工作流试跑", exact: true }).click();
|
||||
check(
|
||||
(await page.locator("tbody tr").count()) === 1,
|
||||
"workflow test source isolated",
|
||||
);
|
||||
await page.getByRole("button", { name: "全部来源", exact: true }).click();
|
||||
// Pending/running without a numeric report must remain unknown.
|
||||
await page.goto(base + "/tasks");
|
||||
await page.locator("tbody tr").first().waitFor();
|
||||
check(
|
||||
(await page.locator(".task-progress").count()) === 0,
|
||||
"no fabricated percentages after reload",
|
||||
);
|
||||
jobs = [];
|
||||
await page.goto(base + "/tasks");
|
||||
await page.waitForTimeout(400);
|
||||
check((await page.locator("tbody tr").count()) === 0, "true empty data");
|
||||
await page.screenshot({ path: path.join(out, "true-empty.png") });
|
||||
apiError = true;
|
||||
await page.goto(base + "/tasks");
|
||||
await page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "任务加载失败" })
|
||||
.waitFor({ timeout: 15000 });
|
||||
check(true, "API failure shown honestly");
|
||||
apiError = false;
|
||||
jobs = structuredClone(fixture.jobs);
|
||||
await page.getByRole("button", { name: "重试", exact: true }).click();
|
||||
await page.locator("tbody tr").first().waitFor();
|
||||
check(true, "load retry restores tasks");
|
||||
for (const route of [
|
||||
"workflows",
|
||||
"connections",
|
||||
"library",
|
||||
"settings",
|
||||
"create?kind=image",
|
||||
"create?kind=video",
|
||||
]) {
|
||||
await page.goto(base + "/" + route);
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({
|
||||
path: path.join(out, "regression-" + route.replace(/\W/g, "-") + ".png"),
|
||||
});
|
||||
check(
|
||||
(await page.locator(".task-table").count()) === 0,
|
||||
`${route}: task table styles scoped`,
|
||||
);
|
||||
if (route.startsWith("create?")) {
|
||||
const textarea = page.locator(".studio-form textarea").first();
|
||||
await textarea.fill("HEL769 isolated creative draft");
|
||||
check(
|
||||
(await textarea.inputValue()) === "HEL769 isolated creative draft",
|
||||
`${route}: prompt remains editable`,
|
||||
);
|
||||
await page.reload();
|
||||
await page.locator(".studio-form textarea").first().waitFor();
|
||||
check(
|
||||
(await page.locator(".studio-form textarea").first().inputValue()) ===
|
||||
"HEL769 isolated creative draft",
|
||||
`${route}: draft survives reload`,
|
||||
);
|
||||
}
|
||||
if (route === "workflows")
|
||||
check(
|
||||
(await page
|
||||
.locator(".mg-side")
|
||||
.evaluate((e) => e.getBoundingClientRect().width)) === 220,
|
||||
"workflow sidebar retains 220px",
|
||||
);
|
||||
}
|
||||
for (const route of [
|
||||
"/",
|
||||
"/workbench",
|
||||
"/agents",
|
||||
"/canvas",
|
||||
"/build-scene",
|
||||
"/models",
|
||||
"/model-connections",
|
||||
"/image",
|
||||
"/video",
|
||||
]) {
|
||||
await page.goto(base + route);
|
||||
await page.locator(".refract-shell > .sidebar").waitFor({ timeout: 15000 });
|
||||
await page.waitForTimeout(350);
|
||||
check(
|
||||
(await page.locator(".refract-shell > .sidebar").count()) === 1,
|
||||
`${route}: shared rail reachable`,
|
||||
);
|
||||
if (route === "/models")
|
||||
check(
|
||||
page.url().includes("/connections?tab=llm"),
|
||||
"legacy models redirect",
|
||||
);
|
||||
if (route === "/model-connections")
|
||||
check(page.url().includes("/connections"), "connections alias redirect");
|
||||
}
|
||||
await page.goto(base + "/create?kind=image");
|
||||
await page.locator(".studio-form textarea").first().waitFor();
|
||||
check(
|
||||
(await page.locator(".studio-form input[type=file]").count()) === 0,
|
||||
"t2i without image input keeps no padding slot",
|
||||
);
|
||||
await page.keyboard.press("Control+k");
|
||||
await page.getByRole("dialog").waitFor();
|
||||
check(true, "Ctrl+K opens global search");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
await page.goto(base + "/tasks");
|
||||
await page.locator(".task-table tbody tr").first().waitFor();
|
||||
await ref.setViewportSize({ width: 1440, height: 900 });
|
||||
for (const theme of ["night", "day", "grade", "bluehour"]) {
|
||||
await page.evaluate((theme) => {
|
||||
localStorage.setItem("aigc-theme", theme);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
|
||||
);
|
||||
}, theme);
|
||||
await ref.evaluate((theme) => {
|
||||
go("tasks");
|
||||
setTheme(theme);
|
||||
}, theme);
|
||||
await page.waitForTimeout(650);
|
||||
await page
|
||||
.locator('.refract-shell > .sidebar a[href="/workflows"]')
|
||||
.hover();
|
||||
await ref.locator('.rail [data-go="workflows"]').hover();
|
||||
await page.waitForTimeout(250);
|
||||
result.checks.push({
|
||||
name: `${theme}: rail hover`,
|
||||
pass: true,
|
||||
actual: await measure(page, {
|
||||
hover: '.refract-shell > .sidebar a[href="/workflows"]',
|
||||
}),
|
||||
sample: await measure(ref, { hover: '.rail [data-go="workflows"]' }),
|
||||
});
|
||||
const hover = result.checks.at(-1);
|
||||
for (const prop of ["color", "background", "radius"])
|
||||
check(
|
||||
hover.actual.hover[prop] === hover.sample.hover[prop],
|
||||
`${theme}: hover ${prop} matches sample`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(out, `hover-${theme}-actual.png`),
|
||||
});
|
||||
await ref.screenshot({ path: path.join(out, `hover-${theme}-sample.png`) });
|
||||
}
|
||||
await page.locator('.sidebar-bottom button[title="主题"]').click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
const firstChoice = page.getByRole("dialog").locator(".choice").first();
|
||||
await firstChoice.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("Enter");
|
||||
check(
|
||||
(await page.evaluate(() => document.documentElement.dataset.theme)) ===
|
||||
"day",
|
||||
"theme keyboard selection",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
check(
|
||||
await page
|
||||
.locator('.sidebar-bottom button[title="主题"]')
|
||||
.evaluate((e) => e === document.activeElement),
|
||||
"theme dialog restores focus",
|
||||
);
|
||||
await page.locator('button[title="展开导航"]').click();
|
||||
check(
|
||||
(await page.locator(".refract-shell.expanded").count()) === 1,
|
||||
"existing navigation expansion retained",
|
||||
);
|
||||
await page.locator('button[title="收起导航"]').click();
|
||||
await page.goto(base + "/tasks");
|
||||
await page.locator('.sidebar-bottom button[title="主题"]').click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.getByRole("dialog").getByRole("switch").click();
|
||||
await page.getByRole("dialog").locator(".choice").nth(3).click();
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.dataset.theme === "bluehour",
|
||||
);
|
||||
await page.waitForTimeout(50);
|
||||
result.themeTransition = await page.evaluate(() => ({
|
||||
theme: document.documentElement.dataset.theme,
|
||||
animations: document.getAnimations().map((a) => ({
|
||||
name: a.animationName,
|
||||
duration: a.effect?.getTiming().duration,
|
||||
})),
|
||||
reduced: document.documentElement.dataset.reduce,
|
||||
}));
|
||||
check(
|
||||
result.themeTransition.theme === "bluehour",
|
||||
"full-motion theme switch applies",
|
||||
);
|
||||
await page.waitForTimeout(650);
|
||||
check(
|
||||
(await page.locator(".queue-ring").count()) === 1,
|
||||
"real running jobs restore original queue ring in full motion",
|
||||
);
|
||||
await page.getByRole("dialog").getByRole("switch").click();
|
||||
check(
|
||||
(await page.locator(".queue-ring").count()) === 0,
|
||||
"reduced motion removes queue ring",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
if (process.env.HEL769_SKIP_VISUAL) {
|
||||
await page.goto(base + "/settings");
|
||||
for (const [index, id] of ["night", "day", "grade", "bluehour"].entries()) {
|
||||
const tile = page.locator(".choices.tiles .choice").nth(index);
|
||||
await tile.click();
|
||||
check(await tile.getAttribute("aria-pressed") === "true", `${id}: settings selection`);
|
||||
check(await page.evaluate(() => localStorage.getItem("aigc-theme")) === id, `${id}: saved theme`);
|
||||
await page.reload();
|
||||
await page.locator(".choices.tiles").waitFor();
|
||||
check(await page.locator(".choices.tiles .choice").nth(index).getAttribute("aria-pressed") === "true", `${id}: selected theme survives reload`);
|
||||
}
|
||||
for (const [width,height] of [[1440,900],[1366,768],[1280,720]]) {
|
||||
await page.setViewportSize({width,height});
|
||||
for (const theme of ["night", "day", "grade", "bluehour"]) {
|
||||
await page.evaluate(theme => {localStorage.setItem("aigc-theme", theme);window.dispatchEvent(new StorageEvent("storage", {key:"aigc-theme",newValue:theme}));}, theme);
|
||||
await page.goto(base + "/workflows");
|
||||
await page.locator(".mg-layout").waitFor();
|
||||
check(await page.locator(".mg-layout").evaluate(e => getComputedStyle(e).gridTemplateColumns.split(" ").length === 3), `${width}/${theme}: workflow three columns`);
|
||||
await page.goto(base + "/tasks");
|
||||
await page.locator(".task-table tbody tr").first().waitFor();
|
||||
check(await page.locator(".task-table th").count() === 8, `${width}/${theme}: tasks eight columns`);
|
||||
check(await page.locator(".task-table").evaluate(e => e.getBoundingClientRect().width > innerWidth - 150), `${width}/${theme}: task table uses full content width`);
|
||||
}
|
||||
}
|
||||
}
|
||||
check(result.errors.length === 0, "no browser page errors");
|
||||
const recordings = [await page.video().path(), await ref.video().path()];
|
||||
await context.close();
|
||||
result.recordings = recordings.map((p) => path.relative(out, p));
|
||||
} finally {
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
out,
|
||||
process.env.HEL769_SKIP_VISUAL
|
||||
? "interaction-results.json"
|
||||
: "results.json",
|
||||
),
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
await browser.close();
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Foreground isolated evidence run. No user data or external generation services."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import imageio_ffmpeg
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT.parent / "hel769-evidence"
|
||||
OUT.mkdir(exist_ok=True)
|
||||
|
||||
# This database exists only for the duration of this foreground verification.
|
||||
with tempfile.TemporaryDirectory(prefix="hel769-isolated-", dir=OUT) as data:
|
||||
db = sqlite3.connect(Path(data) / "fixture.sqlite")
|
||||
db.execute("create table fixture_jobs (id integer primary key, purpose text, body text)")
|
||||
names = [
|
||||
"镜头 07 · 首尾帧", "分镜草图 8/12", "Qwen 局部重绘 #62",
|
||||
"参考生视频 #21", "Krea2 文生图 #55", "镜头 06 · 首尾帧",
|
||||
]
|
||||
workflow_names = [
|
||||
"H3 首尾帧生视频", "Krea2 · 4 宫格故事板", "Qwen 2.1 · 局部重绘",
|
||||
"H3 参考生视频", "Krea2 · 文生图", "H3 首尾帧生视频",
|
||||
]
|
||||
workflow_rows = []
|
||||
for i in range(6):
|
||||
kind = "video" if i in (0, 3, 5) else "image"
|
||||
job = {
|
||||
"id": i + 1,
|
||||
"project_id": 1 if i in (0, 1, 5) else 0,
|
||||
"scene_id": 1 if i in (0, 1, 5) else None,
|
||||
"clip_id": None,
|
||||
"kind": kind,
|
||||
"workflow_id": i + 1,
|
||||
"status": ["running", "running", "pending", "failed", "done", "done"][i],
|
||||
"payload": {
|
||||
"title": names[i],
|
||||
"source": "workbench" if i in (0, 1, 5) else "studio-" + kind,
|
||||
"endpoint": {"id": "hel769-fake", "name": "5090D"},
|
||||
},
|
||||
"output_paths": (
|
||||
["HEL769-fixture-4.png" if i == 4 else "HEL769-fixture-video.mp4"]
|
||||
if i > 3 else []
|
||||
),
|
||||
"error": "OOM" if i == 3 else None,
|
||||
"created_at": f"2026-10-21T13:0{i}:00Z",
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"retry_count": 0,
|
||||
"phase": "comfy_running" if i < 2 else None,
|
||||
}
|
||||
db.execute(
|
||||
"insert into fixture_jobs values (?,?,?)",
|
||||
(i + 1, "HEL-751 isolated sample correspondence; never production",
|
||||
json.dumps(job, ensure_ascii=False)),
|
||||
)
|
||||
workflow_rows.append({
|
||||
"id": i + 1, "name": workflow_names[i], "kind": kind,
|
||||
"media_io": {"in": [], "out": [kind]}, "tags": [],
|
||||
"favorite": False, "category": None, "model_family": None,
|
||||
"usage": "text2video" if kind == "video" else "text2image",
|
||||
"is_enabled": True, "needs_mapping": False,
|
||||
"input_schema": [{
|
||||
"nodeId": "1", "label": "HEL769 fixture prompt",
|
||||
"role": "prompt", "kind": "textarea", "defaultValue": "", "required": True,
|
||||
}],
|
||||
"output_schema": [{"nodeId": "2", "label": "Output", "kind": kind}],
|
||||
"endpoint_id": "hel769-fake",
|
||||
})
|
||||
db.commit()
|
||||
jobs = [json.loads(row[0]) for row in db.execute("select body from fixture_jobs order by id")]
|
||||
db.close()
|
||||
(Path(data) / "fixtures.json").write_text(
|
||||
json.dumps({"jobs": jobs, "workflows": workflow_rows}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
imageio_ffmpeg.get_ffmpeg_exe(), "-y", "-f", "lavfi", "-i",
|
||||
"color=c=navy:s=320x180:r=24", "-f", "lavfi", "-i",
|
||||
"sine=frequency=440:sample_rate=44100", "-t", "2", "-c:v",
|
||||
"libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(Path(data) / "video.mp4"),
|
||||
],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
|
||||
)
|
||||
env = os.environ | {"HEL769_OUT": str(OUT), "HEL769_DATA": data}
|
||||
subprocess.run(["node", "scripts/verify-hel769.mjs"], cwd=ROOT, env=env, check=True)
|
||||
|
||||
print("Isolated database and media removed; all run-owned servers closed.")
|
||||
|
||||
# Compose unchanged captures into review artifacts; never alter the original HTML.
|
||||
if not os.environ.get("HEL769_SKIP_VISUAL"):
|
||||
evidence = json.loads((OUT / "results.json").read_text())
|
||||
lines = [
|
||||
"| Theme / viewport | Element | Sample x,y,w,h | Actual x,y,w,h | Delta x,y,w,h |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for measurement in evidence["measurements"]:
|
||||
name = f"{measurement['route']}-{measurement['theme']}-{measurement['width']}"
|
||||
sample = Image.open(OUT / f"{name}-sample.png").convert("RGB")
|
||||
actual = Image.open(OUT / f"{name}-actual.png").convert("RGB")
|
||||
pair = Image.new("RGB", (sample.width * 2, sample.height + 28), "#151515")
|
||||
ImageDraw.Draw(pair).text((12, 8), "Confirmed sample (left) / Isolated implementation (right)", fill="white")
|
||||
pair.paste(sample, (0, 28))
|
||||
pair.paste(actual, (sample.width, 28))
|
||||
pair.save(OUT / f"{name}-pair.png")
|
||||
for key, expected in measurement["sample"].items():
|
||||
observed = measurement["actual"][key]
|
||||
if not expected or not observed:
|
||||
continue
|
||||
coordinates = ("x", "y", "width", "height")
|
||||
values = lambda row: ", ".join(f"{row[c]:.3f}" for c in coordinates)
|
||||
delta = {c: observed[c] - expected[c] for c in coordinates}
|
||||
lines.append(
|
||||
f"| {measurement['route']} {measurement['theme']} {measurement['width']}×{measurement['height']} "
|
||||
f"| {key} | {values(expected)} | {values(observed)} | {values(delta)} |"
|
||||
)
|
||||
if key in ("themeName", "themeTone"):
|
||||
lines.append(f"| typography | {key} | {expected['font']} / {expected['line']} | {observed['font']} / {observed['line']} | font-size / line-height |")
|
||||
(OUT / "dimensions.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
import hashlib
|
||||
for f in sorted(OUT.glob("*-internal-sample.png")):
|
||||
actual = Image.open(str(f).replace("-sample.png", "-actual.png")).convert("RGB")
|
||||
sample = Image.open(f).convert("RGB")
|
||||
pair = Image.new("RGB", (sample.width, sample.height * 2 + 48), "#151515")
|
||||
draw = ImageDraw.Draw(pair)
|
||||
draw.text((8, 5), "Confirmed sample", fill="white")
|
||||
pair.paste(sample, (0, 24))
|
||||
draw.text((8, sample.height + 29), "Isolated implementation", fill="white")
|
||||
pair.paste(actual, (0, sample.height + 48))
|
||||
pair.resize((pair.width * 3, pair.height * 3), Image.Resampling.NEAREST).save(str(f).replace("-sample.png", "-pair-3x.png"))
|
||||
files = sorted(OUT.glob("*.png"))
|
||||
manifest = [{"file": f.name, "bytes": f.stat().st_size, "sha256": hashlib.sha256(f.read_bytes()).hexdigest()} for f in files]
|
||||
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
Reference in New Issue
Block a user