From 5674cfe8c37653c6650200b45266ed08702dcb10 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 1 Sep 2026 17:54:02 +0800 Subject: [PATCH] fix: expose configuration menus and relax password length --- AGENTS.md | 2 - cmd/control-plane/main.go | 4 +- cmd/control-plane/main_test.go | 19 ++++ docs/deployment.md | 8 +- web/package-lock.json | 171 ++++++++++++++++++++++++++++++ web/package.json | 2 + web/src/AccountList.jsx | 6 +- web/src/BrowserImageList.test.jsx | 4 +- web/src/layout.jsx | 4 +- web/src/main.jsx | 2 +- web/tests/responsive.e2e.js | 20 ++++ web/vite.config.js | 8 ++ 12 files changed, 233 insertions(+), 17 deletions(-) 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 }, + }, }, })