import { readFile } from 'node:fs/promises'; const base = process.env.EP_API_URL || 'http://127.0.0.1:8090'; const apiBase = `${base}/api/v1`; function assert(condition, message) { if (!condition) throw new Error(message); } async function request(path, options = {}, expectedStatus = 200) { const response = await fetch(apiBase + path, options); const text = await response.text(); let body; try { body = JSON.parse(text); } catch { throw new Error(`${path} returned non-JSON (${response.status}): ${text.slice(0, 160)}`); } if (response.status !== expectedStatus || (expectedStatus >= 200 && expectedStatus < 300 && body.code !== 0)) { throw new Error(`${path} failed: HTTP ${response.status}, code ${body.code}, ${body.message || ''}`); } return body; } function json(body, token) { return { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body), }; } async function waitFor(predicate, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const value = await predicate(); if (value) return value; await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error('timed out waiting for condition'); } async function openBrowserWS(token) { assert(typeof WebSocket === 'function', 'Node WebSocket is unavailable'); const socket = new WebSocket(`${base.replace(/^http/, 'ws')}/ws/browser?token=${encodeURIComponent(token)}`); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Browser WS open timeout')), 3000); socket.addEventListener('open', () => { clearTimeout(timer); resolve(); }); socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('Browser WS open failed')); }); }); return socket; } async function main() { const health = await fetch(`${base}/health`); assert(health.ok, `health failed: HTTP ${health.status}`); const agentRoute = await fetch(`${apiBase}/agent/devices`); assert(agentRoute.status === 404, `mock mode must hide Agent routes, got ${agentRoute.status}`); const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; const email = `web-smoke-${suffix}@everypublish.local`; const password = 'WebSmoke-pass-2026'; const registered = await request('/auth/register', json({ email, password, nickname: 'Web Smoke' })); const token = registered.data.accessToken; assert(token, 'registration did not return access token'); const loggedIn = await request('/auth/login', json({ email, password })); assert(loggedIn.data.accessToken && loggedIn.data.refreshToken, 'login did not return token pair'); const refreshed = await request('/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: loggedIn.data.refreshToken }), }); assert(refreshed.data.accessToken && refreshed.data.refreshToken, 'refresh did not rotate token pair'); const me = await request('/auth/me', { headers: { Authorization: `Bearer ${token}` } }); assert(me.data.workspaceId && me.data.memberRole === 'owner', 'auth/me workspace role missing'); const runtime = await request('/runtime/status', { headers: { Authorization: `Bearer ${token}` } }); assert(runtime.data.executorMode && Array.isArray(runtime.data.adapters) && runtime.data.db && runtime.data.redis, 'runtime status incomplete'); const workspaces = await request('/workspaces', { headers: { Authorization: `Bearer ${token}` } }); assert(workspaces.data.list?.length === 1, 'default workspace missing'); const workspaceId = workspaces.data.list[0].id; const originalWorkspaceName = workspaces.data.list[0].name; await request(`/workspaces/${workspaceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: `${originalWorkspaceName} smoke` }) }); await request(`/workspaces/${workspaceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: originalWorkspaceName }) }); const memberEmail = `web-smoke-member-${suffix}@everypublish.local`; const memberRegistered = await request('/auth/register', json({ email: memberEmail, password: 'MemberSmoke-pass-2026', nickname: 'Member Smoke' })); const memberToken = memberRegistered.data.accessToken; const invitation = await request('/members/invite', json({ email: memberEmail, role: 'viewer' }, token)); await request('/members/join', json({ token: invitation.data.token }, memberToken)); const memberWorkspace = await request('/workspaces', { headers: { Authorization: `Bearer ${memberToken}` } }); await request(`/auth/switch-workspace/${workspaceId}`, json({}, memberToken)); const memberSwitched = await request(`/auth/switch-workspace/${workspaceId}`, json({}, memberToken)); await request('/accounts', json({ platform: 'douyin', remark: 'viewer forbidden' }, memberSwitched.data.accessToken), 403); const memberRefreshed = await request('/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: memberSwitched.data.refreshToken }) }); const memberMe = await request('/auth/me', { headers: { Authorization: `Bearer ${memberRefreshed.data.accessToken}` } }); assert(memberMe.data.workspaceId === workspaceId, 'refresh lost selected workspace'); const memberRows = await request('/members', { headers: { Authorization: `Bearer ${token}` } }); const invitedMember = memberRows.data.list.find((item) => item.email === memberEmail); assert(invitedMember?.role === 'viewer', 'member invitation/join failed'); await request(`/members/${invitedMember.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); await request('/tasks', json({ title: 'unauthorized smoke', accountIds: [1], materialIds: [1] }), 401); const socket = await openBrowserWS(token); const events = []; socket.addEventListener('message', (event) => { try { events.push(JSON.parse(event.data)); } catch { // Ignore malformed event and let the final assertion fail if needed. } }); const account = await request('/accounts', json({ platform: 'douyin', remark: 'web smoke' }, token)); const accountId = account.data.id; const bind = await request(`/accounts/${accountId}/bind`, { ...json({}, token) }); const challengeId = bind.data.challengeId; const bindAgain = await request(`/accounts/${accountId}/bind`, { ...json({}, token) }); assert(bindAgain.data.challengeId === challengeId, 'repeated bind created duplicate active challenge'); const challengeList = await request('/challenges', { headers: { Authorization: `Bearer ${token}` } }); const challenge = challengeList.data.list.find((item) => item.id === challengeId); assert(challenge?.kind === 'qr' && challenge.qrUrl?.startsWith('mock://'), 'mock QR challenge was not created'); await request(`/accounts/${accountId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ remark: 'web smoke updated' }) }); await request(`/accounts/${accountId}/credentials`, json({ cookies: 'SESSDATA=mock; bili_jct=mock; DedeUserID=1' }, token), 409); await request(`/accounts/${accountId}/check`, { ...json({}, token) }, 409); await request(`/challenges/${challengeId}/solve`, json({}, token), 400); await request(`/challenges/${challengeId}/solve`, json({ value: 'web-smoke-login' }, token)); const activeAccounts = await request('/accounts?status=active', { headers: { Authorization: `Bearer ${token}` } }); assert(activeAccounts.data.list.some((item) => item.id === accountId), 'account did not become active'); const secondAccount = await request('/accounts', json({ platform: 'douyin', remark: 'challenge lifecycle' }, token)); const secondAccountId = secondAccount.data.id; const secondBind = await request(`/accounts/${secondAccountId}/bind`, { ...json({}, token) }); await request(`/challenges/${secondBind.data.challengeId}/suspend`, json({}, token)); let secondChallenge = (await request('/challenges', { headers: { Authorization: `Bearer ${token}` } })).data.list.find((item) => item.id === secondBind.data.challengeId); assert(secondChallenge?.status === 'suspended', 'challenge suspend did not persist'); const resent = await request(`/challenges/${secondBind.data.challengeId}/resend`, json({}, token)); assert(resent.data.qrUrl?.startsWith('mock://'), 'mock challenge resend did not return QR'); await request(`/challenges/${secondBind.data.challengeId}/solve`, json({ value: 'web-smoke-resend' }, token)); const bytes = await readFile(new URL('../references/social-auto-upload/videos/demo.mp4', import.meta.url)); const form = new FormData(); form.append('file', new Blob([bytes], { type: 'video/mp4' }), 'demo.mp4'); const material = await request('/materials', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form }); const materialId = material.data.material.id; assert(material.data.material.kind === 'video', 'mp4 upload was not classified as video'); const materialURL = await request(`/materials/${materialId}/url`, { headers: { Authorization: `Bearer ${token}` } }); const firstDownload = await fetch(materialURL.data.url); assert(firstDownload.ok, `signed material URL failed: HTTP ${firstDownload.status}`); await firstDownload.arrayBuffer(); const secondDownload = await fetch(materialURL.data.url); assert(secondDownload.status === 404 || secondDownload.status === 410, `signed URL was reusable: HTTP ${secondDownload.status}`); const previewURL = await request(`/materials/${materialId}/url`, { headers: { Authorization: `Bearer ${token}` } }); const previewDownload = await fetch(previewURL.data.url + '?inline=1'); assert(previewDownload.ok && (previewDownload.headers.get('content-type') || '').includes('video'), 'inline material preview failed'); await previewDownload.arrayBuffer(); const materialList = await request('/materials?kind=video&size=100', { headers: { Authorization: `Bearer ${token}` } }); assert(materialList.data.list.some((item) => item.id === materialId), 'material kind filter missed upload'); const task = await request('/tasks', json({ title: 'Web smoke task', content: 'Web-only smoke test', tags: ['smoke'], accountIds: [accountId], materialIds: [materialId], }, token)); await request('/tasks', json({ title: 'Web smoke invalid schedule', content: 'invalid', accountIds: [accountId], materialIds: [materialId], scheduleAt: Date.now() - 1000 }, token), 400); const taskId = task.data.id; await request(`/tasks/${taskId}/submit`, { ...json({}, token) }); await request(`/tasks/${taskId}/approve`, { ...json({}, token) }); // The first local worker tick can be delayed by a cold MySQL/Go process. // Give the mock executor enough room without changing its state semantics. const finalTask = await waitFor(async () => { const result = await request(`/tasks/${taskId}`, { headers: { Authorization: `Bearer ${token}` } }); return result.data.status === 'success' ? result.data : null; }, 10000); assert(finalTask.publishedUrls.includes('mock://'), 'mock result URL missing'); assert(finalTask.receipts.includes('mock executor completed'), 'mock receipt missing'); const timeline = await request(`/tasks/${taskId}/events`, { headers: { Authorization: `Bearer ${token}` } }); const statuses = timeline.data.list.map((event) => event.status); assert(statuses.join('>') === 'draft>pending_review>queued>dispatched>running>success', `unexpected task timeline: ${statuses.join('>')}`); await waitFor(() => events.some((event) => event.type === 'task.status' || event.type === 'challenge.status'), 3000); const rejectedTask = await request('/tasks', json({ title: 'Web smoke reject', content: 'reject path', accountIds: [accountId], materialIds: [materialId] }, token)); await request(`/tasks/${rejectedTask.data.id}/submit`, { ...json({}, token) }); await request(`/tasks/${rejectedTask.data.id}/reject`, json({ note: '需要补充说明' }, token)); await request(`/tasks/${rejectedTask.data.id}/resubmit`, json({}, token)); await request(`/tasks/${rejectedTask.data.id}/cancel`, json({}, token)); const rejectedFinal = await request(`/tasks/${rejectedTask.data.id}`, { headers: { Authorization: `Bearer ${token}` } }); assert(rejectedFinal.data.status === 'cancelled', 'reject/resubmit/cancel flow failed'); const draftTask = await request('/tasks', json({ title: 'Web smoke delete', content: 'delete path', accountIds: [accountId], materialIds: [materialId] }, token)); await request(`/tasks/${draftTask.data.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); const scheduledTask = await request('/tasks', json({ title: 'Web smoke schedule', content: 'schedule path', accountIds: [accountId], materialIds: [materialId], scheduleAt: Date.now() + 3600000 }, token)); await request(`/tasks/${scheduledTask.data.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ title: 'Web smoke schedule', content: 'schedule path', tags: [], accountIds: [accountId], materialIds: [materialId], scheduleAt: null }) }); const immediateTask = await request(`/tasks/${scheduledTask.data.id}`, { headers: { Authorization: `Bearer ${token}` } }); assert(!immediateTask.data.scheduleAt, 'schedule clear did not persist'); const notifications = await request('/notifications', { headers: { Authorization: `Bearer ${token}` } }); assert(Array.isArray(notifications.data.list), 'notification list missing'); await request('/notifications/read-all', json({}, token)); const audits = await request('/audit-logs?size=20', { headers: { Authorization: `Bearer ${token}` } }); assert(Array.isArray(audits.data.list), 'audit log list missing'); const nextPassword = 'WebSmoke-next-2026'; await request('/auth/password', json({ currentPassword: password, newPassword: nextPassword }, token)); const relogin = await request('/auth/login', json({ email, password: nextPassword })); assert(relogin.data.accessToken, 'changed password could not login'); await request(`/accounts/${secondAccountId}/unbind`, json({}, token)); await request(`/accounts/${secondAccountId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); await request(`/materials/${materialId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); socket.close(); console.log(JSON.stringify({ ok: true, accountId, materialId, taskId, timeline: statuses, browserEvents: events.length, coverage: ['auth', 'workspace', 'members', 'runtime', 'permissions', 'account', 'challenge-resend', 'materials', 'task-success', 'task-reject-resubmit-cancel', 'task-schedule-edit', 'notifications', 'audit'] })); } main().catch((error) => { console.error(`web-smoke failed: ${error.message}`); process.exitCode = 1; });