chore: stabilize Zhinian pilot delivery

This commit is contained in:
inman
2026-05-12 19:44:44 +08:00
parent 45389855e1
commit 20b5aff4ad
174 changed files with 41428 additions and 784 deletions

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

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

View File

@@ -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 () => {

View File

@@ -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'

View File

@@ -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('帮我总结一下这份资料');
});
});

View File

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

View File

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

View File

@@ -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();

View File

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