Files
zn-ai/scripts/agents-runtime-smoke.mjs
duanshuwen ee72cf7261 feat: refactor HomePage to integrate agents store and update related components
feat: add runtime event handling for providers in ProvidersSection

feat: update routing to include Channels and Agents pages

feat: extend route types and navigation items for Channels and Agents

feat: implement agents store for managing agent data and interactions

fix: update chat store to utilize agents store for agent-related functionality

chore: export agents store from index

fix: enhance runtime types for better event handling

fix: update Vite config to handle dev server URL correctly
2026-04-18 14:56:32 +08:00

367 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
});