import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import { it } from 'node:test';
import type { Browser } from 'playwright-core';
import { installDesignBrowser } from './design-fixture.ts';
import { requiredEnv, visit, VIEWPORTS } from './harness.ts';
let browser: Browser;
installDesignBrowser((value) => { browser = value });
const cases = [
{ name: 'short', html: '已保存配置', label: '' },
{ name: 'action', html: '已检查 105 个来源:更新 2933 条', label: '查看' },
{ name: 'long', html: '检查了 105 个来源:更新 2933 条 · 跳过 281 条无资源 · 排除 20 个超大合集 · 跳过 862 条超出首次采集范围的历史内容 · 回查 241 条 · 11 个来源没有更新', label: '查看' },
{ name: 'undo', html: '已把 12 项移入回收站', label: '撤销' },
{ name: 'error', html: '检查失败:来源暂时无法连接,请稍后重试。', label: '' },
];
for (const viewport of [...VIEWPORTS, { name: 'narrow', width: 320, height: 844, mobile: true }]) {
it(`${viewport.name} 的短回执、长内容与操作按钮都留在视口内`, { timeout: 60_000 }, async () => {
const opened = await visit(browser, '/follow', viewport);
try {
await opened.page.locator('#toasts').waitFor({ state: 'attached' });
await mkdir(resolve('../build/toast-preview'), { recursive: true });
for (const scenario of cases) {
await opened.page.evaluate(async (scenario) => {
const entry = '/dist/peach-app.js';
const ui = await import(entry);
ui.showToast(document.getElementById('toasts'), {
success: '',
error: '',
}, 'layout-preview', { html: scenario.html, alert: scenario.name === 'error', timeout: Infinity,
action: scenario.label ? { label: scenario.label, run: () => {} } : null });
}, scenario);
const toast = opened.page.locator('[data-sonner-toast]').filter({ hasText: scenario.html });
await toast.waitFor();
await opened.page.waitForFunction((text) => [...document.querySelectorAll('[data-sonner-toast]')]
.some((node) => node.textContent?.includes(text) && node.getAttribute('data-mounted') === 'true'
&& node.getAnimations().length === 0), scenario.html);
const box = await toast.evaluate((node) => {
const rect = node.getBoundingClientRect();
const title = node.querySelector('[data-title]')!.getBoundingClientRect();
const icon = node.querySelector('[data-icon]')!.getBoundingClientRect();
const button = node.querySelector('[data-button]')?.getBoundingClientRect();
return { left: rect.left, right: rect.right, width: rect.width,
scrollWidth: node.scrollWidth, clientWidth: node.clientWidth,
titleLeft: title.left, iconRight: icon.right,
titleRight: title.right, titleBottom: title.bottom,
buttonLeft: button?.left, buttonTop: button?.top };
});
assert.ok(box.left >= 0 && box.right <= viewport.width, JSON.stringify(box));
assert.ok(box.scrollWidth <= box.clientWidth, JSON.stringify(box));
assert.ok(box.titleLeft >= box.iconRight, JSON.stringify(box));
if (scenario.name === 'long' && !viewport.mobile) {
assert.ok(box.width > 400 && box.width <= 560, JSON.stringify(box));
}
if (scenario.label) {
assert.ok(box.buttonLeft! >= box.titleRight || box.buttonTop! >= box.titleBottom, JSON.stringify(box));
const inline = scenario.name === 'undo' || (scenario.name === 'action' && viewport.width >= 390);
if (inline) assert.ok(box.buttonLeft! >= box.titleRight && box.buttonTop! < box.titleBottom, JSON.stringify(box));
else assert.ok(box.buttonTop! >= box.titleBottom, JSON.stringify(box));
}
const rect = (await toast.boundingBox())!;
await opened.page.screenshot({ path: resolve(`../build/toast-preview/${viewport.name}-${scenario.name}.png`),
clip: { x: rect.x - 14, y: rect.y - 14, width: rect.width + 28, height: rect.height + 28 } });
}
} finally {
await opened.close();
}
});
}
for (const viewport of VIEWPORTS) {
it(`${viewport.name} 的失败回执自己是红框,停在右下角小窗之上不压住它`, { timeout: 60_000 }, async () => {
// 小窗挂在舞台里:进一次详情,那块节点才在。
const opened = await visit(browser, `/assets/${requiredEnv('PEACH_E2E_ITEM')}`, viewport);
try {
const mini = opened.page.locator('#miniplayer');
await mini.waitFor({ state: 'attached' });
await mini.evaluate((node) => { (node as HTMLElement).hidden = false });
await opened.page.evaluate(async () => {
const entry = '/dist/peach-app.js';
const ui = await import(entry);
ui.showToast(document.getElementById('toasts'), { success: '', error: '' },
'stack-preview', { html: '检查失败:来源暂时无法连接,请稍后重试。', alert: true, timeout: Infinity, action: null });
});
const toast = opened.page.locator('[data-sonner-toast][data-type=error]');
await toast.waitFor();
await opened.page.waitForFunction(() => [...document.querySelectorAll('[data-sonner-toast]')]
.every((node) => node.getAttribute('data-mounted') === 'true' && node.getAnimations().length === 0));
const [toastBox, miniBox] = [(await toast.boundingBox())!, (await mini.boundingBox())!];
assert.ok(toastBox.y + toastBox.height <= miniBox.y, JSON.stringify({ toastBox, miniBox }));
const colors = await toast.evaluate((node) => {
const probe = document.createElement('span');
probe.style.color = 'var(--color-border-error-default)';
document.body.append(probe);
const expected = getComputedStyle(probe).color;
probe.remove();
return { border: getComputedStyle(node).borderTopColor, expected };
});
assert.equal(colors.border, colors.expected);
} finally {
await opened.close();
}
});
}