aboutsummaryrefslogtreecommitdiffstats
path: root/tools/youtube-tap-test.js
blob: bf2832fc1b8c507765693ab7ae0074da744a5736 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
// A YouTube clip in a post takes one tap, like any clip: on the picture —
// the still, or the player already going (autoplayed, muted) — it plays with
// sound, full screen. The player's own taps never reach the page, so a layer
// over the picture takes them; YouTube's control bar along the bottom is
// left to YouTube, and full screen the layer steps aside.
// Full screen, YouTube's own full-screen button leaves it. Under a mouse
// there is no layer: the player takes its own clicks (pause, full screen).
// Run: bun tools/youtube-tap-test.js
import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=480x360', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;

const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
const now = Math.floor(Date.now() / 1000);
const POSTS = [
  finalizeEvent({ kind: 1, created_at: now - 100, tags: [], content: 'watch this https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, SK),
  ...Array.from({ length: 4 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 200 - i * 60, tags: [], content: 'filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5) }, SK)),
];
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, fetch: (req) => {
  const path = new URL(req.url).pathname;
  if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
  return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
// YouTube, stood in for: the player page notes every command it is sent
const PLAYER = '<!doctype html><html><body style="margin:0;background:#222;height:100vh"><script>window.__cmds=[];addEventListener("message",(e)=>{try{const m=JSON.parse(e.data);if(m.event==="command")window.__cmds.push(m.func)}catch{}});window.__clicks=0;addEventListener("click",()=>{window.__clicks++;if(!window.__fsButton)return;if(document.fullscreenElement)document.exitFullscreen();else document.documentElement.requestFullscreen().catch(()=>{})});</script></body></html>';

const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
async function run(saveData) {
  console.log('\n[' + (saveData ? 'Data Saver: the still first' : 'autoplay: the player already going, muted') + ']');
  const ctx = await browser.createBrowserContext();
  const page = await ctx.newPage();
  await page.setRequestInterception(true);
  page.on('request', (r) => {
    const u = r.url();
    if (u.startsWith('https://www.youtube-nocookie.com/embed/')) return r.respond({ status: 200, contentType: 'text/html', body: PLAYER });
    if (u.startsWith('https://i.ytimg.com/')) return r.respond({ status: 200, contentType: 'image/png', body: png });
    return r.continue();
  });
  await page.evaluateOnNewDocument((sd) => {
    if (sd) Object.defineProperty(navigator, 'connection', { configurable: true, get: () => ({ saveData: true, effectiveType: '4g', addEventListener() {}, removeEventListener() {} }) });
    const Real = window.WebSocket;
    window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
    window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
  }, saveData);
  const errs = [];
  page.on('pageerror', (e) => errs.push(String(e)));
  const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
  const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
  const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
  const box = () => page.evaluate(() => { const b = document.querySelector('.notes-feed .yt-embed').getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height }; });
  const st = () => page.evaluate(() => { const b = document.querySelector('.yt-embed'); const f = b && b.querySelector('iframe'); const sh = b && b.querySelector('.yt-shield'); return { fs: !!b && document.fullscreenElement === b, iframe: !!f, muted: !!f && /mute=1/.test(f.src), shield: !!sh && getComputedStyle(sh).display !== 'none', thread: !!document.querySelector('.thread-card') }; });
  const cmds = async () => { const f = page.frames().find((x) => x.url().startsWith('https://www.youtube-nocookie.com/')); return f ? f.evaluate(() => window.__cmds.slice()) : null; };
  try {
    // a phone: a finger, no hover (a mouse is run apart, below)
    await page.setViewport({ width: 390, height: 844, hasTouch: true, isMobile: true });
    await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
    await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
    await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
    await click('create a new wallet'); await sleep(600);
    await seedWords(page);
    const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
    await skipSeedCheck(page);
    await waitText('receive', 20000);
    const base = cacheKeyFor(mn + '\n');
    await page.evaluate(([k, pk, ns]) => {
      localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
      localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
      localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Tuber', t: Date.now() } }));
      localStorage.setItem('btc-wallet-feed:' + k, 'following');
    }, [base, AUTHOR, POSTS]);
    await page.reload({ waitUntil: 'domcontentloaded' });
    await waitText('receive', 20000);
    await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
    await page.waitForSelector('.notes-feed .yt-embed', { timeout: 20000 });
    await page.evaluate(() => document.querySelector('.notes-feed .yt-embed').scrollIntoView({ block: 'center' }));
    await sleep(1500);
    let s = await st();
    if (!saveData) {
      check('in view, the player loads by itself, muted', s.iframe && s.muted, JSON.stringify(s));
      check('…with a layer over its picture', s.shield);
      await waitFor(() => !!document.querySelector('.yt-embed iframe'), null, 3000);
      await sleep(500);
      // YouTube's control bar along the bottom is YouTube's
      let b = await box();
      await page.touchscreen.tap(b.x + b.w * 0.3, b.y + b.h - 20); await sleep(500);
      check('a tap on YouTube\'s control bar stays YouTube\'s', !(await st()).fs && !(await st()).thread, JSON.stringify(await st()));
    } else {
      check('with Data Saver, the still and a play button', !s.iframe && await page.evaluate(() => !!document.querySelector('.yt-embed .yt-play')), JSON.stringify(s));
    }
    const b = await box();
    await page.touchscreen.tap(b.x + b.w * 0.3, b.y + b.h * 0.35);
    check('one tap on the picture: full screen', await waitFor(() => document.fullscreenElement === document.querySelector('.yt-embed'), null, 3000), JSON.stringify(await st()));
    s = await st();
    check('…the player there', s.iframe);
    check('…with sound', saveData ? !s.muted : (await waitFor(() => true, null, 300)) && (await cmds())?.includes('unMute'), saveData ? JSON.stringify(s) : JSON.stringify(await cmds()));
    if (!saveData) check('…playing', (await cmds())?.includes('playVideo'));
    check('…not the thread', !s.thread);
    check('full screen, the layer steps aside (YouTube\'s controls are all there)', !s.shield, JSON.stringify(s));
    await page.evaluate(() => document.exitFullscreen()); await sleep(500);
    s = await st();
    check('out of full screen, the layer is back', s.shield && !s.fs, JSON.stringify(s));
    // YouTube's own full-screen button, pressed while we hold the screen:
    // the frame asks for full screen from inside — that press means "out"
    const b2 = await box();
    await page.touchscreen.tap(b2.x + b2.w * 0.3, b2.y + b2.h * 0.35);
    await waitFor(() => document.fullscreenElement === document.querySelector('.yt-embed'), null, 3000);
    await page.frames().find((x) => x.url().startsWith('https://www.youtube-nocookie.com/')).evaluate(() => { window.__fsButton = true; });
    await page.touchscreen.tap(200, 400); // inside the frame now
    check('full screen, the player\'s own full-screen button leaves it', await waitFor(() => !document.fullscreenElement, null, 3000), await page.evaluate(() => String(document.fullscreenElement && document.fullscreenElement.tagName)));
    check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
  } finally { await ctx.close(); }
}
// A mouse reaches the player's own buttons, so its clicks are the player's:
// the middle one pauses, the corner one goes full screen and back.
async function runMouse() {
  console.log('\n[a mouse: the player takes its own clicks]');
  const ctx = await browser.createBrowserContext();
  const page = await ctx.newPage();
  await page.setRequestInterception(true);
  page.on('request', (r) => {
    const u = r.url();
    if (u.startsWith('https://www.youtube-nocookie.com/embed/')) return r.respond({ status: 200, contentType: 'text/html', body: PLAYER });
    if (u.startsWith('https://i.ytimg.com/')) return r.respond({ status: 200, contentType: 'image/png', body: png });
    return r.continue();
  });
  await page.evaluateOnNewDocument(() => {
    const Real = window.WebSocket;
    window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
    window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
  });
  const errs = [];
  page.on('pageerror', (e) => errs.push(String(e)));
  const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
  const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
  const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
  const box = () => page.evaluate(() => { const b = document.querySelector('.notes-feed .yt-embed').getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height }; });
  const fsTag = () => page.evaluate(() => (document.fullscreenElement ? document.fullscreenElement.tagName : ''));
  const player = () => page.frames().find((x) => x.url().startsWith('https://www.youtube-nocookie.com/'));
  try {
    await page.setViewport({ width: 1280, height: 900 });
    await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
    await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
    await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
    await click('create a new wallet'); await sleep(600);
    await seedWords(page);
    const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
    await skipSeedCheck(page);
    await waitText('receive', 20000);
    const base = cacheKeyFor(mn + '\n');
    await page.evaluate(([k, pk, ns]) => {
      localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
      localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
      localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Tuber', t: Date.now() } }));
      localStorage.setItem('btc-wallet-feed:' + k, 'following');
    }, [base, AUTHOR, POSTS]);
    await page.reload({ waitUntil: 'domcontentloaded' });
    await waitText('receive', 20000);
    await page.evaluate(() => { const n = document.querySelector('.app-nav-button[aria-label="Feed"]') || [...document.querySelectorAll('a,button')].find((x) => /^feed$/i.test((x.getAttribute('aria-label') || x.textContent || '').trim())); if (n) n.click(); });
    await page.waitForSelector('.notes-feed .yt-embed', { timeout: 20000 });
    await page.evaluate(() => document.querySelector('.notes-feed .yt-embed').scrollIntoView({ block: 'center' }));
    await waitFor(() => !!document.querySelector('.yt-embed iframe'), null, 6000);
    await sleep(800);
    const b = await box();
    await page.mouse.move(b.x + b.w * 0.4, b.y + b.h * 0.4); await sleep(200);
    check('under a mouse, no layer over the player', await page.evaluate(() => { const sh = document.querySelector('.yt-embed .yt-shield'); return !sh || getComputedStyle(sh).display === 'none'; }));
    await player().evaluate(() => { window.__fsButton = true; }); // from here every click is the corner button
    await page.mouse.click(b.x + b.w * 0.5, b.y + b.h * 0.5); await sleep(600);
    check('a click in the middle reaches the player', (await player().evaluate(() => window.__clicks)) === 1);
    check('…and the page does not take the box full screen', (await fsTag()) !== 'DIV', await fsTag());
    check('…nor open the thread', !(await page.evaluate(() => !!document.querySelector('.thread-card'))));
    // (the stand-in goes full screen on a click, as YouTube's corner button does)
    check('the player\'s own full screen is left to it', (await fsTag()) === 'IFRAME', await fsTag());
    await page.mouse.click(400, 300); await sleep(600);
    check('…and its button brings it back', (await fsTag()) === '', await fsTag());
    check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
  } finally { await ctx.close(); }
}
try { await run(false); await run(true); await runMouse(); } finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ a YouTube clip: one tap, full screen, with sound' : '\n❌ failed');
process.exit(ok ? 0 : 1);