From cf2c47b826d26f6753774cb35192cf0dfe2400fa Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 18 Jun 2026 11:17:25 +0800 Subject: [PATCH] docs: add e2e test foundation plan --- .../plans/2026-06-18-e2e-test-foundation.md | 608 ++++++++++++++++++ 1 file changed, 608 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-18-e2e-test-foundation.md diff --git a/docs/superpowers/plans/2026-06-18-e2e-test-foundation.md b/docs/superpowers/plans/2026-06-18-e2e-test-foundation.md new file mode 100644 index 0000000..09013f5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-e2e-test-foundation.md @@ -0,0 +1,608 @@ +# 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 +