gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit27c916fa27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre27c916fa/tests/franchises.mjs

36.9 KB

  1. // tests/franchises.mjs — THE GATE of tracker.worldapi.org#19 (mission 066): franchises, timelines, the widget, the editor —
  2. // a gate of its own beside tests/browser.mjs (same parts: its own ident from identkit.mjs, the fake TMDB, a real headless
  3. // Chrome), on its own storage (.scratch/franchise-store). Proves:
  4. // - THE SEED from fake TMDB collections: one timeline per collection with ≥ 2 of our movies, parts in release order, a part
  5. // we don't have / an adult one left out, a 1-title collection gives none, 404 and "no collection" are remembered; every
  6. // part of a known collection is checked WITHOUT its own request; ≥ 260 ms between TMDB requests; it RESUMES after a stop
  7. // (no movie asked twice, no timeline twice) and has nothing to do on the next start
  8. // - the WIDGET: "‹ Prequel | name | Sequel ›" links, the ends greyed out, a Sequel click (client-side) moves along, the
  9. // franchise above it once the timeline has one → its index
  10. // - /franchises, /franchises/<slug> (each timeline sortable: timeline order | release date, a series by its LAST episode),
  11. // /timelines/<slug>
  12. // - THE EDITOR only for the creator (TRACKER_EDITORS = alice's ident id here; az5b2 in production): signed out and bob see
  13. // "Only the creator …" and no forms; alice creates a franchise and a timeline, attaches, adds (search), moves, removes
  14. // - 390 / 1280 px: nothing scrolls sideways; screenshots with TRACKER_GATE_SHOTS
  15. //
  16. // TRACKER_GATE_SHOTS=/tmp/w066/shots TRACKER_GATE_PORT=8732 TRACKER_GATE_IDENT_PORT=8733 TRACKER_GATE_CHROME=8734-8736 \
  17. // TRACKER_GATE_TMDB_PORT=8737 node tests/franchises.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', 'franchise-store');
  35. const SESSIONS = join(APP, '.scratch', 'franchise-sessions');
  36. 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')));
  37. rmSync(STORE, { recursive: true, force: true });
  38. rmSync(SESSIONS, { recursive: true, force: true });
  39. mkdirSync(SESSIONS, { recursive: true });
  40. if (SHOTS) mkdirSync(SHOTS, { recursive: true });
  41. let failures = 0, passes = 0;
  42. function check(label, ok, detail = '') {
  43. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  44. if (ok) passes++; else failures++;
  45. }
  46. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  47. let ident = null, server = null, browser = null, log = '', tmdb = null;
  48. async function stopServer() {
  49. if (server) {
  50. const s = server;
  51. try { s.kill('SIGTERM'); } catch {}
  52. await new Promise(r => { if (s.exitCode !== null || s.signalCode !== null) return r(); s.once('exit', r); setTimeout(r, 3000); });
  53. server = null;
  54. }
  55. }
  56. async function stop() {
  57. if (ident) { try { await ident.stop(); } catch {} ident = null; }
  58. if (browser) { try { await browser.close(); } catch {} browser = null; }
  59. await stopServer();
  60. if (tmdb) { try { await tmdb.stop(); } catch {} tmdb = null; }
  61. }
  62. // what the fake TMDB says (tests/seed-franchises.hl has the movies): collection 900's parts OUT of release order, with a part
  63. // we don't have (9999) and an adult one (9105); 901 has one part of ours; 9106 has none; 9107 is unknown (404)
  64. const coll900 = { id: 900, name: 'Gate Alien Collection' };
  65. const gateMovies = () => ({
  66. 1003: { id: 1003, title: 'Gate Test Movie', poster_path: null, belongs_to_collection: null },
  67. 9101: { id: 9101, title: 'Gate Alien', belongs_to_collection: coll900 },
  68. 9102: { id: 9102, title: 'Gate Aliens', belongs_to_collection: coll900 },
  69. 9103: { id: 9103, title: 'Gate Alien 3', belongs_to_collection: coll900 },
  70. 9104: { id: 9104, title: 'Gate Loner', belongs_to_collection: { id: 901, name: 'Gate Lonely Collection' } },
  71. 9105: { id: 9105, title: 'Gate Adult Alien', adult: true, belongs_to_collection: coll900 },
  72. 9106: { id: 9106, title: 'Gate Standalone', belongs_to_collection: null },
  73. });
  74. const gateCollections = () => ({
  75. 900: { id: 900, name: 'Gate Alien Collection', parts: [
  76. { id: 9103, title: 'Gate Alien 3', release_date: '1992-05-22', adult: false },
  77. { id: 9999, title: 'Not Ours', release_date: '1980-01-01', adult: false },
  78. { id: 9101, title: 'Gate Alien', release_date: '1979-05-25', adult: false },
  79. { id: 9105, title: 'Gate Adult Alien', release_date: '1990-01-01', adult: true },
  80. { id: 9102, title: 'Gate Aliens', release_date: '1986-07-18', adult: false }] },
  81. 901: { id: 901, name: 'Gate Lonely Collection', parts: [{ id: 9104, title: 'Gate Loner', release_date: '2000-01-01' }, { id: 9998, title: 'Not Ours Either', release_date: '2003-01-01' }] },
  82. });
  83. async function main() {
  84. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  85. const alice = await ident.signIn('[email protected]');
  86. const bob = await ident.signIn('[email protected]');
  87. const APPKEY = await ident.registerApp(alice, 'tracker (franchise gate)', [BASE]);
  88. const aliceIdentity = await ident.exchange(APPKEY, await ident.selectorCode(alice, APPKEY, BASE));
  89. 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);
  90. const MPACKDB = join(STORE, 'mpackdb');
  91. mkdirSync(MPACKDB, { recursive: true });
  92. const seedOut = execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-show.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_IDENTITY: aliceIdentity } }).toString()
  93. + execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-franchises.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB } }).toString();
  94. check('seed: the fixture shows (tests/seed-show.hl) + 7 collection movies (tests/seed-franchises.hl)', seedOut.includes('gate-test-show') && seedOut.includes('seed-franchises: 7 movies'), seedOut);
  95. tmdb = await startFakeTmdb({ port: TMDB_PORT, shows: {}, movies: gateMovies(), collections: gateCollections() });
  96. const startServer = async (extra = {}) => {
  97. log = '';
  98. server = spawn(join(APP, 'bin/hybriel'), ['project.hl'], { cwd: APP, env: { ...process.env, TRACKER_PORT: String(PORT), TRACKER_SESSIONS: SESSIONS + '/', TRACKER_STORAGE: MPACKDB,
  99. TRACKER_WATCH: '0', TRACKER_URL: BASE, IDENT_URL: ident.base, IDENT_EXCHANGE_URL: ident.base, TRACKER_KEY: APPKEY.key, TRACKER_SECRET: APPKEY.secret,
  100. TMDB_READ_TOKEN: 'gate-token', TMDB_BASE_URL: tmdb.base + '/3', TMDB_IMAGE_URL: tmdb.base + '/t/p', TVMAZE_BASE_URL: tmdb.base + '/tvmaze',
  101. TRACKER_SYNC: '0', TRACKER_BACKFILL: '0', TRACKER_SHORTIDS: '0', TRACKER_REPAIR: '0', TRACKER_KINDS: '0', TRACKER_CREDITS: '0', TRACKER_EDITORS: aliceIdentity, TRACKER_COLLECTIONS_LOG_EVERY: '1', TRACKER_COLLECTIONS_PERSIST_EVERY: '1', ...extra },
  102. stdio: ['ignore', 'pipe', 'pipe'] });
  103. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  104. for (let i = 0; ; i++) {
  105. try { if ((await fetch(BASE + '/')).ok) break; } catch {}
  106. if (i > 80) throw new Error('tracker did not come up\n' + log);
  107. await sleep(250);
  108. }
  109. };
  110. const waitLog = async (re, ms, label) => {
  111. for (let t = 0; t < ms && !re.test(log); t += 100) await sleep(100);
  112. if (!re.test(log)) throw new Error('log never showed ' + label + '\n' + log.slice(-3000));
  113. };
  114. // ==== THE SEED, stopped in the middle (TMDB answers slowly), then resumed ====
  115. tmdb.delayMs = 700;
  116. await startServer();
  117. await waitLog(/collections: start, 7 movies to check/, 10000, 'the seed start');
  118. check('seed: starts by itself, 7 movies to check (Gate Test Movie + 6 of the 7 fixtures: the adult one is skipped)', true);
  119. await waitLog(/collections: [23]\/7 movies/, 20000, 'two steps');
  120. await stopServer();
  121. const firstRun = log;
  122. const firstRequests = tmdb.requests.length;
  123. tmdb.delayMs = 0;
  124. await startServer();
  125. await waitLog(/collections done: /, 20000, 'the seed done');
  126. const startLine = (log.match(/collections: start, (\d+) movies to check/) || [])[1];
  127. check('seed RESUMES after a stop: the second start has fewer movies left than 7', startLine && Number(startLine) < 7 && Number(startLine) > 0, 'second start: ' + startLine + '\nfirst run:\n' + firstRun.split('\n').filter(l => l.startsWith('collections')).join('\n'));
  128. const paths = tmdb.requests.map(r => r.path);
  129. const moviePaths = paths.filter(p => p.startsWith('/3/movie/'));
  130. const dup = moviePaths.filter((p, i) => moviePaths.indexOf(p) !== i);
  131. check('seed: no movie asked twice across the stop and the restart; never the adult one (9105) nor a part of a known collection on its own (two of 9101/9102/9103 never asked)',
  132. dup.length === 0 && !moviePaths.includes('/3/movie/9105') && ['9101', '9102', '9103'].filter(id => moviePaths.includes('/3/movie/' + id)).length === 1, JSON.stringify(paths));
  133. check('seed: each collection asked exactly once (900, 901), 7 TMDB requests in all (5 movies + 2 collections)',
  134. paths.filter(p => p === '/3/collection/900').length === 1 && paths.filter(p => p === '/3/collection/901').length === 1 && paths.length === 7, JSON.stringify(paths) + ' (first run: ' + firstRequests + ')');
  135. const second = tmdb.requests.slice(firstRequests);
  136. // a step's 1–2 requests, then 260 ms per request made: any 3 requests in a row span ≥ 500 ms (≤ 40 / 10 s)
  137. const spans = second.slice(2).map((r, i) => r.at - second[i].at);
  138. check('seed: paced like the adult backfill — any 3 TMDB requests in a row span ≥ 500 ms (≤ 40 / 10 s)', spans.length > 0 && spans.every(g => g >= 500), JSON.stringify(spans));
  139. // the counters are per start: the first run's last line + the done line add up to the whole seed
  140. const done = log.match(/collections done: .*/)[0];
  141. const lastFirst = firstRun.split('\n').filter(l => /^collections: \d+\/7 movies/.test(l)).pop() || '';
  142. const sum = (k) => Number((lastFirst.match(new RegExp(' ' + k + '=(\\d+)')) || [0, 0])[1]) + Number((done.match(new RegExp(' ' + k + '=(\\d+)')) || [0, 0])[1]);
  143. check('seed totals (both starts): 1 timeline with 3 titles, 1 too-small collection, 2 without a collection, 1 TMDB 404, 0 retries',
  144. sum('timelines') === 1 && sum('titles') === 3 && sum('small') === 1 && sum('noCollection') === 2 && sum('notFound') === 1 && sum('retries') === 0, lastFirst + '\n' + done);
  145. await stopServer();
  146. await startServer();
  147. await waitLog(/collections: (nothing to do|start, 0 movies)/, 10000, 'nothing to do');
  148. check('seed: the next start finds every movie checked — nothing to do, no TMDB request', /collections: start, 0 movies to check/.test(log) && /collections: nothing to do/.test(log) && tmdb.requests.length === 7, log.split('\n').filter(l => l.startsWith('collections')).join('\n'));
  149. // ==== the pages, signed OUT ====
  150. browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  151. const page = await browser.newPage();
  152. globalThis.__gatePage = page;
  153. const ev = (e) => page.evaluate(e);
  154. const hydrated = () => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'hydrated' });
  155. const open = async (path) => { await page.goto(BASE + path); await hydrated(); };
  156. const widgets = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('franchise-widget')).map(w => ({
  157. franchise: w.querySelector('a.fw-franchise') ? w.querySelector('a.fw-franchise').textContent + ' ' + w.querySelector('a.fw-franchise').getAttribute('href') : '',
  158. prev: w.querySelector('.fw-prev') ? (w.querySelector('.fw-prev').tagName === 'A' ? 'A ' + w.querySelector('.fw-prev').getAttribute('href') + ' ' + w.querySelector('.fw-prev fw-title').textContent : 'OFF ' + w.querySelector('.fw-prev').textContent.trim()) : '',
  159. name: w.querySelector('a.fw-timeline').textContent + ' ' + w.querySelector('a.fw-timeline').getAttribute('href'),
  160. next: w.querySelector('.fw-next') ? (w.querySelector('.fw-next').tagName === 'A' ? 'A ' + w.querySelector('.fw-next').getAttribute('href') + ' ' + w.querySelector('.fw-next fw-title').textContent : 'OFF ' + w.querySelector('.fw-next').textContent.trim()) : '' })))`).then(JSON.parse);
  161. const loginAs = async (who) => {
  162. const code = await ident.selectorCode(who, APPKEY, BASE);
  163. await page.goto(BASE + '/login/callback?ident_code=' + code);
  164. await page.waitForSelector('#logout');
  165. await hydrated();
  166. };
  167. const logout = async () => { await page.click('#logout'); await page.waitForSelector('#loginbutton', { timeout: 15000 }); };
  168. await open('/movies/gate-aliens');
  169. let w = await widgets();
  170. check('widget (Gate Aliens): ONE widget, no franchise yet, "‹ Prequel" → Gate Alien (1979), the timeline → /timelines/Gate-Alien-Collection, "Sequel ›" → Gate Alien 3 (1992)',
  171. w.length === 1 && w[0].franchise === '' && w[0].prev === 'A /movies/gate-alien Gate Alien (1979)' && w[0].name === 'Gate Alien Collection /timelines/Gate-Alien-Collection'
  172. && w[0].next === 'A /movies/gate-alien-3 Gate Alien 3 (1992)', JSON.stringify(w));
  173. // mission 025: right under the title/poster block — phone: below the poster, above the plot; wide: in the right column under the title
  174. const widgetPos = () => ev(`JSON.stringify((() => { const r = (s) => document.querySelector(s).getBoundingClientRect(), w = r('franchise-widgets');
  175. return { inMeta: !!document.querySelector('show-meta franchise-widgets'), title: r('show-title').bottom, poster: r('show-poster').bottom, posterRight: r('show-poster').right, top: w.top, bottom: w.bottom, left: w.left, summary: r('show-summary').top }; })())`).then(JSON.parse);
  176. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
  177. const posPhone = await widgetPos();
  178. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  179. const posWide = await widgetPos();
  180. await page.send('Emulation.clearDeviceMetricsOverride', {});
  181. check('widget on a phone (390 px): under the title and the poster, above the plot (mission 025: it was below the cast)', posPhone.inMeta && posPhone.title <= posPhone.top && posPhone.poster <= posPhone.top && posPhone.bottom <= posPhone.summary, JSON.stringify(posPhone));
  182. check('widget at 1280 px: right of the poster, under the title, above the plot', posWide.left >= posWide.posterRight && posWide.title <= posWide.top && posWide.bottom <= posWide.summary, JSON.stringify(posWide));
  183. await open('/movies/gate-alien');
  184. w = await widgets();
  185. check('widget (Gate Alien, the first): the Prequel end is greyed out (no link), Sequel → Gate Aliens', w.length === 1 && w[0].prev === 'OFF ‹ Prequel' && w[0].next === 'A /movies/gate-aliens Gate Aliens (1986)'
  186. && (await ev(`getComputedStyle(document.querySelector('.fw-prev.off')).opacity`)) === '0.5', JSON.stringify(w));
  187. await ev(`window.__w066 = 'same document'`);
  188. await page.click('a.fw-next');
  189. await page.waitFor(`location.pathname === '/movies/gate-aliens' && document.querySelector('show-title h1 type-name') && document.querySelector('show-title h1 type-name').textContent === 'Gate Aliens'`, { timeout: 10000, label: 'sequel click' });
  190. await sleep(300);
  191. w = await widgets();
  192. check('widget: a "Sequel ›" click opens Gate Aliens client-side (no reload) and its widget follows (Prequel → Gate Alien)', (await ev('window.__w066')) === 'same document'
  193. && w.length === 1 && w[0].prev === 'A /movies/gate-alien Gate Alien (1979)' && w[0].next === 'A /movies/gate-alien-3 Gate Alien 3 (1992)', JSON.stringify(w));
  194. await open('/movies/gate-alien-3');
  195. w = await widgets();
  196. check('widget (Gate Alien 3, the last): Sequel greyed out', w.length === 1 && w[0].next === 'OFF Sequel ›' && w[0].prev === 'A /movies/gate-aliens Gate Aliens (1986)', JSON.stringify(w));
  197. for (const [slug, why] of [['gate-loner', 'a 1-title collection'], ['gate-standalone', 'no collection'], ['gate-test-show', 'in no timeline']]) {
  198. await open('/shows/' + slug);
  199. check(`widget: none on ${slug} (${why})`, (await ev(`document.querySelectorAll('franchise-widget').length`)) === 0);
  200. }
  201. await open('/franchises');
  202. const loose = await ev(`Array.from(document.querySelectorAll('#timeline-list a.timeline-link')).map(a => a.querySelector('timeline-link-name').textContent + ' (' + a.querySelector('timeline-link-count').textContent + ') ' + a.getAttribute('href')).join('|')`);
  203. check('/franchises signed out: "No franchises yet.", the timelines without one: only Gate Alien Collection (3 titles; the 1-title one made none), no Edit link',
  204. (await ev(`document.querySelector('franchises-page p.muted').textContent`)) === 'No franchises yet.'
  205. && loose === 'Gate Alien Collection (3 titles) /timelines/Gate-Alien-Collection' && !(await ev(`!!document.querySelector('#franchises-edit')`)), loose);
  206. await open('/timelines/Gate-Alien-Collection');
  207. const items = () => ev(`Array.from(document.querySelectorAll('a.timeline-item')).map(a => a.getAttribute('data-timeline').slice(0, 4) + ':' + a.querySelector('film-title').textContent + '@' + a.querySelector('.timeline-date').textContent).join('|')`);
  208. check('/timelines/Gate-Alien-Collection: the parts in release order (TMDB gave them mixed), without the one we lack and the adult one; no franchise above, no Edit link signed out',
  209. (await items()).replace(/^[^:]*:/, '').split('|').map(x => x.replace(/^[^:]*:/, '')).join('|') === 'Gate Alien (1979)@1979-05-25|Gate Aliens (1986)@1986-07-18|Gate Alien 3 (1992)@1992-05-22'
  210. && !(await ev(`!!document.querySelector('#franchise-up')`)) && !(await ev(`!!document.querySelector('#timeline-edit')`)), await items());
  211. await open('/franchises/edit');
  212. check('/franchises/edit signed out: "Only the creator …", no form', (await ev(`document.querySelector('#edit-refused') ? document.querySelector('#edit-refused').textContent : ''`)) === 'Only the creator can edit franchises and timelines.'
  213. && !(await ev(`!!document.querySelector('#fe-name')`)));
  214. await open('/franchises/nope');
  215. check('/franchises/<unknown> and /timelines/<unknown>: Not found', (await ev(`document.querySelector('h1').textContent`)) === 'Not found'
  216. && (await (await fetch(BASE + '/timelines/nope')).text()).includes('Not found'));
  217. // ==== bob (signed in, not the creator): no editor ====
  218. await loginAs(bob);
  219. await open('/franchises/edit');
  220. const bobRefused = (await ev(`!!document.querySelector('#edit-refused')`)) && !(await ev(`!!document.querySelector('#fe-name')`));
  221. await open('/timelines/Gate-Alien-Collection/edit');
  222. check('bob (signed in, not the creator): /franchises/edit and /timelines/…/edit say "Only the creator …", no forms, no chips, no ↑↓✕',
  223. bobRefused && (await ev(`!!document.querySelector('#edit-refused')`)) && !(await ev(`!!document.querySelector('#franchise-chips') || !!document.querySelector('button.mv')`)));
  224. await open('/franchises');
  225. check('bob: no Edit link on /franchises', !(await ev(`!!document.querySelector('#franchises-edit')`)));
  226. await logout();
  227. // ==== alice = the creator: the editor ====
  228. await loginAs(alice);
  229. await open('/franchises');
  230. check('creator: /franchises has the Edit link → /franchises/edit', (await ev(`document.querySelector('#franchises-edit') ? document.querySelector('#franchises-edit').getAttribute('href') : ''`)) === '/franchises/edit');
  231. await page.click('#franchises-edit');
  232. await page.waitFor(`location.pathname === '/franchises/edit' && !!document.querySelector('#fe-name')`, { timeout: 10000, label: 'editor' });
  233. await page.type('#fe-name', 'Gate Alien Franchise');
  234. await page.type('#fe-summary', 'Facehuggers, all of them.');
  235. await page.click('#fe-create');
  236. await page.waitFor(`location.pathname === '/franchises/Gate-Alien-Franchise' && document.querySelector('h1 type-name') && document.querySelector('h1 type-name').textContent === 'Gate Alien Franchise'`, { timeout: 10000, label: 'new franchise page' });
  237. check('editor: "Create franchise" opens the new franchise\'s index (/franchises/Gate-Alien-Franchise): name, summary, "No timelines yet."',
  238. (await ev(`document.querySelector('p.franchise-summary').textContent`)) === 'Facehuggers, all of them.' && (await ev(`document.querySelector('franchise-page p.muted').textContent`)) === 'No timelines yet.');
  239. await open('/franchises/edit');
  240. await page.click('#fe-create');
  241. await page.waitFor(`!!document.querySelector('#edit-message')`, { timeout: 5000, label: 'empty name refused' });
  242. check('editor: an empty name is refused with a message', /needs a name/.test(await ev(`document.querySelector('#edit-message').textContent`)));
  243. // attach the seeded timeline to the franchise
  244. await open('/timelines/Gate-Alien-Collection/edit');
  245. const chips = () => ev(`Array.from(document.querySelectorAll('#franchise-chips button')).map(b => b.textContent + (b.classList.contains('on') ? '*' : '')).join('|')`);
  246. check('editor (Gate Alien Collection): the franchise chips — None (on) | Gate Alien Franchise; the 3 titles in order with ↑ ↓ ✕', (await chips()) === 'None*|Gate Alien Franchise'
  247. && (await ev(`Array.from(document.querySelectorAll('#edit-rows edit-row film-title')).map(x => x.textContent).join('|')`)) === 'Gate Alien (1979)|Gate Aliens (1986)|Gate Alien 3 (1992)', await chips());
  248. await page.click('#franchise-chips button[value]:not(.on)');
  249. await page.waitFor(`document.querySelector('#franchise-chips button.on') && document.querySelector('#franchise-chips button.on').textContent === 'Gate Alien Franchise'`, { timeout: 5000, label: 'chip on' });
  250. await open('/timelines/Gate-Alien-Collection/edit');
  251. check('editor: a tap on the franchise chip attaches the timeline (still on after a reload)', (await chips()) === 'None|Gate Alien Franchise*', await chips());
  252. await open('/movies/gate-aliens');
  253. w = await widgets();
  254. check('widget: the franchise ABOVE the row now, linking to its index', w.length === 1 && w[0].franchise === 'Gate Alien Franchise /franchises/Gate-Alien-Franchise'
  255. && (await ev(`document.querySelector('a.fw-franchise').getBoundingClientRect().bottom <= document.querySelector('fw-row').getBoundingClientRect().top`)), JSON.stringify(w));
  256. await page.click('a.fw-franchise');
  257. await page.waitFor(`location.pathname === '/franchises/Gate-Alien-Franchise' && document.querySelectorAll('timeline-head').length === 1`, { timeout: 10000, label: 'franchise index via the widget' });
  258. check('widget: a click on the franchise opens its index with the timeline', (await ev(`document.querySelector('a.timeline-name').textContent`)) === 'Gate Alien Collection');
  259. // a new hand-made timeline: series + movie, attached, titles added by search, moved, one removed
  260. await open('/franchises/edit');
  261. await page.type('#te-name', 'Gate Mixed Timeline');
  262. await page.click('#te-create');
  263. await page.waitFor(`location.pathname === '/timelines/Gate-Mixed-Timeline/edit' && !!document.querySelector('#ted-search')`, { timeout: 10000, label: 'new timeline editor' });
  264. check('editor: "Create timeline" opens its editor, no titles yet', /No titles yet/.test(await ev(`document.querySelector('timeline-edit-page').textContent`)));
  265. await page.click('#franchise-chips button[value]:not(.on)');
  266. await page.waitFor(`document.querySelector('#franchise-chips button.on') && document.querySelector('#franchise-chips button.on').textContent === 'Gate Alien Franchise'`, { timeout: 5000, label: 'chip on (mixed)' });
  267. const addBySearch = async (text, title) => {
  268. await page.type('#ted-search', text, { clear: true });
  269. await page.waitFor(`Array.from(document.querySelectorAll('#ted-results edit-row')).some(r => r.querySelector('film-title').textContent === ${JSON.stringify(title)})`, { timeout: 8000, label: 'result ' + title });
  270. const before = await ev(`document.querySelectorAll('#edit-rows edit-row').length`);
  271. await ev(`Array.from(document.querySelectorAll('#ted-results edit-row')).find(r => r.querySelector('film-title').textContent === ${JSON.stringify(title)}).querySelector('button.add').click()`);
  272. await page.waitFor(`document.querySelectorAll('#edit-rows edit-row').length === ${before + 1}`, { timeout: 8000, label: 'added ' + title });
  273. };
  274. const editRows = () => ev(`Array.from(document.querySelectorAll('#edit-rows edit-row')).map(r => r.querySelector('edit-pos').textContent + '.' + r.querySelector('film-title').textContent).join('|')`);
  275. await addBySearch('pager older', 'Pager Older Series (2019)');
  276. await addBySearch('gate test', 'Gate Test Show (2024)');
  277. await addBySearch('gate test', 'Gate Test Movie (1996)');
  278. await addBySearch('alien 3', 'Gate Alien 3 (1992)');
  279. check('editor: four titles added by search, each at the end', (await editRows()) === '1.Pager Older Series (2019)|2.Gate Test Show (2024)|3.Gate Test Movie (1996)|4.Gate Alien 3 (1992)', await editRows());
  280. check('editor: a title already in the timeline is not offered again', !(await ev(`Array.from(document.querySelectorAll('#ted-results film-title')).some(t => t.textContent === 'Gate Alien 3 (1992)')`)));
  281. await ev(`document.querySelector('#edit-rows edit-row[data-show="fr0000000000f103"] button.rm').click()`);
  282. await page.waitFor(`document.querySelectorAll('#edit-rows edit-row').length === 3`, { timeout: 8000, label: 'removed' });
  283. await ev(`document.querySelectorAll('#edit-rows edit-row')[2].querySelector('button[value="up"]').click()`);
  284. await page.waitFor(`document.querySelectorAll('#edit-rows edit-row')[1].querySelector('film-title').textContent === 'Gate Test Movie (1996)'`, { timeout: 8000, label: 'moved up' });
  285. check('editor: ✕ removes, ↑ moves one place up: Pager Older Series, Gate Test Movie, Gate Test Show', (await editRows()) === '1.Pager Older Series (2019)|2.Gate Test Movie (1996)|3.Gate Test Show (2024)', await editRows());
  286. check('editor: the first row\'s ↑ and the last row\'s ↓ are greyed out (not clickable)', (await ev(`document.querySelectorAll('#edit-rows edit-row')[0].querySelector('button[value="up"]').classList.contains('off') && document.querySelectorAll('#edit-rows edit-row')[2].querySelector('button[value="down"]').classList.contains('off') && getComputedStyle(document.querySelectorAll('#edit-rows edit-row')[0].querySelector('button[value="up"]')).pointerEvents === 'none'`)));
  287. if (SHOTS) {
  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. await page.type('#ted-search', 'gate', { clear: true });
  291. await page.waitFor(`document.querySelectorAll('#ted-results edit-row').length > 0`, { timeout: 8000, label: 'results for the shot' });
  292. writeFileSync(join(SHOTS, `timeline-edit-${wd}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true })).data, 'base64'));
  293. }
  294. await page.send('Emulation.clearDeviceMetricsOverride', {});
  295. }
  296. await open('/timelines/Gate-Mixed-Timeline/edit');
  297. check('editor: after a reload the order and the franchise are stored', (await editRows()) === '1.Pager Older Series (2019)|2.Gate Test Movie (1996)|3.Gate Test Show (2024)' && (await chips()) === 'None|Gate Alien Franchise*', await editRows());
  298. // the franchise index: two timelines, each sortable on its own
  299. await open('/franchises/Gate-Alien-Franchise');
  300. const blockItems = (tid) => ev(`Array.from(document.querySelectorAll('a.timeline-item[data-timeline="' + ${tid} + '"]')).map(a => a.querySelector('film-title').textContent + '@' + a.querySelector('.timeline-date').textContent).join('|')`);
  301. const heads = JSON.parse(await ev(`JSON.stringify(Array.from(document.querySelectorAll('timeline-head')).map(h => ({ id: h.getAttribute('data-timeline'), name: h.querySelector('a.timeline-name').textContent, on: h.querySelector('button.sortbtn.on').textContent })))`));
  302. check('index: both timelines of the franchise, the older first (by first release), each sorted by "Timeline"', heads.map(h => h.name + ':' + h.on).join('|') === 'Gate Alien Collection:Timeline|Gate Mixed Timeline:Timeline', JSON.stringify(heads));
  303. const mixedId = JSON.stringify((heads.find(h => h.name === 'Gate Mixed Timeline') || {}).id);
  304. const alienId = JSON.stringify((heads.find(h => h.name === 'Gate Alien Collection') || {}).id);
  305. const mixedTimelineOrder = await blockItems(mixedId);
  306. check('index: the mixed timeline in timeline order; a series shows the date of its LAST released episode', mixedTimelineOrder === 'Pager Older Series (2019)@last episode 2021-01-01|Gate Test Movie (1996)@1996-11-18|Gate Test Show (2024)@last episode 2020-06-15', mixedTimelineOrder);
  307. await ev(`document.querySelector('timeline-head[data-timeline=' + JSON.stringify(${mixedId}) + '] button[value="release"]').click()`);
  308. await page.waitFor(`document.querySelector('timeline-head[data-timeline=' + JSON.stringify(${mixedId}) + '] button.sortbtn.on').textContent === 'Release'`, { timeout: 5000, label: 'release toggle' });
  309. const mixedRelease = await blockItems(mixedId);
  310. check('index: "Release" sorts that timeline by release date — Gate Test Show (last episode 2020-06-15, not its 2099 one nor its 2024 year) between the 1996 movie and the series ending 2021',
  311. mixedRelease === 'Gate Test Movie (1996)@1996-11-18|Gate Test Show (2024)@last episode 2020-06-15|Pager Older Series (2019)@last episode 2021-01-01', mixedRelease);
  312. check('index: the other timeline keeps its own toggle ("Timeline")', (await ev(`document.querySelector('timeline-head[data-timeline=' + JSON.stringify(${alienId}) + '] button.sortbtn.on').textContent`)) === 'Timeline');
  313. await ev(`document.querySelector('timeline-head[data-timeline=' + JSON.stringify(${mixedId}) + '] button[value="timeline"]').click()`);
  314. await page.waitFor(`document.querySelector('timeline-head[data-timeline=' + JSON.stringify(${mixedId}) + '] button.sortbtn.on').textContent === 'Timeline'`, { timeout: 5000, label: 'timeline toggle' });
  315. check('index: "Timeline" brings the in-world order back', (await blockItems(mixedId)) === mixedTimelineOrder);
  316. await open('/timelines/Gate-Mixed-Timeline');
  317. check('/timelines/Gate-Mixed-Timeline: the franchise above it (→ its index), the creator\'s Edit link', (await ev(`document.querySelector('#franchise-up').textContent + ' ' + document.querySelector('#franchise-up').getAttribute('href')`)) === 'Gate Alien Franchise /franchises/Gate-Alien-Franchise'
  318. && (await ev(`document.querySelector('#timeline-edit').getAttribute('href')`)) === '/timelines/Gate-Mixed-Timeline/edit');
  319. await open('/movies/gate-test-movie');
  320. w = await widgets();
  321. check('widget on a movie in the mixed timeline: Prequel → the SERIES before it, Sequel → the series after it', w.length === 1 && w[0].prev === 'A /series/pager-older-series Pager Older Series (2019)'
  322. && w[0].next === 'A /series/gate-test-show Gate Test Show (2024)' && w[0].franchise === 'Gate Alien Franchise /franchises/Gate-Alien-Franchise', JSON.stringify(w));
  323. await open('/series/gate-test-show');
  324. w = await widgets();
  325. check('widget on a series page too (last of its timeline: Sequel greyed out)', w.length === 1 && w[0].next === 'OFF Sequel ›' && w[0].prev === 'A /movies/gate-test-movie Gate Test Movie (1996)', JSON.stringify(w));
  326. // a title in TWO timelines: Gate Alien 3 into the mixed one again → two widgets
  327. await open('/timelines/Gate-Mixed-Timeline/edit');
  328. await addBySearch('alien 3', 'Gate Alien 3 (1992)');
  329. await open('/movies/gate-alien-3');
  330. w = await widgets();
  331. check('a title in TWO timelines has two widgets (one per timeline)', w.length === 2 && w.map(x => x.name.split(' /')[0]).sort().join('|') === 'Gate Alien Collection|Gate Mixed Timeline' && w.every(x => x.franchise.startsWith('Gate Alien Franchise')), JSON.stringify(w));
  332. // ==== mission 070 (merge with #20/#21): typed headings "Franchise | …" / "Timeline | …" in their colours; title links → /movies|/series ====
  333. const typedH1 = () => ev(`JSON.stringify((() => { const h = document.querySelector('h1.typed'); if (!h) return null; const w = h.querySelector('type-word'), n = h.querySelector('type-name');
  334. return { text: h.textContent.replace(/\\s+/g, ' ').trim(), word: w ? [w.textContent, getComputedStyle(w).color] : null, name: n ? n.textContent : null }; })())`).then(JSON.parse);
  335. const typedHeads = {};
  336. for (const p of ['/franchises', '/franchises/Gate-Alien-Franchise', '/timelines/Gate-Alien-Collection', '/timelines/Gate-Mixed-Timeline/edit']) { await open(p); typedHeads[p] = await typedH1(); }
  337. check('#20 headings on #19 pages: "Franchises" (blueLight), "Franchise | Gate Alien Franchise" (blueLight), "Timeline | Gate Alien Collection" (greenLight), the editor "Timeline | Gate Mixed Timeline"',
  338. typedHeads['/franchises'] && typedHeads['/franchises'].word[0] === 'Franchises' && typedHeads['/franchises'].word[1] === 'rgb(156, 220, 254)'
  339. && typedHeads['/franchises/Gate-Alien-Franchise'].text === 'Franchise | Gate Alien Franchise' && typedHeads['/franchises/Gate-Alien-Franchise'].word[1] === 'rgb(156, 220, 254)'
  340. && typedHeads['/timelines/Gate-Alien-Collection'].text === 'Timeline | Gate Alien Collection' && typedHeads['/timelines/Gate-Alien-Collection'].word[1] === 'rgb(181, 206, 168)'
  341. && typedHeads['/timelines/Gate-Mixed-Timeline/edit'].text === 'Timeline | Gate Mixed Timeline', JSON.stringify(typedHeads));
  342. await open('/timelines/Gate-Alien-Collection');
  343. const nameCount = await ev(`document.querySelector('franchise-page').innerText.split('Gate Alien Collection').length - 1`);
  344. check('timeline page: its name once (the heading), not again over the list (mission 025); count and sort toggle still there', nameCount === 1 && (await ev(`document.querySelectorAll('a.timeline-name').length`)) === 0
  345. && (await ev(`!!document.querySelector('timeline-head timeline-count') && document.querySelectorAll('timeline-head button.sortbtn').length === 2`)), 'name ' + nameCount + '×');
  346. await open('/timelines/Gate-Mixed-Timeline');
  347. const mixedHrefs = await ev(`Array.from(document.querySelectorAll('a.timeline-item')).map(a => a.getAttribute('href')).join(',')`);
  348. check('#21 links on #19 pages: the mixed timeline links its series to /series/<slug> and its movie to /movies/<slug>, none to /shows/',
  349. mixedHrefs.includes('/series/pager-older-series') && mixedHrefs.includes('/movies/gate-test-movie') && mixedHrefs.includes('/series/gate-test-show') && !mixedHrefs.includes('/shows/'), mixedHrefs);
  350. // ==== 390 / 1280: nothing scrolls sideways; screenshots ====
  351. const pages = [['franchises', '/franchises'], ['franchise', '/franchises/Gate-Alien-Franchise'], ['timeline', '/timelines/Gate-Alien-Collection'], ['widget-movie', '/movies/gate-alien-3'],
  352. ['widget-aliens', '/movies/gate-aliens'], ['franchise-edit', '/franchises/edit'], ['timeline-edit-reload', '/timelines/Gate-Mixed-Timeline/edit']];
  353. for (const [wd, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  354. await page.send('Emulation.setDeviceMetricsOverride', { width: wd, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  355. const bad = [];
  356. for (const [name, path] of pages) {
  357. await open(path);
  358. if (name.startsWith('widget')) await ev(`document.querySelector('franchise-widget').scrollIntoView({ block: 'center' })`);
  359. const sw = await ev('document.documentElement.scrollWidth');
  360. if (sw > wd) bad.push(name + ' ' + sw);
  361. if (name.startsWith('widget')) {
  362. const row = JSON.parse(await ev(`JSON.stringify((() => { const r = (s) => document.querySelector(s).getBoundingClientRect(); return { p: r('.fw-prev'), t: r('a.fw-timeline'), n: r('.fw-next') }; })())`));
  363. if (!(row.p.right <= row.t.left + 1 && row.t.right <= row.n.left + 1 && Math.abs((row.p.top + row.p.bottom) / 2 - (row.n.top + row.n.bottom) / 2) < 30)) bad.push(name + ' widget not one row ' + JSON.stringify(row));
  364. }
  365. if (SHOTS) writeFileSync(join(SHOTS, `${name}-${wd}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  366. }
  367. check(`${wd} px: no franchise page scrolls sideways; the widget is ONE row (Prequel | name | Sequel side by side)`, bad.length === 0, bad.join('; '));
  368. }
  369. await page.send('Emulation.clearDeviceMetricsOverride', {});
  370. await logout();
  371. const problems = page.problems();
  372. check('no console errors/warnings', problems.length === 0, page.dumpConsole());
  373. }
  374. try {
  375. await main();
  376. } catch (e) {
  377. 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));
  378. } finally {
  379. await stop();
  380. }
  381. console.log(`\n${passes} passed, ${failures} failed`);
  382. process.exit(failures === 0 ? 0 : 1);

Branches

Latest commits

  • 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
  • 84e1b3e1tracker: reports 025 + 026mre
  • dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre
  • 2667da05tracker#30 (mission 025): /my/ pages from slim cached title cards, episode rows and watch sets (after the jobs /my/series 1.8 s -> 0.06 s, /my/unwatched 4.1 -> 0.18 s); timeline page shows its name once; franchise widget under the poster/title; movies with TV leftovers (First Contact) go through the details repair; tools/count-tmdb-ids.hl; gates 327/0, 52/0, 32/0mre
  • 3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre
  • e63b1d28antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 1cda451dtracker: Hybriel master 190aa11d (#127 both shapes, GC correctness fc838894) — conductor adopts despite /my/series 2.4x after jobs (memory 8.0 → 1.7 GB boot); see reports/071mre
  • c1fa2f2etracker (mission 071): Hybriel master 190aa11d measured on the real copy vs the live binary 8590df63 — NOT adopted (after the first-start jobs /my/series 2.4x slower, /series 1.6x, RSS swings 7.4-12.2 GB; fresh it is flat at 1.7-2.2 GB and /my/unwatched faster), vendor stays 8efba065, candidate kept in .scratch/w071/vendor-190aa11d; tests/kinds.mjs: collection seed off (its TMDB request broke check 1 in 1 of 4 runs); tests/realdata-071.sh + realdata-071-bench.mjs + tools/realdata-071-table.py; README + STATUS (numbers, how to repeat); gates 325/0, 32/0, 50/0mre
  • 8081350atracker docs (mission 070): README (summary, Config HL_GC_BYTES — kept at Hybriel's default, the 256 MiB setting is taken out of docker-compose.yml again: the jobs grew to 12+ GB with it too, see STATUS), Test (three gates), Deploy (first start ~50 min: kinds then seed, restart once after collections done, memory numbers), Vendored Hybriel 8efba065 + #48 audit, Files; STATUS mission 070 entry (merges, migrated counts, lambda audit, gates, RSS old vs new, how to repeat, open points); docs/kinds.md + docs/franchises.md job order; tests/realdata-070-*.sh, tools/count-migrated.hl, tools/ref-params.py, tools/lambda-audit.pymre
  • 1ad19c8ctracker: re-vendor Hybriel master 8efba065 (#126 GC by bytes, #48 lambda parameters copy) (mission 070): bin/hybriel sha256 50361e95…, plugins core crypto data fetch fs http http1 mpackdb proc smtp time web; lambda audit: 13 lambdas change a passed record/list (11 through a local alias), no caller relies on it — unchanged; 319 read-only lambda parameters get & (no copy per call: /my/schedule 2.35 → 0.46 s, /my/unwatched 13.7 → 5.7 s on the real copy); /my/unwatched one merge sort instead of n² inserts; docker-compose HL_GC_BYTES=268435456; deploy.sh runs kinds.mjs + franchises.mjs too (default ports 8700–8710); tests/realdata-070.mjs; gates browser 325/0, kinds 32/0, franchises 50/0mre
  • 46b21389tracker (mission 070, conductor): the sync never destroys migrated data — the one-time summary step MOVES a copied summary to migratedSummary (marker summaries-moved.txt) instead of clearing it; a migrated record's first title/genres/homepage/tagline TMDB replaces → migratedTitle/migratedGenres/migratedHomepage/migratedTagline, a migrated season's/episode's title/summary → migratedTitle/migratedSummary (set once); tests/peek-shows.hl prints them; gate 325/0mre
  • 749019b0Merge t19 (tracker#19 franchises + timelines) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs/faketmdb.mjs, both sides kept; franchise/timeline pages get #20's typed heading (Franchise | …, Timeline | …), their title links via titlePath (/movies|/series|/shows); the collection seed waits for repair, kinds and credits too; franchises.mjs URLs + 2 new checks; gates browser 323/0, kinds 32/0, franchises 50/0mre