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:
+78
-41
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user