// 族谱可视化(节点-连线树)的截图与 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',
`
${marker}${marker}
`,
);
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);
// 面包屑(锁定路径):窄屏上「锁定」由大纲行触发(画布卡片在窄屏是紧凑卡片,
// 整个卡片体都是「打开详情」按钮,点它只会跳详情——见下方图谱视图段的备注),
// 所以这里在大纲形态下再点一行,断言面包屑出现且当前项已被滚进视口。
await page
.locator('.lineage-outline__row .lineage-outline__main')
.first()
.click();
await page.waitForTimeout(1200);
const outlineCrumb = 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,
insideViewport: box.left >= -1 && box.right <= window.innerWidth + 1,
left: Math.round(box.left),
right: Math.round(box.right),
innerWidth: window.innerWidth,
};
});
check(
`${width}px:大纲行锁定后面包屑出现且当前项被滚进视口`,
Boolean(outlineCrumb) &&
outlineCrumb.count >= 1 &&
outlineCrumb.insideViewport,
JSON.stringify(outlineCrumb),
);
await take('breadcrumb');
// 画布检查前重载一次:保证不残留 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();
const visibleNodes = [
...document.querySelectorAll('.game-lineage-node'),
].filter((node) => {
const box = node.getBoundingClientRect();
return (
box.left >= canvasBox.left - 2 &&
box.right <= canvasBox.right + 2 &&
box.top >= canvasBox.top - 2 &&
box.bottom <= canvasBox.bottom + 2
);
}).length;
const totalNodes =
document.querySelectorAll('.game-lineage-node').length;
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,
visibleNodes,
totalNodes,
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');
console.log(
`[narrow ${width}] 备注:图谱视图里点卡片会跳详情(窄屏紧凑卡片整块是「打开详情」按钮,没有可锁定区域)——信息面板/面包屑改由大纲形态验证。`,
);
}
} 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 {
// 封面是远端资源:等所有
落地再截,否则会出现灰块假象。
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;
});