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) { const mock = vi.fn(async (input: unknown, init?: RequestInit) => impl(String(input), init)); vi.stubGlobal('fetch', mock); return mock; } function makePlayerResponse(overrides: Record = {}): Record { 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 = [ '', '

Hello & world

', '

二行目 あ

', '
', ].join(''); const XML_FORMAT_2 = 'Fallback line'; interface SearchVideoFixture { id: string; title: string; channel?: string; views?: string; published?: string; length?: string; desc?: string; } function makeSearchData(videos: SearchVideoFixture[]): Record { 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 + 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; 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; 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; 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(''); }); 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 = ``; 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('no data'); 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('
'); 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 = ``; 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> }; 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 = ``; 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('blocked'); }); 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'); }); });