diff --git a/AGENTS.md b/AGENTS.md
index 3e09b2d..c44f6af 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,9 +3,7 @@
## 开发阶段原则
- 开发阶段仅关注业务功能:不实现访问限制、认证、网络隔离等安全策略,安全由用户自行把控。
-
- 开发阶段不做数据兼容:数据库 schema 可随时破坏性重建,不编写迁移兼容、存量回填或双写代码;网关与环境数据均可删除重来。
-
- 保持变更小而独立可评审,并附带覆盖该变更的最小相关检查。
- 使用下方已批准的技术栈;在栈内优先复用现有代码、标准库和平台原生能力,而非新增依赖或抽象。
- 在集成边界保持幂等性和向后兼容;文档化重试与失败行为。
diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go
index fa79629..8a0e9ba 100644
--- a/cmd/control-plane/main.go
+++ b/cmd/control-plane/main.go
@@ -165,8 +165,8 @@ func loadConfig() (config, error) {
if cfg.username == "" || strings.Contains(cfg.username, ":") {
return config{}, errors.New("CONTROL_PLANE_USERNAME must be non-empty and contain no colon")
}
- if len(cfg.password) < 16 {
- return config{}, errors.New("CONTROL_PLANE_PASSWORD must contain at least 16 bytes")
+ if len(cfg.password) < 6 {
+ return config{}, errors.New("CONTROL_PLANE_PASSWORD must contain at least 6 bytes")
}
databaseURL, err := url.Parse(cfg.databaseURL)
if err != nil || databaseURL.Host == "" || databaseURL.Path == "" || databaseURL.Path == "/" ||
diff --git a/cmd/control-plane/main_test.go b/cmd/control-plane/main_test.go
index 55b1864..f1fff45 100644
--- a/cmd/control-plane/main_test.go
+++ b/cmd/control-plane/main_test.go
@@ -84,6 +84,25 @@ func TestLoadConfigRequiresControlPlaneCredentials(t *testing.T) {
}
}
+func TestLoadConfigPasswordLength(t *testing.T) {
+ t.Setenv("CONTROL_PLANE_USERNAME", "operator")
+ for _, test := range []struct {
+ password string
+ wantErr bool
+ }{
+ {"12345", true},
+ {"123456", false},
+ } {
+ t.Run(test.password, func(t *testing.T) {
+ t.Setenv("CONTROL_PLANE_PASSWORD", test.password)
+ _, err := loadConfig()
+ if (err != nil) != test.wantErr {
+ t.Fatalf("loadConfig() error = %v, wantErr %v", err, test.wantErr)
+ }
+ })
+ }
+}
+
func TestControlPlaneAuthentication(t *testing.T) {
logger := logrus.StandardLogger()
previousOutput := logger.Out
diff --git a/docs/deployment.md b/docs/deployment.md
index c47a850..17380ce 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -97,7 +97,7 @@ docker compose logs --tail=200 creator-hub docker-gateway postgres
1. 登录后在「网关管理」注册 http://docker-gateway:8081,令牌必须等于 GATEWAY_TOKEN。
2. 在「镜像版本」添加并启用一个可拉取的 fingerprint-chromium 镜像。
3. 在「网络出口」创建出口并点击「检测」,健康状态必须为「健康」;这里只填写凭据引用 ID,不填写密码、Cookie 或 token。
-4. 在「账号池」创建平台为 mock 的账号;创建后默认暂停。
+4. 在「社媒账号」创建平台为 mock 的账号;创建后默认暂停。
5. 在「运行环境」选择该账号、健康出口和镜像,使用正整数 Fingerprint Seed 创建环境;随后在账号详情点击「恢复账号」,再在「运行环境」点击「启动」。
6. 在账号详情创建文本草稿,点击「核对草稿」,勾选“我已核对当前账号、草稿内容、运行环境和固定出口”,依次执行「确认当前快照」→「保存确认」→「加入队列」。
7. Mock 执行器没有独立页面,使用下面的 POST /api/phase-a/mock/execute,再到「任务中心」和「审计」核对结果。
@@ -227,7 +227,7 @@ docker compose stop
| 现象 | 先检查 | 处理 |
| --- | --- | --- |
-| docker compose config 报 required | CONTROL_PLANE_USERNAME、CONTROL_PLANE_PASSWORD 是否在当前 shell 非空 | 重新 export 两个变量;用户名不能含冒号,密码至少 16 字节 |
+| docker compose config 报 required | CONTROL_PLANE_USERNAME、CONTROL_PLANE_PASSWORD 是否在当前 shell 非空 | 重新 export 两个变量;用户名不能含冒号,密码至少 6 字节 |
| 手工验证脚本在 `:?` 处退出 | CREATORHUB_PORT、GATEWAY_TOKEN、CONTROL_PLANE_USERNAME、CONTROL_PLANE_PASSWORD 是否都已 export | 在启动 Compose 的同一个 shell 中 export 完整变量集;不要只依赖 `.env` 或 Compose 默认值 |
| creator-hub 未启动 | docker compose ps、docker compose logs --tail=200 postgres docker-gateway creator-hub | 先确认 PostgreSQL 与网关 health 为 healthy;网关需能访问 /var/run/docker.sock,DOCKER_GID 使用 stat -c '%g' /var/run/docker.sock 的实际值 |
| API 返回 401 | curl 是否带 --user CONTROL_PLANE_USERNAME:CONTROL_PLANE_PASSWORD | /healthz 不需要认证,其余 /api/* 需要控制面 Basic Auth |
@@ -250,7 +250,7 @@ Compose 部署时通常只需设置以下宿主机变量:
| `DOCKER_GID` | `999` | Docker socket 的宿主机组 ID;必须按实际值设置 |
| `GATEWAY_TOKEN` | `dev-creatorhub-gateway-token` | Compose 未提供变量时的默认值;本说明要求显式 export 随机值,并同步填入网关注册表单 |
| `CONTROL_PLANE_USERNAME` | 无(必填) | 控制面唯一用户;不能包含冒号 |
-| `CONTROL_PLANE_PASSWORD` | 无(必填) | 控制面密码,至少 16 字节;使用随机值 |
+| `CONTROL_PLANE_PASSWORD` | 无(必填) | 控制面密码,至少 6 字节;使用随机值 |
服务本身支持并校验以下环境变量;`compose.yaml` 会在控制面凭据缺失或为空时拒绝渲染。下表中的 Compose 默认值不由手工验证脚本隐式读取;手工验证沿用上文的显式 export 要求:
@@ -261,7 +261,7 @@ Compose 部署时通常只需设置以下宿主机变量:
| `creator-hub` | `DATABASE_URL` | `postgres://creatorhub@postgres/creatorhub?sslmode=disable` |
| `creator-hub` | `LOG_LEVEL` | 默认 `info` |
| `creator-hub` | `CONTROL_PLANE_USERNAME` | 必填;HTTP Basic Auth 用户名 |
-| `creator-hub` | `CONTROL_PLANE_PASSWORD` | 必填且至少 16 字节;不会写入日志或响应 |
+| `creator-hub` | `CONTROL_PLANE_PASSWORD` | 必填且至少 6 字节;不会写入日志或响应 |
| `docker-gateway` | `LISTEN_ADDR` | 默认 `:8081` |
| `docker-gateway` | `DOCKER_SOCKET` | 默认值和 Compose 挂载均固定为 `/var/run/docker.sock`;不能只覆盖环境变量 |
| `docker-gateway` | `BROWSER_NETWORK` | `creatorhub_browser` |
diff --git a/web/package-lock.json b/web/package-lock.json
index d33a312..38db8e6 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -26,6 +26,7 @@
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@vitejs/plugin-react": "6.1.0",
+ "@vitest/coverage-v8": "4.1.11",
"jsdom": "27.0.1",
"vite": "8.2.2",
"vitest": "4.1.11"
@@ -205,6 +206,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@csstools/color-helpers": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
@@ -1238,6 +1249,37 @@
}
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
+ "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.11",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.11",
+ "vitest": "4.1.11"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
@@ -1411,6 +1453,25 @@
"node": ">=12"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
+ "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
@@ -1997,6 +2058,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@@ -2061,6 +2132,13 @@
"node": ">=18"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -2155,6 +2233,45 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2543,6 +2660,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
+ "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3227,6 +3372,19 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -3314,6 +3472,19 @@
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
},
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
diff --git a/web/package.json b/web/package.json
index 5fb3096..828ac9b 100644
--- a/web/package.json
+++ b/web/package.json
@@ -7,6 +7,7 @@
"build": "vite build",
"dev": "vite",
"test": "vitest run",
+ "test:coverage": "vitest run --coverage",
"test:responsive": "playwright test"
},
"dependencies": {
@@ -28,6 +29,7 @@
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@vitejs/plugin-react": "6.1.0",
+ "@vitest/coverage-v8": "4.1.11",
"jsdom": "27.0.1",
"vite": "8.2.2",
"vitest": "4.1.11"
diff --git a/web/src/AccountList.jsx b/web/src/AccountList.jsx
index efe8abf..d3bc0ad 100644
--- a/web/src/AccountList.jsx
+++ b/web/src/AccountList.jsx
@@ -152,7 +152,7 @@ export function AccountList() {
const needle = filter.trim().toLowerCase()
const visible = needle ? accounts.filter(account => `${account.platform} ${account.platform_account_key} ${account.id}`.toLowerCase().includes(needle)) : accounts
- useEffect(() => { document.title = 'CreatorHub · 账号池' }, [])
+ useEffect(() => { document.title = 'CreatorHub · 社媒账号' }, [])
async function createAccount(data) {
setBusy(true); setMessage(null)
@@ -169,7 +169,7 @@ export function AccountList() {
return (
<>
- 账号池管理账号授权、暂停状态与固定运行资源
+ 社媒账号管理账号授权、暂停状态与固定运行资源
{message ? {message.text} : null}
{error ? {actionError(error, '账号列表冲突')} : null}
@@ -228,7 +228,7 @@ export function AccountDetail() {
if (error || !account) return {error?.message || '账号不存在'}
return (
<>
- {account.platform_account_key}{account.id} · {account.platform}
+ {account.platform_account_key}{account.id} · {account.platform}
{message ? {message.text} : null}
{browsersError ? refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>{environmentError(browsersError)} : null}
} sx={{ mb: 2.5 }}>暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有 hold。
diff --git a/web/src/BrowserImageList.test.jsx b/web/src/BrowserImageList.test.jsx
index ae32700..0af9d67 100644
--- a/web/src/BrowserImageList.test.jsx
+++ b/web/src/BrowserImageList.test.jsx
@@ -55,12 +55,10 @@ describe('BrowserImageList', () => {
it('surfaces delete rejection (referenced by env)', async () => {
const dataProvider = provider({ delete: vi.fn().mockRejectedValue(new Error('该版本正被环境引用')) })
+ vi.spyOn(window, 'confirm').mockReturnValue(true)
render()
fireEvent.click(await screen.findByLabelText('删除 148.0.0.1'))
- expect(window.confirm).toBeTruthy()
- window.confirm = vi.fn(() => true)
- fireEvent.click(screen.getByLabelText('删除 148.0.0.1'))
expect((await screen.findByRole('alert')).textContent).toContain('该版本正被环境引用')
})
diff --git a/web/src/layout.jsx b/web/src/layout.jsx
index 30b0ce0..6fe6fbb 100644
--- a/web/src/layout.jsx
+++ b/web/src/layout.jsx
@@ -37,9 +37,9 @@ export function CreatorHubMenu({ collapsed, onNavigate }) {
{collapsed ? null : 运营}
-
+
- {collapsed ? null : }
+ {collapsed ? null : }
diff --git a/web/src/main.jsx b/web/src/main.jsx
index beea254..fdc1c1b 100644
--- a/web/src/main.jsx
+++ b/web/src/main.jsx
@@ -23,7 +23,7 @@ createRoot(document.getElementById('root')).render(
-
+
diff --git a/web/tests/responsive.e2e.js b/web/tests/responsive.e2e.js
index d01f0b6..5f0049b 100644
--- a/web/tests/responsive.e2e.js
+++ b/web/tests/responsive.e2e.js
@@ -12,6 +12,26 @@ const runtime = {
endpoint: 'http://account-a:9222',
}
+test('opens social-account and network-exit configuration from the main menu', async ({ page }) => {
+ await page.route('**/api/browsers', route => route.fulfill({ json: [] }))
+ await page.route('**/api/gateways', route => route.fulfill({ json: [] }))
+ await page.route('**/api/browser-images', route => route.fulfill({ json: [] }))
+ await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [] }))
+ await page.route('**/api/network-exits', route => route.fulfill({ json: [] }))
+ await page.setViewportSize({ width: 599, height: 800 })
+ await page.goto('/#/browsers')
+
+ await page.getByRole('button', { name: '打开导航' }).click()
+ await page.getByRole('link', { name: '社媒账号' }).click()
+ await expect(page).toHaveURL(/#\/accounts$/)
+ await expect(page.getByRole('heading', { level: 1, name: '社媒账号' })).toBeVisible()
+
+ await page.getByRole('button', { name: '打开导航' }).click()
+ await page.getByRole('link', { name: '网络出口' }).click()
+ await expect(page).toHaveURL(/#\/network-exits$/)
+ await expect(page.getByRole('heading', { level: 1, name: '网络出口' })).toBeVisible()
+})
+
test('keeps the create form inside a 900px viewport', async ({ page }) => {
await page.route('**/api/browsers', route => route.fulfill({ json: [] }))
await page.route('**/api/gateways', route => route.fulfill({ json: [] }))
diff --git a/web/vite.config.js b/web/vite.config.js
index cc82e63..320758a 100644
--- a/web/vite.config.js
+++ b/web/vite.config.js
@@ -8,5 +8,13 @@ export default defineConfig({
},
test: {
environment: 'jsdom',
+ pool: 'threads',
+ maxWorkers: 1,
+ isolate: false, // ponytail: one worker avoids 90s startup stalls; restore isolation if tests leak state.
+ coverage: {
+ provider: 'v8',
+ reporter: ['text'],
+ thresholds: { lines: 65 },
+ },
},
})