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
This commit is contained in:
366
scripts/agents-runtime-smoke.mjs
Normal file
366
scripts/agents-runtime-smoke.mjs
Normal file
@@ -0,0 +1,366 @@
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user