import assert from 'node:assert/strict'; import http from 'node:http'; import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; import { access, mkdtemp, rm } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { _electron as electron } from 'playwright'; const require = createRequire(import.meta.url); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const distMainEntry = path.join(repoRoot, 'dist-electron', 'main', 'main.js'); const electronExecutable = require('electron'); async function ensureBuildArtifacts() { await access(distMainEntry); } async function startUpstreamServer() { const requests = []; const server = http.createServer((req, res) => { const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); requests.push({ method: req.method || 'GET', pathname: requestUrl.pathname, search: requestUrl.search, }); if (req.method === 'GET' && requestUrl.pathname === '/ingress/api/channels/targets') { const channelType = requestUrl.searchParams.get('channelType') || ''; const accountId = requestUrl.searchParams.get('accountId') || ''; res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ success: true, channelType, accountId: accountId || null, targets: [ { value: 'remote-ops-room', label: 'Remote Ops Room', description: 'Simulated upstream discovery target', kind: 'name', source: 'remote', channelType, accountId: accountId || undefined, }, ], })); return; } res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ success: false, error: 'Not found' })); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => resolve()); }); const address = server.address(); if (!address || typeof address === 'string') { throw new Error('Failed to bind upstream smoke server'); } return { baseUrl: `http://127.0.0.1:${address.port}/ingress`, requests, async close() { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); }, }; } async function attachGatewayCollector(page) { await page.evaluate(() => { const scope = window; if (scope.__smokeGatewayCollectorAttached) return; scope.__smokeGatewayCollectorAttached = true; scope.__smokeGatewayEvents = []; window.api.on('gateway:event', (event) => { scope.__smokeGatewayEvents.push({ ...event, observedAt: new Date().toISOString(), }); }); }); } async function waitForIpcBridge(page, timeout = 15000) { await page.waitForFunction( () => Boolean(window.api && typeof window.api.invoke === 'function' && typeof window.api.on === 'function'), { timeout }, ); } async function clearGatewayEvents(page) { await page.evaluate(() => { window.__smokeGatewayEvents = []; }); } async function waitForGatewayEvent(page, predicateSource, timeout = 15000) { await page.waitForFunction( (predicateBody) => { const events = Array.isArray(window.__smokeGatewayEvents) ? window.__smokeGatewayEvents : []; const predicate = new Function('event', predicateBody); return events.some((event) => { try { return Boolean(predicate(event)); } catch { return false; } }); }, predicateSource, { timeout }, ); return page.evaluate((predicateBody) => { const events = Array.isArray(window.__smokeGatewayEvents) ? window.__smokeGatewayEvents : []; const predicate = new Function('event', predicateBody); return events.find((event) => { try { return Boolean(predicate(event)); } catch { return false; } }) ?? null; }, predicateSource); } async function invokeIpc(page, channel, ...args) { return page.evaluate( async ({ channel, args }) => window.api.invoke(channel, ...args), { channel, args }, ); } async function hostApiFetch(page, pathName, init = {}) { const method = init.method || 'GET'; const headers = init.headers || {}; const body = typeof init.body === 'undefined' ? null : typeof init.body === 'string' ? init.body : JSON.stringify(init.body); const response = await invokeIpc(page, 'hostapi:fetch', { path: pathName, method, headers, body, }); if (response?.success === false || response?.ok === false) { throw new Error(response?.error || response?.text || `Host API failed for ${method} ${pathName}`); } return typeof response?.json !== 'undefined' ? response.json : typeof response?.data !== 'undefined' ? response.data : response; } async function navigateTo(page, hashPath, headingText) { await page.evaluate((nextHash) => { window.location.hash = nextHash; }, hashPath); await page.waitForURL(new RegExp(`#${hashPath.replace('/', '\\/')}`), { timeout: 15000 }); await page.getByText(headingText, { exact: false }).first().waitFor({ timeout: 15000 }); } async function main() { await ensureBuildArtifacts(); const tempHome = await mkdtemp(path.join(os.tmpdir(), 'zn-ai-agents-smoke-home-')); const tempUserDataDir = path.join(tempHome, 'user-data'); const upstreamServer = await startUpstreamServer(); const launchEnv = { ...process.env }; delete launchEnv.ELECTRON_RUN_AS_NODE; let electronApp; try { console.log(`[smoke] using temporary HOME: ${tempHome}`); console.log(`[smoke] using temporary userData: ${tempUserDataDir}`); console.log(`[smoke] upstream targets server: ${upstreamServer.baseUrl}`); console.log(`[smoke] electron executable: ${electronExecutable}`); console.log('[smoke] launching Electron...'); electronApp = await electron.launch({ executablePath: electronExecutable, args: ['.'], cwd: repoRoot, timeout: 30000, env: { ...launchEnv, HOME: tempHome, ZN_AI_USER_DATA_DIR: tempUserDataDir, ZN_AI_HOST_API_BASE_URL: upstreamServer.baseUrl, VITE_SERVICE_URL: upstreamServer.baseUrl, }, }); console.log('[smoke] Electron launched'); console.log('[smoke] waiting for first window...'); const page = await electronApp.firstWindow(); console.log('[smoke] first window acquired'); await page.waitForLoadState('domcontentloaded'); console.log('[smoke] first window DOM ready'); await page.evaluate(() => { window.sessionStorage.setItem('token', JSON.stringify('smoke-token')); window.location.hash = '/models'; window.location.reload(); }); console.log('[smoke] login bypass injected'); await page.waitForURL(/#\/models/, { timeout: 15000 }); await page.getByRole('heading', { name: 'AI Providers' }).waitFor({ timeout: 15000 }); await waitForIpcBridge(page); await attachGatewayCollector(page); const gatewayDefaultBefore = await invokeIpc(page, 'gateway:rpc', 'provider.getDefault', {}); assert.equal(gatewayDefaultBefore?.accountId ?? null, null, 'expected clean provider default state'); console.log('[smoke] models page loaded'); const providerAccountId = 'smoke-ollama-account'; const providerLabel = 'Smoke Ollama'; await clearGatewayEvents(page); await hostApiFetch(page, '/api/provider-accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: { account: { id: providerAccountId, vendorId: 'ollama', label: providerLabel, authMode: 'local', baseUrl: 'http://127.0.0.1:11434', model: 'llama3.2', enabled: true, isDefault: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, }, }); await waitForGatewayEvent( page, "return event.type === 'runtime:changed' && event.topics.includes('providers') && event.reason === 'providers:changed';", ); await clearGatewayEvents(page); await hostApiFetch(page, '/api/provider-accounts/default', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: { accountId: providerAccountId }, }); await waitForGatewayEvent( page, "return event.type === 'runtime:changed' && event.topics.includes('providers') && event.reason === 'providers:changed';", ); await page.getByText(providerLabel, { exact: false }).waitFor({ timeout: 15000 }); const gatewayDefaultAfter = await invokeIpc(page, 'gateway:rpc', 'provider.getDefault', {}); assert.equal(gatewayDefaultAfter?.accountId, providerAccountId, 'gateway default provider did not update'); console.log('[smoke] provider runtime sync and UI refresh verified'); await navigateTo(page, '/agents', 'Agents'); await clearGatewayEvents(page); await hostApiFetch(page, '/api/agents', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: { name: 'Smoke Agent' }, }); await waitForGatewayEvent( page, "return event.type === 'runtime:changed' && event.topics.includes('agents') && event.reason === 'agents:created';", ); await page.getByText('Smoke Agent', { exact: false }).first().waitFor({ timeout: 15000 }); await page.getByText('Smoke Agent', { exact: false }).first().click(); const agentsSnapshot = await hostApiFetch(page, '/api/agents'); const smokeAgent = Array.isArray(agentsSnapshot?.agents) ? agentsSnapshot.agents.find((agent) => agent.name === 'Smoke Agent') : null; assert.ok(smokeAgent?.id, 'expected Smoke Agent to exist after creation'); console.log('[smoke] agents page auto refresh verified'); await invokeIpc(page, 'set-config', 'selectedChannels', [ { id: 'douyin-account-1', channelName: '抖音门店号', channelUrl: 'https://life.douyin.com/?openConversationId=local-fallback-room&accountId=douyin-account-1', }, ]); await clearGatewayEvents(page); await hostApiFetch(page, '/api/channels/binding', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: { channelType: 'douyin', accountId: 'douyin-account-1', agentId: smokeAgent.id, }, }); await waitForGatewayEvent( page, "return event.type === 'runtime:changed' && event.topics.includes('channels') && event.topics.includes('channel-targets') && event.reason === 'channels:binding-updated';", ); await page.getByText('抖音门店号', { exact: false }).first().waitFor({ timeout: 15000 }); console.log('[smoke] agents-side binding summary refresh verified'); await navigateTo(page, '/channels', 'Channels'); await page.getByText('抖音门店号', { exact: false }).first().waitFor({ timeout: 15000 }); await page.getByText('当前归属:Smoke Agent', { exact: false }).waitFor({ timeout: 15000 }); console.log('[smoke] channels page binding refresh verified'); await navigateTo(page, '/cron', '定时任务'); const directTargetCatalog = await hostApiFetch(page, '/api/channels/targets?channelType=douyin&accountId=douyin-account-1'); console.log(`[smoke] direct target discovery: ${JSON.stringify(directTargetCatalog)}`); await page.getByRole('button', { name: '新建任务' }).click(); await page.getByText('新建定时任务').waitFor({ timeout: 15000 }); await page.getByPlaceholder('例如:晨间播报').fill('Smoke Cron Broadcast'); await page.getByPlaceholder('描述任务要执行或广播的内容').fill('Validate remote target discovery and delivery binding.'); await page.getByRole('button', { name: '执行并发送' }).click(); await page.locator('select').nth(1).selectOption('douyin'); await page.locator('select').nth(2).selectOption('douyin-account-1'); await page.getByRole('button', { name: 'Remote Ops Room' }).waitFor({ timeout: 15000 }); await page.getByRole('button', { name: 'Remote Ops Room' }).click(); await page.getByRole('button', { name: '创建任务' }).last().click(); await page.getByText('定时任务已创建。', { exact: false }).waitFor({ timeout: 15000 }); await page.getByText('Smoke Cron Broadcast', { exact: false }).waitFor({ timeout: 15000 }); await page.getByText('remote-ops-room', { exact: false }).first().waitFor({ timeout: 15000 }); const targetRequests = upstreamServer.requests.filter((entry) => entry.pathname === '/ingress/api/channels/targets'); assert.ok(targetRequests.length > 0, 'expected upstream /api/channels/targets to be called'); console.log('[smoke] cron remote target discovery verified'); console.log('[smoke] all checks passed'); } finally { await electronApp?.close().catch(() => {}); await upstreamServer.close().catch(() => {}); await rm(tempHome, { recursive: true, force: true }).catch(() => {}); } } main().catch((error) => { console.error('[smoke] failed'); console.error(error); process.exitCode = 1; });