fix: expose configuration menus and relax password length

This commit is contained in:
2026-09-01 17:54:02 +08:00
parent 2332a94a0a
commit 5674cfe8c3
12 changed files with 233 additions and 17 deletions
-2
View File
@@ -3,9 +3,7 @@
## 开发阶段原则
- 开发阶段仅关注业务功能:不实现访问限制、认证、网络隔离等安全策略,安全由用户自行把控。
- 开发阶段不做数据兼容:数据库 schema 可随时破坏性重建,不编写迁移兼容、存量回填或双写代码;网关与环境数据均可删除重来。
- 保持变更小而独立可评审,并附带覆盖该变更的最小相关检查。
- 使用下方已批准的技术栈;在栈内优先复用现有代码、标准库和平台原生能力,而非新增依赖或抽象。
- 在集成边界保持幂等性和向后兼容;文档化重试与失败行为。
+2 -2
View File
@@ -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 == "/" ||
+19
View File
@@ -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
+4 -4
View File
@@ -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` |
+171
View File
@@ -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",
+2
View File
@@ -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"
+3 -3
View File
@@ -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 (
<>
<Box component="header" sx={{ mb: 4 }}><Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}><AccountCircleOutlined color="primary" fontSize="large" /><Typography variant="h1">账号池</Typography></Stack><Typography color="text.secondary" sx={{ mt: 1 }}>管理账号授权、暂停状态与固定运行资源</Typography></Box>
<Box component="header" sx={{ mb: 4 }}><Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}><AccountCircleOutlined color="primary" fontSize="large" /><Typography variant="h1">社媒账号</Typography></Stack><Typography color="text.secondary" sx={{ mt: 1 }}>管理账号授权、暂停状态与固定运行资源</Typography></Box>
<AccountForm busy={busy} onSubmit={createAccount} />
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
{error ? <Alert severity="error" sx={{ mb: 2.5 }}>{actionError(error, '账号列表冲突')}</Alert> : null}
@@ -228,7 +228,7 @@ export function AccountDetail() {
if (error || !account) return <Alert severity="error">{error?.message || '账号不存在'}</Alert>
return (
<>
<Box component="header" sx={{ mb: 4, minWidth: 0 }}><Button component={RouterLink} to="/accounts" sx={{ mb: 1 }}>← 返回账号池</Button><Typography variant="h1" sx={wrapAnywhere}>{account.platform_account_key}</Typography><Typography color="text.secondary" sx={{ ...wrapAnywhere, mt: 1 }}>{account.id} · {account.platform}</Typography></Box>
<Box component="header" sx={{ mb: 4, minWidth: 0 }}><Button component={RouterLink} to="/accounts" sx={{ mb: 1 }}>← 返回社媒账号</Button><Typography variant="h1" sx={wrapAnywhere}>{account.platform_account_key}</Typography><Typography color="text.secondary" sx={{ ...wrapAnywhere, mt: 1 }}>{account.id} · {account.platform}</Typography></Box>
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>{environmentError(browsersError)}</Alert> : null}
<Alert severity="warning" icon={<PauseCircleOutlined />} sx={{ mb: 2.5 }}>暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有 hold。</Alert>
+1 -3
View File
@@ -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(<CoreAdminContext dataProvider={dataProvider}><BrowserImageList /></CoreAdminContext>)
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('该版本正被环境引用')
})
+2 -2
View File
@@ -37,9 +37,9 @@ export function CreatorHubMenu({ collapsed, onNavigate }) {
</Box>
<Box component="nav" aria-label="主导航" sx={{ px: 1.5, pt: 2 }}>
{collapsed ? null : <Typography variant="overline" sx={{ display: 'block', px: 1.25, mb: 0.75, color: '#7f94b2', fontWeight: 700 }}>运营</Typography>}
<ListItemButton component={NavLink} to="/accounts" aria-label="账号池" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
<ListItemButton component={NavLink} to="/accounts" aria-label="社媒账号" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><AccountCircleOutlined /></ListItemIcon>
{collapsed ? null : <ListItemText primary="账号池" />}
{collapsed ? null : <ListItemText primary="社媒账号" />}
</ListItemButton>
<ListItemButton component={NavLink} to="/tasks" aria-label="任务中心" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><AssignmentTurnedInOutlined /></ListItemIcon>
+1 -1
View File
@@ -23,7 +23,7 @@ createRoot(document.getElementById('root')).render(
<ThemeProvider theme={theme}>
<CssBaseline />
<CoreAdmin dataProvider={dataProvider} layout={CreatorHubLayout} title="CreatorHub" disableTelemetry>
<Resource name="accounts" list={AccountList} options={{ label: '账号池' }} />
<Resource name="accounts" list={AccountList} options={{ label: '社媒账号' }} />
<Resource name="tasks" list={TaskList} options={{ label: '任务中心' }} />
<Resource name="audit" list={AuditList} options={{ label: '审计记录' }} />
<Resource name="network-exits" list={NetworkExitList} options={{ label: '网络出口' }} />
+20
View File
@@ -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: [] }))
+8
View File
@@ -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 },
},
},
})