import { expect, test } from '@playwright/test' const runtime = { id: 'container-a', alias: 'account-a', name: 'account-a', state: 'running', status: 'Up', image_version: '148.0.0.1', fingerprint: { seed: 1000 }, profile: 'creatorhub-profile-account-a', endpoint: 'http://account-a:9222', } // 应用带登录门:直接预置 Basic 凭证,走与真实登录等价的 localStorage 路径 const credential = 'user:pass' test.beforeEach(async ({ page }) => { await page.addInitScript(credential => localStorage.setItem('creatorhub.auth', credential), credential) }) 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: [] })) 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: 900, height: 800 }) await page.goto('/#/browsers') await expect(page.getByRole('button', { name: '收起导航' }).first()).toBeVisible() const createButton = page.getByRole('button', { name: '创建环境' }) await expect(createButton).toBeVisible() expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(900) const box = await createButton.boundingBox() expect(box).not.toBeNull() expect(box.x + box.width).toBeLessThanOrEqual(900) }) test('shows the CDP endpoint only in the active branch at 900px and 599px', async ({ page }) => { await page.route('**/api/browsers', route => route.fulfill({ json: [runtime] })) 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: [] })) const endpoint = page.getByText('http://account-a:9222') await page.setViewportSize({ width: 900, height: 800 }) await page.goto('/#/browsers') await expect(page.getByRole('cell', { name: 'http://account-a:9222' })).toBeVisible() await expect(endpoint.filter({ visible: true })).toHaveCount(1) await page.setViewportSize({ width: 599, height: 800 }) await expect(page.getByRole('cell', { name: 'http://account-a:9222' })).toBeHidden() await expect(endpoint.filter({ visible: true })).toHaveCount(1) }) test('enforces browser readiness before starting at 900px', async ({ page }) => { const stopped = { ...runtime, state: 'exited', status: 'Exited', endpoint: '' } const environments = [ { ...stopped, id: 'ready', alias: 'ready', name: '就绪环境', schedule_status: 'ready' }, { ...stopped, id: 'missing', alias: 'missing', name: '实例缺失环境', schedule_status: 'blocked', schedule_block_reason: 'runtime_missing' }, { ...stopped, id: 'revoked', alias: 'revoked', name: '已撤权环境', schedule_status: 'blocked', schedule_block_reason: 'account_revoked' }, ] await page.route('**/api/browsers', route => route.fulfill({ json: environments })) 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.route('**/api/browsers/*/start', route => route.fulfill({ status: 204 })) await page.setViewportSize({ width: 900, height: 900 }) await page.goto('/#/browsers') const ready = page.getByRole('button', { name: '启动 就绪环境' }) const missing = page.getByRole('button', { name: '启动 实例缺失环境' }) await expect(ready).toBeEnabled() await expect(missing).toBeEnabled() await expect(page.getByRole('button', { name: '启动 已撤权环境(授权已撤销)' })).toBeDisabled() for (const [button, alias] of [[ready, 'ready'], [missing, 'missing']]) { const request = page.waitForRequest(`**/api/browsers/${alias}/start`) await button.click() expect((await request).method()).toBe('POST') } }) test('submits the minimal social-account form at 900px', async ({ page }) => { await page.route('**/api/phase-a/accounts', async route => { if (route.request().method() === 'POST') return route.fulfill({ json: { id: 'account-new' } }) return route.fulfill({ json: [] }) }) await page.route('**/api/browsers', route => route.fulfill({ json: [] })) await page.setViewportSize({ width: 900, height: 900 }) await page.goto('/#/accounts') // 简单创建走弹窗:先打开 modal 再填写 await page.getByRole('button', { name: '创建账号' }).click() const dialog = page.getByRole('dialog') await dialog.getByRole('textbox', { name: '账号名称', exact: true }).fill('店铺一号') await dialog.getByRole('combobox', { name: '平台类型', exact: true }).click() await page.getByRole('option', { name: '抖音', exact: true }).click() await dialog.getByRole('textbox', { name: '账号 ID', exact: true }).fill('shop-new') await dialog.getByRole('textbox', { name: 'TAGS', exact: true }).fill('主账号,直播') await dialog.getByRole('textbox', { name: 'Cookies', exact: true }).fill('sessionid=value; token=second') const request = page.waitForRequest(request => request.url().endsWith('/api/phase-a/accounts') && request.method() === 'POST') await dialog.getByRole('button', { name: '创建账号' }).click() expect((await request).postDataJSON()).toEqual({ name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-new', tags: ['主账号', '直播'], cookies: 'sessionid=value; token=second' }) }) test('keeps account and network-exit pages inside 599px, 900px and 1280px', async ({ page }) => { const accountKey = 'a'.repeat(128) const credentialID = 'c'.repeat(128) const hostname = [63, 63, 63, 61].map(length => 'h'.repeat(length)).join('.') const account = { id: 'account-a', name: '店铺一号', platform: 'douyin', platform_account_key: accountKey, tags: ['主账号'], authorization_status: 'authorized', runtime_status: 'paused', version: 1 } const binding = { alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 1, schedule_status: 'blocked', schedule_block_reason: 'account_paused' } const networkExit = { id: 'exit-a', protocol: 'socks5', host: hostname, port: 1080, health_status: 'healthy', credential_reference: { id: credentialID, provider: 'os_keyring' } } await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] })) await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account })) await page.route('**/api/phase-a/drafts?account_id=account-a', route => route.fulfill({ json: [] })) await page.route('**/api/browsers', route => route.fulfill({ json: [binding] })) await page.route('**/api/network-exits', route => route.fulfill({ json: [networkExit] })) for (const path of ['/#/accounts', '/#/accounts/account-a', '/#/network-exits']) { for (const width of [599, 900, 1280]) { await page.setViewportSize({ width, height: 900 }) await page.goto(path) await expect(page.getByRole('heading', { level: 1 })).toBeVisible() expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width) } } }) test('opens account detail at the phase A route', async ({ page }) => { const account = { id: 'account-a', name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-a', tags: [], authorization_status: 'authorized', runtime_status: 'paused', version: 1 } await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] })) await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account })) await page.route('**/api/browsers', route => route.fulfill({ json: [] })) await page.route('**/api/phase-a/drafts?account_id=account-a', route => route.fulfill({ json: [] })) await page.setViewportSize({ width: 599, height: 900 }) await page.goto('/#/accounts') await page.getByRole('link', { name: '查看账号' }).click() await expect(page).toHaveURL(/#\/accounts\/account-a$/) await expect(page.getByRole('heading', { level: 1, name: '店铺一号' })).toBeVisible() expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(599) }) test('keeps draft review responsive and restores focus after dialog close and successful save', async ({ page }) => { const account = { id: 'account-a', name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-a', tags: [], authorization_status: 'authorized', runtime_status: 'active', version: 2 } const draft = { id: 'draft-a', account_id: 'account-a', version: 1, content: 'x'.repeat(1000), account, versions: [{ id: 'draft-a', account_id: 'account-a', version: 1, content: 'x'.repeat(1000) }], confirmations: [], tasks: [], } const binding = { alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 4, schedule_status: 'ready', schedule_block_reason: '' } await page.route('**/api/phase-a/drafts/draft-a', route => route.fulfill({ json: draft })) await page.route('**/api/browsers', route => route.fulfill({ json: [binding] })) await page.route('**/api/phase-a/confirmations', route => route.fulfill({ json: { id: 'confirmation-a' } })) for (const width of [599, 900, 1280]) { await page.setViewportSize({ width, height: 900 }) await page.goto('/#/drafts/draft-a') await expect(page.getByRole('heading', { level: 1, name: '草稿核对' })).toBeVisible() expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width) } await page.setViewportSize({ width: 599, height: 900 }) await page.getByRole('checkbox', { name: /我已核对当前账号/ }).check() const trigger = page.getByRole('button', { name: '确认当前快照' }) await trigger.click() await expect(page.getByRole('dialog', { name: '确认草稿版本' })).toBeVisible() await page.keyboard.press('Escape') await expect(page.getByRole('dialog')).toBeHidden() await expect(trigger).toBeFocused() await trigger.click() await page.getByRole('button', { name: '保存确认' }).click() await expect(page.getByRole('dialog')).toBeHidden() await expect(trigger).toBeFocused() }) test('keeps task recovery and audit traceable without blind retry at 599px, 900px and 1280px', async ({ page }) => { let verified = false let resumed = false let auditPage = 0 const baseTask = { id: 'task-a', account_id: 'account-a', draft_id: 'draft-a', confirmation_id: 'confirmation-a', state: 'needs_confirmation', hold_reason: 'task_result_uncertain', browser_env_alias: 'env-a', network_exit_id: 'exit-a', runtime_instance_id: 'runtime-a', binding_version: 4, confirmation: { id: 'confirmation-a', version: 1, account_version: 2, draft_version: 1, browser_env_alias: 'env-a', network_exit_id: 'exit-a', runtime_instance_id: 'runtime-a', binding_version: 4 }, attempts: [{ id: 'attempt-a', started_at: '2026-08-30T00:00:00Z', outcome: 'uncertain', evidence: { mock_outcome: 'uncertain' } }], } const listTask = { ...baseTask, confirmation: undefined, attempts: undefined, allowed_action: undefined, updated_at: '2026-08-30T00:01:00Z' } await page.route(/\/api\/phase-a\/tasks(?:\/.*)?(?:\?.*)?$/, async route => { const request = route.request() const url = new URL(request.url()) if (request.method() === 'POST' && url.pathname.endsWith('/verify')) { expect(request.postDataJSON()).toEqual({ result: 'not_executed' }) verified = true return route.fulfill({ status: 204 }) } if (request.method() === 'POST' && url.pathname.endsWith('/resume')) { resumed = true return route.fulfill({ status: 204 }) } if (url.pathname.endsWith('/task-a')) return route.fulfill({ json: { ...baseTask, state: resumed ? 'queued' : baseTask.state, allowed_action: resumed ? '' : verified ? 'resume' : 'verify', verification_result: verified ? 'not_executed' : undefined } }) return route.fulfill({ json: [listTask] }) }) await page.route('**/api/phase-a/attempts/attempt-a', route => route.fulfill({ json: { ...baseTask.attempts[0], task_id: 'task-a', browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4 } })) await page.route('**/api/browsers/env-a', route => route.fulfill({ json: { alias: 'env-a', name: '店铺环境', account_id: 'account-a', image_version: '148', binding_version: 4, runtime_instance_id: 'runtime-a', network_exit: { id: 'exit-a', health_status: 'healthy' } } })) await page.route('**/api/network-exits/exit-a', route => route.fulfill({ json: { id: 'exit-a', protocol: 'socks5', host: 'proxy.example', port: 1080, health_status: 'healthy', version: 2 } })) await page.route('**/api/phase-a/audit*', route => { const currentPage = Number(new URL(route.request().url()).searchParams.get('page') || 1) auditPage = Math.max(auditPage, currentPage) return route.fulfill({ json: { data: [{ id: 7, event_type: 'task_verified', account_id: 'account-a', task_id: 'task-a', attempt_id: 'attempt-a', browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4, reason_code: 'manual_verification_recorded', created_at: '2026-08-30T00:01:00Z', }], total: 26, page: currentPage, page_size: 25 } }) }) for (const path of ['/#/tasks', '/#/tasks/task-a', '/#/attempts/attempt-a', '/#/browsers/env-a', '/#/network-exits/exit-a', '/#/audit?task_id=task-a']) { for (const width of [599, 900, 1280]) { await page.setViewportSize({ width, height: 900 }) await page.goto(path) await expect(page.getByRole('heading', { level: 1 })).toBeVisible() expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width) } } verified = false await page.setViewportSize({ width: 599, height: 900 }) await page.goto('/#/tasks/task-a') await expect(page.getByRole('button', { name: /恢复排队/ })).toHaveCount(0) await page.getByRole('button', { name: '记录人工核验' }).click() await expect(page.getByRole('button', { name: '按核验结论恢复排队' })).toBeVisible() await page.getByRole('button', { name: '按核验结论恢复排队' }).click() await expect.poll(() => resumed).toBe(true) await expect(page.getByText('已排队')).toBeVisible() await page.getByRole('link', { name: '查看关联审计' }).click() await expect(page).toHaveURL(/#\/audit\?task_id=task-a$/) await expect(page.getByRole('link', { name: 'task-a' }).first()).toBeVisible() await page.getByRole('button', { name: '下一页' }).click() await expect.poll(() => auditPage).toBe(2) await page.getByRole('link', { name: 'attempt-a' }).first().click() await expect(page).toHaveURL(/#\/attempts\/attempt-a$/) await expect(page.getByRole('link', { name: '返回任务' })).toHaveAttribute('href', '#/tasks/task-a') })