Ctrl K
ComunidadGuías…

frontend/e2e/setup.test.ts

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* 首次运行页的整条流程:加载、填错、原位写回、改对、完成态。
 *
 * 跑在 `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();
    }
  });
});