# E2E Test Foundation Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a reusable Playwright E2E foundation and cover the model multidimensional pricing save/reload workflow. **Architecture:** Playwright runs from `web/`, starts the React app through Vite, and starts the Go backend through a global setup helper. The backend uses a temporary SQLite database and a fixed test session secret so each E2E run is isolated and repeatable. **Tech Stack:** Playwright, Bun, React/Vite, Go backend, SQLite. --- ## File Structure - Create `web/playwright.config.js`: Playwright configuration, projects, web server, artifacts, and env wiring. - Create `web/e2e/global-setup.js`: start backend with temporary SQLite and wait for readiness. - Create `web/e2e/global-teardown.js`: stop backend process and remove temporary database directory. - Create `web/e2e/auth.setup.js`: log in as `root / 123456` and save storage state. - Create `web/e2e/model-pricing.spec.js`: first real E2E workflow for model multidimensional pricing. - Create `web/e2e/utils/process.js`: cross-platform process spawn/kill helpers. - Create `web/e2e/utils/wait.js`: HTTP polling helper. - Modify `web/package.json`: add E2E scripts. - Modify `web/src/components/settings/RatioSetting.jsx`: add `data-testid` to the model pricing tab content. - Modify `web/src/pages/Setting/Operation/ModelPricing/index.jsx`: add stable selectors for the page, add button, search input, and table. - Modify `web/src/pages/Setting/Operation/ModelPricing/Editor.jsx`: add stable selectors for model input, save, generator, and JSON editor. - Modify `web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx`: add stable selectors for add dimension and editable cells. - Modify `web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx`: add stable selectors for base price, add ratio, apply, and ratio inputs. ## Task 1: Add Playwright Scripts **Files:** - Modify: `web/package.json` - [ ] **Step 1: Add E2E scripts** Add these entries inside `scripts`: ```json "e2e": "playwright test", "e2e:ui": "playwright test --ui", "e2e:headed": "playwright test --headed", "e2e:install": "playwright install chromium" ``` - [ ] **Step 2: Verify package scripts parse** Run: ```powershell cd web bun pm pkg get scripts.e2e ``` Expected output includes: ```text "playwright test" ``` - [ ] **Step 3: Commit** ```powershell git add web/package.json git commit -m "test: add e2e scripts" ``` ## Task 2: Add Backend Process Helpers **Files:** - Create: `web/e2e/utils/process.js` - Create: `web/e2e/utils/wait.js` - [ ] **Step 1: Create process helper** Create `web/e2e/utils/process.js`: ```js import { spawn } from 'node:child_process'; export function startProcess(command, args, options = {}) { const child = spawn(command, args, { cwd: options.cwd, env: { ...process.env, ...(options.env || {}) }, shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'pipe'], }); const output = []; child.stdout.on('data', (chunk) => output.push(chunk.toString())); child.stderr.on('data', (chunk) => output.push(chunk.toString())); child.once('exit', (code) => { if (code !== null && code !== 0) { output.push(`\nprocess exited with code ${code}\n`); } }); return { child, output }; } export async function stopProcess(child) { if (!child || child.killed) return; await new Promise((resolve) => { child.once('exit', resolve); child.kill(process.platform === 'win32' ? undefined : 'SIGTERM'); setTimeout(() => { if (!child.killed) child.kill('SIGKILL'); resolve(); }, 5000).unref(); }); } ``` - [ ] **Step 2: Create HTTP wait helper** Create `web/e2e/utils/wait.js`: ```js export async function waitForHttp(url, options = {}) { const timeoutMs = options.timeoutMs || 60000; const intervalMs = options.intervalMs || 500; const start = Date.now(); let lastError; while (Date.now() - start < timeoutMs) { try { const response = await fetch(url, { cache: 'no-store' }); if (response.ok) return response; lastError = new Error(`HTTP ${response.status}`); } catch (error) { lastError = error; } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error(`Timed out waiting for ${url}: ${lastError?.message || 'no response'}`); } ``` - [ ] **Step 3: Commit** ```powershell git add web/e2e/utils/process.js web/e2e/utils/wait.js git commit -m "test: add e2e process helpers" ``` ## Task 3: Add Playwright Runtime Setup **Files:** - Create: `web/e2e/global-setup.js` - Create: `web/e2e/global-teardown.js` - Create: `web/playwright.config.js` - [ ] **Step 1: Create global setup** Create `web/e2e/global-setup.js`: ```js import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { startProcess } from './utils/process.js'; import { waitForHttp } from './utils/wait.js'; const statePath = path.resolve('.playwright/e2e-state.json'); export default async function globalSetup() { const repoRoot = path.resolve('..'); const backendPort = process.env.E2E_BACKEND_PORT || '3001'; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'new-api-e2e-')); const sqlitePath = path.join(tempDir, 'e2e.db'); await fs.mkdir(path.dirname(statePath), { recursive: true }); const backend = startProcess('go', ['run', 'main.go'], { cwd: repoRoot, env: { PORT: backendPort, SQLITE_PATH: `${sqlitePath}?_busy_timeout=30000`, SESSION_SECRET: 'e2e_session_secret_for_local_tests', }, }); try { await waitForHttp(`http://127.0.0.1:${backendPort}/api/setup`, { timeoutMs: 90000 }); } catch (error) { console.error(backend.output.join('')); backend.child.kill(); throw error; } await fs.writeFile( statePath, JSON.stringify({ backendPid: backend.child.pid, tempDir, backendPort, }), 'utf8', ); } ``` - [ ] **Step 2: Create global teardown** Create `web/e2e/global-teardown.js`: ```js import fs from 'node:fs/promises'; import path from 'node:path'; const statePath = path.resolve('.playwright/e2e-state.json'); export default async function globalTeardown() { try { const state = JSON.parse(await fs.readFile(statePath, 'utf8')); if (state.backendPid) { try { process.kill(state.backendPid); } catch {} } if (state.tempDir) { await fs.rm(state.tempDir, { recursive: true, force: true }); } await fs.rm(statePath, { force: true }); } catch {} } ``` - [ ] **Step 3: Create Playwright config** Create `web/playwright.config.js`: ```js import { defineConfig, devices } from '@playwright/test'; const frontendPort = process.env.E2E_FRONTEND_PORT || '5173'; const backendPort = process.env.E2E_BACKEND_PORT || '3001'; export default defineConfig({ testDir: './e2e', globalSetup: './e2e/global-setup.js', globalTeardown: './e2e/global-teardown.js', timeout: 60_000, expect: { timeout: 10_000 }, fullyParallel: false, retries: process.env.CI ? 1 : 0, reporter: [['list'], ['html', { open: 'never' }]], use: { baseURL: `http://127.0.0.1:${frontendPort}`, trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'off', }, webServer: { command: `bun run dev -- --host 127.0.0.1 --port ${frontendPort}`, url: `http://127.0.0.1:${frontendPort}`, reuseExistingServer: !process.env.CI, env: { VITE_REACT_APP_SERVER_URL: `http://127.0.0.1:${backendPort}`, }, timeout: 90_000, }, projects: [ { name: 'setup', testMatch: /auth\.setup\.js/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/root.json', }, dependencies: ['setup'], testIgnore: /auth\.setup\.js/, }, ], }); ``` - [ ] **Step 4: Run Playwright list to verify config loads** Run: ```powershell cd web bunx playwright test --list ``` Expected: command exits successfully and lists no feature tests yet, or only setup if Task 4 has already been completed. - [ ] **Step 5: Commit** ```powershell git add web/playwright.config.js web/e2e/global-setup.js web/e2e/global-teardown.js git commit -m "test: add e2e runtime setup" ``` ## Task 4: Add Login Storage Setup **Files:** - Create: `web/e2e/auth.setup.js` - [ ] **Step 1: Create auth setup test** Create `web/e2e/auth.setup.js`: ```js import { test, expect } from '@playwright/test'; import fs from 'node:fs/promises'; test('authenticate as root', async ({ page }) => { await fs.mkdir('e2e/.auth', { recursive: true }); await page.goto('/login'); await page.getByPlaceholder(/用户名|username/i).fill('root'); await page.getByPlaceholder(/密码|password/i).fill('123456'); const loginResponse = page.waitForResponse((response) => response.url().includes('/api/user/login') && response.request().method() === 'POST', ); await page.getByRole('button', { name: /登录|login/i }).click(); const response = await loginResponse; expect(response.ok()).toBeTruthy(); await expect(page).toHaveURL(/\/console/); await page.context().storageState({ path: 'e2e/.auth/root.json' }); }); ``` - [ ] **Step 2: Run setup only** Run: ```powershell cd web bunx playwright test --project=setup ``` Expected: setup test passes and creates `web/e2e/.auth/root.json`. - [ ] **Step 3: Ignore generated auth state** If `web/e2e/.auth/root.json` appears in `git status`, add this to `.gitignore`: ```gitignore web/e2e/.auth/ web/.playwright/ web/playwright-report/ web/test-results/ ``` - [ ] **Step 4: Commit** ```powershell git add web/e2e/auth.setup.js .gitignore git commit -m "test: add e2e root login setup" ``` ## Task 5: Add Stable Selectors To Model Pricing UI **Files:** - Modify: `web/src/components/settings/RatioSetting.jsx` - Modify: `web/src/pages/Setting/Operation/ModelPricing/index.jsx` - Modify: `web/src/pages/Setting/Operation/ModelPricing/Editor.jsx` - Modify: `web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx` - Modify: `web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx` - [ ] **Step 1: Add tab content selector** In `web/src/components/settings/RatioSetting.jsx`, wrap the model pricing tab content: ```jsx
``` - [ ] **Step 2: Add page-level selectors** In `web/src/pages/Setting/Operation/ModelPricing/index.jsx`, use these props: ```jsx ``` ```jsx