140 lines
10 KiB
JavaScript
140 lines
10 KiB
JavaScript
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('<svg xmlns="http://www.w3.org/2000/svg" width="330" height="186"><defs><linearGradient id="g"><stop stop-color="#096574"/><stop offset="1" stop-color="#192636"/></linearGradient></defs><path fill="url(#g)" d="M0 0h330v186H0z"/></svg>');
|
|
await context.route("https://www.youtube.com/**", route => route.fulfill({ contentType: "text/html", body: `<!doctype html><html dark><head><meta charset="utf-8"><style>
|
|
body{margin:24px;background:#0f0f0f;color:#eee;font:16px/1.4 Arial}main{display:flex;gap:26px;align-items:flex-start}.card{display:block;width:330px;position:relative}a{color:inherit;text-decoration:none}img,video{display:block;width:100%;height:auto;aspect-ratio:330/186;object-fit:cover;border-radius:12px}a.title{display:block;font-weight:600;min-height:48px;margin-top:10px}.views{color:#aaa;margin-top:5px}ytd-thumbnail,yt-thumbnail-view-model{display:block}.short-image{display:block}.legacy-image{display:block;padding-bottom:28px}.modern-image{display:block;padding-bottom:20px}.scaled{transform:scale(.85);transform-origin:top left}.bordered{border:3px solid #333;padding:7px;box-sizing:border-box}
|
|
</style></head><body><main>
|
|
<ytm-shorts-lockup-view-model id="short-card" class="card"><a class="short-image" href="/shorts/Mf9GCn_LsUI"><yt-thumbnail-view-model><img src="${poster}" alt="Shorts preview"></yt-thumbnail-view-model></a><a class="title" href="/shorts/Mf9GCn_LsUI">ТЫ НЕ ВСТРЕЧАЛ этого Левиафана в Subnautica 2...</a><div class="views">1,6 млн просмотров</div></ytm-shorts-lockup-view-model>
|
|
<div id="legacy-card" class="card"><ytd-thumbnail><a id="thumbnail" class="legacy-image" href="/watch?v=jNQXAC9IVRw"><img src="${poster}" alt="Video preview"></a></ytd-thumbnail><div class="title">S.T.A.L.K.E.R. — видео и описание</div></div>
|
|
<yt-lockup-view-model id="modern-card" class="card scaled"><a class="yt-lockup-view-model__content-image modern-image" href="/watch?v=aircAruvnKk"><yt-thumbnail-view-model class="bordered"><img src="${poster}" alt="Modern preview"></yt-thumbnail-view-model><span>Название внутри ссылки</span></a><div class="views">Информация под превью</div></yt-lockup-view-model>
|
|
</main></body></html>` }));
|
|
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 = '<video style="background:#123b47"></video>';
|
|
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(); }
|