chore: stabilize Zhinian pilot delivery
This commit is contained in:
144
tests/e2e/yinian-delivery-smoke.spec.ts
Normal file
144
tests/e2e/yinian-delivery-smoke.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
getStableWindow,
|
||||
installIpcMocks,
|
||||
test,
|
||||
} from './fixtures/electron';
|
||||
|
||||
const repoRoot = resolve(process.cwd());
|
||||
const rendererEntry = pathToFileURL(join(repoRoot, 'dist/index.html')).toString();
|
||||
|
||||
function hostJson(json: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hostKey(path: string, method = 'GET') {
|
||||
return JSON.stringify([path, method]);
|
||||
}
|
||||
|
||||
test.describe('Zhinian delivery smoke', () => {
|
||||
test('covers setup, login, chat, quick tasks, history, workspace pages, and NianxxPlay shell', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp();
|
||||
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: {
|
||||
state: 'running',
|
||||
port: 18789,
|
||||
pid: 12345,
|
||||
},
|
||||
hostApi: {
|
||||
[hostKey('/api/apps/nianxx-play/status')]: hostJson({
|
||||
success: true,
|
||||
running: true,
|
||||
starting: false,
|
||||
managed: true,
|
||||
baseUrl: 'http://127.0.0.1:3000',
|
||||
port: 3000,
|
||||
}),
|
||||
[hostKey('/api/apps/nianxx-play/start', 'POST')]: hostJson({
|
||||
success: true,
|
||||
running: true,
|
||||
starting: false,
|
||||
managed: true,
|
||||
baseUrl: 'http://127.0.0.1:3000',
|
||||
port: 3000,
|
||||
}),
|
||||
[hostKey('/api/channels/accounts')]: hostJson({ success: true, channels: [] }),
|
||||
[hostKey('/api/cron/jobs')]: hostJson({ jobs: [] }),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
|
||||
await expect(page.getByTestId('setup-page')).toBeVisible();
|
||||
await page.getByTestId('setup-skip-button').click();
|
||||
await expect(page.getByTestId('yinian-login-page')).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.setItem('yinian:quick-tasks', JSON.stringify({
|
||||
state: {
|
||||
tasks: [
|
||||
{
|
||||
id: 'qt-docx',
|
||||
name: 'word',
|
||||
description: '调用文档能力生成内容',
|
||||
skills: [{ id: 'docx', name: 'docx' }],
|
||||
enabled: true,
|
||||
showInComposer: true,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'qt-design',
|
||||
name: '设计',
|
||||
description: '调用设计能力优化表达',
|
||||
skills: [{ id: 'design', name: 'design' }],
|
||||
enabled: true,
|
||||
showInComposer: true,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
version: 3,
|
||||
}));
|
||||
});
|
||||
|
||||
await page.goto(`${rendererEntry}#/login`);
|
||||
await expect(page.getByTestId('yinian-login-page')).toBeVisible();
|
||||
await page.locator('#account').fill('admin');
|
||||
await page.locator('#password').fill('123456');
|
||||
const captchaInput = page.locator('#captcha-code');
|
||||
if (await captchaInput.count()) {
|
||||
await captchaInput.fill('5678');
|
||||
}
|
||||
await page.locator('form button[type="submit"]').click();
|
||||
await expect(page.getByTestId('today-page')).toBeVisible();
|
||||
|
||||
await page.getByTestId('sidebar-new-chat').click();
|
||||
await expect(page.getByTestId('chat-composer-input')).toBeVisible();
|
||||
await expect(page.getByTestId('chat-quick-task-qt-docx')).toBeVisible();
|
||||
await expect(page.getByTestId('chat-quick-task-qt-docx')).toBeEnabled({ timeout: 60_000 });
|
||||
await page.getByTestId('chat-quick-task-qt-docx').click();
|
||||
await expect(page.getByText('使用docx skill')).toBeVisible();
|
||||
await page.getByTestId('chat-quick-task-qt-design').click();
|
||||
await expect(page.getByText('使用design skill')).toBeVisible();
|
||||
await expect(page.getByText('使用docx skill')).toHaveCount(0);
|
||||
|
||||
await page.getByTestId('sidebar-chat-history').click();
|
||||
await expect(page.getByTestId('sidebar-chat-history-panel')).toBeVisible();
|
||||
|
||||
await page.goto(`${rendererEntry}#/knowledge`);
|
||||
await expect(page.getByTestId('knowledge-page')).toBeVisible();
|
||||
|
||||
await page.goto(`${rendererEntry}#/cron`);
|
||||
await expect(page.getByTestId('cron-page')).toBeVisible();
|
||||
|
||||
await page.goto(`${rendererEntry}#/settings/skills`);
|
||||
await expect(page.getByTestId('settings-page')).toBeVisible();
|
||||
await expect(page.getByTestId('yinian-skills-page')).toBeVisible();
|
||||
await expect(page.getByTestId('yinian-skills-tab-quickTasks')).toBeVisible();
|
||||
|
||||
await page.getByTestId('sidebar-nav-app-center').click();
|
||||
await expect(page.getByTestId('app-center-page')).toBeVisible();
|
||||
await expect(page.getByTestId('app-center-item-nianxx-play')).toBeVisible();
|
||||
await page.getByTestId('app-center-item-nianxx-play').click();
|
||||
await expect(page.getByTestId('nianxx-play-page')).toBeVisible();
|
||||
await page.getByTestId('nianxx-play-nav-planning').click();
|
||||
await expect(page.getByTestId('nianxx-play-page')).toBeVisible();
|
||||
await page.getByTestId('nianxx-play-back').click();
|
||||
await expect(page.getByTestId('app-center-page')).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
50
tests/unit/assistant-output-sanitizer.test.ts
Normal file
50
tests/unit/assistant-output-sanitizer.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
sanitizeAssistantMessage,
|
||||
sanitizeAssistantMessageLike,
|
||||
sanitizeAssistantText,
|
||||
} from '@/stores/chat/assistant-output-sanitizer';
|
||||
import type { RawMessage } from '@/stores/chat/types';
|
||||
|
||||
describe('assistant output sanitizer', () => {
|
||||
it('removes external office-suite guidance while preserving generated file details', () => {
|
||||
const result = sanitizeAssistantText([
|
||||
'转换完成!PDF 已生成:',
|
||||
'',
|
||||
'/Users/inmanx/.openclaw/media/outbound/result.pdf',
|
||||
'',
|
||||
'共 15 页,文件大小 252KB。',
|
||||
'',
|
||||
'需要说明的是:由于系统没有安装 LibreOffice,我用的是 Python 库(python-pptx + reportlab)进行的转换,只能还原文字内容,原始 PPT 的视觉布局无法完美保留。',
|
||||
'如果需要精确还原设计,建议用 LibreOffice 打开原 PPTX 文件然后导出 PDF。',
|
||||
].join('\n'));
|
||||
|
||||
expect(result).toContain('转换完成!PDF 已生成');
|
||||
expect(result).toContain('/Users/inmanx/.openclaw/media/outbound/result.pdf');
|
||||
expect(result).toContain('共 15 页');
|
||||
expect(result).toContain('内置转换能力');
|
||||
expect(result).not.toMatch(/LibreOffice|libreoffice|python-pptx|reportlab/i);
|
||||
});
|
||||
|
||||
it('sanitizes assistant text blocks without touching non-text blocks', () => {
|
||||
const message: RawMessage = {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'PDF 已生成。\n\n建议安装 soffice 后重试。' },
|
||||
{ type: 'tool_use', id: 'tool-1', name: 'write', input: { path: '/tmp/a.pdf' } },
|
||||
],
|
||||
};
|
||||
|
||||
const sanitized = sanitizeAssistantMessage(message);
|
||||
expect(sanitized).not.toBe(message);
|
||||
expect(sanitized.content).toEqual([
|
||||
{ type: 'text', text: 'PDF 已生成。\n\n说明:已使用内置转换能力完成处理;如果需要更高保真的版式还原,请联系管理员配置企业转换服务。' },
|
||||
{ type: 'tool_use', id: 'tool-1', name: 'write', input: { path: '/tmp/a.pdf' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not invent an assistant role for empty streaming control events', () => {
|
||||
const controlEvent = {};
|
||||
expect(sanitizeAssistantMessageLike(controlEvent)).toBe(controlEvent);
|
||||
});
|
||||
});
|
||||
@@ -147,16 +147,20 @@ describe('WeCom plugin configuration', () => {
|
||||
await rm(testUserData, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('sets plugins.entries.wecom.enabled when saving wecom config', async () => {
|
||||
it('saves wecom as a built-in channel and keeps stale plugin registration disabled', async () => {
|
||||
const { saveChannelConfig } = await import('@electron/utils/channel-config');
|
||||
|
||||
await saveChannelConfig('wecom', { botId: 'test-bot', secret: 'test-secret' }, 'agent-a');
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
const plugins = config.plugins as { allow: string[], entries: Record<string, { enabled?: boolean }> };
|
||||
|
||||
expect(plugins.allow).toContain('wecom');
|
||||
expect(plugins.entries['wecom'].enabled).toBe(true);
|
||||
const channels = config.channels as Record<string, { enabled?: boolean; accounts?: Record<string, { botId?: string; enabled?: boolean }> }>;
|
||||
|
||||
expect(channels.wecom.enabled).toBe(true);
|
||||
expect(channels.wecom.accounts?.['agent-a']).toMatchObject({
|
||||
botId: 'test-bot',
|
||||
enabled: true,
|
||||
});
|
||||
expect(config.plugins).toBeUndefined();
|
||||
});
|
||||
|
||||
it('saves whatsapp as a built-in channel instead of a plugin', async () => {
|
||||
|
||||
@@ -41,6 +41,8 @@ vi.mock('@/stores/chat/helpers', () => ({
|
||||
clearHistoryPoll: (...args: unknown[]) => clearHistoryPoll(...args),
|
||||
enrichWithCachedImages: (...args: unknown[]) => enrichWithCachedImages(...args),
|
||||
enrichWithToolResultFiles: (...args: unknown[]) => enrichWithToolResultFiles(...args),
|
||||
filterInternalConversationSegments: (messages: Array<{ role?: unknown; content?: unknown }>) =>
|
||||
messages.filter((message) => !isInternalMessage(message)),
|
||||
getLatestOptimisticUserMessage: (messages: Array<{ role: string; timestamp?: number }>, userTimestampMs: number) =>
|
||||
[...messages].reverse().find(
|
||||
(message) => message.role === 'user'
|
||||
|
||||
@@ -17,4 +17,18 @@ describe('chat message display cleanup', () => {
|
||||
|
||||
expect(extractText({ role: 'user', content })).toBe('');
|
||||
});
|
||||
|
||||
it('hides injected knowledge context from user display text', () => {
|
||||
const content = [
|
||||
'帮我总结一下这份资料',
|
||||
'',
|
||||
'[知识库上下文]',
|
||||
'用户已选择在本轮对话中使用当前组织空间知识库。',
|
||||
'',
|
||||
'## 内部资料.docx',
|
||||
'这是一段不应该出现在用户气泡里的知识库正文。',
|
||||
].join('\n');
|
||||
|
||||
expect(extractText({ role: 'user', content })).toBe('帮我总结一下这份资料');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,4 +165,31 @@ describe('gateway store event wiring', () => {
|
||||
expect(chatStateMock.state.activeRunId).toBeNull();
|
||||
expect(chatStateMock.state.pendingFinal).toBe(false);
|
||||
});
|
||||
|
||||
it('does not clear sending state for terminal notifications from a different active run', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 });
|
||||
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
|
||||
handlers.set(eventName, handler);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { useGatewayStore } = await import('@/stores/gateway');
|
||||
await useGatewayStore.getState().init();
|
||||
|
||||
handlers.get('gateway:notification')?.({
|
||||
method: 'agent',
|
||||
params: {
|
||||
phase: 'completed',
|
||||
runId: 'stale-run',
|
||||
sessionKey: 'session-1',
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(chatStateMock.state.sending).toBe(true);
|
||||
expect(chatStateMock.state.activeRunId).toBe('run-1');
|
||||
expect(chatStateMock.state.pendingFinal).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,9 +96,8 @@ describe('Yinian model diagnostics', () => {
|
||||
|
||||
expect(modelDiagnosticsMocks.writeOpenClawConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
models: expect.objectContaining({
|
||||
pricing: expect.objectContaining({ enabled: false }),
|
||||
providers: expect.objectContaining({
|
||||
minimax: expect.objectContaining({ timeoutSeconds: 300 }),
|
||||
minimax: expect.not.objectContaining({ timeoutSeconds: expect.anything() }),
|
||||
}),
|
||||
}),
|
||||
agents: expect.objectContaining({
|
||||
@@ -123,7 +122,7 @@ describe('Yinian model diagnostics', () => {
|
||||
const diagnostics = await buildYinianModelConfigDiagnostics();
|
||||
expect(diagnostics.ok).toBe(true);
|
||||
expect(diagnostics.model.primary).toBe('minimax/MiniMax-M2.7');
|
||||
expect(diagnostics.runtime.pricingCatalogFetchDisabled).toBe(true);
|
||||
expect(diagnostics.runtime.heartbeatDisabled).toBe(true);
|
||||
expect(diagnostics.checks.find((check) => check.id === 'auth-profile')?.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -456,7 +456,7 @@ describe('sanitizeOpenClawConfig', () => {
|
||||
|
||||
const result = await readOpenClawJson();
|
||||
const defaults = (result.agents as Record<string, unknown>).defaults as Record<string, unknown>;
|
||||
expect(defaults.skills).toEqual([]);
|
||||
expect(defaults.skills).toEqual(['docx', 'pdf', 'pptx', 'xlsx', 'design', 'image-search']);
|
||||
expect(defaults.heartbeat).toMatchObject({ every: '0m' });
|
||||
|
||||
logSpy.mockRestore();
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('useYinianStore', () => {
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(window.yinian.auth.restoreSession).mockReset();
|
||||
vi.mocked(window.yinian.auth.getSessionState).mockReset();
|
||||
vi.mocked(window.yinian.auth.loginWithPassword).mockReset();
|
||||
vi.mocked(window.yinian.auth.logout).mockReset();
|
||||
vi.mocked(window.yinian.app.getConfig).mockReset();
|
||||
@@ -75,6 +76,34 @@ describe('useYinianStore', () => {
|
||||
expect(window.yinian.skills.getRegistry).toHaveBeenCalledWith('workspace_hangzhou_ops');
|
||||
});
|
||||
|
||||
it('falls back to session state when restoreSession handler is not registered yet', async () => {
|
||||
vi.mocked(window.yinian.auth.restoreSession).mockRejectedValueOnce(
|
||||
new Error("Error invoking remote method 'yinian:auth:restoreSession': Error: No handler registered for 'yinian:auth:restoreSession'"),
|
||||
);
|
||||
vi.mocked(window.yinian.auth.getSessionState).mockResolvedValueOnce({ authenticated: false });
|
||||
|
||||
await useYinianStore.getState().init();
|
||||
|
||||
expect(window.yinian.auth.getSessionState).toHaveBeenCalled();
|
||||
expect(useYinianStore.getState().status).toBe('unauthenticated');
|
||||
expect(useYinianStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it('opens login when yinian auth handlers are absent in a mismatched build', async () => {
|
||||
vi.mocked(window.yinian.auth.restoreSession).mockRejectedValueOnce(
|
||||
new Error("Error invoking remote method 'yinian:auth:restoreSession': Error: No handler registered for 'yinian:auth:restoreSession'"),
|
||||
);
|
||||
vi.mocked(window.yinian.auth.getSessionState).mockRejectedValueOnce(
|
||||
new Error("Error invoking remote method 'yinian:auth:getSessionState': Error: No handler registered for 'yinian:auth:getSessionState'"),
|
||||
);
|
||||
|
||||
await useYinianStore.getState().init();
|
||||
|
||||
expect(useYinianStore.getState().status).toBe('unauthenticated');
|
||||
expect(useYinianStore.getState().session.authenticated).toBe(false);
|
||||
expect(useYinianStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it('switches workspace and refreshes registry for the new hotel', async () => {
|
||||
const switched = { ...session, currentHotelId: 'workspace_shanghai_growth' };
|
||||
vi.mocked(window.yinian.app.switchHotel).mockResolvedValueOnce(switched);
|
||||
|
||||
Reference in New Issue
Block a user