// Browse scenario driven ENTIRELY with real mouse/keyboard input via xdotool // (see human.mjs). Connects to a Chrome already running with // --remote-debugging-port (default 9222) on the streamed X display. // // All ACTIONS are real input: address-bar navigation (Ctrl+L + typing), // search typing, clicking the video, the settings gear -> 화질 -> 1080p menu, // the autoplay toggle, the play button, fullscreen via the 'f' key, scrolling, // and entering 나무위키. The CDP/DOM API is used ONLY to read state for // verification (paused/quality/fullscreen) and as a rare click fallback when an // element has no on-screen box. // // One environment workaround: on the streamed VNC desktop (xfwm4) Chrome does // NOT hide its toolbar when a video enters HTML5 fullscreen ('f'), so the // address bar bleeds into the broadcast. We therefore toggle the BROWSER window // into Chrome-initiated fullscreen via CDP (Browser.setWindowBounds) around the // 'f' step - that reliably hides the toolbar (innerHeight 1080 vs 988) - then // restore it. This is a window-chrome action, not a page interaction. import { chromium } from 'playwright'; import { humanClick, humanType, humanKey, humanHover, navigateOmnibox, humanScroll, sleep } from './human.mjs'; const CDP = process.env.CDP_PORT || '9222'; const VID = process.env.TEST_VIDEO_ID || 'X_am71G6Vy4'; const SEARCH = process.env.TEST_YT_QUERY || '내손을잡아'; const NAVER_Q = process.env.TEST_NAVER_QUERY || '아이유'; // MV_QUERY mode: search this query and auto-pick the first result that actually // reports >=60fps (instead of clicking the fixed TEST_VIDEO_ID). WATCH_SECONDS // is how long to watch windowed and fullscreen (default 20). const MV_QUERY = process.env.MV_QUERY || ''; const WATCH_MS = parseInt(process.env.WATCH_SECONDS || '20', 10) * 1000; // Subtitles (off-by-default, Korean-on) and YouTube ad-skipping are applied // broadcast-wide by broadcast-helper.mjs, not here. const b = await chromium.connectOverCDP(`http://localhost:${CDP}`); const ctx = b.contexts()[0]; const page = ctx.pages()[0]; page.setDefaultTimeout(25000); const read = (fn) => page.evaluate(fn); const playerLoc = () => page.locator('#movie_player'); const fpsNow = () => read(() => { try { const s = document.getElementById('movie_player').getStatsForNerds(); const m = (s.resolution || '').match(/@(\d+)/); return m ? +m[1] : null; } catch { return null; } }); async function skipAdsQuick() { for (let i = 0; i < 8; i++) { const ad = page.locator('.ytp-ad-skip-button, .ytp-ad-skip-button-modern, .ytp-skip-ad-button'); if (await ad.count().catch(() => 0)) { await humanClick(page, ad.first()); await sleep(1200); } else break; } } // 1) open YouTube by typing the URL in the address bar await navigateOmnibox('https://www.youtube.com'); await sleep(3000); // 2) really type the search and submit (fixed query, or the MV query) await humanClick(page, page.locator('input#search, input[name=search_query]').first()); await humanType(MV_QUERY || SEARCH); await humanKey('Return'); await sleep(3800); // open a result with the real mouse, wait for the player, skip ads, ensure playing async function openAndPlay(link) { await humanClick(page, link); await sleep(3500); await page.waitForSelector('#movie_player', { timeout: 25000 }); await sleep(2000); await skipAdsQuick(); if (await read(() => document.querySelector('video')?.paused)) { await humanClick(page, page.locator('.ytp-large-play-button, .ytp-play-button').first()); } await sleep(1500); } // 3) pick the video: in MV mode auto-pick the first result that really reports // >=60fps; otherwise click the fixed concert clip. if (MV_QUERY) { const resultsUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(MV_QUERY)}&sp=EgQQARgD`; let picked = false; for (let i = 0; i < 5 && !picked; i++) { const results = page.locator('ytd-video-renderer a#video-title, ytd-rich-item-renderer a#video-title'); if (!(await results.nth(i).count().catch(() => 0))) break; await openAndPlay(results.nth(i)); const fps = await fpsNow(); console.log(`MV candidate ${i} fps=${fps}`); if (fps && fps >= 60) { picked = true; break; } await navigateOmnibox(resultsUrl); await sleep(3000); } if (!picked) console.log('MV: no >=60fps result found, using last opened'); } else { let link = page.locator(`a#video-title[href*="${VID}"], a[href*="${VID}"]`).first(); if (!(await link.count().catch(() => 0))) link = page.locator('ytd-video-renderer a#video-title, ytd-rich-item-renderer a#video-title').first(); await openAndPlay(link); } // 5) set 1080p through the real settings menu (gear -> 화질 -> 1080p), verify async function setQuality1080() { for (let attempt = 0; attempt < 2; attempt++) { await humanHover(page, playerLoc()); await humanClick(page, page.locator('.ytp-settings-button')); await sleep(900); let qrow = page.locator('.ytp-menuitem', { hasText: /화질|Quality/ }).first(); if (!(await qrow.count().catch(() => 0))) qrow = page.locator('.ytp-panel-menu .ytp-menuitem').last(); await humanClick(page, qrow); await sleep(900); const item = page.locator('.ytp-menuitem', { hasText: /1080/ }).first(); if (await item.count().catch(() => 0)) await humanClick(page, item); await sleep(2000); const q = await read(() => document.getElementById('movie_player')?.getPlaybackQuality?.()); if (q && /1080/.test(q)) return q; } return null; } console.log('QUALITY', await setQuality1080()); // 6) turn off autoplay with a real click if it is on const auto = page.locator('.ytp-autonav-toggle-button'); if ((await auto.count().catch(() => 0)) && (await auto.getAttribute('aria-checked').catch(() => null)) === 'true') { await humanHover(page, playerLoc()); await humanClick(page, auto); } console.log('STEP watch-1080-windowed'); await sleep(WATCH_MS); // 7) fullscreen with the real 'f' key. broadcast-helper.mjs detects the HTML5 // fullscreen and hides Chrome's toolbar (window fullscreen) broadcast-wide, so // the address bar stays off the stream. await humanHover(page, playerLoc()); await humanKey('f'); await sleep(1800); if (!(await read(() => !!document.fullscreenElement))) { await humanHover(page, playerLoc()); await humanKey('f'); await sleep(1500); } console.log('STEP fullscreen', await read(() => ({ full: !!document.fullscreenElement, h: window.innerHeight }))); await sleep(WATCH_MS); // 8) exit video fullscreen ('f'); the helper restores the toolbar await humanKey('f'); await sleep(1500); // 9) Naver via the address bar, then really type the query await navigateOmnibox('https://www.naver.com'); await sleep(2800); await humanClick(page, page.locator('input#query').first()); await humanType(NAVER_Q); await humanKey('Return'); await sleep(2800); await humanScroll(page, +1, 18); console.log('STEP naver-scrolled'); // 10) enter 나무위키 with a real click, then scroll const namu = page.locator('a[href*="namu.wiki"]').first(); if (await namu.count().catch(() => 0)) { await humanClick(page, namu); await sleep(3000); await humanScroll(page, +1, 14); await humanScroll(page, -1, 8); await humanScroll(page, +1, 10); console.log('STEP namu-scrolled'); } else console.log('STEP namu-not-found'); console.log('SCENARIO_DONE'); await b.close();