1c51599f07
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>
101 lines
4.8 KiB
JavaScript
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);
|