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
+} type='primary' onClick={() => setEditingModel('')}>
+```
+
+```jsx
+
+```
+
+```jsx
+
+```
+
+- [ ] **Step 3: Add editor selectors**
+
+In `web/src/pages/Setting/Operation/ModelPricing/Editor.jsx`, add:
+
+```jsx
+} onClick={save} loading={saving}>
+```
+
+```jsx
+
+```
+
+```jsx
+} onClick={() => setGeneratorVisible(true)}>
+```
+
+```jsx
+
+```
+
+- [ ] **Step 4: Add dimension selectors**
+
+In `web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx`, add indexed `data-testid` attributes to editable inputs:
+
+```jsx
+ update(index, { key })} />
+```
+
+```jsx
+ update(index, { source: composeDimensionSource(parsed.sourceType, path) })}
+/>
+```
+
+```jsx
+} onClick={add}>
+```
+
+- [ ] **Step 5: Add generator selectors**
+
+In `web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx`, add:
+
+```jsx
+} onClick={addEntry}>
+```
+
+```jsx
+