feat: prepare Zhinian desktop pilot
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-05-07 21:49:20 +08:00
parent cddaf37016
commit 0abc48189c
103 changed files with 10975 additions and 2049 deletions

View File

@@ -18,6 +18,18 @@ describe('chat internal message filter', () => {
expect(isInternalMessage({ role: 'assistant', content })).toBe(true);
});
it('filters OpenClaw heartbeat poll user messages', () => {
expect(isInternalMessage({ role: 'user', content: '[OpenClaw heartbeat poll]' })).toBe(true);
expect(isInternalMessage({
role: 'user',
content: [
'Read HEARTBEAT.md if it exists (workspace context). Follow it strictly.',
'If nothing needs attention, reply HEARTBEAT_OK.',
'When reading HEARTBEAT.md, use workspace file /Users/test/.openclaw/workspace/HEARTBEAT.md.',
].join(' '),
})).toBe(true);
});
it('filters pure channel ingress metadata from Feishu', () => {
const content = 'System: [2026-04-27 11:09:29 GMT+8] Feishu[default] DM | ou_256bec6880a8c77271bc610c5e42fe89 [msg:om_x100b51d9784e2908c144d6c6cde19a6]';

View File

@@ -117,7 +117,7 @@ describe('chat session actions', () => {
expect(h.read().loadHistory).toHaveBeenCalledTimes(1);
});
it('newSession creates a canonical session key and clears transient state', async () => {
it('newSession creates a desktop canonical session key and clears transient state', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111);
const { createSessionActions } = await import('@/stores/chat/session-actions');
const h = makeHarness({
@@ -132,8 +132,8 @@ describe('chat session actions', () => {
actions.newSession();
const next = h.read();
expect(next.currentSessionKey).toBe('agent:foo:session-1711111111111');
expect(next.sessions.some((s) => s.key === 'agent:foo:session-1711111111111')).toBe(true);
expect(next.currentSessionKey).toBe('agent:main:session-1711111111111');
expect(next.sessions.some((s) => s.key === 'agent:main:session-1711111111111')).toBe(true);
expect(next.messages).toEqual([]);
expect(next.streamingText).toBe('');
expect(next.activeRunId).toBeNull();
@@ -174,4 +174,3 @@ describe('chat session actions', () => {
expect(h.read().sessions.find((session) => session.key === 'agent:main:cron:job-1')?.updatedAt).toBe(1773281731621);
});
});

View File

@@ -2,6 +2,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const hostApiFetchMock = vi.fn();
const subscribeHostEventMock = vi.fn();
const chatStateMock = vi.hoisted(() => ({
state: {
currentSessionKey: 'session-1',
sessions: [] as Array<{ key: string }>,
sending: true,
activeRunId: 'run-1' as string | null,
pendingFinal: true,
lastUserMessageAt: 123,
error: null as string | null,
loadHistory: vi.fn(),
loadSessions: vi.fn(),
handleChatEvent: vi.fn(),
},
}));
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
@@ -11,10 +25,28 @@ vi.mock('@/lib/host-events', () => ({
subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args),
}));
vi.mock('@/stores/chat', () => ({
useChatStore: {
getState: () => chatStateMock.state,
setState: (patch: Record<string, unknown>) => {
Object.assign(chatStateMock.state, patch);
},
},
}));
describe('gateway store event wiring', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
Object.assign(chatStateMock.state, {
currentSessionKey: 'session-1',
sessions: [],
sending: true,
activeRunId: 'run-1',
pendingFinal: true,
lastUserMessageAt: 123,
error: null,
});
});
it('subscribes to host events through subscribeHostEvent on init', async () => {
@@ -76,4 +108,61 @@ describe('gateway store event wiring', () => {
expect(status.gatewayReady).toBeUndefined();
expect(status.state === 'running' && status.gatewayReady !== false).toBe(true);
});
it('does not clear sending state on intermediate agent phase=end notifications', 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: 'end',
runId: 'run-1',
sessionKey: 'session-1',
},
});
await vi.waitFor(() => {
expect(chatStateMock.state.loadHistory).toHaveBeenCalled();
});
expect(chatStateMock.state.sending).toBe(true);
expect(chatStateMock.state.activeRunId).toBe('run-1');
expect(chatStateMock.state.pendingFinal).toBe(true);
});
it('clears sending state on terminal agent completion notifications', 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: 'run-1',
sessionKey: 'session-1',
},
});
await vi.waitFor(() => {
expect(chatStateMock.state.sending).toBe(false);
});
expect(chatStateMock.state.activeRunId).toBeNull();
expect(chatStateMock.state.pendingFinal).toBe(false);
});
});

View File

@@ -47,9 +47,9 @@ describe('GatewayManager heartbeat recovery', () => {
(manager as unknown as { startPing: () => void }).startPing();
vi.advanceTimersByTime(120_000);
vi.advanceTimersByTime(360_000);
expect(ws.ping).toHaveBeenCalledTimes(3);
expect(ws.ping).toHaveBeenCalledTimes(5);
expect(ws.terminate).not.toHaveBeenCalled();
expect(restartSpy).toHaveBeenCalledTimes(1);
@@ -77,13 +77,13 @@ describe('GatewayManager heartbeat recovery', () => {
(manager as unknown as { startPing: () => void }).startPing();
vi.advanceTimersByTime(30_000); // ping #1
vi.advanceTimersByTime(30_000); // miss #1 + ping #2
vi.advanceTimersByTime(60_000); // ping #1
vi.advanceTimersByTime(60_000); // miss #1 + ping #2
(manager as unknown as { handleMessage: (message: unknown) => void }).handleMessage('alive');
vi.advanceTimersByTime(30_000); // recovered, ping #3
vi.advanceTimersByTime(30_000); // miss #1 + ping #4
vi.advanceTimersByTime(30_000); // miss #2 + ping #5
vi.advanceTimersByTime(60_000); // recovered, ping #3
vi.advanceTimersByTime(60_000); // miss #1 + ping #4
vi.advanceTimersByTime(60_000); // miss #2 + ping #5
expect(ws.terminate).not.toHaveBeenCalled();
expect(restartSpy).not.toHaveBeenCalled();
@@ -112,7 +112,7 @@ describe('GatewayManager heartbeat recovery', () => {
(manager as unknown as { startPing: () => void }).startPing();
vi.advanceTimersByTime(120_000);
vi.advanceTimersByTime(360_000);
expect(restartSpy).not.toHaveBeenCalled();

View File

@@ -4,12 +4,12 @@ import { resolveSupportedLanguage } from '../../shared/language';
describe('resolveSupportedLanguage', () => {
it('uses the base language for supported regional locales', () => {
expect(resolveSupportedLanguage('zh-CN')).toBe('zh');
expect(resolveSupportedLanguage('ja_JP')).toBe('ja');
expect(resolveSupportedLanguage('en-US')).toBe('en');
});
it('falls back to English for unsupported locales', () => {
expect(resolveSupportedLanguage('fr-FR')).toBe('en');
expect(resolveSupportedLanguage('ja_JP')).toBe('en');
expect(resolveSupportedLanguage('ko')).toBe('en');
});

View File

@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const modelDiagnosticsMocks = vi.hoisted(() => ({
config: {} as Record<string, unknown>,
writeOpenClawConfig: vi.fn(),
testOpenClawDir: '/tmp/clawx-tests/model-diagnostics-openclaw',
}));
vi.mock('@electron/utils/channel-config', () => ({
readOpenClawConfig: vi.fn(async () => modelDiagnosticsMocks.config),
writeOpenClawConfig: vi.fn(async (config: Record<string, unknown>) => {
modelDiagnosticsMocks.config = config;
modelDiagnosticsMocks.writeOpenClawConfig(config);
}),
}));
vi.mock('@electron/utils/paths', () => ({
getOpenClawConfigDir: () => modelDiagnosticsMocks.testOpenClawDir,
}));
vi.mock('@electron/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
function authProfilesPath(homeDir: string): string {
return join(homeDir, '.openclaw', 'agents', 'main', 'agent', 'auth-profiles.json');
}
describe('Yinian model diagnostics', () => {
const originalHome = process.env.HOME;
const testHome = join(tmpdir(), 'clawx-tests', 'model-diagnostics-home');
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
process.env.HOME = testHome;
rmSync(testHome, { recursive: true, force: true });
rmSync(modelDiagnosticsMocks.testOpenClawDir, { recursive: true, force: true });
mkdirSync(join(testHome, '.openclaw', 'agents', 'main', 'agent'), { recursive: true });
mkdirSync(modelDiagnosticsMocks.testOpenClawDir, { recursive: true });
modelDiagnosticsMocks.config = {
models: {
providers: {
minimax: {
baseUrl: 'https://api.minimaxi.com/anthropic',
api: 'anthropic-messages',
timeoutSeconds: 30,
models: [{ id: 'MiniMax-M2.7' }],
},
},
pricing: {
enabled: true,
},
},
agents: {
defaults: {
model: {
primary: 'minimax/MiniMax-M2.7',
fallbacks: ['minimax/MiniMax-M2.5'],
},
},
},
};
writeFileSync(authProfilesPath(testHome), JSON.stringify({
version: 1,
profiles: {
'minimax-cn:default': {
type: 'api_key',
provider: 'minimax-cn',
key: 'secret',
},
},
}, null, 2));
});
afterEach(() => {
process.env.HOME = originalHome;
rmSync(testHome, { recursive: true, force: true });
rmSync(modelDiagnosticsMocks.testOpenClawDir, { recursive: true, force: true });
});
it('repairs runtime model defaults and normalizes MiniMax auth profile aliases', async () => {
const {
buildYinianModelConfigDiagnostics,
ensureYinianModelRuntimeConfigured,
} = await import('@electron/utils/model-diagnostics');
await ensureYinianModelRuntimeConfigured();
expect(modelDiagnosticsMocks.writeOpenClawConfig).toHaveBeenCalledWith(expect.objectContaining({
models: expect.objectContaining({
pricing: expect.objectContaining({ enabled: false }),
providers: expect.objectContaining({
minimax: expect.objectContaining({ timeoutSeconds: 300 }),
}),
}),
agents: expect.objectContaining({
defaults: expect.objectContaining({
heartbeat: expect.objectContaining({ every: '0m' }),
}),
}),
}));
const authStore = JSON.parse(readFileSync(authProfilesPath(testHome), 'utf8')) as {
profiles: Record<string, { provider?: string; key?: string }>;
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
};
expect(authStore.profiles['minimax:default']).toMatchObject({
provider: 'minimax',
key: 'secret',
});
expect(authStore.order?.minimax).toContain('minimax:default');
expect(authStore.lastGood?.minimax).toBe('minimax:default');
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.checks.find((check) => check.id === 'auth-profile')?.status).toBe('ok');
});
});

View File

@@ -359,7 +359,7 @@ describe('sanitizeOpenClawConfig', () => {
it('properly sanitizes a genuinely empty {} config (fresh install)', async () => {
// A fresh install with {} is a valid config — sanitize should proceed
// and enforce tools.profile, commands.restart, etc.
// and enforce the desktop tools profile, commands.restart, etc.
await writeOpenClawJson({});
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
@@ -371,7 +371,35 @@ describe('sanitizeOpenClawConfig', () => {
const result = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>;
// Fresh install should get tools settings enforced
const tools = result.tools as Record<string, unknown>;
expect(tools.profile).toBe('full');
expect(tools.profile).toBe('coding');
logSpy.mockRestore();
});
it('removes deprecated plugins.bundledDiscovery before Gateway start', async () => {
await writeOpenClawJson({
plugins: {
allow: ['agentbus'],
bundledDiscovery: 'compat',
entries: {
agentbus: { enabled: true },
},
},
});
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await sanitizeOpenClawConfig();
const result = await readOpenClawJson();
expect((result.plugins as Record<string, unknown>).bundledDiscovery).toBeUndefined();
expect(result.plugins).toMatchObject({
allow: ['agentbus'],
entries: {
agentbus: { enabled: true },
},
});
logSpy.mockRestore();
});
@@ -399,7 +427,37 @@ describe('sanitizeOpenClawConfig', () => {
});
// tools settings should now be enforced
const tools = result.tools as Record<string, unknown>;
expect(tools.profile).toBe('full');
expect(tools.profile).toBe('coding');
logSpy.mockRestore();
});
it('sets Yinian managed agents to lightweight skill loading', async () => {
await writeOpenClawJson({
models: {
providers: {
minimax: {
baseUrl: 'https://api.minimaxi.com/anthropic',
api: 'anthropic-messages',
},
},
},
agents: {
defaults: {
model: { primary: 'minimax/MiniMax-M2.7' },
},
},
});
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await sanitizeOpenClawConfig();
const result = await readOpenClawJson();
const defaults = (result.agents as Record<string, unknown>).defaults as Record<string, unknown>;
expect(defaults.skills).toEqual([]);
expect(defaults.heartbeat).toMatchObject({ every: '0m' });
logSpy.mockRestore();
});
@@ -505,7 +563,7 @@ describe('sanitizeOpenClawConfig', () => {
expect(dingtalk.clientSecret).toBe('dt-secret');
});
it('removes stale minimax-portal-auth plugin entries when merged minimax plugin is installed', async () => {
it('trims Yinian managed plugins to the core desktop plugin surface', async () => {
await writeOpenClawJson({
plugins: {
allow: ['minimax-portal-auth', 'custom-plugin'],
@@ -550,11 +608,11 @@ describe('sanitizeOpenClawConfig', () => {
const result = await readOpenClawJson();
const plugins = result.plugins as Record<string, unknown>;
const allow = plugins.allow as string[];
const entries = plugins.entries as Record<string, Record<string, unknown>>;
const entries = (plugins.entries || {}) as Record<string, Record<string, unknown>>;
expect(allow).toEqual(['custom-plugin']);
expect(new Set(allow)).toEqual(new Set(['minimax', 'cloud-sync']));
expect(entries['minimax-portal-auth']).toBeUndefined();
expect(entries['custom-plugin']).toEqual({ enabled: true });
expect(entries['custom-plugin']).toBeUndefined();
});
});

View File

@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { cleanupOpenClawRuntimeNativeClipboard } from '@electron/utils/optional-native-cleanup';
describe('optional native cleanup', () => {
const root = join(tmpdir(), 'clawx-tests', 'optional-native-cleanup');
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
it('removes optional native clipboard packages from a managed OpenClaw runtime', () => {
const nodeModules = join(root, 'openclaw', 'node_modules');
const scope = join(nodeModules, '@mariozechner');
const nativeClipboard = join(scope, 'clipboard-darwin-arm64');
const genericClipboard = join(scope, 'clipboard');
const codingAgent = join(scope, 'pi-coding-agent');
const unrelated = join(nodeModules, '@example', 'clipboard-darwin-arm64');
for (const dir of [nativeClipboard, genericClipboard, codingAgent, unrelated]) {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'package.json'), '{}\n');
}
const removed = cleanupOpenClawRuntimeNativeClipboard(join(root, 'openclaw'));
expect(removed).toBe(2);
expect(existsSync(nativeClipboard)).toBe(false);
expect(existsSync(genericClipboard)).toBe(false);
expect(existsSync(codingAgent)).toBe(true);
expect(existsSync(unrelated)).toBe(true);
});
});

View File

@@ -4,7 +4,7 @@ import { stripProcessMessagePrefix } from '@/pages/Chat/message-utils';
import type { RawMessage, ToolStatus } from '@/stores/chat';
describe('deriveTaskSteps', () => {
it('builds running steps from streaming thinking and tool status', () => {
it('builds running steps from streaming tool status without exposing thinking blocks', () => {
const streamingTools: ToolStatus[] = [
{
name: 'web_search',
@@ -27,12 +27,6 @@ describe('deriveTaskSteps', () => {
});
expect(steps).toEqual([
expect.objectContaining({
id: 'stream-thinking-0',
label: 'Thinking',
status: 'running',
kind: 'thinking',
}),
expect.objectContaining({
label: 'web_search',
status: 'running',
@@ -160,7 +154,7 @@ describe('deriveTaskSteps', () => {
}));
});
it('keeps recent completed steps from assistant history', () => {
it('keeps recent completed tool steps from assistant history without exposing thinking blocks', () => {
const messages: RawMessage[] = [
{
role: 'assistant',
@@ -179,12 +173,6 @@ describe('deriveTaskSteps', () => {
});
expect(steps).toEqual([
expect.objectContaining({
id: 'history-thinking-assistant-1-0',
label: 'Thinking',
status: 'completed',
kind: 'thinking',
}),
expect.objectContaining({
id: 'tool-2',
label: 'read_file',
@@ -194,7 +182,7 @@ describe('deriveTaskSteps', () => {
]);
});
it('splits cumulative streaming thinking into separate execution steps', () => {
it('keeps thinking-only stream chunks out of the execution graph', () => {
const steps = deriveTaskSteps({
messages: [],
streamingMessage: {
@@ -208,23 +196,7 @@ describe('deriveTaskSteps', () => {
streamingTools: [],
});
expect(steps).toEqual([
expect.objectContaining({
id: 'stream-thinking-0',
detail: 'Reviewing X.',
status: 'completed',
}),
expect.objectContaining({
id: 'stream-thinking-1',
detail: 'Comparing Y.',
status: 'completed',
}),
expect.objectContaining({
id: 'stream-thinking-2',
detail: 'Drafting answer.',
status: 'running',
}),
]);
expect(steps).toEqual([]);
});
it('keeps earlier reply segments in the graph when the last streaming segment is rendered separately', () => {

View File

@@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { Today } from '@/pages/Today';
import { useYinianStore } from '@/stores/yinian';
import { useYinianSkillsStore } from '@/stores/yinian-skills';
import { useSkillsStore } from '@/stores/skills';
import { useChatStore } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
import i18n from '@/i18n';
import type { YinianConfigSnapshot, YinianLocalSkill } from '../../shared/yinian';
const hotelHangzhou = {
@@ -53,9 +57,18 @@ function createLocalSkill(overrides: Partial<YinianLocalSkill>): YinianLocalSkil
};
}
function renderToday() {
return render(
<MemoryRouter>
<Today />
</MemoryRouter>,
);
}
describe('Today page', () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
await i18n.changeLanguage('zh');
useYinianStore.setState({
status: 'authenticated',
session: {
@@ -79,22 +92,36 @@ describe('Today page', () => {
loading: false,
error: null,
});
useChatStore.setState({
sessions: [],
sessionLabels: {},
sessionLastActivity: {},
});
useGatewayStore.setState({
status: {
state: 'stopped',
port: 18789,
gatewayReady: false,
},
});
});
it('shows quick actions and plain status cards when workspace has no entitlements', () => {
render(<Today />);
it('shows work entry, pinned apps, and quick tools when workspace has no entitlements', () => {
renderToday();
expect(screen.getAllByText('智念企业组织空间').length).toBeGreaterThan(0);
expect(screen.getByText('开始对话')).toBeInTheDocument();
expect(screen.getByText('管理知识库')).toBeInTheDocument();
expect(screen.getByText('管理应用')).toBeInTheDocument();
expect(screen.getByText('今天想先做什么?')).toBeInTheDocument();
expect(screen.getByText('开始工作')).toBeInTheDocument();
expect(screen.getByText('常用应用')).toBeInTheDocument();
expect(screen.getByText('智念视频助手')).toBeInTheDocument();
expect(screen.getByText('定时任务')).toBeInTheDocument();
expect(screen.getByText('知识库')).toBeInTheDocument();
expect(screen.queryByText('0/0')).not.toBeInTheDocument();
expect(screen.getAllByText('0 个').length).toBeGreaterThan(0);
expect(screen.queryByText('需要关注')).not.toBeInTheDocument();
expect(screen.queryByText('最近话')).not.toBeInTheDocument();
expect(screen.getByText('最近话')).toBeInTheDocument();
});
it('summarizes entitlements with local registry readiness', () => {
it('shows recent conversations before generic empty history', () => {
useYinianStore.setState({
config: createConfig({
entitlements: [
@@ -162,44 +189,66 @@ describe('Today page', () => {
},
],
});
useChatStore.setState({
sessions: [
{ key: 'agent:main:session-a', updatedAt: Date.now() - 2 * 60_000 },
{ key: 'agent:main:session-b', updatedAt: Date.now() - 60 * 60_000 },
],
sessionLabels: {
'agent:main:session-a': '整理本周门店内容',
'agent:main:session-b': '又见乌江宣传片',
},
sessionLastActivity: {
'agent:main:session-a': Date.now() - 2 * 60_000,
'agent:main:session-b': Date.now() - 60 * 60_000,
},
});
render(<Today />);
renderToday();
expect(screen.getByText('管理应用')).toBeInTheDocument();
expect(screen.queryByText('2/2')).not.toBeInTheDocument();
expect(screen.queryByText('下发 2 个 · 本地 2 个')).not.toBeInTheDocument();
expect(screen.getAllByText('2 个').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText(/待处理 1 个/)).not.toBeInTheDocument();
expect(screen.getAllByText(/日报生成助手/).length).toBeGreaterThan(0);
expect(screen.getByText('整理本周门店内容')).toBeInTheDocument();
expect(screen.getByText('又见乌江宣传片')).toBeInTheDocument();
expect(screen.queryByText('数据检查助手 有新版本')).not.toBeInTheDocument();
});
it('updates displayed workspace and counters after hotel switch refreshes config', () => {
const { rerender } = render(<Today />);
const { rerender } = render(
<MemoryRouter>
<Today />
</MemoryRouter>,
);
expect(screen.getAllByText('智念企业组织空间').length).toBeGreaterThan(0);
useYinianStore.setState({
config: createConfig({
hotel: hotelShanghai,
entitlements: [
{
skillId: 'customer-reply-helper',
name: '客户回复助手',
version: '0.9.0',
enabled: true,
category: 'guest-comm',
triggers: ['manual'],
},
],
}),
act(() => {
useYinianStore.setState({
config: createConfig({
hotel: hotelShanghai,
entitlements: [
{
skillId: 'customer-reply-helper',
name: '客户回复助手',
version: '0.9.0',
enabled: true,
category: 'guest-comm',
triggers: ['manual'],
},
],
}),
});
useYinianSkillsStore.setState({ localSkills: [] });
});
useYinianSkillsStore.setState({ localSkills: [] });
rerender(<Today />);
rerender(
<MemoryRouter>
<Today />
</MemoryRouter>,
);
expect(screen.getAllByText('智念增长组织空间').length).toBeGreaterThan(0);
expect(screen.queryByText('1/0')).not.toBeInTheDocument();
expect(screen.getByText('客户回复助手')).toBeInTheDocument();
expect(screen.queryByText(/待处理 1 个/)).not.toBeInTheDocument();
expect(screen.queryByText('智念企业组织空间')).not.toBeInTheDocument();
});

View File

@@ -4,6 +4,8 @@ import { YinianSkills } from '@/pages/YinianSkills';
import { useYinianStore } from '@/stores/yinian';
import { useYinianSkillsStore } from '@/stores/yinian-skills';
import { useSkillsStore } from '@/stores/skills';
import { useQuickTasksStore } from '@/stores/quick-tasks';
import i18n from '@/i18n';
import type { YinianConfigSnapshot, YinianLocalSkill } from '../../shared/yinian';
const toastSuccessMock = vi.fn();
@@ -62,9 +64,14 @@ function createLocalSkill(overrides: Partial<YinianLocalSkill>): YinianLocalSkil
};
}
function clickCapabilityTab(label: '服务下发' | '本地安装') {
fireEvent.click(screen.getByRole('button', { name: new RegExp(`^${label}\\s+\\d+$`) }));
}
describe('YinianSkills page', () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
await i18n.changeLanguage('zh');
useYinianStore.setState({
status: 'authenticated',
session: {
@@ -89,6 +96,7 @@ describe('YinianSkills page', () => {
error: null,
fetchSkills: vi.fn().mockResolvedValue(undefined),
});
useQuickTasksStore.setState({ tasks: [] });
vi.mocked(window.yinian.skills.getRegistry).mockReset();
vi.mocked(window.yinian.skills.getRegistry).mockResolvedValue(undefined);
vi.mocked(window.yinian.skills.sync).mockReset();
@@ -97,8 +105,9 @@ describe('YinianSkills page', () => {
it('shows empty entitlement state', () => {
render(<YinianSkills />);
expect(screen.getByText('智念企业组织空间')).toBeInTheDocument();
expect(screen.getByTestId('yinian-skills-empty-entitlements')).toHaveTextContent('当前组织空间尚未开通应用');
expect(screen.getByText('业务能力包')).toBeInTheDocument();
clickCapabilityTab('服务下发');
expect(screen.getByTestId('yinian-skills-empty-entitlements')).toHaveTextContent('当前组织空间尚未开通能力包');
});
it('maps entitlement and registry states for installed, update, disabled, and failed skills', () => {
@@ -149,6 +158,7 @@ describe('YinianSkills page', () => {
});
render(<YinianSkills />);
clickCapabilityTab('服务下发');
expect(screen.getByTestId('yinian-skill-state-daily-report')).toHaveTextContent('已是最新');
expect(screen.getByTestId('yinian-skill-state-data-check')).toHaveTextContent('有更新');
@@ -179,7 +189,7 @@ describe('YinianSkills page', () => {
});
render(<YinianSkills />);
fireEvent.click(screen.getByRole('button', { name: /本地安装/ }));
clickCapabilityTab('本地安装');
expect(screen.getByText('本地总结助手')).toBeInTheDocument();
expect(screen.getByText('整理本地会话内容')).toBeInTheDocument();
@@ -187,6 +197,52 @@ describe('YinianSkills page', () => {
expect(screen.getByText('/tmp/openclaw/skills/local-summary')).toBeInTheDocument();
});
it('hides OpenClaw built-in skills from local capability packs by default', () => {
useSkillsStore.setState({
skills: [
{
id: 'builtin-search',
slug: 'builtin-search',
name: 'OpenClaw 内置搜索',
description: 'OpenClaw 自带能力',
enabled: true,
version: '1.0.0',
isBundled: true,
source: 'openclaw-bundled',
},
{
id: 'extra-helper',
slug: 'extra-helper',
name: 'OpenClaw 扩展目录助手',
description: 'OpenClaw 扩展目录能力',
enabled: true,
version: '1.0.0',
source: 'openclaw-extra',
},
{
id: 'local-summary',
slug: 'local-summary',
name: '本地总结助手',
description: '整理本地会话内容',
enabled: true,
version: '0.2.0',
source: 'openclaw-managed',
baseDir: '/tmp/openclaw/skills/local-summary',
},
],
loading: false,
error: null,
fetchSkills: vi.fn().mockResolvedValue(undefined),
});
render(<YinianSkills />);
clickCapabilityTab('本地安装');
expect(screen.queryByText('OpenClaw 内置搜索')).not.toBeInTheDocument();
expect(screen.queryByText('OpenClaw 扩展目录助手')).not.toBeInTheDocument();
expect(screen.getByText('本地总结助手')).toBeInTheDocument();
});
it('shows empty registry warning when entitlements exist but no local skills are installed', () => {
useYinianStore.setState({
config: createConfig({
@@ -204,9 +260,10 @@ describe('YinianSkills page', () => {
});
render(<YinianSkills />);
clickCapabilityTab('服务下发');
expect(screen.getByTestId('yinian-skill-state-daily-report')).toHaveTextContent('已开通未同步');
expect(screen.getByTestId('yinian-skills-empty-registry')).toHaveTextContent('这台电脑还没有准备好应用');
expect(screen.getByTestId('yinian-skills-empty-registry')).toHaveTextContent('这台电脑还没有准备好能力包');
});
it('sync button stores result and shows success toast', async () => {
@@ -231,7 +288,7 @@ describe('YinianSkills page', () => {
});
render(<YinianSkills />);
const syncButton = screen.getByRole('button', { name: '同步应用' });
const syncButton = screen.getByRole('button', { name: '同步能力包' });
await waitFor(() => {
expect(syncButton).not.toBeDisabled();
});
@@ -239,7 +296,7 @@ describe('YinianSkills page', () => {
await waitFor(() => {
expect(window.yinian.skills.sync).toHaveBeenCalled();
expect(toastSuccessMock).toHaveBeenCalledWith('应用已同步');
expect(toastSuccessMock).toHaveBeenCalledWith('能力包已同步');
});
expect(useYinianSkillsStore.getState().localSkills).toHaveLength(1);
});