import assert from "node:assert/strict"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const artifacts = path.join(root, "test-artifacts"); await fs.mkdir(artifacts, { recursive: true }); const browser = await chromium.launch({ headless: true, executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" }); const context = await browser.newContext({ viewport: { width: 1200, height: 800 }, reducedMotion: "reduce" }); await context.addInitScript({ path: path.join(root, "tests/ui-fixture.js") }); const poster = 'data:image/svg+xml,' + encodeURIComponent(''); await context.route("https://www.youtube.com/**", route => route.fulfill({ contentType: "text/html", body: `
Shorts previewТЫ НЕ ВСТРЕЧАЛ этого Левиафана в Subnautica 2...
1,6 млн просмотров
Video preview
S.T.A.L.K.E.R. — видео и описание
Modern previewНазвание внутри ссылки
Информация под превью
` })); const page = await context.newPage(); const errors = []; page.on("pageerror", error => errors.push(error.message)); async function assertInside(id) { const geometry = await page.locator(`#${id}`).evaluate(card => { const owner = card.dataset.ytdlPreview || card.querySelector('[data-ytdl-preview]')?.dataset.ytdlPreview; const host = document.querySelector(`[data-ytdl-preview-for="${owner}"]`); const media = card.querySelector('video, img'); const box = value => ({ left:value.left, top:value.top, right:value.right, bottom:value.bottom }); return { media: box(media.getBoundingClientRect()), buttons: [...host.shadowRoot.querySelectorAll('button')].map(button => box(button.getBoundingClientRect())), count: document.querySelectorAll(`[data-ytdl-preview-for="${owner}"]`).length }; }); assert.equal(geometry.count, 1, `Only one control pair in ${id}`); for (const button of geometry.buttons) { assert.ok(button.left >= geometry.media.left + 3 && button.right <= geometry.media.right - 3 && button.top >= geometry.media.top + 3 && button.bottom <= geometry.media.bottom - 3, `Controls must stay inside ${id}'s actual picture: ${JSON.stringify(geometry)}`); } } try { await page.goto("https://www.youtube.com/"); await page.addScriptTag({ path: path.join(root, "extension/controls.js") }); for (const id of ["short-card", "legacy-card", "modern-card"]) { await page.locator(`#${id} [data-ytdl-preview], #${id}[data-ytdl-preview]`).waitFor(); await assertInside(id); } // The real YouTube app is absolutely positioned: BODY has zero height, and // its scroll/auto overflow is propagated to the viewport, not a zero-size clip. await page.evaluate(() => { document.querySelector('main').style.position='absolute'; document.body.style.overflowY='scroll'; document.body.style.overflowX='auto'; }); await page.waitForTimeout(100); const shortOwner=await page.locator('#short-card').getAttribute('data-ytdl-preview'); assert.equal(await page.locator(`[data-ytdl-preview-for="${shortOwner}"]`).isVisible(),true,'Viewport overflow must not hide buttons when BODY has zero height'); await assertInside('short-card'); await page.screenshot({ path: path.join(artifacts, "thumbnail-placement.png"), animations: "disabled" }); await page.evaluate(() => { const card = document.querySelector('#short-card'); card.addEventListener('mouseenter', () => { const replacement = document.createElement('a'); replacement.href = '/shorts/Mf9GCn_LsUI'; replacement.className = 'short-image'; replacement.innerHTML = ''; card.querySelector('.short-image').replaceWith(replacement); const player = document.createElement('div'); player.className = 'hover-player'; player.style.cssText = 'position:absolute;inset:0 0 auto;height:186px;background:#123b47;z-index:30;pointer-events:auto;border-radius:12px'; card.append(player); }, { once:true }); }); await page.locator('#short-card').hover(); await page.locator('#short-card .hover-player').waitFor(); await page.waitForTimeout(250); await assertInside('short-card'); const shortKey = await page.locator('#short-card').getAttribute('data-ytdl-preview'); const shortButtons = page.locator(`[data-ytdl-preview-for="${shortKey}"]`); await shortButtons.locator('.download').click(); assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), 'https://www.youtube.com/watch?v=Mf9GCn_LsUI'); assert.equal(page.url(), 'https://www.youtube.com/'); await page.screenshot({ path: path.join(artifacts, 'thumbnail-hover-placement.png'), animations:'disabled' }); // YouTube can hide the original card while rendering its hover player in a // separate overlay. Controls must be outside that opacity/visibility subtree. await page.evaluate(() => document.querySelector('#short-card').style.opacity = '0'); assert.equal(await shortButtons.evaluate(host => host.parentElement === document.documentElement), true); assert.equal(await shortButtons.locator('.download').evaluate(button => Number(getComputedStyle(button).opacity)), 1); await shortButtons.locator('.download').click(); await page.evaluate(() => { document.querySelector('#short-card').style.opacity = ''; document.querySelector('#short-card .short-image').style.visibility = 'hidden'; }); await page.waitForTimeout(250); assert.equal(await shortButtons.isVisible(), true); await shortButtons.locator('.download').click(); await page.evaluate(() => document.querySelector('#short-card .short-image').style.visibility = ''); // Hover playback can remove the entire image link, leaving only metadata and // a separate player. That metadata must not move the controls into the title. const beforeRemoval = await shortButtons.boundingBox(); await page.evaluate(() => { const link = document.querySelector('#short-card .short-image'); window.__removedThumbnail = link; link.remove(); }); await page.waitForTimeout(250); assert.deepEqual(await shortButtons.boundingBox(), beforeRemoval); await shortButtons.locator('.download').click(); await page.evaluate(() => document.querySelector('#short-card').prepend(window.__removedThumbnail)); // Layout changes without a window resize: columns narrower, title grows, // and a lazy image gets its final height after initial attachment. await page.evaluate(() => { for (const card of document.querySelectorAll('.card')) card.style.width = '260px'; document.querySelector('#short-card .title').style.minHeight = '110px'; document.querySelector('#short-card .hover-player').style.height = '146.5px'; document.querySelector('#legacy-card img').style.aspectRatio = '4/3'; }); await page.waitForTimeout(300); for (const id of ['short-card','legacy-card','modern-card']) await assertInside(id); await page.evaluate(() => { document.querySelector('#short-card .short-image').href = '/shorts/jNQXAC9IVRw'; document.querySelector('#short-card .title').href = '/shorts/jNQXAC9IVRw'; }); await shortButtons.locator('.download').click(); assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), 'https://www.youtube.com/watch?v=jNQXAC9IVRw'); await page.evaluate(() => { document.body.style.minHeight = '1500px'; }); await page.setViewportSize({width:1200,height:300}); await page.evaluate(() => window.scrollTo(0,60)); await page.waitForTimeout(100); await assertInside('short-card'); // A horizontally clipped Shorts shelf must not leave buttons floating over // content outside the visible shelf. await page.evaluate(() => { const card = document.querySelector('#short-card'); const shelf = document.createElement('div'); shelf.id = 'short-shelf'; shelf.style.cssText = 'width:260px;overflow:hidden;flex-shrink:0'; card.before(shelf); shelf.append(card); card.style.marginLeft = '300px'; }); await page.waitForTimeout(100); assert.equal(await shortButtons.isVisible(),false); await page.evaluate(() => document.querySelector('#short-card').style.marginLeft='0'); await page.waitForTimeout(100); assert.equal(await shortButtons.isVisible(),true); await page.evaluate(() => document.querySelector('#short-card').remove()); await shortButtons.waitFor({state:'detached'}); assert.deepEqual(errors, []); console.log('Thumbnail placement passed: real YouTube zero-height BODY, image bounds, title links, padding, borders, scaled layout, replaced/hidden/removed hover previews, responsive/lazy sizing, scroll, clipped shelves and recycled URLs.'); } finally { await browser.close(); }