tracker
All repositories: gitoria
29.3 KB
// tests/kinds.mjs — THE GATE of tracker.worldapi.org#20 + #21 (mission 068): typed page headings ("Series | Reacher") and the// split of TV titles into Series (/series) and Shows (/shows) — a gate of its own beside tests/browser.mjs (same parts: its own// ident from identkit.mjs, the fake TMDB, a real headless Chrome), on its own storage (.scratch/kinds-store). Proves:// - KIND FROM THE STORED DATA (kind backfill off): a title with the genre Talk is a SHOW at once, every other TV title a series;// /series and /shows list exactly those; no TMDB request// - THE DAILY SYNC stores TMDB's type: a followed series whose type is Reality moves to /shows without a restart// - THE KIND BACKFILL: one GET /tv/<id> per TV title without a kind and without a show genre (never for a Talk title or one// without tmdbId), ≥ 260 ms apart; Video / News → show, Scripted / Documentary / Miniseries → series, a 404 stays a series;// the lists change without a restart; it RESUMES after a stop (no title asked twice) and has nothing to do on the next start// - HEADINGS (#20) on every page kind: "<Type> | <name>", the type word in its colour (computed), the bar muted, the name white;// list pages (/movies, /series, /shows) their type word; the homepage rows' headings and the search / filmography labels// - ADDRESSES: /series/<slug>, /shows/<slug>, /movies/<slug>; the old /shows/<slug> of a series → 301 /series/<slug>, of a movie// → 301 /movies/<slug>; a show at /shows/<slug> renders (and works: Follow, a season) although that route is a function route// - SIGNED IN: five home tiles, /my/series and /my/shows each with its own titles, the search's labels, a search import of a// talk show lands on /shows/<slug>// - 390 / 1280 px: nothing scrolls sideways, a long show name wraps beside "Show |"; screenshots with TRACKER_GATE_SHOTS//// TRACKER_GATE_SHOTS=/tmp/w068/shots TRACKER_GATE_PORT=8761 TRACKER_GATE_IDENT_PORT=8762 TRACKER_GATE_CHROME=8763-8765 \// TRACKER_GATE_TMDB_PORT=8766 node tests/kinds.mjsimport { spawn, execFileSync } from 'node:child_process';import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';import { dirname, join, resolve } from 'node:path';import { fileURLToPath } from 'node:url';import { launchBrowser } from './cdp.mjs';import { startIdent } from './identkit.mjs';import { startFakeTmdb } from './faketmdb.mjs';if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';const HERE = dirname(fileURLToPath(import.meta.url));const APP = resolve(HERE, '..');const PORT = Number(process.env.TRACKER_GATE_PORT || 8700);const [CH_FROM, CH_TO] = (process.env.TRACKER_GATE_CHROME || '8702-8709').split('-').map(Number);const IDENT_PORT = Number(process.env.TRACKER_GATE_IDENT_PORT || 8701);const TMDB_PORT = Number(process.env.TRACKER_GATE_TMDB_PORT || 8710);const SHOTS = process.env.TRACKER_GATE_SHOTS || '';const BASE = `http://127.0.0.1:${PORT}`;const STORE = join(APP, '.scratch', 'kinds-store');const SESSIONS = join(APP, '.scratch', 'kinds-sessions');const IDENT_DIR = process.env.TRACKER_GATE_IDENT_DIR || [resolve(APP, '../ident.worldapi.org'), '/media/STORAGE/projects/ident.worldapi.org'].find(d => existsSync(join(d, 'project.hl')));rmSync(STORE, { recursive: true, force: true });rmSync(SESSIONS, { recursive: true, force: true });mkdirSync(SESSIONS, { recursive: true });if (SHOTS) mkdirSync(SHOTS, { recursive: true });let failures = 0, passes = 0;function check(label, ok, detail = '') {console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);if (ok) passes++; else failures++;}const sleep = (ms) => new Promise(r => setTimeout(r, ms));let ident = null, server = null, browser = null, log = '', tmdb = null;async function stopServer() {if (server) {const s = server;try { s.kill('SIGTERM'); } catch {}await new Promise(r => { if (s.exitCode !== null || s.signalCode !== null) return r(); s.once('exit', r); setTimeout(r, 3000); });server = null;}}async function stop() {if (ident) { try { await ident.stop(); } catch {} ident = null; }if (browser) { try { await browser.close(); } catch {} browser = null; }await stopServer();if (tmdb) { try { await tmdb.stop(); } catch {} tmdb = null; }}// what the fake TMDB says about the TV titles (tests/seed-kinds.hl + seed-show.hl's two followed series); 7007 is unknown (404)const tvOf = (id, name, type, extra = {}) => ({ id, name, type, overview: name, status: 'Returning Series', poster_path: null, seasons: [], external_ids: {}, ...extra });const gateShows = () => ({1001: tvOf(1001, 'Gate Test Show', 'Scripted'), 1002: tvOf(1002, 'Unwatched Gate Show', 'Scripted'),7001: tvOf(7001, 'Gate Reacher', 'Scripted'), 7002: tvOf(7002, 'Gate Late Show With A Very Long Name That Has To Wrap On A Phone', 'Talk Show'), 7003: tvOf(7003, 'Gate Island Reality', 'Reality'),7004: tvOf(7004, 'Gate Nature Docs', 'Documentary'), 7005: tvOf(7005, 'Gate Evening News', 'News'), 7006: tvOf(7006, 'Gate Mini', 'Miniseries'),7008: tvOf(7008, 'Gate Clip Video', 'Video'),// the search import (#14): a talk show only TMDB has7010: tvOf(7010, 'Gate Imported Talk', 'Talk Show', { first_air_date: '2021-04-04', genres: [{ id: 10767, name: 'Talk' }] }),});const gateSearch = () => ({ 'gate imported': [{ media_type: 'tv', id: 7010, name: 'Gate Imported Talk', first_air_date: '2021-04-04', poster_path: null, overview: 'talk' }] });// the palette (shared/tokens.hl) as computed coloursconst C = { movie: 'rgb(220, 220, 170)', series: 'rgb(78, 201, 176)', show: 'rgb(197, 134, 192)', genre: 'rgb(86, 155, 212)', person: 'rgb(206, 145, 120)',white: 'rgb(245, 250, 255)', muted: 'rgb(145, 150, 155)' };const SERIES_ALL = ['Gate Clip Video', 'Gate Evening News', 'Gate Island Reality', 'Gate Lost Kind', 'Gate Mini', 'Gate Nature Docs', 'Gate No Id', 'Gate Reacher', 'Gate Test Show', 'Pager Older Series', 'Unwatched Gate Show'];const LATE = 'Gate Late Show With A Very Long Name That Has To Wrap On A Phone';async function main() {ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });const alice = await ident.signIn('[email protected]');const APPKEY = await ident.registerApp(alice, 'tracker (kinds gate)', [BASE]);const aliceIdentity = await ident.exchange(APPKEY, await ident.selectorCode(alice, APPKEY, BASE));check('ident: our own ident runs, the app is registered', /^pk_[0-9a-f]{32}$/.test(APPKEY.key) && !!aliceIdentity);const MPACKDB = join(STORE, 'mpackdb');mkdirSync(MPACKDB, { recursive: true });const seedOut = execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-show.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_IDENTITY: aliceIdentity } }).toString()+ execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-kinds.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_USER: 'gateu0000000000u1' } }).toString();check('seed: the fixture shows (tests/seed-show.hl) + 9 TV titles of both kinds (tests/seed-kinds.hl), alice follows 3 of them', seedOut.includes('gate-test-show') && seedOut.includes('seed-kinds: 9 TV titles, 3 genres, 3 follows'), seedOut);tmdb = await startFakeTmdb({ port: TMDB_PORT, shows: gateShows(), movies: { 1003: { id: 1003, title: 'Gate Test Movie', poster_path: null, external_ids: {} } }, search: gateSearch() });const startServer = async (extra = {}) => {log = '';server = spawn(join(APP, 'bin/hybriel'), ['project.hl'], { cwd: APP, env: { ...process.env, TRACKER_PORT: String(PORT), TRACKER_SESSIONS: SESSIONS + '/', TRACKER_STORAGE: MPACKDB,TRACKER_WATCH: '0', TRACKER_URL: BASE, IDENT_URL: ident.base, IDENT_EXCHANGE_URL: ident.base, TRACKER_KEY: APPKEY.key, TRACKER_SECRET: APPKEY.secret,TMDB_READ_TOKEN: 'gate-token', TMDB_BASE_URL: tmdb.base + '/3', TMDB_IMAGE_URL: tmdb.base + '/t/p', TVMAZE_BASE_URL: tmdb.base + '/tvmaze',TRACKER_SYNC: '0', TRACKER_BACKFILL: '0', TRACKER_SHORTIDS: '0', TRACKER_KINDS: '0', TRACKER_KINDS_LOG_EVERY: '2', TRACKER_REPAIR: '0', TRACKER_CREDITS: '0', TRACKER_COLLECTIONS: '0', ...extra },stdio: ['ignore', 'pipe', 'pipe'] });server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);for (let i = 0; ; i++) {try { if ((await fetch(BASE + '/')).ok) break; } catch {}if (i > 80) throw new Error('tracker did not come up\n' + log);await sleep(250);}};const waitLog = async (re, ms, label) => {for (let t = 0; t < ms && !re.test(log); t += 100) await sleep(100);if (!re.test(log)) throw new Error('log never showed ' + label + '\n' + log.slice(-3000));};// a list page's tile titles, every page of it (node fetch: the server's own render)const listed = async (path) => {const out = [];for (let p = 1; p < 10; p++) {const h = await (await fetch(BASE + (p === 1 ? path : path + '/page/' + p))).text();const t = [...h.matchAll(/<tile-title>([^<]*)<\/tile-title>/g)].map(m => m[1]);out.push(...t);if (!h.includes('page ' + p + ' of ' + p) && t.length === 24) continue;break;}return out.sort();};// ==== 1. THE STORED DATA ALONE (kind backfill off): the Talk genre makes a show, everything else is a series ====await startServer();check('kinds off: the log says so, and no TMDB request was made', /kind backfill: off \(TRACKER_KINDS=0\)/.test(log) && tmdb.requests.length === 0, log);let ser = await listed('/series'), sho = await listed('/shows');check('stored data: /series lists every TV title without a show genre (11, Gate Island Reality too — its type is not known yet); /shows only the Talk one',JSON.stringify(ser) === JSON.stringify(SERIES_ALL) && JSON.stringify(sho) === JSON.stringify([LATE]), JSON.stringify({ ser, sho }));const head = async (path, opts = {}) => { const r = await fetch(BASE + path, { redirect: 'manual', ...opts }); return { status: r.status, location: r.headers.get('location'), html: r.status === 200 ? await r.text() : '' }; };let r1 = await head('/shows/gate-reacher'), r2 = await head('/shows/gate-test-movie'), r3 = await head('/shows/gate-late-show'), r4 = await head('/series/gate-reacher'), r5 = await head('/movies/gate-test-movie'), r6 = await head('/shows/no-such-title');check('old links: /shows/<series> → 301 /series/<slug>, /shows/<movie> → 301 /movies/<slug>',r1.status === 301 && r1.location === '/series/gate-reacher' && r2.status === 301 && r2.location === '/movies/gate-test-movie', JSON.stringify([r1, r2].map(r => [r.status, r.location])));const h1Of = (h) => { const m = h.match(/<h1[^>]*>([\s\S]*?)<\/h1>/); return m ? m[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() : null; };check('addresses: /shows/<show>, /series/<series>, /movies/<movie> answer 200 with "<Type> | <name>"; an unknown /shows/<slug> its "Not found"',r3.status === 200 && h1Of(r3.html) === 'Show | ' + LATE && r4.status === 200 && h1Of(r4.html) === 'Series | Gate Reacher' && r5.status === 200 && h1Of(r5.html) === 'Movie | Gate Test Movie'&& r6.status === 200 && h1Of(r6.html) === 'Not found', JSON.stringify([r3, r4, r5, r6].map(r => [r.status, h1Of(r.html)])));const r7 = await head('/shows/page/1'), r8 = await head('/my/nothing');check('/shows/page/1 is still the list; /my/<anything else> says "Not found"', r7.status === 200 && h1Of(r7.html) === 'Shows' && h1Of(r8.html) === 'Not found', JSON.stringify([h1Of(r7.html), h1Of(r8.html)]));await stopServer();// ==== 2. THE DAILY SYNC stores TMDB's type: alice's followed Gate Island Reality (type Reality) moves to /shows, no restart ====await startServer({ TRACKER_SYNC: '1', TRACKER_SYNC_HOUR: String(new Date().getUTCHours()) });await waitLog(/tmdb sync done: /, 30000, 'the daily sync');ser = await listed('/series'); sho = await listed('/shows');check('daily sync: the followed Gate Island Reality (TMDB type Reality) is a show now — on /shows, gone from /series — without a restart',sho.includes('Gate Island Reality') && !ser.includes('Gate Island Reality') && ser.includes('Gate Reacher') && sho.length === 2, JSON.stringify({ ser, sho }));await stopServer();// ==== 3. THE KIND BACKFILL, stopped in the middle (TMDB answers slowly), then resumed ====const before = tmdb.requests.length;tmdb.delayMs = 700;await startServer({ TRACKER_KINDS: '1' });await waitLog(/kind backfill: start, \d+ TV titles without a kind/, 10000, 'the kind backfill start');const firstStart = Number(log.match(/kind backfill: start, (\d+) TV titles/)[1]);await waitLog(/kind backfill: [2-4]\/\d+ titles/, 20000, 'two steps');await stopServer();const firstLog = log;tmdb.delayMs = 0;await startServer({ TRACKER_KINDS: '1' });await waitLog(/kind backfill done: /, 20000, 'the kind backfill done');const secondStart = Number((log.match(/kind backfill: start, (\d+) TV titles/) || [])[1]);const doneLine = (log.match(/kind backfill done: .*/) || [''])[0];// the sync of step 2 stored the kind of the 5 followed series (1001, 1002, 7001, 7002, 7003): 7 TV titles are left (7004–7008,// Gate No Id and seed-show's Pager Older Series, both without tmdbId)check('kind backfill: starts with the 7 TV titles still without a kind (the sync did the followed ones), RESUMES after a stop with fewer',firstStart === 7 && secondStart > 0 && secondStart < firstStart, JSON.stringify({ firstStart, secondStart }));const asked = tmdb.requests.slice(before).map(r => r.path).filter(p => /^\/3\/tv\/\d+$/.test(p));const dup = asked.filter((p, i) => asked.indexOf(p) !== i);check('kind backfill: one GET /tv/<id> each for 7004, 7005, 7006, 7007, 7008 — none twice across the stop, never the Talk show (7002) or the title without tmdbId',dup.length === 0 && JSON.stringify([...asked].sort()) === JSON.stringify(['/3/tv/7004', '/3/tv/7005', '/3/tv/7006', '/3/tv/7007', '/3/tv/7008']), JSON.stringify(asked));const times = tmdb.requests.slice(before).filter(r => /^\/3\/tv\/\d+$/.test(r.path)).map(r => r.at);const gaps = times.slice(1).map((t, i) => t - times[i]).filter(g => g < 5000);check('kind backfill: ≥ 260 ms between two TMDB requests', gaps.every(g => g >= 255), JSON.stringify(gaps));const kindLog = (firstLog + log).split('\n').filter(l => l.startsWith('kind')).join('\n');check('kind backfill: logs start, every 2 titles (TRACKER_KINDS_LOG_EVERY) and a done line; the 404 (7007) is logged once',/kind backfill done: \d+\/\d+ titles, series=\d+ shows=\d+ fromGenre=\d+ moved=\d+ requests=\d+ errors=\d+ retries=0 seconds=/.test(doneLine)&& (kindLog.match(/Gate Lost Kind: HTTP 404/g) || []).length === 1, kindLog);ser = await listed('/series'); sho = await listed('/shows');check('kind backfill → the lists at once: /shows = Gate Clip Video (Video), Gate Evening News (News), Gate Island Reality, the Talk show; /series the other 8 (Documentary, Miniseries, Scripted, the 404, no tmdbId)',JSON.stringify(sho) === JSON.stringify(['Gate Clip Video', 'Gate Evening News', 'Gate Island Reality', LATE])&& JSON.stringify(ser) === JSON.stringify(['Gate Lost Kind', 'Gate Mini', 'Gate Nature Docs', 'Gate No Id', 'Gate Reacher', 'Gate Test Show', 'Pager Older Series', 'Unwatched Gate Show']), JSON.stringify({ ser, sho }));await stopServer();const reqBefore = tmdb.requests.length;await startServer({ TRACKER_KINDS: '1' });await waitLog(/kind backfill: (nothing to do|start, \d+)/, 10000, 'the third start');await sleep(600);check('kind backfill: the next start has nothing to do and asks TMDB nothing', /kind backfill: nothing to do/.test(log) && tmdb.requests.length === reqBefore, log.split('\n').filter(l => l.startsWith('kind')).join('\n'));ser = await listed('/series'); sho = await listed('/shows');check('after a restart the stored kinds give the same two lists', sho.length === 4 && ser.length === 8 && sho.includes('Gate Evening News') && ser.includes('Gate Lost Kind'), JSON.stringify({ ser, sho }));// ==== 4. IN THE BROWSER: headings, colours, lists, the home, the search, signed in ====browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });const page = await browser.newPage();page.captureNetwork();const ev = (e) => page.evaluate(e);const hydrated = () => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'hydrated' });const go = async (path) => { await page.goto(BASE + path); await hydrated(); };// the h1 of a typed heading: its parts' text and computed coloursconst typed = () => ev(`JSON.stringify((() => { const h = document.querySelector('h1.typed'); if (!h) return null; const c = (s) => { const e = h.querySelector(s); return e ? [e.textContent.trim(), getComputedStyle(e).color] : null; };return { word: c('type-word'), sep: c('type-sep'), name: c('type-name'), text: h.innerText.replace(/\\s+/g, ' ').trim() }; })())`).then(JSON.parse);const isTyped = (t, word, color, name) => !!t && t.word && t.word[0] === word && t.word[1] === color && (name == null ? !t.name : (t.sep && t.sep[0] === '|' && t.sep[1] === C.muted && t.name && t.name[0] === name && t.name[1] === C.white));const loginAs = async (who) => {const code = await ident.selectorCode(who, APPKEY, BASE);await page.goto(BASE + '/login/callback?ident_code=' + code);await page.waitForSelector('#logout');await hydrated();};const shot = async (name) => { if (SHOTS) writeFileSync(join(SHOTS, name + '.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })).data, 'base64')); };const HEADINGS = [['/series/gate-reacher', 'Series', C.series, 'Gate Reacher'],['/shows/gate-late-show', 'Show', C.show, LATE],['/movies/gate-test-movie', 'Movie', C.movie, 'Gate Test Movie'],['/genres/Drama', 'Genre', C.genre, 'Drama'],['/person/Complete-Person', 'Person', C.person, 'Complete Person'],['/movies', 'Movies', C.movie, null], ['/series', 'Series', C.series, null], ['/shows', 'Shows', C.show, null]];const seen = {};for (const [path, word, color, name] of HEADINGS) { await go(path); seen[path] = await typed(); }check('headings (#20): "Series | Gate Reacher", "Show | …", "Movie | …", "Genre | Drama", "Person | Complete Person" — the type word in its colour, the bar muted, the name white; /movies /series /shows their type word',HEADINGS.every(([path, word, color, name]) => isTyped(seen[path], word, color, name)), JSON.stringify(seen));check('headings read as one line of text: "Series | Gate Reacher"', seen['/series/gate-reacher'].text === 'Series | Gate Reacher', seen['/series/gate-reacher'].text);// the internally rendered show page (a function route) hydrates and works: its one season (open) closes and opens again —// the episodes come from a face (showSeasonEpisodes)await go('/shows/gate-late-show');const open0 = await ev(`document.querySelectorAll('episode-row').length`);await page.click('button.season-toggle');await page.waitFor(`document.querySelectorAll('episode-row').length === 0`, { timeout: 10000, label: 'season closed' }).catch(() => {});await page.click('button.season-toggle');await page.waitFor(`document.querySelectorAll('episode-row').length === 1`, { timeout: 10000, label: 'season opened' }).catch(() => {});check('/shows/<show> (rendered by the function route) is a live page: its season closes and opens again with the episode "First Night"',open0 === 1 && (await ev(`Array.from(document.querySelectorAll('episode-row episode-title')).map(e => e.textContent).join(',')`)) === 'First Night');// the homepage rows: headings in their colours, the Shows row has the show (signed out)await go('/');const homeHeads = await ev(`JSON.stringify(Array.from(document.querySelectorAll('home-section h2 a')).map(a => [a.textContent, a.getAttribute('href'), getComputedStyle(a).color]))`);check('home: three rows — Movies (yellow) → /movies, Series (accent) → /series, Shows (purple) → /shows',homeHeads === JSON.stringify([['Movies', '/movies', C.movie], ['Series', '/series', C.series], ['Shows', '/shows', C.show]]), homeHeads);const homeShows = await ev(`JSON.stringify(Array.from(document.querySelectorAll('#home-shows a.tile')).map(a => a.querySelector('tile-title').textContent + '|' + a.getAttribute('href')))`);const homeSeries = await ev(`JSON.stringify(Array.from(document.querySelectorAll('#home-series a.tile')).map(a => a.querySelector('tile-title').textContent))`);check('home: the Shows row has the show with a released episode → /shows/<slug>; the Series row no show',homeShows === JSON.stringify([LATE + '|/shows/gate-late-show']) && !homeSeries.includes('Gate Late'), homeShows + ' ' + homeSeries);// a tile on /series is a client-side navigation; one on /shows a full page load (its route is a function route)await go('/series');await ev(`window.__w068 = 'same document'`);await page.click('a.tile[href="/series/gate-reacher"]');await page.waitFor(`location.pathname === '/series/gate-reacher' && !!document.querySelector('h1.typed type-name')`, { timeout: 10000, label: 'series tile' });check('/series: a tile opens /series/<slug> client-side ("Series | Gate Reacher")', (await ev('window.__w068')) === 'same document' && (await typed()).text === 'Series | Gate Reacher');await go('/shows');await page.click('a.tile[href="/shows/gate-evening-news"]');await page.waitFor(`location.pathname === '/shows/gate-evening-news' && !!document.querySelector('h1.typed type-name')`, { timeout: 10000, label: 'show tile' });await hydrated();check('/shows: a tile opens /shows/<slug> ("Show | Gate Evening News")', (await typed()).text === 'Show | Gate Evening News');// ---- signed in (alice) ----await loginAs(alice);await go('/');const tiles = await ev(`Array.from(document.querySelectorAll('home-nav a.hometile')).map(a => a.textContent.trim() + ' ' + a.getAttribute('href')).join(',')`);check('home signed in: five tiles — Unwatched, Schedule, Series → /my/series, Shows → /my/shows, Movies', tiles === 'Unwatched /my/unwatched,Schedule /my/schedule,Series /my/series,Shows /my/shows,Movies /my/movies', tiles);const myRows = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('my-show-row a.myshow-title')).map(a => a.textContent + '|' + a.getAttribute('href')))`).then(JSON.parse);await go('/my/series');const mySeries = await myRows(), mySeriesHead = await typed();await go('/my/shows');const myShows = await myRows(), myShowsHead = await typed();check('/my/series: alice\'s followed SERIES (no show), each → /series/<slug>; heading "My Series"',JSON.stringify(mySeries) === JSON.stringify(['Unwatched Gate Show (2025)|/series/unwatched-gate-show', 'Gate Reacher (2022)|/series/gate-reacher', 'Gate Test Show (2024)|/series/gate-test-show'])&& mySeriesHead.text === 'My Series' && mySeriesHead.word[1] === C.series, JSON.stringify({ mySeries, mySeriesHead }));check('/my/shows: alice\'s followed SHOWS only, each → /shows/<slug>; heading "My Shows" (purple)',JSON.stringify(myShows) === JSON.stringify(['Gate Island Reality (2010)|/shows/gate-island-reality', LATE + ' (2015)|/shows/gate-late-show'])&& myShowsHead.text === 'My Shows' && myShowsHead.word[1] === C.show, JSON.stringify({ myShows, myShowsHead }));// Follow on the internally rendered show page: a session face works there (unfollow, then follow again)await go('/shows/gate-evening-news');const f0 = await ev(`document.querySelector('#follow').textContent`);await page.click('#follow');await page.waitFor(`document.querySelector('#follow').textContent === 'Following'`, { timeout: 10000, label: 'follow' }).catch(() => {});await go('/my/shows');const afterFollow = await myRows();await go('/shows/gate-evening-news');await page.click('#follow');await page.waitFor(`document.querySelector('#follow').textContent === 'Follow'`, { timeout: 10000, label: 'unfollow' }).catch(() => {});await go('/my/shows');const afterUnfollow = await myRows();check('/shows/<show>: Follow works on the function-rendered page (→ top of /my/shows), and Following → Follow again',f0 === 'Follow' && afterFollow[0] === 'Gate Evening News (1990)|/shows/gate-evening-news' && afterUnfollow.length === 2, JSON.stringify({ f0, afterFollow, afterUnfollow }));// the search: our titles with their kind word in its colour, each → its own page; a talk show imported from TMDB lands on /showsawait go('/search/gate');const sr = JSON.parse(await ev(`JSON.stringify(Array.from(document.querySelectorAll('#titles search-row')).map(r => [r.querySelector('.search-title').textContent, r.querySelector('.search-title').getAttribute('href'), r.querySelector('search-type').textContent, getComputedStyle(r.querySelector('search-type')).color]))`));const want = (t, href, word, color) => sr.some(r => r[0].startsWith(t) && r[1] === href && r[2] === word && r[3] === color);check('search: labels Series / Show / Movie in their colours, links to /series/, /shows/, /movies/',want('Gate Reacher', '/series/gate-reacher', 'Series', C.series) && want('Gate Evening News', '/shows/gate-evening-news', 'Show', C.show)&& want('Gate Test Movie', '/movies/gate-test-movie', 'Movie', C.movie) && want('Gate Late Show', '/shows/gate-late-show', 'Show', C.show), JSON.stringify(sr));await go('/search/gate%20imported');await page.click('#fetchweb');await page.waitFor(`!!document.querySelector('web-row button.add')`, { timeout: 10000, label: 'web results' });const webLabel = await ev(`document.querySelector('web-row search-type').textContent`);await page.click('web-row button.add');await page.waitFor(`location.pathname === '/shows/Gate-Imported-Talk' && !!document.querySelector('h1.typed type-name')`, { timeout: 15000, label: 'imported talk show' }).catch(() => {});await hydrated();const imp = await typed();check('search import: a TMDB "TV" result of type Talk Show → stored as a show, opens /shows/Gate-Imported-Talk ("Show | Gate Imported Talk"), and /shows lists it',webLabel === 'TV' && (await ev('location.pathname')) === '/shows/Gate-Imported-Talk' && imp && imp.text === 'Show | Gate Imported Talk' && (await listed('/shows')).includes('Gate Imported Talk'), JSON.stringify({ webLabel, imp, path: await ev('location.pathname') }));// the person page: its filmography labels (Series / Movie) in their colours, each → its own pageawait go('/person/Complete-Person');const film = await ev(`JSON.stringify(Array.from(document.querySelectorAll('a.film-row')).map(a => [a.getAttribute('href'), a.querySelector('film-type').textContent, getComputedStyle(a.querySelector('film-type')).color]))`);check('person: the filmography says Series / Movie in their colours and links to /series/ and /movies/',film === JSON.stringify([['/series/gate-test-show', 'Series', C.series], ['/movies/gate-test-movie', 'Movie', C.movie]]), film);// ---- 390 / 1280: nothing sideways; the long show name wraps beside "Show |"; screenshots ----const PAGES = [['series-page', '/series/gate-reacher'], ['show-page', '/shows/gate-late-show'], ['movie-page', '/movies/gate-test-movie'], ['genre', '/genres/Drama'],['person', '/person/Complete-Person'], ['series-list', '/series'], ['shows-list', '/shows'], ['movies-list', '/movies'], ['home', '/'], ['my-series', '/my/series'], ['my-shows', '/my/shows'], ['search', '/search/gate']];const layout = {};for (const [w, mobile] of [[390, true], [1280, false]]) {await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: 844, deviceScaleFactor: mobile ? 2 : 1, mobile });for (const [name, path] of PAGES) {await go(path);await page.waitFor(`Array.from(document.images).every(i => i.complete)`, { timeout: 5000, label: 'images' }).catch(() => {});layout[name + '-' + w] = await ev(`document.documentElement.scrollWidth <= ${w}`);if (name === 'show-page') layout['wrap-' + w] = await ev(`JSON.stringify((() => { const h = document.querySelector('h1.typed'), w = h.querySelector('type-word').getBoundingClientRect(), ne = h.querySelector('type-name'), n = ne.getBoundingClientRect();return { lines: Math.round(n.height / parseFloat(getComputedStyle(ne).lineHeight)), besideWord: n.left > w.right, right: n.right <= h.getBoundingClientRect().right + 0.5 }; })())`).then(JSON.parse);await shot(name + '-' + w);}}await page.send('Emulation.clearDeviceMetricsOverride', {});check('390 / 1280: no page scrolls sideways', Object.entries(layout).filter(([k]) => !k.startsWith('wrap')).every(([, v]) => v === true), JSON.stringify(layout));check('390: the long show name wraps (several lines) in its own column right of "Show |"; 1280: fewer lines',layout['wrap-390'].lines >= 2 && layout['wrap-390'].besideWord && layout['wrap-390'].right && layout['wrap-1280'].lines < layout['wrap-390'].lines, JSON.stringify([layout['wrap-390'], layout['wrap-1280']]));const problems = page.problems ? page.problems() : [];check('browser: no console errors / failed requests', problems.length === 0, JSON.stringify(problems).slice(0, 2000));}try { await main(); } catch (e) {check('gate ran without throwing', false, (e && e.stack) || String(e));console.log('server log (tail):\n' + log.slice(-3000));}await stop();try { execFileSync('pgrep', ['-f', `remote-debugging-port=${CH_FROM}`]); console.log('WARNING: a Chrome on ' + CH_FROM + ' is still running'); } catch {}console.log(`\n${passes} passed, ${failures} failed`);process.exit(failures ? 1 : 0);
Branches
- mainmain branch
Latest commits
- 8751adb8tracker: report 032mre
- 9bce1f65tracker mission 032: STATUS gate files + the hour-boundary flakemre
- 718bfb89tracker#37 (mission 032): /people = everyone, last updated first (updatedAt stamped by the person fill; view built at boot, touched people first at once), photo + name tiles (person colour) with the /movies pagination, /people/<letter> removed; photo = our file, tmdbProfile, a cast/crew entry's profile (in-memory map at boot), else the new 'no photo' placeholder; new cast/crew/created_by people keep tmdbProfile; search people rows with the photo; /settings = the heading only; util.hl sortDesc starts from sorted runs (same result, 105k: 1.6 s -> 0.15 s); gates 369/0, 32/0, 52/0, check-theme 0; README/STATUS/LOGmre
- 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
- eb3b9205tracker: report 031mre
- 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre
- 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre
- a386dc92tracker: reports 029 + 030mre
- 71e0fd7dtracker missions 029 + 030: README (What it does, Files, gate count), STATUS (real-copy numbers, how to repeat, open points), LOGmre
- d36ea6eatracker#34 + #35 (mission 030): Follow directly under the poster, as wide as the poster (show.hl, styles.hl); the status pill next to a series' title — TVmaze's status (new tvmazeStatus, stored by the sync's TVmaze merge) else TMDB's, TVmaze Ended + TMDB Canceled = Canceled, inverted (filled, dark text, no border), green running / yellow pending / red canceled / muted ended (shows.hl statusOf); the daily delta asks TVmaze's status of an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep, sync.hl syncTvmazeStatus); the status backfill after the details repair (backfill.hl, jobs.hl statusTick; resumable, 550 ms per TVmaze request); gates 354/0, 32/0, 52/0mre
- 7d7d4487tracker#33 (mission 029): reduced titles — every title TMDB's details never went through this app (no detailsAt, no tmdbSync) is incomplete (shows.hl isIncomplete; the old tracker's migrated rows passed #26's test: 5,697 non-adult on the live copy, 691 series without seasons); the repair job does the visibly reduced first (shows.hl missingParts), the page completes one on open; a title TMDB has no poster for (The Remaining) shows the placeholder; tools/count-incomplete.hl; gate fixtures stand for synced titles (tmdbSync), tests/seed-reduced.hl + #33 checks; gates 347/0, 32/0, 52/0mre
- 661c2592tracker: report 028mre
- 27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre
- d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre
- 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
- 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
- 4f47f181tracker: report 027mre