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>
This commit is contained in:
martbost
2026-09-17 06:20:46 -05:00
parent b3385ca3f8
commit 1c51599f07
20 changed files with 5170 additions and 46 deletions
+78 -41
View File
@@ -1,61 +1,98 @@
// 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, so
// checking the markup is not enough: this reads the parsed cues back out of the player.
//
// 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)); };
const browser = await chromium.launch();
const VTT = '/captions/rmc-team-overview-f8856dc743';
// 1. the files are served as real VTT, not octet-stream (the silent killer)
for (const lang of ['en', 'it']) {
const r = await fetch(B + VTT + '.' + lang + '.vtt');
const ct = r.headers.get('content-type') || '';
const body = await r.text();
t('serves ' + lang + '.vtt as text/vtt', r.ok && /text\/vtt/.test(ct), r.status + ' ' + ct);
t(lang + '.vtt starts with WEBVTT', body.startsWith('WEBVTT'), body.slice(0, 20));
t(lang + '.vtt has cues with timings', (body.match(/-->/g) || []).length > 50, String((body.match(/-->/g) || []).length));
// 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 load = async (lang) => {
const browser = await chromium.launch();
const load = async lang => {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
if (lang) await ctx.addInitScript(l => { try { localStorage.setItem('rmc.lang', l); } catch (e) {} }, lang);
const p = await ctx.newPage();
await p.goto(B + '/training', { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2500);
return { ctx, p };
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 readTracks = p => p.evaluate(() => {
const v = document.querySelector('video[src*="rmc-team-overview"]');
if (!v) return null;
return 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 : '' }));
});
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 available, nothing forced on screen
let { ctx, p } = await load(null);
let tracks = await readTracks(p);
t('the overview player has caption tracks', !!tracks && tracks.length === 2, JSON.stringify(tracks));
t('no captions forced on for an English member', !!tracks && tracks.every(x => x.mode !== 'showing'), JSON.stringify(tracks && tracks.map(x => x.mode)));
// 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. a member who chose Italian: the Italian track is showing, with real parsed cues
({ ctx, p } = await load('it'));
// the browser only parses a track once it is enabled, so read after the matcher runs
await p.waitForTimeout(1500);
tracks = await readTracks(p);
const it = (tracks || []).find(x => x.lang === 'it');
const en = (tracks || []).find(x => x.lang === 'en');
t('Italian track is the one showing', !!it && it.mode === 'showing', JSON.stringify(tracks && tracks.map(x => x.lang + ':' + x.mode)));
t('English track is not also showing', !!en && en.mode !== 'showing', en && en.mode);
t('the Italian cues actually parsed', !!it && it.cues > 50, it && String(it.cues));
t('and they are Italian, not English passthrough', !!it && /parte|reclutano|squadra|team/i.test(it.first) && !/^Most team builds fail/.test(it.first), it && it.first);
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);