Files
zn-ai/tests/chat-tooling.test.ts
DEV_DSW 4c61e93c3e Add unit tests for skill capabilities, skill planner, and UV setup
- Implement tests for random ID generation, ensuring preference for crypto.randomUUID.
- Create tests for runtime context capabilities, validating the injection of enabled skill capabilities.
- Add tests for skill capability parsing, including classification and command example extraction.
- Introduce tests for the skill planner, verifying tool call planning based on user requests and attachment requirements.
- Establish tests for UV setup, ensuring proper handling of Python installation scenarios and environment checks.
2026-04-24 17:02:59 +08:00

993 lines
31 KiB
TypeScript

// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs-extra';
import { createChatToolRuntime } from '../electron/gateway/chat-tooling';
import type { SkillCapability } from '../electron/gateway/skill-capability-parser';
const pdfJsMocks = vi.hoisted(() => ({
getDocument: vi.fn(),
}));
const skillConfigMocks = vi.hoisted(() => ({
getSkillConfig: vi.fn(),
}));
const fetchMocks = vi.hoisted(() => ({
fetch: vi.fn(),
}));
const browserOpenMocks = vi.hoisted(() => ({
openUrlInBrowser: vi.fn(),
}));
const clawHubMocks = vi.hoisted(() => ({
search: vi.fn(),
}));
const workbookRows = [
{ hotel: 'Hongqiao', sales_amount: 128000, room_nights: 420, occupancy: 0.86 },
{ hotel: 'Pudong', sales_amount: 98000, room_nights: 365, occupancy: 0.73 },
{ hotel: 'Hongqiao', sales_amount: 128000, room_nights: 420, occupancy: 0.86 },
];
vi.mock('xlsx', () => ({
readFile: vi.fn(() => ({
SheetNames: ['sales'],
Sheets: {
sales: { '!ref': 'A1:D4' },
},
})),
utils: {
sheet_to_json: vi.fn(() => workbookRows),
},
}));
vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
getDocument: pdfJsMocks.getDocument,
}));
vi.mock('@electron/utils/skill-config', () => ({
getSkillConfig: skillConfigMocks.getSkillConfig,
}));
vi.mock('@electron/service/browser-open-service', () => ({
openUrlInBrowser: browserOpenMocks.openUrlInBrowser,
}));
vi.mock('../electron/gateway/clawhub', () => ({
ClawHubService: class {
search = clawHubMocks.search;
},
}));
const spreadsheetCapability: SkillCapability = {
skillKey: 'minimax-xlsx',
slug: 'minimax-xlsx',
name: 'MiniMax XLSX',
description: 'Analyze spreadsheet files such as .xls, .xlsx, .csv, and .tsv.',
enabled: true,
category: 'document',
allowedTools: [],
operationHints: ['read', 'analyze'],
triggerHints: ['spreadsheet', 'excel'],
inputExtensions: ['.xls', '.xlsx', '.csv', '.tsv'],
requiredEnvVars: [],
requiresAuth: false,
plannerSummary: 'document skill; operations: read, analyze; inputs: .xls, .xlsx, .csv, .tsv',
renderHints: {
card: 'document-analysis',
preferredView: 'table',
skillType: 'spreadsheet',
},
};
const genericSearchCapability: SkillCapability = {
skillKey: 'minimax-search',
slug: 'minimax-search',
name: 'MiniMax Search',
description: 'Search indexed knowledge sources and summarize the results.',
enabled: true,
category: 'search',
allowedTools: ['web.search'],
operationHints: ['search', 'retrieve'],
triggerHints: ['search', 'lookup'],
inputExtensions: [],
requiredEnvVars: ['ZN_AI_TEST_GENERIC_SKILL_ENV'],
requiresAuth: true,
plannerSummary: 'search skill; operations: search, retrieve; inputs: text',
renderHints: {
card: 'search-results',
preferredView: 'summary',
skillType: 'search',
},
};
const braveSearchCapability: SkillCapability = {
skillKey: 'brave-web-search',
slug: 'brave-web-search',
name: 'Brave Web Search',
description: 'Search the web with Brave Search.',
enabled: true,
category: 'search',
allowedTools: ['web.search'],
operationHints: ['search'],
triggerHints: ['search', 'web'],
inputExtensions: [],
requiredEnvVars: ['BRAVE_SEARCH_API_KEY'],
requiresAuth: true,
plannerSummary: 'search skill; operations: search; inputs: text',
renderHints: {
card: 'search-results',
preferredView: 'summary',
skillType: 'search',
},
};
const tavilySearchCapability: SkillCapability = {
skillKey: 'tavily-search',
slug: 'tavily-search',
name: 'Tavily Search',
description: 'Search the web with Tavily.',
enabled: true,
category: 'search',
allowedTools: ['Bash(tvly *)'],
operationHints: ['search', 'research'],
triggerHints: ['search', 'latest'],
inputExtensions: [],
requiredEnvVars: ['TAVILY_API_KEY'],
requiresAuth: true,
plannerSummary: 'search skill; operations: search, research; inputs: text',
renderHints: {
card: 'search-results',
preferredView: 'summary',
skillType: 'search',
},
};
const browserSkillCapability: SkillCapability = {
skillKey: 'browser-scout',
slug: 'browser-scout',
name: 'Browser Scout',
description: 'Open an explicit URL in the local browser.',
enabled: true,
category: 'general',
allowedTools: ['browser.open_url'],
operationHints: ['open'],
triggerHints: ['open url', 'visit page'],
inputExtensions: [],
requiredEnvVars: [],
requiresAuth: false,
plannerSummary: 'browser skill; operations: open; inputs: url',
renderHints: {
card: 'browser-step',
preferredView: 'summary',
skillType: 'browser',
},
};
const commandSkillCapability: SkillCapability = {
skillKey: 'find-skills',
slug: 'find-skills',
name: 'Find Skills',
description: 'Discover installable skills for a task.',
enabled: true,
category: 'general',
allowedTools: [],
operationHints: ['find'],
triggerHints: ['find a skill', 'discover skill'],
inputExtensions: [],
requiredEnvVars: [],
requiresAuth: false,
plannerSummary: 'command skill; operations: find; inputs: text',
renderHints: {
card: 'search-results',
preferredView: 'summary',
skillType: 'command',
},
};
const docxCapability: SkillCapability = {
skillKey: 'docx',
slug: 'docx',
name: 'DOCX',
description: 'Create, read, edit, or analyze .docx files.',
enabled: true,
category: 'document',
allowedTools: [],
operationHints: ['read', 'analyze'],
triggerHints: ['docx', 'word document'],
inputExtensions: ['.docx'],
requiredEnvVars: [],
requiresAuth: false,
plannerSummary: 'document skill; operations: read, analyze; inputs: .docx',
renderHints: {
card: 'document-analysis',
preferredView: 'summary',
skillType: 'document',
},
};
const pptxCapability: SkillCapability = {
skillKey: 'pptx',
slug: 'pptx',
name: 'PPTX',
description: 'Create, read, edit, or analyze .pptx files.',
enabled: true,
category: 'document',
allowedTools: [],
operationHints: ['read', 'analyze'],
triggerHints: ['pptx', 'presentation'],
inputExtensions: ['.pptx'],
requiredEnvVars: [],
requiresAuth: false,
plannerSummary: 'document skill; operations: read, analyze; inputs: .pptx',
renderHints: {
card: 'document-analysis',
preferredView: 'summary',
skillType: 'document',
},
};
const pdfCapability: SkillCapability = {
skillKey: 'pdf',
slug: 'pdf',
name: 'PDF',
description: 'Create, read, edit, or analyze .pdf files.',
enabled: true,
category: 'document',
allowedTools: [],
operationHints: ['read', 'analyze'],
triggerHints: ['pdf', 'document'],
inputExtensions: ['.pdf'],
requiredEnvVars: [],
requiresAuth: false,
plannerSummary: 'document skill; operations: read, analyze; inputs: .pdf',
renderHints: {
card: 'document-analysis',
preferredView: 'summary',
skillType: 'document',
},
};
const tempDirs: string[] = [];
async function createTempDir(prefix: string): Promise<string> {
const root = `${process.cwd().replace(/\\/g, '/')}/.tmp-vitest`;
const dir = `${root}/${prefix}${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
await fs.ensureDir(dir);
tempDirs.push(dir);
return dir;
}
async function createDocxFixture(): Promise<string> {
const JSZip = (await import('jszip')).default;
const dir = await createTempDir('zn-ai-docx-');
const filePath = `${dir}/brief.docx`;
const zip = new JSZip();
zip.file('word/document.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p>
<w:pPr><w:pStyle w:val="Heading1"/></w:pPr>
<w:r><w:t>Quarterly Hotel Review</w:t></w:r>
</w:p>
<w:p>
<w:r><w:t>Revenue grew 12 percent compared with last quarter.</w:t></w:r>
</w:p>
<w:tbl>
<w:tr><w:tc><w:p><w:r><w:t>Table Cell</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
</w:body>
</w:document>`);
zip.file('word/comments.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:comment w:id="0"><w:p><w:r><w:t>Looks good</w:t></w:r></w:p></w:comment>
</w:comments>`);
zip.file('docProps/core.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Hotel Review</dc:title>
<dc:creator>ZN AI</dc:creator>
</cp:coreProperties>`);
zip.file('docProps/app.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
<Pages>3</Pages>
</Properties>`);
await fs.writeFile(filePath, await zip.generateAsync({ type: 'nodebuffer' }));
return filePath;
}
async function createPptxFixture(): Promise<string> {
const JSZip = (await import('jszip')).default;
const dir = await createTempDir('zn-ai-pptx-');
const filePath = `${dir}/deck.pptx`;
const zip = new JSZip();
zip.file('ppt/presentation.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:sldIdLst>
<p:sldId id="256" r:id="rId1"/>
<p:sldId id="257" r:id="rId2" show="0"/>
</p:sldIdLst>
</p:presentation>`);
zip.file('ppt/slides/slide1.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:spTree><p:sp><p:txBody><a:p><a:r><a:t>Executive Summary</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld>
</p:sld>`);
zip.file('ppt/slides/slide2.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:spTree><p:sp><p:txBody><a:p><a:r><a:t>Growth Plan</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld>
</p:sld>`);
await fs.writeFile(filePath, await zip.generateAsync({ type: 'nodebuffer' }));
return filePath;
}
async function createPdfFixture(): Promise<string> {
const dir = await createTempDir('zn-ai-pdf-');
const filePath = `${dir}/brief.pdf`;
await fs.writeFile(filePath, Buffer.from('%PDF-1.4\n%mock\n', 'utf-8'));
return filePath;
}
async function createGenericCommandSkillFixture(scriptName = 'search.js'): Promise<{ baseDir: string; scriptPath: string }> {
const dir = await createTempDir('zn-ai-command-skill-');
const scriptsDir = `${dir}/scripts`;
const scriptPath = `${scriptsDir}/${scriptName}`;
await fs.ensureDir(scriptsDir);
await fs.writeFile(
scriptPath,
[
'const query = process.argv.slice(2).join(" ").trim();',
'process.stdout.write(JSON.stringify({',
' query,',
' summary: `Ran generic command for ${query}`,',
' results: [{',
' title: `Result for ${query}`,',
' url: "https://example.com/result",',
' snippet: "Generated from local command script",',
' }],',
'}));',
].join('\n'),
'utf8',
);
return { baseDir: dir, scriptPath };
}
describe('chat tooling adapters', () => {
beforeEach(() => {
delete process.env.ZN_AI_TEST_GENERIC_SKILL_ENV;
pdfJsMocks.getDocument.mockReset();
skillConfigMocks.getSkillConfig.mockReset();
skillConfigMocks.getSkillConfig.mockResolvedValue(undefined);
fetchMocks.fetch.mockReset();
browserOpenMocks.openUrlInBrowser.mockReset();
clawHubMocks.search.mockReset();
vi.stubGlobal('fetch', fetchMocks.fetch);
});
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.remove(dir)));
vi.unstubAllGlobals();
});
it('analyzes legacy .xls attachments without relying on a system python runtime', async () => {
const runtime = createChatToolRuntime([spreadsheetCapability]);
const result = await runtime.run({
toolCallId: 'tool-call-1',
toolName: 'minimax-xlsx',
input: {
prompt: 'Analyze this hotel sales workbook.',
skillKey: 'minimax-xlsx',
attachments: [
{
fileName: 'hotel-sales.xls',
filePath: 'F:\\Downloads\\hotel-sales.xls',
mimeType: 'application/vnd.ms-excel',
source: 'user-upload',
},
],
},
});
expect(result.execution.ok).toBe(true);
expect(result.execution.summary).toContain('3 total row(s)');
const executionRaw = result.execution.raw as {
reports: Array<{
report: {
engine: string;
workbook: { sheetNames: string[] };
structure: Record<string, { shape: { rows: number; cols: number } }>;
quality: Record<string, Array<{ type: string }>>;
};
}>;
};
expect(executionRaw.reports[0]?.report.engine).toBe('node-xlsx');
expect(executionRaw.reports[0]?.report.workbook.sheetNames).toContain('sales');
expect(executionRaw.reports[0]?.report.structure.sales?.shape).toEqual({
rows: 3,
cols: 4,
});
expect(executionRaw.reports[0]?.report.quality.sales).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'duplicate_rows',
}),
]),
);
});
it('analyzes docx attachments with the generic document adapter', async () => {
const runtime = createChatToolRuntime([docxCapability]);
const filePath = await createDocxFixture();
const result = await runtime.run({
toolCallId: 'tool-call-docx',
toolName: 'docx',
input: {
prompt: 'Review this Word document.',
attachments: [
{
fileName: 'brief.docx',
filePath,
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
source: 'user-upload',
},
],
},
});
expect(result.execution.ok).toBe(true);
expect(result.execution.summary).toContain('Document analysis completed.');
expect(result.execution.renderHints).toEqual(expect.objectContaining({
skillType: 'document',
}));
const executionRaw = result.execution.raw as {
fileCount: number;
kinds: string[];
reports: Array<{
kind: string;
summary: string;
metadata: {
pageCount?: number;
paragraphCount?: number;
tableCount?: number;
commentCount?: number;
};
preview: Array<{ text?: string }>;
}>;
};
expect(executionRaw.fileCount).toBe(1);
expect(executionRaw.kinds).toContain('docx');
expect(executionRaw.reports[0]?.kind).toBe('docx');
expect(executionRaw.reports[0]?.summary).toContain('2 paragraph(s)');
expect(executionRaw.reports[0]?.metadata).toEqual(expect.objectContaining({
pageCount: 3,
paragraphCount: 2,
tableCount: 1,
commentCount: 1,
}));
expect(executionRaw.reports[0]?.preview[0]?.text).toContain('Quarterly Hotel Review');
});
it('analyzes pptx attachments with the generic document adapter', async () => {
const runtime = createChatToolRuntime([pptxCapability]);
const filePath = await createPptxFixture();
const result = await runtime.run({
toolCallId: 'tool-call-pptx',
toolName: 'pptx',
input: {
prompt: 'Summarize this deck.',
attachments: [
{
fileName: 'deck.pptx',
filePath,
mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
source: 'user-upload',
},
],
},
});
expect(result.execution.ok).toBe(true);
const executionRaw = result.execution.raw as {
reports: Array<{
kind: string;
metadata: {
slideCount?: number;
hiddenSlideCount?: number;
};
preview: Array<{ slide?: number; text?: string }>;
}>;
};
expect(executionRaw.reports[0]?.kind).toBe('pptx');
expect(executionRaw.reports[0]?.metadata).toEqual(expect.objectContaining({
slideCount: 2,
hiddenSlideCount: 1,
}));
expect(executionRaw.reports[0]?.preview[0]).toEqual(expect.objectContaining({
slide: 1,
}));
expect(executionRaw.reports[0]?.preview[0]?.text).toContain('Executive Summary');
});
it('analyzes pdf attachments with the generic document adapter', async () => {
pdfJsMocks.getDocument.mockImplementation(() => ({
promise: Promise.resolve({
numPages: 2,
getMetadata: async () => ({
info: {
Title: 'Hotel Brief',
Author: 'ZN AI',
},
}),
getPage: async (pageNumber: number) => ({
getTextContent: async () => ({
items: [
{ str: pageNumber === 1 ? 'Hotel brief overview' : 'Revenue improved year over year' },
],
}),
}),
destroy: async () => undefined,
}),
destroy: async () => undefined,
}));
const runtime = createChatToolRuntime([pdfCapability]);
const filePath = await createPdfFixture();
const result = await runtime.run({
toolCallId: 'tool-call-pdf',
toolName: 'pdf',
input: {
prompt: 'Analyze this PDF.',
attachments: [
{
fileName: 'brief.pdf',
filePath,
mimeType: 'application/pdf',
source: 'user-upload',
},
],
},
});
expect(result.execution.ok).toBe(true);
const executionRaw = result.execution.raw as {
reports: Array<{
kind: string;
engine: string;
metadata: {
title?: string;
pageCount?: number;
};
preview: Array<{ page?: number; text?: string }>;
}>;
};
expect(executionRaw.reports[0]?.kind).toBe('pdf');
expect(executionRaw.reports[0]?.engine).toBe('node-pdfjs');
expect(executionRaw.reports[0]?.metadata).toEqual(expect.objectContaining({
title: 'Hotel Brief',
pageCount: 2,
}));
expect(executionRaw.reports[0]?.preview[1]).toEqual(expect.objectContaining({
page: 2,
}));
});
it('executes brave-web-search with configured credentials and normalizes results', async () => {
skillConfigMocks.getSkillConfig.mockResolvedValue({
apiKey: 'brave-key',
env: {},
});
fetchMocks.fetch.mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({
query: {
original: 'hotel revenue trends',
more_results_available: true,
},
web: {
family_friendly: true,
results: [
{
title: 'Hotel Revenue Trends Q1',
url: 'https://example.com/revenue-q1',
description: 'Revenue increased across the segment.',
age: '2 days ago',
page_age: '2026-04-20T10:00:00Z',
profile: {
name: 'Example News',
},
},
{
title: 'Hotel Demand Outlook',
url: 'https://example.com/demand',
description: 'Demand remains resilient.',
},
],
},
}),
});
const runtime = createChatToolRuntime([braveSearchCapability]);
const result = await runtime.run({
toolCallId: 'tool-call-brave',
toolName: 'brave-web-search',
input: {
prompt: 'hotel revenue trends',
count: 2,
timeRange: 'week',
country: 'US',
},
});
expect(result.execution.ok).toBe(true);
expect(result.execution.summary).toContain('Found 2 result(s)');
expect(fetchMocks.fetch).toHaveBeenCalledTimes(1);
const [requestUrl, requestInit] = fetchMocks.fetch.mock.calls[0] || [];
expect(String(requestUrl)).toContain('https://api.search.brave.com/res/v1/web/search?');
expect(String(requestUrl)).toContain('q=hotel+revenue+trends');
expect(String(requestUrl)).toContain('count=2');
expect(String(requestUrl)).toContain('freshness=pw');
expect(String(requestUrl)).toContain('country=US');
expect(requestInit).toEqual(expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
'X-Subscription-Token': 'brave-key',
}),
}));
const executionRaw = result.execution.raw as {
provider: string;
resultCount: number;
query: string;
results: Array<{ title: string; url: string; snippet?: string; source?: string }>;
moreResultsAvailable?: boolean;
};
expect(executionRaw.provider).toBe('brave');
expect(executionRaw.resultCount).toBe(2);
expect(executionRaw.query).toBe('hotel revenue trends');
expect(executionRaw.results[0]).toEqual(expect.objectContaining({
title: 'Hotel Revenue Trends Q1',
url: 'https://example.com/revenue-q1',
snippet: 'Revenue increased across the segment.',
source: 'Example News',
}));
expect(result.execution.artifacts?.[0]).toEqual(expect.objectContaining({
kind: 'url',
uri: 'https://example.com/revenue-q1',
}));
});
it('executes tavily-search with configured env credentials and normalizes results', async () => {
skillConfigMocks.getSkillConfig.mockResolvedValue({
env: {
TAVILY_API_KEY: 'tvly-key',
},
});
fetchMocks.fetch.mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({
query: 'hotel demand forecast',
answer: 'Demand is increasing heading into the summer season.',
response_time: 1.42,
results: [
{
title: 'Hospitality Forecast 2026',
url: 'https://example.com/forecast',
content: 'Forecast shows double-digit demand growth.',
score: 0.9123,
published_date: '2026-04-21',
},
],
}),
});
const runtime = createChatToolRuntime([tavilySearchCapability]);
const result = await runtime.run({
toolCallId: 'tool-call-tavily',
toolName: 'tavily-search',
input: {
prompt: 'hotel demand forecast',
maxResults: 3,
depth: 'advanced',
timeRange: 'month',
includeDomains: ['example.com', 'reuters.com'],
includeAnswer: true,
},
});
expect(result.execution.ok).toBe(true);
expect(result.execution.summary).toContain('Found 1 result(s)');
expect(fetchMocks.fetch).toHaveBeenCalledTimes(1);
const [requestUrl, requestInit] = fetchMocks.fetch.mock.calls[0] || [];
expect(requestUrl).toBe('https://api.tavily.com/search');
expect(requestInit).toEqual(expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer tvly-key',
}),
}));
const body = JSON.parse(String(requestInit?.body || '{}')) as Record<string, unknown>;
expect(body).toEqual(expect.objectContaining({
query: 'hotel demand forecast',
max_results: 3,
search_depth: 'advanced',
time_range: 'month',
include_answer: true,
include_domains: ['example.com', 'reuters.com'],
}));
const executionRaw = result.execution.raw as {
provider: string;
query: string;
answer?: string;
responseTimeMs?: number;
results: Array<{ title: string; score?: number; publishedAt?: string }>;
};
expect(executionRaw.provider).toBe('tavily');
expect(executionRaw.query).toBe('hotel demand forecast');
expect(executionRaw.answer).toContain('Demand is increasing');
expect(executionRaw.responseTimeMs).toBe(1420);
expect(executionRaw.results[0]).toEqual(expect.objectContaining({
title: 'Hospitality Forecast 2026',
score: 0.9123,
publishedAt: '2026-04-21',
}));
});
it('executes browser-capable skills by delegating to browser.open_url behavior', async () => {
browserOpenMocks.openUrlInBrowser.mockResolvedValue({
pageUrl: 'https://example.com/hotels',
title: 'Hotel Trends',
});
const runtime = createChatToolRuntime([browserSkillCapability]);
const result = await runtime.run({
toolCallId: 'tool-call-browser-skill',
toolName: 'browser-scout',
input: {
prompt: 'Open https://example.com/hotels',
},
});
expect(result.execution.ok).toBe(true);
expect(browserOpenMocks.openUrlInBrowser).toHaveBeenCalledWith(
'https://example.com/hotels',
expect.any(Object),
);
expect(result.execution.renderHints).toEqual(expect.objectContaining({
skillType: 'browser',
}));
expect(result.execution.raw).toEqual(expect.objectContaining({
pageUrl: 'https://example.com/hotels',
title: 'Hotel Trends',
skillKey: 'browser-scout',
}));
});
it('executes find-skills through the command adapter and normalizes clawhub results', async () => {
clawHubMocks.search.mockResolvedValue([
{
slug: 'vercel-labs/agent-skills@react-best-practices',
name: 'react-best-practices',
description: 'React and Next.js guidance from Vercel.',
version: '1.2.0',
downloads: 185000,
stars: 4200,
},
]);
const runtime = createChatToolRuntime([commandSkillCapability]);
const result = await runtime.run({
toolCallId: 'tool-call-find-skills',
toolName: 'find-skills',
input: {
prompt: 'react performance',
maxResults: 5,
},
});
expect(result.execution.ok).toBe(true);
expect(clawHubMocks.search).toHaveBeenCalledWith({
query: 'react performance',
limit: 5,
});
expect(result.execution.summary).toContain('1 matching skill result(s)');
expect(result.execution.logs).toContain('npx skills find react performance');
expect(result.execution.renderHints).toEqual(expect.objectContaining({
card: 'search-results',
skillType: 'command',
}));
const executionRaw = result.execution.raw as {
provider: string;
command?: string;
results: Array<{
title: string;
url: string;
installCommand?: string;
downloads?: number;
stars?: number;
}>;
};
expect(executionRaw.provider).toBe('clawhub');
expect(executionRaw.command).toBe('npx skills find');
expect(executionRaw.results[0]).toEqual(expect.objectContaining({
title: 'react-best-practices',
url: 'https://skills.sh/vercel-labs/agent-skills/react-best-practices',
installCommand: 'npx skills add vercel-labs/agent-skills@react-best-practices -g -y',
downloads: 185000,
stars: 4200,
}));
});
it('executes generic command-style skills from manifest command templates', async () => {
const fixture = await createGenericCommandSkillFixture('search.js');
const capability: SkillCapability = {
...commandSkillCapability,
skillKey: 'custom-command-skill',
slug: 'custom-command-skill',
name: 'Custom Command Skill',
baseDir: fixture.baseDir,
manifestPath: `${fixture.baseDir}/SKILL.md`,
commandExamples: ['node scripts/search.js [query]'],
};
const runtime = createChatToolRuntime([capability]);
const result = await runtime.run({
toolCallId: 'tool-call-generic-command-template',
toolName: 'custom-command-skill',
input: {
prompt: 'hotel strategy',
},
});
expect(result.execution.ok).toBe(true);
expect(result.execution.summary).toContain('Command completed via node scripts/search.js hotel strategy');
const executionRaw = result.execution.raw as {
skillKey: string;
command: string;
query: string;
summary: string;
results: Array<{ title: string; url: string }>;
};
expect(executionRaw.skillKey).toBe('custom-command-skill');
expect(executionRaw.command).toContain('node scripts/search.js hotel strategy');
expect(executionRaw.query).toBe('hotel strategy');
expect(executionRaw.summary).toContain('hotel strategy');
expect(executionRaw.results[0]).toEqual(expect.objectContaining({
title: 'Result for hotel strategy',
url: 'https://example.com/result',
}));
});
it('executes generic command-style skills from a single scripts entrypoint when no manifest template exists', async () => {
const fixture = await createGenericCommandSkillFixture('run.js');
const capability: SkillCapability = {
...commandSkillCapability,
skillKey: 'script-entry-skill',
slug: 'script-entry-skill',
name: 'Script Entry Skill',
baseDir: fixture.baseDir,
manifestPath: `${fixture.baseDir}/SKILL.md`,
commandExamples: [],
};
const runtime = createChatToolRuntime([capability]);
const result = await runtime.run({
toolCallId: 'tool-call-generic-command-script',
toolName: 'script-entry-skill',
input: {
prompt: 'competitive review',
},
});
expect(result.execution.ok).toBe(true);
const executionRaw = result.execution.raw as {
command: string;
query: string;
};
expect(executionRaw.command).toContain('run.js competitive review');
expect(executionRaw.query).toBe('competitive review');
});
it('keeps spreadsheet execution isolated from non-spreadsheet skills', async () => {
const runtime = createChatToolRuntime([genericSearchCapability]);
const result = await runtime.run({
toolCallId: 'tool-call-2',
toolName: 'minimax-xlsx',
input: {
prompt: 'Analyze this workbook.',
skillKey: 'minimax-xlsx',
attachments: [
{
fileName: 'hotel-sales.xls',
filePath: 'F:\\Downloads\\hotel-sales.xls',
mimeType: 'application/vnd.ms-excel',
source: 'user-upload',
},
],
},
});
expect(result.preflight.ok).toBe(false);
expect(result.preflight.error).toEqual(expect.objectContaining({
code: 'missing_skill_runtime',
}));
});
it('surfaces missing env prerequisites for non-spreadsheet skills', async () => {
const runtime = createChatToolRuntime([genericSearchCapability]);
const result = await runtime.run({
toolCallId: 'tool-call-3',
toolName: 'minimax-search',
input: {
prompt: 'Search for hotel sales anomalies.',
},
});
expect(result.preflight.ok).toBe(false);
expect(result.preflight.error).toEqual(expect.objectContaining({
code: 'missing_required_env',
}));
expect(result.execution.summary).toContain('ZN_AI_TEST_GENERIC_SKILL_ENV');
});
it('returns a capability-aware blocked result for unsupported generic skill runtimes', async () => {
process.env.ZN_AI_TEST_GENERIC_SKILL_ENV = 'configured';
const runtime = createChatToolRuntime([
{
...genericSearchCapability,
requiresAuth: false,
requiredEnvVars: [],
},
]);
const result = await runtime.run({
toolCallId: 'tool-call-4',
toolName: 'minimax-search',
input: {
prompt: 'Search for hotel sales anomalies.',
},
});
expect(result.preflight.ok).toBe(false);
expect(result.preflight.error).toEqual(expect.objectContaining({
code: 'skill_runtime_not_implemented',
}));
expect(result.execution.summary).toContain('category=search');
expect(result.execution.summary).toContain('allowedTools=web.search');
});
});