import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { Browser } from 'playwright-core'; import { installDesignBrowser, PIXEL, tokenColor } from './design-fixture.ts'; import { requiredEnv, settle, visit, VIEWPORTS } from './harness.ts'; describe('任务页云下载布局', () => { let browser: Browser; installDesignBrowser((next) => { browser = next; }); for (const viewport of VIEWPORTS) it(`${viewport.name} 资源分色并在高占用时加深,已取消使用红色标签`, async () => { const reading = (high: boolean) => ({ cpu: { state: 'ready', percent: high ? 75 : 5, high }, gpu: { state: 'ready', percent: high ? 80 : 4, high }, network: { state: 'ready', bytes_per_second: high ? 15 * 1024 * 1024 : 1024, high }, sampled_at: '2026-10-31T08:00:00Z', }); const row = (id: number, status: string) => ({ id, task_key: `resource-${id}`, task_label: `资源采样任务 ${id}`, trigger: 'manual', status, host: 'desk', started_at: '2026-10-31T08:00:00Z', finished_at: status === 'running' ? null : '2026-10-31T08:01:00Z', elapsed_seconds: 60, progress_current: 1, progress_total: 10, progress_label: '正在处理', result_summary: {}, error: '', parent_run_id: null as number | null, followup_key: '', }); const opened = await visit(browser, '/tasks', viewport, async page => { await page.route('**/api/downloads?*', route => route.fulfill({ json: { available: false, tasks: [], providers: [] } })); await page.route('**/api/tasks*', route => route.fulfill({ json: { available: true, running: [ { ...row(1, 'running'), resources: reading(true), control: { state: 'running', actions: ['pause', 'cancel', 'set_speed'], speed_mode: 'standard' } }, { ...row(90, 'running'), parent_run_id: 1, followup_key: 'child:90', resources: reading(false), control: { state: 'running', actions: ['pause', 'cancel', 'set_speed'], speed_mode: 'standard' } }, ], skipped: [], finished: [row(3, 'cancelled'), row(4, 'succeeded'), row(5, 'succeeded')], followup_counts: { 4: { cancelled: 2 }, 5: { succeeded: 1, cancelled: 1 } }, } })); }); try { const page = opened.page; await page.locator('[data-task-resources="1"]').waitFor(); const pairs = [['cpu', 'blue'], ['gpu', 'purple'], ['network', 'cyan']] as const; const backgrounds: string[] = []; for (const [metric, color] of pairs) { const high = page.locator(`[data-task-resources="1"] [data-task-resource="${metric}"]`); const normal = page.locator(`[data-task-resources="90"] [data-task-resource="${metric}"]`); const normalBackground = await normal.evaluate(node => getComputedStyle(node).backgroundColor); const highBackground = await high.evaluate(node => getComputedStyle(node).backgroundColor); assert.equal(normalBackground, await tokenColor(page, 'body', `--color-status-${color}-background`)); assert.equal(highBackground, await tokenColor(page, 'body', `--color-status-${color}-text`)); assert.notEqual(highBackground, normalBackground); backgrounds.push(highBackground); } assert.equal(new Set(backgrounds).size, 3); const red = await tokenColor(page, 'body', '--color-status-rose-background'); for (const [id, label] of [[3, '已取消'], [4, '后续任务已取消'], [5, '部分后续任务已取消']] as const) { const badge = page.locator(`#run-${id}`).getByText(label, { exact: true }); const color = await badge.evaluate(node => { const chip = node.parentElement?.tagName === 'SPAN' ? node.parentElement : node; return getComputedStyle(chip).backgroundColor; }); assert.equal(color, red); } const geometry = await page.locator('#run-1').evaluate(card => { const controls = card.querySelector('[data-task-controls="1"]')!.getBoundingClientRect(); const title = card.querySelector('strong')!.getBoundingClientRect(); return { fits: document.documentElement.scrollWidth <= innerWidth, separate: controls.left >= title.right || controls.top >= title.bottom, insideCard: controls.right <= card.getBoundingClientRect().right }; }); assert.equal(geometry.fits, true); assert.equal(geometry.separate, true); assert.equal(geometry.insideCard, true); assert.equal(await page.locator('[data-task-controls="1"]').getByRole('button', { name: '处理速度:标准' }).count(), 1); assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); for (const viewport of VIEWPORTS) it(`${viewport.name} 零读数标为未使用,单部作品的任务链接到作品详情`, async () => { const name = '[Studio]Very_Long_Unbroken_File_Name_That_Should_Wrap_Inside_The_Card_2026-11-03_1080p.mp4'; const opened = await visit(browser, '/tasks', viewport, async page => { await page.route('**/api/downloads?*', route => route.fulfill({ json: { available: false, tasks: [], providers: [] } })); await page.route('**/api/tasks*', route => route.fulfill({ json: { available: true, skipped: [], finished: [{ id: 8, task_key: 'amane-bridge', task_label: 'amane 桥重建', trigger: 'manual', status: 'failed', host: 'desk', started_at: '2026-11-03T07:00:00Z', finished_at: '2026-11-03T07:00:01Z', elapsed_seconds: 1, progress_current: 0, progress_total: null, progress_label: '', result_summary: { ok: false }, error: 'uv sync 失败(exit 1):× Failed to fetch amane\n…\n fatal: missing object', parent_run_id: null, followup_key: '', }], running: [{ id: 7, task_key: 'mp4-header-repair', task_label: '播放文件头修复', trigger: 'manual', status: 'running', host: 'desk', started_at: '2026-11-03T08:00:00Z', finished_at: null, elapsed_seconds: 140, progress_current: 0, progress_total: null, progress_label: '读取帧时间戳', result_summary: { asset_id: 6297, name }, error: '', parent_run_id: null, followup_key: '', control: { state: 'running', actions: ['pause', 'cancel'] }, resources: { cpu: { state: 'ready', percent: 61.6, high: true }, gpu: { state: 'ready', percent: 0, high: false }, network: { state: 'unavailable', bytes_per_second: null, high: false, via: 'cloud', reason: '网盘挂载的缓存与预读无法按任务取得实际网速' }, sampled_at: '2026-11-03T08:02:20Z', }, }], } })); }); try { const page = opened.page; const chips = page.locator('[data-task-resources="7"]'); await chips.waitFor(); const idle = await chips.locator('[data-task-resource]').evaluateAll(nodes => Object.fromEntries(nodes.map(node => [(node as HTMLElement).dataset.taskResource, 'resourceIdle' in (node as HTMLElement).dataset]))); assert.deepEqual(idle, { cpu: false, gpu: true, network: false }); assert.equal(await chips.locator('[data-task-resource="network"]').getAttribute('data-resource-via'), 'cloud'); const link = page.locator('[data-task-subject="7"]'); assert.equal(await link.getAttribute('href'), '/assets/6297'); assert.equal(await link.getAttribute('title'), name); assert.equal(await page.locator('#run-7 details').count(), 0); const geometry = await page.locator('#run-7').evaluate(card => { const box = card.getBoundingClientRect(); const link = card.querySelector('[data-task-subject]')!.getBoundingClientRect(); return { fits: document.documentElement.scrollWidth <= innerWidth, inside: link.right <= box.right }; }); assert.deepEqual(geometry, { fits: true, inside: true }); // 失败卡:框线是 danger 色且有宽度,画成不占盒子的 outline;结束原因在正文,全文收在底部那一行可展开的明细里。 const failed = page.locator('#run-8'); const frame = await failed.evaluate((node) => { const style = getComputedStyle(node); return { style: style.outlineStyle, width: style.outlineWidth, color: style.outlineColor, border: style.borderTopWidth }; }); assert.deepEqual(frame, { style: 'solid', width: '1px', color: await tokenColor(page, '#run-8', '--color-border-error-default'), border: '0px' }); assert.equal(await failed.locator('[data-task-error]').count(), 1); assert.equal(await failed.locator('details [data-error-details]').count(), 1); assert.equal(await failed.locator('[data-error-excerpt]').count(), 0); assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); for (const viewport of VIEWPORTS) it(`${viewport.name} 追更问题展示来源原因和管理入口`, async () => { const opened = await visit(browser, '/tasks', viewport, async page => { await page.route('**/api/tasks*', route => route.fulfill({ json: { available: true, running: [], skipped: [], finished: [{ id: 4038, task_key: 'follow-check', task_label: '追更检查', trigger: 'scheduled', status: 'succeeded', finished_at: '2026-10-20T00:00:00Z', result_summary: { source_failed: 51, issues: Array.from({ length: 51 }, (_, index) => ({ source: 12 + index, label: `示例作者 ${index + 1}`, provider: '示例站点', kind: '检查失败', error: 'HTTP 403:站点拒绝访问' })) }, error: '', parent_run_id: null, followup_key: '', }], } })); }); try { const card = opened.page.locator('[data-run-id="4038"]'); await card.getByText('发现问题', { exact: true }).waitFor(); const disclosure = card.locator('summary').filter({ hasText: '问题详情 · 51 项' }); assert.equal(await disclosure.getAttribute('aria-expanded'), 'false'); assert.equal(await card.locator('[data-task-issues]').isVisible(), false); // 问题详情与技术详情同为卡片底部的一行:同一层级、同一左边,问题详情在前。 const rows = await card.locator('details').evaluateAll((nodes) => nodes.map((node) => ({ footer: node.parentElement!.parentElement === node.closest('li'), left: Math.round(node.getBoundingClientRect().left), issues: Boolean(node.querySelector('[data-task-issues]')) }))); assert.equal(rows.length, 2); assert.deepEqual(rows.map((row) => [row.footer, row.issues]), [[true, true], [true, false]]); assert.equal(rows[0].left, rows[1].left); await disclosure.click(); const errors = card.getByRole('region', { name: '任务问题详情' }); await errors.waitFor(); const geometry = await errors.evaluate((node) => ({ height: node.getBoundingClientRect().height, scrollable: node.scrollHeight > node.clientHeight, overlay: node.getAttribute('data-overlay-scrollbar') })); assert.ok(geometry.height > 0 && geometry.height <= 320, JSON.stringify(geometry)); assert.equal(geometry.scrollable, true); assert.equal(geometry.overlay, 'true'); await card.locator('summary').filter({ hasText: '技术详情' }).click(); const background = await card.locator('[data-code-block]').first().evaluate((node) => getComputedStyle(node).backgroundColor); assert.equal(await errors.evaluate((node) => getComputedStyle(node.parentElement!).backgroundColor), background); assert.match(await card.locator('[data-task-issues]').innerText(), /示例作者.*示例站点.*检查失败/s); assert.match(await card.locator('[data-task-issues]').innerText(), /HTTP 403:站点拒绝访问/); // 分层:名字是中等字重,来源与类别次级色,原因比名字小一档且用次级色。 const layers = await card.locator('[data-task-issues] > li').first().evaluate((item) => { const name = item.firstElementChild!, meta = name.querySelector('span')!, reason = item.querySelector('[data-error-excerpt]')!; const style = (node: Element) => getComputedStyle(node); return { name: style(name).fontWeight, metaColor: style(meta).color, nameColor: style(name).color, reasonColor: style(reason).color, smaller: parseFloat(style(reason).fontSize) < parseFloat(style(name).fontSize) }; }); assert.equal(layers.name, '500'); assert.equal(layers.smaller, true); assert.notEqual(layers.metaColor, layers.nameColor); assert.equal(layers.reasonColor, layers.metaColor); const link = card.getByRole('link', { name: '查看关注来源', exact: true }); assert.equal(await link.getAttribute('href'), '/subscriptions'); await link.click(); await opened.page.waitForURL('**/subscriptions'); await opened.page.locator('[data-empty-state]').filter({ hasText: '还没有关注来源' }).waitFor(); await settle(opened.page); assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); for (const viewport of VIEWPORTS) it(`${viewport.name} 检查结果和技术代码块完整可读`, async () => { const result = { checked: 1218, total: 1218, gone_count: 5, unclear_count: 54, diagnostic: 'technical-detail-'.repeat(80) }; const opened = await visit(browser, '/tasks', viewport, async page => { await page.route('**/api/downloads?*', route => route.fulfill({ json: { available: false, tasks: [], providers: [] } })); await page.route('**/api/tasks*', route => route.fulfill({ json: { available: true, running: [], skipped: [], finished_has_more: false, finished: [{ id: 4037, task_key: 'link-check', task_label: '链接检查', trigger: 'manual', status: 'succeeded', started_at: '2026-10-01T00:00:00Z', finished_at: '2026-10-01T00:01:00Z', elapsed_seconds: 60, result_summary: result, error: '', parent_run_id: null, followup_key: '', }], } })); }); try { const page = opened.page; const card = page.locator('[data-run-id="4037"]'); await card.getByText('发现问题', { exact: true }).waitFor(); assert.equal(await card.getByText('链接已失效 5', { exact: true }).count(), 1); assert.equal(await card.getByText('链接访问失败 54', { exact: true }).count(), 1); const resultLink = card.getByRole('link', { name: '查看有问题的链接' }); assert.equal(await resultLink.getAttribute('href'), '/data-cleanup#link-manager'); assert.equal(await resultLink.evaluate(link => getComputedStyle(link).textDecorationLine), 'none'); await card.locator('summary').click(); assert.equal(await card.locator('[data-code-block] code').textContent(), JSON.stringify(result, null, 2)); const geometry = await card.locator('pre').evaluate(pre => ({ overflows: pre.scrollWidth > pre.clientWidth, pageFits: document.documentElement.scrollWidth <= innerWidth, leftGap: pre.closest('[data-code-block]')!.getBoundingClientRect().left - pre.closest('[data-run-id]')!.getBoundingClientRect().left, bottomGap: pre.closest('[data-run-id]')!.getBoundingClientRect().bottom - pre.closest('[data-code-block]')!.getBoundingClientRect().bottom, })); assert.equal(geometry.overflows, true, '长行在代码块内部横向滚动'); assert.equal(geometry.pageFits, true); assert.ok(Math.abs(geometry.leftGap - geometry.bottomGap) <= 1, `技术详情代码块的底部留白与卡片左右内边距一致:${JSON.stringify(geometry)}`); assert.equal(await card.getByRole('button', { name: '复制代码' }).count(), 1); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); for (const viewport of VIEWPORTS) it(`${viewport.name} 左侧封面可见,番号完整,长标题可展开`, async () => { const title = '制作记录与采访合集:摄影、场景设计及幕后访谈 '.repeat(12); const assetId = Number(requiredEnv('PEACH_E2E_ITEM')); const opened = await visit(browser, '/tasks', viewport, async (page) => { await page.route('**/cover?*', (route) => route.fulfill({ contentType: 'image/svg+xml', body: '' })); await page.route('**/entity-image?*', (route) => route.fulfill({ contentType: 'image/png', body: Buffer.from(PIXEL, 'base64') })); await page.route('**/api/downloads?*', (route) => route.fulfill({ json: { available: false, providers: [], tasks: [{ id: 1, title, code: 'ABC-123', provider: '115', provider_label: '115', state: 'ingested', state_label: '已入库', postprocess_state: 'complete', postprocess_label: '整理完成', target: '/云下载', source_uri: '', submitted_at: '2026-10-01T00:00:00Z', asset_id: assetId, artwork: { id: 10, code: 'ABC-123', has_cover: true, cover_key: 'ABC-123', poster_box: { x0: 776, y0: 0, x1: 1480, y1: 1000, px: [1480, 1000], method: 'fold' }, performer_entities: [{ id: 30, name: '测试女优', has_image: true }] } }, { id: 2, title: '另一条下载任务', code: 'XYZ-456', provider: '115', provider_label: '115', state: 'failed', state_label: '失败', target: '/云下载', source_uri: '', submitted_at: '2026-10-01T00:00:00Z' }], } })); }); try { const page = opened.page; const firstCard = page.locator('[data-download-id="1"]'); await page.locator('[data-download-id="1"]').waitFor(); await settle(page); await page.waitForFunction(async () => { const images = document.querySelectorAll( '[data-download-id="1"] [data-download-cover] img, [data-download-id="1"] [data-media-avatar] img'); if (images.length !== 2) return false; try { await Promise.all([...images].map(image => image.decode())); return [...images].every(image => image.complete && image.naturalWidth > 0); } catch { return false; } }); const geometry = await page.locator('[data-download-id="1"]').evaluate((card) => { const heading = card.querySelector('strong')!; const titleContent = heading.querySelector('a') || heading; const row = heading.parentElement!; const metadata = row.nextElementSibling!; const provider = [...metadata.querySelectorAll('span')].filter((span) => span.textContent === '115').at(-1)!; const detail = metadata.lastElementChild!; const cover = card.querySelector('[data-download-cover]')!; const detailColumn = cover.nextElementSibling!; const picture = cover.querySelector('img') as HTMLImageElement; const avatar = card.querySelector('[data-media-avatar] img') as HTMLImageElement; const code = document.createRange(); const headingText = heading.querySelector('a')?.firstChild || heading.firstChild!; code.setStart(headingText, 0); code.setEnd(headingText, 'ABC-123'.length); const pictureBox = picture.getBoundingClientRect(); const coverBox = cover.getBoundingClientRect(); const other = document.querySelector('[data-download-id="2"]')!; const emptyCover = other.querySelector('[data-download-cover]')!; const emptyBox = emptyCover.getBoundingClientRect(); const probe = document.createElement('span'); probe.style.backgroundColor = 'var(--color-background-quaternary-default)'; emptyCover.append(probe); const expectedGround = getComputedStyle(probe).backgroundColor; probe.remove(); return { title: heading.getBoundingClientRect().height, line: Number.parseFloat(getComputedStyle(heading).lineHeight), row: row.getBoundingClientRect().height, clipped: titleContent.scrollWidth > titleContent.clientWidth, overflow: document.documentElement.scrollWidth > window.innerWidth, providerTop: provider.getBoundingClientRect().top, metadataTop: detail.getBoundingClientRect().top, coverLeft: cover.getBoundingClientRect().left, textLeft: heading.getBoundingClientRect().left, coverTop: cover.getBoundingClientRect().top, coverBottom: cover.getBoundingClientRect().bottom, coverHeight: coverBox.height, coverRatio: coverBox.width / coverBox.height, panelLeft: pictureBox.left + pictureBox.width * 776 / 1480, panelRight: pictureBox.right, panelTop: pictureBox.top, panelBottom: pictureBox.bottom, coverRight: coverBox.right, backdrop: getComputedStyle(cover, '::before').backgroundImage, backdropFilter: getComputedStyle(cover, '::before').filter, coverWidth: coverBox.width, emptyWidth: emptyBox.width, emptyHeight: emptyBox.height, emptyGround: getComputedStyle(emptyCover).backgroundColor, expectedGround, otherTitleLeft: other.querySelector('strong')!.getBoundingClientRect().left, detailTop: detailColumn.getBoundingClientRect().top, detailBottom: detailColumn.getBoundingClientRect().bottom, coverReady: picture.complete && picture.naturalWidth > 0, avatarReady: avatar.complete && avatar.naturalWidth > 0, codeFits: code.getBoundingClientRect().width <= heading.clientWidth }; }); assert.equal(geometry.title, geometry.line); assert.ok(geometry.row < geometry.line * 2); assert.equal(geometry.clipped, true); assert.equal(geometry.codeFits, true, '番号不能被状态标签挤掉'); assert.equal(geometry.overflow, false); assert.ok(geometry.coverLeft < geometry.textLeft); if (!viewport.mobile) { assert.equal(geometry.coverTop, geometry.detailTop); assert.equal(geometry.coverBottom, geometry.detailBottom); } assert.equal(geometry.coverReady, true); assert.ok(Math.abs(geometry.coverRatio - .75) < .01, '封面框复用首页 JAV 大图比例'); assert.equal(geometry.coverWidth, geometry.emptyWidth, '各张卡片的封面列等宽'); assert.equal(geometry.coverHeight, geometry.emptyHeight, '各张卡片的封面框等高'); assert.equal(geometry.textLeft, geometry.otherTitleLeft, '各张卡片的标题起点对齐'); assert.equal(geometry.emptyGround, geometry.expectedGround, '缺封面使用共享灰色占位底'); await page.evaluate(() => { document.documentElement.dataset.theme = 'dark'; document.documentElement.classList.add('dark'); }); const darkGround = await page.locator('[data-download-id="2"]').evaluate(card => ({ card: getComputedStyle(card).backgroundColor, picture: getComputedStyle(card.querySelector('[data-download-cover]')!).backgroundColor, })); assert.notEqual(darkGround.picture, darkGround.card, '深色模式下占位与卡片背景须可分辨'); assert.ok(geometry.panelLeft >= geometry.coverLeft - 1 && geometry.panelRight <= geometry.coverRight + 1 && geometry.panelTop >= geometry.coverTop - 1 && geometry.panelBottom <= geometry.coverBottom + 1, `正封四边完整可见:${JSON.stringify(geometry)}`); assert.notEqual(geometry.backdrop, 'none', '正封比例留白使用同图补底'); assert.ok(geometry.backdropFilter.includes('blur('), '补底复用首页模糊效果'); assert.equal(geometry.avatarReady, true); assert.equal(await firstCard.locator('[data-download-cover]').getByText('无封面').count(), 0); assert.equal(geometry.providerTop, geometry.metadataTop, '来源文字与目录、时间没有对齐'); await page.screenshot({ path: `../build/agent-verification/browser/activity-${viewport.name}.png`, fullPage: true }); await firstCard.getByRole('button', { name: '展开标题', exact: true }).click(); assert.equal(await firstCard.locator('[data-download-title-full]').textContent(), `ABC-123 · ${title.trim()}`); assert.equal(await firstCard.locator('[data-download-cover]').evaluate(cover => cover.getBoundingClientRect().height), geometry.coverHeight); await firstCard.getByRole('button', { name: '收起标题', exact: true }).click(); await firstCard.locator('[data-download-title-full]').waitFor({ state: 'hidden' }); for (const selector of ['[data-download-cover-link]', '[data-download-title-link]']) { await page.locator(selector).click(); await page.waitForURL(`**/assets/${assetId}`); await page.goBack(); await page.locator('[data-download-id="1"]').waitFor(); await settle(page); } assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); });