This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { ToolContext, ToolResult } from './core.js';
|
||||
|
||||
// The cache DB path is resolved from process.cwd() at module load, so stub
|
||||
// cwd BEFORE importing the module to keep the sqlite cache in a temp dir.
|
||||
const tmpRoot = mkdtempSync(join(tmpdir(), 'ms-learn-test-'));
|
||||
let executeTool: (n: string, i: Record<string, unknown>, c: ToolContext) => Promise<ToolResult | null>;
|
||||
let TOOL_DEFS: Record<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tmpRoot);
|
||||
const mod = await import('./ms-learn.js');
|
||||
executeTool = mod.executeTool;
|
||||
TOOL_DEFS = mod.TOOL_DEFS;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const ctx = {} as ToolContext;
|
||||
|
||||
function htmlResponse(body: string, status = 200): Response {
|
||||
return new Response(body, { status, headers: { 'content-type': 'text/html' } });
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const LEARN_HTML = `<!doctype html>
|
||||
<html><head>
|
||||
<title>Durable Functions overview | Microsoft Learn</title>
|
||||
</head><body>
|
||||
<nav>site nav chrome</nav>
|
||||
<main>
|
||||
<article>
|
||||
<h1>Durable Functions overview</h1>
|
||||
<p>Durable Functions is an & extension of Azure Functions.</p>
|
||||
<h2>Patterns</h2>
|
||||
<ul><li>Function chaining</li><li>Fan-out/fan-in</li></ul>
|
||||
<pre><code class="lang-csharp">var x = 1;</code></pre>
|
||||
<p>See <a href="https://learn.microsoft.com/en-us/azure/other">related docs</a>.</p>
|
||||
</article>
|
||||
</main>
|
||||
<footer>footer chrome</footer>
|
||||
</body></html>`;
|
||||
|
||||
describe('TOOL_DEFS', () => {
|
||||
it('registers the four Learn tools', () => {
|
||||
expect(Object.keys(TOOL_DEFS).sort()).toEqual([
|
||||
'FetchMicrosoftLearn',
|
||||
'RefreshMicrosoftLearnCache',
|
||||
'SearchMicrosoftLearn',
|
||||
'SearchMicrosoftLearnCache',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeTool', () => {
|
||||
it('returns null for unknown tool names', async () => {
|
||||
expect(await executeTool('NotALearnTool', {}, ctx)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FetchMicrosoftLearn', () => {
|
||||
it('requires a url', async () => {
|
||||
const res = await executeTool('FetchMicrosoftLearn', {}, ctx);
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('url is required');
|
||||
});
|
||||
|
||||
it('rejects URLs outside learn.microsoft.com', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const res = await executeTool('FetchMicrosoftLearn', { url: 'https://evil.example/docs' }, ctx);
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('not on learn.microsoft.com');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches a page, converts to markdown, and caches it', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse(LEARN_HTML)));
|
||||
const res = await executeTool(
|
||||
'FetchMicrosoftLearn',
|
||||
{ url: 'https://learn.microsoft.com/en-us/azure/durable?view=latest#anchor' },
|
||||
ctx,
|
||||
);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('Fetched and cached');
|
||||
// Title cleaned of the "| Microsoft Learn" suffix.
|
||||
expect(res?.output).toContain('# Durable Functions overview');
|
||||
// Entity decoding, headings, lists, fenced code, links survive.
|
||||
expect(res?.output).toContain('an & extension');
|
||||
expect(res?.output).toContain('## Patterns');
|
||||
expect(res?.output).toContain('- Function chaining');
|
||||
expect(res?.output).toContain('```csharp\nvar x = 1;\n```');
|
||||
expect(res?.output).toContain('[related docs](https://learn.microsoft.com/en-us/azure/other)');
|
||||
// Chrome outside <article> is dropped.
|
||||
expect(res?.output).not.toContain('site nav chrome');
|
||||
expect(res?.output).not.toContain('footer chrome');
|
||||
});
|
||||
|
||||
it('serves the cached copy (query/hash canonicalized away) without refetching', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const res = await executeTool(
|
||||
'FetchMicrosoftLearn',
|
||||
{ url: 'https://learn.microsoft.com/en-us/azure/durable?other=param' },
|
||||
ctx,
|
||||
);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('Cached (age=');
|
||||
expect(res?.output).toContain('Durable Functions overview');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports HTTP errors as tool errors', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse('nope', 404)));
|
||||
const res = await executeTool(
|
||||
'FetchMicrosoftLearn',
|
||||
{ url: 'https://learn.microsoft.com/en-us/azure/missing' },
|
||||
ctx,
|
||||
);
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('HTTP 404');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchMicrosoftLearnCache', () => {
|
||||
it('requires a query', async () => {
|
||||
const res = await executeTool('SearchMicrosoftLearnCache', {}, ctx);
|
||||
expect(res?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('finds previously cached pages via FTS', async () => {
|
||||
const res = await executeTool('SearchMicrosoftLearnCache', { query: 'durable functions' }, ctx);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('Cache hits');
|
||||
expect(res?.output).toContain('https://learn.microsoft.com/en-us/azure/durable');
|
||||
});
|
||||
|
||||
it('reports zero hits without error', async () => {
|
||||
const res = await executeTool('SearchMicrosoftLearnCache', { query: 'zzz-no-such-term' }, ctx);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('No cache hits');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RefreshMicrosoftLearnCache', () => {
|
||||
it('force-refetches and overwrites the cache', async () => {
|
||||
const updated = LEARN_HTML.replace('an & extension', 'a refreshed extension');
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse(updated)));
|
||||
const res = await executeTool(
|
||||
'RefreshMicrosoftLearnCache',
|
||||
{ url: 'https://learn.microsoft.com/en-us/azure/durable' },
|
||||
ctx,
|
||||
);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('Refreshed');
|
||||
|
||||
const cached = await executeTool(
|
||||
'FetchMicrosoftLearn',
|
||||
{ url: 'https://learn.microsoft.com/en-us/azure/durable' },
|
||||
ctx,
|
||||
);
|
||||
expect(cached?.output).toContain('a refreshed extension');
|
||||
});
|
||||
|
||||
it('surfaces refresh failures', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
|
||||
const res = await executeTool(
|
||||
'RefreshMicrosoftLearnCache',
|
||||
{ url: 'https://learn.microsoft.com/en-us/azure/durable' },
|
||||
ctx,
|
||||
);
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('socket hang up');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchMicrosoftLearn', () => {
|
||||
it('requires a query', async () => {
|
||||
const res = await executeTool('SearchMicrosoftLearn', {}, ctx);
|
||||
expect(res?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('merges online results with cache hits and clamps top to 25', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(async () =>
|
||||
jsonResponse({
|
||||
results: [
|
||||
{ title: 'Durable Functions overview', url: 'https://learn.microsoft.com/en-us/azure/durable', description: 'desc' },
|
||||
{ title: 'broken', description: 'no url, filtered out' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const res = await executeTool(
|
||||
'SearchMicrosoftLearn',
|
||||
{ query: 'durable functions', top: 100, products: ['azure'] },
|
||||
ctx,
|
||||
);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('## Online results (1)');
|
||||
// The cached page is flagged.
|
||||
expect(res?.output).toContain('[cached]');
|
||||
expect(res?.output).toContain('## Cache hits');
|
||||
const calledUrl = String(fetchMock.mock.calls[0]?.[0]);
|
||||
expect(calledUrl).toContain('%24top=25');
|
||||
expect(calledUrl).toContain('products=azure');
|
||||
});
|
||||
|
||||
it('falls back to cache-only results when the online API fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => jsonResponse({}, 503)));
|
||||
const res = await executeTool('SearchMicrosoftLearn', { query: 'durable functions' }, ctx);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('## Cache hits');
|
||||
expect(res?.output).toContain('online search failed');
|
||||
});
|
||||
|
||||
it('errors when online fails and the cache has nothing', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => jsonResponse({}, 503)));
|
||||
const res = await executeTool('SearchMicrosoftLearn', { query: 'zzz-no-such-term' }, ctx);
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('Search failed');
|
||||
});
|
||||
|
||||
it('reports no results without error when both sources are empty', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => jsonResponse({ results: [] })));
|
||||
const res = await executeTool('SearchMicrosoftLearn', { query: 'zzz-no-such-term' }, ctx);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('No results');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user