gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit1704ec451704ec45tracker#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 + STATUSmre1704ec45/tests/browser.mjs

142.2 KB

  1. // tests/browser.mjs — THE GATE of tracker.worldapi.org#1: this app's own server on its own storage, its OWN ident
  2. // (a copy of ident's code without .env, codes to a mail sink: tests/identkit.mjs, same as calendar's gate), a real
  3. // headless Chrome. Proves: signed out (header shows "Log in with ident") -> sign in -> the header selector shows
  4. // signed in, "Log out" appears, the page itself stays empty -> sign out -> signed out again (a reload confirms it).
  5. //
  6. // node tests/browser.mjs (TRACKER_GATE_PORT server, default 8700; TRACKER_GATE_IDENT_PORT ident, default 8701;
  7. // TRACKER_GATE_CHROME "8702-8709" = Chrome debug ports; TRACKER_GATE_TMDB_PORT the fake
  8. // TMDB, default 8710)
  9. import { spawn, execFileSync } from 'node:child_process';
  10. import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
  11. import { dirname, join, resolve } from 'node:path';
  12. import { fileURLToPath } from 'node:url';
  13. import { launchBrowser } from './cdp.mjs';
  14. import { startIdent } from './identkit.mjs';
  15. import { startFakeTmdb, PNG } from './faketmdb.mjs';
  16. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  17. const HERE = dirname(fileURLToPath(import.meta.url));
  18. const APP = resolve(HERE, '..');
  19. const PORT = Number(process.env.TRACKER_GATE_PORT || 8700);
  20. const [CH_FROM, CH_TO] = (process.env.TRACKER_GATE_CHROME || '8702-8709').split('-').map(Number);
  21. const IDENT_PORT = Number(process.env.TRACKER_GATE_IDENT_PORT || 8701);
  22. const TMDB_PORT = Number(process.env.TRACKER_GATE_TMDB_PORT || 8710);
  23. const BASE = `http://127.0.0.1:${PORT}`;
  24. const STORE = join(APP, '.scratch', 'gate-store');
  25. 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')));
  26. const SCRATCH = join(APP, '.scratch');
  27. const SESSIONS = join(SCRATCH, 'gate-sessions');
  28. rmSync(SESSIONS, { recursive: true, force: true });
  29. rmSync(STORE, { recursive: true, force: true });
  30. mkdirSync(SESSIONS, { recursive: true });
  31. let failures = 0, passes = 0;
  32. function check(label, ok, detail = '') {
  33. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  34. if (ok) passes++; else failures++;
  35. }
  36. let ident = null, server = null, browser = null, log = '', tmdb = null;
  37. async function stopServer() {
  38. if (server) {
  39. const s = server;
  40. try { s.kill('SIGTERM'); } catch {}
  41. await new Promise(r => { if (s.exitCode !== null || s.signalCode !== null) return r(); s.once('exit', r); setTimeout(r, 3000); });
  42. server = null;
  43. }
  44. }
  45. async function stop() {
  46. if (ident) { try { await ident.stop(); } catch {} ident = null; }
  47. if (browser) { try { await browser.close(); } catch {} browser = null; }
  48. await stopServer();
  49. if (tmdb) { try { await tmdb.stop(); } catch {} tmdb = null; }
  50. }
  51. // tracker.worldapi.org#9: what the fake TMDB (tests/faketmdb.mjs) knows about the two fixture shows (tmdbId 1001,
  52. // 1002, tests/seed-show.hl). Show 1: season 0 (must NOT be synced — the show has no season 0), S1 with the two
  53. // fixture episodes (no tmdbId locally → matched by number; E2 renamed "Second Episode") + a new E3 (released),
  54. // S2 unchanged, a NEW S3 (E1 released, E2 upcoming 2098-06-01). Show 2 (no seasons yet): a NEW S1 E1 (upcoming
  55. // 2097-01-01). Both have a poster.
  56. // tracker.worldapi.org#12: TMDB's external_ids — show 1 a DIFFERENT imdb id than the stored tt0000001 (must not overwrite)
  57. // + a tvdb id (fills); show 2 an imdb id (fills) + a different tvdb id than the stored 4002 (must not overwrite). The movie
  58. // 1003 (only via /movie/): an imdb id. The fake TVmaze knows imdb tt0000001 → 501 and thetvdb 4002 → 502 (tt0000002 is unknown).
  59. const gateTmdbMovies = () => ({ 1003: { id: 1003, title: 'Gate Test Movie', poster_path: '/gate3.png', external_ids: { imdb_id: 'tt0000003', wikidata_id: 'Q1' } },
  60. // tracker.worldapi.org#14: a movie only TMDB has — imported from the search
  61. 1005: { id: 1005, title: 'Gate Import Movie', poster_path: '/gate5.png', release_date: '2001-05-05', overview: 'an imported movie', genres: [{ id: 18, name: 'Drama' }], external_ids: { imdb_id: 'tt0000005' } },
  62. // mission 054 (tests/seed-adult.hl): what TMDB says about the titles whose adult flag we don't know yet; 1120 is unknown (404)
  63. 1011: { id: 1011, title: 'Adult Gate Movie', adult: true, poster_path: '/flag11.png' },
  64. 1012: { id: 1012, title: 'Flagless Gate Movie', adult: false, poster_path: '/flag12.png', external_ids: { imdb_id: 'tt0001012' } },
  65. 1013: { id: 1013, title: 'Flagless Adult Movie', adult: true, poster_path: '/flag13.png', external_ids: {} },
  66. ...Object.fromEntries(Array.from({ length: 10 }, (_, k) => [1101 + k, { id: 1101 + k, title: 'Backfill Movie ' + String(k + 1).padStart(2, '0'), adult: false, poster_path: `/bf${k + 1}.png`, external_ids: {} }])) });
  67. // tracker.worldapi.org#15: TVmaze's episode lists — at first the SAME as TMDB's (same numbers, same days, + a special without
  68. // a number that must be ignored), so the first syncs take nothing from TVmaze; the "TVmaze merge" block below changes them.
  69. const gateTvmazeEpisodes = () => ({
  70. 501: [{ id: 8001, season: 1, number: 1, name: 'Pilot', airdate: '2020-01-01' }, { id: 8002, season: 1, number: 2, name: 'Second Episode', airdate: '2020-06-15' },
  71. { id: 8003, season: 1, number: 3, name: 'Third', airdate: '2020-09-01' }, { id: 8004, season: 2, number: 1, name: 'Season Two Opener', airdate: '2099-01-01' },
  72. { id: 8005, season: 3, number: 1, name: 'Third Season Start', airdate: '2021-05-05' }, { id: 8006, season: 3, number: 2, name: 'Coming Soon', airdate: '2098-06-01' },
  73. { id: 8099, season: 3, number: null, name: 'A TVmaze Special', airdate: '2021-06-01', type: 'significant_special' }],
  74. 502: [{ id: 8101, season: 1, number: 1, name: 'Brand New', airdate: '2097-01-01' }],
  75. });
  76. const gateTvmaze = () => ({ imdb: { tt0000001: 501 }, thetvdb: { 4002: 502, 4004: 504 }, episodes: gateTvmazeEpisodes() });
  77. // tracker.worldapi.org#14: TMDB's search/multi for "gate": the two titles we HAVE (tv 1001, movie 1003 — left out of the web
  78. // list), a person (dropped), and three new ones: tv 1004 + movie 1005 (importable) and tv 1006 (in the search, but its details
  79. // answer 404 — the failed import)
  80. const gateSearch = () => ({ gate: [
  81. { media_type: 'tv', id: 1001, name: 'Gate Test Show', first_air_date: '2024-01-01', poster_path: '/gate1.png', overview: 'have it' },
  82. { media_type: 'tv', id: 1004, name: 'Gate Import Show', first_air_date: '2023-02-02', poster_path: '/gate4.png', overview: 'new series' },
  83. { media_type: 'person', id: 77, name: 'Gate Person' },
  84. { media_type: 'movie', id: 1003, title: 'Gate Test Movie', release_date: '1996-11-18', poster_path: '/gate3.png' },
  85. { media_type: 'movie', id: 1005, title: 'Gate Import Movie', release_date: '2001-05-05', poster_path: null, overview: 'new movie' },
  86. { media_type: 'tv', id: 1006, name: 'Gate Broken Show', first_air_date: '', poster_path: '/gate6.png' },
  87. // mission 054: an ADULT title in TMDB's answer (despite include_adult=false) — never in our web list
  88. { media_type: 'movie', id: 1030, title: 'Gate Adult Web', adult: true, release_date: '2002-02-02', poster_path: '/gate30.png' }] });
  89. // tracker.worldapi.org#16: TMDB's people. Test Actor (3002): facts + a photo; credits = two titles we HAVE (tv 1001, movie 1003),
  90. // new ones — a movie as cast AND crew ("Hero / Director"), a series, an ADULT movie (stored, never shown), an undated one (last),
  91. // a crew-only one, and 80 more movies (87 titles → 3 batches of 40 after the TMDB step). Second Actor (3003): two titles that
  92. // exist by then. Complete Person (3001) must never be asked.
  93. const gatePersons = () => {
  94. const bulk = [];
  95. for (let i = 1; i <= 80; i++) bulk.push({ media_type: 'movie', id: 2100 + i, title: 'Bulk Film ' + String(i).padStart(2, '0'), release_date: '2005-01-01', character: 'Extra', adult: false, poster_path: null, genre_ids: [] });
  96. return {
  97. 3002: { id: 3002, name: 'Test Actor', biography: 'Test Actor bio from TMDB.', birthday: '1970-05-06', deathday: null, place_of_birth: 'Fixture Town', profile_path: '/face2.png', imdb_id: 'nm0000002', adult: false,
  98. combined_credits: {
  99. cast: [
  100. { media_type: 'tv', id: 1001, name: 'Gate Test Show', first_air_date: '2024-01-01', character: 'Someone', poster_path: '/gate1.png' },
  101. { media_type: 'movie', id: 1003, title: 'Gate Test Movie', release_date: '1996-11-18', character: 'Cameo', poster_path: '/gate3.png' },
  102. { media_type: 'movie', id: 2001, title: 'Person Film One', release_date: '2010-05-05', character: 'Hero', adult: false, poster_path: '/pf1.png', genre_ids: [18], overview: 'a film of the person', original_language: 'en' },
  103. { media_type: 'tv', id: 2002, name: 'Person Series', first_air_date: '2015-03-03', character: 'Lead', adult: false, poster_path: null, genre_ids: [18, 10765] },
  104. { media_type: 'movie', id: 2003, title: 'Hidden Adult Film', release_date: '2012-01-01', character: 'X', adult: true, poster_path: null },
  105. { media_type: 'movie', id: 2004, title: 'Announced Film', release_date: '', character: 'Villain', adult: false, poster_path: null },
  106. ...bulk],
  107. crew: [
  108. { media_type: 'movie', id: 2001, title: 'Person Film One', release_date: '2010-05-05', job: 'Director', adult: false, poster_path: '/pf1.png' },
  109. { media_type: 'movie', id: 2005, title: 'Directed Only', release_date: '2018-08-08', job: 'Writer', adult: false, poster_path: null }] } },
  110. 3003: { id: 3003, name: 'Second Actor', biography: '', birthday: null, deathday: null, place_of_birth: null, profile_path: null,
  111. combined_credits: { cast: [{ media_type: 'movie', id: 2001, title: 'Person Film One', release_date: '2010-05-05', character: 'Sidekick', poster_path: '/pf1.png' },
  112. { media_type: 'tv', id: 1001, name: 'Gate Test Show', first_air_date: '2024-01-01', character: 'Guest', poster_path: '/gate1.png' },
  113. // TMDB's credit says not adult, but OUR record of it is (adult = true, stored by Test Actor's fill): still hidden
  114. { media_type: 'movie', id: 2003, title: 'Hidden Adult Film', release_date: '2012-01-01', character: 'Y', adult: false }], crew: [] } },
  115. 3001: { id: 3001, name: 'Complete Person', combined_credits: { cast: [{ media_type: 'movie', id: 2999, title: 'Must Not Appear', release_date: '2000-01-01' }], crew: [] } },
  116. };
  117. };
  118. const gateTmdbShows = () => ({
  119. 1001: { id: 1001, name: 'Gate Test Show', overview: 'TMDB overview', status: 'Returning Series', poster_path: '/gate1.png', external_ids: { imdb_id: 'tt9999999', tvdb_id: 4001, tvrage_id: null }, seasons: [
  120. { id: 90, season_number: 0, name: 'Specials', air_date: '2019-01-01', episodes: [{ id: 5000, episode_number: 1, name: 'A Special', air_date: '2019-01-01' }] },
  121. { id: 91, season_number: 1, name: 'Season 1', air_date: '2020-01-01', episodes: [
  122. { id: 5001, episode_number: 1, name: 'Pilot', air_date: '2020-01-01' },
  123. { id: 5002, episode_number: 2, name: 'Second Episode', air_date: '2020-06-15' },
  124. { id: 5003, episode_number: 3, name: 'Third', air_date: '2020-09-01' }] },
  125. { id: 92, season_number: 2, name: 'Season 2', air_date: '2099-01-01', episodes: [{ id: 5004, episode_number: 1, name: 'Season Two Opener', air_date: '2099-01-01' }] },
  126. { id: 93, season_number: 3, name: 'Season 3', air_date: '2021-05-05', episodes: [
  127. { id: 5005, episode_number: 1, name: 'Third Season Start', air_date: '2021-05-05' },
  128. { id: 5006, episode_number: 2, name: 'Coming Soon', air_date: '2098-06-01' }] }] },
  129. // tracker.worldapi.org#14: NOT in our database — imported from the search (no one follows it, the sync never asks for it)
  130. 1004: { id: 1004, name: 'Gate Import Show', overview: 'an imported series', status: 'Returning Series', poster_path: '/gate4.png', first_air_date: '2023-02-02',
  131. original_language: 'en', genres: [{ id: 18, name: 'Drama' }, { id: 10765, name: 'Sci-Fi & Fantasy' }], external_ids: { imdb_id: 'tt0000004', tvdb_id: 4004 }, seasons: [
  132. { id: 95, season_number: 1, name: 'Season 1', air_date: '2023-02-02', episodes: [
  133. { id: 8001, episode_number: 1, name: 'Imported One', air_date: '2023-02-02' }, { id: 8002, episode_number: 2, name: 'Imported Two', air_date: '2023-02-09' }] }] },
  134. // mission 054: a series whose adult flag we don't know yet (tests/seed-adult.hl) — TMDB says not adult
  135. 1014: { id: 1014, name: 'Flagless Gate Series', adult: false, overview: 'flagless', status: 'Ended', poster_path: '/flag14.png', external_ids: { imdb_id: 'tt0001014', tvdb_id: 4014 }, seasons: [
  136. { id: 96, season_number: 1, name: 'Season 1', air_date: '2001-03-03', episodes: [{ id: 9601, episode_number: 1, name: 'Flagless Pilot', air_date: '2001-03-03' }] }] },
  137. 1002: { id: 1002, name: 'Unwatched Gate Show', overview: 'second', status: 'Returning Series', poster_path: '/gate2.png', external_ids: { imdb_id: 'tt0000002', tvdb_id: 4999 }, seasons: [
  138. { id: 94, season_number: 1, name: 'Season 1', air_date: '2097-01-01', episodes: [{ id: 7001, episode_number: 1, name: 'Brand New', air_date: '2097-01-01' }] }] },
  139. });
  140. async function main() {
  141. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  142. const alice = await ident.signIn('[email protected]');
  143. const APPKEY = await ident.registerApp(alice, 'tracker (gate)', [BASE]);
  144. check('ident: our own ident runs (a copy without .env), tracker is registered', /^pk_[0-9a-f]{32}$/.test(APPKEY.key) && !existsSync(join(STORE, 'ident', 'ident-code', '.env')));
  145. // tracker.worldapi.org#5: learn alice's per-app identity (a throwaway code, consumed here) so the
  146. // fixture below can seed a follow for the SAME user the real login flow will later resolve to
  147. // (users.hl `ensureUser` matches an existing users.db row by `identity`, reusing its id) — this is
  148. // the only way to give /unwatched a known followed show before the browser ever signs in.
  149. const preCode = await ident.selectorCode(alice, APPKEY, BASE);
  150. const aliceIdentity = await ident.exchange(APPKEY, preCode);
  151. // tracker.worldapi.org#4/#5: a tiny fixture show (tests/seed-show.hl) — written BEFORE the server
  152. // starts, self-assigned ids only (never update(), see that file's header)
  153. const MPACKDB = join(STORE, 'mpackdb');
  154. mkdirSync(MPACKDB, { recursive: true });
  155. const seedOut = execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-show.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_IDENTITY: aliceIdentity } }).toString();
  156. check('seed: the fixture show was written, alice follows it', seedOut.includes('gate-test-show') && seedOut.includes('follows it'), seedOut);
  157. // tracker.worldapi.org#8: the fixture show's poster FILE exists (a 1×1 png), the second show has none →
  158. // /my/shows must give the first its own /posters/sh1.png and the second the one placeholder URL /posters/none
  159. mkdirSync(join(MPACKDB, 'posters'), { recursive: true });
  160. writeFileSync(join(MPACKDB, 'posters', 'sh1.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64'));
  161. // tracker.worldapi.org#9: the fake TMDB. EVERY run of the app/tool in this gate points at it with the gate's own
  162. // token — the runtime loads the project's real .env (TMDB_READ_TOKEN) by itself, the environment given here wins
  163. tmdb = await startFakeTmdb({ port: TMDB_PORT, shows: gateTmdbShows(), movies: gateTmdbMovies(), tvmaze: gateTvmaze(), search: gateSearch(), persons: gatePersons() });
  164. const TMDB_ENV = { TMDB_READ_TOKEN: 'gate-token', TMDB_BASE_URL: tmdb.base + '/3', TMDB_IMAGE_URL: tmdb.base + '/t/p', TVMAZE_BASE_URL: tmdb.base + '/tvmaze' };
  165. const startServer = async (extra = {}) => {
  166. log = '';
  167. server = spawn(join(APP, 'bin/hybriel'), ['project.hl'], { cwd: APP, env: { ...process.env, TRACKER_PORT: String(PORT), TRACKER_SESSIONS: SESSIONS + '/', TRACKER_STORAGE: MPACKDB, TRACKER_WATCH: '0',
  168. TRACKER_URL: BASE, IDENT_URL: ident.base, IDENT_EXCHANGE_URL: ident.base, TRACKER_KEY: APPKEY.key, TRACKER_SECRET: APPKEY.secret, ...TMDB_ENV, TRACKER_SYNC: '0', TRACKER_BACKFILL: '0', ...extra }, stdio: ['ignore', 'pipe', 'pipe'] });
  169. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  170. for (let i = 0; ; i++) {
  171. try { if ((await fetch(BASE + '/')).ok) break; } catch {}
  172. if (i > 80) throw new Error('tracker did not come up\n' + log);
  173. await new Promise(r => setTimeout(r, 250));
  174. }
  175. };
  176. await startServer();
  177. check('tracker is up', true);
  178. browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  179. const page = await browser.newPage();
  180. globalThis.__gatePage = page;
  181. page.captureNetwork();
  182. await page.captureWs(); // tracker#17: which faces a season click sends (wsSent)
  183. // tracker#17: a FAKE DELAY for one face — while `window.__delayFace = { name, ms }` is set, an outgoing websocket frame
  184. // naming that face is sent `ms` later (the real socket, only held back), so the gate sees the state BEFORE the answer
  185. await page.addInitScript(`(() => {
  186. const Native = window.WebSocket;
  187. if (!Native) return;
  188. window.WebSocket = class extends Native {
  189. send(d) {
  190. const f = window.__delayFace;
  191. if (f && String(d).includes(f.name)) { setTimeout(() => super.send(d), f.ms); return; }
  192. return super.send(d);
  193. }
  194. };
  195. })()`);
  196. const ev = (e) => page.evaluate(e);
  197. const hydrated = () => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'hydrated' });
  198. // mission 047: every check icon AND season caret on the page is a real SVG element (not XHTML) and actually drawn
  199. const svgState = () => ev(`JSON.stringify((() => {
  200. const svgs = Array.from(document.querySelectorAll('button.checkicon svg, season-caret svg'));
  201. const shapes = Array.from(document.querySelectorAll('button.checkicon svg circle, button.checkicon svg path, season-caret svg path'));
  202. return { svgs: svgs.length, badNs: svgs.concat(shapes).filter(e => e.namespaceURI !== 'http://www.w3.org/2000/svg').length,
  203. undrawn: Array.from(document.querySelectorAll('button.checkicon svg circle')).filter(c => !(c.getBoundingClientRect().width > 0)).length };
  204. })())`);
  205. const svgOk = (st) => { const v = JSON.parse(st); return v.svgs > 0 && v.badNs === 0 && v.undrawn === 0; };
  206. // the caret's direction per season row, top to bottom (tracker#17): 'down' (closed, no transform) or 'up' (open,
  207. // rotate(180deg) = matrix(-1, 0, 0, -1, 0, 0)); waits out the caret's .15 s transition first
  208. const carets = async () => { await new Promise(r => setTimeout(r, 250)); return ev(`JSON.stringify(Array.from(document.querySelectorAll('season-row')).map(r => {
  209. const t = getComputedStyle(r.querySelector('season-caret')).transform;
  210. if (t === 'none') return 'down';
  211. const m = t.startsWith('matrix(') ? t.slice(7, -1).split(',').map(Number) : [];
  212. return m.length === 6 && Math.abs(m[0] + 1) < 1e-6 && Math.abs(m[3] + 1) < 1e-6 && Math.abs(m[1]) < 1e-6 ? 'up' : t;
  213. }))`); };
  214. // tracker.worldapi.org#12: the header's link icons as [label, href, target, rel], in order
  215. const extLinks = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('show-links a.extlink')).map(a => [a.textContent, a.getAttribute('href'), a.getAttribute('target'), a.getAttribute('rel')]))`);
  216. const linksAre = (got, want) => JSON.stringify(JSON.parse(got)) === JSON.stringify(want.map(([l, h]) => [l, h, '_blank', 'noopener']));
  217. const TMDB_TV = 'https://www.themoviedb.org/tv/', TMDB_MOVIE = 'https://www.themoviedb.org/movie/', IMDB = 'https://www.imdb.com/title/', TVDB = 'https://thetvdb.com/dereferrer/series/', TVMAZE = 'https://www.tvmaze.com/shows/';
  218. // a click on a link icon: a NEW tab opens at that address (no access to this one: noopener), this tab stays where it is.
  219. // The new tab is closed again at once (it would load the real site).
  220. const clickOpensNewTab = async (sel) => {
  221. const seen = new Map();
  222. const off = browser.conn.onEvent((m) => {
  223. if ((m.method === 'Target.targetCreated' || m.method === 'Target.targetInfoChanged') && m.params.targetInfo.type === 'page') seen.set(m.params.targetInfo.targetId, m.params.targetInfo);
  224. });
  225. await browser.conn.send('Target.setDiscoverTargets', { discover: true });
  226. const known = new Set(seen.keys());
  227. const before = await ev('location.href');
  228. await page.click(sel);
  229. const fresh = () => [...seen.values()].find(i => !known.has(i.targetId) && i.url && i.url !== 'about:blank');
  230. for (let i = 0; i < 100 && !fresh(); i++) await new Promise(r => setTimeout(r, 50));
  231. const tab = fresh();
  232. off();
  233. await browser.conn.send('Target.setDiscoverTargets', { discover: false });
  234. if (tab) await browser.conn.send('Target.closeTarget', { targetId: tab.targetId }).catch(() => {});
  235. return { url: tab ? tab.url : null, canAccessOpener: tab ? tab.canAccessOpener : null, stayed: (await ev('location.href')) === before };
  236. };
  237. // tracker.worldapi.org#13: the poster tiles of a row/grid as "title|year|href|poster"
  238. const tilesOf = (sel) => ev(`JSON.stringify(Array.from(document.querySelectorAll(${JSON.stringify(sel)} + ' a.tile')).map(a => [a.querySelector('tile-title').textContent, a.querySelector('tile-year').textContent, a.getAttribute('href'), a.querySelector('img.tile-poster').getAttribute('src')].join('|')))`).then(JSON.parse);
  239. const titlesOf = async (sel) => (await tilesOf(sel)).map(t => t.split('|')[0]);
  240. const movieRange = (from, to) => { const o = []; for (let i = from; i >= to; i--) o.push('Pager Movie ' + String(i).padStart(2, '0')); return o; };
  241. const pagerState = () => ev(`JSON.stringify({ items: Array.from(document.querySelectorAll('pagination li')).map(l => l.textContent.trim()), current: (document.querySelector('pagination li.current') || { textContent: '' }).textContent.trim() })`).then(JSON.parse);
  242. // hybriel 64527baa (in the vendored ff51cf46): a face taking `session` also answers with a sync of every member derived
  243. // from session — a handler that ALSO appended the returned row would show it twice. After every face-driven change:
  244. // each row (by its button value) once. Waits 400 ms first so a late sync has landed.
  245. const rowsOnce = async (sel) => { await new Promise(r => setTimeout(r, 400)); return JSON.parse(await ev(`JSON.stringify((() => { const v = Array.from(document.querySelectorAll(${JSON.stringify(sel)})).map(b => b.value); return { n: v.length, unique: new Set(v).size === v.length, values: v }; })())`)); };
  246. const modalOpen = () => ev(`!!(document.querySelector('dialog#signin') && document.querySelector('dialog#signin').open)`);
  247. const loginAs = async (who) => {
  248. const code = await ident.selectorCode(who, APPKEY, BASE);
  249. await page.goto(BASE + '/login/callback?ident_code=' + code);
  250. await page.waitForSelector('#logout');
  251. await hydrated();
  252. };
  253. // ---- signed out: header shows the selector + "Log in with ident", the page itself is empty ----
  254. await page.goto(BASE + '/');
  255. await hydrated();
  256. check('signed out: the identity selector is in the header', await ev(`!!document.querySelector('#selector')`));
  257. check('signed out: "Log in with ident" shows, no "Log out"', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));
  258. // mission 056: signed out on a phone the header is ONE row ("choose ident" + "Log in" never wrap); " with ident" from 40rem on
  259. const headerRow = () => ev(`JSON.stringify((() => { const h = document.querySelector('application-header').getBoundingClientRect();
  260. const parts = [document.querySelector('application-header .brand'), document.querySelector('#searchlink'), document.querySelector('#selector'), document.querySelector('#loginbutton')].map(e => e.getBoundingClientRect());
  261. return { h: Math.round(h.height), oneRow: parts.every(r => r.top >= h.top && r.bottom <= h.bottom && r.top < parts[0].bottom && r.bottom > parts[0].top), right: Math.round(Math.max(...parts.map(r => r.right))),
  262. sw: document.documentElement.scrollWidth, text: document.querySelector('#loginbutton').innerText.trim(), choose: document.querySelector('#selector').shadowRoot.querySelector('[part~="button"]').getBoundingClientRect().height }; })())`).then(JSON.parse);
  263. const rows56 = {};
  264. for (const [w, mobile] of [[320, true], [390, true], [1280, false]]) {
  265. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: 800, deviceScaleFactor: mobile ? 2 : 1, mobile });
  266. await page.goto(BASE + '/');
  267. await hydrated();
  268. await page.waitFor(`!!document.querySelector('#selector').shadowRoot && !!document.querySelector('#selector').shadowRoot.querySelector('[part~="button"]')`, { timeout: 5000, label: 'selector drawn' });
  269. rows56[w] = await headerRow();
  270. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `header-signed-out-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  271. }
  272. await page.send('Emulation.clearDeviceMetricsOverride', {});
  273. await page.goto(BASE + '/');
  274. await hydrated();
  275. check('signed out: the header is ONE row at 320/390/1280 px (≤ 56 px high, nothing below the brand, no sideways scroll); a phone says "Log in", a wide screen "Log in with ident"',
  276. [320, 390, 1280].every(w => rows56[w].oneRow && rows56[w].h <= 56 && rows56[w].sw <= w && rows56[w].right <= w && rows56[w].choose <= 40) && rows56[320].text === 'Log in' && rows56[390].text === 'Log in' && rows56[1280].text === 'Log in with ident', JSON.stringify(rows56));
  277. // ---- tracker.worldapi.org#13: the homepage signed OUT — no icon tiles, the text, "Movies" (→ /movies) with the 10
  278. // latest RELEASED movies (not the 2099 one), "Shows" (→ /shows) with the series by newest released episode ----
  279. check('home signed out: no icon tiles, no centered sign-in; the text about the site (with the sign-in hint)', (await ev(`document.querySelectorAll('a.hometile').length`)) === 0
  280. && /keeps track of the series and movies you follow/.test(await ev(`document.querySelector('home-intro').textContent`)) && /Log in with ident/.test(await ev(`document.querySelector('home-intro').textContent`)));
  281. check('home: the headings "Movies" and "Shows" link to /movies and /shows', (await ev(`[document.querySelector('#home-movies h2 a'), document.querySelector('#home-shows h2 a')].map(a => a.textContent + ' ' + a.getAttribute('href')).join(',')`)) === 'Movies /movies,Shows /shows');
  282. let tl = await tilesOf('#home-movies');
  283. check('home: the 10 latest RELEASED movies, newest first (Pager Movie 30…21; the 2099 one is not out yet), each poster + title + year → its page',
  284. JSON.stringify(tl.map(t => t.split('|')[0])) === JSON.stringify(movieRange(30, 21)) && tl[0] === 'Pager Movie 30|2001|/shows/pager-movie-30|/posters/none', JSON.stringify(tl));
  285. tl = await tilesOf('#home-shows');
  286. check('home: the series by newest RELEASED episode (Pager Older Series 2021-01-01, Gate Test Show 2020-06-15; one with no released episode is left out)',
  287. JSON.stringify(tl) === JSON.stringify(['Pager Older Series|2019|/shows/pager-older-series|/posters/none', 'Gate Test Show|2024|/shows/gate-test-show|/posters/sh1.png']), JSON.stringify(tl));
  288. check('home: every tile poster loaded (the placeholder or the real file)', await ev(`new Promise(r => setTimeout(r, 300)).then(() => Array.from(document.querySelectorAll('img.tile-poster')).filter(i => i.getBoundingClientRect().right <= innerWidth).every(i => i.complete && i.naturalWidth > 0))`));
  289. // ---- tracker.worldapi.org#4: /shows/:slug signed OUT — the header/genres/cast show, no checks ----
  290. await page.goto(BASE + '/shows/gate-test-show');
  291. await hydrated();
  292. check('signed out: the show header renders (title, genre, cast)', (await ev(`document.querySelector('show-header h1').textContent`)) === 'Gate Test Show'
  293. && (await ev(`document.querySelector('.genre-pill').textContent`)) === 'Drama'
  294. && (await ev(`document.querySelector('.artist-link').textContent`)) === 'Test Actor');
  295. // ---- tracker.worldapi.org#12: link icons only for the ids the show has (before any sync: show 1 has tmdb + imdb) ----
  296. let lk = await extLinks();
  297. check('link icons: show 1 (tmdbId + imdbId only) has exactly TMDB (tv/1001) and IMDb (tt0000001), each target=_blank rel=noopener',
  298. linksAre(lk, [['TMDB', TMDB_TV + '1001'], ['IMDb', IMDB + 'tt0000001/']]), lk);
  299. check('link icons: monochrome badges (one colour for all, no image), small (≤ 1.6rem high)',
  300. (await ev(`(() => { const a = Array.from(document.querySelectorAll('a.extlink')); const c = a.map(e => getComputedStyle(e)); return a.length > 0 && new Set(c.map(x => x.backgroundColor + x.color)).size === 1 && a.every(e => !e.querySelector('img') && e.getBoundingClientRect().height <= 25.6) })()`)));
  301. const nt = await clickOpensNewTab('a.extlink[data-site="imdb"]');
  302. check('link icons: a click opens the show on IMDb in a NEW tab without access to this one (noopener); this page stays', nt.url === IMDB + 'tt0000001/' && nt.canAccessOpener === false && nt.stayed, JSON.stringify(nt));
  303. await page.goto(BASE + '/shows/unwatched-gate-show');
  304. await hydrated();
  305. lk = await extLinks();
  306. check('link icons: show 2 (tmdbId + tvdbId only) has exactly TMDB and TheTVDB (dereferrer/series/4002)', linksAre(lk, [['TMDB', TMDB_TV + '1002'], ['TVDB', TVDB + '4002']]), lk);
  307. await page.goto(BASE + '/shows/gate-test-movie');
  308. await hydrated();
  309. lk = await extLinks();
  310. check('link icons: a movie (tmdbId only) has exactly TMDB as /movie/1003', linksAre(lk, [['TMDB', TMDB_MOVIE + '1003']]), lk);
  311. await page.goto(BASE + '/shows/gate-test-show');
  312. await hydrated();
  313. check('signed out: the latest season (2) is expanded, season 1 collapsed', (await ev(`document.querySelectorAll('episode-row').length`)) === 1);
  314. // mission 047: signed out the checks and "Follow" are VISIBLE; a click opens the sign-in modal and writes nothing
  315. check('signed out (mission 047): the season + episode checks are visible (drawn SVG) and so is "Follow"',
  316. (await ev(`document.querySelectorAll('button.checkicon').length`)) === 3 && svgOk(await svgState())
  317. && (await ev(`document.querySelector('#follow').textContent`)) === 'Follow' && (await ev(`document.querySelector('#follow').getBoundingClientRect().width > 0`)), await svgState());
  318. check('signed out: the sign-in modal is closed at first', !(await modalOpen()));
  319. await page.click('button.checkicon[value="ep0000000000ep0e3"]');
  320. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after a check click' }).catch(() => {});
  321. check('signed out: a check click opens the modal "You need to sign in to follow shows and mark episodes" with a sign-in link (ident login) and Close',
  322. (await modalOpen()) && (await ev(`document.querySelector('dialog#signin p').textContent`)) === 'You need to sign in to follow shows and mark episodes.'
  323. && (await ev(`document.querySelector('#signin-link').getAttribute('href')`)).startsWith(ident.base + '/login?key=')
  324. && (await ev(`document.querySelector('#signin-link').getBoundingClientRect().width > 0`))
  325. && (await ev(`Array.from(document.querySelectorAll('dialog#signin footer button')).map(b => b.textContent).join()`)) === 'Close',
  326. await ev(`document.querySelector('dialog#signin') ? document.querySelector('dialog#signin').outerHTML.slice(0, 600) : 'no dialog'`));
  327. if (process.env.TRACKER_GATE_SHOTS) { mkdirSync(process.env.TRACKER_GATE_SHOTS, { recursive: true }); writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'signed-out-modal-1280.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64')); }
  328. await page.click('dialog#signin footer button');
  329. await page.waitFor(`!document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal closed' }).catch(() => {});
  330. check('signed out: Close closes the modal; the check stayed unwatched (nothing written)', !(await modalOpen())
  331. && (await ev(`document.querySelector('button.checkicon[value="ep0000000000ep0e3"]').getAttribute('class')`)) === 'checkicon regular');
  332. await page.click('#follow');
  333. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after Follow' }).catch(() => {});
  334. check('signed out: "Follow" opens the same modal and stays "Follow"', (await modalOpen()) && (await ev(`document.querySelector('#follow').textContent`)) === 'Follow');
  335. await page.click('dialog#signin footer button');
  336. await page.click('button.checkicon[value="se000000000se0s1"]');
  337. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after a season check' }).catch(() => {});
  338. check('signed out: a SEASON check opens the modal too', await modalOpen());
  339. await page.click('dialog#signin footer button');
  340. await page.goto(BASE + '/shows/gate-test-show');
  341. await hydrated();
  342. check('signed out: after a reload every check is still unwatched (the clicks wrote nothing)',
  343. (await ev(`Array.from(document.querySelectorAll('button.checkicon')).every(b => b.getAttribute('class') === 'checkicon regular')`)));
  344. // ---- tracker.worldapi.org#5: /my/unwatched signed OUT — no rows, no checks ----
  345. await page.goto(BASE + '/my/unwatched');
  346. await hydrated();
  347. check('signed out: /my/unwatched shows no rows and asks to sign in', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 0
  348. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');
  349. // ---- tracker.worldapi.org#6: /my/schedule signed OUT — no rows, no checks ----
  350. await page.goto(BASE + '/my/schedule');
  351. await hydrated();
  352. check('signed out: /my/schedule shows no rows and asks to sign in', (await ev(`document.querySelectorAll('schedule-row').length`)) === 0
  353. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');
  354. // ---- tracker.worldapi.org#7: /my/shows signed OUT — no rows, the same sign-in message ----
  355. await page.goto(BASE + '/my/shows');
  356. await hydrated();
  357. check('signed out: /my/shows shows no rows and asks to sign in', (await ev(`document.querySelectorAll('my-show-row').length`)) === 0
  358. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');
  359. // ==== tracker.worldapi.org#14: THE SEARCH, signed OUT — the header's magnifier, our database as you type, "Fetch from web"
  360. // (the fake TMDB's search/multi), "Add" → the sign-in modal ====
  361. const searchFor = () => ev(`document.querySelector('search-page') ? document.querySelector('search-page').getAttribute('data-for') : null`);
  362. const searchTitles = () => 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]))`);
  363. const searchPeople = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('#people .search-person')).map(a => [a.textContent, a.getAttribute('href')]))`);
  364. const webList = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('web-row')).map(r => [r.querySelector('web-title').textContent, r.querySelector('button.add').value, r.querySelector('search-type').textContent]))`);
  365. const typeSearch = async (text) => {
  366. await page.type('#searchinput', text, { clear: true });
  367. await page.waitFor(`document.querySelector('search-page').getAttribute('data-for') === ${JSON.stringify(text)}`, { timeout: 10000, label: 'results for ' + text });
  368. };
  369. const GATE_TITLES = [['Gate Test Show (2024)', '/shows/gate-test-show', 'Series'], ['Gate Test Movie (1996)', '/shows/gate-test-movie', 'Movie'], ['Unwatched Gate Show (2025)', '/shows/unwatched-gate-show', 'Series']];
  370. check('search: the header has the magnifier (a drawn SVG) linking to /search',
  371. (await ev(`document.querySelector('#searchlink').getAttribute('href')`)) === '/search'
  372. && (await ev(`(() => { const s = document.querySelector('#searchlink svg'); return s.namespaceURI === 'http://www.w3.org/2000/svg' && s.querySelector('circle').getBoundingClientRect().width > 0 })()`)));
  373. await ev(`window.__w052 = 'same document'`);
  374. await page.click('#searchlink');
  375. await page.waitFor(`location.pathname === '/search' && !!document.querySelector('#searchinput')`, { timeout: 10000, label: 'search page' });
  376. check('search: the magnifier opens /search (client-side), an empty search field and "Search our shows, movies and people."',
  377. (await ev('window.__w052')) === 'same document' && (await ev(`document.querySelector('#searchinput').value`)) === ''
  378. && (await ev(`document.querySelector('#searchnote').textContent`)) === 'Search our shows, movies and people.' && !(await ev(`!!document.querySelector('#fetchweb')`)));
  379. await typeSearch('g');
  380. check('search: one letter → "Type at least 2 letters.", no "Fetch from web"', (await ev(`document.querySelector('#searchnote').textContent`)) === 'Type at least 2 letters.' && !(await ev(`!!document.querySelector('#fetchweb')`)));
  381. await page.type('#searchinput', 'ate');
  382. await page.waitFor(`document.querySelector('search-page').getAttribute('data-for') === 'gate'`, { timeout: 10000, label: 'results for gate' });
  383. let st14 = await searchTitles();
  384. check('search: typing "gate" lists our titles at once — name start first, the year, Series/Movie, linking to the show; the address follows (/search/gate)',
  385. st14 === JSON.stringify(GATE_TITLES) && (await ev('location.pathname')) === '/search/gate' && (await ev('window.__w052')) === 'same document', st14);
  386. await typeSearch('test act');
  387. check('search: people too — "test act" finds the person "Test Actor" (/person/Test-Actor) and no title', (await searchPeople()) === JSON.stringify([['Test Actor', '/person/Test-Actor']]) && (await searchTitles()) === '[]', await searchPeople());
  388. await typeSearch('GATE tést');
  389. check('search: case and accents do not matter ("GATE tést" = gate test); the address carries the text encoded',
  390. (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)) && (await ev('location.pathname')) === '/search/GATE%20t%C3%A9st', (await searchTitles()) + ' ' + (await ev('location.pathname')));
  391. await typeSearch('zzqq');
  392. check('search: nothing found → "Nothing in our database yet." and "Fetch from web"', (await ev(`document.querySelector('#searchnote').textContent`)) === 'Nothing in our database yet.' && (await ev(`!!document.querySelector('#fetchweb')`)));
  393. await page.goto(BASE + '/search/GATE%20t%C3%A9st');
  394. await hydrated();
  395. check('search: the address alone (reload / shared link) renders the same results on the server, the text in the field',
  396. (await ev(`document.querySelector('#searchinput').value`)) === 'GATE tést' && (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)) && (await searchFor()) === 'GATE tést');
  397. const bad14 = await fetch(BASE + '/search/x%zz');
  398. const bad14b = await fetch(BASE + '/search/%E0%80');
  399. check('search: a malformed address is no error — /search/x%zz 200 with the text taken literally, /search/%E0%80 (broken UTF-8) 200',
  400. bad14.status === 200 && (await bad14.text()).includes('value="x%zz"') && bad14b.status === 200 && (await bad14b.text()).includes('<search-page'), bad14.status + ' ' + bad14b.status);
  401. await typeSearch('gate/te');
  402. check('search: a "/" in the text goes into the address as a space (an encoded / would split the path) — and that address reloads to the same titles',
  403. (await ev('location.pathname')) === '/search/gate%20te' && (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)), await ev('location.pathname'));
  404. await page.goto(BASE + '/search/gate%20te');
  405. await hydrated();
  406. check('search: … reloaded', (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)) && (await ev(`document.querySelector('#searchinput').value`)) === 'gate te');
  407. await page.goto(BASE + '/search/gate');
  408. await hydrated();
  409. const searchesBefore = tmdb.searchRequests.length;
  410. await page.click('#fetchweb');
  411. await page.waitFor(`document.querySelectorAll('web-row').length > 0 || !!document.querySelector('#webnote')`, { timeout: 10000, label: 'web results' });
  412. let wl = await webList();
  413. const GATE_WEB = [['Gate Import Show (2023)', 'tv:1004', 'Series'], ['Gate Import Movie (2001)', 'movie:1005', 'Movie'], ['Gate Broken Show', 'tv:1006', 'Series']];
  414. check('search: "Fetch from web" (signed out too) lists TMDB\'s titles WE DON\'T HAVE — not tv 1001 / movie 1003 (ours), no person; one search/multi request with the token',
  415. wl === JSON.stringify(GATE_WEB) && tmdb.searchRequests.length === searchesBefore + 1 && tmdb.searchRequests[searchesBefore].path === '/3/search/multi?query=gate&include_adult=false&page=1'
  416. && tmdb.searchRequests[searchesBefore].authorization === 'Bearer gate-token', wl + ' ' + JSON.stringify(tmdb.searchRequests));
  417. check('search: the web rows\' small posters come from TMDB\'s image host (w92); one without a poster gets /posters/none',
  418. (await ev(`JSON.stringify(Array.from(document.querySelectorAll('web-row img')).map(i => i.getAttribute('src')))`)) === JSON.stringify([tmdb.base + '/t/p/w92/gate4.png', '/posters/none', tmdb.base + '/t/p/w92/gate6.png']));
  419. const requestsBefore = tmdb.requests.length;
  420. await page.click('web-row button.add[value="tv:1004"]');
  421. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after Add' }).catch(() => {});
  422. check('search: signed out, "Add" opens the sign-in modal ("You need to sign in to add titles.", sign-in link) and sends nothing',
  423. (await modalOpen()) && (await ev(`document.querySelector('dialog#signin p').textContent`)) === 'You need to sign in to add titles.'
  424. && (await ev(`document.querySelector('#signin-link').getAttribute('href')`)).startsWith(ident.base + '/login?key=') && tmdb.requests.length === requestsBefore);
  425. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'search-signed-out-modal-1280.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  426. await page.click('dialog#signin footer button');
  427. await page.waitFor(`!document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal closed' }).catch(() => {});
  428. check('search: Close closes it; the web list is still there', !(await modalOpen()) && (await webList()) === JSON.stringify(GATE_WEB));
  429. // ---- tracker.worldapi.org#3: no bottom border on the header, no border on filled buttons ----
  430. check('the header has no bottom border', (await ev(`getComputedStyle(document.querySelector('application-header')).borderBottomWidth`)) === '0px');
  431. check('"Log in with ident" has no border', (await ev(`getComputedStyle(document.querySelector('#loginbutton')).borderTopWidth`)) === '0px');
  432. check('the ident selector\'s "choose ident" button has no border', (await ev(`getComputedStyle(document.querySelector('#selector').shadowRoot.querySelector('[part~="button"]')).borderTopWidth`)) === '0px');
  433. // ---- tracker.worldapi.org#8: the old addresses answer a permanent redirect to the /my/ ones ----
  434. for (const [from, to] of [['/unwatched', '/my/unwatched'], ['/schedule', '/my/schedule']]) {
  435. const m = await fetch(BASE + from, { redirect: 'manual' });
  436. check(`${from} answers 301 → ${to}`, m.status === 301 && m.headers.get('location') === to, m.status + ' ' + m.headers.get('location'));
  437. }
  438. await page.goto(BASE + '/unwatched');
  439. await hydrated();
  440. check('the browser following /unwatched lands on /my/unwatched', (await ev(`location.pathname`)) === '/my/unwatched' && !!(await ev(`document.querySelector('unwatched-page')`)));
  441. // ---- tracker.worldapi.org#8: the show page says "1 episode" for one, "2 episodes" for two ----
  442. await page.goto(BASE + '/shows/gate-test-show');
  443. await hydrated();
  444. check('show page: season counts "1 episode" (singular) and "2 episodes"', JSON.stringify(await ev(`Array.from(document.querySelectorAll('season-count')).map(e => e.textContent)`)) === JSON.stringify(['1 episode', '2 episodes']),
  445. JSON.stringify(await ev(`Array.from(document.querySelectorAll('season-count')).map(e => e.textContent)`)));
  446. let r = await fetch(BASE + '/login/callback', { redirect: 'manual' });
  447. check('login: /login/callback without a code → /login/failed', r.status === 302 && r.headers.get('location') === '/login/failed');
  448. // ---- sign in: the header selector flips to signed in, "Log out" appears, page stays empty ----
  449. await loginAs(alice);
  450. check('signed in: the selector is still in the header, now signed in', (await ev(`!!document.querySelector('#selector')`)) && (await ev(`document.querySelector('#selector').classList.contains('in')`)));
  451. check('signed in: "Log out" shows, no "Log in with ident"', (await ev(`!!document.querySelector('#logout')`)) && !(await ev(`!!document.querySelector('#loginbutton')`)));
  452. check('home signed in: four icon tiles → /my/unwatched, /my/schedule, /my/shows, /my/movies, each with a drawn svg icon; the text + both rows below them, no sign-in hint',
  453. (await ev(`Array.from(document.querySelectorAll('home-nav a.hometile')).map(a => a.textContent.trim() + ' ' + a.getAttribute('href') + ' ' + (a.querySelector('svg').namespaceURI === 'http://www.w3.org/2000/svg' && a.querySelector('svg').getBoundingClientRect().width > 30)).join(',')`))
  454. === 'Unwatched /my/unwatched true,Schedule /my/schedule true,Shows /my/shows true,Movies /my/movies true'
  455. && (await ev(`document.querySelector('home-nav').getBoundingClientRect().bottom <= document.querySelector('home-intro').getBoundingClientRect().top && document.querySelector('home-intro').getBoundingClientRect().bottom <= document.querySelector('#home-movies').getBoundingClientRect().top`))
  456. && !/Log in with ident/.test(await ev(`document.querySelector('home-intro').textContent`)) && (await ev(`document.querySelectorAll('#home-movies a.tile').length`)) === 10);
  457. await ev(`window.__w050 = 'same document'`);
  458. await page.click('a.hometile[href="/my/movies"]');
  459. await page.waitFor(`location.pathname === '/my/movies' && !!document.querySelector('my-movies-page')`, { timeout: 10000, label: 'icon → /my/movies' });
  460. check('home: the Movies icon leads to /my/movies (client-side, no reload); nothing followed yet → "You follow no movies yet."', (await ev('window.__w050')) === 'same document'
  461. && (await ev(`document.querySelector('my-movies-page p.muted').textContent`)) === 'You follow no movies yet.');
  462. check('"Log out" has no border (only inverted buttons have one)', (await ev(`getComputedStyle(document.querySelector('#logout')).borderTopWidth`)) === '0px');
  463. check('the ident selector\'s signed-in status has no border', (await ev(`(() => { const e = document.querySelector('#selector').shadowRoot.querySelector('[part~="status"]'); return e ? getComputedStyle(e).borderTopWidth : '0px' })()`)) === '0px');
  464. check('the header brand "tracker" has font weight 100', (await ev(`getComputedStyle(document.querySelector('application-header .brand')).fontWeight`)) === '100');
  465. // ---- tracker.worldapi.org#5: /unwatched signed IN — only already-released, unwatched episodes of
  466. // followed shows, newest release first; a click removes the row. Run BEFORE the show-page checks
  467. // below (which toggle these same fixture episodes watched) so all three still start unwatched here.
  468. await page.goto(BASE + '/my/unwatched');
  469. await hydrated();
  470. const unwatchedRowTexts = () => ev(`Array.from(document.querySelectorAll('.unwatched-show')).map(e => e.textContent)`);
  471. check('signed in: /my/unwatched lists only already-released episodes, newest release first (not the future one), titles with the year',
  472. JSON.stringify(await unwatchedRowTexts()) === JSON.stringify(['Gate Test Show (2024)', 'Gate Test Show (2024)'])
  473. && (await ev(`document.querySelectorAll('unwatched-row').length`)) === 2);
  474. check('signed in: /my/unwatched shows show title, the episode as S01E02 + title and release date, linking to the show',
  475. (await ev(`document.querySelector('unwatched-row unwatched-episode').textContent`)) === 'S01E02 · Second'
  476. && (await ev(`document.querySelector('unwatched-row unwatched-date').textContent`)) === '2020-06-15'
  477. && (await ev(`document.querySelector('unwatched-row .unwatched-show').getAttribute('href')`)) === '/shows/gate-test-show');
  478. check('signed in: /my/unwatched starts with regular (unwatched) check icons', (await ev(`document.querySelectorAll('unwatched-row button.checkicon.regular').length`)) === 2);
  479. await page.click('unwatched-row button.checkicon');
  480. await page.waitFor(`document.querySelectorAll('unwatched-row').length === 1`, { timeout: 10000, label: 'unwatched row removed' });
  481. let once = await rowsOnce('unwatched-row button.checkicon');
  482. check('session-sync: after the /my/unwatched click (face unwatchedToggle) every row appears once', once.unique && once.n === 1, JSON.stringify(once));
  483. check('click an unwatched episode\'s check: it disappears from the list', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 1
  484. && (await ev(`document.querySelector('unwatched-episode').textContent`)) === 'S01E01 · Pilot');
  485. await page.goto(BASE + '/my/unwatched');
  486. await hydrated();
  487. check('reload: the watch from /my/unwatched is server-side, not just client state', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 1
  488. && (await ev(`document.querySelector('unwatched-episode').textContent`)) === 'S01E01 · Pilot');
  489. // ---- tracker.worldapi.org#7: /my/shows signed IN — followed shows, newest follow first, small
  490. // poster, title linking to the show, the last watched episode (only ep 2 "Second" is watched so far)
  491. const myShows = () => ev(`Array.from(document.querySelectorAll('my-show-row')).map(r => ({
  492. title: r.querySelector('.myshow-title') && r.querySelector('.myshow-title').textContent,
  493. href: r.querySelector('.myshow-title') && r.querySelector('.myshow-title').getAttribute('href'),
  494. poster: r.querySelector('img.myshow-poster') && r.querySelector('img.myshow-poster').getAttribute('src'),
  495. posterLoaded: !!(r.querySelector('img.myshow-poster') && r.querySelector('img.myshow-poster').complete && r.querySelector('img.myshow-poster').naturalWidth > 0),
  496. episode: r.querySelector('my-show-episode') ? r.querySelector('my-show-episode').textContent : null }))`);
  497. const waitPostersLoaded = () => page.waitFor(`Array.from(document.querySelectorAll('img.myshow-poster')).every(i => i.complete && i.naturalWidth > 0)`, { timeout: 10000, label: 'posters loaded' });
  498. await page.goto(BASE + '/my/shows');
  499. await hydrated();
  500. await waitPostersLoaded();
  501. let ms = await myShows();
  502. check('signed in: /my/shows lists both followed shows, newest follow first, with the year (2025 from `release` — that show has no `year`)', JSON.stringify(ms.map(m => m.title)) === JSON.stringify(['Unwatched Gate Show (2025)', 'Gate Test Show (2024)']), JSON.stringify(ms));
  503. check('signed in: /my/shows titles link to the show pages', ms.length === 2 && ms[0].href === '/shows/unwatched-gate-show' && ms[1].href === '/shows/gate-test-show', JSON.stringify(ms));
  504. check('signed in: /my/shows shows a loaded poster per row (its own file, else the one placeholder URL /posters/none)', ms.length === 2 && ms.every(m => m.posterLoaded) && ms[1].poster === '/posters/sh1.png' && ms[0].poster === '/posters/none', JSON.stringify(ms));
  505. check('signed in: /my/shows shows the last watched episode as S01E02, none for the never-watched show', ms.length === 2 && ms[1].episode === 'S01E02' && ms[0].episode === null, JSON.stringify(ms));
  506. check('/my/shows: the poster is small (2.5rem wide)', (await ev(`getComputedStyle(document.querySelector('img.myshow-poster')).width`)) === '40px');
  507. // ---- tracker.worldapi.org#6: /schedule signed IN — only NOT-yet-released episodes of followed
  508. // shows, soonest first, no check icons at all (read-only page, unaffected by any watch toggle above)
  509. await page.goto(BASE + '/my/schedule');
  510. await hydrated();
  511. check('signed in: /my/schedule lists only the not-yet-released episode, title with the year', (await ev(`document.querySelectorAll('schedule-row').length`)) === 1
  512. && (await ev(`document.querySelector('.schedule-show').textContent`)) === 'Gate Test Show (2024)');
  513. check('signed in: /my/schedule shows show title, the episode as S02E01 + title, release date, linking to the show, no checks',
  514. (await ev(`document.querySelector('schedule-row schedule-episode').textContent`)) === 'S02E01 · Season Two Opener'
  515. && (await ev(`document.querySelector('schedule-row schedule-date').textContent`)) === '2099-01-01'
  516. && (await ev(`document.querySelector('schedule-row .schedule-show').getAttribute('href')`)) === '/shows/gate-test-show'
  517. && (await ev(`document.querySelectorAll('schedule-row button.checkicon').length`)) === 0);
  518. // ---- tracker.worldapi.org#4: /shows/:slug signed IN — check icons, episode toggle, season toggle,
  519. // collapse/expand, and a reload proves the watch really is server-side ----
  520. const EP3 = 'ep0000000000ep0e3'; // season 2 (expanded by default), episode 1 "Season Two Opener"
  521. const EP1 = 'ep0000000000ep0e1'; // season 1 (collapsed by default), episode 1 "Pilot"
  522. const EP2 = 'ep0000000000ep0e2'; // season 1, episode 2 "Second"
  523. const SEASON1 = 'se000000000se0s1';
  524. const checkClass = (id) => ev(`document.querySelector('button.checkicon[value="${id}"]').getAttribute('class')`);
  525. const clickValue = (sel, id) => page.click(`${sel}[value="${id}"]`);
  526. await page.goto(BASE + '/shows/gate-test-show');
  527. await hydrated();
  528. check('signed in: episode check icons show, starting "regular" (not watched)', (await checkClass(EP3)) === 'checkicon regular');
  529. await clickValue('button.checkicon', EP3);
  530. await page.waitFor(`document.querySelector('button.checkicon[value="${EP3}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'ep3 solid' });
  531. check('click an episode check: it turns solid', (await checkClass(EP3)) === 'checkicon solid');
  532. check('season 1 starts collapsed (no episode rows of it yet)', (await ev(`document.querySelectorAll('episode-row').length`)) === 1);
  533. await clickValue('button.checkicon', SEASON1);
  534. await page.waitFor(`document.querySelector('button.checkicon[value="${SEASON1}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'season1 solid' });
  535. check('click a season check: the season itself turns solid', (await checkClass(SEASON1)) === 'checkicon solid');
  536. await clickValue('button.season-toggle', SEASON1);
  537. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  538. check('expanding season 1 shows its episodes already solid (the season check watched them too)',
  539. (await checkClass(EP1)) === 'checkicon solid' && (await checkClass(EP2)) === 'checkicon solid');
  540. await page.goto(BASE + '/shows/gate-test-show');
  541. await hydrated();
  542. check('reload: episode 3\'s watch is server-side, not just client state', (await checkClass(EP3)) === 'checkicon solid');
  543. await clickValue('button.season-toggle', SEASON1);
  544. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  545. check('reload: season 1\'s bulk watch is server-side too', (await checkClass(EP1)) === 'checkicon solid' && (await checkClass(EP2)) === 'checkicon solid' && (await checkClass(SEASON1)) === 'checkicon solid');
  546. // ---- mission 047, the creator's additions on the show page (signed in) ----
  547. check('episodes are not indented: an episode\'s check is exactly under its season\'s check (same left edge)',
  548. (await ev(`Math.abs(document.querySelector('episode-row button.checkicon').getBoundingClientRect().left - document.querySelector('season-row button.checkicon').getBoundingClientRect().left) < 0.5`)));
  549. check('artist links are orange #ce9178 (not red)', (await ev(`getComputedStyle(document.querySelector('.artist-link')).color`)) === 'rgb(206, 145, 120)');
  550. check('an unwatched check is the inverted solid icon: accent outline circle AND accent check mark; watched = filled accent disc, dark check',
  551. (await ev(`(() => { const b = document.querySelector('button.checkicon.regular'); const c = getComputedStyle(b.querySelector('circle')), p = getComputedStyle(b.querySelector('path'));
  552. return c.stroke === 'rgb(78, 201, 176)' && c.fill !== 'rgb(78, 201, 176)' && p.stroke === 'rgb(78, 201, 176)' })()`))
  553. && (await ev(`(() => { const b = document.querySelector('button.checkicon.solid'); const c = getComputedStyle(b.querySelector('circle')), p = getComputedStyle(b.querySelector('path'));
  554. return c.fill === 'rgb(78, 201, 176)' && p.stroke === 'rgb(15, 20, 25)' })()`)));
  555. // ---- tracker.worldapi.org#7: /my/shows again — S02E01 was watched BEFORE season 1's bulk watch, so
  556. // this proves "last" = the highest season/episode watched, not the most recently watched one
  557. await page.goto(BASE + '/my/shows');
  558. await hydrated();
  559. ms = await myShows();
  560. check('/my/shows: the last episode is the HIGHEST watched (S02E01), not the most recent watch; the other show still none',
  561. ms.length === 2 && ms[1].episode === 'S02E01' && ms[0].episode === null, JSON.stringify(ms));
  562. // ---- mission 047 (the creator: "i dont see the check marks"): a CLIENT-SIDE navigation /my/shows → click the show
  563. // title builds the show page in the browser; its check <svg> must be an SVG element (the old vendored build made it
  564. // in the XHTML namespace: nothing drawn, circle width 0). A window marker proves no full page load happened.
  565. await ev(`window.__w047 = 'same document'`);
  566. await page.click(`a.myshow-title[href="/shows/gate-test-show"]`);
  567. await page.waitFor(`location.pathname === '/shows/gate-test-show' && !!document.querySelector('button.checkicon svg circle')`, { timeout: 10000, label: 'show page after client-side navigation' });
  568. const svgNs = await ev(`JSON.stringify({ marker: window.__w047 || null, ns: document.querySelector('button.checkicon svg').namespaceURI,
  569. circleNs: document.querySelector('button.checkicon svg circle').namespaceURI,
  570. circleWidth: document.querySelector('button.checkicon svg circle').getBoundingClientRect().width })`);
  571. const sv = JSON.parse(svgNs);
  572. check('client-side navigation /my/shows → show page: the check svg is in the SVG namespace and its circle is drawn (width > 0)',
  573. sv.marker === 'same document' && sv.ns === 'http://www.w3.org/2000/svg' && sv.circleNs === 'http://www.w3.org/2000/svg' && sv.circleWidth > 0, svgNs);
  574. lk = await extLinks();
  575. check('client-side navigation: the link icons are there too (TMDB + IMDb)', linksAre(lk, [['TMDB', TMDB_TV + '1001'], ['IMDb', IMDB + 'tt0000001/']]), lk);
  576. // mission 047 #11: the checks also vanished after expanding/collapsing a season — same cause; and the caret flips
  577. let cs = await carets();
  578. check('season caret at the header\'s right edge (tracker#17): UP for the open season 2, DOWN for the closed season 1',
  579. cs === JSON.stringify(['up', 'down']) && (await ev(`(() => { const r = document.querySelector('season-row'); return Math.abs(r.getBoundingClientRect().right - r.querySelector('season-caret').getBoundingClientRect().right) < 2 })()`)), cs);
  580. await clickValue('button.season-toggle', SEASON1);
  581. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  582. let st = await svgState();
  583. cs = await carets();
  584. check('after EXPANDING season 1 (client-side): every check + caret is a drawn SVG, season 1\'s caret now points UP', svgOk(st) && cs === JSON.stringify(['up', 'up']), st + ' ' + cs);
  585. once = await rowsOnce('show-seasons button.checkicon');
  586. check('after EXPANDING (face showSeasonEpisodes) every season/episode row appears once (2 seasons + 3 episodes), no skeleton left', once.unique && once.n === 5 && (await ev(`document.querySelectorAll('skeleton-row').length`)) === 0, JSON.stringify(once));
  587. await clickValue('button.checkicon', EP1);
  588. await page.waitFor(`document.querySelector('button.checkicon[value="${EP1}"]').getAttribute('class') === 'checkicon regular'`, { timeout: 10000, label: 'ep1 regular' });
  589. st = await svgState();
  590. check('after an EPISODE click: every check is still a drawn SVG', svgOk(st), st);
  591. once = await rowsOnce('show-seasons button.checkicon');
  592. check('session-sync: after an EPISODE click (face showToggleEpisode) every row appears once', once.unique && once.n === 5, JSON.stringify(once));
  593. await clickValue('button.checkicon', EP1);
  594. await page.waitFor(`document.querySelector('button.checkicon[value="${EP1}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'ep1 solid again' });
  595. await clickValue('button.season-toggle', SEASON1);
  596. await page.waitFor(`!document.querySelector('button.checkicon[value="${EP1}"]')`, { timeout: 10000, label: 'season 1 collapsed' });
  597. st = await svgState();
  598. cs = await carets();
  599. check('after COLLAPSING season 1 again: every check + caret is a drawn SVG, its caret points DOWN again', svgOk(st) && cs === JSON.stringify(['up', 'down']), st + ' ' + cs);
  600. // ---- tracker#17: SKELETON rows while a season's episodes load. The face's frame is held back 1.5 s (`__delayFace`), so
  601. // the state right after the click is the one BEFORE the answer. Season 1 has 2 episodes → 2 skeleton rows.
  602. const skeletonState = () => ev(`JSON.stringify((() => { const sk = Array.from(document.querySelectorAll('skeleton-row'));
  603. const ep = document.querySelector('episode-row'), t = sk[0] && sk[0].querySelector('skeleton-title');
  604. return { n: sk.length, ep1: !!document.querySelector('button.checkicon[value="${EP1}"]'), anim: t ? getComputedStyle(t).animationName : null,
  605. parts: sk.every(r => r.querySelector('skeleton-check') && r.querySelector('skeleton-number') && r.querySelector('skeleton-title')),
  606. h: sk[0] ? Math.round(sk[0].getBoundingClientRect().height) : 0, epH: ep ? Math.round(ep.getBoundingClientRect().height) : 0,
  607. checkW: sk[0] ? Math.round(sk[0].querySelector('skeleton-check').getBoundingClientRect().width) : 0,
  608. afterSeason1: sk[0] ? sk[0].previousElementSibling.querySelector('button.season-toggle').value === '${SEASON1}' : false }; })())`).then(JSON.parse);
  609. const delayFace = (ms) => ev(`window.__delayFace = ${ms ? `{ name: 'showSeasonEpisodes', ms: ${ms} }` : 'null'}`);
  610. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  611. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  612. await delayFace(1500);
  613. await clickValue('button.season-toggle', SEASON1);
  614. await page.waitFor(`document.querySelectorAll('skeleton-row').length > 0`, { timeout: 1000, label: 'skeleton' }).catch(() => {});
  615. await ev(`document.querySelector('skeleton-row') && document.querySelector('skeleton-row').scrollIntoView({ block: 'center' })`);
  616. let sk = await skeletonState();
  617. cs = await carets();
  618. check(`${w} px (tracker#17): right after the click, before the answer — 2 skeleton rows (season 1 has 2 episodes) under season 1, in the episode row's shape (check disc, number, title; same height), shimmering; no episode yet; caret already UP`,
  619. sk.n === 2 && !sk.ep1 && sk.anim === 'tracker-shimmer' && sk.parts && Math.abs(sk.h - sk.epH) <= 1 && sk.checkW === 32 && sk.afterSeason1 && cs === JSON.stringify(['up', 'up']), JSON.stringify(sk) + ' ' + cs);
  620. if (process.env.TRACKER_GATE_SHOTS) { mkdirSync(process.env.TRACKER_GATE_SHOTS, { recursive: true }); writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `show-skeleton-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64')); }
  621. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  622. sk = await skeletonState();
  623. once = await rowsOnce('show-seasons button.checkicon');
  624. check(`${w} px (tracker#17): after the answer the skeleton is gone and the real rows are there once each (2 seasons + 3 episodes, hybriel#121), still solid`,
  625. sk.n === 0 && once.unique && once.n === 5 && (await checkClass(EP1)) === 'checkicon solid' && (await checkClass(EP2)) === 'checkicon solid', JSON.stringify(sk) + ' ' + JSON.stringify(once));
  626. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `show-open-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  627. await clickValue('button.season-toggle', SEASON1);
  628. await page.waitFor(`!document.querySelector('button.checkicon[value="${EP1}"]')`, { timeout: 10000, label: 'season 1 closed' });
  629. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `show-closed-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  630. }
  631. await page.send('Emulation.clearDeviceMetricsOverride', {});
  632. // closed again WHILE loading: the late answer must not bring the rows back
  633. const sent0 = (await page.wsSent()).length;
  634. await delayFace(1200);
  635. await clickValue('button.season-toggle', SEASON1);
  636. await page.waitFor(`document.querySelectorAll('skeleton-row').length === 2`, { timeout: 1000, label: 'skeleton 2' }).catch(() => {});
  637. await clickValue('button.season-toggle', SEASON1);
  638. await new Promise(r => setTimeout(r, 1800));
  639. once = await rowsOnce('show-seasons button.checkicon');
  640. cs = await carets();
  641. check('tracker#17: a season closed again while loading stays closed after the late answer (only season 2\'s episode, no skeleton, caret down)',
  642. once.n === 3 && !once.values.includes(EP1) && (await ev(`document.querySelectorAll('skeleton-row').length`)) === 0 && cs === JSON.stringify(['up', 'down']), JSON.stringify(once) + ' ' + cs);
  643. await delayFace(0);
  644. // closing asks the server nothing; opening sends exactly the one sessionless face (no showRows)
  645. const sentNow = (await page.wsSent()).slice(sent0);
  646. check('tracker#17: opening sends only showSeasonEpisodes (never the session face showRows), closing sends nothing',
  647. sentNow.filter(f => f.includes('showSeasonEpisodes')).length === 1 && !sentNow.some(f => f.includes('showRows')), JSON.stringify(sentNow));
  648. // ---- mission 047 #12: FOLLOW on the show page — "Following" filled; a click unfollows ("Follow", outlined in the
  649. // accent), the show leaves /my/shows; a click follows again (follow date = now) → the top of /my/shows; reloads keep it
  650. const followState = () => ev(`JSON.stringify((() => { const b = document.querySelector('#follow'); const c = getComputedStyle(b);
  651. return { text: b.textContent, cls: b.getAttribute('class'), bg: c.backgroundColor, border: c.borderTopWidth + ' ' + c.borderTopColor }; })())`);
  652. let fs = JSON.parse(await followState());
  653. check('follow: alice follows the show → "Following", the filled accent button (no border)', fs.text === 'Following' && fs.cls === 'follow' && fs.bg === 'rgb(78, 201, 176)' && fs.border.startsWith('0px'), JSON.stringify(fs));
  654. await page.click('#follow');
  655. await page.waitFor(`document.querySelector('#follow').textContent === 'Follow'`, { timeout: 10000, label: 'unfollowed' }).catch(() => {});
  656. fs = JSON.parse(await followState());
  657. check('follow: a click unfollows → "Follow", outlined (1px accent border, transparent)', fs.text === 'Follow' && fs.cls === 'follow inverted' && fs.border === '1px rgb(78, 201, 176)' && fs.bg === 'rgba(0, 0, 0, 0)', JSON.stringify(fs));
  658. await page.goto(BASE + '/shows/gate-test-show');
  659. await hydrated();
  660. check('follow: the unfollow survives a reload', (await ev(`document.querySelector('#follow').textContent`)) === 'Follow');
  661. await page.goto(BASE + '/my/shows');
  662. await hydrated();
  663. ms = await myShows();
  664. check('follow: the unfollowed show is gone from /my/shows', ms.length === 1 && ms[0].href === '/shows/unwatched-gate-show', JSON.stringify(ms));
  665. await page.goto(BASE + '/shows/gate-test-show');
  666. await hydrated();
  667. await page.click('#follow');
  668. await page.waitFor(`document.querySelector('#follow').textContent === 'Following'`, { timeout: 10000, label: 'followed' }).catch(() => {});
  669. check('follow: a click follows again → "Following"', (await ev(`document.querySelector('#follow').textContent`)) === 'Following');
  670. await new Promise(r => setTimeout(r, 400));
  671. check('session-sync: after the Follow toggles (face showToggleFollow) ONE Follow button, one show header, every season row once',
  672. (await ev(`document.querySelectorAll('#follow').length === 1 && document.querySelectorAll('show-header').length === 1`)) && (await rowsOnce('season-row button.checkicon')).unique);
  673. await page.goto(BASE + '/my/shows');
  674. await hydrated();
  675. ms = await myShows();
  676. check('follow: followed just now → the TOP of /my/shows (newest follow first), after a reload', ms.length === 2 && ms[0].href === '/shows/gate-test-show' && ms[1].href === '/shows/unwatched-gate-show', JSON.stringify(ms));
  677. await page.goto(BASE + '/shows/gate-test-show');
  678. await hydrated();
  679. check('follow: "Following" survives a reload', (await ev(`document.querySelector('#follow').textContent`)) === 'Following');
  680. // ==== tracker.worldapi.org#13: /movies, /shows (24 per page, the copied pagination, the page in the PATH) and
  681. // /my/movies (the Follow button on a movie's page) ====
  682. await page.goto(BASE + '/');
  683. await hydrated();
  684. await ev(`window.__w050 = 'same document'`);
  685. await page.click('#home-movies h2 a');
  686. await page.waitFor(`location.pathname === '/movies' && document.querySelectorAll('a.tile').length === 24`, { timeout: 10000, label: 'heading → /movies' });
  687. let titles = await titlesOf('tile-grid');
  688. check('/movies (the homepage heading, client-side): page 1 = 24 movies, release date desc — the 2099 one first, then Pager Movie 30…08',
  689. (await ev('window.__w050')) === 'same document' && JSON.stringify(titles) === JSON.stringify(['Pager Future Movie', ...movieRange(30, 8)]), JSON.stringify(titles));
  690. let pg = await pagerState();
  691. check('/movies: the pagination says "1 2 Next", 1 current (no Previous on the first page); "33 movies … page 1 of 2"',
  692. JSON.stringify(pg) === JSON.stringify({ items: ['1', '2', 'Next'], current: '1' }) && /^33 movies, newest first · page 1 of 2$/.test(await ev(`document.querySelector('.list-count').textContent`)), JSON.stringify(pg));
  693. await ev(`Array.from(document.querySelectorAll('pagination button')).find(b => b.textContent.trim() === 'Next').click()`);
  694. await page.waitFor(`location.pathname === '/movies/page/2' && document.querySelectorAll('a.tile').length === 9`, { timeout: 10000, label: 'Next → page 2' });
  695. titles = await titlesOf('tile-grid');
  696. pg = await pagerState();
  697. check('/movies "Next": the address is /movies/page/2 (client-side), the last 9: Pager Movie 07…01, the 1996 fixture movie, the undated one last; "Previous 1 2", 2 current; title says page 2',
  698. (await ev('window.__w050')) === 'same document' && JSON.stringify(titles) === JSON.stringify([...movieRange(7, 1), 'Gate Test Movie', 'Pager Undated Movie'])
  699. && JSON.stringify(pg) === JSON.stringify({ items: ['Previous', '1', '2'], current: '2' }) && (await ev('document.title')) === 'Movies, page 2 — tracker', JSON.stringify({ titles, pg }));
  700. await ev(`Array.from(document.querySelectorAll('pagination button')).find(b => b.textContent.trim() === '1').click()`);
  701. await page.waitFor(`location.pathname === '/movies' && document.querySelectorAll('a.tile').length === 24`, { timeout: 10000, label: '1 → /movies' });
  702. check('/movies: the page number "1" goes back to /movies (page 1)', (await pagerState()).current === '1' && (await titlesOf('tile-grid'))[0] === 'Pager Future Movie');
  703. await ev('history.back()');
  704. await page.waitFor(`location.pathname === '/movies/page/2' && document.querySelectorAll('a.tile').length === 9`, { timeout: 10000, label: 'Back → page 2' });
  705. check('/movies: the browser\'s Back returns to /movies/page/2 with page 2 shown', (await pagerState()).current === '2' && (await ev('window.__w050')) === 'same document');
  706. await page.goto(BASE + '/movies/page/2');
  707. await hydrated();
  708. check('/movies/page/2 loaded directly: the same 9 movies, 2 current', (await ev(`document.querySelectorAll('a.tile').length`)) === 9 && (await pagerState()).current === '2');
  709. await page.goto(BASE + '/movies/page/99');
  710. await hydrated();
  711. const p99 = await pagerState();
  712. await page.goto(BASE + '/movies/page/abc');
  713. await hydrated();
  714. check('/movies: a page past the end shows the last page, a page that is no number the first', p99.current === '2' && (await pagerState()).current === '1');
  715. await page.goto(BASE + '/');
  716. await hydrated();
  717. await ev(`window.__w050 = 'same document'`);
  718. await page.click('#home-shows h2 a');
  719. await page.waitFor(`location.pathname === '/shows' && !!document.querySelector('tile-grid a.tile')`, { timeout: 10000, label: 'heading → /shows' });
  720. tl = await tilesOf('tile-grid');
  721. check('/shows (the homepage heading): every series (no movies) by newest released episode, the one with none released last; one page → no pagination at all',
  722. (await ev('window.__w050')) === 'same document' && JSON.stringify(tl.map(t => t.split('|')[0])) === JSON.stringify(['Pager Older Series', 'Gate Test Show', 'Unwatched Gate Show'])
  723. && (await ev(`document.querySelectorAll('pagination').length`)) === 0, JSON.stringify(tl));
  724. // a movie's page has the Follow button (the show page) → /my/movies; /my/shows stays series only
  725. await page.click('a.tile[href="/shows/gate-test-show"]');
  726. await page.waitFor(`location.pathname === '/shows/gate-test-show' && !!document.querySelector('#follow')`, { timeout: 10000, label: 'tile → show page' });
  727. check('/shows: a tile opens the show page (client-side)', (await ev('window.__w050')) === 'same document');
  728. await page.goto(BASE + '/shows/gate-test-movie');
  729. await hydrated();
  730. check('a movie\'s page has the Follow button ("Follow": alice does not follow it yet)', (await ev(`document.querySelector('#follow').textContent`)) === 'Follow');
  731. await page.click('#follow');
  732. await page.waitFor(`document.querySelector('#follow').textContent === 'Following'`, { timeout: 10000, label: 'movie followed' }).catch(() => {});
  733. await page.goto(BASE + '/my/movies');
  734. await hydrated();
  735. const myMovies = await ev(`JSON.stringify(Array.from(document.querySelectorAll('my-show-row')).map(r => [r.querySelector('a.myshow-title').textContent, r.querySelector('a.myshow-title').getAttribute('href'), r.querySelector('img.myshow-poster').getAttribute('src')].join('|')))`);
  736. check('/my/movies: the movie alice just followed — title with year, its page, the poster (placeholder URL)', myMovies === JSON.stringify(['Gate Test Movie (1996)|/shows/gate-test-movie|/posters/none']), myMovies);
  737. await page.goto(BASE + '/my/shows');
  738. await hydrated();
  739. ms = await myShows();
  740. check('/my/shows stays SERIES only: the followed movie is not there', ms.length === 2 && ms.every(m => m.href !== '/shows/gate-test-movie'), JSON.stringify(ms));
  741. // ---- mission 047, MOBILE FIRST: at a phone's 390 px the header is ONE row (brand + ident selector + "Log out"), and
  742. // no page scrolls sideways; at 1280 px the same pages. Screenshots of both widths with TRACKER_GATE_SHOTS.
  743. const pages = [['home', '/'], ['show', '/shows/gate-test-show'], ['my-shows', '/my/shows'], ['my-unwatched', '/my/unwatched'], ['my-schedule', '/my/schedule'],
  744. ['movies', '/movies'], ['movies-page-2', '/movies/page/2'], ['shows-list', '/shows'], ['my-movies', '/my/movies'], ['search', '/search/gate']];
  745. if (process.env.TRACKER_GATE_SHOTS) mkdirSync(process.env.TRACKER_GATE_SHOTS, { recursive: true });
  746. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  747. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  748. const bad = [];
  749. for (const [name, path] of pages) {
  750. await page.goto(BASE + path);
  751. await hydrated();
  752. const m = JSON.parse(await ev(`JSON.stringify((() => {
  753. const hd = document.querySelector('application-header').getBoundingClientRect();
  754. const mid = (s) => { const r = document.querySelector(s).getBoundingClientRect(); return r.top + r.height / 2; };
  755. const mids = ['application-header .brand', '#selector', '#logout'].map(mid);
  756. const wd = (s) => Math.round(document.querySelector(s).getBoundingClientRect().width);
  757. return { headerH: hd.height, spread: Math.max(...mids) - Math.min(...mids), scrollW: document.documentElement.scrollWidth, widths: ['application-header .brand', '#userbox', '#selector', '#logout'].map(wd) };
  758. })())`));
  759. if (m.spread > 8 || m.headerH > 64 || m.scrollW > w) bad.push(name + ' ' + JSON.stringify(m));
  760. if (name === 'show') {
  761. // mission 047 #8: phone = title first, then the poster at the full content width, then genres/plot/cast;
  762. // desktop unchanged = poster left (10rem), title right of it
  763. const L = JSON.parse(await ev(`JSON.stringify((() => { const b = (s) => { const r = document.querySelector(s).getBoundingClientRect(); return { l: r.left, r: r.right, t: r.top, b: r.bottom, w: r.width }; };
  764. const main = document.querySelector('main'); const cs = getComputedStyle(main);
  765. return { h1: b('show-header h1'), follow: b('#follow'), links: b('show-links'), poster: b('show-poster img'), genres: b('show-genres'), summary: b('show-summary'), cast: b('show-cast'),
  766. content: main.getBoundingClientRect().width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) }; })())`));
  767. const ok = mobile
  768. ? L.h1.b <= L.links.t && L.links.b <= L.poster.t && Math.abs(L.poster.w - L.content) < 1 && L.poster.b <= L.genres.t && L.genres.b <= L.summary.t && L.summary.b <= L.cast.t && L.follow.t < L.poster.t
  769. : Math.abs(L.poster.w - 160) < 1 && L.poster.r <= L.h1.l && L.h1.t < L.poster.b && L.poster.t <= L.genres.t && L.poster.r <= L.links.l && L.h1.b <= L.links.t && L.links.b <= L.genres.t;
  770. check(`${w} px: show page layout — ${mobile ? 'title (+ Follow) first, the link icons under it, then the poster at the full content width, then genres, plot, cast' : 'poster left (10rem), the title right of it, the link icons under the title'}`, ok, JSON.stringify(L));
  771. }
  772. if (process.env.TRACKER_GATE_SHOTS) {
  773. if (name === 'my-shows' || name === 'my-movies') await waitPostersLoaded();
  774. // #13: every poster tile in view loaded
  775. await page.waitFor(`Array.from(document.querySelectorAll('img.tile-poster')).filter(i => { const r = i.getBoundingClientRect(); return r.top < innerHeight && r.bottom > 0 && r.left < innerWidth && r.right > 0 }).every(i => i.complete && i.naturalWidth > 0)`, { timeout: 10000, label: 'tiles loaded' });
  776. const shot = await page.send('Page.captureScreenshot', { format: 'png' });
  777. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `${name}-${w}.png`), Buffer.from(shot.data, 'base64'));
  778. }
  779. }
  780. check(`${w} px: on every page the header is one row (brand, ident selector, "Log out" side by side, ≤ 64 px high) and nothing scrolls sideways`, bad.length === 0, bad.join('; '));
  781. }
  782. await page.send('Emulation.clearDeviceMetricsOverride', {});
  783. // ==== tracker.worldapi.org#9: THE TMDB SYNC, against the fake TMDB — first the tool (app stopped: hl:mpackdb is
  784. // one process per storage), twice (idempotent), then the pages; then the app's own daily run ====
  785. check('sync: the app ran with the daily sync switched off until now (TRACKER_SYNC=0), no TMDB request yet', log.includes('tmdb sync: off (TRACKER_SYNC=0)') && tmdb.requests.length === 0, log);
  786. await page.goto('about:blank'); // leave the app first: an open page would log websocket reconnect errors
  787. await stopServer();
  788. // spawned, not execFileSync: the fake TMDB lives in THIS node process and must keep answering meanwhile
  789. const runTool = () => new Promise((ok) => {
  790. const p = spawn(join(APP, 'bin/hybriel'), ['tools/sync-tmdb.hl', '--limit', '5'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, ...TMDB_ENV }, stdio: ['ignore', 'pipe', 'pipe'] });
  791. let o = '';
  792. p.stdout.on('data', d => o += d); p.stderr.on('data', d => o += d);
  793. p.on('exit', () => ok(o));
  794. });
  795. let out = await runTool();
  796. let done = (out.match(/tmdb sync done: .*/) || [''])[0];
  797. check('sync tool: 3 followed shows (2 series + a movie another user follows) → 2 new seasons (show 1 S3, show 2 S1), 4 new episodes, the 3 fixture episodes updated (tmdbId by S/E number, E2 renamed), 3 posters, 5 ids filled, 3 TVmaze lookups + 2 TVmaze episode lists (#15: they agree with TMDB → nothing taken), 0 errors',
  798. done.includes('shows=3 newSeasons=2 newEpisodes=4 updatedEpisodes=3') && done.includes('posters=3 ids=5') && done.includes('tvmazeRequests=5 errors=0 tvmazeEpisodes=0 tvmazeSeasons=0 tvmazeFilled=0 tvmazeSkipped=0'), out);
  799. // per show, in this order (the order BETWEEN shows follows the follow table, which the follow test above reshuffled)
  800. const perShow = [['/3/tv/1001?append_to_response=external_ids', '/3/tv/1001?append_to_response=season/1,season/2,season/3', '/t/p/w342/gate1.png'],
  801. ['/3/tv/1002?append_to_response=external_ids', '/3/tv/1002?append_to_response=season/1', '/t/p/w342/gate2.png'], ['/3/movie/1003?append_to_response=external_ids', '/t/p/w342/gate3.png']];
  802. const paths = tmdb.requests.map(q => q.path);
  803. check('sync tool: every TMDB call carried the Bearer token, season 0 was never asked for; per show ONE details request that also brings external_ids (no extra request), one for its seasons, the poster; the movie only via /movie/, never /tv/1003',
  804. tmdb.requests.filter(q => q.path.startsWith('/3/')).every(q => q.authorization === 'Bearer gate-token') && !paths.some(p => p.includes('season/0'))
  805. && JSON.stringify([...paths].sort()) === JSON.stringify(perShow.flat().sort()) && perShow.every(list => list.every((p, i) => i === 0 || paths.indexOf(list[i - 1]) < paths.indexOf(p))),
  806. JSON.stringify(paths));
  807. check('sync tool: TVmaze asked only for shows without a TVmaze id: show 1 by imdb (hit), show 2 by imdb (404) then by thetvdb (hit); then (#15) each series\' episode list by the found id; never for the movie',
  808. JSON.stringify([...tmdb.tvmazeRequests].sort()) === JSON.stringify(['/tvmaze/lookup/shows?imdb=tt0000001', '/tvmaze/lookup/shows?imdb=tt0000002', '/tvmaze/lookup/shows?thetvdb=4002', '/tvmaze/shows/501', '/tvmaze/shows/502', '/tvmaze/shows/501/episodes', '/tvmaze/shows/502/episodes'].sort()),
  809. JSON.stringify(tmdb.tvmazeRequests));
  810. const tvmazeBefore = tmdb.tvmazeRequests.length;
  811. out = await runTool();
  812. done = (out.match(/tmdb sync done: .*/) || [''])[0];
  813. check('sync tool again: idempotent — nothing new, nothing updated, no poster downloaded again, no id filled, no TVmaze lookup (every show has its TVmaze id now) — only the 2 episode lists, nothing taken from them',
  814. done.includes('newSeasons=0 newEpisodes=0 updatedEpisodes=0 updatedSeasons=0 posters=0 ids=0') && done.includes('tvmazeRequests=2 errors=0 tvmazeEpisodes=0 tvmazeSeasons=0 tvmazeFilled=0 tvmazeSkipped=0')
  815. && JSON.stringify(tmdb.tvmazeRequests.slice(tvmazeBefore).sort()) === JSON.stringify(['/tvmaze/shows/501/episodes', '/tvmaze/shows/502/episodes']), out);
  816. await startServer();
  817. await page.goto(BASE + '/my/schedule');
  818. await hydrated();
  819. const scheduleRows = () => ev(`Array.from(document.querySelectorAll('schedule-row')).map(r => r.querySelector('.schedule-show').textContent + ' | ' + r.querySelector('schedule-episode').textContent + ' | ' + r.querySelector('schedule-date').textContent)`);
  820. let rows = await scheduleRows();
  821. check('after the sync: /my/schedule lists the new upcoming episodes of both shows, soonest first', JSON.stringify(rows) === JSON.stringify([
  822. 'Unwatched Gate Show (2025) | S01E01 · Brand New | 2097-01-01', 'Gate Test Show (2024) | S03E02 · Coming Soon | 2098-06-01', 'Gate Test Show (2024) | S02E01 · Season Two Opener | 2099-01-01']), JSON.stringify(rows));
  823. await page.goto(BASE + '/my/unwatched');
  824. await hydrated();
  825. rows = await ev(`Array.from(document.querySelectorAll('unwatched-row')).map(r => r.querySelector('unwatched-episode').textContent + ' | ' + r.querySelector('unwatched-date').textContent)`);
  826. check('after the sync: /my/unwatched lists the newly released episodes (S03E01, S01E03), newest first', JSON.stringify(rows) === JSON.stringify(['S03E01 · Third Season Start | 2021-05-05', 'S01E03 · Third | 2020-09-01']), JSON.stringify(rows));
  827. await page.goto(BASE + '/my/shows');
  828. await hydrated();
  829. await waitPostersLoaded();
  830. ms = await myShows();
  831. check('after the sync: /my/shows shows both real posters (/posters/sh2.png is new), loaded', ms.length === 2 && ms.every(m => m.posterLoaded) && ms[0].poster === '/posters/sh1.png' && ms[1].poster === '/posters/sh2.png', JSON.stringify(ms));
  832. r = await fetch(BASE + '/posters/sh2.png');
  833. const posterBytes = Buffer.from(await r.arrayBuffer());
  834. check('the downloaded poster is served by /posters/… byte for byte (image/png), stored in storage (mpackdb/posters/)', r.status === 200 && r.headers.get('content-type') === 'image/png' && posterBytes.equals(PNG) && existsSync(join(MPACKDB, 'posters', 'sh2.png')));
  835. await page.goto(BASE + '/shows/gate-test-show');
  836. await hydrated();
  837. const seasonCounts = await ev(`Array.from(document.querySelectorAll('season-row')).map(e => e.querySelector('season-label').textContent + ': ' + e.querySelector('season-count').textContent)`);
  838. check('after the sync: the show page has S3 (new) S2 S1, no season 0, no duplicated episode (S1 has 3)', JSON.stringify(seasonCounts) === JSON.stringify(['Season 3: 2 episodes', 'Season 2: 1 episode', 'Season 1: 3 episodes']), JSON.stringify(seasonCounts));
  839. check('after the sync: the latest season shows its new episodes, the poster is the downloaded one',
  840. JSON.stringify(await ev(`Array.from(document.querySelectorAll('episode-row episode-title')).map(e => e.textContent)`)) === JSON.stringify(['Third Season Start', 'Coming Soon'])
  841. && (await ev(`document.querySelector('show-poster img').getAttribute('src')`)) === '/posters/sh1.png' && (await ev(`document.querySelector('show-poster img').naturalWidth`)) > 0);
  842. await clickValue('button.season-toggle', SEASON1);
  843. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  844. const s1 = await ev(`Array.from(document.querySelectorAll('episode-row')).map(e => e.querySelector('episode-number').textContent + ' ' + e.querySelector('episode-title').textContent + ' ' + e.querySelector('button.checkicon').getAttribute('class'))`);
  845. check('after the sync: S1 keeps its episodes (same ids, still watched), E2 renamed from TMDB, E3 new and unwatched',
  846. s1.includes('1 Pilot checkicon solid') && s1.includes('2 Second Episode checkicon solid') && s1.includes('3 Third checkicon regular') && s1.length === 5, JSON.stringify(s1));
  847. // ---- tracker.worldapi.org#12: after the sync every show has the ids it lacked — the stored ones unchanged ----
  848. await page.goto(BASE + '/shows/gate-test-show');
  849. await hydrated();
  850. lk = await extLinks();
  851. check('after the sync: show 1 has all four icons — its OWN imdb id kept (tt0000001, not TMDB\'s tt9999999), tvdb 4001 + TVmaze 501 filled',
  852. linksAre(lk, [['TMDB', TMDB_TV + '1001'], ['IMDb', IMDB + 'tt0000001/'], ['TVDB', TVDB + '4001'], ['TVmaze', TVMAZE + '501']]), lk);
  853. await page.goto(BASE + '/shows/unwatched-gate-show');
  854. await hydrated();
  855. lk = await extLinks();
  856. check('after the sync: show 2 has all four — imdb tt0000002 filled, its OWN tvdb 4002 kept (not 4999), TVmaze 502 (found by thetvdb after imdb missed)',
  857. linksAre(lk, [['TMDB', TMDB_TV + '1002'], ['IMDb', IMDB + 'tt0000002/'], ['TVDB', TVDB + '4002'], ['TVmaze', TVMAZE + '502']]), lk);
  858. await page.goto(BASE + '/shows/gate-test-movie');
  859. await hydrated();
  860. lk = await extLinks();
  861. check('after the sync: the movie has TMDB (/movie/1003) + IMDb (tt0000003 filled), no TheTVDB/TVmaze, no seasons',
  862. linksAre(lk, [['TMDB', TMDB_MOVIE + '1003'], ['IMDb', IMDB + 'tt0000003/']]) && (await ev(`document.querySelectorAll('season-row').length`)) === 0, lk);
  863. // ---- tracker.worldapi.org#13: the public lists are built at start — after the sync (tool, then a restart) show 1's new
  864. // S03E01 (2021-05-05) puts it before Pager Older Series (2021-01-01); show 2 has only an upcoming episode: still last
  865. await page.goto(BASE + '/shows');
  866. await hydrated();
  867. tl = await titlesOf('tile-grid');
  868. check('after the sync + restart: /shows by newest released episode — Gate Test Show (S03E01 2021-05-05) first now, its synced poster; Unwatched Gate Show (only upcoming) last',
  869. JSON.stringify(tl) === JSON.stringify(['Gate Test Show', 'Pager Older Series', 'Unwatched Gate Show']) && (await tilesOf('tile-grid'))[0].endsWith('|/posters/sh1.png'), JSON.stringify(tl));
  870. if (process.env.TRACKER_GATE_SHOTS) {
  871. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
  872. for (const [name, path] of [['synced-show-390', '/shows/gate-test-show'], ['synced-movie-390', '/shows/gate-test-movie']]) {
  873. await page.goto(BASE + path);
  874. await hydrated();
  875. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, name + '.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  876. }
  877. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  878. for (const [name, path] of [['synced-show', '/shows/gate-test-show'], ['synced-movie', '/shows/gate-test-movie'], ['synced-my-shows', '/my/shows'], ['synced-my-unwatched', '/my/unwatched'], ['synced-my-schedule', '/my/schedule']]) {
  879. await page.goto(BASE + path);
  880. await hydrated();
  881. if (name === 'synced-my-shows') await waitPostersLoaded();
  882. const shot = await page.send('Page.captureScreenshot', { format: 'png' });
  883. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, name + '.png'), Buffer.from(shot.data, 'base64'));
  884. }
  885. await page.send('Emulation.clearDeviceMetricsOverride', {});
  886. }
  887. // ---- THE DAILY RUN INSIDE THE APP: TRACKER_SYNC_HOUR = this very UTC hour, so the app's own clock starts the
  888. // run right after boot. TMDB now has one more episode and answers slowly (700 ms per request): the run takes
  889. // seconds, and pages asked for meanwhile must still be answered between two shows, never after the whole run.
  890. await page.goto('about:blank');
  891. await stopServer();
  892. tmdb.shows[1001].seasons[3].episodes.push({ id: 5007, episode_number: 3, name: 'Added Live', air_date: '2096-03-03' });
  893. // tracker.worldapi.org#13: and show 2 an episode that is ALREADY OUT — the running app must move it to the top of /shows
  894. tmdb.shows[1002].seasons[0].episodes.push({ id: 7002, episode_number: 2, name: 'Out Already', air_date: '2022-02-02' });
  895. tmdb.delayMs = 700;
  896. const before = tmdb.requests.length, tvmazeBeforeRun = tmdb.tvmazeRequests.length;
  897. await startServer({ TRACKER_SYNC: '1', TRACKER_SYNC_HOUR: String(new Date().getUTCHours()) });
  898. const lat = [];
  899. const t0 = Date.now();
  900. while (!log.includes('tmdb sync done') && Date.now() - t0 < 30000) {
  901. const a = Date.now();
  902. await fetch(BASE + '/');
  903. lat.push({ at: a - t0, ms: Date.now() - a, running: log.includes('tmdb sync: start') && !log.includes('tmdb sync done') });
  904. await new Promise(r => setTimeout(r, 150));
  905. }
  906. const runDone = (log.match(/tmdb sync done: .*/) || [''])[0];
  907. check('in-app daily run: started by the app\'s own clock at the configured hour, found the 2 new episodes, 0 errors',
  908. log.includes('tmdb sync: daily at ' + new Date().getUTCHours() + ':00 UTC') && log.includes('tmdb sync: start, 3 followed shows') && runDone.includes('newSeasons=0 newEpisodes=2 updatedEpisodes=0') && runDone.includes('errors=0'), log);
  909. const during = lat.filter(l => l.running);
  910. const runSecs = Number((runDone.match(/seconds=([0-9.]+)/) || [0, 0])[1]);
  911. console.log(` (in-app run ${runSecs} s; ${during.length} pages asked during it, slowest ${Math.max(0, ...during.map(l => l.ms))} ms)`);
  912. check('in-app daily run: the server kept answering DURING the run (between shows) — every page < 2.5 s though the run took ≥ 3 s',
  913. runSecs >= 3 && during.length >= 3 && during.every(l => l.ms < 2500), 'run ' + runSecs + ' s, during: ' + JSON.stringify(during));
  914. check('in-app daily run: the Bearer token on every call, 5 requests (2 per series, 1 for the movie; no poster again: unchanged), no TVmaze lookup, only the 2 episode lists (#15)', tmdb.requests.slice(before).filter(q => q.path.startsWith('/3/')).every(q => q.authorization === 'Bearer gate-token') && tmdb.requests.length - before === 5
  915. && JSON.stringify(tmdb.tvmazeRequests.slice(tvmazeBeforeRun).sort()) === JSON.stringify(['/tvmaze/shows/501/episodes', '/tvmaze/shows/502/episodes']), JSON.stringify(tmdb.requests.slice(before)) + JSON.stringify(tmdb.tvmazeRequests.slice(tvmazeBeforeRun)));
  916. await page.goto(BASE + '/my/schedule');
  917. await hydrated();
  918. rows = await scheduleRows();
  919. check('after the in-app run: /my/schedule has the episode TMDB added meanwhile (S03E03, 2096-03-03) on top', rows.length === 4 && rows[0] === 'Gate Test Show (2024) | S03E03 · Added Live | 2096-03-03', JSON.stringify(rows));
  920. await page.goto(BASE + '/shows');
  921. await hydrated();
  922. tl = await titlesOf('tile-grid');
  923. check('after the in-app run (no restart): /shows has Unwatched Gate Show on top — its new S01E02 (2022-02-02) is out; the homepage row too',
  924. JSON.stringify(tl) === JSON.stringify(['Unwatched Gate Show', 'Gate Test Show', 'Pager Older Series']), JSON.stringify(tl));
  925. await page.goto(BASE + '/');
  926. await hydrated();
  927. tl = await titlesOf('#home-shows');
  928. check('after the in-app run: the homepage\'s Shows row has it first as well', JSON.stringify(tl) === JSON.stringify(['Unwatched Gate Show', 'Gate Test Show', 'Pager Older Series']), JSON.stringify(tl));
  929. // ==== tracker.worldapi.org#15: THE TVMAZE MERGE — TVmaze knows more than TMDB: show 1 (TVmaze 501) gets S3E4's real
  930. // title + air date (TMDB: "Episode 4", no date), a new S3E5 and a new season 4; TVmaze's other name for S3E1 must NOT
  931. // replace TMDB's; its S1E1 airs on another day but has the same title (still agrees). Show 2 (502) TVmaze numbers
  932. // differently (S1E1 another title, 7 years off — like a split pilot) → TVmaze is ignored for it. ====
  933. await page.goto('about:blank');
  934. await stopServer();
  935. tmdb.delayMs = 0;
  936. tmdb.shows[1001].seasons[3].episodes.push({ id: 5008, episode_number: 4, name: 'Episode 4' });
  937. const tvm501 = tmdb.tvmaze.episodes[501].filter(e => !(e.season === 3 && e.number === 1));
  938. tvm501.push({ id: 8005, season: 3, number: 1, name: 'Other Name From TVmaze', airdate: '2021-05-05' }, { id: 8007, season: 3, number: 3, name: 'Added Live', airdate: '2096-03-03' },
  939. { id: 8008, season: 3, number: 4, name: 'Real Fourth', airdate: '2096-04-04' }, { id: 8009, season: 3, number: 5, name: 'Only On TVmaze', airdate: '2096-05-05' },
  940. { id: 8010, season: 4, number: 1, name: 'New Season Premiere', airdate: '2096-06-06' });
  941. tmdb.tvmaze.episodes[501] = tvm501;
  942. tmdb.tvmaze.episodes[502] = [{ id: 8101, season: 1, number: 1, name: 'Brand New Pre-Pilot', airdate: '2090-01-01' }, { id: 8102, season: 1, number: 2, name: 'Should Not Appear', airdate: '2097-02-01' }];
  943. // a date that differs (another country's premiere) but the SAME title still counts as the same numbering
  944. tvm501.find(e => e.season === 1 && e.number === 1).airdate = '2019-12-20';
  945. out = await runTool();
  946. done = (out.match(/tmdb sync done: .*/) || [''])[0];
  947. check('tvmaze merge: TMDB adds S3E4 ("Episode 4", no date); TVmaze adds 2 episodes (S3E5, S4E1) + 1 season (S4), fills 1 episode (S3E4), skips show 2 (numbered differently), 0 errors',
  948. done.includes('newSeasons=0 newEpisodes=1 updatedEpisodes=0') && done.includes('tvmazeRequests=2 errors=0 tvmazeEpisodes=2 tvmazeSeasons=1 tvmazeFilled=1 tvmazeSkipped=1') && out.includes('SKIPPED (numbered differently)'), out);
  949. out = await runTool();
  950. done = (out.match(/tmdb sync done: .*/) || [''])[0];
  951. check('tvmaze merge again: idempotent — nothing new, nothing filled, no TMDB update (TMDB\'s "Episode 4" does not replace TVmaze\'s "Real Fourth")',
  952. done.includes('newSeasons=0 newEpisodes=0 updatedEpisodes=0 updatedSeasons=0') && done.includes('errors=0 tvmazeEpisodes=0 tvmazeSeasons=0 tvmazeFilled=0 tvmazeSkipped=1'), out);
  953. await startServer();
  954. await page.goto(BASE + '/my/schedule');
  955. await hydrated();
  956. rows = await scheduleRows();
  957. check('tvmaze merge: /my/schedule shows what only TVmaze has (S03E04 with its TVmaze title + date, S03E05, S04E01), in date order; nothing of show 2\'s differently numbered TVmaze list', JSON.stringify(rows) === JSON.stringify([
  958. 'Gate Test Show (2024) | S03E03 · Added Live | 2096-03-03', 'Gate Test Show (2024) | S03E04 · Real Fourth | 2096-04-04', 'Gate Test Show (2024) | S03E05 · Only On TVmaze | 2096-05-05',
  959. 'Gate Test Show (2024) | S04E01 · New Season Premiere | 2096-06-06', 'Unwatched Gate Show (2025) | S01E01 · Brand New | 2097-01-01',
  960. 'Gate Test Show (2024) | S03E02 · Coming Soon | 2098-06-01', 'Gate Test Show (2024) | S02E01 · Season Two Opener | 2099-01-01']), JSON.stringify(rows));
  961. const seasonsAndTitles = async () => {
  962. await page.goto(BASE + '/shows/gate-test-show');
  963. await hydrated();
  964. const seasons = await ev(`Array.from(document.querySelectorAll('season-row')).map(e => e.querySelector('season-label').textContent + ': ' + e.querySelector('season-count').textContent)`);
  965. await clickValue('button.season-toggle', await ev(`Array.from(document.querySelectorAll('season-row')).find(e => e.querySelector('season-label').textContent === 'Season 3').querySelector('button.season-toggle').value`));
  966. await page.waitFor(`document.querySelectorAll('episode-row').length >= 6`, { timeout: 10000, label: 'S3 expanded' });
  967. const titles = await ev(`Array.from(document.querySelectorAll('episode-row')).map(e => e.querySelector('episode-number').textContent + ' ' + e.querySelector('episode-title').textContent)`);
  968. return { seasons, titles };
  969. };
  970. let tst = await seasonsAndTitles();
  971. check('tvmaze merge: the show page has the new Season 4 (1 episode) and Season 3 with 5 — S3E1 keeps TMDB\'s title, S3E4 TVmaze\'s',
  972. JSON.stringify(tst.seasons) === JSON.stringify(['Season 4: 1 episode', 'Season 3: 5 episodes', 'Season 2: 1 episode', 'Season 1: 3 episodes'])
  973. && JSON.stringify(tst.titles) === JSON.stringify(['1 New Season Premiere', '1 Third Season Start', '2 Coming Soon', '3 Added Live', '4 Real Fourth', '5 Only On TVmaze']), JSON.stringify(tst));
  974. if (process.env.TRACKER_GATE_SHOTS) {
  975. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  976. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  977. for (const [name, path] of [['tvmaze-schedule', '/my/schedule'], ['tvmaze-show', '/shows/gate-test-show']]) {
  978. await page.goto(BASE + path);
  979. await hydrated();
  980. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `${name}-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  981. }
  982. }
  983. await page.send('Emulation.clearDeviceMetricsOverride', {});
  984. }
  985. // TMDB catches up: it now lists S3E5 (its own title) and season 4 — the TMDB step ADOPTS TVmaze's rows by number (no
  986. // duplicate), TMDB's values win; its "Episode 4" still never replaces "Real Fourth"
  987. await page.goto('about:blank');
  988. await stopServer();
  989. tmdb.shows[1001].seasons[3].episodes.push({ id: 5009, episode_number: 5, name: 'Official Fifth', air_date: '2096-05-05' });
  990. tmdb.shows[1001].seasons.push({ id: 95, season_number: 4, name: 'Season 4', air_date: '2096-06-06', episodes: [{ id: 5010, episode_number: 1, name: 'New Season Premiere', air_date: '2096-06-06' }] });
  991. out = await runTool();
  992. done = (out.match(/tmdb sync done: .*/) || [''])[0];
  993. check('tvmaze merge, TMDB catches up: TMDB adopts the 2 TVmaze episodes (updatedEpisodes=2: tmdbId set, S3E5 renamed), creates nothing, TVmaze adds nothing',
  994. done.includes('newSeasons=0 newEpisodes=0 updatedEpisodes=2 updatedSeasons=0') && done.includes('errors=0 tvmazeEpisodes=0 tvmazeSeasons=0 tvmazeFilled=0'), out);
  995. await startServer();
  996. tst = await seasonsAndTitles();
  997. check('tvmaze merge, TMDB caught up: still S4 1 / S3 5 episodes (no duplicates), S3E5 now TMDB\'s "Official Fifth", S3E4 still "Real Fourth"',
  998. JSON.stringify(tst.seasons) === JSON.stringify(['Season 4: 1 episode', 'Season 3: 5 episodes', 'Season 2: 1 episode', 'Season 1: 3 episodes'])
  999. && JSON.stringify(tst.titles) === JSON.stringify(['1 New Season Premiere', '1 Third Season Start', '2 Coming Soon', '3 Added Live', '4 Real Fourth', '5 Official Fifth']), JSON.stringify(tst));
  1000. // ==== tracker.worldapi.org#14: THE SEARCH, signed IN — "Add" imports the title like the daily sync and opens its page ====
  1001. tmdb.delayMs = 0;
  1002. await page.goto(BASE + '/search/gate');
  1003. await hydrated();
  1004. await page.click('#fetchweb');
  1005. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000, label: 'web results (signed in)' });
  1006. const dbBefore = await searchTitles();
  1007. // hybriel#121/#122: a SESSION face (searchImport) that stays on the page — tv 1006's details answer 404 — must leave every
  1008. // list as it was: each row once, the route-param text still in the field and in data-for
  1009. await page.click('web-row button.add[value="tv:1006"]');
  1010. await page.waitFor(`!!document.querySelector('#importerror')`, { timeout: 10000, label: 'import error' }).catch(() => {});
  1011. await new Promise(r => setTimeout(r, 300));
  1012. check('search import: a failed import (TMDB 404) says so and stays — the database list, the web list and the text are unchanged, no row twice (hybriel#121/#122)',
  1013. (await ev(`document.querySelector('#importerror') ? document.querySelector('#importerror').textContent : ''`)) === 'Could not add it: TMDB: HTTP 404 for /tv/1006'
  1014. && (await searchTitles()) === dbBefore && dbBefore === JSON.stringify(GATE_TITLES) && (await webList()) === JSON.stringify(GATE_WEB)
  1015. && (await ev(`document.querySelector('#searchinput').value`)) === 'gate' && (await searchFor()) === 'gate' && (await ev('location.pathname')) === '/search/gate',
  1016. (await ev(`document.querySelector('search-page').outerHTML.slice(0, 1500)`)));
  1017. await typeSearch('gate t');
  1018. check('search import: after that session face the page still searches (typing "gate t" → the two "Gate Test" titles)', (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)), await searchTitles());
  1019. await typeSearch('gate');
  1020. await page.click('#fetchweb');
  1021. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000 });
  1022. const reqBefore = tmdb.requests.length;
  1023. await page.click('web-row button.add[value="tv:1004"]');
  1024. await page.waitFor(`location.pathname !== '/search/gate' && !!document.querySelector('show-header h1')`, { timeout: 15000, label: 'imported show page' });
  1025. await page.waitFor(`!!document.querySelector('show-poster img') && document.querySelector('show-poster img').complete`, { timeout: 5000 }).catch(() => {});
  1026. const imp = JSON.parse(await ev(`JSON.stringify({ path: location.pathname, h1: document.querySelector('show-header h1').textContent,
  1027. seasons: Array.from(document.querySelectorAll('season-row')).map(r => r.querySelector('season-label').textContent + ': ' + r.querySelector('season-count').textContent),
  1028. episodes: Array.from(document.querySelectorAll('episode-row episode-title')).map(e => e.textContent), genres: Array.from(document.querySelectorAll('.genre-pill')).map(g => g.textContent),
  1029. poster: document.querySelector('show-poster img') ? document.querySelector('show-poster img').getAttribute('src') : null,
  1030. posterW: document.querySelector('show-poster img') ? document.querySelector('show-poster img').naturalWidth : 0, summary: document.querySelector('show-summary').textContent })`));
  1031. lk = await extLinks();
  1032. check('search import: "Add" on tv 1004 adds it and opens its page /shows/Gate-Import-Show — season, episodes, poster (downloaded), genres matched by name, the summary',
  1033. imp.path === '/shows/Gate-Import-Show' && imp.h1 === 'Gate Import Show' && JSON.stringify(imp.seasons) === JSON.stringify(['Season 1: 2 episodes'])
  1034. && JSON.stringify(imp.episodes) === JSON.stringify(['Imported One', 'Imported Two']) && JSON.stringify(imp.genres) === JSON.stringify(['Drama'])
  1035. && imp.poster === '/posters/tmdb-tv-1004.png' && imp.posterW > 0 && imp.summary === 'an imported series' && existsSync(join(MPACKDB, 'posters', 'tmdb-tv-1004.png')), JSON.stringify(imp));
  1036. check('search import: the ids came like the daily sync\'s — TMDB, IMDb tt0000004, TVDB 4004, TVmaze 504 (looked up by thetvdb)',
  1037. linksAre(lk, [['TMDB', TMDB_TV + '1004'], ['IMDb', IMDB + 'tt0000004/'], ['TVDB', TVDB + '4004'], ['TVmaze', TVMAZE + '504']]), lk);
  1038. const impPaths = tmdb.requests.slice(reqBefore).map(q => q.path);
  1039. check('search import: TMDB asked exactly like the sync (details, seasons, poster) + one details request for the new record, all with the token',
  1040. JSON.stringify(impPaths) === JSON.stringify(['/3/tv/1004', '/3/tv/1004?append_to_response=external_ids', '/3/tv/1004?append_to_response=season/1', '/t/p/w342/gate4.png'])
  1041. && tmdb.requests.slice(reqBefore).filter(q => q.path.startsWith('/3/')).every(q => q.authorization === 'Bearer gate-token'), JSON.stringify(impPaths));
  1042. await page.goto(BASE + '/search/gate');
  1043. await hydrated();
  1044. st14 = await searchTitles();
  1045. check('search import: our database has it at once (the index too): "gate" now also lists Gate Import Show (2023)',
  1046. st14 === JSON.stringify([GATE_TITLES[0], GATE_TITLES[1], ['Gate Import Show (2023)', '/shows/Gate-Import-Show', 'Series'], GATE_TITLES[2]]), st14);
  1047. await page.click('#fetchweb');
  1048. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000 });
  1049. wl = await webList();
  1050. check('search import: the web list leaves it out now (we have it)', wl === JSON.stringify(GATE_WEB.slice(1)), wl);
  1051. await page.click('web-row button.add[value="movie:1005"]');
  1052. await page.waitFor(`location.pathname === '/shows/Gate-Import-Movie' && !!document.querySelector('show-header h1')`, { timeout: 15000, label: 'imported movie page' });
  1053. lk = await extLinks();
  1054. check('search import: a MOVIE (movie 1005) → /shows/Gate-Import-Movie, synced via /movie/ (TMDB /movie/1005 + IMDb), no seasons',
  1055. (await ev(`document.querySelector('show-header h1').textContent`)) === 'Gate Import Movie' && linksAre(lk, [['TMDB', TMDB_MOVIE + '1005'], ['IMDb', IMDB + 'tt0000005/']])
  1056. && (await ev(`document.querySelectorAll('season-row').length`)) === 0 && !tmdb.requests.some(q => q.path.startsWith('/3/tv/1005')), lk);
  1057. await page.goto(BASE + '/search/gate%20import');
  1058. await hydrated();
  1059. st14 = await searchTitles();
  1060. check('search import: both imported titles are found, each once', st14 === JSON.stringify([['Gate Import Show (2023)', '/shows/Gate-Import-Show', 'Series'], ['Gate Import Movie (2001)', '/shows/Gate-Import-Movie', 'Movie']]), st14);
  1061. // merge of #13 + #14: an imported title is in the public lists at once (catalog.hl refreshCatalogShow), no restart
  1062. await page.goto(BASE + '/movies');
  1063. await hydrated();
  1064. titles = await titlesOf('tile-grid');
  1065. await page.goto(BASE + '/shows');
  1066. await hydrated();
  1067. tl = await titlesOf('tile-grid');
  1068. check('search import → the lists at once: /movies has Gate Import Movie (2001-05-05) right after the 2099 one, /shows has Gate Import Show',
  1069. titles[0] === 'Pager Future Movie' && titles[1] === 'Gate Import Movie' && titles.filter(t => t === 'Gate Import Movie').length === 1 && tl.filter(t => t === 'Gate Import Show').length === 1, JSON.stringify({ titles, tl }));
  1070. if (process.env.TRACKER_GATE_SHOTS) {
  1071. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  1072. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  1073. await page.goto(BASE + '/search/gate');
  1074. await hydrated();
  1075. await page.click('#fetchweb');
  1076. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000 });
  1077. await page.waitFor(`Array.from(document.querySelectorAll('img')).every(i => i.complete)`, { timeout: 5000 }).catch(() => {});
  1078. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `search-web-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1079. }
  1080. await page.send('Emulation.clearDeviceMetricsOverride', {});
  1081. }
  1082. check('search: the server log has no error (the imports logged their sync counts)', !/error absorbed/i.test(log) && (log.match(/search import: /g) || []).length === 2, log.split('\n').filter(l => /search|error/i.test(l)).join('\n'));
  1083. // ==== mission 054: ADULT TITLES — the public lists and the search show only titles TMDB says are NOT adult (unknown =
  1084. // hidden); a followed adult title stays on /my/* and its page; the in-app BACKFILL asks TMDB title by title, resumes
  1085. // after a restart, and pages stay fast meanwhile. tests/seed-adult.hl adds the titles (app stopped) ====
  1086. await page.goto('about:blank'); // mission 056: a tab left on a page logs websocket reconnect errors while the server is down
  1087. await stopServer();
  1088. const seedAdult = execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-adult.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_USER: 'gateu0000000000u1' } }).toString();
  1089. check('adult: fixture written (1 adult title alice follows, 14 with an unknown flag)', seedAdult.includes('15 titles') && seedAdult.includes('follows Adult Gate Movie'), seedAdult);
  1090. await startServer();
  1091. const ADULT_ALL = ['Adult Gate Movie', 'Flagless Gate Movie', 'Flagless Adult Movie', 'Flagless Gate Series', 'Gone Gate Movie', 'Backfill Movie'];
  1092. // the signed-OUT HTML of a page (node fetch, no cookie): which of these titles it names
  1093. const namesIn = async (path) => { const h = await (await fetch(BASE + path)).text(); return ADULT_ALL.filter(t => h.includes(t)); };
  1094. const allMoviesHtml = async () => { let h = ''; for (const p of ['/movies', '/movies/page/2']) h += await (await fetch(BASE + p)).text(); return h; };
  1095. const mh = await allMoviesHtml();
  1096. let seen = { home: await namesIn('/'), movies: ADULT_ALL.filter(t => mh.includes(t)), shows: await namesIn('/shows') };
  1097. check('adult (backfill off): signed out, the homepage, every /movies page and /shows list NONE of the adult/unknown titles; the count is unchanged (34 movies)',
  1098. seen.home.length === 0 && seen.movies.length === 0 && seen.shows.length === 0 && mh.includes('34 movies, newest first'), JSON.stringify(seen));
  1099. let sr = await (await fetch(BASE + '/search/flagless')).text();
  1100. const sr2 = await (await fetch(BASE + '/search/adult%20gate')).text();
  1101. check('adult (backfill off): the search finds none of them ("flagless", "adult gate" → "Nothing in our database yet.")',
  1102. sr.includes('Nothing in our database yet.') && !sr.includes('class="search-title"') && sr2.includes('Nothing in our database yet.'), sr.slice(sr.indexOf('<search-page'), sr.indexOf('<search-page') + 600));
  1103. await page.goto(BASE + '/my/movies');
  1104. await hydrated();
  1105. const myMv = await ev(`JSON.stringify(Array.from(document.querySelectorAll('my-show-row')).map(r => r.querySelector('a.myshow-title').textContent))`);
  1106. check('adult: alice follows the adult movie → it IS on her /my/movies (newest follow first)', JSON.parse(myMv)[0] === 'Adult Gate Movie (2001)', myMv);
  1107. await page.click('a.myshow-title[href="/shows/adult-gate-movie"]');
  1108. await page.waitFor(`location.pathname === '/shows/adult-gate-movie' && !!document.querySelector('show-page')`, { timeout: 10000, label: 'adult page (alice)' });
  1109. await page.waitFor(`!!document.querySelector('show-header h1')`, { timeout: 5000 }).catch(() => {});
  1110. check('adult: …and its page opens for her (client-side from /my/movies, and on a reload) with "Following"',
  1111. (await ev(`document.querySelector('show-header h1') ? document.querySelector('show-header h1').textContent : 'none'`)) === 'Adult Gate Movie'
  1112. && (await (async () => { await page.goto(BASE + '/shows/adult-gate-movie'); await hydrated(); return ev(`(document.querySelector('show-header h1') || {}).textContent + '|' + (document.querySelector('#follow') || {}).textContent`); })()) === 'Adult Gate Movie|Following');
  1113. // mission 056: the adult title's PAGE is "Not found" for anyone who does not follow it — signed out (no cookie) here
  1114. const adultOut = await (await fetch(BASE + '/shows/adult-gate-movie')).text();
  1115. const pageH1 = (h) => (h.match(/<show-page[^>]*>[\s\S]*?<h1[^>]*>([^<]*)<\/h1>/) || [])[1];
  1116. check('adult (056): signed out, /shows/adult-gate-movie says "Not found" and names no title (a non-adult page still opens: Flagless Gate Movie is unknown, not hidden)',
  1117. pageH1(adultOut) === 'Not found' && !adultOut.includes('Adult Gate Movie') && pageH1(await (await fetch(BASE + '/shows/flagless-gate-movie')).text()) === 'Flagless Gate Movie', pageH1(adultOut));
  1118. await page.goto(BASE + '/');
  1119. await hydrated();
  1120. const homeIn = await ev(`document.querySelector('home').textContent`);
  1121. check('adult: signed in too, the homepage rows have none of them (only /my/* is personal)', ADULT_ALL.every(t => !homeIn.includes(t)), homeIn);
  1122. await page.goto(BASE + '/search/gate');
  1123. await hydrated();
  1124. const wsBefore = tmdb.searchRequests.length;
  1125. await page.click('#fetchweb');
  1126. await page.waitFor(`document.querySelectorAll('web-row').length > 0 || !!document.querySelector('#webnote')`, { timeout: 10000, label: 'web results (adult)' });
  1127. wl = await webList();
  1128. check('adult: "Fetch from web" asks TMDB with include_adult=false and leaves out the result TMDB marks adult (Gate Adult Web)',
  1129. !wl.includes('Gate Adult Web') && wl.includes('Gate Broken Show') && tmdb.searchRequests.length === wsBefore + 1 && tmdb.searchRequests[wsBefore].path.includes('include_adult=false'), wl);
  1130. check('adult (backfill off): the log says so, no TMDB request was made for the unknown titles', log.includes('adult backfill: off (TRACKER_BACKFILL=0)') && !tmdb.requests.some(q => /\/3\/(movie|tv)\/(1012|1013|1014|1120|11\d\d)/.test(q.path)), log);
  1131. // ---- the BACKFILL: every answer slowed down to 300 ms; stopped after 6 titles, started again → it goes on with the rest ----
  1132. await page.goto('about:blank'); // mission 056: a tab left on a page logs websocket reconnect errors while the server is down
  1133. await stopServer();
  1134. tmdb.delayMs = 300;
  1135. const bfReq = tmdb.requests.length;
  1136. await startServer({ TRACKER_BACKFILL: '1', TRACKER_BACKFILL_LOG_EVERY: '3' });
  1137. const waitLog = async (re, ms) => { const t0 = Date.now(); while (!re.test(log) && Date.now() - t0 < ms) await new Promise(r => setTimeout(r, 100)); return re.test(log); };
  1138. const got6 = await waitLog(/adult backfill: 6\/14 titles/, 30000);
  1139. const log1 = log;
  1140. await page.goto('about:blank'); // mission 056: a tab left on a page logs websocket reconnect errors while the server is down
  1141. await stopServer();
  1142. const asked1 = tmdb.requests.slice(bfReq).map(q => q.path).filter(p => /^\/3\/(movie|tv)\//.test(p));
  1143. check('backfill: started with the 14 unknown titles, logs every 3 (TRACKER_BACKFILL_LOG_EVERY), one details request per title (+ external_ids), with the token',
  1144. got6 && log1.includes('adult backfill: start, 14 titles without an adult flag') && /adult backfill: 3\/14 titles/.test(log1)
  1145. && asked1.every(p => p.endsWith('?append_to_response=external_ids')) && tmdb.requests.slice(bfReq).filter(q => q.path.startsWith('/3/')).every(q => q.authorization === 'Bearer gate-token'), log1.split('\n').filter(l => /backfill/.test(l)).join('\n'));
  1146. const bfReq2 = tmdb.requests.length;
  1147. await startServer({ TRACKER_BACKFILL: '1', TRACKER_BACKFILL_LOG_EVERY: '3' });
  1148. // the start line comes with the clock's first tick, which can be after the first page answer (mission 056: it was)
  1149. await waitLog(/adult backfill: start, \d+ titles/, 5000);
  1150. const left = Number((log.match(/adult backfill: start, (\d+) titles/) || [])[1]);
  1151. // pages DURING the run: signed out, the homepage, /movies and a search, back to back, timed
  1152. const during54 = [];
  1153. const t054 = Date.now();
  1154. while (!/adult backfill done/.test(log) && Date.now() - t054 < 60000) {
  1155. for (const p of ['/', '/movies', '/search/backfill']) { const t = Date.now(); await (await fetch(BASE + p)).text(); during54.push({ p, ms: Date.now() - t }); }
  1156. }
  1157. const bfSecs = (Date.now() - t054) / 1000;
  1158. const asked2 = tmdb.requests.slice(bfReq2).map(q => q.path).filter(p => /^\/3\/(movie|tv)\//.test(p));
  1159. const askedIds = [...asked1, ...asked2].map(p => p.match(/\/3\/(?:movie|tv)\/(\d+)/)[1]);
  1160. const twice = askedIds.filter((x, k) => askedIds.indexOf(x) !== k);
  1161. const doneLine = (log.match(/adult backfill done: .*/) || [''])[0];
  1162. console.log(` (backfill second start: ${left} titles left, ${bfSecs} s; ${during54.length} pages asked during it, slowest ${Math.max(0, ...during54.map(l => l.ms))} ms; ${doneLine})`);
  1163. check('backfill RESUMES after a restart: the second start finds only the titles still unknown (≤ 8 of 14), every one of the 14 was asked, none twice except at most the one cut off by the stop',
  1164. left >= 1 && left <= 8 && new Set(askedIds).size === 14 && twice.length <= 1 && asked1.length + asked2.length - twice.length === 14, JSON.stringify({ left, asked1, asked2, twice }));
  1165. check('backfill: done — it stops when nothing is unknown (the done line with its counts); the 404 (1120) was logged',
  1166. /^adult backfill done: \d+\/\d+ titles, adult=\d+ notAdult=\d+ unknown=\d+ posters=\d+ ids=\d+ requests=\d+ errors=\d+ retries=0 seconds=/.test(doneLine) && (log1 + log).includes('HTTP 404 for /movie/1120'), (log1 + log).split('\n').filter(l => /backfill/.test(l)).join('\n'));
  1167. check('backfill: pages stay fast during the run (300 ms per TMDB answer) — every page < 1.2 s, and pages were asked during it',
  1168. during54.length >= 6 && during54.every(l => l.ms < 1200), JSON.stringify(during54.filter(l => l.ms >= 500)));
  1169. // the lists updated AS the flags arrived (no restart since the run)
  1170. seen = { home: await namesIn('/'), shows: await namesIn('/shows') };
  1171. const mh2 = await allMoviesHtml();
  1172. seen.movies = ADULT_ALL.filter(t => mh2.includes(t));
  1173. check('backfill → the lists at once (no restart): /movies has Flagless Gate Movie + the 10 Backfill Movies (45 movies), NOT the adult ones or the 404; /shows and the homepage have Flagless Gate Series (+ the homepage Flagless Gate Movie)',
  1174. JSON.stringify(seen.movies) === JSON.stringify(['Flagless Gate Movie', 'Backfill Movie']) && mh2.includes('Backfill Movie 10') && mh2.includes('Backfill Movie 01') && mh2.includes('45 movies, newest first')
  1175. && JSON.stringify(seen.shows) === JSON.stringify(['Flagless Gate Series']) && JSON.stringify(seen.home) === JSON.stringify(['Flagless Gate Movie', 'Flagless Gate Series']), JSON.stringify(seen));
  1176. sr = await (await fetch(BASE + '/search/flagless')).text();
  1177. check('backfill → the search at once: "flagless" finds Flagless Gate Series + Flagless Gate Movie, not Flagless Adult Movie',
  1178. sr.includes('Flagless Gate Movie') && sr.includes('Flagless Gate Series') && !sr.includes('Flagless Adult Movie'), sr.slice(sr.indexOf('<search-page'), sr.indexOf('<search-page') + 900));
  1179. check('backfill: the ADULT title\'s poster is NOT downloaded (Flagless Adult Movie: asked, but no /t/p/w342/flag13.png request, no posters/admvfa.* file)',
  1180. !tmdb.requests.some(q => q.path.includes('flag13')) && !readdirSync(join(MPACKDB, 'posters')).some(f => f.startsWith('admvfa.')) && tmdb.requests.some(q => q.path.startsWith('/3/movie/1013?')),
  1181. (log1 + log).split('\n').filter(l => /backfill/.test(l)).join('\n'));
  1182. const pr = await fetch(BASE + '/posters/admvfl.png');
  1183. check('backfill: the poster was downloaded and is served (/posters/admvfl.png, image/png)', pr.status === 200 && pr.headers.get('content-type') === 'image/png' && existsSync(join(MPACKDB, 'posters', 'admvfl.png')));
  1184. // a third start: nothing unknown is left that TMDB can answer (the 404 is marked) → no request at all
  1185. await page.goto('about:blank'); // mission 056: a tab left on a page logs websocket reconnect errors while the server is down
  1186. await stopServer();
  1187. const bfReq3 = tmdb.requests.length;
  1188. tmdb.delayMs = 0;
  1189. await startServer({ TRACKER_BACKFILL: '1' });
  1190. await waitLog(/adult backfill: nothing to do/, 5000);
  1191. await new Promise(r => setTimeout(r, 500));
  1192. check('backfill: a restart afterwards has nothing to do (the 404 title is marked, not asked again) — no TMDB request',
  1193. log.includes('adult backfill: start, 0 titles') && log.includes('adult backfill: nothing to do') && tmdb.requests.length === bfReq3, log.split('\n').filter(l => /backfill/.test(l)).join('\n'));
  1194. check('adult: the server log has no error', !/error absorbed/i.test(log), log);
  1195. await page.goto(BASE + '/');
  1196. await hydrated();
  1197. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'adult-home-1280.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1198. // mission 056: alice UNFOLLOWS the adult movie on its page (a session face → hl:web's session sync re-derives `found` with
  1199. // the page's slug, hybriel#122): she no longer follows it → "Not found" at once, on a reload, and it leaves /my/movies
  1200. await page.goto(BASE + '/shows/adult-gate-movie');
  1201. await hydrated();
  1202. const adultBefore = await ev(`(document.querySelector('show-header h1') || {}).textContent + '|' + (document.querySelector('#follow') || {}).textContent`);
  1203. await page.click('#follow');
  1204. const adultGone = await page.waitFor(`(document.querySelector('show-page h1') || {}).textContent === 'Not found'`, { timeout: 5000, label: 'adult page hidden after unfollow' }).then(() => true, () => false);
  1205. const adultAfter = await ev(`(document.querySelector('show-page h1') || {}).textContent + '|' + ((document.querySelector('#follow') || {}).textContent || '')`);
  1206. await page.goto(BASE + '/shows/adult-gate-movie');
  1207. await hydrated();
  1208. const adultReload = await ev(`(document.querySelector('show-page h1') || {}).textContent`);
  1209. await page.goto(BASE + '/my/movies');
  1210. await hydrated();
  1211. const myMv2 = await ev(`Array.from(document.querySelectorAll('a.myshow-title')).map(a => a.textContent).join(',')`);
  1212. check('adult (056): alice sees the adult page while she follows it; after Unfollow ON the page it says "Not found" (no reload), on a reload too, and it is off her /my/movies',
  1213. adultBefore === 'Adult Gate Movie|Following' && adultGone && adultAfter === 'Not found|' && adultReload === 'Not found' && !myMv2.includes('Adult Gate Movie'), JSON.stringify({ adultBefore, adultAfter, adultReload, myMv2 }));
  1214. // and a normal show page still works after a session face (mission 054's failure: the browser re-derived `found` → `rows`)
  1215. await page.goto(BASE + '/shows/gate-test-show');
  1216. await hydrated();
  1217. const ep56 = await ev(`(() => { const b = document.querySelector('episode-row button.checkicon'); return b ? b.value + '|' + b.className : null; })()`);
  1218. await page.click('episode-row button.checkicon');
  1219. const ep56ok = await page.waitFor(`document.querySelector('episode-row button.checkicon').className !== ${JSON.stringify((ep56 || '|').split('|')[1])}`, { timeout: 5000, label: 'episode toggled (056)' }).then(() => true, () => false);
  1220. await page.click('episode-row button.checkicon');
  1221. const ep56back = await page.waitFor(`document.querySelector('episode-row button.checkicon').className === ${JSON.stringify((ep56 || '|').split('|')[1])}`, { timeout: 5000, label: 'episode toggled back (056)' }).then(() => true, () => false);
  1222. check('adult (056): on a normal show page an episode check toggles and toggles back (the session face + sync with the new `found`)', ep56 !== null && ep56ok && ep56back, ep56);
  1223. // ==== tracker.worldapi.org#16: PERSON PAGES — a complete person at once; an incomplete one fetches TMDB on the first visit
  1224. // ("Loading filmography…", filled without a reload, step by step), titles added once, adult hidden, the links, client-side
  1225. // navigation show → person → show → Back ====
  1226. const filmRows = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('a.film-row')).map(a => [a.querySelector('film-title').textContent, a.querySelector('film-year').textContent, a.querySelector('film-type').textContent, a.querySelector('film-role').textContent, a.getAttribute('href')]))`).then(JSON.parse);
  1227. const personFacts = () => ev(`JSON.stringify({ h1: (document.querySelector('person-meta h1') || {}).textContent || null, facts: Array.from(document.querySelectorAll('person-fact')).map(f => f.textContent),
  1228. bio: (document.querySelector('person-bio') || {}).textContent || null, photo: (document.querySelector('person-photo img') || { getAttribute: () => null }).getAttribute('src'),
  1229. photoLoaded: !!(document.querySelector('person-photo img') && document.querySelector('person-photo img').complete && document.querySelector('person-photo img').naturalWidth > 0),
  1230. loading: !!document.querySelector('#personloading'), error: (document.querySelector('#fillerror') || {}).textContent || null })`).then(JSON.parse);
  1231. await page.goto(BASE + '/person/Complete-Person');
  1232. await hydrated();
  1233. await new Promise(r => setTimeout(r, 600));
  1234. let pf = await personFacts();
  1235. let fr = await filmRows();
  1236. check('person: a COMPLETE person shows at once — name, born/died, bio, the filmography newest first with roles and years; no loading, TMDB not asked',
  1237. pf.h1 === 'Complete Person' && JSON.stringify(pf.facts) === JSON.stringify(['Born 3 February 1950 in Gate City', 'Died 1 January 2020']) && pf.bio === 'A fixture person who is complete.'
  1238. && pf.photo === null && !pf.loading && JSON.stringify(fr) === JSON.stringify([['Gate Test Show', '2024', 'Series', 'Old Role', '/shows/gate-test-show'], ['Gate Test Movie', '1996', 'Movie', 'Movie Role', '/shows/gate-test-movie']])
  1239. && tmdb.personRequests.length === 0, JSON.stringify({ pf, fr, req: tmdb.personRequests }));
  1240. await page.goto(BASE + '/person/Nobody-Here');
  1241. await hydrated();
  1242. check('person: an unknown slug says "Not found"', (await ev(`document.querySelector('person-page h1').textContent`)) === 'Not found');
  1243. check('person: /profiles/<bad name> answers 400', (await fetch(BASE + '/profiles/..%2Fx')).status === 400 || (await fetch(BASE + '/profiles/a%20b')).status === 400);
  1244. // client-side: the show page's cast link → the person page (a window marker proves no reload); TMDB answers slowly (900 ms)
  1245. // so the loading state is seen; then it fills in by itself
  1246. tmdb.personDelayMs = 900;
  1247. await page.goto(BASE + '/shows/gate-test-show');
  1248. await hydrated();
  1249. await ev(`window.__w055 = 'same document'`);
  1250. const fillT0 = Date.now();
  1251. await page.click('a.artist-link[href="/person/Test-Actor"]');
  1252. await page.waitFor(`location.pathname === '/person/Test-Actor' && !!document.querySelector('person-meta h1')`, { timeout: 10000, label: 'person page after the cast click' });
  1253. pf = await personFacts();
  1254. const rowsBefore = (await filmRows()).length;
  1255. // meanwhile another request: answered while the fill runs (the fetch steps yield)
  1256. check('person: first visit (client-side from the cast link) shows "Loading filmography…" and no rows yet',
  1257. pf.h1 === 'Test Actor' && pf.loading && (await ev(`document.querySelector('#personloading').textContent`)).startsWith('Loading filmography') && rowsBefore === 0, JSON.stringify({ pf, rowsBefore }));
  1258. await page.waitFor(`!document.querySelector('#personloading') && document.querySelectorAll('a.film-row').length > 0`, { timeout: 20000, label: 'filmography filled in' });
  1259. const fillMs = Date.now() - fillT0;
  1260. await page.waitFor(`document.querySelector('person-photo img') && document.querySelector('person-photo img').complete`, { timeout: 5000, label: 'photo' }).catch(() => {});
  1261. pf = await personFacts();
  1262. fr = await filmRows();
  1263. const marker16 = await ev('window.__w055 || null');
  1264. check('person: the filmography filled in WITHOUT a reload (same document) — facts and the photo from TMDB too',
  1265. marker16 === 'same document' && !pf.loading && pf.error === null && JSON.stringify(pf.facts) === JSON.stringify(['Born 6 May 1970 in Fixture Town']) && pf.bio === 'Test Actor bio from TMDB.'
  1266. && pf.photo === '/profiles/p1.png' && pf.photoLoaded, JSON.stringify({ marker16, pf, fillMs }));
  1267. const bio0 = await ev(`document.querySelector('person-bio').className`);
  1268. await page.click('person-bio');
  1269. await page.waitFor(`document.querySelector('person-bio').className === 'full'`, { timeout: 5000, label: 'bio open' }).catch(() => {});
  1270. const bio1 = await ev(`document.querySelector('person-bio').className + '|' + getComputedStyle(document.querySelector('person-bio')).webkitLineClamp`);
  1271. check('person: the bio is SHORT (four lines) until clicked, then whole', bio0 === 'short' && bio1 === 'full|none', bio0 + ' → ' + bio1);
  1272. const titles16 = fr.map(r => r[0]);
  1273. check('person: 86 rows (87 TMDB titles − the adult one), newest first, undated last; roles of cast + crew merged; the adult title hidden',
  1274. fr.length === 86 && JSON.stringify(fr.slice(0, 4)) === JSON.stringify([['Gate Test Show', '2024', 'Series', 'Someone', '/shows/gate-test-show'], ['Directed Only', '2018', 'Movie', 'Writer', '/shows/Directed-Only'],
  1275. ['Person Series', '2015', 'Series', 'Lead', '/shows/Person-Series'], ['Person Film One', '2010', 'Movie', 'Hero / Director', '/shows/Person-Film-One']])
  1276. && titles16[4] === 'Bulk Film 01' && titles16[83] === 'Bulk Film 80' && JSON.stringify(fr[84]) === JSON.stringify(['Gate Test Movie', '1996', 'Movie', 'Cameo', '/shows/gate-test-movie'])
  1277. && JSON.stringify(fr[85]) === JSON.stringify(['Announced Film', '', 'Movie', 'Villain', '/shows/Announced-Film']) && !titles16.includes('Hidden Adult Film'), JSON.stringify(fr.slice(0, 6)) + ' … ' + JSON.stringify(fr.slice(-3)));
  1278. const posters16 = JSON.parse(await ev(`JSON.stringify(Array.from(document.querySelectorAll('a.film-row img.film-poster')).slice(0, 4).map(i => i.getAttribute('src')))`));
  1279. check('person: posters — our file when we have one (show 1), else TMDB\'s small one (w92), else the placeholder',
  1280. posters16[0] === '/posters/sh1.png' && posters16[1] === '/posters/none' && posters16[3] === tmdb.base + '/t/p/w92/pf1.png', JSON.stringify(posters16));
  1281. const pr16 = tmdb.personRequests.map(q => q.path);
  1282. const fillLines = log.split('\n').filter(l => l.startsWith('person fill: '));
  1283. check('person: ONE TMDB request (person + combined_credits, Bearer token) + the photo (w185); the fill ran in 4 steps (TMDB, then 3 × 40 titles) and added 85 titles (2 existed)',
  1284. JSON.stringify(pr16) === JSON.stringify(['/3/person/3002?append_to_response=combined_credits', '/t/p/w185/face2.png']) && tmdb.personRequests[0].authorization === 'Bearer gate-token'
  1285. && fillLines.length === 1 && /^person fill: Test Actor \(tmdb 3002\) credits=87 added=85 requests=2 steps=4 /.test(fillLines[0]), JSON.stringify({ pr16, fillLines }));
  1286. tmdb.personDelayMs = 0;
  1287. // the links: a row → its show page (client-side); Back → the person page, complete now (no loading)
  1288. await page.click('a.film-row[href="/shows/gate-test-show"]');
  1289. await page.waitFor(`location.pathname === '/shows/gate-test-show' && !!document.querySelector('show-header h1')`, { timeout: 10000, label: 'show page from the filmography' });
  1290. const back1 = await ev(`document.querySelector('show-header h1').textContent + '|' + (window.__w055 || '')`);
  1291. await ev('history.back()');
  1292. await page.waitFor(`location.pathname === '/person/Test-Actor' && document.querySelectorAll('a.film-row').length === 86`, { timeout: 10000, label: 'Back to the person' });
  1293. pf = await personFacts();
  1294. check('person: a filmography row → the show page (client-side), Back → the person page, filled, no loading again',
  1295. back1 === 'Gate Test Show|same document' && !pf.loading && (await ev('window.__w055 || null')) === 'same document', JSON.stringify({ back1, pf }));
  1296. await page.click('a.film-row[href="/shows/Person-Film-One"]');
  1297. await page.waitFor(`location.pathname === '/shows/Person-Film-One' && !!document.querySelector('show-header h1')`, { timeout: 10000, label: 'new title page' });
  1298. lk = await extLinks();
  1299. check('person: a title the fill added has its page (/shows/Person-Film-One: title, the overview, TMDB link, genre Drama, the person in its cast)',
  1300. (await ev(`document.querySelector('show-header h1').textContent`)) === 'Person Film One' && (await ev(`document.querySelector('show-summary').textContent`)) === 'a film of the person'
  1301. && linksAre(lk, [['TMDB', TMDB_MOVIE + '2001']]) && (await ev(`Array.from(document.querySelectorAll('a.genre-pill')).map(a => a.textContent).join(',')`)) === 'Drama'
  1302. && (await ev(`Array.from(document.querySelectorAll('a.artist-link')).map(a => a.textContent + ' ' + a.getAttribute('href')).join(',')`)) === 'Test Actor /person/Test-Actor', lk);
  1303. // second visit (full load): complete — TMDB not asked again, nothing added, same rows
  1304. await page.goto(BASE + '/person/Test-Actor');
  1305. await hydrated();
  1306. await new Promise(r => setTimeout(r, 700));
  1307. pf = await personFacts();
  1308. check('person: a SECOND visit is complete at once — no loading, the same 86 rows, TMDB not asked again, no second fill',
  1309. !pf.loading && (await filmRows()).length === 86 && tmdb.personRequests.length === 2 && log.split('\n').filter(l => l.startsWith('person fill: ')).length === 1, JSON.stringify({ pf, req: tmdb.personRequests.length }));
  1310. // another person whose credits we have by now: linked, nothing added (no duplicate titles)
  1311. await page.goto(BASE + '/person/Second-Actor');
  1312. await hydrated();
  1313. await page.waitFor(`!document.querySelector('#personloading')`, { timeout: 15000, label: 'second actor filled' });
  1314. fr = await filmRows();
  1315. const second = log.split('\n').filter(l => l.startsWith('person fill: Second Actor'));
  1316. check('person: a second person with titles we have — linked, added=0, no photo; the title OUR record marks adult hidden though the credit is not',
  1317. JSON.stringify(fr) === JSON.stringify([['Gate Test Show', '2024', 'Series', 'Guest', '/shows/gate-test-show'], ['Person Film One', '2010', 'Movie', 'Sidekick', '/shows/Person-Film-One']])
  1318. && second.length === 1 && / added=0 requests=1 /.test(second[0]) && (await personFacts()).photo === null, JSON.stringify({ fr, second }));
  1319. await page.goto(BASE + '/search/person%20film');
  1320. await hydrated();
  1321. check('person: the search finds the added title once', (await ev(`Array.from(document.querySelectorAll('a.search-title')).filter(a => a.textContent === 'Person Film One (2010)').length`)) === 1);
  1322. check('person: the server log has no error', !/error absorbed|not stored/i.test(log), log.split('\n').filter(l => /person|error/i.test(l)).join('\n'));
  1323. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  1324. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  1325. for (const [name, path] of [['person', '/person/Test-Actor'], ['person-complete', '/person/Complete-Person']]) {
  1326. await page.goto(BASE + path);
  1327. await hydrated();
  1328. await page.waitFor(`Array.from(document.querySelectorAll('img')).filter(i => i.getBoundingClientRect().top < innerHeight).every(i => i.complete)`, { timeout: 5000 }).catch(() => {});
  1329. check(`person: ${path} at ${w} px — no sideways scroll, header one row`, (await ev('document.documentElement.scrollWidth')) <= w && (await ev(`document.querySelector('application-header').getBoundingClientRect().height`)) <= 64);
  1330. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `${name}-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1331. }
  1332. }
  1333. await page.send('Emulation.clearDeviceMetricsOverride', {});
  1334. // ==== tracker.worldapi.org#10: THE INSTALLABLE APP — hl:web's own manifest + service worker (project.hl appIcons /
  1335. // offline = [ Home ]). The manifest and every icon over HTTP; Chrome's own verdict (installability, manifest parse);
  1336. // the worker registered for the whole app; then OFFLINE: the tab loses the network (the note appears), the server
  1337. // is stopped too (CDP's offline emulation does not reach the service worker's own fetches — measured — so only a
  1338. // dead server proves the cache), and `/` still opens: header + the note. A data page gets hl:web's "Unavailable
  1339. // offline" page. A separate tab, so its expected socket errors stay out of the main tab's console check.
  1340. const html = await (await fetch(BASE + '/')).text();
  1341. const mhref = (html.match(/<link rel="manifest" href="([^"]+)"/) || [])[1];
  1342. check('pwa: every page head links the manifest, the apple-touch-icon, the favicon and the theme colour (the token `darker`)',
  1343. mhref === '/__hl/manifest.webmanifest' && html.includes('<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">')
  1344. && html.includes('<link rel="icon" href="/icons/favicon.svg">') && html.includes('<meta name="theme-color" content="rgb(15, 20, 25)">'), html.slice(0, 900));
  1345. const mres = await fetch(BASE + mhref);
  1346. const man = await mres.json();
  1347. check('pwa: the manifest is served as application/manifest+json: name "tracker", start_url /, scope /, display standalone, the token colours',
  1348. /manifest\+json/.test(mres.headers.get('content-type') || '') && man.name === 'tracker' && man.short_name === 'tracker' && man.start_url === '/' && man.scope === '/'
  1349. && man.display === 'standalone' && man.theme_color === 'rgb(15, 20, 25)' && man.background_color === 'rgb(25, 30, 35)', JSON.stringify(man));
  1350. const iconOk = [];
  1351. for (const ic of man.icons || []) {
  1352. const ir = await fetch(BASE + ic.src);
  1353. const b = Buffer.from(await ir.arrayBuffer());
  1354. iconOk.push(ir.ok && ic.type === 'image/png' && b.readUInt32BE(16) === Number(ic.sizes.split('x')[0]) && b.readUInt32BE(20) === Number(ic.sizes.split('x')[1]));
  1355. }
  1356. check('pwa: every manifest icon loads and is a real PNG of its declared size (192 + 512, any + maskable)',
  1357. iconOk.length === 4 && iconOk.every(Boolean) && ['any', 'maskable'].every(pu => ['192x192', '512x512'].every(sz => man.icons.some(i => i.purpose === pu && i.sizes === sz))), JSON.stringify(man.icons) + ' ' + JSON.stringify(iconOk));
  1358. const touch = await fetch(BASE + '/icons/apple-touch-icon.png');
  1359. const touchBytes = Buffer.from(await touch.arrayBuffer());
  1360. const fav = await fetch(BASE + '/icons/favicon.svg');
  1361. const favText = await fav.text();
  1362. const ico = await fetch(BASE + '/favicon.ico');
  1363. const icoBytes = Buffer.from(await ico.arrayBuffer());
  1364. check('pwa: apple-touch-icon is a 180×180 PNG, the favicon an SVG in tracker green, /favicon.ico a real icon file',
  1365. touch.ok && touchBytes.readUInt32BE(16) === 180 && fav.ok && /image\/svg\+xml/.test(fav.headers.get('content-type') || '') && favText.includes('#4ec9b0')
  1366. && ico.ok && icoBytes.readUInt32BE(0) === 0x00000100, `${touch.status} ${fav.status} ${ico.status}`);
  1367. await page.goto('about:blank'); // the server goes down below: the main tab must not sit on a page meanwhile
  1368. const app = await browser.newPage();
  1369. const aev = (e) => app.evaluate(e);
  1370. const appHydrated = () => app.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'app tab hydrated' });
  1371. await app.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
  1372. await app.goto(BASE + '/');
  1373. await appHydrated();
  1374. await app.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && r.active))`, { timeout: 15000, label: 'service worker active' });
  1375. await app.waitFor(`!!navigator.serviceWorker.controller`, { timeout: 10000, label: 'page controlled by the worker' });
  1376. const inst = await app.send('Page.getInstallabilityErrors');
  1377. check('pwa: Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, JSON.stringify(inst));
  1378. const am = await app.send('Page.getAppManifest');
  1379. check('pwa: Chrome parses the manifest without errors', am.url && am.url.endsWith('/__hl/manifest.webmanifest') && (am.errors || []).length === 0, JSON.stringify(am.errors));
  1380. check('pwa: hl:web\'s service worker is registered for the whole app (scope /) and controls the page',
  1381. (await aev(`navigator.serviceWorker.getRegistration().then(r => new URL(r.scope).pathname + ' ' + new URL(r.active.scriptURL).pathname)`)) === '/ /__hl/sw.js' && (await aev('!!navigator.serviceWorker.controller')));
  1382. check('pwa: online, no offline note', !(await aev(`!!document.querySelector('#offline')`)));
  1383. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'pwa-phone-online.png'), Buffer.from((await app.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1384. await app.setOffline(true);
  1385. await app.waitFor(`!!document.querySelector('#offline')`, { timeout: 5000, label: 'offline note (live)' }).catch(() => {});
  1386. check('offline: the open page says so within seconds (the shell\'s tick)', (await aev(`document.querySelector('#offline') ? document.querySelector('#offline').textContent : ''`)) === 'You are offline. Your shows and lists need the network.');
  1387. await stopServer();
  1388. let down = false;
  1389. try { await fetch(BASE + '/'); } catch { down = true; }
  1390. await app.goto(BASE + '/');
  1391. await app.waitFor(`!!document.querySelector('#offline')`, { timeout: 8000, label: 'offline note after reload' }).catch(() => {});
  1392. // the Shows row: 3 fixture series + Gate Import Show (#14's import, in the lists at once since the #13/#14 merge)
  1393. check('offline: with the server gone too, a reload of / still opens the shell from the worker\'s cache — header, brand, the note, the home (#13: its text and both rows — 5 shows since mission 054 added Flagless Gate Series)',
  1394. down && (await aev(`document.querySelector('application-header .brand') ? document.querySelector('application-header .brand').textContent : ''`)) === 'tracker'
  1395. && (await aev(`!!document.querySelector('#offline')`)) && (await aev(`!!document.querySelector('home-intro') && document.querySelectorAll('#home-movies a.tile').length === 10 && document.querySelectorAll('#home-shows a.tile').length === 5`))
  1396. && (await aev(`getComputedStyle(document.querySelector('application-header')).backgroundColor`)) === 'rgb(15, 20, 25)', 'server down: ' + down);
  1397. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'pwa-phone-offline.png'), Buffer.from((await app.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1398. await app.goto(BASE + '/my/shows');
  1399. check('offline: a data page (/my/shows) is not kept — hl:web\'s "Unavailable offline" page', (await aev(`!!document.querySelector('main[data-hl-offline]') && document.querySelector('h1').textContent`)) === 'Unavailable offline');
  1400. await app.setOffline(false);
  1401. await startServer();
  1402. await app.goto(BASE + '/');
  1403. await appHydrated();
  1404. await app.waitFor(`!document.querySelector('#offline')`, { timeout: 5000, label: 'note gone' }).catch(() => {});
  1405. check('back online: / from the server again, no offline note', !(await aev(`!!document.querySelector('#offline')`)));
  1406. await app.close();
  1407. await page.goto(BASE + '/my/schedule');
  1408. await hydrated();
  1409. // ---- sign out: back to signed out, a reload confirms the app's own session (not just client state) ----
  1410. await page.click('#logout');
  1411. await page.waitForSelector('#loginbutton', { timeout: 15000 });
  1412. check('sign out returns to the signed-out header', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));
  1413. await page.goto(BASE + '/');
  1414. await hydrated();
  1415. check('reload after sign-out stays signed out', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));
  1416. const problems = page.problems();
  1417. check('no console errors/warnings', problems.length === 0, page.dumpConsole());
  1418. }
  1419. try {
  1420. await main();
  1421. } catch (e) {
  1422. check('gate ran without throwing', false, (e.stack || String(e)) + (globalThis.__gatePage ? '\nbrowser console:\n' + globalThis.__gatePage.dumpConsole() : '') + '\nserver log (tail):\n' + log.slice(-2000));
  1423. } finally {
  1424. await stop();
  1425. }
  1426. console.log(`\n${passes} passed, ${failures} failed`);
  1427. process.exit(failures === 0 ? 0 : 1);

Branches

Latest commits

  • 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
  • cbdc4ea7tracker#12: link icons TMDB/IMDb/TVDB/TVmaze; sync fills missing ids (TVmaze lookup); movies fetched via /movie/mre
  • b105bcd8tracker#11: Hybriel master ff51cf46 (checks no longer vanish), mobile-first styles, carets, follow button, sign-in modal, inverted check, orange castmre
  • 31b758aatracker#10: installable app (manifest, service worker, offline shell), own icon + faviconmre
  • 2fa9d997tracker#9: TMDB sync (followed shows: seasons, episodes, posters), tools/sync-tmdb.hl + daily run 04:00 UTC, fake TMDB in gatemre