gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitfbb903ccfbb903cctracker#33/#37 (mission 032): a title without a TMDB poster gets its backdrop (w780, posterFromBackdrop, shown 2:3 centre-cropped); movies store runtime, the page shows release date + runtime; art backfill (public titles without poster file / movies without runtime) and person photo backfill (tmdbProfile / photoCheck) as the last start jobs (TRACKER_ART, TRACKER_PHOTOS; off in every gate start); gates 379/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy: art 4977 titles in 42 min (115 posters, 15 backdrops, 4609 runtimes), The Remaining shows its backdrop + 7 minmrefbb903cc/tests/franchiseseed.mjs

27.5 KB

  1. // tests/franchiseseed.mjs — THE GATE of tracker.worldapi.org#40 (mission 034): the curated franchise seed (lib/franchiseseed.hl)
  2. // — a gate of its own beside tests/franchises.mjs (same parts: its own ident from identkit.mjs, the fake TMDB, a real headless
  3. // Chrome), on its own storage (.scratch/franchiseseed-store) and its own data file (written here, TRACKER_FRANCHISE_FILE).
  4. // Proves:
  5. // - the seed runs LAST (after the collection seed), makes the file's franchises + timelines, MOVIES AND SERIES, in the file's
  6. // order; titles we lack are IMPORTED through the search's import (complete: poster, seasons + episodes, cast); TMDB's 404
  7. // is remembered; a franchise the creator made of the same name is ADOPTED (not doubled), a timeline of the same name is
  8. // LEFT ALONE; the file's TMDB collection joins the franchise; the order kind shows ("· in-universe order")
  9. // - it RESUMES after a stop in the middle of the imports (no title imported twice, nothing doubled) and is IDEMPOTENT (the
  10. // next start imports nothing, writes nothing, asks TMDB nothing)
  11. // - EDITOR CHANGES WIN: a seeded timeline the creator re-ordered is not written again when the file changes; an unedited one
  12. // takes the file's new title
  13. // - a SERIES page shows the widget (franchise above, ‹ Prequel | Timeline | Sequel ›) like a movie page
  14. // - 390 / 1280 px: nothing scrolls sideways; screenshots with TRACKER_GATE_SHOTS
  15. //
  16. // TRACKER_GATE_SHOTS=/tmp/w093/shots TRACKER_GATE_PORT=8760 TRACKER_GATE_IDENT_PORT=8761 TRACKER_GATE_CHROME=8762-8764 \
  17. // TRACKER_GATE_TMDB_PORT=8767 node tests/franchiseseed.mjs
  18. import { spawn, execFileSync } from 'node:child_process';
  19. import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
  20. import { dirname, join, resolve } from 'node:path';
  21. import { fileURLToPath } from 'node:url';
  22. import { launchBrowser } from './cdp.mjs';
  23. import { startIdent } from './identkit.mjs';
  24. import { startFakeTmdb } from './faketmdb.mjs';
  25. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  26. const HERE = dirname(fileURLToPath(import.meta.url));
  27. const APP = resolve(HERE, '..');
  28. const PORT = Number(process.env.TRACKER_GATE_PORT || 8700);
  29. const [CH_FROM, CH_TO] = (process.env.TRACKER_GATE_CHROME || '8702-8709').split('-').map(Number);
  30. const IDENT_PORT = Number(process.env.TRACKER_GATE_IDENT_PORT || 8701);
  31. const TMDB_PORT = Number(process.env.TRACKER_GATE_TMDB_PORT || 8710);
  32. const SHOTS = process.env.TRACKER_GATE_SHOTS || '';
  33. const BASE = `http://127.0.0.1:${PORT}`;
  34. const STORE = join(APP, '.scratch', 'franchiseseed-store');
  35. const SESSIONS = join(APP, '.scratch', 'franchiseseed-sessions');
  36. const DATA_FILE = join(STORE, 'franchises.json');
  37. 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')));
  38. rmSync(STORE, { recursive: true, force: true });
  39. rmSync(SESSIONS, { recursive: true, force: true });
  40. mkdirSync(SESSIONS, { recursive: true });
  41. if (SHOTS) mkdirSync(SHOTS, { recursive: true });
  42. let failures = 0, passes = 0;
  43. function check(label, ok, detail = '') {
  44. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  45. if (ok) passes++; else failures++;
  46. }
  47. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  48. let ident = null, server = null, browser = null, log = '', tmdb = null;
  49. async function stopServer() {
  50. if (server) {
  51. const s = server;
  52. try { s.kill('SIGTERM'); } catch {}
  53. await new Promise(r => { if (s.exitCode !== null || s.signalCode !== null) return r(); s.once('exit', r); setTimeout(r, 3000); });
  54. server = null;
  55. }
  56. }
  57. async function stop() {
  58. if (ident) { try { await ident.stop(); } catch {} ident = null; }
  59. if (browser) { try { await browser.close(); } catch {} browser = null; }
  60. await stopServer();
  61. if (tmdb) { try { await tmdb.stop(); } catch {} tmdb = null; }
  62. }
  63. // THE DATA FILE (the gate's own, shaped like data/franchises.json). Gate Trek: a franchise to make, two timelines mixing what we
  64. // have (Gate Test Show tv 1001, Gate Test Movie 1003 — tests/seed-show.hl) with what TMDB has (imported) and one TMDB lacks (9404).
  65. // Gate Wars: the creator's own franchise (tests/seed-franchiseseed.hl) with the creator's own "Gate Creator Line", a seeded saga
  66. // and TMDB collection 900 (the collection seed's "Gate Alien Collection").
  67. const gateData = (more = false) => ({
  68. about: 'tests/franchiseseed.mjs — the gate\'s data file',
  69. franchises: [
  70. { name: 'Gate Trek', summary: 'Boldly gating.', collections: [], timelines: [
  71. { name: 'Gate Trek — Prime', order: 'in-universe', titles: [['tv', 1001, 'Gate Test Show (2024)'], ['movie', 9201, 'Gate Trek Movie I (1979)'],
  72. ...(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)']] },
  73. { name: 'Gate Trek — Kelvin', order: 'release', titles: [['movie', 9202, 'Kelvin One (2009)'], ['movie', 9203, 'Kelvin Two (2013)'], ...(more ? [['movie', 9205, 'Kelvin Three (2016)']] : [])] }] },
  74. { name: 'Gate Wars', summary: 'not used: the creator\'s franchise keeps its own', collections: [900], timelines: [
  75. { name: 'Gate Creator Line', order: 'in-universe', titles: [['movie', 9201, 'Gate Trek Movie I (1979)'], ['movie', 9202, 'Kelvin One (2009)']] },
  76. { name: 'Gate Wars — Saga', order: 'in-universe', titles: [['tv', 9302, 'Gate Wars Series (2008)'], ['movie', 9101, 'Gate Alien (1979)']] },
  77. { name: 'Gate Alien Saga', order: 'in-universe', titles: [['movie', 9101, 'Gate Alien (1979)'], ['movie', 9102, 'Gate Aliens (1986)'], ['movie', 9103, 'Gate Alien 3 (1992)']] }] },
  78. ],
  79. });
  80. const writeData = (more) => writeFileSync(DATA_FILE, JSON.stringify(gateData(more), null, '\t'));
  81. // the fake TMDB: the collection seed's movies (as tests/franchises.mjs) + the titles to import
  82. const coll900 = { id: 900, name: 'Gate Alien Collection' };
  83. const film = (id, title, date, poster, actor, coll = null) => ({ id, title, release_date: date, poster_path: poster, overview: title + ' — a gate film', genres: [{ id: 18, name: 'Drama' }],
  84. status: 'Released', belongs_to_collection: coll, external_ids: { imdb_id: 'tt' + String(id).padStart(7, '0') }, credits: { cast: [{ id: 3300 + (id % 100), name: actor, character: 'Lead', order: 0 }], crew: [] } });
  85. const gateMovies = () => ({
  86. 1003: { id: 1003, title: 'Gate Test Movie', poster_path: null, belongs_to_collection: null },
  87. 9101: { id: 9101, title: 'Gate Alien', belongs_to_collection: coll900 },
  88. 9102: { id: 9102, title: 'Gate Aliens', belongs_to_collection: coll900 },
  89. 9103: { id: 9103, title: 'Gate Alien 3', belongs_to_collection: coll900 },
  90. 9104: { id: 9104, title: 'Gate Loner', belongs_to_collection: { id: 901, name: 'Gate Lonely Collection' } },
  91. 9106: { id: 9106, title: 'Gate Standalone', belongs_to_collection: null },
  92. // tracker#40: the imported movies' own TMDB collections, made by the collection seed on a LATER start — 904 only partly in a
  93. // curated timeline (joins Gate Trek, stays shown), 903 wholly in "Gate Trek — Kelvin" (made hidden: superseded)
  94. 9201: film(9201, 'Gate Trek Movie I', '1979-12-07', '/gt1.png', 'Gate Trek Admiral', { id: 904, name: 'Gate Extras Collection' }),
  95. 9202: film(9202, 'Kelvin One', '2009-05-08', '/gk1.png', 'Kelvin Captain', { id: 903, name: 'Gate Kelvin Collection' }),
  96. 9203: film(9203, 'Kelvin Two', '2013-05-16', '/gk2.png', 'Kelvin Villain', { id: 903, name: 'Gate Kelvin Collection' }),
  97. 9204: film(9204, 'Gate Trek Movie II', '1982-06-04', '/gt2.png', 'Gate Trek Khan'),
  98. 9205: film(9205, 'Kelvin Three', '2016-07-22', '/gk3.png', 'Kelvin Pilot'),
  99. });
  100. const gateCollections = () => ({
  101. 900: { id: 900, name: 'Gate Alien Collection', parts: [
  102. { id: 9101, title: 'Gate Alien', release_date: '1979-05-25', adult: false }, { id: 9102, title: 'Gate Aliens', release_date: '1986-07-18', adult: false },
  103. { id: 9103, title: 'Gate Alien 3', release_date: '1992-05-22', adult: false }] },
  104. 901: { id: 901, name: 'Gate Lonely Collection', parts: [{ id: 9104, title: 'Gate Loner', release_date: '2000-01-01' }] },
  105. 903: { id: 903, name: 'Gate Kelvin Collection', parts: [{ id: 9202, title: 'Kelvin One', release_date: '2009-05-08' }, { id: 9203, title: 'Kelvin Two', release_date: '2013-05-16' }, { id: 9299, title: 'Not Ours', release_date: '2020-01-01' }] },
  106. 904: { id: 904, name: 'Gate Extras Collection', parts: [{ id: 9201, title: 'Gate Trek Movie I', release_date: '1979-12-07' }, { id: 9106, title: 'Gate Standalone', release_date: '2001-01-01' }] },
  107. });
  108. 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,
  109. original_language: 'en', genres: [{ id: 18, name: 'Drama' }], external_ids: {}, created_by: [],
  110. aggregate_credits: { cast: [{ id: 3400 + (id % 100), name: actor, roles: [{ character: 'Chief', episode_count: 2 }], total_episode_count: 2 }], crew: [] },
  111. seasons: [{ id: id * 10 + 1, season_number: 1, name: 'Season 1', air_date: date, episodes: [
  112. { 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' }] }] });
  113. const gateShows = () => ({
  114. 9301: series(9301, 'Gate Trek Series', '1987-09-28', '/gts.png', 'Gate Trek Engineer'),
  115. 9302: series(9302, 'Gate Wars Series', '2008-10-03', '/gws.png', 'Gate Wars Padawan'),
  116. });
  117. async function main() {
  118. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  119. const alice = await ident.signIn('[email protected]');
  120. const APPKEY = await ident.registerApp(alice, 'tracker (franchise seed gate)', [BASE]);
  121. const aliceIdentity = await ident.exchange(APPKEY, await ident.selectorCode(alice, APPKEY, BASE));
  122. 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);
  123. const MPACKDB = join(STORE, 'mpackdb');
  124. mkdirSync(MPACKDB, { recursive: true });
  125. const hl = (file) => execFileSync(join(APP, 'bin/hybriel'), [file], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_IDENTITY: aliceIdentity } }).toString();
  126. const seedOut = hl('tests/seed-show.hl') + hl('tests/seed-franchises.hl') + hl('tests/seed-franchiseseed.hl');
  127. check('seed: the fixture shows + the collection movies + the creator\'s own franchise "Gate Wars" and timeline "Gate Creator Line"',
  128. seedOut.includes('gate-test-show') && seedOut.includes('seed-franchises: 7 movies') && seedOut.includes('seed-franchiseseed: 1 franchise, 1 timeline'), seedOut);
  129. writeData(false);
  130. tmdb = await startFakeTmdb({ port: TMDB_PORT, shows: gateShows(), movies: gateMovies(), collections: gateCollections() });
  131. const startServer = async (extra = {}) => {
  132. log = '';
  133. server = spawn(join(APP, 'bin/hybriel'), ['project.hl'], { cwd: APP, env: { ...process.env, TRACKER_PORT: String(PORT), TRACKER_SESSIONS: SESSIONS + '/', TRACKER_STORAGE: MPACKDB,
  134. TRACKER_WATCH: '0', TRACKER_URL: BASE, IDENT_URL: ident.base, IDENT_EXCHANGE_URL: ident.base, TRACKER_KEY: APPKEY.key, TRACKER_SECRET: APPKEY.secret,
  135. TMDB_READ_TOKEN: 'gate-token', TMDB_BASE_URL: tmdb.base + '/3', TMDB_IMAGE_URL: tmdb.base + '/t/p', TVMAZE_BASE_URL: tmdb.base + '/tvmaze',
  136. TRACKER_SYNC: '0', TRACKER_ART: '0', TRACKER_PHOTOS: '0', TRACKER_BACKFILL: '0', TRACKER_SHORTIDS: '0', TRACKER_REPAIR: '0', TRACKER_KINDS: '0', TRACKER_CREDITS: '0', TRACKER_EDITORS: aliceIdentity,
  137. TRACKER_FRANCHISE_FILE: DATA_FILE, TRACKER_FRANCHISE_SEED_LOG_EVERY: '1', ...extra },
  138. stdio: ['ignore', 'pipe', 'pipe'] });
  139. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  140. for (let i = 0; ; i++) {
  141. try { if ((await fetch(BASE + '/')).ok) break; } catch {}
  142. if (i > 80) throw new Error('tracker did not come up\n' + log);
  143. await sleep(250);
  144. }
  145. };
  146. const waitLog = async (re, ms, label) => {
  147. for (let t = 0; t < ms && !re.test(log); t += 100) await sleep(100);
  148. if (!re.test(log)) throw new Error('log never showed ' + label + '\n' + log.slice(-3000));
  149. };
  150. const lines = (re) => log.split('\n').filter(l => re.test(l));
  151. let allLogs = '';
  152. // ==== FIRST START, stopped in the middle of the imports (TMDB answers slowly), then resumed ====
  153. tmdb.delayMs = 700;
  154. await startServer();
  155. await waitLog(/franchise seed: start, /, 30000, 'the seed start');
  156. const firstStart = lines(/^franchise seed: start/)[0] || '';
  157. const collectionsDoneAt = log.indexOf('collections done: '), seedStartAt = log.indexOf('franchise seed: start');
  158. check('the seed starts LAST — after the collection seed is done — and finds 2 franchises, 5 timelines, 11 titles: 5 we have, 6 to import',
  159. collectionsDoneAt >= 0 && seedStartAt > collectionsDoneAt && /2 franchises, 5 timelines, 11 titles in the file — 5 we have, 6 to import, 0 TMDB lacks/.test(firstStart), firstStart + '\n' + lines(/^collections/).join('\n'));
  160. await waitLog(/search import: /, 30000, 'the first import');
  161. await stopServer();
  162. allLogs += log;
  163. const firstImports = (log.match(/search import: /g) || []).length;
  164. tmdb.delayMs = 0;
  165. await startServer();
  166. await waitLog(/franchise seed done: /, 60000, 'the seed done');
  167. allLogs += log;
  168. const secondStart = lines(/^franchise seed: start/)[0] || '';
  169. const toImport = Number((secondStart.match(/(\d+) to import/) || [])[1]);
  170. check('seed RESUMES after a stop: the second start has fewer titles left to import (' + toImport + ') than 6 — the first run imported ' + firstImports,
  171. toImport < 6 && toImport >= 4 && firstImports >= 1, secondStart);
  172. const importLines = allLogs.split('\n').filter(l => /^search import: (movie|tv) \d+ /.test(l));
  173. const imported = importLines.map(l => l.match(/^search import: (\w+ \d+)/)[1]).sort();
  174. 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")',
  175. imported.join(',') === 'movie 9201,movie 9202,movie 9203,tv 9301,tv 9302' && (allLogs.match(/^search import credits: /gm) || []).length === 5, importLines.join('\n'));
  176. 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'));
  177. const done = lines(/^franchise seed done: /)[0] || '';
  178. check('done: 2 franchises (1 made, 1 the creator\'s ADOPTED), 5 timelines (4 made, the creator\'s "Gate Creator Line" KEPT), collection 900 attached AND superseded (all in "Gate Alien Saga"), the Extras collection (made on this start from the imported Gate Trek Movie I, partly covered) joined Gate Trek, 1 failed',
  179. /franchises=2 \(created=1 adopted=1\) timelines=5 \(created=4 updated=0 unchanged=0 kept=1 empty=0\) collections=1 superseded=1 unsuperseded=0 joined=1 titles=11 /.test(done) && / failed=1 /.test(done), done);
  180. // ==== THIRD START: nothing to do ====
  181. // the import's requests (details with append_to_response) since `from` — the collection seed's plain /movie/<id> checks of the
  182. // newly imported movies are its own business
  183. const importAsks = (from) => tmdb.requests.slice(from).map(r => r.path).filter(p => p.includes('append_to_response'));
  184. const requestsBefore = tmdb.requests.length;
  185. await stopServer();
  186. await startServer();
  187. await waitLog(/franchise seed done: /, 30000, 'the idempotent run');
  188. allLogs += log;
  189. const third = lines(/^franchise seed(: start| done)/).join('\n');
  190. const thirdColl = lines(/^collections done: /)[0] || '';
  191. check('the collection seed checks the imported Kelvin movies on the next start: "Gate Kelvin Collection" is made HIDDEN at once (all in Gate Trek — Kelvin)',
  192. / timelines=1 titles=2 superseded=1 /.test(thirdColl), thirdColl);
  193. check('IDEMPOTENT: the next start has 0 to import (10 we have, the 404 not asked again), makes and writes NOTHING, imports nothing from TMDB',
  194. /10 we have, 0 to import, 1 TMDB lacks/.test(third) && /\(created=0 adopted=0\) timelines=5 \(created=0 updated=0 unchanged=4 kept=1 empty=0\) collections=0 superseded=0 unsuperseded=0 joined=0 /.test(third)
  195. && / imported=0 failed=0 /.test(third) && importAsks(requestsBefore).length === 0, third + '\n' + importAsks(requestsBefore).join(' '));
  196. // ==== the pages, signed OUT ====
  197. browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  198. const page = await browser.newPage();
  199. globalThis.__gatePage = page;
  200. const ev = (e) => page.evaluate(e);
  201. const hydrated = () => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'hydrated' });
  202. const open = async (path) => { await page.goto(BASE + path); await hydrated(); };
  203. const widgets = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('franchise-widget')).map(w => ({
  204. franchise: w.querySelector('a.fw-franchise') ? w.querySelector('a.fw-franchise').textContent + ' ' + w.querySelector('a.fw-franchise').getAttribute('href') : '',
  205. prev: w.querySelector('.fw-prev') ? (w.querySelector('.fw-prev').tagName === 'A' ? 'A ' + w.querySelector('.fw-prev').getAttribute('href') : 'OFF') : '',
  206. name: w.querySelector('a.fw-timeline').textContent + ' ' + w.querySelector('a.fw-timeline').getAttribute('href'),
  207. next: w.querySelector('.fw-next') ? (w.querySelector('.fw-next').tagName === 'A' ? 'A ' + w.querySelector('.fw-next').getAttribute('href') : 'OFF') : '' })))`).then(JSON.parse);
  208. 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 : '',
  209. 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);
  210. await open('/franchises');
  211. 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('|')`);
  212. const loose = await ev(`Array.from(document.querySelectorAll('#timeline-list timeline-link-name')).map(x => x.textContent).join('|')`);
  213. check('/franchises: Gate Trek (3 timelines: Prime, Kelvin, the joined Extras collection; the superseded Kelvin collection not counted) and Gate Wars (2: the sagas; collection 900 hidden) — Gate Wars ONCE; loose: only the creator\'s "Gate Creator Line"',
  214. fr === 'Gate Trek (3 timelines) /franchises/Gate-Trek|Gate Wars (2 timelines) /franchises/Gate-Wars' && loose === 'Gate Creator Line', fr + ' / ' + loose);
  215. await open('/franchises/Gate-Trek');
  216. let b = (await blocks()).filter(x => x.name.startsWith('Gate Trek — '));
  217. 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"',
  218. JSON.stringify(b) === JSON.stringify([
  219. { 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)'] },
  220. { name: 'Gate Trek — Kelvin', count: '2 titles · release order', items: ['Kelvin One (2009)', 'Kelvin Two (2013)'] }]), JSON.stringify(b));
  221. check('/franchises/Gate-Trek: the file\'s summary', (await ev(`document.querySelector('p.franchise-summary').textContent`)) === 'Boldly gating.');
  222. await open('/franchises/Gate-Wars');
  223. b = await blocks();
  224. check('/franchises/Gate-Wars (the creator\'s): its own summary kept; the two seeded sagas — TMDB collection 900 is NOT shown (superseded by Gate Alien Saga)',
  225. (await ev(`document.querySelector('p.franchise-summary').textContent`)) === 'The creator\'s own franchise.'
  226. && b.map(x => x.name + ':' + x.count + ':' + x.items.join(',')).sort().join('|') === 'Gate Alien Saga:3 titles · in-universe 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));
  227. // tracker#40: no second widget from a collection the curated timelines cover; a partly covered one stays
  228. await open('/movies/gate-aliens');
  229. let w = await widgets();
  230. await open('/movies/Kelvin-One');
  231. const wk = await widgets();
  232. await open('/movies/Gate-Trek-Movie-I');
  233. const wt = await widgets();
  234. const hiddenPage = await (await fetch(BASE + '/timelines/Gate-Alien-Collection')).text();
  235. check('superseded collections give NO widget: Gate Aliens has ONE (Gate Alien Saga, Gate Wars above), Kelvin One ONE (Gate Trek — Kelvin); Gate Trek Movie I keeps the partly covered Extras collection beside Prime (2); the hidden timeline\'s own page still answers (for the editor)',
  236. w.length === 1 && w[0].name.startsWith('Gate Alien Saga ') && w[0].franchise.startsWith('Gate Wars ') && wk.length === 1 && wk[0].name.startsWith('Gate Trek — Kelvin ')
  237. && wt.map(x => x.name.split(' /')[0]).sort().join('|') === 'Gate Extras Collection|Gate Trek — Prime' && hiddenPage.includes('Gate Alien Collection'), JSON.stringify([w, wk, wt]));
  238. await open('/timelines/Gate-Creator-Line');
  239. b = await blocks();
  240. check('/timelines/Gate-Creator-Line (the creator\'s, same name as in the file): untouched — still only Gate Alien 3, no franchise',
  241. b.length === 1 && b[0].items.join(',') === 'Gate Alien 3 (1992)' && !(await ev(`!!document.querySelector('#franchise-up')`)), JSON.stringify(b));
  242. // a SERIES page: the widget like a movie's, and the imported series is complete
  243. await open('/series/Gate-Trek-Series');
  244. w = await widgets();
  245. 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',
  246. 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'
  247. && w[0].next === 'A /movies/gate-test-movie', JSON.stringify(w));
  248. const text = await ev('document.body.innerText');
  249. check('the imported series is complete: its poster, Season 1, its cast (Gate Trek Engineer) — the search\'s import, not a minimal record',
  250. (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));
  251. await page.click('a.fw-prev');
  252. 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' });
  253. await sleep(300);
  254. w = await widgets();
  255. check('a "‹ Prequel" click opens the imported movie; its Prime widget (beside the partly covered Extras collection\'s): Prequel → the fixture SERIES Gate Test Show, Sequel → the imported series',
  256. w.filter(x => x.name.startsWith('Gate Trek — Prime ')).length === 1 && w.filter(x => x.name.startsWith('Gate Trek — Prime '))[0].prev === 'A /series/gate-test-show'
  257. && w.filter(x => x.name.startsWith('Gate Trek — Prime '))[0].next === 'A /series/Gate-Trek-Series', JSON.stringify(w));
  258. await open('/series/gate-test-show');
  259. w = await widgets();
  260. 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));
  261. // ==== EDITOR CHANGES WIN: alice moves a title in Prime, the file then grows in Prime AND Kelvin ====
  262. const code = await ident.selectorCode(alice, APPKEY, BASE);
  263. await page.goto(BASE + '/login/callback?ident_code=' + code);
  264. await page.waitForSelector('#logout');
  265. await hydrated();
  266. await open('/timelines/Gate-Trek-Prime/edit');
  267. await ev(`document.querySelectorAll('#edit-rows edit-row')[1].querySelector('button[value="up"]').click()`);
  268. await page.waitFor(`document.querySelectorAll('#edit-rows edit-row')[0].querySelector('film-title').textContent === 'Gate Trek Movie I (1979)'`, { timeout: 8000, label: 'moved up' });
  269. writeData(true);
  270. // the page leaves the app first: a stopped server would be a refused socket in the console
  271. await page.goto('about:blank');
  272. await stopServer();
  273. await startServer();
  274. await waitLog(/franchise seed done: /, 60000, 'the run after the file changed');
  275. allLogs += log;
  276. const fourth = lines(/^franchise seed(: start| done)/).join('\n');
  277. 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',
  278. /2 to import/.test(fourth) && /timelines=5 \(created=0 updated=1 unchanged=2 kept=2 empty=0\)/.test(fourth) && / imported=2 /.test(fourth), fourth);
  279. await open('/franchises/Gate-Trek');
  280. b = (await blocks()).filter(x => x.name.startsWith('Gate Trek — '));
  281. 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',
  282. JSON.stringify(b) === JSON.stringify([
  283. { 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)'] },
  284. { name: 'Gate Trek — Kelvin', count: '3 titles · release order', items: ['Kelvin One (2009)', 'Kelvin Two (2013)', 'Kelvin Three (2016)'] }]), JSON.stringify(b));
  285. 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);
  286. // ==== 390 / 1280: nothing scrolls sideways; screenshots ====
  287. const pages = [['seed-franchises', '/franchises'], ['seed-franchise', '/franchises/Gate-Trek'], ['seed-widget-series', '/series/Gate-Trek-Series'], ['seed-timeline', '/timelines/Gate-Trek-Kelvin']];
  288. for (const [wd, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  289. await page.send('Emulation.setDeviceMetricsOverride', { width: wd, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  290. const bad = [];
  291. for (const [name, path] of pages) {
  292. await open(path);
  293. if (name.includes('widget')) await ev(`document.querySelector('franchise-widget').scrollIntoView({ block: 'center' })`);
  294. const sw = await ev('document.documentElement.scrollWidth');
  295. if (sw > wd) bad.push(name + ' ' + sw);
  296. if (SHOTS) writeFileSync(join(SHOTS, `${name}-${wd}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  297. }
  298. check(`${wd} px: /franchises, a franchise, a series with the widget, a timeline — nothing scrolls sideways`, bad.length === 0, bad.join('; '));
  299. }
  300. await page.send('Emulation.clearDeviceMetricsOverride', {});
  301. const problems = page.problems();
  302. check('no console errors/warnings', problems.length === 0, page.dumpConsole());
  303. 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'));
  304. }
  305. try {
  306. await main();
  307. } catch (e) {
  308. 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));
  309. } finally {
  310. await stop();
  311. }
  312. console.log(`\n${passes} passed, ${failures} failed`);
  313. process.exit(failures === 0 ? 0 : 1);

Branches

Latest commits

  • fbb903cctracker#33/#37 (mission 032): a title without a TMDB poster gets its backdrop (w780, posterFromBackdrop, shown 2:3 centre-cropped); movies store runtime, the page shows release date + runtime; art backfill (public titles without poster file / movies without runtime) and person photo backfill (tmdbProfile / photoCheck) as the last start jobs (TRACKER_ART, TRACKER_PHOTOS; off in every gate start); gates 379/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy: art 4977 titles in 42 min (115 posters, 15 backdrops, 4609 runtimes), The Remaining shows its backdrop + 7 minmre
  • 4ecc67b2tracker: STATUS/LOG for the t38 + t40 merge (gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy checks)mre
  • e5945d2fMerge t40 (tracker#40 curated franchises, Franchises menu, superseded collections) into main: deploy.sh lists all six gates (browser, kinds, franchises, pager, franchiseseed, check-theme); search.hl keeps #37's personPhotoOf + #40's importWithCredits; pager gate runs with TRACKER_FRANCHISE_SEED=0; gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0mre
  • b638e99dMerge t38 (tracker#38 pagination, #35 Returning/Airing label) into main: LOG/STATUS keep both sides; pager gate follows #37's /people (everyone, last updated first: seed updatedAt); gates pager 229/0, browser 374/0mre
  • f8ffa4dbtracker: report 032 + The Remaining + #39mre
  • 7565a863tracker: LOG timemre
  • b10f00c8tracker#39: double episodes — migrated episodes whose TMDB id TMDB replaced are adopted by their number in the sync (old id -> migratedTmdbId); merge.hl step 3 merges each season's doubles at start (keeper: most watches > synced > first; watches moved/parked; tombstones into mergedEpisodes, nothing deleted); tools/count-duplicate-episodes.hl; gate fixture + paths-m039; live copy 850 -> 0 in 64 s; gates 373/0, 32/0, 52/0mre
  • 8f1d4542tracker#40: superseded collections — a TMDB collection timeline whose titles are all in one curated timeline is hidden (supersededBy; kept: own page + editor finder), set by the collection seed when it makes one and by the curated build (lifted when the cover is gone); partly covered ones join that franchise; no second widget (First Contact: only Star Trek — Prime); gate franchiseseed 26/0, browser 365/0, kinds 32/0, franchises 53/0, check-theme 0; real copy 24 supersededmre
  • 9448d643tracker#35 follow-up (mission 033): TVmaze 'Running' is labelled 'Returning', or 'Airing' while a non-special episode of the two newest seasons is released within today +-7 days (data unchanged); gate fixtures Running/Airing/Aired + a special; browser 366/0, kinds 32/0, franchises 52/0, pager 229/0, check-theme 0mre
  • 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
  • 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
  • 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
  • 03ec792ftracker#38 (mission 033): pagination goes exactly to the clicked page — tilelist read the clicked button's text after pagination.hl's own handler had rebuilt the buttons (real clicks only); now li.current, else the button's own text; new gate tests/pager.mjs (5 lists x 11 pages, 390/1280, real + script clicks, Back/Forward) 229/0; browser 365/0, kinds 32/0, franchises 52/0, check-theme 0mre
  • 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