AI 能快速生成界面,但生成结果只能算交付候选,不能直接等同于可维护的产品。本文用一个可运行的任务面板演示如何把交互、响应式、可访问性和视觉回归纳入 Playwright 验收线,并让失败结果真正阻断合并。

先把“看起来能用”改写成可验证条件

接管 AI 生成的原型时,我不会先讨论组件是否优雅,而是先确认用户能否完成关键任务。原因很简单:样式重构可以逐步进行,错误的交互路径、不可操作的移动端页面和缺失的表单语义却会直接影响交付。

验收清单需要描述外部行为,而不是绑定实现细节。例如,测试“用户填写任务名称并提交后,列表出现新任务”,比测试某个 .task-item 节点数量增加更稳定。前者允许后续替换框架或调整 DOM,后者很容易因重构失效。

风险可验证条件Playwright 手段
关键交互失效提交任务后列表和状态提示同步更新角色定位、表单操作、断言
移动端溢出390 像素视口下没有横向滚动设置视口、读取布局尺寸
键盘不可操作输入框之后可通过 Tab 到达提交按钮键盘操作、焦点断言
语义或对比度问题页面没有已知的 axe 违规项@axe-core/playwright
样式意外变化桌面端和移动端截图与基线一致toHaveScreenshot

这份清单不等于完整的产品测试。支付、权限、数据一致性等风险仍需单独设计,但它足以构成前端原型进入评审和持续集成的最低验收线。

建立一个可运行的原型项目

示例使用 Vite 提供静态页面,Playwright 负责验收。新建目录后写入以下 package.json

{
  "name": "prototype-acceptance",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test:e2e": "playwright test",
    "test:update": "playwright test --update-snapshots"
  },
  "devDependencies": {
    "@axe-core/playwright": "^4.10.0",
    "@playwright/test": "^1.49.0",
    "vite": "^5.4.0"
  }
}

创建 index.html。页面保留一个最小但完整的业务闭环:填写任务、提交并得到反馈;布局在窄屏下自动改为单列。

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>交付任务面板</title>
  <style>
    * { box-sizing: border-box; }
    body { margin: 0; color: #0f172a; background: #f8fafc; font-family: system-ui, sans-serif; }
    main { width: min(960px, calc(100% - 32px)); margin: 40px auto; }
    h1 { margin-bottom: 8px; }
    .intro { color: #475569; }
    .grid { display: grid; grid-template-columns: 2fr 3fr; gap: 24px; margin-top: 24px; }
    .card { padding: 24px; border: 1px solid #cbd5e1; border-radius: 12px; background: white; }
    label { display: block; margin-bottom: 8px; font-weight: 700; }
    input { width: 100%; min-height: 44px; padding: 10px; border: 1px solid #64748b; border-radius: 6px; }
    button { min-height: 44px; margin-top: 12px; padding: 0 18px; border: 0; border-radius: 6px; color: white; background: #1d4ed8; cursor: pointer; }
    input:focus, button:focus { outline: 3px solid #f59e0b; outline-offset: 2px; }
    li + li { margin-top: 10px; }
    #status { min-height: 24px; color: #166534; }
    @media (max-width: 640px) {
      main { margin: 24px auto; }
      .grid { grid-template-columns: 1fr; }
      .card { padding: 18px; }
    }
  </style>
</head>
<body>
  <main>
    <h1>交付任务面板</h1>
    <p class="intro">记录进入发布前必须完成的检查项。</p>
    <div class="grid">
      <section class="card" aria-labelledby="create-title">
        <h2 id="create-title">添加任务</h2>
        <form id="task-form">
          <label for="task-name">任务名称</label>
          <input id="task-name" name="task" required>
          <button type="submit">加入清单</button>
        </form>
        <p id="status" role="status" aria-live="polite"></p>
      </section>
      <section class="card" aria-labelledby="list-title">
        <h2 id="list-title">待验收任务</h2>
        <ul id="task-list"><li>检查支付流程</li></ul>
      </section>
    </div>
  </main>
  <script>
    const form = document.querySelector('#task-form');
    const input = document.querySelector('#task-name');
    const list = document.querySelector('#task-list');
    const status = document.querySelector('#status');

    form.addEventListener('submit', (event) => {
      event.preventDefault();
      const name = input.value.trim();
      if (!name) return;
      const item = document.createElement('li');
      item.textContent = name;
      list.append(item);
      status.textContent = `已添加:${name}`;
      form.reset();
      input.focus();
    });
  </script>
</body>
</html>

执行 npm installnpx playwright install chromium 即可安装依赖。生成的锁文件也应提交到仓库,保证本地与 CI 使用相同的依赖解析结果。

用端到端测试覆盖交互、布局与可访问性

创建 playwright.config.ts,让测试启动生产构建后的预览服务,而不是依赖开发者提前运行服务器:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: Boolean(process.env.CI),
  retries: process.env.CI ? 1 : 0,
  reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
  use: {
    baseURL: 'http://127.0.0.1:4173',
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure'
  },
  expect: {
    toHaveScreenshot: { maxDiffPixelRatio: 0.01 }
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } }
  ],
  webServer: {
    command: 'npm run build && npm run preview -- --host 127.0.0.1',
    url: 'http://127.0.0.1:4173',
    reuseExistingServer: !process.env.CI
  }
});

再创建 tests/acceptance.spec.ts

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.beforeEach(async ({ page }) => {
  await page.goto('/');
});

test('用户可以添加验收任务', async ({ page }) => {
  await page.getByLabel('任务名称').fill('补充退款说明');
  await page.getByRole('button', { name: '加入清单' }).click();

  await expect(page.getByRole('listitem', { name: '补充退款说明' })).toBeVisible();
  await expect(page.getByRole('status')).toHaveText('已添加:补充退款说明');
  await expect(page.getByLabel('任务名称')).toBeFocused();
});

test('表单支持键盘导航且没有已知可访问性违规', async ({ page }) => {
  const input = page.getByLabel('任务名称');
  await input.focus();
  await page.keyboard.press('Tab');
  await expect(page.getByRole('button', { name: '加入清单' })).toBeFocused();

  const result = await new AxeBuilder({ page }).analyze();
  expect(result.violations).toEqual([]);
});

test('移动端布局没有横向溢出', async ({ page }) => {
  await page.setViewportSize({ width: 390, height: 844 });
  const size = await page.evaluate(() => ({
    scrollWidth: document.documentElement.scrollWidth,
    clientWidth: document.documentElement.clientWidth
  }));
  expect(size.scrollWidth).toBeLessThanOrEqual(size.clientWidth);
});

test('桌面端和移动端视觉基线保持稳定', async ({ page }) => {
  await page.setViewportSize({ width: 1280, height: 800 });
  await expect(page).toHaveScreenshot('home-desktop.png', {
    fullPage: true,
    animations: 'disabled'
  });

  await page.setViewportSize({ width: 390, height: 844 });
  await expect(page).toHaveScreenshot('home-mobile.png', {
    fullPage: true,
    animations: 'disabled'
  });
});

这些测试有意优先使用标签、角色和可见文本定位。它既接近用户操作方式,也会迫使页面保留基本语义。axe 检查可以发现一部分自动化可识别的问题,但不能替代读屏器试用、内容理解和真实用户验证。

管理视觉基线并让 CI 阻断回归

首次建立快照时运行 npm run test:update,审核生成的图片后再提交。视觉快照应在与 CI 相同的操作系统、浏览器版本和字体环境中生成,否则渲染差异可能产生无意义的失败。基线变化最好独立说明原因,不要在 CI 中自动更新,也不要为了让测试通过而持续放宽差异阈值。

GitHub Actions 可使用 .github/workflows/e2e.yml

name: E2E acceptance

on:
  pull_request:
  push:
    branches: [main]

jobs:
  playwright:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e
      - if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-failure
          path: |
            playwright-report
            test-results
          if-no-files-found: ignore

工作流失败只代表检查结果可见,要实现真正阻断,还需要在仓库分支保护规则中把该工作流设为必需状态检查。失败时保留 trace、截图和视频,评审者可以区分产品回归、测试缺陷与环境差异;若确认是预期改版,再由开发者更新并审核基线。

总结

接管 AI 原型的关键不是增加多少测试,而是把模糊的“能用”转换为团队可以重复执行的交付条件:

  • 先按用户任务、移动端布局、键盘操作和回归风险整理验收清单;
  • 使用角色、标签和可见文本编写端到端测试,减少对 DOM 结构的依赖;
  • 用 axe 补充自动化可访问性检查,但保留必要的人工验证;
  • 在稳定环境中维护视觉基线,任何更新都需要评审;
  • 通过 CI、失败产物和分支保护,让验收失败真正阻断合并。

AI 可以继续承担页面生成和局部修改,但交付标准应掌握在开发团队手中。这样得到的不是一次性演示,而是一条能随产品迭代持续运行的验收线。