// Proves the caption tracks actually load and follow the member's chosen language. // // A .vtt served with the wrong Content-Type is silently ignored by the browser, and a // track that fails to parse shows zero cues with no error anywhere. So checking the // markup is not enough: this reads the parsed cues back out of the player itself. // // Run: LOCAL=http://127.0.0.1:3399 node qa/captions-e2e.mjs // LOCAL=https://rmcircle.team node qa/captions-e2e.mjs import { pathToFileURL } from 'node:url'; import fs from 'node:fs'; const PW = 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright'; const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default; const B = process.env.LOCAL || 'http://127.0.0.1:3399'; const ok = [], bad = []; const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; // the captioned set is whatever training.html actually wires up, so this suite follows // the page rather than a list that can drift out of date const html = fs.readFileSync('public/training.html', 'utf8'); const players = []; for (const m of html.matchAll(/]*src="([^"]+\.mp4)"[^>]*>([\s\S]*?)<\/video>/g)) { const langs = [...m[2].matchAll(/srclang="([a-z]+)"[^>]*src="([^"]+)"/g)].map(x => ({ lang: x[1], src: x[2] })); if (langs.length) players.push({ video: m[1], langs }); } t('training.html wires up captioned players', players.length > 0, String(players.length)); console.log('captioned players: ' + players.length); // 1. every referenced file is served as real VTT with real cues for (const p of players) { for (const { lang, src } of p.langs) { const r = await fetch(B + src); const ct = r.headers.get('content-type') || ''; const body = r.ok ? await r.text() : ''; const cues = (body.match(/-->/g) || []).length; t(src.split('/').pop() + ' served as text/vtt with cues', r.ok && /text\/vtt/.test(ct) && body.startsWith('WEBVTT') && cues > 10, r.status + ' ' + ct + ' cues=' + cues); } } const browser = await chromium.launch(); const load = async lang => { const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); await ctx.addInitScript(l => { try { if (l) localStorage.setItem('rmc.lang', l); } catch (e) {} // the event flyer covers the page and is not what this suite tests const g = Storage.prototype.getItem; Storage.prototype.getItem = function (k) { if (/^rmc-promo-/.test(k)) return 'seen'; if (/^rmc-announce-/.test(k)) return 'done'; return g.call(this, k); }; }, lang); const page = await ctx.newPage(); await page.goto(B + '/training', { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3000); return { ctx, page }; }; const read = page => page.evaluate(() => Array.from(document.querySelectorAll('video')).map(v => ({ src: v.getAttribute('src'), tracks: Array.from(v.textTracks).map(tr => ({ lang: tr.language, mode: tr.mode, cues: tr.cues ? tr.cues.length : 0, first: tr.cues && tr.cues[0] ? tr.cues[0].text : '' })) }))); // 2. an English member: tracks are available but nothing is forced over the picture let { ctx, page } = await load(null); let vids = await read(page); for (const p of players) { const v = vids.find(x => x.src === p.video); t('player has its tracks: ' + p.video.split('/').pop(), !!v && v.tracks.length === p.langs.length, v ? String(v.tracks.length) : 'player not found'); t('nothing forced on for an English member: ' + p.video.split('/').pop(), !!v && v.tracks.every(x => x.mode !== 'showing'), v && JSON.stringify(v.tracks.map(x => x.mode))); } await ctx.close(); // 3. each translated language: that track shows, parses, and is not English passthrough const ENGLISH_OPENERS = /^(Most team builds|Welcome|Hey|In this video|Alright|So )/i; for (const lang of ['it', 'fr', 'es', 'de', 'pt']) { ({ ctx, page } = await load(lang)); await page.waitForTimeout(1500); vids = await read(page); for (const p of players) { if (!p.langs.some(l => l.lang === lang)) continue; const v = vids.find(x => x.src === p.video); const tr = v && v.tracks.find(x => x.lang === lang); const name = p.video.split('/').pop().slice(0, 30); t(lang + ': showing on ' + name, !!tr && tr.mode === 'showing', tr ? tr.mode : 'track missing'); t(lang + ': cues parsed on ' + name, !!tr && tr.cues > 10, tr ? String(tr.cues) : '-'); t(lang + ': not English passthrough on ' + name, !!tr && !ENGLISH_OPENERS.test(tr.first), tr && tr.first.slice(0, 50)); t(lang + ': English not also showing on ' + name, !!v && v.tracks.every(x => x.lang === lang || x.mode !== 'showing'), v && JSON.stringify(v.tracks.map(x => x.lang + ':' + x.mode))); } await ctx.close(); } // 4. DISCOVERABILITY. Marty could not find the captions on his own page because the // native CC button is tiny and nothing switches on for an English reader. A feature // nobody can find is not shipped, so every captioned player must carry visible chips. ({ ctx, page } = await load(null)); const bars = await page.evaluate(() => Array.from(document.querySelectorAll('video')).map(v => { const bar = v.parentNode && v.parentNode.querySelector('.cap-bar'); return { src: v.getAttribute('src'), has: !!bar, labels: bar ? Array.from(bar.querySelectorAll('button[data-lang]')).map(b => b.textContent) : [] }; })); for (const p of players) { const b = bars.find(x => x.src === p.video); const name = p.video.split('/').pop().slice(0, 30); t('visible subtitle chips on ' + name, !!b && b.has, 'no .cap-bar'); t('chips cover every track plus Off on ' + name, !!b && b.labels.length === p.langs.length + 1, b && b.labels.join(',')); t('chips are named in their own language on ' + name, !!b && b.labels.includes('Italiano') && b.labels.includes('Deutsch'), b && b.labels.join(',')); } // clicking a chip switches the track for an English reader who wants one await page.click('.cap-bar button[data-lang="pt"]').catch(() => {}); await page.waitForTimeout(900); const afterClick = await read(page); const firstWithTracks = afterClick.find(v => v.tracks.length); t('clicking a chip turns that language on', !!firstWithTracks && firstWithTracks.tracks.some(x => x.lang === 'pt' && x.mode === 'showing'), firstWithTracks && JSON.stringify(firstWithTracks.tracks.map(x => x.lang + ':' + x.mode))); await page.click('.cap-bar button[data-lang=""]').catch(() => {}); await page.waitForTimeout(700); const afterOff = await read(page); const off = afterOff.find(v => v.tracks.length); t('"Off" turns captions back off', !!off && off.tracks.every(x => x.mode !== 'showing'), off && JSON.stringify(off.tracks.map(x => x.mode))); await ctx.close(); console.log('PASS ' + ok.length); for (const b of bad) console.log('FAIL ' + b); await browser.close(); process.exit(bad.length ? 1 : 0);