/* Any copyright is dedicated to the Public Domain. https://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; /** * Tests for YouTube extraction. When the sourceUrl is a YouTube watch page, the * extractor reads the video metadata from the page markup and the caption * transcript from the DOM, then emits a single labeled block that replaces the * generic page walk. Both the new (transcript-segment-view-model) and legacy * (ytd-transcript-segment-renderer) segment element generations are supported. */ const { isYouTubeWatchUrl, extractVideoMetadata, extractTranscriptSegments, formatYouTubeContent, } = ChromeUtils.importESModule( "moz-src:///toolkit/components/pageextractor/YouTubeExtraction.sys.mjs" ); const YOUTUBE_URL = "https://www.youtube.com/watch?v=DUgPFNRmsCQ"; // The schema.org VideoObject (JSON-LD), the primary structured source, as // YouTube exposes it. YouTube splits the fields across two objects, which is // mirrored here to exercise the merge. const VIDEO_METADATA = ` `; // A watch page still carrying a stale VideoObject for a previously watched // video (YouTube leaves these in the DOM across client-side navigations). The // stale object precedes the current one and carries only name + uploadDate; // both expose an embedUrl so the current video's id disambiguates them. const STALE_VIDEO_METADATA = ` `; const EXPECTED_METADATA_OBJECT = { title: "Example video title", channel: "Example Channel", uploadDate: "2020-01-15", duration: "10:35", views: "12345", likes: "678", genre: "Film & Animation", description: "Video description text.", }; const EXPECTED_SEGMENTS = [ { timestamp: "0:03", text: "All right, so we have a phone now." }, { timestamp: "0:07", text: "What does the B stand for?" }, ]; const NEW_GENERATION_SEGMENTS = ` ${VIDEO_METADATA}
0:03
3 seconds
All right, so we have a phone now.
0:07
7 seconds
What does the B stand for?
`; const LEGACY_GENERATION_SEGMENTS = ` ${VIDEO_METADATA}
0:03
All right, so we have a phone now.
0:07
What does the B stand for?
`; // YouTube renders the same transcript into more than one engagement panel. // Reading must be scoped to a single panel so the transcript is not duplicated. const DUPLICATE_PANEL_SEGMENTS = ` ${VIDEO_METADATA}
0:03
All right, so we have a phone now.
0:07
What does the B stand for?
0:03
All right, so we have a phone now.
0:07
What does the B stand for?
`; const EXPECTED_METADATA_BLOCK = [ "Title: Example video title", "Channel: Example Channel", "Published: 2020-01-15", "Duration: 10:35", "Views: 12345", "Likes: 678", "Category: Film & Animation", "", "Description:", "Video description text.", ].join("\n"); const EXPECTED_TRANSCRIPT_BLOCK = [ "Transcript:", "", "[0:03] All right, so we have a phone now.", "[0:07] What does the B stand for?", ].join("\n"); const EXPECTED_CONTENT = `${EXPECTED_METADATA_BLOCK}\n\n${EXPECTED_TRANSCRIPT_BLOCK}`; // YouTube extraction is off by default; enable it for the extraction tests. add_setup(async function () { await SpecialPowers.pushPrefEnv({ set: [["browser.pageextractor.youtube.enabled", true]], }); }); /** * The URL detection should only match youtube.com watch pages with a video id. */ add_task(function test_is_youtube_watch_url() { const watchUrls = [ "https://www.youtube.com/watch?v=abc123", "https://youtube.com/watch?v=abc123", "https://www.youtube.com/watch?v=abc123&t=42s", ]; for (const url of watchUrls) { ok(isYouTubeWatchUrl(URL.parse(url)), `${url} should be a watch page`); } // Only the desktop site renders the transcript control and engagement panel // this extraction reads. const nonWatchUrls = [ "https://m.youtube.com/watch?v=abc123", "https://music.youtube.com/watch?v=abc123", "https://www.youtube.com/", "https://www.youtube.com/watch", "https://www.youtube.com/feed/subscriptions", "https://www.youtube.com/results?search_query=abc", "https://www.notyoutube.com/watch?v=abc123", "https://example.com/watch?v=abc123", "", null, undefined, ]; for (const url of nonWatchUrls) { ok(!isYouTubeWatchUrl(URL.parse(url)), `${url} should not be a watch page`); } }); /** * Metadata should be read from the schema.org VideoObject (JSON-LD), merging * the fields YouTube splits across multiple objects. */ add_task(function test_extract_video_metadata() { const doc = new DOMParser().parseFromString( `${VIDEO_METADATA}`, "text/html" ); Assert.deepEqual( extractVideoMetadata(doc), EXPECTED_METADATA_OBJECT, "All fields should be read and merged from the JSON-LD video objects" ); }); /** * A stale VideoObject for a previously watched video (left in the DOM after a * client-side navigation) that precedes the current one must not supply any * fields when the current video id is known. With no id, all objects are read. */ add_task(function test_extract_video_metadata_ignores_stale_object() { const doc = new DOMParser().parseFromString( `${STALE_VIDEO_METADATA}`, "text/html" ); Assert.deepEqual( extractVideoMetadata(doc, "DUgPFNRmsCQ"), EXPECTED_METADATA_OBJECT, "All fields should come from the current video's object, not the stale one" ); is( extractVideoMetadata(doc).title, "Previously watched video", "With no current id, objects are read in document order (fallback)" ); }); /** * The JSON-LD VideoObject is the only metadata source. A page that only mirrors * the same values in OpenGraph and microdata yields no metadata at all, so the * generic walk is what surfaces them. */ add_task(function test_extract_video_metadata_requires_json_ld() { const doc = new DOMParser().parseFromString( `Some video - YouTube
`, "text/html" ); Assert.deepEqual( extractVideoMetadata(doc), { title: "", channel: "", uploadDate: "", duration: "", views: "", likes: "", genre: "", description: "", }, "Without JSON-LD no metadata should be inferred from OpenGraph/microdata" ); }); /** * The JSON-LD reader should accept the shapes schema.org allows: `@type` and * `author` as arrays or objects, `interactionType` as an object, VideoObjects * nested under `@graph`, and an hour-long duration. */ add_task(function test_extract_video_metadata_json_ld_shapes() { const doc = new DOMParser().parseFromString( ` `, "text/html" ); Assert.deepEqual( extractVideoMetadata(doc), { title: "Shapes video", channel: "Graph Channel", uploadDate: "2021-07-04", duration: "1:02:03", views: "4321", likes: "", genre: "Education", description: "", }, "Array/object JSON-LD shapes should all be handled" ); }); /** * The pure segment parsing + formatting should work on an inert document. */ add_task(function test_extract_and_format_segments() { const emptyMetadata = { title: "", channel: "", uploadDate: "", duration: "", views: "", likes: "", genre: "", description: "", }; const doc = new DOMParser().parseFromString( `${NEW_GENERATION_SEGMENTS}`, "text/html" ); const segments = extractTranscriptSegments(doc); Assert.deepEqual( segments, EXPECTED_SEGMENTS, "Both new-generation segments should be parsed" ); is( formatYouTubeContent(emptyMetadata, segments), EXPECTED_TRANSCRIPT_BLOCK, "A transcript with no metadata should be a single labeled section" ); is( formatYouTubeContent(EXPECTED_METADATA_OBJECT, segments), EXPECTED_CONTENT, "Metadata and transcript should be joined into labeled sections" ); const legacyDoc = new DOMParser().parseFromString( `${LEGACY_GENERATION_SEGMENTS}`, "text/html" ); Assert.deepEqual( extractTranscriptSegments(legacyDoc), EXPECTED_SEGMENTS, "Legacy-generation segments should be parsed" ); }); /** * Empty fields and an absent transcript should be omitted from the block. */ add_task(function test_format_omits_missing_fields() { is( formatYouTubeContent( { title: "Only a title", channel: "", uploadDate: "", duration: "", views: "", likes: "", genre: "", description: "", }, [] ), "Title: Only a title", "A block with only a title should have no other lines or sections" ); }); /** * The full structured block should be produced end-to-end from a watch page * carrying new-generation transcript segments. */ add_task(async function test_structured_content_new_generation() { const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { getPageExtractor, cleanup } = await html`${NEW_GENERATION_SEGMENTS}`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, EXPECTED_CONTENT, "Metadata sections should precede the transcript" ); await cleanup(); }); /** * The transcript must not be duplicated when several transcript panels contain * the same segments. */ add_task(async function test_transcript_not_duplicated_across_panels() { const doc = new DOMParser().parseFromString( `${DUPLICATE_PANEL_SEGMENTS}`, "text/html" ); Assert.deepEqual( extractTranscriptSegments(doc), EXPECTED_SEGMENTS, "Only the first transcript panel's segments should be read" ); const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { getPageExtractor, cleanup } = await html`${DUPLICATE_PANEL_SEGMENTS}`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, EXPECTED_CONTENT, "Transcript should appear once even with multiple transcript panels" ); await cleanup(); }); add_task(async function test_structured_content_legacy_generation() { const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { getPageExtractor, cleanup } = await html`${LEGACY_GENERATION_SEGMENTS}`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, EXPECTED_CONTENT, "Legacy-generation segments should produce the same block" ); await cleanup(); }); /** * When segments are not yet rendered, getText should activate the transcript * control and wait for the segments to appear. */ add_task(async function test_transcript_panel_opened_on_click() { const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { getPageExtractor, cleanup } = await html` ${VIDEO_METADATA}
`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, `${EXPECTED_METADATA_BLOCK}\n\nTranscript:\n\n[0:05] Injected after click.`, "Metadata should precede a transcript loaded after opening the panel" ); await cleanup(); }); add_task(async function test_structural_transcript_button_preferred() { await SpecialPowers.pushPrefEnv({ set: [["browser.pageextractor.youtube.timeoutMs", 200]], }); const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { getPageExtractor, cleanup } = await html` ${VIDEO_METADATA}
`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, `${EXPECTED_METADATA_BLOCK}\n\nTranscript:\n\n[0:05] Loaded from the structural control.`, "The structural transcript control should take priority over an earlier fallback" ); await cleanup(); await SpecialPowers.popPrefEnv(); }); /** * When extraction opens the transcript panel, it should close it again once the * segments have been read so the page is restored to its prior state. */ add_task(async function test_transcript_panel_closed_after_extraction() { const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { tab, getPageExtractor, cleanup } = await html` ${VIDEO_METADATA}
`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, `${EXPECTED_METADATA_BLOCK}\n\nTranscript:\n\n[0:05] Injected after click.`, "The opened transcript should still be included in the result" ); const closed = await SpecialPowers.spawn(tab.linkedBrowser, [], () => content.document .querySelector("ytd-engagement-panel-section-list-renderer") .hasAttribute("data-closed") ); ok(closed, "The transcript panel should be closed after extraction"); await cleanup(); }); /** * Modern layouts render the transcript into a combined "In this video" * engagement panel that carries no transcript-specific target-id. The panel we * open must still be closed by anchoring on the segment's nearest engagement * panel rather than a target-id match. */ add_task(async function test_untagged_panel_closed_after_extraction() { const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { tab, getPageExtractor, cleanup } = await html` ${VIDEO_METADATA}
`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, `${EXPECTED_METADATA_BLOCK}\n\nTranscript:\n\n[0:05] Injected after click.`, "A transcript in an untagged engagement panel should still be extracted" ); const closed = await SpecialPowers.spawn(tab.linkedBrowser, [], () => content.document .querySelector("ytd-engagement-panel-section-list-renderer") .hasAttribute("data-closed") ); ok( closed, "An untagged transcript panel opened by extraction should be closed" ); await cleanup(); }); /** * A transcript the user already had open should be left open: extraction reads * the segments but must not close a panel it did not open. */ add_task(async function test_already_open_transcript_left_open() { const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { tab, getPageExtractor, cleanup } = await html` ${VIDEO_METADATA}
0:03
All right, so we have a phone now.
0:07
What does the B stand for?
`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, EXPECTED_CONTENT, "An already-open transcript should still be extracted" ); const closed = await SpecialPowers.spawn(tab.linkedBrowser, [], () => content.document .querySelector("ytd-engagement-panel-section-list-renderer") .hasAttribute("data-closed") ); ok(!closed, "A transcript the user already had open should be left open"); await cleanup(); }); /** * If the transcript control is present but the segments never render (a slow or * failed transcript load, or YouTube changing its segment markup), extraction * must not hang or throw: it waits up to the timeout, then falls back to the * generic walk with the metadata block prepended (here the generic walk finds * nothing else, so only the metadata block remains). */ add_task(async function test_transcript_panel_never_loads() { await SpecialPowers.pushPrefEnv({ set: [["browser.pageextractor.youtube.timeoutMs", 200]], }); const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { getPageExtractor, cleanup } = await html` ${VIDEO_METADATA} `; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, EXPECTED_METADATA_BLOCK, "A transcript that never renders yields the metadata block" ); await cleanup(); await SpecialPowers.popPrefEnv(); }); /** * A panel this extraction opened must be closed again even when the segments * never render (a slow or failed transcript load), so the page is restored. */ add_task(async function test_panel_closed_when_segments_never_render() { await SpecialPowers.pushPrefEnv({ set: [["browser.pageextractor.youtube.timeoutMs", 200]], }); const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { tab, getPageExtractor, cleanup } = await html` ${VIDEO_METADATA}
`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, EXPECTED_METADATA_BLOCK, "The metadata block is still returned when no transcript renders" ); const [opened, closed] = await SpecialPowers.spawn( tab.linkedBrowser, [], () => { const panel = content.document.querySelector( "ytd-engagement-panel-section-list-renderer" ); return [ panel.hasAttribute("data-opened"), panel.hasAttribute("data-closed"), ]; } ); ok(opened, "The panel should have been opened by extraction"); ok(closed, "A panel we opened must be closed even when no segment renders"); await cleanup(); await SpecialPowers.popPrefEnv(); }); /** * On the slow-load path we must not toggle a panel we cannot attribute to our * own click: when several transcript panels exist and none rendered a segment, * none should be closed. */ add_task(async function test_ambiguous_panels_not_closed_on_timeout() { await SpecialPowers.pushPrefEnv({ set: [["browser.pageextractor.youtube.timeoutMs", 200]], }); const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { tab, getPageExtractor, cleanup } = await html` ${VIDEO_METADATA} `; const actor = getPageExtractor(); await actor.getText({ sourceUrl: YOUTUBE_URL }); const anyClosed = await SpecialPowers.spawn(tab.linkedBrowser, [], () => [ ...content.document.querySelectorAll( "ytd-engagement-panel-section-list-renderer" ), ].some(p => p.hasAttribute("data-closed")) ); ok(!anyClosed, "No panel should be closed when the open one is ambiguous"); await cleanup(); await SpecialPowers.popPrefEnv(); }); /** * A non-YouTube sourceUrl should not trigger structured extraction, and the * segment text should flow through the generic extraction unchanged. */ add_task(async function test_no_structured_content_for_non_youtube() { const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { getPageExtractor, cleanup } = await html`${NEW_GENERATION_SEGMENTS}`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: "https://example.com/article", }); ok( !result.text.includes("Title:") && !result.text.includes("Transcript:"), "No structured block should be added for non-YouTube pages" ); ok( result.text.includes("All right, so we have a phone now."), "Segment text is still extracted generically without the YouTube strategy" ); await cleanup(); }); /** * A YouTube watch page with no metadata or transcript should fall back to the * generic page content. */ add_task(async function test_youtube_without_content() { const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { getPageExtractor, cleanup } = await html`

A video with captions disabled.

`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, "A video with captions disabled.", "Without extractable content, the generic page content is returned" ); await cleanup(); }); /** * With no transcript, the generic walk is kept so page content (e.g. comments) * survives, and the metadata block (header fields + description) is prepended. */ add_task(async function test_no_transcript_prepends_metadata_block() { const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { getPageExtractor, cleanup } = await html` ${VIDEO_METADATA}

A viewer comment on the video.

`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); is( result.text, `${EXPECTED_METADATA_BLOCK}\n\nA viewer comment on the video.`, "The metadata block should be prepended to the retained generic content" ); await cleanup(); }); /** * The YouTube block is capped at sufficientLength so a long transcript can't * emit far more text than the other extractors. */ add_task(async function test_content_truncated_to_sufficient_length() { const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { getPageExtractor, cleanup } = await html`${NEW_GENERATION_SEGMENTS}`; const actor = getPageExtractor(); const sufficientLength = 50; const result = await actor.getText({ sourceUrl: YOUTUBE_URL, sufficientLength, }); is( result.text, EXPECTED_CONTENT.slice(0, sufficientLength), "The structured block should be truncated to sufficientLength" ); await cleanup(); }); /** * The feature can be disabled via preference. */ add_task(async function test_disabled_by_pref() { await SpecialPowers.pushPrefEnv({ set: [["browser.pageextractor.youtube.enabled", false]], }); const { html } = await MLTestUtils.serveHTMLInTab({ browser: gBrowser }); const { getPageExtractor, cleanup } = await html`${NEW_GENERATION_SEGMENTS}`; const actor = getPageExtractor(); const result = await actor.getText({ sourceUrl: YOUTUBE_URL }); ok( !result.text.includes("Title:") && !result.text.includes("Transcript:"), "No structured block should be produced when the pref is disabled" ); // The YouTube strategy's filter selector must be gated on the pref too, // otherwise a transcript the user opened themselves would be dropped from the // generic walk without anything replacing it. for (const { text } of EXPECTED_SEGMENTS) { ok( result.text.includes(text), `Raw segment text "${text}" should survive the generic walk` ); } await cleanup(); await SpecialPowers.popPrefEnv(); });