88a3ad1149
Three things. GERMAN AND PORTUGUESE. Marty's call, and the right one: the translation cache only shows who is already here, not who Manson is bringing. Manson knows his audience, so Portuguese goes in even though site data ranks German above it. All five videos now carry English, Italian, French, Spanish, German and Portuguese. Disclaimers checked by hand in the two new languages: "Es wird kein Einkommen garantiert" and "Nenhuma renda e garantida". FINDABILITY. Marty went looking for the multilingual feature on his own training page and could not find it, because the first build leaned on the native CC control and nothing switches on for an English reader. A feature nobody can find is not shipped. Every captioned player now carries a visible row of subtitle chips underneath, named in their own language, with an Off option. Captions still stay off by default for an English reader and still turn themselves on for anyone who already picked a language with the globe button; the chips just make the choice visible instead of buried. THE DEAD BUTTON. The inbox banner's "Add mine" shipped with NO click handler, so it rendered and did absolutely nothing. That is why Marty could not add his address while connected to the right wallet: nothing was listening. It now opens the same optional profile dialog and re-renders when it completes. Tests: gate-e2e 38 (up from 33, and rendering that button is explicitly not the test any more, it has to actually open the dialog), captions-e2e 158 across six languages including the new chip behaviour, clicking one and turning them back off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
6.8 KiB
JavaScript
131 lines
6.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', '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);
|