Files
rm-circle-team-router/qa/captions-e2e.mjs
T
martbost 1c51599f07 Captions: Manson's five videos, in Italian, French and Spanish
Manson narrowed the ask to five: how the team build works, joining on the site,
joining in the dApp, your level is your reach, and the textbook play. Those five now
carry English, Italian, French and Spanish caption tracks. The other fifteen training
videos are deliberately untouched.

Languages are the top three by real demand from the translation cache rather than by
instinct. German still outranks Portuguese there by more than double, which is worth
settling before adding a fourth.

Captions stay off for an English reader and switch on automatically for anyone who
already picked a language with the globe button, so this rides the choice members
have made rather than adding a second one.

Disclaimer language came through intact in all three, checked by hand because a
softened "no income is guaranteed" is a compliance problem rather than a typo:
Nessun reddito e garantito / Aucun revenu n'est garanti / No se garantizan ingresos.
A native-speaker read of those specific lines is still worth having before this is
promoted anywhere.

qa/captions-e2e.mjs now discovers the captioned players from training.html instead of
a hardcoded list, so it cannot drift as videos are added, and it reads the parsed cues
back out of each player: 91 assertions covering content type, cue counts, the right
track showing per language, English never showing alongside, and a guard against a
track that is really English wearing a foreign label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:20:46 -05:00

101 lines
4.8 KiB
JavaScript

// 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(/<video[^>]*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']) {
({ 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();
}
console.log('PASS ' + ok.length);
for (const b of bad) console.log('FAIL ' + b);
await browser.close();
process.exit(bad.length ? 1 : 0);