Files
maestro/src/engine/tools/youtube.test.ts
T
oss-sync 5502478636
CI / build-and-test (push) Has been cancelled
sync: update from private repo (5b6df2f)
2026-06-10 08:40:41 +00:00

411 lines
17 KiB
TypeScript

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>&amp; world</s></p>',
'<p t="61000" d="2000">二行目 &#x3042;</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');
});
});