245 lines
9.0 KiB
TypeScript
245 lines
9.0 KiB
TypeScript
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');
|
|
});
|
|
});
|