- 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.
993 lines
31 KiB
TypeScript
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');
|
|
});
|
|
});
|