gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit8f1d45428f1d4542tracker#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 supersededmre8f1d4542/tests/franchiseseed.mjs

27.4 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_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

  • 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
  • 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