Files
Genarrative/scripts/capture-game-lineage-visual.mjs
suzmii 32f4025516
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m15s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m17s
Project CI / Backend tests (pull_request) Successful in 9m29s
Project CI / Frontend tests (pull_request) Successful in 3m27s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m30s
Project CI / Repository checks (pull_request) Successful in 5m28s
Project CI / Native shell tests (pull_request) Successful in 6m55s
test(共创): 新增 AGC 客户端 CDP 真机 Fork 验收 + 窄屏三档核验,并同步实测覆盖文档
2026-10-07 01:27:26 +08:00

1682 lines
64 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.
// 族谱可视化(节点-连线树)的截图与 DOM 断言工具。
//
// 这不是 CI 门禁脚本,是给人看的验收工具:它先造一棵真实的多代多分支树(+一个降级态 fixture),
// 再用 Playwright 打开 /games/lineage?id=<根> 出 5 张截图并顺手做 DOM 断言。
//
// 用法(需要本地 dev 栈 + admin 凭据;playwright 走临时目录,见仓库既有 e2e 的用法):
// E2E_ADMIN_USER=admin E2E_ADMIN_PASSWORD=123456 \
// E2E_PLAYWRIGHT_DIR=%TEMP%/genarrative-pw E2E_CHROMIUM_EXECUTABLE='C:/Program Files/Google/Chrome/Application/chrome.exe' \
// node scripts/capture-game-lineage-visual.mjs
//
// 契约来源(读实现确认;符号检索定位,不写行号):src/components/game-distribution/GameLineagePage.tsx
// - 节点:`.game-lineage-node`(`role="button"` + `tabIndex=0` + `aria-label`)、锚点额外 `--anchor` + `aria-current="true"`、
// 占位节点 `--stub` + `aria-disabled="true"`(标题就是「原作品已不可用」)、非高亮节点 `--dimmed`
// - 画布:`.game-lineage-canvas`(滚轮缩放、指针拖拽平移)→ `.game-lineage-canvas__content` 带 transform
// - 缩放控件:`button[aria-label="放大"]` / `button[aria-label="缩小"]`、百分比 `role="status"` 的 `.game-lineage-zoom`
// - hover:节点 onMouseEnter → 祖先路径高亮、其余 `--dimmed`
// - 降级文案:占位节点标题「原作品已不可用」、页内 notice「母版已不可用…」
// - 首次进入自动 fitToView(不抢用户后续缩放)
// - 布局纯函数:src/components/game-distribution/lineageTreeLayout.ts(缺失父作品会生成 stub 节点)
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { deflateSync } from 'node:zlib';
import JSZip from 'jszip';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const DEV_PASSWORD = 'GenE2e123!';
const GATE_KEY = 'game-distribution:publish';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD');
process.exit(2);
}
const devStack = JSON.parse(
readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'),
);
const API = (
process.env.E2E_API_BASE ??
devStack.services?.['api-server']?.url ??
''
).replace(/\/+$/u, '');
const WEB = (
process.env.E2E_WEB_BASE ??
devStack.services?.web?.url ??
'http://127.0.0.1:3000'
).replace(/\/+$/u, '');
let checks = 0;
let failures = 0;
function check(name, ok, detail = '') {
checks += 1;
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
// ---------- 真实封面图(脚本内生成,不依赖网络/外部资源) ----------
//
// 需求方要凭截图判断「够不够帅」,1×1 透明 PNG 会在卡片上显示成灰块,看不出视觉上限;
// 这里用纯 Node(zlib + 手写 PNG chunk)生成 512×288 的**有内容**封面:每个节点一张、
// 色相不同、并叠加斜条纹与底部条带,便于在截图里一眼分辨不同节点的封面。
const PNG_SIGNATURE = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const PNG_CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
})();
function pngCrc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc = PNG_CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const typeAndData = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(pngCrc32(typeAndData), 0);
return Buffer.concat([length, typeAndData, crc]);
}
function hslToRgb(hue, saturation, lightness) {
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
const hueSection = (((hue % 360) + 360) % 360) / 60;
const second = chroma * (1 - Math.abs((hueSection % 2) - 1));
const base = lightness - chroma / 2;
const table = [
[chroma, second, 0],
[second, chroma, 0],
[0, chroma, second],
[0, second, chroma],
[second, 0, chroma],
[chroma, 0, second],
];
const [red, green, blue] = table[Math.floor(hueSection) % 6];
return [
Math.round((red + base) * 255),
Math.round((green + base) * 255),
Math.round((blue + base) * 255),
];
}
/// 每个 index 一张不同的封面:色相轮转 + 斜条纹 + 底部条带,便于在截图里区分节点。
function makeCoverPng(index, total = 6) {
const width = 512;
const height = 288;
const hue = Math.round((360 / total) * index + 18);
const stripeCount = 3 + index;
const raw = Buffer.alloc((width * 3 + 1) * height);
let offset = 0;
for (let y = 0; y < height; y += 1) {
raw[offset] = 0; // filter: none
offset += 1;
for (let x = 0; x < width; x += 1) {
// 垂直渐变:顶部亮、底部暗
const gradient = 0.72 - (y / height) * 0.42;
// 斜条纹:每张图的条数不同,条纹上提亮
const stripe = (x + y * 0.75) % 96 < 96 / stripeCount ? 0.16 : 0;
// 底部条带:模拟卡片下缘的一条深色 caption
const bottomBand = y > height - 46 ? -0.24 : 0;
const lightness = Math.min(
0.9,
Math.max(0.08, gradient + stripe + bottomBand),
);
const [red, green, blue] = hslToRgb(hue, 0.55, lightness);
raw[offset] = red;
raw[offset + 1] = green;
raw[offset + 2] = blue;
offset += 3;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // color type: truecolor
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
return Buffer.concat([
PNG_SIGNATURE,
pngChunk('IHDR', ihdr),
pngChunk('IDAT', deflateSync(raw, { level: 9 })),
pngChunk('IEND', Buffer.alloc(0)),
]);
}
const COVER_PNG = makeCoverPng(0);
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
signal: AbortSignal.timeout(120_000),
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return {
status: response.status,
text,
json,
data: json?.data,
error: json?.error,
};
}
function brief(body) {
const code = body?.error?.code ?? body?.json?.error?.code ?? '';
return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 160)}`;
}
async function register(prefix) {
const phone = `${prefix}${String(Date.now()).slice(-8)}`;
const response = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
});
return { phone, token: response.data?.token };
}
async function setDisplayName(token, displayName) {
return api('/api/profile/me', {
method: 'PATCH',
token,
body: { displayName },
});
}
function gameMetadata({ title, coverAssetId }) {
return {
title,
summary: '族谱可视化截图 fixture',
description: '',
category: '休闲',
tags: ['e2e'],
coverAssetId,
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
async function uploadCover(token, id, png = COVER_PNG) {
const fileName = `visual-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'lineage-visual', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: png.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200) throw new Error(`直传凭证失败 ${brief(ticket)}`);
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([png], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) throw new Error(`直传失败 ${put.status}`);
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: png.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-lineage-visual',
},
});
if (confirm.status !== 200) throw new Error(`确认素材失败 ${brief(confirm)}`);
return confirm.data.assetObject.assetObjectId;
}
async function createGame({ token, metadata, idemKey, fork }) {
return api('/api/game-distribution/games', {
method: 'POST',
token,
headers: { 'Idempotency-Key': idemKey },
body: fork ? { ...metadata, fork } : metadata,
});
}
async function ownerGame(token, gameId) {
const response = await api(`/api/game-distribution/my-games/${gameId}`, {
token,
});
return response.data?.game ?? null;
}
async function publicVersionIdOf(token, gameId) {
const versions = (await ownerGame(token, gameId))?.versions ?? [];
return (
versions.find((version) => version.status === 'published')?.versionId ?? ''
);
}
function publicDetail(gameId) {
return api(`/api/game-distribution/games/${gameId}`);
}
async function buildZip(marker) {
const zip = new JSZip();
zip.file(
'index.html',
`<!doctype html><html><head><meta charset="utf-8"><title>${marker}</title></head><body><h1>${marker}</h1></body></html>`,
);
const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
return {
bytes,
fileCount: 1,
sha256: createHash('sha256').update(bytes).digest('hex'),
};
}
async function publishToPublic({
token,
admin,
gameId,
gameRevision,
metadata,
stamp,
tag,
label,
changeSummary,
}) {
const zip = await buildZip(`visual-${tag}-${stamp}`);
const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token,
headers: { 'Idempotency-Key': `visual-version-${tag}-${stamp}` },
body: {
packageSha256: zip.sha256,
packageBytes: zip.bytes.length,
packageFileCount: zip.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
// 衍生作品必填「本次核心改动说明」;根节点不传(传了也会被忽略)。
...(changeSummary ? { changeSummary } : {}),
},
});
const versionId = created.data?.versionId;
if (!versionId) {
check(`${label} 建版本`, false, brief(created));
return { versionId: null };
}
const upload = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token,
headers: {
'Idempotency-Key': `visual-upload-${tag}-${stamp}`,
'Content-Type': 'application/zip',
},
binary: zip.bytes,
},
);
if (upload.status !== 200) {
check(`${label} 传发行包`, false, brief(upload));
return { versionId: null };
}
const submitted = await api(
`/api/game-distribution/versions/${versionId}/submit`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': `visual-submit-${tag}-${stamp}` },
body: { expectedPublicationRevision: gameRevision },
},
);
const readback = await api(`/api/game-distribution/versions/${versionId}`, {
token,
});
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `visual-approve-${tag}-${stamp}` },
body: {
decision: 'approve',
expectedPublicationRevision:
readback.data?.version?.publicationRevision ?? gameRevision,
},
},
);
check(
`${label} 发布链路完成(送审 ${submitted.status} / 通过 ${approved.status})`,
submitted.status === 202 && approved.status === 200,
`submitted=${submitted.status} approved=${approved.status}`,
);
return { versionId };
}
async function promoteForkAuthorization({ token, gameId, to, key }) {
return api(`/api/game-distribution/games/${gameId}/fork-authorization`, {
method: 'PUT',
token,
headers: { 'Idempotency-Key': key },
body: { expectedForkAuthorization: 'forbidden', forkAuthorization: to },
});
}
// ---------- 造树 ----------
async function createPublishedNode({
label,
title,
owner,
admin,
parent,
promoteTo,
stamp,
coverPng,
}) {
const coverAssetId = await uploadCover(
owner.token,
`${stamp}-${label}`,
coverPng,
);
const parentVersionId = parent
? await publicVersionIdOf(parent.token, parent.gameId)
: '';
const metadata = gameMetadata({ title, coverAssetId });
const created = await createGame({
token: owner.token,
metadata,
idemKey: `visual-${label}-${stamp}`,
...(parent
? { fork: { parentGameId: parent.gameId, parentVersionId } }
: {}),
});
const gameId = created.data?.id;
check(
`造树:节点 ${label} 创建(${parent ? `父=${parent.label}` : '根'})`,
created.status === 200 && Boolean(gameId),
brief(created),
);
if (!gameId) return { label, gameId: null, token: owner.token, title };
await publishToPublic({
token: owner.token,
admin,
gameId,
gameRevision: created.data?.publicationRevision ?? 0,
metadata,
stamp,
tag: `visual-${label}`,
label,
// 衍生节点(有父)必填核心改动说明:截图里的「每一代差异」用它渲染。
...(parent
? {
changeSummary: `视觉夹具:节点 ${label} 相对 ${parent.label} 的核心改动说明`,
}
: {}),
});
const detail = await publicDetail(gameId);
check(
`造树:节点 ${label} 已公开`,
detail.data?.status === 'published',
`status=${detail.data?.status ?? '∅'}`,
);
// 新语义:衍生作品在创建时**继承**父作品档位(创建后忽略请求里的档位、母版才允许 PUT 提升)。
if (parent) {
const childView = await ownerGame(owner.token, gameId);
const parentView = await ownerGame(parent.token, parent.gameId);
const childTier = childView?.forkAuthorization ?? null;
const parentTier = parentView?.forkAuthorization ?? null;
check(
`造树:衍生节点 ${label} 继承父档位(${childTier} === ${parentTier})`,
childTier !== null && childTier === parentTier,
`child=${childTier} parent=${parentTier}`,
);
} else if (promoteTo) {
const promoted = await promoteForkAuthorization({
token: owner.token,
gameId,
to: promoteTo,
key: `visual-promote-${label}-${stamp}`,
});
check(
`造树:母版节点 ${label} 授权提升=${promoteTo}`,
promoted.status === 200,
brief(promoted),
);
}
return { label, gameId, token: owner.token, title };
}
// ---------- Playwright ----------
async function loadPlaywright() {
const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim();
if (!dir) return import('playwright');
const requireFromDir = createRequire(path.join(dir, 'noop.js'));
return requireFromDir('playwright');
}
/// 窄屏三档核验(360 / 390 / 414):移动端形态(大纲缩进树 / 图谱画布 / bottom sheet / 面包屑)、
/// 触控尺寸与横向溢出,以及**本轮新豁免**:深链进 `/games/lineage?id=…` 首屏不再被
/// 「移动端仅支持作品展示」欢迎弹窗盖住。
///
/// 契约来源(读实现确认,按符号引用):
/// - 移动端形态与默认值:`src/components/game-distribution/GameLineagePage.tsx` 的 `viewMode` /
/// `effectiveViewMode`(`isMobileViewport ? 'outline' : 'canvas'`)
/// - 大纲树:`src/components/game-distribution/LineageOutlineList.tsx` 的 `lineage-outline` /
/// `lineage-outline__row` / `lineage-outline__main` / `lineage-outline__guide`
/// - 底部 sheet:`game-lineage-panel--sheet` + `game-lineage-sheet-backdrop`(点遮罩关闭)
/// - 图谱视图切换:`button[aria-pressed]` 文案「图谱视图」
/// - 面包屑:`game-lineage-breadcrumb__crumb`(`scrollIntoView` 由实现保证)
async function captureNarrowBreakpoints({ browser, lineageUrl, shotDir }) {
const widths = (process.env.E2E_CAPTURE_NARROW_WIDTHS ?? '')
.split(',')
.map((entry) => Number.parseInt(entry.trim(), 10))
.filter((value) => Number.isInteger(value) && value >= 320);
if (widths.length === 0) return;
for (const width of widths) {
const context = await browser.newContext({
viewport: { width, height: 844 },
hasTouch: true,
permissions: ['clipboard-read', 'clipboard-write'],
});
const page = await context.newPage();
const take = async (name) => {
const file = path.join(shotDir, `narrow-${width}-${name}.png`);
await page.screenshot({ path: file, fullPage: false });
console.log(`[lineage-visual] 截图 ${file}`);
return file;
};
try {
await page.goto(lineageUrl, { waitUntil: 'commit', timeout: 120_000 });
await page.waitForFunction(
() =>
document.querySelector('.lineage-outline') !== null ||
document.querySelector('.game-lineage-canvas') !== null,
undefined,
{ timeout: 120_000 },
);
await page.waitForTimeout(1500);
const welcome = await page.evaluate(() => ({
text: document.body.innerText.includes('移动端仅支持作品展示'),
node: document.querySelectorAll(
'[class*="platform-mobile-home-welcome"]',
).length,
}));
check(
`${width}px:深链进族谱页不再被「移动端仅支持作品展示」弹窗盖住`,
!welcome.text && welcome.node === 0,
`text=${welcome.text} nodes=${welcome.node}`,
);
const layout = await page.evaluate(() => {
const rows = [...document.querySelectorAll('.lineage-outline__row')];
const mains = rows
.map((row) => row.querySelector('.lineage-outline__main'))
.filter(Boolean);
const rects = mains.map((el) => el.getBoundingClientRect());
const outer = document.querySelector('.lineage-outline');
const outerRight = outer
? outer.getBoundingClientRect().right
: window.innerWidth;
return {
outline: document.querySelectorAll('.lineage-outline').length,
rows: rows.length,
guides: document.querySelectorAll('.lineage-outline__guide').length,
minRowHeight: rows.length
? Math.min(
...rows.map((el) =>
Math.round(el.getBoundingClientRect().height),
),
)
: 0,
firstIndent: mains.length
? getComputedStyle(mains[0]).paddingLeft
: '',
lastIndent: mains.length
? getComputedStyle(mains[mains.length - 1]).paddingLeft
: '',
// 缩进的真实载体是「每一代多一列导线」:比较各行内容的左边界更有意义(padding 不变)。
firstLeft: rects.length ? Math.round(rects[0].left) : 0,
lastLeft: rects.length ? Math.round(rects[rects.length - 1].left) : 0,
distinctLefts: new Set(rects.map((box) => Math.round(box.left))).size,
overflowingRows: rects.filter((box) => box.right > outerRight + 1)
.length,
docOverflow: document.documentElement.scrollWidth - window.innerWidth,
};
});
check(
`${width}px:默认是大纲式缩进树(有行、有导线、有缩进层级)`,
layout.outline === 1 &&
layout.rows >= 2 &&
layout.guides >= 1 &&
layout.distinctLefts >= 2 &&
layout.lastLeft - layout.firstLeft >= 8,
`rows=${layout.rows} guides=${layout.guides} leftOrders=${layout.firstLeft}→${layout.lastLeft} levels=${layout.distinctLefts}`,
);
check(
`${width}px:大纲行高 ≥44(触控友好)且长标题不横向溢出`,
layout.minRowHeight >= 44 &&
layout.overflowingRows === 0 &&
layout.docOverflow <= 1,
`minRowHeight=${layout.minRowHeight} overflowingRows=${layout.overflowingRows} docOverflow=${layout.docOverflow}`,
);
await take('outline');
// 点第一行 → bottom sheet
await page
.locator('.lineage-outline__row .lineage-outline__main')
.first()
.click();
await page.waitForTimeout(1200);
const sheet = await page.evaluate(() => {
const panel = document.querySelector('.game-lineage-panel--sheet');
const backdrop = document.querySelector('.game-lineage-sheet-backdrop');
if (!panel) return null;
const box = panel.getBoundingClientRect();
return {
bottomGap: Math.round(window.innerHeight - box.bottom),
height: Math.round(box.height),
hasBackdrop: Boolean(backdrop),
};
});
check(
`${width}px:点节点弹出底部 sheet(贴底、有遮罩)`,
Boolean(sheet) && sheet.bottomGap <= 6 && sheet.hasBackdrop,
JSON.stringify(sheet),
);
await take('sheet');
// 关闭 sheet:先试显式的「关闭信息面板」按钮(遮罩就是它),再试 Esc。
const closeButton = page
.getByRole('button', { name: '关闭信息面板' })
.first();
let sheetClosed = false;
if ((await closeButton.count()) > 0) {
await closeButton.click({ force: true }).catch(() => {});
await page.waitForTimeout(600);
sheetClosed =
(await page.locator('.game-lineage-panel--sheet').count()) === 0;
}
if (!sheetClosed) {
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(400);
sheetClosed =
(await page.locator('.game-lineage-panel--sheet').count()) === 0;
}
check(`${width}px:底部 sheet 可关闭(遮罩按钮或 Esc)`, sheetClosed);
// 画布检查前重载一次:保证不残留 sheet 与滚动位置,切形态的判断才干净。
await page.reload({ waitUntil: 'commit', timeout: 120_000 });
await page.waitForFunction(
() =>
document.querySelector('.lineage-outline') !== null ||
document.querySelector('.game-lineage-canvas') !== null,
undefined,
{ timeout: 120_000 },
);
await page.waitForTimeout(1500);
await page.evaluate(() => window.scrollTo(0, 0));
// 切图谱视图:画布初始定位应把「当前作品」放进画布
const toggle = page.getByText('图谱视图', { exact: true }).first();
if ((await toggle.count()) > 0) {
await toggle.scrollIntoViewIfNeeded().catch(() => {});
await toggle.click({ timeout: 20000 }).catch(async () => {
await toggle.click({ force: true, timeout: 20000 }).catch(() => {});
});
await page.waitForTimeout(2500);
const canvas = await page.evaluate(() => {
const canvasEl = document.querySelector('.game-lineage-canvas');
const anchor = document.querySelector('.game-lineage-node--anchor');
if (!canvasEl) return null;
const canvasBox = canvasEl.getBoundingClientRect();
const anchorBox = anchor?.getBoundingClientRect();
return {
anchorFound: Boolean(anchor),
anchorInside:
Boolean(anchorBox) &&
anchorBox.left >= canvasBox.left - 2 &&
anchorBox.right <= canvasBox.right + 2 &&
anchorBox.top >= canvasBox.top - 2 &&
anchorBox.bottom <= canvasBox.bottom + 2,
zoom: document
.querySelector('.game-lineage-zoom')
?.textContent?.trim(),
hint: document.body.innerText.includes('已按可读缩放显示'),
};
});
check(
`${width}px:图谱视图(横向画布)首屏把当前作品放进画布`,
Boolean(canvas) && canvas.anchorFound && canvas.anchorInside,
JSON.stringify(canvas),
);
await take('canvas');
// 选中一个节点 → 面包屑;当前面包屑必须已被滚进视口
const target = page.locator('.game-lineage-node').nth(1);
if ((await target.count()) > 0) {
await target.click({ position: { x: 6, y: 6 } });
await page.waitForTimeout(1200);
const crumb = await page.evaluate(() => {
const items = [
...document.querySelectorAll('.game-lineage-breadcrumb__crumb'),
];
if (items.length === 0) return null;
const box = items[items.length - 1].getBoundingClientRect();
return {
count: items.length,
right: Math.round(box.right),
left: Math.round(box.left),
innerWidth: window.innerWidth,
insideViewport:
box.left >= -1 && box.right <= window.innerWidth + 1,
};
});
check(
`${width}px:选中节点后面包屑出现且当前项被滚进视口`,
Boolean(crumb) && crumb.count >= 1 && crumb.insideViewport,
JSON.stringify(crumb),
);
await take('breadcrumb');
}
}
} finally {
await context.close();
}
}
}
/// 「只截图、不造树」模式:给已有作品(例如 scripts/seed-game-distribution-co-creation.mjs 灌的共创网络)
/// 出验收截图。设 `E2E_CAPTURE_LINEAGE_URL` 即进入该模式——不注册账号、不建作品、不下架,纯 GET + 截图。
async function captureExistingUrls() {
const lineageUrl = (process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim();
const themeUrl = (process.env.E2E_CAPTURE_THEME_URL ?? '').trim();
const selectTitle = (process.env.E2E_CAPTURE_SELECT_TITLE ?? '').trim();
// 最小节点数:两节点的子树(例如「母版 → 一个衍生」)也要能截,默认 3。
const minNodes =
Number.parseInt((process.env.E2E_CAPTURE_MIN_NODES ?? '').trim(), 10) || 3;
// 视口可配:全树总览常常比 960 高,给个高的窗口才能真的「一眼看全」。
const sizeMatch = /^(\d{3,4})x(\d{3,4})$/u.exec(
(process.env.E2E_CAPTURE_WINDOW ?? '').trim(),
);
const desktopViewport = sizeMatch
? { width: Number(sizeMatch[1]), height: Number(sizeMatch[2]) }
: { width: 1440, height: 960 };
const { chromium } = await loadPlaywright();
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
const shotDir = await mkdtemp(
path.join(tmpdir(), 'genarrative-co-creation-visual-'),
);
const shots = [];
const take = async (target, name) => {
const file = path.join(shotDir, name);
await target.screenshot({ path: file, fullPage: false });
shots.push(file);
console.log(`[lineage-visual] 截图 ${file}`);
return file;
};
try {
// 封面是远端资源:等所有 <img> 落地再截,否则会出现灰块假象。
const waitImages = async (target) => {
await target
.waitForFunction(
() =>
[...document.images].every(
(img) => img.complete && img.naturalWidth > 0,
),
undefined,
{ timeout: 60_000 },
)
.catch(() => {});
await target.waitForTimeout(600);
};
// ---------- 桌面:全树总览 ----------
const desktop = await browser.newContext({
viewport: desktopViewport,
});
const page = await desktop.newPage();
await page.goto(lineageUrl, { waitUntil: 'commit', timeout: 120_000 });
await page.waitForFunction(
(min) => document.querySelectorAll('.game-lineage-node').length >= min,
minNodes,
{ timeout: 120_000 },
);
await page.waitForTimeout(1400);
await waitImages(page);
const nodeCount = await page.locator('.game-lineage-node').count();
check(
'截图模式:/games/lineage 已渲染多代节点',
nodeCount >= minNodes,
`nodes=${nodeCount} url=${lineageUrl}`,
);
// 首屏视野契约(与 lineage 工具同口径):要么真的全览,要么按可读缩放下限 + 提示。
const fit = await page.evaluate(() => {
const canvas = document
.querySelector('.game-lineage-canvas')
?.getBoundingClientRect();
if (!canvas) return { allInside: false, hint: false };
const allInside = [
...document.querySelectorAll('.game-lineage-node'),
].every((node) => {
const box = node.getBoundingClientRect();
return (
box.left >= canvas.left - 2 &&
box.top >= canvas.top - 2 &&
box.right <= canvas.right + 2 &&
box.bottom <= canvas.bottom + 2
);
});
return {
allInside,
hint: [...document.querySelectorAll('*')].some((element) =>
(element.textContent ?? '').includes('已按可读缩放显示'),
),
};
});
check(
'截图模式:首屏要么全树在画布内,要么给出「已按可读缩放显示」提示',
fit.allInside || fit.hint,
`allInside=${fit.allInside} hint=${fit.hint} 视口=${desktopViewport.width}x${desktopViewport.height}`,
);
await take(page, '01-lineage-overview.png');
// ---------- 桌面:选中态(信息面板 + 面包屑) ----------
// 注意:卡片主体是「打开详情」按钮(stopPropagation),点它会跳走;要「选中/锁定」得点卡片**内边距**(6,6)。
const card = selectTitle
? page
.locator('.game-lineage-node')
.filter({ hasText: selectTitle })
.first()
: page.locator('.game-lineage-node').nth(1);
let selected = false;
if ((await card.count()) > 0) {
await card.click({ position: { x: 6, y: 6 } });
selected = true;
} else {
await page
.locator('.game-lineage-node')
.nth(1)
.click({ position: { x: 6, y: 6 } });
selected = true;
}
if (selected) {
await page
.locator('.game-lineage-panel')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
const crumbs = await page
.locator('.game-lineage-breadcrumb__crumb')
.count();
check(
'截图模式:选中节点后出现信息面板与面包屑',
crumbs >= 1,
`面包屑=${crumbs}`,
);
await waitImages(page);
await take(page, '02-lineage-selected.png');
}
// ---------- 窄屏 ----------
// 移动端自 2026-10-07 起默认是**大纲式缩进树**(`lineage-outline`),不再默认渲染画布节点;
// 这里两种形态都接受,只断言「有可交互节点 + 页面不横向溢出」。
const mobile = await browser.newContext({
viewport: { width: 390, height: 844 },
});
const mobilePage = await mobile.newPage();
await mobilePage.goto(lineageUrl, {
waitUntil: 'commit',
timeout: 120_000,
});
await mobilePage.waitForFunction(
(min) =>
document.querySelectorAll('.game-lineage-node').length >= min ||
document.querySelectorAll('.lineage-outline__row').length >= min,
minNodes,
{ timeout: 120_000 },
);
await mobilePage.waitForTimeout(1400);
// 窄屏首次进入可能弹「移动端仅支持作品展示」的欢迎弹窗(族谱深链已豁免,这里兜底):
// 先点掉再截图,否则截出来只剩一个遮罩弹窗。
const welcomeOk = mobilePage.getByRole('button', {
name: '好',
exact: true,
});
if ((await welcomeOk.count()) > 0) {
await welcomeOk.first().click();
await mobilePage.waitForTimeout(800);
}
await waitImages(mobilePage);
const narrow = await mobilePage.evaluate(() => ({
canvas: Boolean(document.querySelector('.game-lineage-canvas')),
outline: document.querySelectorAll('.lineage-outline__row').length,
nodes: document.querySelectorAll('.game-lineage-node').length,
overflow: document.documentElement.scrollWidth - window.innerWidth > 1,
}));
check(
'截图模式:窄屏下族谱有可交互节点(大纲或画布)且页面不横向溢出',
(narrow.outline >= minNodes || narrow.nodes >= minNodes) &&
!narrow.overflow,
`outline=${narrow.outline} nodes=${narrow.nodes} canvas=${narrow.canvas} overflow=${narrow.overflow}`,
);
await take(mobilePage, '03-lineage-narrow.png');
await mobile.close();
// ---------- 主题页 ----------
if (themeUrl) {
const themePage = await desktop.newPage();
await themePage.goto(themeUrl, { waitUntil: 'commit', timeout: 120_000 });
await themePage
.locator('.game-theme-root')
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
await themePage.waitForTimeout(1000);
await waitImages(themePage);
const roots = await themePage.locator('.game-theme-root').count();
check('截图模式:主题页已渲染作品树', roots >= 1, `roots=${roots}`);
await take(themePage, '04-theme-page.png');
void themePage;
}
await desktop.close();
await captureNarrowBreakpoints({ browser, lineageUrl, shotDir });
console.log(`[lineage-visual] 截图目录=${shotDir}`);
console.log(`[lineage-visual] 结果:check=${checks} fail=${failures}`);
} finally {
await browser.close();
}
return shots;
}
async function main() {
if ((process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim()) {
await captureExistingUrls();
const failed = failures > 0;
console.log(
`[lineage-visual] 只用现有作品截图:check=${checks} fail=${failures}`,
);
process.exit(failed ? 1 : 0);
}
console.log(`[lineage-visual] api-server=${API} web=${WEB}`);
const stamp = Date.now();
const suffix = String(stamp).slice(-6);
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
check('管理员登录', Boolean(admin), `status=${adminLogin.status}`);
if (!admin) process.exit(1);
await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: GATE_KEY,
enabled: true,
rolloutPercent: 100,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: '族谱可视化截图',
},
});
const authorA = await register('132');
const authorB = await register('133');
const nameA = `可视化作者甲${suffix}`;
const nameB = `可视化作者乙${suffix}`;
await setDisplayName(authorA.token, nameA);
await setDisplayName(authorB.token, nameB);
check(
'两个作者账号就绪(用于分支上的作者多样性)',
Boolean(authorA.token && authorB.token),
`phones=${authorA.phone}/${authorB.phone}`,
);
// 每个节点一张不同的真实封面(脚本内生成,512×288 渐变+斜条纹),便于在截图里区分节点。
const coverFor = (index) => makeCoverPng(index, 8);
// 富树:1 根 + 3 直接子 + 1 孙 + 1 曾孙
const root = await createPublishedNode({
label: 'R',
title: `可视化母版 ${suffix}`,
owner: authorA,
admin,
promoteTo: 'nonCommercial',
stamp,
coverPng: coverFor(0),
});
const b1 = await createPublishedNode({
label: 'B1',
title: `分支 B1 ${suffix}`,
owner: authorA,
admin,
parent: root,
promoteTo: 'nonCommercial',
stamp,
coverPng: coverFor(1),
});
const c1 = await createPublishedNode({
label: 'C1',
title: `分支 C1 ${suffix}`,
owner: authorB,
admin,
parent: root,
promoteTo: 'full',
stamp,
coverPng: coverFor(2),
});
const f1 = await createPublishedNode({
label: 'F1',
title: `分支 F1 ${suffix}`,
owner: authorB,
admin,
parent: root,
stamp,
coverPng: coverFor(3),
});
const d = await createPublishedNode({
label: 'D',
title: `二代 D ${suffix}`,
owner: authorA,
admin,
parent: b1,
promoteTo: 'nonCommercial',
stamp,
coverPng: coverFor(4),
});
const e = await createPublishedNode({
label: 'E',
title: `三代 E ${suffix}`,
owner: authorB,
admin,
parent: d,
stamp,
coverPng: coverFor(5),
});
const nodeIds = {
R: root.gameId,
B1: b1.gameId,
C1: c1.gameId,
F1: f1.gameId,
D: d.gameId,
E: e.gameId,
};
const allBuilt = Object.values(nodeIds).every(Boolean);
check('富树 6 节点全部建成', allBuilt, JSON.stringify(nodeIds));
if (!allBuilt) process.exit(1);
// 降级态 fixture:父作品下架后仍保留子作品(子作品的树里父节点应显示「原作品已不可用」)
const degradedParent = await createPublishedNode({
label: 'P',
title: `降级母版 ${suffix}`,
owner: authorA,
admin,
promoteTo: 'nonCommercial',
stamp,
coverPng: coverFor(6),
});
const degradedChild = await createPublishedNode({
label: 'P-C',
title: `降级子作品 ${suffix}`,
owner: authorB,
admin,
parent: degradedParent,
stamp,
coverPng: coverFor(7),
});
const parentNow = await ownerGame(authorA.token, degradedParent.gameId);
const unpublish = await api(
`/api/game-distribution/games/${degradedParent.gameId}/unpublish`,
{
method: 'POST',
token: authorA.token,
headers: { 'Idempotency-Key': `visual-unpublish-${stamp}` },
body: {
expectedPublicationRevision: parentNow?.publicationRevision ?? 0,
},
},
);
check(
'降级 fixture:父作品已下架(子作品仍公开)',
unpublish.status === 200,
brief(unpublish),
);
const richUrl = `${WEB}/games/lineage?id=${nodeIds.R}`;
const degradedUrl = `${WEB}/games/lineage?id=${degradedChild.gameId}`;
console.log(`[lineage-visual] 富树 URL=${richUrl}`);
console.log(`[lineage-visual] 降级态 URL=${degradedUrl}`);
const { chromium } = await loadPlaywright();
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
const shotDir = await mkdtemp(
path.join(tmpdir(), 'genarrative-lineage-visual-'),
);
const shots = [];
const takeShot = async (page, name) => {
const file = path.join(shotDir, name);
await page.screenshot({ path: file, fullPage: false });
shots.push(file);
console.log(`[lineage-visual] 截图 ${file}`);
return file;
};
// 初次进入的视野契约:要么真的全览(所有节点在画布内),要么按可读缩放下限显示并给出提示。
const fitContract = async (target) => {
const zoom = Number.parseInt(
await target.locator('.game-lineage-zoom').first().innerText(),
10,
);
const hint = await target
.getByText('已按可读缩放显示', { exact: false })
.count();
const allInside = await target.evaluate(() => {
const canvas = document
.querySelector('.game-lineage-canvas')
?.getBoundingClientRect();
if (!canvas) return false;
return [...document.querySelectorAll('.game-lineage-node')].every(
(node) => {
const box = node.getBoundingClientRect();
return (
box.left >= canvas.left - 2 &&
box.top >= canvas.top - 2 &&
box.right <= canvas.right + 2 &&
box.bottom <= canvas.bottom + 2
);
},
);
});
return { zoom, hint, allInside };
};
try {
// ---------- 桌面:总览 / 缩放 / hover ----------
const desktop = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await desktop.newPage();
await page.goto(richUrl, { waitUntil: 'commit', timeout: 120_000 });
const nodeCards = page.locator('.game-lineage-node');
await page.waitForFunction(
() => document.querySelectorAll('.game-lineage-node').length >= 6,
undefined,
{ timeout: 120_000 },
);
await page.waitForTimeout(900);
const apiTree = await api(
`/api/game-distribution/games/${nodeIds.R}/lineage`,
);
const apiNodeCount = (apiTree.data?.nodes ?? []).length;
const domNodeCount = await nodeCards.count();
check(
'DOM 断言:画布节点数 = 接口返回的可见节点数',
domNodeCount === apiNodeCount,
`dom=${domNodeCount} api=${apiNodeCount}`,
);
const generations = await nodeCards.evaluateAll((nodes) =>
nodes.map((node) => Number(node.getAttribute('data-generation'))),
);
check(
'DOM 断言:节点代际分布覆盖 0/1/2/3(多代可见)',
[0, 1, 2, 3].every((generation) => generations.includes(generation)),
`generations=${generations.sort().join(',')}`,
);
const anchor = page.locator('.game-lineage-node--anchor');
check(
'DOM 断言:当前作品节点有可识别标记(--anchor + aria-current)',
(await anchor.count()) === 1 &&
(await anchor.first().getAttribute('aria-current')) === 'true' &&
(await anchor
.first()
.innerText()
.then((text) => text.includes('当前作品'))),
`anchorCount=${await anchor.count()}`,
);
const accessibleNames = await nodeCards.evaluateAll((nodes) =>
nodes.every((node) => (node.getAttribute('aria-label') ?? '').length > 0),
);
check(
'DOM 断言:每个节点卡片都有可访问名称(aria-label)',
accessibleNames,
);
const openButtons = await page.locator('.game-lineage-node__open').count();
check(
'DOM 断言:每个可交互节点都有可聚焦的「打开《…》详情」主行动作(占位节点除外)',
openButtons >= 6,
`openButtons=${openButtons}`,
);
const b1Node = nodeCards.filter({ hasText: b1.title }).first();
const c1Node = nodeCards.filter({ hasText: c1.title }).first();
const rootNode = nodeCards.filter({ hasText: root.title }).first();
check(
'DOM 断言:三个直接子分支与根都在画布上',
(await rootNode.count()) === 1 &&
(await b1Node.count()) === 1 &&
(await c1Node.count()) === 1 &&
(await nodeCards.filter({ hasText: e.title }).count()) === 1,
);
// 本轮返工点:节点卡片减负(只留 封面/代际/标题/作者/Fork)+ 真实封面缩略图
const cardText = await nodeCards.first().innerText();
check(
'DOM 断言:节点卡片已减负(无「次游玩」、无「与原版对比」)',
!cardText.includes('次游玩') &&
(await page.locator('.game-lineage-node__compare').count()) === 0 &&
(await page.getByText('与原版对比').count()) === 0 &&
(await page.locator('.game-lineage-node__fork').count()) >= 6,
`cardText=${cardText.replace(/\n/gu, ' / ').slice(0, 70)}`,
);
const coverImages = await page
.locator('.game-lineage-node__cover img')
.count();
check(
'DOM 断言:节点卡片渲染真实封面缩略图(coverObjectKey 有值才渲染)',
coverImages >= 6,
`nodeCoverImages=${coverImages}`,
);
const axisVisible = await page.locator('.game-lineage-axis').count();
check(
'DOM 断言:代际轴是独立固定层且标签未被画布裁掉',
axisVisible >= 1 &&
(await page
.locator('.game-lineage-axis')
.first()
.boundingBox()
.then((box) => Boolean(box) && box.x >= 0)),
`axisLayers=${axisVisible}`,
);
const initialFit = await fitContract(page);
check(
'DOM 断言(桌面初访):要么真全览,要么按可读缩放下限(≥60%)显示并给出提示',
initialFit.allInside || (initialFit.zoom >= 60 && initialFit.hint >= 1),
`zoom=${initialFit.zoom}% hint=${initialFit.hint} allInside=${initialFit.allInside}`,
);
await takeShot(page, 'lineage-01-overview.png');
// ② zoom 到分支
for (let index = 0; index < 4; index += 1) {
await page.locator('button[aria-label="放大"]').first().click();
await page.waitForTimeout(150);
}
const zoomLabel = await page
.locator('.game-lineage-zoom')
.first()
.innerText();
await takeShot(page, 'lineage-02-zoom-branch.png');
check(
'DOM 断言:放大后缩放百分比上升',
Number.parseInt(zoomLabel, 10) > 100,
`zoom=${zoomLabel}`,
);
// ⑦ 放大状态下 hover:连线级路径强化特写(本轮返工的核心诉求)
await page
.locator('.game-lineage-node')
.filter({ hasText: b1.title })
.first()
.hover();
await page.waitForTimeout(500);
const activeEdges = await page
.locator('.game-lineage-edge--active')
.count();
const dimmedEdges = await page
.locator('.game-lineage-edge--dimmed')
.count();
await takeShot(page, 'lineage-07-hover-edge-path.png');
check(
'DOM 断言:hover 时「祖先路径的连线」被强化(--active 边 ≥1 且其余边 --dimmed)',
activeEdges >= 1 && dimmedEdges >= 1,
`activeEdges=${activeEdges} dimmedEdges=${dimmedEdges}`,
);
const strokeWidth = await page
.locator('.game-lineage-edge')
.first()
.evaluate((node) => getComputedStyle(node).strokeWidth);
const vectorEffect = await page
.locator('.game-lineage-edge')
.first()
.evaluate((node) => getComputedStyle(node).vectorEffect);
check(
'DOM 断言:连线用 non-scaling-stroke(缩放后线宽仍可辨)',
vectorEffect === 'non-scaling-stroke',
`strokeWidth=${strokeWidth} vectorEffect=${vectorEffect}`,
);
// ⑥ 点「适应窗口」→ 全览(所有节点都落在画布内)
const zoomBeforeFit = Number.parseInt(
await page.locator('.game-lineage-zoom').first().innerText(),
10,
);
await page.locator('button[aria-label="适应窗口"]').first().click();
await page.waitForTimeout(600);
const zoomAfterFit = Number.parseInt(
await page.locator('.game-lineage-zoom').first().innerText(),
10,
);
const allInsideCanvas = await page.evaluate(() => {
const canvas = document
.querySelector('.game-lineage-canvas')
?.getBoundingClientRect();
if (!canvas) return false;
return [...document.querySelectorAll('.game-lineage-node')].every(
(node) => {
const box = node.getBoundingClientRect();
return (
box.left >= canvas.left - 2 &&
box.top >= canvas.top - 2 &&
box.right <= canvas.right + 2 &&
box.bottom <= canvas.bottom + 2
);
},
);
});
await takeShot(page, 'lineage-06-fit-window.png');
check(
'DOM 断言:点「适应窗口」后 6 个节点全部落在画布内(真正的全览)',
allInsideCanvas && zoomAfterFit <= zoomBeforeFit,
`zoom ${zoomBeforeFit}% → ${zoomAfterFit}% allInside=${allInsideCanvas}`,
);
// 复位视野
await page.reload({ waitUntil: 'commit', timeout: 120_000 });
await page.waitForFunction(
() => document.querySelectorAll('.game-lineage-node').length >= 6,
undefined,
{ timeout: 120_000 },
);
await page.waitForTimeout(900);
// ③ hover 中间节点 → 祖先路径高亮、其余降透明度
await nodeCards.filter({ hasText: b1.title }).first().hover();
await page.waitForTimeout(500);
const dimmedTitles = await page
.locator('.game-lineage-node--dimmed')
.evaluateAll((nodes) => nodes.map((node) => node.textContent ?? ''));
const b1Dimmed = await nodeCards
.filter({ hasText: b1.title })
.first()
.getAttribute('class');
const rootDimmed = await nodeCards
.filter({ hasText: root.title })
.first()
.getAttribute('class');
await takeShot(page, 'lineage-03-hover-ancestor.png');
check(
'DOM 断言:hover 中间节点 → 自身与祖先不高亮(不降透明度)',
!(b1Dimmed ?? '').includes('--dimmed') &&
!(rootDimmed ?? '').includes('--dimmed'),
`b1=${b1Dimmed} root=${rootDimmed}`,
);
check(
'DOM 断言:hover 时同代兄弟分支降透明度',
dimmedTitles.some((text) => text.includes(c1.title)) &&
dimmedTitles.some((text) => text.includes(f1.title)),
`dimmed=${dimmedTitles.length} 条`,
);
// 点击子节点 → 跳详情页
await page.reload({ waitUntil: 'commit', timeout: 120_000 });
await page.waitForFunction(
() => document.querySelectorAll('.game-lineage-node').length >= 6,
undefined,
{ timeout: 120_000 },
);
await page.waitForTimeout(800);
await nodeCards.filter({ hasText: d.title }).first().click();
await page.waitForTimeout(800);
check(
'DOM 断言:点击节点跳到该作品详情页(URL 含其 id)',
page.url().includes(`/games/detail`) && page.url().includes(nodeIds.D),
`url=${page.url()}`,
);
await page.goto(richUrl, { waitUntil: 'commit', timeout: 120_000 });
await page.waitForFunction(
() => document.querySelectorAll('.game-lineage-node').length >= 6,
undefined,
{ timeout: 120_000 },
);
await page.waitForTimeout(800);
// 关键字可达:主行动作(打开《…》详情)聚焦后按 Enter 应跳转(卡片主体现在=选中,不再跳详情)。
const openE = page.locator(
`.game-lineage-node__open[aria-label="打开《${e.title}》详情"]`,
);
await openE.first().focus();
await page.keyboard.press('Enter');
await page.waitForTimeout(800);
check(
'DOM 断言:键盘 Enter(在「打开详情」主行动作上)能跳转',
page.url().includes(nodeIds.E),
`url=${page.url()}`,
);
// 本轮终稿的核心:卡片主体点击 = 选中并保持路径高亮 + 面包屑(不跳详情)
await page.goto(richUrl, { waitUntil: 'commit', timeout: 120_000 });
await page.waitForFunction(
() => document.querySelectorAll('.game-lineage-node').length >= 6,
undefined,
{ timeout: 120_000 },
);
await page.waitForTimeout(900);
const b1CardForSelect = page
.locator('.game-lineage-node')
.filter({ hasText: b1.title })
.first();
await b1CardForSelect.click({ position: { x: 6, y: 6 } });
await page.waitForTimeout(700);
const breadcrumb = page.locator('.game-lineage-breadcrumb');
const crumbText = await breadcrumb
.first()
.innerText()
.catch(() => '');
const selectedDimmed = await page
.locator('.game-lineage-node--dimmed')
.count();
const selectedActiveEdges = await page
.locator('.game-lineage-edge--active')
.count();
check(
'DOM 断言(新):点击卡片主体 → 出现面包屑「已选中的路径」且包含根与当前节点',
(await breadcrumb.count()) >= 1 &&
crumbText.includes(root.title) &&
crumbText.includes(b1.title),
`crumb=${crumbText.replace(/\n+/gu, ' › ').slice(0, 70)}`,
);
check(
'DOM 断言(新):点击后路径持续高亮(--active 边 ≥1 且其余节点降透明度)',
selectedActiveEdges >= 1 && selectedDimmed >= 1,
`activeEdges=${selectedActiveEdges} dimmed=${selectedDimmed}`,
);
await page.mouse.move(5, 5);
await page.waitForTimeout(500);
const dimmedAfterHoverOut = await page
.locator('.game-lineage-node--dimmed')
.count();
check(
'DOM 断言(新):鼠标移开后选中路径仍保持(不是 hover 一次性效果)',
dimmedAfterHoverOut >= 1,
`dimmed=${dimmedAfterHoverOut}`,
);
check(
'DOM 断言(新):卡片主体点击不会跳走详情页',
page.url().includes('/games/lineage'),
`url=${page.url()}`,
);
await takeShot(page, 'lineage-08-selected-path.png');
// ---------- 移动端窄屏 ----------
// 只给窄视口宽度(不模拟触摸设备):验证窄屏布局不溢出 + 画布内部可横向平移。
const mobile = await browser.newContext({
viewport: { width: 390, height: 844 },
});
const mobilePage = await mobile.newPage();
await mobilePage.goto(richUrl, { waitUntil: 'commit', timeout: 120_000 });
await mobilePage.waitForFunction(
() => document.querySelectorAll('.game-lineage-node').length >= 6,
undefined,
{ timeout: 120_000 },
);
await mobilePage.waitForTimeout(1200);
// 窄屏首访会弹「欢迎/移动端仅支持作品展示」弹层(platform-mobile-home-welcome-overlay,z-90 覆盖全屏),
// 它会拦住画布的指针事件 —— 按真实用户路径先关掉它(按钮文案「好」)。
const welcomeOverlay = mobilePage.locator(
'.platform-mobile-home-welcome-overlay',
);
const welcomeVisible = await welcomeOverlay
.first()
.waitFor({ state: 'visible', timeout: 5_000 })
.then(() => true)
.catch(() => false);
if (welcomeVisible) {
const dismiss = mobilePage.getByRole('button', {
name: '好',
exact: true,
});
if ((await dismiss.count()) > 0) {
await dismiss.first().click();
await mobilePage.waitForTimeout(600);
}
}
console.log(
`[lineage-visual] 窄屏欢迎弹层:${welcomeVisible ? '出现过并已关闭(按钮「好」)' : '未出现'}`,
);
const overflow = await mobilePage.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
bodyScrollWidth: document.body.scrollWidth,
}));
check(
'DOM 断言(窄屏):页面本身不横向溢出(画布内部才平移)',
overflow.scrollWidth <= overflow.innerWidth + 1 &&
overflow.bodyScrollWidth <= overflow.innerWidth + 1,
JSON.stringify(overflow),
);
const narrowFit = await fitContract(mobilePage);
check(
'DOM 断言(窄屏初访):不再一进来 35% —— 要么真全览,要么 ≥可读下限并给提示',
narrowFit.allInside || (narrowFit.zoom >= 60 && narrowFit.hint >= 1),
`zoom=${narrowFit.zoom}% hint=${narrowFit.hint} allInside=${narrowFit.allInside}`,
);
const canvasBox = await mobilePage
.locator('.game-lineage-canvas')
.first()
.boundingBox();
const contentTransform = async () => {
for (let attempt = 0; attempt < 3; attempt += 1) {
const value = await mobilePage
.locator('.game-lineage-canvas__content')
.first()
.evaluate((node) => getComputedStyle(node).transform, undefined, {
timeout: 8_000,
})
.catch(() => null);
if (value) return value;
await mobilePage.waitForTimeout(700);
}
return 'unavailable';
};
const transformBefore = await contentTransform();
// 起拖点必须在**视口内**且落在画布上:窄屏下画布底部会伸出视口(画布高 56vh+,底部在折叠线外),
// 之前的实现从画布底部起拖,落点在视口外,指针事件根本没送到画布上。这里取可见区域内的点,
// 并用 elementFromPoint 现场确认命中画布(代际轴是 pointer-events:none,事件会穿透到画布)。
if (canvasBox) {
// 在画布可见区域里找一个「命中的元素就是画布本身」的空白点:落在节点/标题上会触发节点点击
// (拖动后可能直接跳转到详情页),落在底部 tab 栏会被挡住。找不到空白区本身就是一条 UX 发现。
const dragPoint = await mobilePage.evaluate(() => {
const canvas = document.querySelector('.game-lineage-canvas');
if (!canvas) return null;
const box = canvas.getBoundingClientRect();
// 起点要求:在视口内、在画布内、不在节点卡上(避免拖动被当成节点点击而跳详情页)、
// 且左侧至少还有 110px 画布空间(拖 90px 不越出画布)。
for (const dy of [0.2, 0.35, 0.5, 0.65, 0.8]) {
for (const dx of [0.85, 0.7, 0.55, 0.4, 0.25]) {
const x = box.x + box.width * dx;
const y = box.y + box.height * dy;
if (y > window.innerHeight - 130 || y < box.y + 8) continue;
if (x - box.x < 110 || x > box.x + box.width - 8) continue;
const element = document.elementFromPoint(x, y);
if (!element || !canvas.contains(element)) continue;
if (element.closest('.game-lineage-node')) continue;
return {
x,
y,
hit: `${element.tagName}.${String(element.className).slice(0, 24)}`,
};
}
}
return {
x: Math.min(box.x + box.width - 12, box.x + 120),
y: Math.min(box.y + box.height - 24, window.innerHeight - 160),
hit: 'fallback',
};
});
const hitLabel = dragPoint ? dragPoint.hit : 'null';
console.log(
`[lineage-visual] 窄屏拖动起点=(${Math.round(dragPoint?.x ?? 0)},${Math.round(dragPoint?.y ?? 0)}) 命中=${hitLabel}`,
);
check(
'DOM 断言(窄屏):存在可拖动的起点(画布内、未落在节点卡上、左侧留有拖动空间)',
hitLabel !== 'fallback' && hitLabel !== 'null',
`hit=${hitLabel} at=(${Math.round(dragPoint?.x ?? 0)},${Math.round(dragPoint?.y ?? 0)})`,
);
if (dragPoint) {
await mobilePage.mouse.move(dragPoint.x, dragPoint.y);
await mobilePage.mouse.down();
await mobilePage.mouse.move(dragPoint.x - 45, dragPoint.y, {
steps: 20,
});
await mobilePage.mouse.move(dragPoint.x - 90, dragPoint.y, {
steps: 20,
});
await mobilePage.mouse.up();
await mobilePage.waitForTimeout(500);
}
}
let transformAfter = await contentTransform();
if (transformAfter === transformBefore && canvasBox) {
// 兜底:换画布右上区域的可见点再试一次
const retryX = canvasBox.x + canvasBox.width - 30;
const retryY = canvasBox.y + 40;
await mobilePage.mouse.move(retryX, retryY);
await mobilePage.mouse.down();
await mobilePage.mouse.move(retryX - 150, retryY, { steps: 25 });
await mobilePage.mouse.up();
await mobilePage.waitForTimeout(500);
transformAfter = await contentTransform();
}
check(
'DOM 断言(窄屏):拖拽可横向平移画布(起点命中画布 + transform 改变)',
transformAfter !== 'unavailable' &&
transformBefore !== 'unavailable' &&
transformAfter !== transformBefore,
`before=${transformBefore} after=${transformAfter}`,
);
check(
'DOM 断言(窄屏):拖拽没有误跳转到详情页(拖动 ≠ 点击节点)',
mobilePage.url().includes('/games/lineage?id='),
`url=${mobilePage.url()}`,
);
await takeShot(mobilePage, 'lineage-04-mobile-narrow.png');
// ---------- 降级态 ----------
const degradedPage = await desktop.newPage();
await degradedPage.goto(degradedUrl, {
waitUntil: 'commit',
timeout: 120_000,
});
await degradedPage.waitForTimeout(2500);
const stubNodes = degradedPage.locator('.game-lineage-node--stub');
const stubCount = await stubNodes.count();
const degradedText = await degradedPage.locator('#root').innerText();
await takeShot(degradedPage, 'lineage-05-parent-unavailable.png');
check(
'DOM 断言(降级):缺失父作品渲染为占位节点(--stub + aria-disabled)',
stubCount >= 1 &&
(await stubNodes.first().getAttribute('aria-disabled')) === 'true',
`stub=${stubCount}`,
);
check(
'DOM 断言(降级):占位节点标题为「原作品已不可用」',
(await stubNodes.first().innerText()).includes('原作品已不可用'),
`stubText=${(await stubNodes.first().innerText()).slice(0, 60)}`,
);
check(
'DOM 断言(降级):页面不出现被下架父作品的标题与作者名',
!degradedText.includes(degradedParent.title) &&
!degradedText.includes(nameA),
`parentTitle=${degradedParent.title}`,
);
check(
'DOM 断言(降级):给出母版不可用的说明文案',
degradedText.includes('不可用'),
);
} finally {
await browser.close().catch(() => {});
}
console.log('');
console.log('截图清单(绝对路径 + 应看到什么):');
const expectations = [
'① lineage-01-overview.png:初访视野 —— 6 个节点(1 根 + 3 个 1 代 + 1 个 2 代 + 1 个 3 代)与连线、左侧**固定代际轴**(母版/第1代/第2代/第3代);节点卡只有「封面缩略图 + 代际角标 + 标题 + 作者名 + Fork」,根卡带「当前作品」;若一次装不下,则按可读缩放下限显示并出现「已按可读缩放显示…点「适应窗口」看全树」提示。',
'② lineage-02-zoom-branch.png:点 4 次「放大」后(141%)—— 卡片与连线都放大,连线在缩放下仍保持可辨线宽(non-scaling-stroke),**左侧代际轴标签仍完整可见**(不再被画布裁掉)。',
'⑦ lineage-07-hover-edge-path.png(新增,核心)—— 在放大状态下 hover 1 代 B1:B1→根的**祖先路径连线被强化**(--active),其余连线变淡(--dimmed),同时节点侧兄弟卡降透明度。这是「可视化路径」需求的直接证据。',
'⑥ lineage-06-fit-window.png(新增)—— 点「适应窗口」后:全树真正完整落在画布框内(6 个节点都在框内)、右侧大片空白消失(内容水平居中)、第 3 代不再贴底被裁。',
'③ lineage-03-hover-ancestor.png:默认视野 hover B1 —— 自身与祖先不降透明度,同代兄弟 C1/F1(及 D/E)降透明度。',
'⑧ lineage-08-selected-path.png(新增)—— 点击 1 代卡片主体(非标题/按钮)后:路径被「选中」并**保持**高亮(祖先连线加粗、其余节点降透明度),鼠标移开也不消失,顶部出现面包屑「已选中的路径」(含根 → 当前节点);页面不跳转。',
'④ lineage-04-mobile-narrow.png:390×844 窄屏 —— 控件折行、**页面本身不横向溢出**、底部 tab 正常;初访不再是 35%(按可读下限 + 提示),画布内可拖拽横向平移(这张是平移后的画面)。',
'⑤ lineage-05-parent-unavailable.png:父作品下架后 —— 缺失父作品处是占位卡「原作品已不可用/标题与作者名不对外展示」,顶部有「母版已不可用…」notice,标题回退为「创作族谱」,全文不出现被下架父作品的标题与作者名。',
];
console.log(`截图目录=${shotDir}`);
for (const line of expectations) console.log(` ${line}`);
console.log(
`[lineage-visual] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures}`,
);
if (failures > 0) process.exitCode = 1;
}
main().catch((error) => {
console.error(`[lineage-visual] 未捕获异常:${error?.stack ?? error}`);
process.exitCode = 1;
});