gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commite7305014e7305014tracker: report 032 (art + photos)mree7305014/tests/kinds.mjs

29.3 KB

  1. // tests/kinds.mjs — THE GATE of tracker.worldapi.org#20 + #21 (mission 068): typed page headings ("Series | Reacher") and the
  2. // split of TV titles into Series (/series) and Shows (/shows) — a gate of its own beside tests/browser.mjs (same parts: its own
  3. // ident from identkit.mjs, the fake TMDB, a real headless Chrome), on its own storage (.scratch/kinds-store). Proves:
  4. // - KIND FROM THE STORED DATA (kind backfill off): a title with the genre Talk is a SHOW at once, every other TV title a series;
  5. // /series and /shows list exactly those; no TMDB request
  6. // - THE DAILY SYNC stores TMDB's type: a followed series whose type is Reality moves to /shows without a restart
  7. // - THE KIND BACKFILL: one GET /tv/<id> per TV title without a kind and without a show genre (never for a Talk title or one
  8. // without tmdbId), ≥ 260 ms apart; Video / News → show, Scripted / Documentary / Miniseries → series, a 404 stays a series;
  9. // the lists change without a restart; it RESUMES after a stop (no title asked twice) and has nothing to do on the next start
  10. // - HEADINGS (#20) on every page kind: "<Type> | <name>", the type word in its colour (computed), the bar muted, the name white;
  11. // list pages (/movies, /series, /shows) their type word; the homepage rows' headings and the search / filmography labels
  12. // - ADDRESSES: /series/<slug>, /shows/<slug>, /movies/<slug>; the old /shows/<slug> of a series → 301 /series/<slug>, of a movie
  13. // → 301 /movies/<slug>; a show at /shows/<slug> renders (and works: Follow, a season) although that route is a function route
  14. // - SIGNED IN: five home tiles, /my/series and /my/shows each with its own titles, the search's labels, a search import of a
  15. // talk show lands on /shows/<slug>
  16. // - 390 / 1280 px: nothing scrolls sideways, a long show name wraps beside "Show |"; screenshots with TRACKER_GATE_SHOTS
  17. //
  18. // TRACKER_GATE_SHOTS=/tmp/w068/shots TRACKER_GATE_PORT=8761 TRACKER_GATE_IDENT_PORT=8762 TRACKER_GATE_CHROME=8763-8765 \
  19. // TRACKER_GATE_TMDB_PORT=8766 node tests/kinds.mjs
  20. import { spawn, execFileSync } from 'node:child_process';
  21. import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
  22. import { dirname, join, resolve } from 'node:path';
  23. import { fileURLToPath } from 'node:url';
  24. import { launchBrowser } from './cdp.mjs';
  25. import { startIdent } from './identkit.mjs';
  26. import { startFakeTmdb } from './faketmdb.mjs';
  27. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  28. const HERE = dirname(fileURLToPath(import.meta.url));
  29. const APP = resolve(HERE, '..');
  30. const PORT = Number(process.env.TRACKER_GATE_PORT || 8700);
  31. const [CH_FROM, CH_TO] = (process.env.TRACKER_GATE_CHROME || '8702-8709').split('-').map(Number);
  32. const IDENT_PORT = Number(process.env.TRACKER_GATE_IDENT_PORT || 8701);
  33. const TMDB_PORT = Number(process.env.TRACKER_GATE_TMDB_PORT || 8710);
  34. const SHOTS = process.env.TRACKER_GATE_SHOTS || '';
  35. const BASE = `http://127.0.0.1:${PORT}`;
  36. const STORE = join(APP, '.scratch', 'kinds-store');
  37. const SESSIONS = join(APP, '.scratch', 'kinds-sessions');
  38. 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')));
  39. rmSync(STORE, { recursive: true, force: true });
  40. rmSync(SESSIONS, { recursive: true, force: true });
  41. mkdirSync(SESSIONS, { recursive: true });
  42. if (SHOTS) mkdirSync(SHOTS, { recursive: true });
  43. let failures = 0, passes = 0;
  44. function check(label, ok, detail = '') {
  45. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  46. if (ok) passes++; else failures++;
  47. }
  48. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  49. let ident = null, server = null, browser = null, log = '', tmdb = null;
  50. async function stopServer() {
  51. if (server) {
  52. const s = server;
  53. try { s.kill('SIGTERM'); } catch {}
  54. await new Promise(r => { if (s.exitCode !== null || s.signalCode !== null) return r(); s.once('exit', r); setTimeout(r, 3000); });
  55. server = null;
  56. }
  57. }
  58. async function stop() {
  59. if (ident) { try { await ident.stop(); } catch {} ident = null; }
  60. if (browser) { try { await browser.close(); } catch {} browser = null; }
  61. await stopServer();
  62. if (tmdb) { try { await tmdb.stop(); } catch {} tmdb = null; }
  63. }
  64. // what the fake TMDB says about the TV titles (tests/seed-kinds.hl + seed-show.hl's two followed series); 7007 is unknown (404)
  65. const tvOf = (id, name, type, extra = {}) => ({ id, name, type, overview: name, status: 'Returning Series', poster_path: null, seasons: [], external_ids: {}, ...extra });
  66. const gateShows = () => ({
  67. 1001: tvOf(1001, 'Gate Test Show', 'Scripted'), 1002: tvOf(1002, 'Unwatched Gate Show', 'Scripted'),
  68. 7001: tvOf(7001, 'Gate Reacher', 'Scripted'), 7002: tvOf(7002, 'Gate Late Show With A Very Long Name That Has To Wrap On A Phone', 'Talk Show'), 7003: tvOf(7003, 'Gate Island Reality', 'Reality'),
  69. 7004: tvOf(7004, 'Gate Nature Docs', 'Documentary'), 7005: tvOf(7005, 'Gate Evening News', 'News'), 7006: tvOf(7006, 'Gate Mini', 'Miniseries'),
  70. 7008: tvOf(7008, 'Gate Clip Video', 'Video'),
  71. // the search import (#14): a talk show only TMDB has
  72. 7010: tvOf(7010, 'Gate Imported Talk', 'Talk Show', { first_air_date: '2021-04-04', genres: [{ id: 10767, name: 'Talk' }] }),
  73. });
  74. const gateSearch = () => ({ 'gate imported': [{ media_type: 'tv', id: 7010, name: 'Gate Imported Talk', first_air_date: '2021-04-04', poster_path: null, overview: 'talk' }] });
  75. // the palette (shared/tokens.hl) as computed colours
  76. const C = { movie: 'rgb(220, 220, 170)', series: 'rgb(78, 201, 176)', show: 'rgb(197, 134, 192)', genre: 'rgb(86, 155, 212)', person: 'rgb(206, 145, 120)',
  77. white: 'rgb(245, 250, 255)', muted: 'rgb(145, 150, 155)' };
  78. const SERIES_ALL = ['Gate Clip Video', 'Gate Evening News', 'Gate Island Reality', 'Gate Lost Kind', 'Gate Mini', 'Gate Nature Docs', 'Gate No Id', 'Gate Reacher', 'Gate Test Show', 'Pager Older Series', 'Unwatched Gate Show'];
  79. const LATE = 'Gate Late Show With A Very Long Name That Has To Wrap On A Phone';
  80. async function main() {
  81. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  82. const alice = await ident.signIn('[email protected]');
  83. const APPKEY = await ident.registerApp(alice, 'tracker (kinds gate)', [BASE]);
  84. const aliceIdentity = await ident.exchange(APPKEY, await ident.selectorCode(alice, APPKEY, BASE));
  85. check('ident: our own ident runs, the app is registered', /^pk_[0-9a-f]{32}$/.test(APPKEY.key) && !!aliceIdentity);
  86. const MPACKDB = join(STORE, 'mpackdb');
  87. mkdirSync(MPACKDB, { recursive: true });
  88. const seedOut = execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-show.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_IDENTITY: aliceIdentity } }).toString()
  89. + execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-kinds.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_USER: 'gateu0000000000u1' } }).toString();
  90. check('seed: the fixture shows (tests/seed-show.hl) + 9 TV titles of both kinds (tests/seed-kinds.hl), alice follows 3 of them', seedOut.includes('gate-test-show') && seedOut.includes('seed-kinds: 9 TV titles, 3 genres, 3 follows'), seedOut);
  91. tmdb = await startFakeTmdb({ port: TMDB_PORT, shows: gateShows(), movies: { 1003: { id: 1003, title: 'Gate Test Movie', poster_path: null, external_ids: {} } }, search: gateSearch() });
  92. const startServer = async (extra = {}) => {
  93. log = '';
  94. server = spawn(join(APP, 'bin/hybriel'), ['project.hl'], { cwd: APP, env: { ...process.env, TRACKER_PORT: String(PORT), TRACKER_SESSIONS: SESSIONS + '/', TRACKER_STORAGE: MPACKDB,
  95. TRACKER_WATCH: '0', TRACKER_URL: BASE, IDENT_URL: ident.base, IDENT_EXCHANGE_URL: ident.base, TRACKER_KEY: APPKEY.key, TRACKER_SECRET: APPKEY.secret,
  96. TMDB_READ_TOKEN: 'gate-token', TMDB_BASE_URL: tmdb.base + '/3', TMDB_IMAGE_URL: tmdb.base + '/t/p', TVMAZE_BASE_URL: tmdb.base + '/tvmaze',
  97. TRACKER_SYNC: '0', TRACKER_ART: '0', TRACKER_PHOTOS: '0', TRACKER_BACKFILL: '0', TRACKER_SHORTIDS: '0', TRACKER_KINDS: '0', TRACKER_KINDS_LOG_EVERY: '2', TRACKER_REPAIR: '0', TRACKER_CREDITS: '0', TRACKER_COLLECTIONS: '0', TRACKER_FRANCHISE_SEED: '0', ...extra },
  98. stdio: ['ignore', 'pipe', 'pipe'] });
  99. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  100. for (let i = 0; ; i++) {
  101. try { if ((await fetch(BASE + '/')).ok) break; } catch {}
  102. if (i > 80) throw new Error('tracker did not come up\n' + log);
  103. await sleep(250);
  104. }
  105. };
  106. const waitLog = async (re, ms, label) => {
  107. for (let t = 0; t < ms && !re.test(log); t += 100) await sleep(100);
  108. if (!re.test(log)) throw new Error('log never showed ' + label + '\n' + log.slice(-3000));
  109. };
  110. // a list page's tile titles, every page of it (node fetch: the server's own render)
  111. const listed = async (path) => {
  112. const out = [];
  113. for (let p = 1; p < 10; p++) {
  114. const h = await (await fetch(BASE + (p === 1 ? path : path + '/page/' + p))).text();
  115. const t = [...h.matchAll(/<tile-title>([^<]*)<\/tile-title>/g)].map(m => m[1]);
  116. out.push(...t);
  117. if (!h.includes('page ' + p + ' of ' + p) && t.length === 24) continue;
  118. break;
  119. }
  120. return out.sort();
  121. };
  122. // ==== 1. THE STORED DATA ALONE (kind backfill off): the Talk genre makes a show, everything else is a series ====
  123. await startServer();
  124. check('kinds off: the log says so, and no TMDB request was made', /kind backfill: off \(TRACKER_KINDS=0\)/.test(log) && tmdb.requests.length === 0, log);
  125. let ser = await listed('/series'), sho = await listed('/shows');
  126. check('stored data: /series lists every TV title without a show genre (11, Gate Island Reality too — its type is not known yet); /shows only the Talk one',
  127. JSON.stringify(ser) === JSON.stringify(SERIES_ALL) && JSON.stringify(sho) === JSON.stringify([LATE]), JSON.stringify({ ser, sho }));
  128. const head = async (path, opts = {}) => { const r = await fetch(BASE + path, { redirect: 'manual', ...opts }); return { status: r.status, location: r.headers.get('location'), html: r.status === 200 ? await r.text() : '' }; };
  129. let r1 = await head('/shows/gate-reacher'), r2 = await head('/shows/gate-test-movie'), r3 = await head('/shows/gate-late-show'), r4 = await head('/series/gate-reacher'), r5 = await head('/movies/gate-test-movie'), r6 = await head('/shows/no-such-title');
  130. check('old links: /shows/<series> → 301 /series/<slug>, /shows/<movie> → 301 /movies/<slug>',
  131. r1.status === 301 && r1.location === '/series/gate-reacher' && r2.status === 301 && r2.location === '/movies/gate-test-movie', JSON.stringify([r1, r2].map(r => [r.status, r.location])));
  132. const h1Of = (h) => { const m = h.match(/<h1[^>]*>([\s\S]*?)<\/h1>/); return m ? m[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() : null; };
  133. check('addresses: /shows/<show>, /series/<series>, /movies/<movie> answer 200 with "<Type> | <name>"; an unknown /shows/<slug> its "Not found"',
  134. r3.status === 200 && h1Of(r3.html) === 'Show | ' + LATE && r4.status === 200 && h1Of(r4.html) === 'Series | Gate Reacher' && r5.status === 200 && h1Of(r5.html) === 'Movie | Gate Test Movie'
  135. && r6.status === 200 && h1Of(r6.html) === 'Not found', JSON.stringify([r3, r4, r5, r6].map(r => [r.status, h1Of(r.html)])));
  136. const r7 = await head('/shows/page/1'), r8 = await head('/my/nothing');
  137. check('/shows/page/1 is still the list; /my/<anything else> says "Not found"', r7.status === 200 && h1Of(r7.html) === 'Shows' && h1Of(r8.html) === 'Not found', JSON.stringify([h1Of(r7.html), h1Of(r8.html)]));
  138. await stopServer();
  139. // ==== 2. THE DAILY SYNC stores TMDB's type: alice's followed Gate Island Reality (type Reality) moves to /shows, no restart ====
  140. await startServer({ TRACKER_SYNC: '1', TRACKER_SYNC_HOUR: String(new Date().getUTCHours()) });
  141. await waitLog(/tmdb sync done: /, 30000, 'the daily sync');
  142. ser = await listed('/series'); sho = await listed('/shows');
  143. check('daily sync: the followed Gate Island Reality (TMDB type Reality) is a show now — on /shows, gone from /series — without a restart',
  144. sho.includes('Gate Island Reality') && !ser.includes('Gate Island Reality') && ser.includes('Gate Reacher') && sho.length === 2, JSON.stringify({ ser, sho }));
  145. await stopServer();
  146. // ==== 3. THE KIND BACKFILL, stopped in the middle (TMDB answers slowly), then resumed ====
  147. const before = tmdb.requests.length;
  148. tmdb.delayMs = 700;
  149. await startServer({ TRACKER_KINDS: '1' });
  150. await waitLog(/kind backfill: start, \d+ TV titles without a kind/, 10000, 'the kind backfill start');
  151. const firstStart = Number(log.match(/kind backfill: start, (\d+) TV titles/)[1]);
  152. await waitLog(/kind backfill: [2-4]\/\d+ titles/, 20000, 'two steps');
  153. await stopServer();
  154. const firstLog = log;
  155. tmdb.delayMs = 0;
  156. await startServer({ TRACKER_KINDS: '1' });
  157. await waitLog(/kind backfill done: /, 20000, 'the kind backfill done');
  158. const secondStart = Number((log.match(/kind backfill: start, (\d+) TV titles/) || [])[1]);
  159. const doneLine = (log.match(/kind backfill done: .*/) || [''])[0];
  160. // the sync of step 2 stored the kind of the 5 followed series (1001, 1002, 7001, 7002, 7003): 7 TV titles are left (7004–7008,
  161. // Gate No Id and seed-show's Pager Older Series, both without tmdbId)
  162. check('kind backfill: starts with the 7 TV titles still without a kind (the sync did the followed ones), RESUMES after a stop with fewer',
  163. firstStart === 7 && secondStart > 0 && secondStart < firstStart, JSON.stringify({ firstStart, secondStart }));
  164. const asked = tmdb.requests.slice(before).map(r => r.path).filter(p => /^\/3\/tv\/\d+$/.test(p));
  165. const dup = asked.filter((p, i) => asked.indexOf(p) !== i);
  166. check('kind backfill: one GET /tv/<id> each for 7004, 7005, 7006, 7007, 7008 — none twice across the stop, never the Talk show (7002) or the title without tmdbId',
  167. dup.length === 0 && JSON.stringify([...asked].sort()) === JSON.stringify(['/3/tv/7004', '/3/tv/7005', '/3/tv/7006', '/3/tv/7007', '/3/tv/7008']), JSON.stringify(asked));
  168. const times = tmdb.requests.slice(before).filter(r => /^\/3\/tv\/\d+$/.test(r.path)).map(r => r.at);
  169. const gaps = times.slice(1).map((t, i) => t - times[i]).filter(g => g < 5000);
  170. check('kind backfill: ≥ 260 ms between two TMDB requests', gaps.every(g => g >= 255), JSON.stringify(gaps));
  171. const kindLog = (firstLog + log).split('\n').filter(l => l.startsWith('kind')).join('\n');
  172. check('kind backfill: logs start, every 2 titles (TRACKER_KINDS_LOG_EVERY) and a done line; the 404 (7007) is logged once',
  173. /kind backfill done: \d+\/\d+ titles, series=\d+ shows=\d+ fromGenre=\d+ moved=\d+ requests=\d+ errors=\d+ retries=0 seconds=/.test(doneLine)
  174. && (kindLog.match(/Gate Lost Kind: HTTP 404/g) || []).length === 1, kindLog);
  175. ser = await listed('/series'); sho = await listed('/shows');
  176. check('kind backfill → the lists at once: /shows = Gate Clip Video (Video), Gate Evening News (News), Gate Island Reality, the Talk show; /series the other 8 (Documentary, Miniseries, Scripted, the 404, no tmdbId)',
  177. JSON.stringify(sho) === JSON.stringify(['Gate Clip Video', 'Gate Evening News', 'Gate Island Reality', LATE])
  178. && JSON.stringify(ser) === JSON.stringify(['Gate Lost Kind', 'Gate Mini', 'Gate Nature Docs', 'Gate No Id', 'Gate Reacher', 'Gate Test Show', 'Pager Older Series', 'Unwatched Gate Show']), JSON.stringify({ ser, sho }));
  179. await stopServer();
  180. const reqBefore = tmdb.requests.length;
  181. await startServer({ TRACKER_KINDS: '1' });
  182. await waitLog(/kind backfill: (nothing to do|start, \d+)/, 10000, 'the third start');
  183. await sleep(600);
  184. check('kind backfill: the next start has nothing to do and asks TMDB nothing', /kind backfill: nothing to do/.test(log) && tmdb.requests.length === reqBefore, log.split('\n').filter(l => l.startsWith('kind')).join('\n'));
  185. ser = await listed('/series'); sho = await listed('/shows');
  186. check('after a restart the stored kinds give the same two lists', sho.length === 4 && ser.length === 8 && sho.includes('Gate Evening News') && ser.includes('Gate Lost Kind'), JSON.stringify({ ser, sho }));
  187. // ==== 4. IN THE BROWSER: headings, colours, lists, the home, the search, signed in ====
  188. browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  189. const page = await browser.newPage();
  190. page.captureNetwork();
  191. const ev = (e) => page.evaluate(e);
  192. const hydrated = () => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'hydrated' });
  193. const go = async (path) => { await page.goto(BASE + path); await hydrated(); };
  194. // the h1 of a typed heading: its parts' text and computed colours
  195. const typed = () => ev(`JSON.stringify((() => { const h = document.querySelector('h1.typed'); if (!h) return null; const c = (s) => { const e = h.querySelector(s); return e ? [e.textContent.trim(), getComputedStyle(e).color] : null; };
  196. return { word: c('type-word'), sep: c('type-sep'), name: c('type-name'), text: h.innerText.replace(/\\s+/g, ' ').trim() }; })())`).then(JSON.parse);
  197. const isTyped = (t, word, color, name) => !!t && t.word && t.word[0] === word && t.word[1] === color && (name == null ? !t.name : (t.sep && t.sep[0] === '|' && t.sep[1] === C.muted && t.name && t.name[0] === name && t.name[1] === C.white));
  198. const loginAs = async (who) => {
  199. const code = await ident.selectorCode(who, APPKEY, BASE);
  200. await page.goto(BASE + '/login/callback?ident_code=' + code);
  201. await page.waitForSelector('#logout');
  202. await hydrated();
  203. };
  204. const shot = async (name) => { if (SHOTS) writeFileSync(join(SHOTS, name + '.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })).data, 'base64')); };
  205. const HEADINGS = [
  206. ['/series/gate-reacher', 'Series', C.series, 'Gate Reacher'],
  207. ['/shows/gate-late-show', 'Show', C.show, LATE],
  208. ['/movies/gate-test-movie', 'Movie', C.movie, 'Gate Test Movie'],
  209. ['/genres/Drama', 'Genre', C.genre, 'Drama'],
  210. ['/person/Complete-Person', 'Person', C.person, 'Complete Person'],
  211. ['/movies', 'Movies', C.movie, null], ['/series', 'Series', C.series, null], ['/shows', 'Shows', C.show, null]];
  212. const seen = {};
  213. for (const [path, word, color, name] of HEADINGS) { await go(path); seen[path] = await typed(); }
  214. check('headings (#20): "Series | Gate Reacher", "Show | …", "Movie | …", "Genre | Drama", "Person | Complete Person" — the type word in its colour, the bar muted, the name white; /movies /series /shows their type word',
  215. HEADINGS.every(([path, word, color, name]) => isTyped(seen[path], word, color, name)), JSON.stringify(seen));
  216. check('headings read as one line of text: "Series | Gate Reacher"', seen['/series/gate-reacher'].text === 'Series | Gate Reacher', seen['/series/gate-reacher'].text);
  217. // the internally rendered show page (a function route) hydrates and works: its one season (open) closes and opens again —
  218. // the episodes come from a face (showSeasonEpisodes)
  219. await go('/shows/gate-late-show');
  220. const open0 = await ev(`document.querySelectorAll('episode-row').length`);
  221. await page.click('button.season-toggle');
  222. await page.waitFor(`document.querySelectorAll('episode-row').length === 0`, { timeout: 10000, label: 'season closed' }).catch(() => {});
  223. await page.click('button.season-toggle');
  224. await page.waitFor(`document.querySelectorAll('episode-row').length === 1`, { timeout: 10000, label: 'season opened' }).catch(() => {});
  225. check('/shows/<show> (rendered by the function route) is a live page: its season closes and opens again with the episode "First Night"',
  226. open0 === 1 && (await ev(`Array.from(document.querySelectorAll('episode-row episode-title')).map(e => e.textContent).join(',')`)) === 'First Night');
  227. // the homepage rows: headings in their colours, the Shows row has the show (signed out)
  228. await go('/');
  229. const homeHeads = await ev(`JSON.stringify(Array.from(document.querySelectorAll('home-section h2 a')).map(a => [a.textContent, a.getAttribute('href'), getComputedStyle(a).color]))`);
  230. check('home: three rows — Movies (yellow) → /movies, Series (accent) → /series, Shows (purple) → /shows',
  231. homeHeads === JSON.stringify([['Movies', '/movies', C.movie], ['Series', '/series', C.series], ['Shows', '/shows', C.show]]), homeHeads);
  232. const homeShows = await ev(`JSON.stringify(Array.from(document.querySelectorAll('#home-shows a.tile')).map(a => a.querySelector('tile-title').textContent + '|' + a.getAttribute('href')))`);
  233. const homeSeries = await ev(`JSON.stringify(Array.from(document.querySelectorAll('#home-series a.tile')).map(a => a.querySelector('tile-title').textContent))`);
  234. check('home: the Shows row has the show with a released episode → /shows/<slug>; the Series row no show',
  235. homeShows === JSON.stringify([LATE + '|/shows/gate-late-show']) && !homeSeries.includes('Gate Late'), homeShows + ' ' + homeSeries);
  236. // a tile on /series is a client-side navigation; one on /shows a full page load (its route is a function route)
  237. await go('/series');
  238. await ev(`window.__w068 = 'same document'`);
  239. await page.click('a.tile[href="/series/gate-reacher"]');
  240. await page.waitFor(`location.pathname === '/series/gate-reacher' && !!document.querySelector('h1.typed type-name')`, { timeout: 10000, label: 'series tile' });
  241. check('/series: a tile opens /series/<slug> client-side ("Series | Gate Reacher")', (await ev('window.__w068')) === 'same document' && (await typed()).text === 'Series | Gate Reacher');
  242. await go('/shows');
  243. await page.click('a.tile[href="/shows/gate-evening-news"]');
  244. await page.waitFor(`location.pathname === '/shows/gate-evening-news' && !!document.querySelector('h1.typed type-name')`, { timeout: 10000, label: 'show tile' });
  245. await hydrated();
  246. check('/shows: a tile opens /shows/<slug> ("Show | Gate Evening News")', (await typed()).text === 'Show | Gate Evening News');
  247. // ---- signed in (alice) ----
  248. await loginAs(alice);
  249. await go('/');
  250. const tiles = await ev(`Array.from(document.querySelectorAll('home-nav a.hometile')).map(a => a.textContent.trim() + ' ' + a.getAttribute('href')).join(',')`);
  251. check('home signed in: five tiles — Unwatched, Schedule, Series → /my/series, Shows → /my/shows, Movies', tiles === 'Unwatched /my/unwatched,Schedule /my/schedule,Series /my/series,Shows /my/shows,Movies /my/movies', tiles);
  252. const myRows = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('my-show-row a.myshow-title')).map(a => a.textContent + '|' + a.getAttribute('href')))`).then(JSON.parse);
  253. await go('/my/series');
  254. const mySeries = await myRows(), mySeriesHead = await typed();
  255. await go('/my/shows');
  256. const myShows = await myRows(), myShowsHead = await typed();
  257. check('/my/series: alice\'s followed SERIES (no show), each → /series/<slug>; heading "My Series"',
  258. JSON.stringify(mySeries) === JSON.stringify(['Unwatched Gate Show (2025)|/series/unwatched-gate-show', 'Gate Reacher (2022)|/series/gate-reacher', 'Gate Test Show (2024)|/series/gate-test-show'])
  259. && mySeriesHead.text === 'My Series' && mySeriesHead.word[1] === C.series, JSON.stringify({ mySeries, mySeriesHead }));
  260. check('/my/shows: alice\'s followed SHOWS only, each → /shows/<slug>; heading "My Shows" (purple)',
  261. JSON.stringify(myShows) === JSON.stringify(['Gate Island Reality (2010)|/shows/gate-island-reality', LATE + ' (2015)|/shows/gate-late-show'])
  262. && myShowsHead.text === 'My Shows' && myShowsHead.word[1] === C.show, JSON.stringify({ myShows, myShowsHead }));
  263. // Follow on the internally rendered show page: a session face works there (unfollow, then follow again)
  264. await go('/shows/gate-evening-news');
  265. const f0 = await ev(`document.querySelector('#follow').textContent`);
  266. await page.click('#follow');
  267. await page.waitFor(`document.querySelector('#follow').textContent === 'Following'`, { timeout: 10000, label: 'follow' }).catch(() => {});
  268. await go('/my/shows');
  269. const afterFollow = await myRows();
  270. await go('/shows/gate-evening-news');
  271. await page.click('#follow');
  272. await page.waitFor(`document.querySelector('#follow').textContent === 'Follow'`, { timeout: 10000, label: 'unfollow' }).catch(() => {});
  273. await go('/my/shows');
  274. const afterUnfollow = await myRows();
  275. check('/shows/<show>: Follow works on the function-rendered page (→ top of /my/shows), and Following → Follow again',
  276. f0 === 'Follow' && afterFollow[0] === 'Gate Evening News (1990)|/shows/gate-evening-news' && afterUnfollow.length === 2, JSON.stringify({ f0, afterFollow, afterUnfollow }));
  277. // the search: our titles with their kind word in its colour, each → its own page; a talk show imported from TMDB lands on /shows
  278. await go('/search/gate');
  279. const sr = JSON.parse(await ev(`JSON.stringify(Array.from(document.querySelectorAll('#titles search-row')).map(r => [r.querySelector('.search-title').textContent, r.querySelector('.search-title').getAttribute('href'), r.querySelector('search-type').textContent, getComputedStyle(r.querySelector('search-type')).color]))`));
  280. const want = (t, href, word, color) => sr.some(r => r[0].startsWith(t) && r[1] === href && r[2] === word && r[3] === color);
  281. check('search: labels Series / Show / Movie in their colours, links to /series/, /shows/, /movies/',
  282. want('Gate Reacher', '/series/gate-reacher', 'Series', C.series) && want('Gate Evening News', '/shows/gate-evening-news', 'Show', C.show)
  283. && want('Gate Test Movie', '/movies/gate-test-movie', 'Movie', C.movie) && want('Gate Late Show', '/shows/gate-late-show', 'Show', C.show), JSON.stringify(sr));
  284. await go('/search/gate%20imported');
  285. await page.click('#fetchweb');
  286. await page.waitFor(`!!document.querySelector('web-row button.add')`, { timeout: 10000, label: 'web results' });
  287. const webLabel = await ev(`document.querySelector('web-row search-type').textContent`);
  288. await page.click('web-row button.add');
  289. await page.waitFor(`location.pathname === '/shows/Gate-Imported-Talk' && !!document.querySelector('h1.typed type-name')`, { timeout: 15000, label: 'imported talk show' }).catch(() => {});
  290. await hydrated();
  291. const imp = await typed();
  292. check('search import: a TMDB "TV" result of type Talk Show → stored as a show, opens /shows/Gate-Imported-Talk ("Show | Gate Imported Talk"), and /shows lists it',
  293. webLabel === 'TV' && (await ev('location.pathname')) === '/shows/Gate-Imported-Talk' && imp && imp.text === 'Show | Gate Imported Talk' && (await listed('/shows')).includes('Gate Imported Talk'), JSON.stringify({ webLabel, imp, path: await ev('location.pathname') }));
  294. // the person page: its filmography labels (Series / Movie) in their colours, each → its own page
  295. await go('/person/Complete-Person');
  296. const film = await ev(`JSON.stringify(Array.from(document.querySelectorAll('a.film-row')).map(a => [a.getAttribute('href'), a.querySelector('film-type').textContent, getComputedStyle(a.querySelector('film-type')).color]))`);
  297. check('person: the filmography says Series / Movie in their colours and links to /series/ and /movies/',
  298. film === JSON.stringify([['/series/gate-test-show', 'Series', C.series], ['/movies/gate-test-movie', 'Movie', C.movie]]), film);
  299. // ---- 390 / 1280: nothing sideways; the long show name wraps beside "Show |"; screenshots ----
  300. const PAGES = [['series-page', '/series/gate-reacher'], ['show-page', '/shows/gate-late-show'], ['movie-page', '/movies/gate-test-movie'], ['genre', '/genres/Drama'],
  301. ['person', '/person/Complete-Person'], ['series-list', '/series'], ['shows-list', '/shows'], ['movies-list', '/movies'], ['home', '/'], ['my-series', '/my/series'], ['my-shows', '/my/shows'], ['search', '/search/gate']];
  302. const layout = {};
  303. for (const [w, mobile] of [[390, true], [1280, false]]) {
  304. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: 844, deviceScaleFactor: mobile ? 2 : 1, mobile });
  305. for (const [name, path] of PAGES) {
  306. await go(path);
  307. await page.waitFor(`Array.from(document.images).every(i => i.complete)`, { timeout: 5000, label: 'images' }).catch(() => {});
  308. layout[name + '-' + w] = await ev(`document.documentElement.scrollWidth <= ${w}`);
  309. if (name === 'show-page') layout['wrap-' + w] = await ev(`JSON.stringify((() => { const h = document.querySelector('h1.typed'), w = h.querySelector('type-word').getBoundingClientRect(), ne = h.querySelector('type-name'), n = ne.getBoundingClientRect();
  310. return { lines: Math.round(n.height / parseFloat(getComputedStyle(ne).lineHeight)), besideWord: n.left > w.right, right: n.right <= h.getBoundingClientRect().right + 0.5 }; })())`).then(JSON.parse);
  311. await shot(name + '-' + w);
  312. }
  313. }
  314. await page.send('Emulation.clearDeviceMetricsOverride', {});
  315. check('390 / 1280: no page scrolls sideways', Object.entries(layout).filter(([k]) => !k.startsWith('wrap')).every(([, v]) => v === true), JSON.stringify(layout));
  316. check('390: the long show name wraps (several lines) in its own column right of "Show |"; 1280: fewer lines',
  317. layout['wrap-390'].lines >= 2 && layout['wrap-390'].besideWord && layout['wrap-390'].right && layout['wrap-1280'].lines < layout['wrap-390'].lines, JSON.stringify([layout['wrap-390'], layout['wrap-1280']]));
  318. const problems = page.problems ? page.problems() : [];
  319. check('browser: no console errors / failed requests', problems.length === 0, JSON.stringify(problems).slice(0, 2000));
  320. }
  321. try { await main(); } catch (e) {
  322. check('gate ran without throwing', false, (e && e.stack) || String(e));
  323. console.log('server log (tail):\n' + log.slice(-3000));
  324. }
  325. await stop();
  326. try { execFileSync('pgrep', ['-f', `remote-debugging-port=${CH_FROM}`]); console.log('WARNING: a Chrome on ' + CH_FROM + ' is still running'); } catch {}
  327. console.log(`\n${passes} passed, ${failures} failed`);
  328. process.exit(failures ? 1 : 0);

Branches

Latest commits

  • e7305014tracker: report 032 (art + photos)mre
  • 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