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
367 lines
13 KiB
JavaScript
367 lines
13 KiB
JavaScript
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;
|
||
});
|