Ctrl K
ComunidadGuías…

frontend/e2e/harness.ts

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

Archivo original ↗Preguntar a KiBot
/* 真浏览器冒烟的夹具:打开页面、收集运行期问题、测布局几何。
 *
 * 只用 playwright-core 驱动本机已装的 Chrome,不下载浏览器;路径由
 * `tests/test_web_e2e.py` 探测后经 PEACH_E2E_CHROME 传进来。服务、数据库和演示库
 * 也归 Python 那一侧准备,这里只认 PEACH_E2E_ORIGIN。
 *
 * 用例只写语义断言(没有横向溢出、等待态会结束、控制台无错误),不做截图比对:
 * 单人部署里维护基线图的成本高于它能拦住的问题。 */
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';

import { chromium, type Browser, type BrowserContext, type Locator, type Page } from 'playwright-core';

export interface Viewport {
  name: string;
  width: number;
  height: number;
  mobile: boolean;
}

export const VIEWPORTS: readonly Viewport[] = [
  { name: 'desktop', width: 1280, height: 800, mobile: false },
  { name: 'mobile', width: 390, height: 844, mobile: true },
];

export interface Visit {
  page: Page;
  /** 页面异常、console.error、同源 4xx/5xx 与失败请求,按发生顺序。 */
  problems: string[];
  close(): Promise<void>;
}

export interface Layout {
  viewportWidth: number;
  scrollWidth: number;
  /** 右边缘越出视口、且没有被任何祖先横向裁切的可见元素,最多列 5 个。 */
  offenders: string[];
}

export function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} 未设置:用 \`tests/test_web_e2e.py\` 起服务后再跑`);
  return value;
}

export async function launch(): Promise<Browser> {
  // 界面语言默认跟浏览器语言走(ADR-0156);用例断言的是中文文案,浏览器语言固定成简体中文,CI 上的 en-US 不影响结果。
  // Chromium 572515601 的回环随机端口缺陷(WSAENOBUFS 10055)由 Playwright 默认禁用的
  // `TcpPortRandomizationWin` 规避;这里不另传 --disable-features,否则会整串覆盖默认值。
  const args: string[] = ['--lang=zh-CN'];
  const networkLogs = process.env.PEACH_E2E_NET_LOG_DIR;
  if (networkLogs) {
    mkdirSync(networkLogs, { recursive: true });
    const filename = `network-${process.pid}-${randomUUID()}.json`;
    args.push(`--log-net-log=${join(networkLogs, filename)}`, '--net-log-capture-mode=HeavilyRedacted');
    console.info(`浏览器网络事件日志:${filename}`);
  }
  return chromium.launch({
    headless: true,
    executablePath: requiredEnv('PEACH_E2E_CHROME'),
    args,
    // Windows 首次加载浏览器与系统组件使用独立的启动预算,页面等待由各用例限定。
    timeout: 60_000,
  });
}

/** 失败时保留焦点、输入和弹层现场;成功路径不额外查询页面。 */
export async function withPageDiagnostics<T>(page: Page, where: string | (() => string), action: () => Promise<T>): Promise<T> {
  try {
    return await action();
  } catch (error) {
    const state = await page.evaluate(() => ({
      path: location.pathname + location.search,
      ready: document.readyState,
      focus: document.activeElement?.outerHTML.slice(0, 600),
      query: document.querySelector<HTMLInputElement>('#q')?.value,
      layers: [...document.querySelectorAll('dialog,[role="dialog"],#searchMenu')].slice(0, 12).map(node => ({
        tag: node.tagName, id: node.id, hidden: (node as HTMLElement).hidden,
        expanded: node.getAttribute('aria-expanded'), text: node.textContent?.slice(0, 600),
      })),
      busy: [...document.querySelectorAll('[aria-busy="true"],[data-skeleton]')]
        .filter(node => !node.closest('[data-content-skeleton][aria-hidden="true"]'))
        .slice(0, 12).map(node => ({ tag: node.tagName, id: node.id, label: node.getAttribute('aria-label') })),
    })).catch(() => '未取得');
    const tree = await page.locator('body').ariaSnapshot({ timeout: 2000 }).catch(() => '未取得');
    throw new Error(`${typeof where === 'function' ? where() : where}:${String(error)}\n页面现场:${JSON.stringify(state)}\n无障碍树:\n${tree.slice(0, 4000)}`,
      { cause: error });
  }
}

/** 等待态以 `aria-busy="true"` 与 `data-skeleton` 为准;超时就是等待态卡住了。
 * 两个标记由页面自己写:island、遗留模板和 React 子树都得发出,这里才等得到。
 *
 * 它只证明「没有东西在等」,证明不了页面画出来了:主体完全没渲染时同样立刻成立。
 * 所以调用方先等到目标页面自己的主体出现,再调它。
 *
 * `except` 是用例自己压住、永远不会结束的那一处等待(例如故意不回的下一页),按选择器排除。 */
export async function settle(page: Page, scope = 'body', { except = '' }: { except?: string } = {}): Promise<void> {
  /* 活动页和馆藏页会持续轮询任务状态,本来就不保证进入 `networkidle`。页面导航由
     `visit()` 等到 load,主体由调用方的 `expectBody()` 验证;这里只等 Peach 自己公开的
     等待态结束。 */
  try {
    await page.waitForFunction(
      ({ selector, except }) => {
        const root = document.querySelector(selector);
        const waiting = (node: Element) => node.matches('[aria-busy="true"],[data-skeleton]')
          && !node.closest('[data-content-skeleton][aria-hidden="true"]') && !(except && node.matches(except));
        return !!root && !waiting(root) && ![...root.querySelectorAll('[aria-busy="true"],[data-skeleton]')].some(waiting);
      },
      { selector: scope, except }, { timeout: 15_000 });
  } catch (error) {
    const pending = await page.evaluate(({ selector, except }) => {
      const root = document.querySelector(selector);
      if (!root) return { missing_scope: selector };
      const waiting = (node: Element) => node.matches('[aria-busy="true"],[data-skeleton]')
        && !node.closest('[data-content-skeleton][aria-hidden="true"]') && !(except && node.matches(except));
      const nodes = [...root.querySelectorAll('[aria-busy="true"],[data-skeleton]')].filter(waiting);
      if (waiting(root)) nodes.unshift(root);
      return nodes.slice(0, 12).map(node => {
        const box = node.getBoundingClientRect();
        return { tag: node.tagName, id: node.id, skeleton: node.getAttribute('data-skeleton'),
          label: node.getAttribute('aria-label'), scope: node.closest('dialog')?.id || 'page',
          width: box.width, height: box.height };
      });
    }, { selector: scope, except }).catch(() => '未取得');
    throw new Error(`页面等待态未结束:${JSON.stringify(pending)}\n${(error as Error).message}`);
  }
}

/** 等目标页面自己的主体出现:每个定位器都要可见,之后才轮到 `settle`。
 * 等不到就带上此刻 `#main` 的无障碍树失败:CI 上只有这段输出,看得出停在骨架、
 * 空态换了文案还是整块没画。 */
export async function expectBody(page: Page, where: string, body: readonly Locator[], problems: readonly string[] = []): Promise<void> {
  for (const locator of body) {
    try {
      await locator.waitFor({ state: 'visible', timeout: 15_000 });
    } catch (error) {
      const snapshot = await page.locator('#main').ariaSnapshot({ timeout: 5_000 }).catch(() => '未取得');
      assert.fail(`${where}:页面主体没有出现,等不到 ${locator}\n${(error as Error).message}\n`
        + `页面:${page.url()};已关闭:${page.isClosed()};运行期问题:${JSON.stringify(problems)}\n`
        + `#main 当时的无障碍树:\n${snapshot.slice(0, 3000)}`);
    }
  }
}

/** Peach 配置页的主体:标题与默认的媒体库分区。 */
export function configurationBody(page: Page): Locator[] {
  return [
    page.locator('#main').getByRole('heading', { name: 'Peach 配置', exact: true }),
    page.locator('#stats').getByRole('tab', { name: '媒体库与入库' }),
  ];
}

export async function visit(browser: Browser, path: string, viewport: Viewport,
  prepare?: (page: Page) => Promise<void>): Promise<Visit> {
  const origin = requiredEnv('PEACH_E2E_ORIGIN');
  const context: BrowserContext = await browser.newContext({
    viewport: { width: viewport.width, height: viewport.height },
    isMobile: viewport.mobile,
    hasTouch: viewport.mobile,
    reducedMotion: 'reduce',
  });
  // 详情页默认自动播放;这台机器旁边有人,冒烟不出声。
  await context.addInitScript(() => {
    localStorage.setItem('peach.settings.v1', JSON.stringify({ detailAutoplay: false }));
  });
  const page = await context.newPage();
  const problems: string[] = [];
  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)}`);
    }
  });
  page.on('requestfailed', (request) => {
    const reason = request.failure()?.errorText ?? '';
    if (request.url().startsWith(origin) && !reason.includes('ERR_ABORTED')) {
      problems.push(`failed ${request.url().slice(origin.length)} ${reason}`);
    }
  });
  /* 桩回调里的 `route.fetch`、`route.continue` 可能在关上下文时还没返回,之后会以
   * 「Request context disposed」失败;没人等它,就成了整个文件的 unhandledRejection。
   * `ignoreErrors` 先撤掉全部桩,让还在路上的那几趟失败时静默返回。 */
  const close = async () => {
    if (!page.isClosed()) await page.unrouteAll({ behavior: 'ignoreErrors' });
    await context.close();
  };
  // 只打开到 load:等哪块主体、何时 settle 由用例决定,见 `settle` 的说明。
  try {
    await prepare?.(page);
    await page.goto(origin + path, { waitUntil: 'load' });
  } catch (error) {
    await close();
    throw error;
  }
  return { page, problems, close };
}

/** 时间轴预览的桩:`/api/timeline` 回固定间隔的自有接触印相,`/timeline` 回合成的 10×10 拼图,
 *  每格写「页-格」序号,截图里看得出取的是哪一格。返回每页被请求的次数。 */
export async function stubTimelineSheets(page: Page, { frames, interval }: { frames: number; interval: number }) {
  const requested = new Map<number, number>();
  await page.route((url) => url.pathname === '/api/timeline', (route) => route.fulfill({
    json: { source: 'peach', frames, interval, sheets: Math.ceil(frames / 100), columns: 10, rows: 10, frame_width: 160 },
  }));
  await page.route((url) => url.pathname === '/timeline', (route) => {
    const sheet = Number(new URL(route.request().url()).searchParams.get('s'));
    requested.set(sheet, (requested.get(sheet) ?? 0) + 1);
    const count = Math.min(100, frames - sheet * 100), rows = Math.ceil(count / 10);
    const cells = Array.from({ length: count }, (_, slot) => {
      const x = slot % 10 * 160, y = Math.floor(slot / 10) * 90;
      return `<rect x="${x}" y="${y}" width="160" height="90" fill="hsl(${(sheet * 100 + slot) * 7 % 360} 40% 35%)"/>`
        + `<text x="${x + 80}" y="${y + 58}" font-size="36" font-family="sans-serif" fill="#fff" text-anchor="middle">${sheet}-${slot}</text>`;
    }).join('');
    return route.fulfill({ contentType: 'image/svg+xml',
      body: `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="${rows * 90}">${cells}</svg>` });
  });
  return requested;
}

/* 越界名单只报根因:页面一旦被撑宽,移动视口跟着变宽,`fixed` 的顶栏、Toast 会一起
 * 伸出去,按文档顺序截前几个就只剩这些症状。所以跳过 `fixed` 及其后代,并且只留
 * 父元素自己没越界的那一层。 */
/** 检查实际可滚动的区域是否由统一滑块或横向手势接管。 */
export async function unmanagedScrollers(page: Page): Promise<string[]> {
  return page.evaluate(() => [...document.querySelectorAll<HTMLElement>('body *')].flatMap(node => {
    if (!(node instanceof HTMLElement) || node.closest('.ovtrack,.ui-ov-edges,[data-skeleton],[inert]')) return [];
    const style = getComputedStyle(node), box = node.getBoundingClientRect();
    if (!box.width || !box.height || style.visibility === 'hidden' || style.display === 'none') return [];
    const vertical = /^(auto|scroll)$/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1;
    const horizontal = /^(auto|scroll)$/.test(style.overflowX) && node.scrollWidth > node.clientWidth + 1;
    if (!vertical && !horizontal) return [];
    if (node.hasAttribute('data-overlay-scrollbar')) return [];
    if (!vertical && (node.hasAttribute('data-horizontal-scroll') || node.hasAttribute('data-scroll-gesture'))) return [];
    return [`${node.tagName.toLowerCase()}${node.id ? '#' + node.id : ''}.${String(node.className).slice(0, 120)}`];
  }));
}

export async function layout(page: Page): Promise<Layout> {
  return page.evaluate(() => {
    const width = document.documentElement.clientWidth;
    const excused = (element: Element): boolean => {
      if (getComputedStyle(element).position === 'fixed') return true;
      for (let parent = element.parentElement; parent && parent !== document.body; parent = parent.parentElement) {
        const style = getComputedStyle(parent);
        if (style.overflowX !== 'visible' || style.position === 'fixed') return true;
      }
      return false;
    };
    const label = (element: Element): string => {
      const id = element.id ? `#${element.id}` : '';
      const classes = [...element.classList].slice(0, 2).map((name) => `.${name}`).join('');
      return `${element.tagName.toLowerCase()}${id}${classes}`;
    };
    const overflowing = new Set<Element>();
    for (const element of document.body.querySelectorAll('*')) {
      const box = element.getBoundingClientRect();
      if (box.width === 0 || box.height === 0 || box.right <= width + 1) continue;
      const style = getComputedStyle(element);
      if (style.visibility === 'hidden' || style.display === 'none' || excused(element)) continue;
      overflowing.add(element);
    }
    const offenders = [...overflowing]
      .filter((element) => !element.parentElement || !overflowing.has(element.parentElement))
      .slice(0, 5)
      .map((element) => `${label(element)} right=${Math.round(element.getBoundingClientRect().right)}`);
    return {
      viewportWidth: width,
      scrollWidth: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),
      offenders,
    };
  });
}