tracker
All repositories: gitoria
24.5 KB
// tests/franchiseseed.mjs — THE GATE of tracker.worldapi.org#40 (mission 034): the curated franchise seed (lib/franchiseseed.hl)// — a gate of its own beside tests/franchises.mjs (same parts: its own ident from identkit.mjs, the fake TMDB, a real headless// Chrome), on its own storage (.scratch/franchiseseed-store) and its own data file (written here, TRACKER_FRANCHISE_FILE).// Proves:// - the seed runs LAST (after the collection seed), makes the file's franchises + timelines, MOVIES AND SERIES, in the file's// order; titles we lack are IMPORTED through the search's import (complete: poster, seasons + episodes, cast); TMDB's 404// is remembered; a franchise the creator made of the same name is ADOPTED (not doubled), a timeline of the same name is// LEFT ALONE; the file's TMDB collection joins the franchise; the order kind shows ("· in-universe order")// - it RESUMES after a stop in the middle of the imports (no title imported twice, nothing doubled) and is IDEMPOTENT (the// next start imports nothing, writes nothing, asks TMDB nothing)// - EDITOR CHANGES WIN: a seeded timeline the creator re-ordered is not written again when the file changes; an unedited one// takes the file's new title// - a SERIES page shows the widget (franchise above, ‹ Prequel | Timeline | Sequel ›) like a movie page// - 390 / 1280 px: nothing scrolls sideways; screenshots with TRACKER_GATE_SHOTS//// TRACKER_GATE_SHOTS=/tmp/w093/shots TRACKER_GATE_PORT=8760 TRACKER_GATE_IDENT_PORT=8761 TRACKER_GATE_CHROME=8762-8764 \// TRACKER_GATE_TMDB_PORT=8767 node tests/franchiseseed.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', 'franchiseseed-store');const SESSIONS = join(APP, '.scratch', 'franchiseseed-sessions');const DATA_FILE = join(STORE, 'franchises.json');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; }}// THE DATA FILE (the gate's own, shaped like data/franchises.json). Gate Trek: a franchise to make, two timelines mixing what we// have (Gate Test Show tv 1001, Gate Test Movie 1003 — tests/seed-show.hl) with what TMDB has (imported) and one TMDB lacks (9404).// Gate Wars: the creator's own franchise (tests/seed-franchiseseed.hl) with the creator's own "Gate Creator Line", a seeded saga// and TMDB collection 900 (the collection seed's "Gate Alien Collection").const gateData = (more = false) => ({about: 'tests/franchiseseed.mjs — the gate\'s data file',franchises: [{ name: 'Gate Trek', summary: 'Boldly gating.', collections: [], timelines: [{ name: 'Gate Trek — Prime', order: 'in-universe', titles: [['tv', 1001, 'Gate Test Show (2024)'], ['movie', 9201, 'Gate Trek Movie I (1979)'],...(more ? [['movie', 9204, 'Gate Trek Movie II (1982)']] : []), ['tv', 9301, 'Gate Trek Series (1987)'], ['movie', 1003, 'Gate Test Movie (1996)'], ['movie', 9404, 'Gate Lost Movie (TMDB 404)']] },{ name: 'Gate Trek — Kelvin', order: 'release', titles: [['movie', 9202, 'Kelvin One (2009)'], ['movie', 9203, 'Kelvin Two (2013)'], ...(more ? [['movie', 9205, 'Kelvin Three (2016)']] : [])] }] },{ name: 'Gate Wars', summary: 'not used: the creator\'s franchise keeps its own', collections: [900], timelines: [{ name: 'Gate Creator Line', order: 'in-universe', titles: [['movie', 9201, 'Gate Trek Movie I (1979)'], ['movie', 9202, 'Kelvin One (2009)']] },{ name: 'Gate Wars — Saga', order: 'in-universe', titles: [['tv', 9302, 'Gate Wars Series (2008)'], ['movie', 9101, 'Gate Alien (1979)']] }] },],});const writeData = (more) => writeFileSync(DATA_FILE, JSON.stringify(gateData(more), null, '\t'));// the fake TMDB: the collection seed's movies (as tests/franchises.mjs) + the titles to importconst coll900 = { id: 900, name: 'Gate Alien Collection' };const film = (id, title, date, poster, actor) => ({ id, title, release_date: date, poster_path: poster, overview: title + ' — a gate film', genres: [{ id: 18, name: 'Drama' }],status: 'Released', belongs_to_collection: null, external_ids: { imdb_id: 'tt' + String(id).padStart(7, '0') }, credits: { cast: [{ id: 3300 + (id % 100), name: actor, character: 'Lead', order: 0 }], crew: [] } });const gateMovies = () => ({1003: { id: 1003, title: 'Gate Test Movie', poster_path: null, belongs_to_collection: null },9101: { id: 9101, title: 'Gate Alien', belongs_to_collection: coll900 },9102: { id: 9102, title: 'Gate Aliens', belongs_to_collection: coll900 },9103: { id: 9103, title: 'Gate Alien 3', belongs_to_collection: coll900 },9104: { id: 9104, title: 'Gate Loner', belongs_to_collection: { id: 901, name: 'Gate Lonely Collection' } },9106: { id: 9106, title: 'Gate Standalone', belongs_to_collection: null },9201: film(9201, 'Gate Trek Movie I', '1979-12-07', '/gt1.png', 'Gate Trek Admiral'),9202: film(9202, 'Kelvin One', '2009-05-08', '/gk1.png', 'Kelvin Captain'),9203: film(9203, 'Kelvin Two', '2013-05-16', '/gk2.png', 'Kelvin Villain'),9204: film(9204, 'Gate Trek Movie II', '1982-06-04', '/gt2.png', 'Gate Trek Khan'),9205: film(9205, 'Kelvin Three', '2016-07-22', '/gk3.png', 'Kelvin Pilot'),});const gateCollections = () => ({900: { id: 900, name: 'Gate Alien Collection', parts: [{ id: 9101, title: 'Gate Alien', release_date: '1979-05-25', adult: false }, { id: 9102, title: 'Gate Aliens', release_date: '1986-07-18', adult: false },{ id: 9103, title: 'Gate Alien 3', release_date: '1992-05-22', adult: false }] },901: { id: 901, name: 'Gate Lonely Collection', parts: [{ id: 9104, title: 'Gate Loner', release_date: '2000-01-01' }] },});const series = (id, name, date, poster, actor) => ({ id, name, overview: name + ' — a gate series', status: 'Ended', type: 'Scripted', poster_path: poster, first_air_date: date,original_language: 'en', genres: [{ id: 18, name: 'Drama' }], external_ids: {}, created_by: [],aggregate_credits: { cast: [{ id: 3400 + (id % 100), name: actor, roles: [{ character: 'Chief', episode_count: 2 }], total_episode_count: 2 }], crew: [] },seasons: [{ id: id * 10 + 1, season_number: 1, name: 'Season 1', air_date: date, episodes: [{ id: id * 100 + 1, episode_number: 1, name: name + ' Pilot', air_date: date }, { id: id * 100 + 2, episode_number: 2, name: name + ' Finale', air_date: date.slice(0, 8) + '28' }] }] });const gateShows = () => ({9301: series(9301, 'Gate Trek Series', '1987-09-28', '/gts.png', 'Gate Trek Engineer'),9302: series(9302, 'Gate Wars Series', '2008-10-03', '/gws.png', 'Gate Wars Padawan'),});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 (franchise seed gate)', [BASE]);const aliceIdentity = await ident.exchange(APPKEY, await ident.selectorCode(alice, APPKEY, BASE));check('ident: our own ident runs, the app is registered, alice\'s ident id known (she is the "creator" here)', /^pk_[0-9a-f]{32}$/.test(APPKEY.key) && !!aliceIdentity);const MPACKDB = join(STORE, 'mpackdb');mkdirSync(MPACKDB, { recursive: true });const hl = (file) => execFileSync(join(APP, 'bin/hybriel'), [file], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_IDENTITY: aliceIdentity } }).toString();const seedOut = hl('tests/seed-show.hl') + hl('tests/seed-franchises.hl') + hl('tests/seed-franchiseseed.hl');check('seed: the fixture shows + the collection movies + the creator\'s own franchise "Gate Wars" and timeline "Gate Creator Line"',seedOut.includes('gate-test-show') && seedOut.includes('seed-franchises: 7 movies') && seedOut.includes('seed-franchiseseed: 1 franchise, 1 timeline'), seedOut);writeData(false);tmdb = await startFakeTmdb({ port: TMDB_PORT, shows: gateShows(), movies: gateMovies(), collections: gateCollections() });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_REPAIR: '0', TRACKER_KINDS: '0', TRACKER_CREDITS: '0', TRACKER_EDITORS: aliceIdentity,TRACKER_FRANCHISE_FILE: DATA_FILE, TRACKER_FRANCHISE_SEED_LOG_EVERY: '1', ...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));};const lines = (re) => log.split('\n').filter(l => re.test(l));let allLogs = '';// ==== FIRST START, stopped in the middle of the imports (TMDB answers slowly), then resumed ====tmdb.delayMs = 700;await startServer();await waitLog(/franchise seed: start, /, 30000, 'the seed start');const firstStart = lines(/^franchise seed: start/)[0] || '';const collectionsDoneAt = log.indexOf('collections done: '), seedStartAt = log.indexOf('franchise seed: start');check('the seed starts LAST — after the collection seed is done — and finds 2 franchises, 4 timelines, 9 titles: 3 we have, 6 to import',collectionsDoneAt >= 0 && seedStartAt > collectionsDoneAt && /2 franchises, 4 timelines, 9 titles in the file — 3 we have, 6 to import, 0 TMDB lacks/.test(firstStart), firstStart + '\n' + lines(/^collections/).join('\n'));await waitLog(/search import: /, 30000, 'the first import');await stopServer();allLogs += log;const firstImports = (log.match(/search import: /g) || []).length;tmdb.delayMs = 0;await startServer();await waitLog(/franchise seed done: /, 60000, 'the seed done');allLogs += log;const secondStart = lines(/^franchise seed: start/)[0] || '';const toImport = Number((secondStart.match(/(\d+) to import/) || [])[1]);check('seed RESUMES after a stop: the second start has fewer titles left to import (' + toImport + ') than 6 — the first run imported ' + firstImports,toImport < 6 && toImport >= 4 && firstImports >= 1, secondStart);const importLines = allLogs.split('\n').filter(l => /^search import: (movie|tv) \d+ /.test(l));const imported = importLines.map(l => l.match(/^search import: (\w+ \d+)/)[1]).sort();check('every missing title imported EXACTLY ONCE across the stop and the restart: movie 9201, 9202, 9203, tv 9301, 9302 (each with its cast: "search import credits")',imported.join(',') === 'movie 9201,movie 9202,movie 9203,tv 9301,tv 9302' && (allLogs.match(/^search import credits: /gm) || []).length === 5, importLines.join('\n'));check('TMDB\'s 404 (movie 9404) is logged and remembered', /^franchise seed: movie:9404 \(Gate Lost Movie \(TMDB 404\)\): TMDB: HTTP 404/m.test(allLogs), lines(/^franchise seed/).join('\n'));const done = lines(/^franchise seed done: /)[0] || '';check('done: 2 franchises (1 made, 1 the creator\'s ADOPTED), 4 timelines (3 made, the creator\'s "Gate Creator Line" KEPT), collection 900 attached, 1 failed',/franchises=2 \(created=1 adopted=1\) timelines=4 \(created=3 updated=0 unchanged=0 kept=1 empty=0\) collections=1 titles=9 /.test(done) && / failed=1 /.test(done), done);// ==== THIRD START: nothing to do ====// the import's requests (details with append_to_response) since `from` — the collection seed's plain /movie/<id> checks of the// newly imported movies are its own businessconst importAsks = (from) => tmdb.requests.slice(from).map(r => r.path).filter(p => p.includes('append_to_response'));const requestsBefore = tmdb.requests.length;await stopServer();await startServer();await waitLog(/franchise seed done: /, 30000, 'the idempotent run');allLogs += log;const third = lines(/^franchise seed(: start| done)/).join('\n');check('IDEMPOTENT: the next start has 0 to import (8 we have, the 404 not asked again), makes and writes NOTHING, imports nothing from TMDB',/8 we have, 0 to import, 1 TMDB lacks/.test(third) && /\(created=0 adopted=0\) timelines=4 \(created=0 updated=0 unchanged=3 kept=1 empty=0\) collections=0 /.test(third)&& / imported=0 failed=0 /.test(third) && importAsks(requestsBefore).length === 0, third + '\n' + importAsks(requestsBefore).join(' '));// ==== the pages, signed OUT ====browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });const page = await browser.newPage();globalThis.__gatePage = page;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 open = async (path) => { await page.goto(BASE + path); await hydrated(); };const widgets = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('franchise-widget')).map(w => ({franchise: w.querySelector('a.fw-franchise') ? w.querySelector('a.fw-franchise').textContent + ' ' + w.querySelector('a.fw-franchise').getAttribute('href') : '',prev: w.querySelector('.fw-prev') ? (w.querySelector('.fw-prev').tagName === 'A' ? 'A ' + w.querySelector('.fw-prev').getAttribute('href') : 'OFF') : '',name: w.querySelector('a.fw-timeline').textContent + ' ' + w.querySelector('a.fw-timeline').getAttribute('href'),next: w.querySelector('.fw-next') ? (w.querySelector('.fw-next').tagName === 'A' ? 'A ' + w.querySelector('.fw-next').getAttribute('href') : 'OFF') : '' })))`).then(JSON.parse);const blocks = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('timeline-head')).map(h => ({ name: h.querySelector('a.timeline-name') ? h.querySelector('a.timeline-name').textContent : '',count: h.querySelector('timeline-count').textContent, items: Array.from(document.querySelectorAll('a.timeline-item[data-timeline="' + h.getAttribute('data-timeline') + '"] film-title')).map(t => t.textContent) })))`).then(JSON.parse);await open('/franchises');const fr = await ev(`Array.from(document.querySelectorAll('#franchise-list a.franchise-row')).map(a => a.querySelector('franchise-name').textContent + ' (' + a.querySelector('franchise-count').textContent + ') ' + a.getAttribute('href')).join('|')`);const loose = await ev(`Array.from(document.querySelectorAll('#timeline-list timeline-link-name')).map(x => x.textContent).join('|')`);check('/franchises: Gate Trek (2 timelines) and Gate Wars (2 timelines: the collection + the saga) — Gate Wars ONCE; loose: only the creator\'s "Gate Creator Line"',fr === 'Gate Trek (2 timelines) /franchises/Gate-Trek|Gate Wars (2 timelines) /franchises/Gate-Wars' && loose === 'Gate Creator Line', fr + ' / ' + loose);await open('/franchises/Gate-Trek');let b = await blocks();check('/franchises/Gate-Trek: Prime in the file\'s order — series and movies mixed, imported ones in place, the 404 one left out — "4 titles · in-universe order"; Kelvin "2 titles · release order"',JSON.stringify(b) === JSON.stringify([{ name: 'Gate Trek — Prime', count: '4 titles · in-universe order', items: ['Gate Test Show (2024)', 'Gate Trek Movie I (1979)', 'Gate Trek Series (1987)', 'Gate Test Movie (1996)'] },{ name: 'Gate Trek — Kelvin', count: '2 titles · release order', items: ['Kelvin One (2009)', 'Kelvin Two (2013)'] }]), JSON.stringify(b));check('/franchises/Gate-Trek: the file\'s summary', (await ev(`document.querySelector('p.franchise-summary').textContent`)) === 'Boldly gating.');await open('/franchises/Gate-Wars');b = await blocks();check('/franchises/Gate-Wars (the creator\'s): its own summary kept; TMDB collection 900 (release order) and the seeded saga (a series before a movie)',(await ev(`document.querySelector('p.franchise-summary').textContent`)) === 'The creator\'s own franchise.'&& b.map(x => x.name + ':' + x.count + ':' + x.items.join(',')).sort().join('|') === 'Gate Alien Collection:3 titles · release order:Gate Alien (1979),Gate Aliens (1986),Gate Alien 3 (1992)|Gate Wars — Saga:2 titles · in-universe order:Gate Wars Series (2008),Gate Alien (1979)', JSON.stringify(b));await open('/timelines/Gate-Creator-Line');b = await blocks();check('/timelines/Gate-Creator-Line (the creator\'s, same name as in the file): untouched — still only Gate Alien 3, no franchise',b.length === 1 && b[0].items.join(',') === 'Gate Alien 3 (1992)' && !(await ev(`!!document.querySelector('#franchise-up')`)), JSON.stringify(b));// a SERIES page: the widget like a movie's, and the imported series is completeawait open('/series/Gate-Trek-Series');let w = await widgets();check('series page (imported Gate Trek Series): ONE widget, "Gate Trek" above → its index, ‹ Prequel → Gate Trek Movie I, the timeline, Sequel › → Gate Test Movie',w.length === 1 && w[0].franchise === 'Gate Trek /franchises/Gate-Trek' && w[0].prev === 'A /movies/Gate-Trek-Movie-I' && w[0].name === 'Gate Trek — Prime /timelines/Gate-Trek-Prime'&& w[0].next === 'A /movies/gate-test-movie', JSON.stringify(w));const text = await ev('document.body.innerText');check('the imported series is complete: its poster, Season 1, its cast (Gate Trek Engineer) — the search\'s import, not a minimal record',(await ev(`!!document.querySelector('show-poster img[src^="/posters/"]:not(.no-poster)')`)) && /Season 1/.test(text) && text.includes('Gate Trek Engineer'), text.slice(0, 800));await page.click('a.fw-prev');await page.waitFor(`location.pathname === '/movies/Gate-Trek-Movie-I' && document.querySelector('show-title h1 type-name') && document.querySelector('show-title h1 type-name').textContent === 'Gate Trek Movie I'`, { timeout: 10000, label: 'prequel click' });await sleep(300);w = await widgets();check('a "‹ Prequel" click opens the imported movie; its widget: Prequel → the fixture SERIES Gate Test Show, Sequel → the imported series',w.length === 1 && w[0].prev === 'A /series/gate-test-show' && w[0].next === 'A /series/Gate-Trek-Series' && text.length > 0, JSON.stringify(w));await open('/series/gate-test-show');w = await widgets();check('a series we HAD (Gate Test Show) is the first of Prime: Prequel greyed out, franchise above', w.length === 1 && w[0].prev === 'OFF' && w[0].next === 'A /movies/Gate-Trek-Movie-I' && w[0].franchise.startsWith('Gate Trek '), JSON.stringify(w));// ==== EDITOR CHANGES WIN: alice moves a title in Prime, the file then grows in Prime AND Kelvin ====const code = await ident.selectorCode(alice, APPKEY, BASE);await page.goto(BASE + '/login/callback?ident_code=' + code);await page.waitForSelector('#logout');await hydrated();await open('/timelines/Gate-Trek-Prime/edit');await ev(`document.querySelectorAll('#edit-rows edit-row')[1].querySelector('button[value="up"]').click()`);await page.waitFor(`document.querySelectorAll('#edit-rows edit-row')[0].querySelector('film-title').textContent === 'Gate Trek Movie I (1979)'`, { timeout: 8000, label: 'moved up' });writeData(true);// the page leaves the app first: a stopped server would be a refused socket in the consoleawait page.goto('about:blank');await stopServer();await startServer();await waitLog(/franchise seed done: /, 60000, 'the run after the file changed');allLogs += log;const fourth = lines(/^franchise seed(: start| done)/).join('\n');check('the file grew (Gate Trek Movie II into Prime, Kelvin Three into Kelvin): 2 to import; Prime KEPT (the creator moved a title), Kelvin UPDATED',/2 to import/.test(fourth) && /timelines=4 \(created=0 updated=1 unchanged=1 kept=2 empty=0\)/.test(fourth) && / imported=2 /.test(fourth), fourth);await open('/franchises/Gate-Trek');b = await blocks();check('Prime keeps the CREATOR\'s order (Gate Trek Movie I first) and does not get the new title; Kelvin has Kelvin Three at the end',JSON.stringify(b) === JSON.stringify([{ name: 'Gate Trek — Prime', count: '4 titles · in-universe order', items: ['Gate Trek Movie I (1979)', 'Gate Test Show (2024)', 'Gate Trek Series (1987)', 'Gate Test Movie (1996)'] },{ name: 'Gate Trek — Kelvin', count: '3 titles · release order', items: ['Kelvin One (2009)', 'Kelvin Two (2013)', 'Kelvin Three (2016)'] }]), JSON.stringify(b));check('the new title was still imported (its page exists) — only the edited timeline is left alone', (await fetch(BASE + '/movies/Gate-Trek-Movie-II')).status === 200);// ==== 390 / 1280: nothing scrolls sideways; screenshots ====const pages = [['seed-franchises', '/franchises'], ['seed-franchise', '/franchises/Gate-Trek'], ['seed-widget-series', '/series/Gate-Trek-Series'], ['seed-timeline', '/timelines/Gate-Trek-Kelvin']];for (const [wd, h, mobile] of [[390, 844, true], [1280, 900, false]]) {await page.send('Emulation.setDeviceMetricsOverride', { width: wd, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });const bad = [];for (const [name, path] of pages) {await open(path);if (name.includes('widget')) await ev(`document.querySelector('franchise-widget').scrollIntoView({ block: 'center' })`);const sw = await ev('document.documentElement.scrollWidth');if (sw > wd) bad.push(name + ' ' + sw);if (SHOTS) writeFileSync(join(SHOTS, `${name}-${wd}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));}check(`${wd} px: /franchises, a franchise, a series with the widget, a timeline — nothing scrolls sideways`, bad.length === 0, bad.join('; '));}await page.send('Emulation.clearDeviceMetricsOverride', {});const problems = page.problems();check('no console errors/warnings', problems.length === 0, page.dumpConsole());check('no error in the server log (absorbed errors included)', !/error absorbed|Error:/.test(allLogs), allLogs.split('\n').filter(l => /error absorbed|Error:/.test(l)).join('\n'));}try {await main();} catch (e) {check('gate ran without throwing', false, (e.stack || String(e)) + (globalThis.__gatePage ? '\nbrowser console:\n' + globalThis.__gatePage.dumpConsole() : '') + '\nserver log (tail):\n' + log.slice(-2500));} finally {await stop();}console.log(`\n${passes} passed, ${failures} failed`);process.exit(failures === 0 ? 0 : 1);
Branches
- mainmain branch
Latest commits
- 7d4b293dtracker#40: "Franchises" in the main menu (desktop header after People, phone sidebar) → /franchises, marked on franchise and timeline pages; gates 365/0, 32/0, 53/0, 24/0, check-theme 0mre
- 93dfb0batracker#40 (mission 034): the curated franchises — data/franchises.json (17 franchises, 31 timelines, 285 TMDB titles, movies + series, in-universe/release order, 12 TMDB collections attached); lib/franchiseseed.hl + jobs.hl franchiseSeedTick (last start job, imports missing titles via details.hl importWithCredits = the search's Add, one per step paced, then one build; franchiseseed.db: editor changes win, the creator's same-name franchise adopted / timeline left alone, 404 remembered, resumable, idempotent); timeline heads 'N titles · in-universe order' (orderKind) and wrap on a phone; series pages show the widget; new gate tests/franchiseseed.mjs (5th in deploy.sh), the others run with TRACKER_FRANCHISE_SEED=0; gates 365/0, 32/0, 52/0, 24/0, check-theme 0; real copy 196 imported, 0 failed, 7 min, restart unchanged=31mre
- 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
- dc1d4be4tracker mission 027: Hybriel master 06617221 vendored (plugin allocator fixes 3a781359 + 413f60e4); real copy RSS through first-start jobs + 400 loads flat ~2.55 GB (190aa11d 2.3 -> 5.6 GB), page times <= 1.1x; gates 342/0, 32/0, 52/0mre