gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit1ed5457e1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre1ed5457e/tests/kinds.mjs

29.2 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 || 8761);
  31. const [CH_FROM, CH_TO] = (process.env.TRACKER_GATE_CHROME || '8763-8765').split('-').map(Number);
  32. const IDENT_PORT = Number(process.env.TRACKER_GATE_IDENT_PORT || 8762);
  33. const TMDB_PORT = Number(process.env.TRACKER_GATE_TMDB_PORT || 8766);
  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', '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_BACKFILL: '0', TRACKER_SHORTIDS: '0', TRACKER_KINDS: '0', TRACKER_KINDS_LOG_EVERY: '2', ...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

  • 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
  • fa1f9dfatracker#29 (mission 063): unwatched check muted grey outline + check (accent only on hover), watched stays solid — no code regression, the accent outline read as ticked; gate checks real checks visibly (computed style + screenshot pixel) on /my/unwatched, show, movie, /my/movies; gate 266, tests/realdata-063.mjs, README + STATUSmre
  • d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre
  • f14db671tracker#22-#25 (mission 058): episode air dates, season check = all episodes watched, movie watched check (+ /my/movies count), /genres/<genre> pages (movies + series, newest first, paginated); gate 238, tests/realdata-058.mjs, README + STATUSmre
  • 1704ec45tracker#17 (mission 057): season caret down/up, skeleton rows while a season loads, sessionless showSeasonEpisodes face (no page re-mount), client-only close; gate 214, tests/realdata-057.mjs, README + STATUSmre
  • f2fe3e36mission 056: README + STATUS (merge, fixes, Hybriel 8590df63, real-data check), tests/realdata-056.mjs, tools/check-public-slugs.hlmre
  • f40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermre
  • 2b7fdd6cmission 056: signed-out header one row on phones ("Log in", nowrap), backfill skips adult titles' posters, gate checksmre
  • 2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre
  • c171227emission 054: hide adult/unknown titles from the public lists and the search; in-app adult-flag backfill (TMDB details + poster per title, resumes), gate + real-data proofmre
  • 139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre
  • 93be9476tracker#16: person pages /person/<slug> with the filmography fetched from TMDB on the first visit (step by step), gatemre
  • 47a3cae6STATUS: mission 053 merge commit idsmre
  • dcc5eecaMerge branch 't14-search'mre
  • 03edc783Merge branch 't15-tvmaze'mre
  • 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
  • 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
  • b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
  • 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
  • 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre