369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import type { Page } from '@playwright/test';
|
|
import type { ElectronApplication } from 'playwright-core';
|
|
import { expect, getStableWindow, test } from './fixtures/electron';
|
|
|
|
type CapturedRequest = {
|
|
path: string;
|
|
method: string;
|
|
body?: Record<string, unknown>;
|
|
};
|
|
|
|
const SESSION_A_ID = 'ses_multichat_a';
|
|
const SESSION_B_ID = 'ses_multichat_b';
|
|
const REGISTRY_PENDING_ERROR = '项目 Agent 配置正在等待运行时重新加载,请在当前回复完成后手动重启运行时再试。';
|
|
|
|
async function disableRendererEventSource(page: Page): Promise<void> {
|
|
await page.addInitScript(() => {
|
|
class DisabledEventSource extends EventTarget {
|
|
static readonly CONNECTING = 0;
|
|
static readonly OPEN = 1;
|
|
static readonly CLOSED = 2;
|
|
readonly CONNECTING = DisabledEventSource.CONNECTING;
|
|
readonly OPEN = DisabledEventSource.OPEN;
|
|
readonly CLOSED = DisabledEventSource.CLOSED;
|
|
readonly url: string;
|
|
readonly withCredentials = false;
|
|
readyState = DisabledEventSource.OPEN;
|
|
onopen: ((event: Event) => void) | null = null;
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
onerror: ((event: Event) => void) | null = null;
|
|
|
|
constructor(url: string) {
|
|
super();
|
|
this.url = url;
|
|
}
|
|
|
|
close(): void {
|
|
this.readyState = DisabledEventSource.CLOSED;
|
|
}
|
|
}
|
|
|
|
Object.defineProperty(window, 'EventSource', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: DisabledEventSource,
|
|
});
|
|
});
|
|
}
|
|
|
|
async function installMultichatHost(electronApp: ElectronApplication): Promise<void> {
|
|
await electronApp.evaluate(async () => {
|
|
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
|
|
const sessionAId = 'ses_multichat_a';
|
|
const sessionBId = 'ses_multichat_b';
|
|
const registryPendingError = '项目 Agent 配置正在等待运行时重新加载,请在当前回复完成后手动重启运行时再试。';
|
|
type MainState = {
|
|
captured: CapturedRequest[];
|
|
sessionABusy: boolean;
|
|
};
|
|
const mainGlobal = globalThis as typeof globalThis & {
|
|
__makeloreMultichatE2EState?: MainState;
|
|
};
|
|
const state: MainState = {
|
|
captured: [],
|
|
sessionABusy: false,
|
|
};
|
|
mainGlobal.__makeloreMultichatE2EState = state;
|
|
|
|
const now = '2026-08-17T10:00:00.000Z';
|
|
const project = {
|
|
id: 'prj_multichat_e2e',
|
|
path: 'D:/e2e/multichat',
|
|
name: 'multichat',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
lastOpenedAt: now,
|
|
};
|
|
const agent = {
|
|
id: 'game-development',
|
|
avatarId: 'avatar-01',
|
|
roleName: '游戏开发',
|
|
name: '游戏开发',
|
|
builtIn: false,
|
|
enabled: true,
|
|
model: 'niancode-user-models/qwen3.7-plus',
|
|
skillIds: [],
|
|
responsibility: {
|
|
mission: '实现并验证项目功能。',
|
|
owns: [],
|
|
boundaries: [],
|
|
collaborators: [],
|
|
principles: [],
|
|
},
|
|
prompt: '',
|
|
archivedAt: null,
|
|
pinned: false,
|
|
};
|
|
const config = {
|
|
schemaVersion: 1,
|
|
projectType: 'custom',
|
|
initialized: true,
|
|
defaultModel: 'niancode-user-models/qwen3.7-plus',
|
|
agents: [agent],
|
|
knowledgeDirectory: 'knowledge',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
const sessions = [
|
|
{
|
|
id: sessionAId,
|
|
title: 'Session A',
|
|
agent: agent.id,
|
|
model: { providerID: 'niancode-user-models', id: 'qwen3.7-plus' },
|
|
time: { updated: 2 },
|
|
},
|
|
{
|
|
id: sessionBId,
|
|
title: 'Session B',
|
|
agent: agent.id,
|
|
model: { providerID: 'niancode-user-models', id: 'qwen3.7-plus' },
|
|
time: { updated: 1 },
|
|
},
|
|
];
|
|
const conversationState = {
|
|
schemaVersion: 1,
|
|
sessions: [
|
|
{
|
|
sessionId: sessionAId,
|
|
agentId: agent.id,
|
|
archivedAt: null,
|
|
unreadCount: 0,
|
|
createdAt: now,
|
|
updatedAt: '2026-08-17T10:00:02.000Z',
|
|
},
|
|
{
|
|
sessionId: sessionBId,
|
|
agentId: agent.id,
|
|
archivedAt: null,
|
|
unreadCount: 0,
|
|
createdAt: now,
|
|
updatedAt: '2026-08-17T10:00:01.000Z',
|
|
},
|
|
],
|
|
updatedAt: '2026-08-17T10:00:02.000Z',
|
|
};
|
|
const runtimeStatus = {
|
|
state: 'running',
|
|
port: 4096,
|
|
url: 'http://127.0.0.1:4096',
|
|
};
|
|
const respond = (json: unknown, responseStatus = 200) => ({
|
|
ok: true,
|
|
data: {
|
|
status: responseStatus,
|
|
ok: responseStatus >= 200 && responseStatus < 300,
|
|
json,
|
|
},
|
|
});
|
|
|
|
ipcMain.removeHandler('hostapi:fetch');
|
|
ipcMain.handle('hostapi:fetch', async (
|
|
_event,
|
|
request: { path?: string; method?: string; body?: string | null },
|
|
) => {
|
|
const path = request.path ?? '';
|
|
const method = request.method ?? 'GET';
|
|
const body = request.body
|
|
? JSON.parse(request.body) as Record<string, unknown>
|
|
: undefined;
|
|
state.captured.push({ path, method, ...(body ? { body } : {}) });
|
|
|
|
if (path === '/api/opencode/status') return respond(runtimeStatus);
|
|
if (path === '/api/opencode/health') {
|
|
return respond({ ok: true, status: runtimeStatus });
|
|
}
|
|
if (path === '/api/opencode/projects' || path.startsWith('/api/opencode/projects?')) {
|
|
return respond({ projects: [project], activeProject: project });
|
|
}
|
|
if (path === '/api/opencode/projects/active' && method === 'GET') {
|
|
return respond({ projects: [project], activeProject: project });
|
|
}
|
|
if (path.startsWith('/api/opencode/projects/config?')) {
|
|
return respond({ status: 'valid', config, knowledgeFiles: [] });
|
|
}
|
|
if (path.startsWith('/api/opencode/projects/template?')) {
|
|
return respond({ status: 'missing' });
|
|
}
|
|
if (path.startsWith('/api/opencode/projects/conversations?')) {
|
|
return respond({ state: conversationState });
|
|
}
|
|
if (path === '/api/opencode/projects/conversations' && method === 'POST') {
|
|
return respond({ success: true, state: conversationState });
|
|
}
|
|
if (path === '/api/opencode/config-summary') {
|
|
return respond({
|
|
model: 'niancode-user-models/qwen3.7-plus',
|
|
smallModel: null,
|
|
providerIds: ['niancode-user-models'],
|
|
enabledProviderIds: ['niancode-user-models'],
|
|
providerCount: 1,
|
|
});
|
|
}
|
|
if (path === '/api/provider-accounts') {
|
|
return respond([{
|
|
id: 'niancode-user-models',
|
|
vendorId: 'custom',
|
|
label: 'Makelore Models',
|
|
authMode: 'api_key',
|
|
model: 'qwen3.7-plus',
|
|
fallbackModels: ['deepseek-chat'],
|
|
enabled: true,
|
|
isDefault: true,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}]);
|
|
}
|
|
if (path === '/api/provider-accounts/key-info') {
|
|
return respond([{ accountId: 'niancode-user-models', hasKey: true, keyMasked: 'sk-***' }]);
|
|
}
|
|
if (path === '/api/provider-vendors') return respond([]);
|
|
if (path === '/api/provider-accounts/default') {
|
|
return respond({ accountId: 'niancode-user-models' });
|
|
}
|
|
if (path === '/api/opencode/sessions') return respond({ sessions });
|
|
if (path === '/api/opencode/sessions/status') {
|
|
return respond({
|
|
statuses: {
|
|
[sessionAId]: { type: state.sessionABusy ? 'busy' : 'idle' },
|
|
[sessionBId]: { type: 'idle' },
|
|
},
|
|
});
|
|
}
|
|
if (
|
|
(path === `/api/opencode/sessions/${sessionAId}/messages`
|
|
|| path === `/api/opencode/sessions/${sessionBId}/messages`)
|
|
&& method === 'GET'
|
|
) {
|
|
return respond({ messages: [] });
|
|
}
|
|
if (path === `/api/opencode/sessions/${sessionAId}/messages` && method === 'POST') {
|
|
state.sessionABusy = true;
|
|
return respond({ success: true }, 202);
|
|
}
|
|
if (
|
|
(path === `/api/opencode/sessions/${sessionAId}/model`
|
|
|| path === `/api/opencode/sessions/${sessionBId}/model`)
|
|
&& method === 'POST'
|
|
) {
|
|
return respond({ success: true, model: body?.model });
|
|
}
|
|
if (path === `/api/opencode/sessions/${sessionBId}/messages` && method === 'POST') {
|
|
return respond({
|
|
success: false,
|
|
error: registryPendingError,
|
|
code: 'OPENCODE_AGENT_REGISTRY_PENDING',
|
|
promptSent: false,
|
|
terminal: true,
|
|
retryable: false,
|
|
runtimeGeneration: 1,
|
|
}, 409);
|
|
}
|
|
if (path.endsWith('/todos')) return respond({ todos: [] });
|
|
if (path.endsWith('/diff')) return respond({ diffs: [] });
|
|
if (path === '/api/opencode/questions') return respond({ questions: [] });
|
|
if (path === '/api/opencode/permissions') return respond({ permissions: [] });
|
|
if (path === '/api/opencode/files/status') return respond({ files: [] });
|
|
if (path === '/api/opencode/commands') {
|
|
return respond({ commands: [], shareEnabled: true });
|
|
}
|
|
if (path === '/api/opencode/skills') return respond({ skills: [] });
|
|
throw new Error(`Unexpected hostapi request: ${method} ${path}`);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function capturedRequests(electronApp: ElectronApplication): Promise<CapturedRequest[]> {
|
|
return await electronApp.evaluate(() => {
|
|
const mainGlobal = globalThis as typeof globalThis & {
|
|
__makeloreMultichatE2EState?: { captured: CapturedRequest[] };
|
|
};
|
|
return structuredClone(mainGlobal.__makeloreMultichatE2EState?.captured ?? []);
|
|
});
|
|
}
|
|
|
|
function sessionSelectionButton(page: Page, title: string) {
|
|
return page.getByRole('button', { name: `归档会话 ${title}`, exact: true })
|
|
.locator('..')
|
|
.getByRole('button')
|
|
.first();
|
|
}
|
|
|
|
async function submitComposer(page: Page, text: string): Promise<void> {
|
|
const composer = page.getByRole('textbox');
|
|
await composer.fill(text);
|
|
const sendButton = page.getByRole('button', { name: '发送', exact: true });
|
|
await expect(sendButton).toBeEnabled();
|
|
await sendButton.click();
|
|
}
|
|
|
|
test('keeps Session A running when Session B is terminally rejected by Agent preflight', async ({
|
|
launchElectronApp,
|
|
}) => {
|
|
const electronApp = await launchElectronApp({ skipSetup: true });
|
|
await installMultichatHost(electronApp);
|
|
|
|
let page = await getStableWindow(electronApp);
|
|
await disableRendererEventSource(page);
|
|
await page.reload();
|
|
page = await getStableWindow(electronApp);
|
|
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
|
await page.getByTestId('ai-module-option-programming').click();
|
|
await page.getByTestId('project-agent-chat-game-development').click();
|
|
|
|
const sessionA = sessionSelectionButton(page, 'Session A');
|
|
const sessionB = sessionSelectionButton(page, 'Session B');
|
|
await expect(sessionA).toBeVisible();
|
|
await expect(sessionB).toBeVisible();
|
|
|
|
await sessionA.click();
|
|
await expect(sessionA).toHaveAttribute('aria-current', 'page');
|
|
const modelSelector = page.getByTestId('opencode-model-selector-trigger');
|
|
await expect(modelSelector).toHaveValue('niancode-user-models/qwen3.7-plus');
|
|
await modelSelector.selectOption('niancode-user-models/deepseek-chat');
|
|
await expect(modelSelector).toHaveValue('niancode-user-models/deepseek-chat');
|
|
await expect.poll(async () => (await capturedRequests(electronApp)).map(
|
|
(request) => `${request.method} ${request.path}`,
|
|
)).toContain(`POST /api/opencode/sessions/${SESSION_A_ID}/model`);
|
|
await submitComposer(page, 'Keep Session A running');
|
|
await expect.poll(async () => [...new Set((await capturedRequests(electronApp))
|
|
.filter((request) => request.path.includes('/messages'))
|
|
.map((request) => `${request.method} ${request.path}`))])
|
|
.toContain(`POST /api/opencode/sessions/${SESSION_A_ID}/messages`);
|
|
const stopButton = page.getByRole('button', { name: '停止当前对话' });
|
|
await expect(stopButton).toBeEnabled();
|
|
await expect(stopButton.getByTestId('opencode-agent-response-loader')).toBeVisible();
|
|
|
|
await sessionB.click();
|
|
await submitComposer(page, 'Try Session B while A is busy');
|
|
const errorBanner = page.getByTestId('opencode-error-banner');
|
|
await expect(errorBanner).toContainText(REGISTRY_PENDING_ERROR);
|
|
await expect(page.getByRole('textbox')).toHaveValue('Try Session B while A is busy');
|
|
await expect(page.getByRole('button', { name: '停止当前对话' })).toHaveCount(0);
|
|
await expect(page.getByTestId('opencode-chat-layout')).toHaveAttribute('data-session-status', 'idle');
|
|
|
|
await expect.poll(async () => (await capturedRequests(electronApp)).map(
|
|
(request) => `${request.method} ${request.path}`,
|
|
)).toContain(`POST /api/opencode/sessions/${SESSION_B_ID}/messages`);
|
|
|
|
// A rejected preflight deliberately keeps B's draft. Clear it so A's busy
|
|
// composer projects the stop control and its response loader.
|
|
await page.getByRole('textbox').fill('');
|
|
await sessionA.click();
|
|
await expect(errorBanner).toHaveCount(0);
|
|
await expect(page.getByTestId('opencode-chat-layout')).toHaveAttribute('data-session-status', 'busy');
|
|
await expect(page.getByRole('button', { name: '停止当前对话' })).toBeEnabled();
|
|
await expect(page.getByTestId('opencode-agent-response-loader')).toBeVisible();
|
|
|
|
await sessionB.click();
|
|
await expect(errorBanner).toContainText(REGISTRY_PENDING_ERROR);
|
|
await expect(page.getByTestId('opencode-chat-layout')).toHaveAttribute('data-session-status', 'idle');
|
|
|
|
const promptPosts = (await capturedRequests(electronApp)).filter((request) => (
|
|
request.method === 'POST' && request.path.endsWith('/messages')
|
|
));
|
|
expect(promptPosts.filter((request) => request.path.includes(SESSION_A_ID))).toHaveLength(1);
|
|
expect(promptPosts.filter((request) => request.path.includes(SESSION_B_ID))).toHaveLength(1);
|
|
expect(promptPosts.find((request) => request.path.includes(SESSION_A_ID))?.body)
|
|
.not.toHaveProperty('model');
|
|
});
|