This commit is contained in:
@@ -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