This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ToolContext } from './core.js';
|
||||
import { executeTool, ensureKeepaGraphs, TOOL_DEFS } from './amazon.js';
|
||||
import type { AmazonProductData } from './structured-blocks.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const ctx = (toolsConfig: Record<string, unknown> = {}): ToolContext =>
|
||||
({ toolsConfig }) as unknown as ToolContext;
|
||||
|
||||
function productBlock(asin: string, title: string, opts: { price?: string; rating?: string; reviews?: string } = {}): string {
|
||||
return `
|
||||
<div data-asin="${asin}" data-component-type="s-search-result">
|
||||
<img class="s-image" src="https://m.media-amazon.com/images/${asin}.jpg" alt=""/>
|
||||
<h2 class="title"><a href="/dp/${asin}"><span>${title}</span></a></h2>
|
||||
${opts.price ? `<span class="a-price"><span class="a-offscreen">${opts.price}</span></span>` : ''}
|
||||
${opts.rating ? `<span class="a-icon-alt">5つ星のうち${opts.rating}</span>` : ''}
|
||||
${opts.reviews ? `<span aria-label="${opts.reviews}件の評価">${opts.reviews}</span>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const SEARCH_HTML = `<html><body>
|
||||
${productBlock('B012345678', 'ワイヤレスイヤホン & ケース', { price: '¥12,980', rating: '4.5', reviews: '1,234' })}
|
||||
${productBlock('B087654321', 'モバイルバッテリー')}
|
||||
</body></html>`;
|
||||
|
||||
function htmlResponse(body: string, status = 200): Response {
|
||||
return new Response(body, { status, headers: { 'content-type': 'text/html' } });
|
||||
}
|
||||
|
||||
describe('TOOL_DEFS', () => {
|
||||
it('registers SearchAmazon with query required', () => {
|
||||
expect(TOOL_DEFS['SearchAmazon']?.function.name).toBe('SearchAmazon');
|
||||
expect(TOOL_DEFS['SearchAmazon']?.function.parameters?.['required']).toEqual(['query']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchAmazon', () => {
|
||||
it('returns null for unknown tool names', async () => {
|
||||
expect(await executeTool('NotAmazon', {}, ctx())).toBeNull();
|
||||
});
|
||||
|
||||
it('requires a query', async () => {
|
||||
const res = await executeTool('SearchAmazon', {}, ctx());
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('query is required');
|
||||
});
|
||||
|
||||
it('parses products into markdown with Keepa graphs and structured blocks', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse(SEARCH_HTML)));
|
||||
const res = await executeTool('SearchAmazon', { query: 'イヤホン' }, ctx());
|
||||
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('## Amazon.co.jp 検索結果: 「イヤホン」');
|
||||
// Entity-decoded title, price, rating with review count.
|
||||
expect(res?.output).toContain('ワイヤレスイヤホン & ケース');
|
||||
expect(res?.output).toContain('- **価格**: ¥12,980');
|
||||
expect(res?.output).toContain('- **評価**: 4.5 (1,234件)');
|
||||
expect(res?.output).toContain('');
|
||||
expect(res?.output).toContain('graph.keepa.com/pricehistory.png?asin=B012345678&domain=co.jp');
|
||||
expect(res?.output).toContain('https://www.amazon.co.jp/dp/B012345678');
|
||||
expect(res?.output).toMatch(/\[\[embed:amazon-\d+\]\]/);
|
||||
|
||||
const blocks = res?.structuredBlocks ?? [];
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]?.type).toBe('amazon_products');
|
||||
const data = blocks[0]?.data as AmazonProductData;
|
||||
expect(data.products).toHaveLength(2);
|
||||
expect(data.products[0]).toMatchObject({
|
||||
asin: 'B012345678',
|
||||
rating: 4.5,
|
||||
reviewCount: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses an integer rating from 5つ星のうち5', async () => {
|
||||
const html = `<html><body>${productBlock('B099999999', '満点商品', { rating: '5', reviews: '10' })}</body></html>`;
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse(html)));
|
||||
const res = await executeTool('SearchAmazon', { query: 'x' }, ctx());
|
||||
expect(res?.output).toContain('- **評価**: 5 (10件)');
|
||||
});
|
||||
|
||||
it('appends the affiliate tag from tools config to product URLs', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse(SEARCH_HTML)));
|
||||
const res = await executeTool(
|
||||
'SearchAmazon',
|
||||
{ query: 'イヤホン' },
|
||||
ctx({ amazonAffiliateTag: 'mytag-22' }),
|
||||
);
|
||||
expect(res?.output).toContain('https://www.amazon.co.jp/dp/B012345678?tag=mytag-22');
|
||||
});
|
||||
|
||||
it('caps max_results', async () => {
|
||||
const many = `<html><body>${Array.from({ length: 12 }, (_, i) =>
|
||||
productBlock(`B0000000${String(i).padStart(2, '0')}`.slice(0, 10), `商品${i}`),
|
||||
).join('\n')}</body></html>`;
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse(many)));
|
||||
const res = await executeTool('SearchAmazon', { query: 'x', max_results: 3 }, ctx());
|
||||
const data = (res?.structuredBlocks?.[0]?.data ?? { products: [] }) as AmazonProductData;
|
||||
expect(data.products).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('suggests BrowseWeb when parsing finds nothing (likely blocked)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse('<html>captcha</html>')));
|
||||
const res = await executeTool('SearchAmazon', { query: 'イヤホン' }, ctx());
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('BrowseWeb');
|
||||
expect(res?.output).toContain('取得できませんでした');
|
||||
});
|
||||
|
||||
it('reports HTTP failures as tool errors with a fallback hint', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => htmlResponse('nope', 503)));
|
||||
const res = await executeTool('SearchAmazon', { query: 'イヤホン' }, ctx());
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('Amazon 検索に失敗しました');
|
||||
expect(res?.output).toContain('BrowseWeb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureKeepaGraphs', () => {
|
||||
it('appends missing Keepa graphs for ASINs referenced in the text', () => {
|
||||
const text = 'おすすめ: https://www.amazon.co.jp/dp/B012345678 です。';
|
||||
const out = ensureKeepaGraphs(text);
|
||||
expect(out).toContain('### 価格推移 (Keepa)');
|
||||
expect(out).toContain('pricehistory.png?asin=B012345678');
|
||||
});
|
||||
|
||||
it('leaves text alone when graphs are already present', () => {
|
||||
const text = [
|
||||
'https://www.amazon.co.jp/dp/B012345678',
|
||||
'',
|
||||
].join('\n');
|
||||
expect(ensureKeepaGraphs(text)).toBe(text);
|
||||
});
|
||||
|
||||
it('leaves text without ASINs untouched', () => {
|
||||
expect(ensureKeepaGraphs('no products here')).toBe('no products here');
|
||||
});
|
||||
|
||||
it('only appends graphs for the ASINs that are missing', () => {
|
||||
const text = [
|
||||
'https://www.amazon.co.jp/dp/B012345678',
|
||||
'https://www.amazon.co.jp/dp/B087654321',
|
||||
'',
|
||||
].join('\n');
|
||||
const out = ensureKeepaGraphs(text);
|
||||
expect(out.match(/pricehistory\.png\?asin=B087654321/g)).toHaveLength(1);
|
||||
expect(out.match(/pricehistory\.png\?asin=B012345678/g)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -85,8 +85,9 @@ function parseProducts(html: string, maxResults: number): AmazonProduct[] {
|
||||
}
|
||||
|
||||
// Extract rating: <span class="a-icon-alt">5つ星のうち4.5</span>
|
||||
const ratingMatch = block.match(/<span class="a-icon-alt">([\d.]+つ星のうち[\d.]+)<\/span>/i)
|
||||
|| block.match(/(\d+(?:\.\d+)?)\s*つ星のうち/i);
|
||||
// The actual rating is the number AFTER のうち; the one before is the scale.
|
||||
const ratingMatch = block.match(/<span class="a-icon-alt">[\d.]+つ星のうち([\d.]+)<\/span>/i)
|
||||
|| block.match(/つ星のうち\s*(\d+(?:\.\d+)?)/i);
|
||||
if (ratingMatch) {
|
||||
const rVal = ratingMatch[1].match(/(\d+(?:\.\d+)?)/);
|
||||
if (rVal) product.rating = rVal[1];
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { executeTool, TOOL_DEFS } from './data.js';
|
||||
import type { ToolContext } from './core.js';
|
||||
|
||||
function makeWorkspace(): string {
|
||||
return fs.mkdtempSync(path.join(tmpdir(), 'maestro-data-'));
|
||||
}
|
||||
|
||||
function makeContext(workspacePath: string, editAllowed = false): ToolContext {
|
||||
return { workspacePath, editAllowed };
|
||||
}
|
||||
|
||||
/** Create a fixture DB with a users table inside the workspace. */
|
||||
function createFixtureDb(workspacePath: string, fileName = 'fixture.db'): string {
|
||||
const dbPath = path.join(workspacePath, fileName);
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER);
|
||||
INSERT INTO users (name, age) VALUES ('alice', 30), ('bob', 25), ('carol', 35);
|
||||
`);
|
||||
db.close();
|
||||
return fileName;
|
||||
}
|
||||
|
||||
describe('SQLite tool', () => {
|
||||
let workspacePath = '';
|
||||
|
||||
afterEach(() => {
|
||||
if (workspacePath) {
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes only the SQLite tool def', () => {
|
||||
expect(Object.keys(TOOL_DEFS)).toEqual(['SQLite']);
|
||||
expect(TOOL_DEFS['SQLite']!.function.name).toBe('SQLite');
|
||||
});
|
||||
|
||||
it('returns null for unknown tool names', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const result = await executeTool('NotATool', { query: 'SELECT 1' }, makeContext(workspacePath));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe('SELECT happy path', () => {
|
||||
it('returns a formatted table for a SELECT in read-only mode', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'SELECT name, age FROM users ORDER BY age', db_path: dbFile },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('name');
|
||||
expect(result?.output).toContain('age');
|
||||
expect(result?.output).toContain('bob');
|
||||
expect(result?.output).toContain('alice');
|
||||
expect(result?.output).toContain('carol');
|
||||
expect(result?.output).toContain('(3 rows)');
|
||||
});
|
||||
|
||||
it('formats an empty result set as (0 rows)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: "SELECT * FROM users WHERE name = 'nobody'", db_path: dbFile },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toBe('(0 rows)');
|
||||
});
|
||||
|
||||
it('uses singular "row" for a single-row result', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: "SELECT name FROM users WHERE name = 'alice'", db_path: dbFile },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('(1 row)');
|
||||
});
|
||||
|
||||
it('renders NULL values as NULL', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
const db = new Database(path.join(workspacePath, dbFile));
|
||||
db.exec("INSERT INTO users (name, age) VALUES ('dave', NULL)");
|
||||
db.close();
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: "SELECT age FROM users WHERE name = 'dave'", db_path: dbFile },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('NULL');
|
||||
});
|
||||
|
||||
it('executes multiple SELECT statements and joins their outputs', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{
|
||||
query: "SELECT name FROM users WHERE name = 'alice'; SELECT name FROM users WHERE name = 'bob';",
|
||||
db_path: dbFile,
|
||||
},
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('alice');
|
||||
expect(result?.output).toContain('bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('write guards (editAllowed=false)', () => {
|
||||
it.each(['INSERT', 'UPDATE', 'DELETE', 'REPLACE'])('rejects %s in read-only mode', async (kw) => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
const queries: Record<string, string> = {
|
||||
INSERT: "INSERT INTO users (name, age) VALUES ('mallory', 1)",
|
||||
UPDATE: "UPDATE users SET age = 99 WHERE name = 'alice'",
|
||||
DELETE: 'DELETE FROM users',
|
||||
REPLACE: "REPLACE INTO users (id, name, age) VALUES (1, 'evil', 0)",
|
||||
};
|
||||
|
||||
const result = await executeTool('SQLite', { query: queries[kw]!, db_path: dbFile }, makeContext(workspacePath, false));
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Only SELECT queries are allowed');
|
||||
});
|
||||
|
||||
it('rejects a write smuggled after a SELECT in a multi-statement query', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'SELECT * FROM users; DELETE FROM users', db_path: dbFile },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
// Guard fires before anything executes; data must be intact
|
||||
const db = new Database(path.join(workspacePath, dbFile), { readonly: true });
|
||||
const count = db.prepare('SELECT COUNT(*) AS c FROM users').get() as { c: number };
|
||||
db.close();
|
||||
expect(count.c).toBe(3);
|
||||
});
|
||||
|
||||
it('rejects CREATE TABLE in read-only mode', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'CREATE TABLE extra (id INTEGER)', db_path: dbFile },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Only SELECT queries are allowed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writes allowed (editAllowed=true)', () => {
|
||||
it('supports CREATE TABLE + INSERT + SELECT in one call', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{
|
||||
query: "CREATE TABLE t (v TEXT); INSERT INTO t (v) VALUES ('x'), ('y'); SELECT COUNT(*) AS c FROM t",
|
||||
db_path: 'new.db',
|
||||
},
|
||||
makeContext(workspacePath, true),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('OK');
|
||||
expect(result?.output).toContain('2 row(s) affected');
|
||||
expect(result?.output).toContain('2');
|
||||
expect(fs.existsSync(path.join(workspacePath, 'new.db'))).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults db_path to temp.db inside the workspace', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'CREATE TABLE t (v TEXT)' },
|
||||
makeContext(workspacePath, true),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(fs.existsSync(path.join(workspacePath, 'temp.db'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports affected row count for UPDATE', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'UPDATE users SET age = age + 1', db_path: dbFile },
|
||||
makeContext(workspacePath, true),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('3 row(s) affected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('always-blocked DDL', () => {
|
||||
it.each([
|
||||
['DROP TABLE users', 'DROP'],
|
||||
['ALTER TABLE users ADD COLUMN extra TEXT', 'ALTER'],
|
||||
["ATTACH DATABASE '/etc/passwd' AS evil", 'ATTACH'],
|
||||
['DETACH DATABASE evil', 'DETACH'],
|
||||
['REINDEX', 'REINDEX'],
|
||||
['VACUUM', 'VACUUM'],
|
||||
])('blocks "%s" even with editAllowed=true', async (query, kw) => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool('SQLite', { query, db_path: dbFile }, makeContext(workspacePath, true));
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Forbidden SQL');
|
||||
expect(result?.output).toContain(kw);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'CREATE INDEX idx ON users (name)',
|
||||
'CREATE UNIQUE INDEX idx ON users (name)',
|
||||
'CREATE TRIGGER trg AFTER INSERT ON users BEGIN SELECT 1; END',
|
||||
])('blocks "%s"', async (query) => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool('SQLite', { query, db_path: dbFile }, makeContext(workspacePath, true));
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Forbidden SQL');
|
||||
});
|
||||
|
||||
it('is case-insensitive for blocked keywords', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'drop table users', db_path: dbFile },
|
||||
makeContext(workspacePath, true),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Forbidden SQL');
|
||||
});
|
||||
|
||||
it('blocks DDL hidden behind a SELECT in a multi-statement query', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'SELECT 1; DROP TABLE users', db_path: dbFile },
|
||||
makeContext(workspacePath, true),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Forbidden SQL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PRAGMA handling', () => {
|
||||
it('allows PRAGMA table_info when edit is enabled', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'PRAGMA table_info(users)', db_path: dbFile },
|
||||
makeContext(workspacePath, true),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('name');
|
||||
expect(result?.output).toContain('age');
|
||||
});
|
||||
|
||||
it('blocks PRAGMA other than table_info/table_list', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'PRAGMA journal_mode = WAL', db_path: dbFile },
|
||||
makeContext(workspacePath, true),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('PRAGMA');
|
||||
expect(result?.output).toContain('not allowed');
|
||||
});
|
||||
|
||||
// Documents current behavior: PRAGMA table_info survives the DDL block list
|
||||
// but the read-only gate only whitelists SELECT, so it is rejected when
|
||||
// editAllowed=false. Schema inspection therefore requires edit mode.
|
||||
it('rejects PRAGMA table_info in read-only mode (current behavior)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'PRAGMA table_info(users)', db_path: dbFile },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Only SELECT queries are allowed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors', () => {
|
||||
it('rejects an empty query', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
|
||||
const result = await executeTool('SQLite', { query: ' ;; ' }, makeContext(workspacePath, true));
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toBe('Empty query');
|
||||
});
|
||||
|
||||
it('returns a Query error for SQL referencing a missing table', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'SELECT * FROM no_such_table', db_path: dbFile },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Query error');
|
||||
expect(result?.output).toContain('no_such_table');
|
||||
});
|
||||
|
||||
it('returns a syntax error as Query error', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbFile = createFixtureDb(workspacePath);
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'SELECT FROM WHERE', db_path: dbFile },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Query error');
|
||||
});
|
||||
|
||||
it('fails to open a missing DB file in read-only mode', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'SELECT 1', db_path: 'does-not-exist.db' },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Failed to open database');
|
||||
});
|
||||
|
||||
it('returns a Query error when the file is not a SQLite database', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.writeFileSync(path.join(workspacePath, 'not-a-db.db'), 'this is plain text, not sqlite');
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'SELECT 1', db_path: 'not-a-db.db' },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('path traversal guard', () => {
|
||||
it.each([
|
||||
'../outside.db',
|
||||
'../../etc/evil.db',
|
||||
'sub/../../escape.db',
|
||||
])('rejects db_path "%s" outside the workspace', async (dbPath) => {
|
||||
workspacePath = makeWorkspace();
|
||||
|
||||
const result = await executeTool('SQLite', { query: 'SELECT 1', db_path: dbPath }, makeContext(workspacePath, true));
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Path traversal');
|
||||
});
|
||||
|
||||
it('rejects an absolute db_path outside the workspace', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'SELECT 1', db_path: '/tmp/evil-absolute.db' },
|
||||
makeContext(workspacePath, true),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Path traversal');
|
||||
});
|
||||
|
||||
it('allows a relative path inside a workspace subdirectory', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
|
||||
createFixtureDb(workspacePath, path.join('output', 'nested.db'));
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'SELECT COUNT(*) AS c FROM users', db_path: 'output/nested.db' },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('large result sets', () => {
|
||||
// Documents current behavior: the tool has no output truncation, so a
|
||||
// large SELECT returns every row verbatim.
|
||||
it('returns all rows of a large SELECT (no truncation in the tool itself)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const dbPath = path.join(workspacePath, 'big.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec('CREATE TABLE big (id INTEGER PRIMARY KEY, payload TEXT)');
|
||||
const insert = db.prepare('INSERT INTO big (payload) VALUES (?)');
|
||||
const tx = db.transaction(() => {
|
||||
for (let i = 0; i < 2000; i++) insert.run(`row-payload-${i}-${'x'.repeat(50)}`);
|
||||
});
|
||||
tx();
|
||||
db.close();
|
||||
|
||||
const result = await executeTool(
|
||||
'SQLite',
|
||||
{ query: 'SELECT * FROM big', db_path: 'big.db' },
|
||||
makeContext(workspacePath, false),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('(2000 rows)');
|
||||
expect(result?.output).toContain('row-payload-0-');
|
||||
expect(result?.output).toContain('row-payload-1999-');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { executeTool } from './maps.js';
|
||||
import type { ToolContext } from './core.js';
|
||||
|
||||
function makeWorkspace(): string {
|
||||
return fs.mkdtempSync(path.join(tmpdir(), 'maestro-maps-'));
|
||||
}
|
||||
|
||||
function makeContext(workspacePath: string, opts: { apiKey?: string } = {}): ToolContext {
|
||||
return {
|
||||
workspacePath,
|
||||
editAllowed: false,
|
||||
toolsConfig: {
|
||||
...(opts.apiKey ? { googleMapsApiKey: opts.apiKey } : {}),
|
||||
mapsTimeout: 5,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const NOMINATIM_PLACE = {
|
||||
place_id: 1,
|
||||
lat: '35.658584',
|
||||
lon: '139.745433',
|
||||
display_name: '東京タワー, 4, 芝公園, 港区, 東京都, 105-0011, 日本',
|
||||
address: { country: '日本' },
|
||||
type: 'attraction',
|
||||
class: 'tourism',
|
||||
importance: 0.7,
|
||||
};
|
||||
|
||||
const OSRM_ROUTE = {
|
||||
code: 'Ok',
|
||||
routes: [
|
||||
{
|
||||
distance: 3456.7,
|
||||
duration: 754,
|
||||
legs: [
|
||||
{
|
||||
steps: [
|
||||
{ maneuver: { type: 'depart' }, distance: 100.4, duration: 30, name: '桜田通り' },
|
||||
{ maneuver: { type: 'turn', modifier: 'left' }, distance: 200, duration: 60, name: '' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('maps tools', () => {
|
||||
let workspacePath = '';
|
||||
|
||||
afterEach(() => {
|
||||
if (workspacePath) {
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns null for unknown tool name', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const result = await executeTool('NotAMapsTool', {}, makeContext(workspacePath));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// SearchPlaces
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('SearchPlaces', () => {
|
||||
it('rejects missing query', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const result = await executeTool('SearchPlaces', {}, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('query は必須です');
|
||||
});
|
||||
|
||||
it('rejects whitespace-only query', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const result = await executeTool('SearchPlaces', { query: ' ' }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('searches via Nominatim when no API key is configured', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([NOMINATIM_PLACE]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await executeTool('SearchPlaces', { query: '東京タワー' }, makeContext(workspacePath));
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('## 地図検索結果: 東京タワー');
|
||||
expect(result?.output).toContain('1件の場所が見つかりました');
|
||||
expect(result?.output).toContain('### 1. 東京タワー');
|
||||
expect(result?.output).toContain('**座標**: 35.658584, 139.745433');
|
||||
expect(result?.output).toContain('**種別**: attraction');
|
||||
expect(result?.output).toContain('https://www.openstreetmap.org/?mlat=35.658584&mlon=139.745433&zoom=17');
|
||||
// embed marker + structured block
|
||||
expect(result?.output).toMatch(/\[\[embed:map-\d+\]\]/);
|
||||
expect(result?.structuredBlocks).toHaveLength(1);
|
||||
const block = result?.structuredBlocks?.[0];
|
||||
expect(block?.type).toBe('map_places');
|
||||
expect(block?.data).toMatchObject({
|
||||
query: '東京タワー',
|
||||
places: [
|
||||
expect.objectContaining({
|
||||
name: '東京タワー',
|
||||
lat: 35.658584,
|
||||
lon: 139.745433,
|
||||
type: 'attraction',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const url = String(fetchMock.mock.calls[0]?.[0]);
|
||||
expect(url).toContain('nominatim.openstreetmap.org/search');
|
||||
expect(url).toContain('limit=5'); // default limit
|
||||
expect(url).toContain('accept-language=ja'); // default lang
|
||||
});
|
||||
|
||||
it('clamps limit to 1..20 and passes lang through', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([NOMINATIM_PLACE]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await executeTool('SearchPlaces', { query: 'tower', limit: 50, lang: 'en' }, makeContext(workspacePath));
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toContain('limit=20');
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toContain('accept-language=en');
|
||||
|
||||
await executeTool('SearchPlaces', { query: 'tower', limit: 0 }, makeContext(workspacePath));
|
||||
expect(String(fetchMock.mock.calls[1]?.[0])).toContain('limit=1');
|
||||
});
|
||||
|
||||
it('returns error on Nominatim HTTP failure', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, 500)));
|
||||
|
||||
const result = await executeTool('SearchPlaces', { query: 'tower' }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Nominatim API エラー: HTTP 500');
|
||||
});
|
||||
|
||||
it('returns non-error message when no places match', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse([])));
|
||||
|
||||
const result = await executeTool('SearchPlaces', { query: 'nowhere-xyz' }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('「nowhere-xyz」に一致する場所が見つかりませんでした');
|
||||
});
|
||||
|
||||
it('returns error when fetch throws', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
|
||||
|
||||
const result = await executeTool('SearchPlaces', { query: 'tower' }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('地図検索エラー: network down');
|
||||
});
|
||||
|
||||
it('uses Google Places API when API key is configured', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
|
||||
status: 'OK',
|
||||
results: [
|
||||
{
|
||||
name: 'Tokyo Tower',
|
||||
formatted_address: '4 Chome-2-8 Shibakoen, Minato City',
|
||||
geometry: { location: { lat: 35.6586, lng: 139.7454 } },
|
||||
types: ['tourist_attraction'],
|
||||
rating: 4.5,
|
||||
opening_hours: { open_now: true },
|
||||
},
|
||||
],
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await executeTool(
|
||||
'SearchPlaces',
|
||||
{ query: 'Tokyo Tower' },
|
||||
makeContext(workspacePath, { apiKey: 'test-google-key' }),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('### 1. Tokyo Tower');
|
||||
expect(result?.output).toContain('**詳細**: 評価: 4.5, 営業中');
|
||||
expect(result?.output).toContain('**種別**: tourist_attraction');
|
||||
// only the Google endpoint was hit; no Nominatim fallback
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const url = String(fetchMock.mock.calls[0]?.[0]);
|
||||
expect(url).toContain('maps.googleapis.com/maps/api/place/textsearch/json');
|
||||
expect(url).toContain('key=test-google-key');
|
||||
});
|
||||
|
||||
it('falls back to Nominatim when Google returns no results', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ status: 'ZERO_RESULTS', results: [] }))
|
||||
.mockResolvedValueOnce(jsonResponse([NOMINATIM_PLACE]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await executeTool(
|
||||
'SearchPlaces',
|
||||
{ query: '東京タワー' },
|
||||
makeContext(workspacePath, { apiKey: 'test-google-key' }),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('### 1. 東京タワー');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(String(fetchMock.mock.calls[1]?.[0])).toContain('nominatim.openstreetmap.org');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// GetDirections
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('GetDirections', () => {
|
||||
it('rejects missing origin / destination', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const ctx = makeContext(workspacePath);
|
||||
const noOrigin = await executeTool('GetDirections', { destination: 'B' }, ctx);
|
||||
expect(noOrigin?.isError).toBe(true);
|
||||
expect(noOrigin?.output).toContain('origin は必須です');
|
||||
|
||||
const noDest = await executeTool('GetDirections', { origin: 'A' }, ctx);
|
||||
expect(noDest?.isError).toBe(true);
|
||||
expect(noDest?.output).toContain('destination は必須です');
|
||||
});
|
||||
|
||||
it('routes via OSRM with "lat,lon" inputs (no geocoding round-trip)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(OSRM_ROUTE));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await executeTool(
|
||||
'GetDirections',
|
||||
{ origin: '35.6586,139.7454', destination: '35.681236, 139.767125' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('## 経路情報');
|
||||
expect(result?.output).toContain('**移動手段**: 車');
|
||||
expect(result?.output).toContain('**距離**: 3.5 km');
|
||||
expect(result?.output).toContain('**所要時間**: 13 分');
|
||||
expect(result?.output).toContain('### 経路ステップ');
|
||||
expect(result?.output).toContain('1. depart (桜田通り) — 100m');
|
||||
expect(result?.output).toContain('2. turn left — 200m');
|
||||
|
||||
// single OSRM call, lat/lon parsed directly (lon,lat order in URL)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const url = String(fetchMock.mock.calls[0]?.[0]);
|
||||
expect(url).toContain('router.project-osrm.org/route/v1/car/');
|
||||
expect(url).toContain('139.7454,35.6586;139.767125,35.681236');
|
||||
});
|
||||
|
||||
it('maps walking/cycling modes to OSRM profiles', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fetchMock = vi.fn().mockImplementation(async () => jsonResponse(OSRM_ROUTE));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const ctx = makeContext(workspacePath);
|
||||
|
||||
const walking = await executeTool(
|
||||
'GetDirections',
|
||||
{ origin: '1,2', destination: '3,4', mode: 'walking' },
|
||||
ctx,
|
||||
);
|
||||
expect(walking?.output).toContain('**移動手段**: 徒歩');
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/route/v1/foot/');
|
||||
|
||||
const cycling = await executeTool(
|
||||
'GetDirections',
|
||||
{ origin: '1,2', destination: '3,4', mode: 'cycling' },
|
||||
ctx,
|
||||
);
|
||||
expect(cycling?.output).toContain('**移動手段**: 自転車');
|
||||
expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/route/v1/bike/');
|
||||
});
|
||||
|
||||
it('geocodes address inputs through Nominatim before calling OSRM', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fetchMock = vi.fn().mockImplementation(async (rawUrl: string | URL) => {
|
||||
const url = String(rawUrl);
|
||||
if (url.includes('nominatim.openstreetmap.org/search')) {
|
||||
if (url.includes(encodeURIComponent('東京駅'))) {
|
||||
return jsonResponse([{ ...NOMINATIM_PLACE, lat: '35.681236', lon: '139.767125' }]);
|
||||
}
|
||||
return jsonResponse([NOMINATIM_PLACE]);
|
||||
}
|
||||
return jsonResponse(OSRM_ROUTE);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await executeTool(
|
||||
'GetDirections',
|
||||
{ origin: '東京タワー', destination: '東京駅' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('**出発地**: 東京タワー');
|
||||
expect(result?.output).toContain('**目的地**: 東京駅');
|
||||
// 2 geocode calls + 1 OSRM call
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
const osrmCall = fetchMock.mock.calls.map((c) => String(c[0])).find((u) => u.includes('router.project-osrm.org'));
|
||||
expect(osrmCall).toContain('139.745433,35.658584;139.767125,35.681236');
|
||||
});
|
||||
|
||||
it('returns error when origin cannot be geocoded', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse([])));
|
||||
|
||||
const result = await executeTool(
|
||||
'GetDirections',
|
||||
{ origin: '存在しない場所xyz', destination: '1,2' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('出発地「存在しない場所xyz」の座標を取得できませんでした');
|
||||
});
|
||||
|
||||
it('returns error on OSRM HTTP failure', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, 502)));
|
||||
|
||||
const result = await executeTool(
|
||||
'GetDirections',
|
||||
{ origin: '1,2', destination: '3,4' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('OSRM 経路取得エラー: HTTP 502');
|
||||
});
|
||||
|
||||
it('returns non-error message when OSRM finds no route', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ code: 'NoRoute', routes: [] })));
|
||||
|
||||
const result = await executeTool(
|
||||
'GetDirections',
|
||||
{ origin: '1,2', destination: '3,4' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('経路が見つかりませんでした');
|
||||
});
|
||||
|
||||
it('writes a Leaflet HTML file when output_html=true', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(OSRM_ROUTE)));
|
||||
|
||||
const result = await executeTool(
|
||||
'GetDirections',
|
||||
{ origin: '1,2', destination: '3,4', output_html: true, filename: 'my_route' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
// .html is appended when missing
|
||||
expect(result?.output).toContain('output/maps/my_route.html');
|
||||
const filePath = path.join(workspacePath, 'output', 'maps', 'my_route.html');
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
const html = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(html).toContain('leaflet');
|
||||
expect(html).toContain('3.5 km');
|
||||
expect(html).toContain('13 分');
|
||||
});
|
||||
|
||||
it('escapes HTML in generated route file names', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fetchMock = vi.fn().mockImplementation(async (rawUrl: string | URL) => {
|
||||
const url = String(rawUrl);
|
||||
if (url.includes('nominatim')) return jsonResponse([NOMINATIM_PLACE]);
|
||||
return jsonResponse(OSRM_ROUTE);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await executeTool(
|
||||
'GetDirections',
|
||||
{ origin: '<script>x</script>', destination: '1,2', output_html: true, filename: 'esc.html' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
const html = fs.readFileSync(path.join(workspacePath, 'output', 'maps', 'esc.html'), 'utf-8');
|
||||
expect(html).not.toContain('<script>x</script>');
|
||||
expect(html).toContain('<script>x</script>');
|
||||
});
|
||||
|
||||
it('uses Google Directions API when API key is configured', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
|
||||
status: 'OK',
|
||||
routes: [
|
||||
{
|
||||
legs: [
|
||||
{
|
||||
distance: { text: '3.4 km' },
|
||||
duration: { text: '12分' },
|
||||
steps: [
|
||||
{ html_instructions: '<b>南</b>に進む', distance: { text: '0.2 km' } },
|
||||
],
|
||||
start_address: '日本、東京都港区芝公園',
|
||||
end_address: '日本、東京都千代田区丸の内',
|
||||
start_location: { lat: 35.6586, lng: 139.7454 },
|
||||
end_location: { lat: 35.6812, lng: 139.7671 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await executeTool(
|
||||
'GetDirections',
|
||||
{ origin: '東京タワー', destination: '東京駅' },
|
||||
makeContext(workspacePath, { apiKey: 'test-google-key' }),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('**距離**: 3.4 km');
|
||||
expect(result?.output).toContain('**所要時間**: 12分');
|
||||
expect(result?.output).toContain('**出発地**: 日本、東京都港区芝公園');
|
||||
// HTML tags stripped from step instructions
|
||||
expect(result?.output).toContain('南に進む (0.2 km)');
|
||||
expect(result?.output).not.toContain('<b>');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toContain('maps.googleapis.com/maps/api/directions/json');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ReverseGeocode
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('ReverseGeocode', () => {
|
||||
it('rejects non-numeric coordinates', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const result = await executeTool('ReverseGeocode', { lat: 'abc', lon: 139 }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('lat と lon は数値で指定してください');
|
||||
});
|
||||
|
||||
it('rejects out-of-range coordinates', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const ctx = makeContext(workspacePath);
|
||||
const badLat = await executeTool('ReverseGeocode', { lat: 91, lon: 0 }, ctx);
|
||||
expect(badLat?.isError).toBe(true);
|
||||
expect(badLat?.output).toContain('座標の範囲が無効です');
|
||||
|
||||
const badLon = await executeTool('ReverseGeocode', { lat: 0, lon: -181 }, ctx);
|
||||
expect(badLon?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('returns formatted address components on success', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
|
||||
place_id: 1,
|
||||
lat: '35.658584',
|
||||
lon: '139.745433',
|
||||
display_name: '東京タワー, 4, 芝公園, 港区, 東京都, 105-0011, 日本',
|
||||
address: {
|
||||
amenity: '東京タワー',
|
||||
house_number: '4',
|
||||
road: '都道301号',
|
||||
suburb: '芝公園',
|
||||
city: '港区',
|
||||
state: '東京都',
|
||||
postcode: '105-0011',
|
||||
country: '日本',
|
||||
country_code: 'jp',
|
||||
},
|
||||
boundingbox: ['35.657', '35.660', '139.744', '139.747'],
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await executeTool(
|
||||
'ReverseGeocode',
|
||||
{ lat: 35.658584, lon: 139.745433 },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('## 逆ジオコーディング結果');
|
||||
expect(result?.output).toContain('**座標**: 35.658584, 139.745433');
|
||||
expect(result?.output).toContain('**住所(全体)**: 東京タワー, 4, 芝公園, 港区, 東京都, 105-0011, 日本');
|
||||
expect(result?.output).toContain('- **郵便番号**: 105-0011');
|
||||
expect(result?.output).toContain('- **国**: 日本');
|
||||
expect(result?.output).toContain('- **都道府県**: 東京都');
|
||||
expect(result?.output).toContain('- **市区町村**: 港区');
|
||||
expect(result?.output).toContain('- **地区**: 芝公園');
|
||||
expect(result?.output).toContain('- **道路/通り**: 都道301号');
|
||||
expect(result?.output).toContain('- **番地**: 4');
|
||||
expect(result?.output).toContain('- **施設**: 東京タワー');
|
||||
expect(result?.output).toContain('https://www.openstreetmap.org/?mlat=35.658584&mlon=139.745433&zoom=17');
|
||||
|
||||
const url = String(fetchMock.mock.calls[0]?.[0]);
|
||||
expect(url).toContain('nominatim.openstreetmap.org/reverse');
|
||||
expect(url).toContain('lat=35.658584');
|
||||
expect(url).toContain('lon=139.745433');
|
||||
expect(url).toContain('accept-language=ja');
|
||||
});
|
||||
|
||||
it('accepts numeric strings for lat/lon', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
|
||||
place_id: 1,
|
||||
lat: '35.0',
|
||||
lon: '139.0',
|
||||
display_name: 'どこか',
|
||||
address: {},
|
||||
boundingbox: [],
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await executeTool(
|
||||
'ReverseGeocode',
|
||||
{ lat: '35.0', lon: '139.0' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('**住所(全体)**: どこか');
|
||||
});
|
||||
|
||||
it('returns error on Nominatim HTTP failure', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, 404)));
|
||||
|
||||
const result = await executeTool('ReverseGeocode', { lat: 35, lon: 139 }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('Nominatim API エラー: HTTP 404');
|
||||
});
|
||||
|
||||
it('returns non-error message when address is not found', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ error: 'Unable to geocode' })));
|
||||
|
||||
const result = await executeTool('ReverseGeocode', { lat: 0, lon: 0 }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('座標 (0, 0) の住所が見つかりませんでした');
|
||||
});
|
||||
|
||||
it('returns error when fetch throws', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));
|
||||
|
||||
const result = await executeTool('ReverseGeocode', { lat: 35, lon: 139 }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('逆ジオコーディングエラー: boom');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import type { ToolContext } from './core.js';
|
||||
import { TOOL_DEFS, executeTool } from './orchestration.js';
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'orchestration-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const makeCtx = (overrides: Partial<ToolContext> = {}): ToolContext =>
|
||||
({
|
||||
workspacePath: tmp,
|
||||
spawnSubTask: vi.fn().mockResolvedValue({
|
||||
subtaskIndex: 1,
|
||||
jobId: 42,
|
||||
workspacePath: join(tmp, 'subtasks', '1'),
|
||||
}),
|
||||
...overrides,
|
||||
}) as unknown as ToolContext;
|
||||
|
||||
describe('TOOL_DEFS', () => {
|
||||
it('registers SpawnSubTask with title and instruction required', () => {
|
||||
const def = TOOL_DEFS['SpawnSubTask'];
|
||||
expect(def?.function.name).toBe('SpawnSubTask');
|
||||
expect(def?.function.parameters?.['required']).toEqual(['title', 'instruction']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeTool', () => {
|
||||
it('returns null for unknown tool names', async () => {
|
||||
expect(await executeTool('NotATool', {}, makeCtx())).toBeNull();
|
||||
});
|
||||
|
||||
it('errors when spawnSubTask is unavailable in this context', async () => {
|
||||
const res = await executeTool(
|
||||
'SpawnSubTask',
|
||||
{ title: 't', instruction: 'i' },
|
||||
makeCtx({ spawnSubTask: undefined }),
|
||||
);
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('使用できません');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ instruction: 'i' }],
|
||||
[{ title: 't' }],
|
||||
[{ title: ' ', instruction: 'i' }],
|
||||
[{ title: 't', instruction: '' }],
|
||||
[{ title: 42, instruction: 'i' }],
|
||||
])('rejects missing/blank title or instruction: %j', async input => {
|
||||
const res = await executeTool('SpawnSubTask', input as Record<string, unknown>, makeCtx());
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('必須');
|
||||
});
|
||||
|
||||
it('spawns with the default piece and reports job details', async () => {
|
||||
const ctx = makeCtx();
|
||||
const res = await executeTool('SpawnSubTask', { title: ' t ', instruction: ' do it ' }, ctx);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('サブタスク #1');
|
||||
expect(res?.output).toContain('ジョブ ID: 42');
|
||||
expect(ctx.spawnSubTask).toHaveBeenCalledWith({ title: 't', instruction: 'do it', piece: 'general' });
|
||||
});
|
||||
|
||||
it('rejects a piece that exists neither builtin nor custom', async () => {
|
||||
const ctx = makeCtx();
|
||||
const res = await executeTool(
|
||||
'SpawnSubTask',
|
||||
{ title: 't', instruction: 'i', piece: 'no-such-piece' },
|
||||
ctx,
|
||||
);
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('no-such-piece');
|
||||
expect(ctx.spawnSubTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a piece found in a custom pieces dir', async () => {
|
||||
const customDir = join(tmp, 'custom-pieces');
|
||||
mkdirSync(customDir, { recursive: true });
|
||||
writeFileSync(join(customDir, 'my-piece.yaml'), 'name: my-piece\n');
|
||||
const ctx = makeCtx({ customPiecesDir: customDir } as Partial<ToolContext>);
|
||||
const res = await executeTool(
|
||||
'SpawnSubTask',
|
||||
{ title: 't', instruction: 'i', piece: 'my-piece' },
|
||||
ctx,
|
||||
);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(ctx.spawnSubTask).toHaveBeenCalledWith({ title: 't', instruction: 'i', piece: 'my-piece' });
|
||||
});
|
||||
|
||||
it('accepts custom pieces dirs given as an array', async () => {
|
||||
const customDir = join(tmp, 'pieces-a');
|
||||
mkdirSync(customDir, { recursive: true });
|
||||
writeFileSync(join(customDir, 'arr-piece.yaml'), 'name: arr-piece\n');
|
||||
const ctx = makeCtx({ customPiecesDir: [join(tmp, 'empty'), customDir] } as Partial<ToolContext>);
|
||||
const res = await executeTool(
|
||||
'SpawnSubTask',
|
||||
{ title: 't', instruction: 'i', piece: 'arr-piece' },
|
||||
ctx,
|
||||
);
|
||||
expect(res?.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces spawn failures as tool errors', async () => {
|
||||
const ctx = makeCtx({ spawnSubTask: vi.fn().mockRejectedValue(new Error('queue full')) });
|
||||
const res = await executeTool('SpawnSubTask', { title: 't', instruction: 'i' }, ctx);
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('queue full');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ToolContext } from './core.js';
|
||||
import { executeTool, TOOL_DEFS } from './speech.js';
|
||||
|
||||
let ws: string;
|
||||
|
||||
beforeEach(() => {
|
||||
ws = mkdtempSync(join(tmpdir(), 'speech-test-'));
|
||||
writeFileSync(join(ws, 'meeting.mp3'), Buffer.from('fake-mp3-bytes'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const ctx = (toolsConfig: Record<string, unknown> | undefined = { speechServerUrl: 'http://stt.local/v1/' }): ToolContext =>
|
||||
({ workspacePath: ws, toolsConfig }) as unknown as ToolContext;
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('TranscribeAudio', () => {
|
||||
it('returns null for unknown tool names', async () => {
|
||||
expect(await executeTool('NotSpeech', {}, ctx())).toBeNull();
|
||||
});
|
||||
|
||||
it('registers TranscribeAudio with file_path required', () => {
|
||||
expect(TOOL_DEFS['TranscribeAudio']?.function.parameters?.['required']).toEqual(['file_path']);
|
||||
});
|
||||
|
||||
it('requires file_path', async () => {
|
||||
const res = await executeTool('TranscribeAudio', {}, ctx());
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('file_path は必須');
|
||||
});
|
||||
|
||||
it('errors when speech_server_url is not configured', async () => {
|
||||
const res = await executeTool('TranscribeAudio', { file_path: 'meeting.mp3' }, ctx({}));
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('speech_server_url');
|
||||
});
|
||||
|
||||
it('rejects unsupported extensions', async () => {
|
||||
writeFileSync(join(ws, 'video.mp4'), 'x');
|
||||
const res = await executeTool('TranscribeAudio', { file_path: 'video.mp4' }, ctx());
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('対応フォーマット');
|
||||
});
|
||||
|
||||
it('errors for a missing file', async () => {
|
||||
const res = await executeTool('TranscribeAudio', { file_path: 'ghost.wav' }, ctx());
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('ファイルが見つかりません');
|
||||
});
|
||||
|
||||
it('blocks paths escaping the workspace', async () => {
|
||||
await expect(
|
||||
executeTool('TranscribeAudio', { file_path: '../outside.mp3' }, ctx()),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('posts the file and formats diarized segments grouped by speaker', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(async () =>
|
||||
jsonResponse({
|
||||
segments: [
|
||||
{ speaker: 'A', text: 'こんにちは。' },
|
||||
{ speaker: 'A', text: '今日の議題です。' },
|
||||
{ speaker: 'B', text: 'はい。' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const res = await executeTool('TranscribeAudio', { file_path: 'meeting.mp3', prompt: '議題' }, ctx());
|
||||
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('## 文字起こし結果: meeting.mp3');
|
||||
expect(res?.output).toContain('[A] こんにちは。今日の議題です。');
|
||||
expect(res?.output).toContain('[B] はい。');
|
||||
|
||||
// URL is normalized (no double slash) and diarization header is sent.
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('http://stt.local/v1/audio/transcriptions');
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect((init.headers as Record<string, string>)['X-Diarize']).toBe('true');
|
||||
const form = init.body as FormData;
|
||||
expect(form.get('language')).toBe('ja');
|
||||
expect(form.get('prompt')).toBe('議題');
|
||||
expect(form.get('response_format')).toBe('verbose_json');
|
||||
});
|
||||
|
||||
it('joins segments without speaker labels when diarize=false', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(async () =>
|
||||
jsonResponse({ segments: [{ text: 'ひとつ。' }, { text: 'ふたつ。' }] }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const res = await executeTool(
|
||||
'TranscribeAudio',
|
||||
{ file_path: 'meeting.mp3', diarize: false },
|
||||
ctx(),
|
||||
);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('ひとつ。ふたつ。');
|
||||
expect(res?.output).not.toContain('[Unknown]');
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect((init.headers as Record<string, string>)['X-Diarize']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to plain text when no segments are returned', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => jsonResponse({ text: '全文テキスト' })));
|
||||
const res = await executeTool('TranscribeAudio', { file_path: 'meeting.mp3' }, ctx());
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('全文テキスト');
|
||||
});
|
||||
|
||||
it('uses the configured speech language as default', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(async () => jsonResponse({ text: 'hi' }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await executeTool(
|
||||
'TranscribeAudio',
|
||||
{ file_path: 'meeting.mp3' },
|
||||
ctx({ speechServerUrl: 'http://stt.local', speechLanguage: 'en' }),
|
||||
);
|
||||
const form = (fetchMock.mock.calls[0]?.[1] as RequestInit).body as FormData;
|
||||
expect(form.get('language')).toBe('en');
|
||||
});
|
||||
|
||||
it('reports server errors with status code', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => new Response('boom', { status: 500 })));
|
||||
const res = await executeTool('TranscribeAudio', { file_path: 'meeting.mp3' }, ctx());
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('音声認識サーバーエラー (500)');
|
||||
});
|
||||
|
||||
it('reports an empty transcription as an error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => jsonResponse({})));
|
||||
const res = await executeTool('TranscribeAudio', { file_path: 'meeting.mp3' }, ctx());
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('文字起こし結果が空です');
|
||||
});
|
||||
|
||||
it('reports connection failures with the server URL', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
|
||||
const res = await executeTool('TranscribeAudio', { file_path: 'meeting.mp3' }, ctx());
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('音声認識サーバーに接続できません');
|
||||
expect(res?.output).toContain('ECONNREFUSED');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { saveStructuredBlocks, type StructuredBlock } from './structured-blocks.js';
|
||||
|
||||
let ws: string;
|
||||
|
||||
beforeEach(() => {
|
||||
ws = mkdtempSync(join(tmpdir(), 'structured-blocks-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const block = (refId: string, overrides: Partial<StructuredBlock> = {}): StructuredBlock => ({
|
||||
refId,
|
||||
type: 'x_posts',
|
||||
title: 'sample',
|
||||
data: { query: 'q', posts: [] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('saveStructuredBlocks', () => {
|
||||
it('writes one JSON file per block under logs/structured', () => {
|
||||
saveStructuredBlocks(ws, [block('ref-1'), block('ref-2', { type: 'map_places' })]);
|
||||
const dir = join(ws, 'logs', 'structured');
|
||||
expect(readdirSync(dir).sort()).toEqual(['ref-1.json', 'ref-2.json']);
|
||||
const parsed = JSON.parse(readFileSync(join(dir, 'ref-1.json'), 'utf-8')) as StructuredBlock;
|
||||
expect(parsed.refId).toBe('ref-1');
|
||||
expect(parsed.type).toBe('x_posts');
|
||||
expect(parsed.data).toEqual({ query: 'q', posts: [] });
|
||||
});
|
||||
|
||||
it('does nothing for an empty block list', () => {
|
||||
saveStructuredBlocks(ws, []);
|
||||
expect(existsSync(join(ws, 'logs', 'structured'))).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips unicode data intact', () => {
|
||||
saveStructuredBlocks(ws, [block('jp', { title: '日本語タイトル', data: { text: '絵文字🎉' } })]);
|
||||
const parsed = JSON.parse(
|
||||
readFileSync(join(ws, 'logs', 'structured', 'jp.json'), 'utf-8'),
|
||||
) as StructuredBlock;
|
||||
expect(parsed.title).toBe('日本語タイトル');
|
||||
expect(parsed.data).toEqual({ text: '絵文字🎉' });
|
||||
});
|
||||
|
||||
it('swallows write failures instead of throwing', () => {
|
||||
// Point the workspace at a path whose parent is a regular file.
|
||||
const bogus = join(ws, 'not-a-dir');
|
||||
saveStructuredBlocks(ws, [block('pre')]); // creates logs/structured
|
||||
expect(() =>
|
||||
saveStructuredBlocks(join(ws, 'logs', 'structured', 'pre.json'), [block('x')]),
|
||||
).not.toThrow();
|
||||
expect(existsSync(bogus)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ToolContext } from './core.js';
|
||||
import { executeTool } from './youtube.js';
|
||||
|
||||
const ctx: ToolContext = {
|
||||
workspacePath: '/tmp/maestro-youtube-test',
|
||||
editAllowed: false,
|
||||
};
|
||||
|
||||
const INNERTUBE_PLAYER_PREFIX = 'https://www.youtube.com/youtubei/v1/player';
|
||||
const INNERTUBE_SEARCH_PREFIX = 'https://www.youtube.com/youtubei/v1/search';
|
||||
const WATCH_PAGE_PREFIX = 'https://www.youtube.com/watch?v=';
|
||||
const RESULTS_PAGE_PREFIX = 'https://www.youtube.com/results?search_query=';
|
||||
|
||||
function jsonResponse(obj: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(obj), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function stubFetch(impl: (url: string, init?: RequestInit) => Response | Promise<Response>) {
|
||||
const mock = vi.fn(async (input: unknown, init?: RequestInit) => impl(String(input), init));
|
||||
vi.stubGlobal('fetch', mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
function makePlayerResponse(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
videoDetails: { title: 'Test Video', lengthSeconds: '125' },
|
||||
captions: {
|
||||
playerCaptionsTracklistRenderer: {
|
||||
captionTracks: [
|
||||
{ languageCode: 'ja', name: { simpleText: '日本語' }, baseUrl: 'https://yt.example/timedtext?lang=ja' },
|
||||
{ languageCode: 'en', name: { simpleText: 'English' }, baseUrl: 'https://yt.example/timedtext?lang=en' },
|
||||
],
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const XML_FORMAT_1 = [
|
||||
'<?xml version="1.0" encoding="utf-8"?><timedtext><body>',
|
||||
'<p t="0" d="1500"><s>Hello </s><s>& world</s></p>',
|
||||
'<p t="61000" d="2000">二行目 あ</p>',
|
||||
'</body></timedtext>',
|
||||
].join('');
|
||||
|
||||
const XML_FORMAT_2 = '<transcript><text start="1.5" dur="2.0">Fallback line</text></transcript>';
|
||||
|
||||
interface SearchVideoFixture {
|
||||
id: string;
|
||||
title: string;
|
||||
channel?: string;
|
||||
views?: string;
|
||||
published?: string;
|
||||
length?: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
function makeSearchData(videos: SearchVideoFixture[]): Record<string, unknown> {
|
||||
return {
|
||||
contents: {
|
||||
twoColumnSearchResultsRenderer: {
|
||||
primaryContents: {
|
||||
sectionListRenderer: {
|
||||
contents: [
|
||||
{
|
||||
itemSectionRenderer: {
|
||||
contents: videos.map((v) => ({
|
||||
videoRenderer: {
|
||||
videoId: v.id,
|
||||
title: { runs: [{ text: v.title }] },
|
||||
ownerText: { runs: [{ text: v.channel ?? 'Chan' }] },
|
||||
...(v.views ? { viewCountText: { simpleText: v.views } } : {}),
|
||||
...(v.published ? { publishedTimeText: { simpleText: v.published } } : {}),
|
||||
...(v.length ? { lengthText: { simpleText: v.length } } : {}),
|
||||
...(v.desc
|
||||
? { detailedMetadataSnippets: [{ snippetText: { runs: [{ text: v.desc }] } }] }
|
||||
: {}),
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('youtube executeTool dispatch', () => {
|
||||
it('returns null for unknown tool names', async () => {
|
||||
const result = await executeTool('NotATool', {}, ctx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetYouTubeTranscript', () => {
|
||||
it('rejects input that contains no extractable video id', async () => {
|
||||
const mock = stubFetch(() => {
|
||||
throw new Error('should not fetch');
|
||||
});
|
||||
const result = await executeTool('GetYouTubeTranscript', { url: 'not a youtube link' }, ctx);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('YouTube 動画 ID を抽出できません');
|
||||
expect(mock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches transcript via InnerTube and formats timestamped segments', async () => {
|
||||
const mock = stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
|
||||
if (url.startsWith('https://yt.example/timedtext')) return new Response(XML_FORMAT_1);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
'GetYouTubeTranscript',
|
||||
{ url: 'https://www.youtube.com/watch?v=abcdefghijk' },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('# Test Video');
|
||||
expect(result?.output).toContain('URL: https://www.youtube.com/watch?v=abcdefghijk');
|
||||
expect(result?.output).toContain('動画時間: 2:05');
|
||||
expect(result?.output).toContain('言語: ja');
|
||||
expect(result?.output).toContain('利用可能な言語: ja(日本語), en(English)');
|
||||
expect(result?.output).toContain('字幕セグメント数: 2');
|
||||
// entity decoding + <s> tag concatenation + timestamp formatting
|
||||
expect(result?.output).toContain('[0:00] Hello & world');
|
||||
expect(result?.output).toContain('[1:01] 二行目 あ');
|
||||
|
||||
// InnerTube call carries the extracted video id
|
||||
const body = JSON.parse(String((mock.mock.calls[0][1] as RequestInit).body)) as Record<string, unknown>;
|
||||
expect(body.videoId).toBe('abcdefghijk');
|
||||
});
|
||||
|
||||
it('accepts a bare 11-character video id', async () => {
|
||||
const mock = stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
|
||||
return new Response(XML_FORMAT_1);
|
||||
});
|
||||
const result = await executeTool('GetYouTubeTranscript', { url: 'dQw4w9WgXcQ' }, ctx);
|
||||
expect(result?.isError).toBe(false);
|
||||
const body = JSON.parse(String((mock.mock.calls[0][1] as RequestInit).body)) as Record<string, unknown>;
|
||||
expect(body.videoId).toBe('dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
it('accepts youtu.be short URLs', async () => {
|
||||
const mock = stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
|
||||
return new Response(XML_FORMAT_1);
|
||||
});
|
||||
const result = await executeTool('GetYouTubeTranscript', { url: 'https://youtu.be/abc_def-123' }, ctx);
|
||||
expect(result?.isError).toBe(false);
|
||||
const body = JSON.parse(String((mock.mock.calls[0][1] as RequestInit).body)) as Record<string, unknown>;
|
||||
expect(body.videoId).toBe('abc_def-123');
|
||||
});
|
||||
|
||||
it('selects the requested language track when available', async () => {
|
||||
const mock = stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
|
||||
if (url === 'https://yt.example/timedtext?lang=en') return new Response(XML_FORMAT_1);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
const result = await executeTool(
|
||||
'GetYouTubeTranscript',
|
||||
{ url: 'abcdefghijk', lang: 'en' },
|
||||
ctx,
|
||||
);
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('言語: en');
|
||||
expect(mock).toHaveBeenCalledWith('https://yt.example/timedtext?lang=en', expect.anything());
|
||||
});
|
||||
|
||||
it('errors with the available language list when the requested language is missing', async () => {
|
||||
stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
const result = await executeTool(
|
||||
'GetYouTubeTranscript',
|
||||
{ url: 'abcdefghijk', lang: 'fr' },
|
||||
ctx,
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('言語 "fr" の字幕は利用できません');
|
||||
expect(result?.output).toContain('ja(日本語)');
|
||||
expect(result?.output).toContain('en(English)');
|
||||
});
|
||||
|
||||
it('returns an error when the InnerTube API responds non-OK', async () => {
|
||||
stubFetch(() => new Response('', { status: 403, statusText: 'Forbidden' }));
|
||||
const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('字幕の取得に失敗しました');
|
||||
expect(result?.output).toContain('403');
|
||||
});
|
||||
|
||||
it('returns an error when the transcript XML cannot be parsed', async () => {
|
||||
stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse(makePlayerResponse());
|
||||
return new Response('<garbage/>');
|
||||
});
|
||||
const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('この動画の字幕を解析できませんでした');
|
||||
});
|
||||
|
||||
it('falls back to the watch page when InnerTube returns no caption tracks', async () => {
|
||||
const pagePlayerResponse = {
|
||||
captions: {
|
||||
playerCaptionsTracklistRenderer: {
|
||||
captionTracks: [
|
||||
{ languageCode: 'en', baseUrl: 'https://yt.example/fallback-track' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const html = `<html><script>var ytInitialPlayerResponse = ${JSON.stringify(pagePlayerResponse)};</script></html>`;
|
||||
stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) {
|
||||
return jsonResponse({ videoDetails: { title: 'Test Video', lengthSeconds: '125' } });
|
||||
}
|
||||
if (url.startsWith(WATCH_PAGE_PREFIX)) return new Response(html);
|
||||
if (url === 'https://yt.example/fallback-track') return new Response(XML_FORMAT_2);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('# Test Video');
|
||||
expect(result?.output).toContain('言語: en');
|
||||
expect(result?.output).toContain('字幕セグメント数: 1');
|
||||
// format-2 XML: start="1.5" → 0:01
|
||||
expect(result?.output).toContain('[0:01] Fallback line');
|
||||
});
|
||||
|
||||
it('reports missing captions when the watch page has no ytInitialPlayerResponse', async () => {
|
||||
stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse({ videoDetails: { title: 'T' } });
|
||||
if (url.startsWith(WATCH_PAGE_PREFIX)) return new Response('<html><body>no data</body></html>');
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('字幕情報が見つかりませんでした');
|
||||
});
|
||||
|
||||
it('reports CAPTCHA when YouTube serves a recaptcha page', async () => {
|
||||
stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse({});
|
||||
if (url.startsWith(WATCH_PAGE_PREFIX)) return new Response('<div class="g-recaptcha"></div>');
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('CAPTCHA');
|
||||
});
|
||||
|
||||
it('reports no captions when the watch page player data has an empty track list', async () => {
|
||||
const html = `<html><script>var ytInitialPlayerResponse = ${JSON.stringify({ captions: { playerCaptionsTracklistRenderer: { captionTracks: [] } } })};</script></html>`;
|
||||
stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_PLAYER_PREFIX)) return jsonResponse({});
|
||||
if (url.startsWith(WATCH_PAGE_PREFIX)) return new Response(html);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
const result = await executeTool('GetYouTubeTranscript', { url: 'abcdefghijk' }, ctx);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('この動画には字幕がありません');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchYouTube', () => {
|
||||
it('formats InnerTube search results and emits structured blocks', async () => {
|
||||
const data = makeSearchData([
|
||||
{
|
||||
id: 'aaaaaaaaaaa',
|
||||
title: 'First Video',
|
||||
channel: 'Chan A',
|
||||
views: '1万 回視聴',
|
||||
published: '1 日前',
|
||||
length: '10:00',
|
||||
desc: 'An interesting clip',
|
||||
},
|
||||
{ id: 'bbbbbbbbbbb', title: 'Second Video', channel: 'Chan B' },
|
||||
]);
|
||||
stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_SEARCH_PREFIX)) return jsonResponse(data);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const result = await executeTool('SearchYouTube', { query: 'cats' }, ctx);
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('YouTube 検索結果: "cats" (2件)');
|
||||
expect(result?.output).toContain('1. First Video');
|
||||
expect(result?.output).toContain('URL: https://www.youtube.com/watch?v=aaaaaaaaaaa');
|
||||
expect(result?.output).toContain('チャンネル: Chan A');
|
||||
expect(result?.output).toContain('動画時間: 10:00');
|
||||
expect(result?.output).toContain('再生回数: 1万 回視聴');
|
||||
expect(result?.output).toContain('投稿日: 1 日前');
|
||||
expect(result?.output).toContain('概要: An interesting clip');
|
||||
expect(result?.output).toContain('2. Second Video');
|
||||
expect(result?.output).toMatch(/\[\[embed:youtube-\d+\]\]/);
|
||||
|
||||
expect(result?.structuredBlocks).toHaveLength(1);
|
||||
const block = result!.structuredBlocks![0];
|
||||
expect(block.type).toBe('youtube_videos');
|
||||
const blockData = block.data as { query: string; videos: Array<Record<string, unknown>> };
|
||||
expect(blockData.query).toBe('cats');
|
||||
expect(blockData.videos).toHaveLength(2);
|
||||
expect(blockData.videos[0]).toMatchObject({
|
||||
videoId: 'aaaaaaaaaaa',
|
||||
title: 'First Video',
|
||||
channelName: 'Chan A',
|
||||
thumbnailUrl: 'https://i.ytimg.com/vi/aaaaaaaaaaa/mqdefault.jpg',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=aaaaaaaaaaa',
|
||||
duration: '10:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('respects the limit parameter', async () => {
|
||||
const data = makeSearchData([
|
||||
{ id: 'aaaaaaaaaaa', title: 'One' },
|
||||
{ id: 'bbbbbbbbbbb', title: 'Two' },
|
||||
{ id: 'ccccccccccc', title: 'Three' },
|
||||
]);
|
||||
stubFetch(() => jsonResponse(data));
|
||||
|
||||
const result = await executeTool('SearchYouTube', { query: 'cats', limit: 1 }, ctx);
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('(1件)');
|
||||
expect(result?.output).toContain('1. One');
|
||||
expect(result?.output).not.toContain('Two');
|
||||
const blockData = result!.structuredBlocks![0].data as { videos: unknown[] };
|
||||
expect(blockData.videos).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a non-error message when the response has no result sections', async () => {
|
||||
stubFetch(() => jsonResponse({ contents: {} }));
|
||||
const result = await executeTool('SearchYouTube', { query: 'nothing' }, ctx);
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('"nothing" の検索結果が見つかりませんでした');
|
||||
});
|
||||
|
||||
it('returns a non-error message when sections contain no videoRenderer items', async () => {
|
||||
const data = {
|
||||
contents: {
|
||||
twoColumnSearchResultsRenderer: {
|
||||
primaryContents: {
|
||||
sectionListRenderer: {
|
||||
contents: [{ itemSectionRenderer: { contents: [{ shelfRenderer: {} }] } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
stubFetch(() => jsonResponse(data));
|
||||
const result = await executeTool('SearchYouTube', { query: 'nothing' }, ctx);
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('"nothing" の動画検索結果が見つかりませんでした');
|
||||
expect(result?.structuredBlocks).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to HTML scraping when the InnerTube API fails', async () => {
|
||||
const data = makeSearchData([{ id: 'aaaaaaaaaaa', title: 'Scraped Video', channel: 'HTML Chan' }]);
|
||||
const html = `<html><script>var ytInitialData = ${JSON.stringify(data)};</script></html>`;
|
||||
stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_SEARCH_PREFIX)) return new Response('', { status: 500 });
|
||||
if (url.startsWith(RESULTS_PAGE_PREFIX)) return new Response(html);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const result = await executeTool('SearchYouTube', { query: 'cats' }, ctx);
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('1. Scraped Video');
|
||||
expect(result?.output).toContain('チャンネル: HTML Chan');
|
||||
// HTML fallback path does not emit structured blocks
|
||||
expect(result?.structuredBlocks).toBeUndefined();
|
||||
});
|
||||
|
||||
it('errors when the fallback HTML has no ytInitialData', async () => {
|
||||
stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_SEARCH_PREFIX)) return new Response('', { status: 500 });
|
||||
return new Response('<html><body>blocked</body></html>');
|
||||
});
|
||||
const result = await executeTool('SearchYouTube', { query: 'cats' }, ctx);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('YouTube 検索結果の解析に失敗しました');
|
||||
});
|
||||
|
||||
it('errors when both the API and the fallback page request fail', async () => {
|
||||
stubFetch((url) => {
|
||||
if (url.startsWith(INNERTUBE_SEARCH_PREFIX)) return new Response('', { status: 500 });
|
||||
return new Response('', { status: 429 });
|
||||
});
|
||||
const result = await executeTool('SearchYouTube', { query: 'cats' }, ctx);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('YouTube 検索に失敗しました');
|
||||
expect(result?.output).toContain('429');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user