frontend/e2e/setup.test.ts
Rama master · réplica de lectura del repositorio original.
/* 首次运行页的整条流程:加载、填错、原位写回、改对、完成态。
*
* 跑在 `peach serve --setup` 上,不是路由冒烟那台已配置的服务:服务与临时 Peach 数据目录由
* `tests/test_web_e2e.py` 的 `SetupE2ETests` 准备,经 PEACH_E2E_SETUP_ORIGIN、
* PEACH_E2E_SETUP_DATA(临时 Peach 数据目录)和 PEACH_E2E_SETUP_MEDIA(一个已建好的空媒体目录)传进来。
* 本文件由首启专用入口执行;单独执行时缺少这些变量则整组跳过。 */
import assert from 'node:assert/strict';
import { readFile, unlink } from 'node:fs/promises';
import { join } from 'node:path';
import { after, before, describe, it } from 'node:test';
import type { Browser, Page } from 'playwright-core';
import { launch, layout, VIEWPORTS } from './harness.ts';
const origin = process.env.PEACH_E2E_SETUP_ORIGIN ?? '';
const media = process.env.PEACH_E2E_SETUP_MEDIA ?? '';
const dataRoot = process.env.PEACH_E2E_SETUP_DATA ?? '';
/** 填错那一轮的 400 是预期内的:Chrome 会把它同时记成一条控制台错误和一条 4xx 响应。 */
const expectedRejection = (problem: string) =>
problem === '400 /api/setup' || problem === '400 /api/setup/restore'
|| problem.startsWith('console Failed to load resource: the server responded with a status of 400');
describe('首次运行页', { skip: origin && media && dataRoot ? false : '只在 SetupE2ETests 起的首启服务上跑' }, () => {
let browser: Browser;
before(async () => { browser = await launch(); });
after(async () => { await browser?.close(); });
it('填错原位写回,改对后进入完成态', { timeout: 120_000 }, async () => {
const viewport = VIEWPORTS.find((candidate) => candidate.name === 'desktop')!;
const context = await browser.newContext({ viewport, reducedMotion: 'reduce' });
const problems: string[] = [];
try {
const page: Page = await context.newPage();
page.on('pageerror', (error) => problems.push(`pageerror ${error.message}`));
page.on('console', (message) => { if (message.type() === 'error') problems.push(`console ${message.text()}`); });
page.on('response', (response) => {
if (response.url().startsWith(origin) && response.status() >= 400) {
problems.push(`${response.status()} ${response.url().slice(origin.length)}`);
}
});
await page.goto(`${origin}/?edit`, { waitUntil: 'load' });
const editor = page.getByRole('group', { name: '文案编辑' });
await editor.waitFor({ state: 'visible' });
for (const size of VIEWPORTS.filter(candidate => ['desktop', 'mobile'].includes(candidate.name))) {
await page.setViewportSize({ width: size.width, height: size.height });
assert.equal(await editor.evaluate(element => getComputedStyle(element).position), 'fixed');
assert.equal(await editor.getByRole('button', { name: '暂停编辑' }).evaluate(element => getComputedStyle(element).height), '36px');
const bounds = await editor.boundingBox();
assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= size.width && bounds.y + bounds.height <= size.height);
}
await page.getByRole('button', { name: '退出编辑' }).click();
await page.waitForURL(`${origin}/`);
await page.setViewportSize({ width: viewport.width, height: viewport.height });
// 加载:题目到齐后表单出现,等待态结束,页面不横向溢出。
const heading = page.getByRole('heading', { name: '欢迎使用 Peach' });
await heading.waitFor({ state: 'visible', timeout: 15_000 });
await page.getByRole('button', { name: '开始设置', exact: true }).click();
const folder = page.getByRole('textbox', { name: '媒体目录 1' });
await folder.waitFor({ state: 'visible', timeout: 15_000 });
assert.equal(await folder.inputValue(), '', '媒体目录由用户填写');
await page.getByRole('button', { name: '我的媒体库 来源色:无颜色', exact: true }).click();
const paints = await page.locator('[data-library-colors] [data-accent-ball]').evaluateAll(elements =>
elements.map(element => getComputedStyle(element).backgroundImage));
assert.equal(new Set(paints).size, 12, '来源色板显示十二种独立颜色');
await page.keyboard.press('Escape');
assert.equal(await page.getByRole('textbox', { name: '数据库中的路径前缀' }).count(), 0,
'普通首次设置不需要填写内部路径');
await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]'), undefined, { timeout: 15_000 });
const host = page.getByRole('radiogroup');
assert.equal(await host.count(), 0, '访问范围在后续步骤中呈现');
assert.equal(await page.getByRole('checkbox', { name: '接下来导入浏览器历史记录' }).count(), 0);
// 数据目录决定占用哪块磁盘,首屏就露出,并带选择文件夹的键。
// 落盘位置跟着服务进程的 PEACH_DATA_ROOT 走;不是临时 Peach 数据目录就不往下提交。
assert.equal(await page.locator('#f-data_root').inputValue(), dataRoot);
await page.getByRole('button', { name: '选择数据目录' }).waitFor({ state: 'visible' });
assert.deepEqual((await layout(page)).offenders, []);
await folder.fill(join(media, 'missing'));
const selectedData = join(dataRoot, 'custom-data');
await page.locator('#f-data_root').fill(selectedData);
await page.getByRole('button', { name: '继续', exact: true }).click();
const proxyMode = page.locator('[aria-labelledby="setup-proxy-title"] button[aria-haspopup="listbox"]');
await proxyMode.scrollIntoViewIfNeeded();
await proxyMode.focus();
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
await proxyMode.click();
await page.getByRole('option', { name: '自定义', exact: true }).waitFor();
await page.getByRole('option', { name: '自定义', exact: true }).click();
await page.locator('[data-trigger="Select"]').waitFor({ state: 'detached' });
const proxy = page.getByLabel('代理地址', { exact: true });
await proxy.fill('http://proxy.invalid');
const mobileForm = VIEWPORTS.find((candidate) => candidate.name === 'mobile')!;
await page.setViewportSize({ width: mobileForm.width, height: mobileForm.height });
assert.deepEqual((await layout(page)).offenders, []);
await page.setViewportSize({ width: viewport.width, height: viewport.height });
// React Aria 的原生 input 是视觉隐藏的,点击落在外面那层画出来的开关上,所以按状态设值。
// 填错:目录不存在、端口越界。
const missing = join(media, 'missing');
await page.getByRole('switch', { name: '访问密码' }).setChecked(false, { force: true });
await page.getByText('高级设置', { exact: true }).click();
const port = page.locator('#f-port');
await port.waitFor({ state: 'visible' });
const defaultPort = await port.inputValue();
await port.fill('70000');
// 折叠关上,验证出错时它自己展开。
await page.getByText('高级设置', { exact: true }).click();
await port.waitFor({ state: 'hidden' });
await page.getByRole('button', { name: '继续', exact: true }).click();
await page.getByRole('checkbox', { name: /^完成设置后扫描/ }).setChecked(false, { force: true });
const rejected = page.waitForResponse((response) => response.url().endsWith('/api/setup')
&& response.request().method() === 'POST');
await page.getByRole('button', { name: '完成设置' }).click();
assert.equal((await rejected).status(), 400);
// 写回:目录行与端口各标各的,高级设置展开,已填的值都在,焦点落在第一个错处。
await page.waitForFunction(() => document.querySelectorAll('input[aria-invalid="true"]').length >= 2);
assert.equal(await folder.getAttribute('aria-invalid'), 'true');
assert.equal(await folder.inputValue(), missing);
await page.getByText(/目录不存在/).first().waitFor({ state: 'visible' });
await page.waitForFunction(() => document.activeElement?.getAttribute('aria-label') === '媒体目录 1');
await folder.fill(media);
await page.locator('#f-data_root').fill(selectedData);
await page.getByRole('button', { name: '继续', exact: true }).click();
await port.waitFor({ state: 'visible' });
assert.equal(await port.getAttribute('aria-invalid'), 'true');
assert.equal(await port.inputValue(), '70000');
assert.equal(await proxy.getAttribute('aria-invalid'), 'true');
assert.equal(await proxy.inputValue(), 'http://proxy.invalid');
await page.getByText('请填写带端口的 HTTP 或 SOCKS 代理地址', { exact: true }).waitFor({ state: 'visible' });
await page.getByText('端口要是 1 到 65535 之间的整数').waitFor({ state: 'visible' });
// 改对:已有目录、默认端口,不扫描。
await port.fill(defaultPort);
await proxy.fill('socks5h://user:private-proxy@127.0.0.1:17890');
await page.getByRole('button', { name: '继续', exact: true }).click();
const accepted = page.waitForResponse((response) => response.url().endsWith('/api/setup')
&& response.request().method() === 'POST');
await page.getByRole('button', { name: '完成设置' }).click();
const acceptedResponse = await accepted;
assert.equal(acceptedResponse.status(), 200, await acceptedResponse.text());
assert.deepEqual(JSON.parse(await readFile(join(selectedData, 'secrets/follow/peach-proxy.json'), 'utf8')), {
mode: 'proxy', proxy: 'socks5h://user:private-proxy@127.0.0.1:17890',
});
// 完成态:入口链接拿到焦点,扫描没有排队,运行信息收起,口令不在页面上。
await page.getByRole('heading', { name: '可以开始使用了' }).waitFor({ state: 'visible' });
assert.equal((await page.content()).includes('private-proxy'), false);
const entry = page.getByRole('link', { name: '进入 Peach' });
await entry.waitFor({ state: 'visible' });
assert.ok(await entry.evaluate((element) => element === document.activeElement), '入口链接拿到焦点');
assert.match((await entry.getAttribute('href')) ?? '', /^https?:\/\//);
await page.getByText('稍后在配置页开始扫描媒体库。').waitFor({ state: 'visible' });
assert.equal(await page.title(), 'Peach · 设置完成');
assert.deepEqual((await layout(page)).offenders, []);
await page.getByText('运行信息', { exact: true }).click();
await page.getByText(selectedData, { exact: true }).waitFor({ state: 'visible' });
const mobile = VIEWPORTS.find((candidate) => candidate.name === 'mobile')!;
await page.setViewportSize({ width: mobile.width, height: mobile.height });
assert.deepEqual((await layout(page)).offenders, []);
// 已有数据目录的入口在首启页可操作,找不到配置时保留路径并说明原因。
const originalConfig = await readFile(join(selectedData, 'config.toml'));
await unlink(join(dataRoot, 'state', 'data-root.txt'));
await page.reload({ waitUntil: 'load' });
await page.getByRole('button', { name: '使用已有数据', exact: true }).click();
const existing = page.getByRole('textbox', { name: '已有数据目录' });
await existing.fill(join(dataRoot, 'missing'));
const restoreRejected = page.waitForResponse((response) => response.url().endsWith('/api/setup/restore'));
await page.getByRole('button', { name: '使用此目录', exact: true }).click();
assert.equal((await restoreRejected).status(), 400);
await page.getByRole('alert').filter({ hasText: 'config.toml' }).waitFor({ state: 'visible' });
assert.deepEqual((await layout(page)).offenders, []);
await existing.fill(selectedData);
const restoreAccepted = page.waitForResponse((response) => response.url().endsWith('/api/setup/restore'));
await page.getByRole('button', { name: '使用此目录', exact: true }).click();
assert.equal((await restoreAccepted).status(), 200);
await page.getByText('已连接已有数据目录。', { exact: true }).waitFor({ state: 'visible' });
assert.equal(await readFile(join(dataRoot, 'state', 'data-root.txt'), 'utf8'), selectedData);
assert.deepEqual(await readFile(join(selectedData, 'config.toml')), originalConfig);
assert.deepEqual((await layout(page)).offenders, []);
await page.setViewportSize({ width: viewport.width, height: viewport.height });
assert.deepEqual((await layout(page)).offenders, []);
assert.deepEqual(problems.filter((problem) => !expectedRejection(problem)), []);
} finally {
await context.close();
}
});
});