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
- dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre
- 2667da05tracker#30 (mission 025): /my/ pages from slim cached title cards, episode rows and watch sets (after the jobs /my/series 1.8 s -> 0.06 s, /my/unwatched 4.1 -> 0.18 s); timeline page shows its name once; franchise widget under the poster/title; movies with TV leftovers (First Contact) go through the details repair; tools/count-tmdb-ids.hl; gates 327/0, 52/0, 32/0mre
- 3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre
- e63b1d28antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 1cda451dtracker: Hybriel master 190aa11d (#127 both shapes, GC correctness fc838894) — conductor adopts despite /my/series 2.4x after jobs (memory 8.0 → 1.7 GB boot); see reports/071mre
- c1fa2f2etracker (mission 071): Hybriel master 190aa11d measured on the real copy vs the live binary 8590df63 — NOT adopted (after the first-start jobs /my/series 2.4x slower, /series 1.6x, RSS swings 7.4-12.2 GB; fresh it is flat at 1.7-2.2 GB and /my/unwatched faster), vendor stays 8efba065, candidate kept in .scratch/w071/vendor-190aa11d; tests/kinds.mjs: collection seed off (its TMDB request broke check 1 in 1 of 4 runs); tests/realdata-071.sh + realdata-071-bench.mjs + tools/realdata-071-table.py; README + STATUS (numbers, how to repeat); gates 325/0, 32/0, 50/0mre
- 8081350atracker docs (mission 070): README (summary, Config HL_GC_BYTES — kept at Hybriel's default, the 256 MiB setting is taken out of docker-compose.yml again: the jobs grew to 12+ GB with it too, see STATUS), Test (three gates), Deploy (first start ~50 min: kinds then seed, restart once after collections done, memory numbers), Vendored Hybriel 8efba065 + #48 audit, Files; STATUS mission 070 entry (merges, migrated counts, lambda audit, gates, RSS old vs new, how to repeat, open points); docs/kinds.md + docs/franchises.md job order; tests/realdata-070-*.sh, tools/count-migrated.hl, tools/ref-params.py, tools/lambda-audit.pymre
- 1ad19c8ctracker: re-vendor Hybriel master 8efba065 (#126 GC by bytes, #48 lambda parameters copy) (mission 070): bin/hybriel sha256 50361e95…, plugins core crypto data fetch fs http http1 mpackdb proc smtp time web; lambda audit: 13 lambdas change a passed record/list (11 through a local alias), no caller relies on it — unchanged; 319 read-only lambda parameters get & (no copy per call: /my/schedule 2.35 → 0.46 s, /my/unwatched 13.7 → 5.7 s on the real copy); /my/unwatched one merge sort instead of n² inserts; docker-compose HL_GC_BYTES=268435456; deploy.sh runs kinds.mjs + franchises.mjs too (default ports 8700–8710); tests/realdata-070.mjs; gates browser 325/0, kinds 32/0, franchises 50/0mre
- 46b21389tracker (mission 070, conductor): the sync never destroys migrated data — the one-time summary step MOVES a copied summary to migratedSummary (marker summaries-moved.txt) instead of clearing it; a migrated record's first title/genres/homepage/tagline TMDB replaces → migratedTitle/migratedGenres/migratedHomepage/migratedTagline, a migrated season's/episode's title/summary → migratedTitle/migratedSummary (set once); tests/peek-shows.hl prints them; gate 325/0mre
- 749019b0Merge t19 (tracker#19 franchises + timelines) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs/faketmdb.mjs, both sides kept; franchise/timeline pages get #20's typed heading (Franchise | …, Timeline | …), their title links via titlePath (/movies|/series|/shows); the collection seed waits for repair, kinds and credits too; franchises.mjs URLs + 2 new checks; gates browser 323/0, kinds 32/0, franchises 50/0mre
- daf49feaMerge t20 (tracker#20 typed headings + #21 series/shows split) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/search.hl/components/search.hl/browser.mjs/faketmdb.mjs, both sides kept; clock order backfill → repair → kinds → credits; withDetailsFields stores tmdbType + kind; gates: browser.mjs URLs → /series|/movies, typed h1 selectors; kinds.mjs repair/credits off, fixture name = seed name; browser 323/0, kinds 32/0mre
- 20e09d89Merge t18 (tracker#18 delta sync) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs, both sides kept; pageShowOf summary = summaryOfmre
- f83571c3tracker#18 (mission 067): daily sync by change lists — TMDB /tv|movie/changes (since the stored day, paged) + TVmaze /updates/shows → only our changed titles (followed: full step, unfollowed: light step — changed seasons, no TVmaze), full walk on first run / gap > 14 days / failed list; show record refreshed (title, tmdbSummary, tagline, status, genres …; renamed titles re-indexed); summary = the creator's own text (page: summary > tmdbSummary > tvmazeSummary), one-time clear of copied summaries (9,647 on the live copy); gate 261, tests/realdata-018*.mjs, README + STATUSmre
- 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
- f2b00674Merge t26 (tracker#26 + #28) into main (mission 062): short ids for every new person (castPersonId, guest route), guest stars stored on the title and created as people only when opened (/person/tmdb/<id>?show=<id> → 302), lean watch/follow clicks (showRow a small object, cast/crew from the slug, watches cached per user, face rows only after a season toggle); gate 311, tests/realdata-062.mjs, README + STATUSmre
- 0553b51ftracker#19 (mission 066): franchises and timelines — tables, /franchises, /franchises/<slug>, /timelines/<slug> (Timeline | Release sort, series by last episode), the Prequel | Timeline | Sequel widget with the franchise above, the creator's editor, TMDB collection seed in the app (resumes, paced); gate tests/franchises.mjs 48/0 + browser.mjs 266/0, tests/realdata-066.mjs, docs/franchises.md, README + STATUSmre
- fa1f9dfatracker#29 (mission 063): unwatched check muted grey outline + check (accent only on hover), watched stays solid — no code regression, the accent outline read as ticked; gate checks real checks visibly (computed style + screenshot pixel) on /my/unwatched, show, movie, /my/movies; gate 266, tests/realdata-063.mjs, README + STATUSmre
- 10bb3f93tracker#28 (mission 061): full cast (all seasons, main cast by episodes, guest stars) + crew (created by, directed by, written by, screenplay, story, music) — stored by the details completion, the daily sync, the search import (one details request) and a background credits job (resumes, RSS limit); show page collapsed after 20 with client-side Show all; showBySlug via a slug map; gate 249, tests/realdata-028.mjs, README + STATUSmre
- d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre
- 25a50bc4tracker#26 (mission 059): titles from a filmography are completed — on open (skeleton, step-wise face showComplete, no reload) and by the in-app details repair (resumes, TMDB-paced, series in parts); cast from TMDB credits; gate 231, tests/realdata-026.mjs, README + STATUSmre