gitoriaLog in with ident

tracker

All repositories: gitoria

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

291.9 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, execFile, execFileSync } from 'node:child_process';
  10. import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
  11. import { dirname, join, resolve } from 'node:path';
  12. import { fileURLToPath } from 'node:url';
  13. import { inflateSync } from 'node:zlib';
  14. import { launchBrowser } from './cdp.mjs';
  15. import { startIdent } from './identkit.mjs';
  16. import { startFakeTmdb, PNG } from './faketmdb.mjs';
  17. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  18. const HERE = dirname(fileURLToPath(import.meta.url));
  19. const APP = resolve(HERE, '..');
  20. const PORT = Number(process.env.TRACKER_GATE_PORT || 8700);
  21. const [CH_FROM, CH_TO] = (process.env.TRACKER_GATE_CHROME || '8702-8709').split('-').map(Number);
  22. const IDENT_PORT = Number(process.env.TRACKER_GATE_IDENT_PORT || 8701);
  23. const TMDB_PORT = Number(process.env.TRACKER_GATE_TMDB_PORT || 8710);
  24. const BASE = `http://127.0.0.1:${PORT}`;
  25. const STORE = join(APP, '.scratch', 'gate-store');
  26. 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')));
  27. const SCRATCH = join(APP, '.scratch');
  28. const SESSIONS = join(SCRATCH, 'gate-sessions');
  29. rmSync(SESSIONS, { recursive: true, force: true });
  30. rmSync(STORE, { recursive: true, force: true });
  31. mkdirSync(SESSIONS, { recursive: true });
  32. let failures = 0, passes = 0;
  33. function check(label, ok, detail = '') {
  34. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  35. if (ok) passes++; else failures++;
  36. }
  37. // tracker#29: the first pixel of a PNG (8-bit RGB/RGBA, as Chrome's screenshots are) — the first pixel of the first row is
  38. // stored raw under every PNG filter (no left / upper neighbour), so no unfiltering is needed
  39. function pngFirstPixel(buf) {
  40. let pos = 8, idat = [];
  41. const colorType = buf[25];
  42. while (pos < buf.length) {
  43. const len = buf.readUInt32BE(pos), type = buf.toString('latin1', pos + 4, pos + 8);
  44. if (type === 'IDAT') idat.push(buf.subarray(pos + 8, pos + 8 + len));
  45. pos += 12 + len;
  46. }
  47. const raw = inflateSync(Buffer.concat(idat));
  48. return colorType === 2 || colorType === 6 ? [raw[1], raw[2], raw[3]] : null;
  49. }
  50. const ACCENT = [78, 201, 176];
  51. const colorDist = (a, b) => a && b ? Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) : Infinity;
  52. // tracker#29 (the creator: "in /my/unwatched all checkboxes are active"): an unwatched check is EMPTY — nothing filled,
  53. // muted grey outline circle + check (the #11 shape) — and a watched one SOLID — accent disc, dark check; `look` is checkLook()
  54. const lookEmpty = (g) => !!g && /\bregular\b/.test(g.cls) && g.w > 10 && g.onTop && g.fill === 'rgba(0, 0, 0, 0)'
  55. && g.stroke === 'rgb(145, 150, 155)' && g.mark === 'rgb(145, 150, 155)' && colorDist(g.pixel, ACCENT) > 150;
  56. const lookSolid = (g) => !!g && /\bsolid\b/.test(g.cls) && g.w > 10 && g.onTop && g.fill === 'rgb(78, 201, 176)'
  57. && g.mark === 'rgb(15, 20, 25)' && colorDist(g.pixel, ACCENT) < 40;
  58. let ident = null, server = null, browser = null, log = '', tmdb = null;
  59. async function stopServer() {
  60. if (server) {
  61. const s = server;
  62. try { s.kill('SIGTERM'); } catch {}
  63. await new Promise(r => { if (s.exitCode !== null || s.signalCode !== null) return r(); s.once('exit', r); setTimeout(r, 3000); });
  64. server = null;
  65. }
  66. }
  67. async function stop() {
  68. if (ident) { try { await ident.stop(); } catch {} ident = null; }
  69. if (browser) { try { await browser.close(); } catch {} browser = null; }
  70. await stopServer();
  71. if (tmdb) { try { await tmdb.stop(); } catch {} tmdb = null; }
  72. }
  73. // tracker.worldapi.org#9: what the fake TMDB (tests/faketmdb.mjs) knows about the two fixture shows (tmdbId 1001,
  74. // 1002, tests/seed-show.hl). Show 1: season 0 (must NOT be synced — the show has no season 0), S1 with the two
  75. // fixture episodes (no tmdbId locally → matched by number; E2 renamed "Second Episode") + a new E3 (released),
  76. // S2 unchanged, a NEW S3 (E1 released, E2 upcoming 2098-06-01). Show 2 (no seasons yet): a NEW S1 E1 (upcoming
  77. // 2097-01-01). Both have a poster.
  78. // tracker.worldapi.org#12: TMDB's external_ids — show 1 a DIFFERENT imdb id than the stored tt0000001 (must not overwrite)
  79. // + a tvdb id (fills); show 2 an imdb id (fills) + a different tvdb id than the stored 4002 (must not overwrite). The movie
  80. // 1003 (only via /movie/): an imdb id. The fake TVmaze knows imdb tt0000001 → 501 and thetvdb 4002 → 502 (tt0000002 is unknown).
  81. // tracker.worldapi.org#28: the movie's credits — cast in TMDB's order (one without a character), crew: the director listed twice
  82. // (one entry), also a writer; 8 more writers (9 > 6 → the crew's "Show all"), screenplay, story, the composer; an assistant
  83. // director (not shown) and an adult crew member (left out)
  84. const gateMovieCredits = () => ({
  85. cast: [{ id: 3210, name: 'Movie Star', character: 'Hero', order: 0 }, { id: 3211, name: 'Movie Sidekick', character: 'Sidekick', order: 1 }, { id: 3212, name: 'Uncredited Extra', character: '', order: 2 }],
  86. crew: [{ id: 3201, name: 'Film Director', department: 'Directing', job: 'Director' }, { id: 3201, name: 'Film Director', department: 'Directing', job: 'Director' },
  87. { id: 3201, name: 'Film Director', department: 'Writing', job: 'Writer' }, ...Array.from({ length: 8 }, (_, k) => ({ id: 3221 + k, name: 'Writer Pool ' + (k + 1), department: 'Writing', job: 'Writer' })),
  88. { id: 3202, name: 'Screen Writer', department: 'Writing', job: 'Screenplay' }, { id: 3203, name: 'Story Author', department: 'Writing', job: 'Story' },
  89. { id: 3204, name: 'Film Composer', department: 'Sound', job: 'Original Music Composer' }, { id: 3205, name: 'Assistant Helper', department: 'Directing', job: 'Assistant Director' },
  90. { id: 3206, name: 'Adult Crew', department: 'Directing', job: 'Director', adult: true }] });
  91. const gateTmdbMovies = () => ({ 1003: { id: 1003, title: 'Gate Test Movie', poster_path: '/gate3.png', external_ids: { imdb_id: 'tt0000003', wikidata_id: 'Q1' }, credits: gateMovieCredits() },
  92. // tracker.worldapi.org#14: a movie only TMDB has — imported from the search
  93. 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' } },
  94. // mission 054 (tests/seed-adult.hl): what TMDB says about the titles whose adult flag we don't know yet; 1120 is unknown (404)
  95. 1011: { id: 1011, title: 'Adult Gate Movie', adult: true, poster_path: '/flag11.png' },
  96. 1012: { id: 1012, title: 'Flagless Gate Movie', adult: false, poster_path: '/flag12.png', external_ids: { imdb_id: 'tt0001012' } },
  97. 1013: { id: 1013, title: 'Flagless Adult Movie', adult: true, poster_path: '/flag13.png', external_ids: {} },
  98. // (#28: their credits come with the credits job)
  99. ...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: {},
  100. credits: { cast: [{ id: 3170, name: 'Backfill Cast', character: 'Role ' + (k + 1) }], crew: [{ id: 3171, name: 'Backfill Director', job: 'Director' }] } }])),
  101. // tracker.worldapi.org#26: the full details of titles Test Actor's filmography adds as MINIMAL records (#16) — Person Film One
  102. // (opened in the gate: homepage, tagline, a 2nd genre, IMDb id, poster, cast = Test Actor (we have him), a NEW person and an
  103. // ADULT person (left out)); Directed Only, Announced Film and Bulk Film 01–78 for the repair job; Bulk Film 79/80 are
  104. // unknown to TMDB (404)
  105. 2001: { id: 2001, title: 'Person Film One', poster_path: '/pf1.png', release_date: '2010-05-05', overview: 'TMDB overview of the film', status: 'Released',
  106. homepage: 'https://example.org/pf1', tagline: 'One film.', genres: [{ id: 18, name: 'Drama' }, { id: 35, name: 'Comedy' }], external_ids: { imdb_id: 'tt0002001' },
  107. credits: { cast: [{ id: 3002, name: 'Test Actor', character: 'Hero', order: 0 }, { id: 3010, name: 'New Cast Member', character: 'Partner', order: 1, gender: 1, profile_path: '/ncm3010.png' },
  108. { id: 3011, name: 'Adult Performer', character: 'Z', order: 2, adult: true }], crew: [{ id: 3002, name: 'Test Actor', job: 'Director' }] } },
  109. 2004: { id: 2004, title: 'Announced Film', poster_path: '/af4.png', external_ids: { imdb_id: 'tt0002004' }, credits: { cast: [{ id: 3002, name: 'Test Actor', character: 'Villain' }] } },
  110. 2005: { id: 2005, title: 'Directed Only', poster_path: '/do5.png', release_date: '2018-08-08', external_ids: {}, credits: { cast: [] } },
  111. ...Object.fromEntries(Array.from({ length: 78 }, (_, k) => [2101 + k, { id: 2101 + k, title: 'Bulk Film ' + String(k + 1).padStart(2, '0'), poster_path: `/bulk${k + 1}.png`,
  112. external_ids: { imdb_id: 'tt00' + (2101 + k) }, credits: { cast: [{ id: 3002, name: 'Test Actor', character: 'Extra' }] } }])) });
  113. // tracker.worldapi.org#15: TVmaze's episode lists — at first the SAME as TMDB's (same numbers, same days, + a special without
  114. // a number that must be ignored), so the first syncs take nothing from TVmaze; the "TVmaze merge" block below changes them.
  115. const gateTvmazeEpisodes = () => ({
  116. 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' },
  117. { 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' },
  118. { 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' },
  119. { id: 8099, season: 3, number: null, name: 'A TVmaze Special', airdate: '2021-06-01', type: 'significant_special' }],
  120. 502: [{ id: 8101, season: 1, number: 1, name: 'Brand New', airdate: '2097-01-01' }],
  121. });
  122. // tracker.worldapi.org#26: Person Series (tmdb 2002) is TVmaze 602 (by its imdb id), which has one episode more than TMDB
  123. // tracker.worldapi.org#18: TVmaze's show summaries (HTML, as TVmaze writes them) → tvmazeSummary, HTML stripped
  124. // tracker#35: TVmaze's status of show 1 (501) — the sync stores it as tvmazeStatus; 5501–5504 are the status backfill's (seed-status.hl)
  125. const gateTvmaze = () => ({ imdb: { tt0000001: 501, tt0002002: 602 }, thetvdb: { 4002: 502, 4004: 504 }, statuses: { 501: 'Running', 5501: 'Running', 5502: 'Ended', 5503: 'Running', 5505: 'Running' }, missing: [5504],
  126. summaries: { 501: '<p><b>Gate Test Show</b> on TVmaze &amp; more.</p>', 502: '<p>Unwatched <i>Gate</i> Show,\non TVmaze.</p><p>Second &quot;paragraph&quot;.</p>' }, episodes: { ...gateTvmazeEpisodes(),
  127. 602: [{ id: 60201, season: 1, number: 1, name: 'Series Pilot', airdate: '2015-03-03' }, { id: 60202, season: 1, number: 2, name: 'Series Second', airdate: '2015-03-10' },
  128. { id: 60203, season: 1, number: 3, name: 'Only On TVmaze', airdate: '2015-03-17' }] } });
  129. // tracker.worldapi.org#14: TMDB's search/multi for "gate": the two titles we HAVE (tv 1001, movie 1003 — left out of the web
  130. // list), a person (dropped), and three new ones: tv 1004 + movie 1005 (importable) and tv 1006 (in the search, but its details
  131. // answer 404 — the failed import)
  132. const gateSearch = () => ({ gate: [
  133. { media_type: 'tv', id: 1001, name: 'Gate Test Show', first_air_date: '2024-01-01', poster_path: '/gate1.png', overview: 'have it' },
  134. { media_type: 'tv', id: 1004, name: 'Gate Import Show', first_air_date: '2023-02-02', poster_path: '/gate4.png', overview: 'new series' },
  135. { media_type: 'person', id: 77, name: 'Gate Person' },
  136. { media_type: 'movie', id: 1003, title: 'Gate Test Movie', release_date: '1996-11-18', poster_path: '/gate3.png' },
  137. { media_type: 'movie', id: 1005, title: 'Gate Import Movie', release_date: '2001-05-05', poster_path: null, overview: 'new movie' },
  138. { media_type: 'tv', id: 1006, name: 'Gate Broken Show', first_air_date: '', poster_path: '/gate6.png' },
  139. // mission 054: an ADULT title in TMDB's answer (despite include_adult=false) — never in our web list
  140. { media_type: 'movie', id: 1030, title: 'Gate Adult Web', adult: true, release_date: '2002-02-02', poster_path: '/gate30.png' }] });
  141. // tracker.worldapi.org#16: TMDB's people. Test Actor (3002): facts + a photo; credits = two titles we HAVE (tv 1001, movie 1003),
  142. // new ones — a movie as cast AND crew ("Hero / Director"), a series, an ADULT movie (stored, never shown), an undated one (last),
  143. // a crew-only one, and 80 more movies (87 titles → 3 batches of 40 after the TMDB step). Second Actor (3003): two titles that
  144. // exist by then. Complete Person (3001) must never be asked.
  145. const gatePersons = () => {
  146. const bulk = [];
  147. 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: [] });
  148. return {
  149. 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,
  150. combined_credits: {
  151. cast: [
  152. { media_type: 'tv', id: 1001, name: 'Gate Test Show', first_air_date: '2024-01-01', character: 'Someone', poster_path: '/gate1.png' },
  153. { media_type: 'movie', id: 1003, title: 'Gate Test Movie', release_date: '1996-11-18', character: 'Cameo', poster_path: '/gate3.png' },
  154. { 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' },
  155. { 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] },
  156. { media_type: 'movie', id: 2003, title: 'Hidden Adult Film', release_date: '2012-01-01', character: 'X', adult: true, poster_path: null },
  157. { media_type: 'movie', id: 2004, title: 'Announced Film', release_date: '', character: 'Villain', adult: false, poster_path: null },
  158. ...bulk],
  159. crew: [
  160. { media_type: 'movie', id: 2001, title: 'Person Film One', release_date: '2010-05-05', job: 'Director', adult: false, poster_path: '/pf1.png' },
  161. { media_type: 'movie', id: 2005, title: 'Directed Only', release_date: '2018-08-08', job: 'Writer', adult: false, poster_path: null }] } },
  162. 3003: { id: 3003, name: 'Second Actor', biography: '', birthday: null, deathday: null, place_of_birth: null, profile_path: null,
  163. combined_credits: { cast: [{ media_type: 'movie', id: 2001, title: 'Person Film One', release_date: '2010-05-05', character: 'Sidekick', poster_path: '/pf1.png' },
  164. { media_type: 'tv', id: 1001, name: 'Gate Test Show', first_air_date: '2024-01-01', character: 'Guest', poster_path: '/gate1.png' },
  165. // TMDB's credit says not adult, but OUR record of it is (adult = true, stored by Test Actor's fill): still hidden
  166. { media_type: 'movie', id: 2003, title: 'Hidden Adult Film', release_date: '2012-01-01', character: 'Y', adult: false }], crew: [] } },
  167. 3001: { id: 3001, name: 'Complete Person', combined_credits: { cast: [{ media_type: 'movie', id: 2999, title: 'Must Not Appear', release_date: '2000-01-01' }], crew: [] } },
  168. // mission 062: the guest stars of Gate Test Show (no person records until opened)
  169. 3142: { id: 3142, name: 'Guest Star One', biography: '', profile_path: null,
  170. combined_credits: { cast: [{ media_type: 'tv', id: 1001, name: 'Gate Test Show', first_air_date: '2024-01-01', character: 'Stranger', poster_path: '/gate1.png' }], crew: [] } },
  171. 3141: { id: 3141, name: 'Guest Star Two', biography: '', profile_path: null, combined_credits: { cast: [], crew: [] } },
  172. // tracker.worldapi.org#26: a person who came with Person Film One's cast
  173. 3010: { id: 3010, name: 'New Cast Member', biography: '', profile_path: null,
  174. combined_credits: { cast: [{ media_type: 'movie', id: 2001, title: 'Person Film One', release_date: '2010-05-05', character: 'Partner', poster_path: '/pf1.png' }], crew: [] } },
  175. };
  176. };
  177. // tracker.worldapi.org#28: show 1001's cast of ALL seasons (aggregate_credits, by episode count): Lead Player (6), Test Actor (5, we
  178. // have him), Ensemble Player 01–18 (4 each), Late Season Star (3 — only in season 3), then the guests (the show has 6 episodes →
  179. // main cast ≥ 3): Guest Star Two (2), Guest Star One (1); an adult one left out = 23 names, 20 shown. Crew: Show Creator
  180. // (created_by AND writer of 5), two directors, a writer who also has a story credit, the composer; a script supervisor (not shown)
  181. const gateShowCredits = () => ({
  182. // (Lead Player's roles as TMDB writes them on real data: combined, repeated, "(uncredited)", more than 4 → "The Lead / Twin / Mirror / Ghost / …")
  183. cast: [{ id: 3101, name: 'Lead Player', roles: [{ character: 'The Lead / Twin', episode_count: 4 }, { character: 'The Lead (uncredited)', episode_count: 1 }, { character: 'Twin', episode_count: 1 },
  184. { character: 'Mirror', episode_count: 1 }, { character: 'Ghost / The Lead', episode_count: 1 }, { character: 'Narrator', episode_count: 1 }], total_episode_count: 6 },
  185. { id: 3002, name: 'Test Actor', profile_path: '/ta-cast.png', roles: [{ character: 'Someone', episode_count: 5 }], total_episode_count: 5 },
  186. ...Array.from({ length: 18 }, (_, k) => ({ id: 3111 + k, name: 'Ensemble Player ' + String(k + 1).padStart(2, '0'), roles: [{ character: 'Crew Member ' + (k + 1), episode_count: 4 }], total_episode_count: 4 })),
  187. { id: 3140, name: 'Late Season Star', profile_path: '/late3140.png', roles: [{ character: 'New Captain', episode_count: 2 }, { character: 'Captain', episode_count: 1 }], total_episode_count: 3 },
  188. { id: 3143, name: 'Adult Cast', adult: true, roles: [{ character: 'Q', episode_count: 3 }], total_episode_count: 3 },
  189. { id: 3141, name: 'Guest Star Two', roles: [{ character: 'Visitor', episode_count: 2 }], total_episode_count: 2 },
  190. { id: 3142, name: 'Guest Star One', roles: [{ character: 'Stranger', episode_count: 1 }], total_episode_count: 1 }],
  191. crew: [{ id: 3151, name: 'Episode Director', profile_path: '/director3151.png', department: 'Directing', jobs: [{ job: 'Director', episode_count: 4 }], total_episode_count: 4 },
  192. { id: 3152, name: 'Staff Writer', department: 'Writing', jobs: [{ job: 'Writer', episode_count: 3 }, { job: 'Story', episode_count: 1 }], total_episode_count: 4 },
  193. { id: 3155, name: 'Second Director', department: 'Directing', jobs: [{ job: 'Director', episode_count: 2 }], total_episode_count: 2 },
  194. { id: 3150, name: 'Show Creator', department: 'Writing', jobs: [{ job: 'Writer', episode_count: 5 }], total_episode_count: 5 },
  195. { id: 3153, name: 'Theme Composer', department: 'Sound', jobs: [{ job: 'Original Music Composer', episode_count: 6 }], total_episode_count: 6 },
  196. { id: 3154, name: 'Script Keeper', department: 'Directing', jobs: [{ job: 'Script Supervisor', episode_count: 6 }], total_episode_count: 6 }] });
  197. const gateTmdbShows = () => ({
  198. 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 },
  199. created_by: [{ id: 3150, name: 'Show Creator', profile_path: '/creator3150.png' }], aggregate_credits: gateShowCredits(), seasons: [
  200. { 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' }] },
  201. { id: 91, season_number: 1, name: 'Season 1', air_date: '2020-01-01', episodes: [
  202. { id: 5001, episode_number: 1, name: 'Pilot', air_date: '2020-01-01' },
  203. { id: 5002, episode_number: 2, name: 'Second Episode', air_date: '2020-06-15' },
  204. { id: 5003, episode_number: 3, name: 'Third', air_date: '2020-09-01' }] },
  205. { 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' }] },
  206. { id: 93, season_number: 3, name: 'Season 3', air_date: '2021-05-05', episodes: [
  207. { id: 5005, episode_number: 1, name: 'Third Season Start', air_date: '2021-05-05' },
  208. { id: 5006, episode_number: 2, name: 'Coming Soon', air_date: '2098-06-01' }] }] },
  209. // tracker.worldapi.org#14: NOT in our database — imported from the search (no one follows it, the sync never asks for it)
  210. 1004: { id: 1004, name: 'Gate Import Show', overview: 'an imported series', status: 'Returning Series', poster_path: '/gate4.png', first_air_date: '2023-02-02',
  211. original_language: 'en', genres: [{ id: 18, name: 'Drama' }, { id: 10765, name: 'Sci-Fi & Fantasy' }], external_ids: { imdb_id: 'tt0000004', tvdb_id: 4004 },
  212. // tracker.worldapi.org#28: an imported title has its cast + crew at once
  213. created_by: [{ id: 3161, name: 'Import Creator' }], aggregate_credits: { cast: [{ id: 3160, name: 'Imported Lead', roles: [{ character: 'Captain', episode_count: 2 }], total_episode_count: 2 }], crew: [] }, seasons: [
  214. { id: 95, season_number: 1, name: 'Season 1', air_date: '2023-02-02', episodes: [
  215. { 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' }] }] },
  216. // mission 054: a series whose adult flag we don't know yet (tests/seed-adult.hl) — TMDB says not adult
  217. 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: [
  218. { 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' }] }] },
  219. // tracker.worldapi.org#26: Test Actor's filmography adds it as a MINIMAL record; opened in the gate → its full details
  220. 2002: { id: 2002, name: 'Person Series', overview: 'TMDB overview of the series', status: 'Ended', poster_path: '/ps2.png', first_air_date: '2015-03-03', homepage: 'https://example.org/ps2',
  221. original_language: 'en', genres: [{ id: 18, name: 'Drama' }], external_ids: { imdb_id: 'tt0002002', tvdb_id: 4202 },
  222. aggregate_credits: { cast: [{ id: 3002, name: 'Test Actor', roles: [{ character: 'Lead', episode_count: 2 }, { character: 'Narrator', episode_count: 1 }] },
  223. { id: 3012, name: 'Series Newcomer', roles: [{ character: 'Friend', episode_count: 2 }] }] }, seasons: [
  224. { id: 97, season_number: 1, name: 'Season 1', air_date: '2015-03-03', episodes: [
  225. { id: 9701, episode_number: 1, name: 'Series Pilot', air_date: '2015-03-03' }, { id: 9702, episode_number: 2, name: 'Series Second', air_date: '2015-03-10' }] },
  226. { id: 98, season_number: 2, name: 'Season 2', air_date: '2016-04-04', episodes: [{ id: 9801, episode_number: 1, name: 'Second Season Start', air_date: '2016-04-04' }] }] },
  227. 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: [
  228. { 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' }] }] },
  229. });
  230. async function main() {
  231. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  232. const alice = await ident.signIn('[email protected]');
  233. const APPKEY = await ident.registerApp(alice, 'tracker (gate)', [BASE]);
  234. 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')));
  235. // tracker.worldapi.org#5: learn alice's per-app identity (a throwaway code, consumed here) so the
  236. // fixture below can seed a follow for the SAME user the real login flow will later resolve to
  237. // (users.hl `ensureUser` matches an existing users.db row by `identity`, reusing its id) — this is
  238. // the only way to give /unwatched a known followed show before the browser ever signs in.
  239. const preCode = await ident.selectorCode(alice, APPKEY, BASE);
  240. const aliceIdentity = await ident.exchange(APPKEY, preCode);
  241. // tracker.worldapi.org#4/#5: a tiny fixture show (tests/seed-show.hl) — written BEFORE the server
  242. // starts, self-assigned ids only (never update(), see that file's header)
  243. const MPACKDB = join(STORE, 'mpackdb');
  244. mkdirSync(MPACKDB, { recursive: true });
  245. const seedOut = execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-show.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_IDENTITY: aliceIdentity } }).toString();
  246. check('seed: the fixture show was written, alice follows it', seedOut.includes('gate-test-show') && seedOut.includes('follows it'), seedOut);
  247. // tracker.worldapi.org#8: the fixture show's poster FILE exists (a 1×1 png), the second show has none →
  248. // /my/series must give the first its own /posters/sh1.png and the second the one placeholder URL /posters/none
  249. mkdirSync(join(MPACKDB, 'posters'), { recursive: true });
  250. writeFileSync(join(MPACKDB, 'posters', 'sh1.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64'));
  251. // tracker.worldapi.org#9: the fake TMDB. EVERY run of the app/tool in this gate points at it with the gate's own
  252. // token — the runtime loads the project's real .env (TMDB_READ_TOKEN) by itself, the environment given here wins
  253. tmdb = await startFakeTmdb({ port: TMDB_PORT, shows: gateTmdbShows(), movies: gateTmdbMovies(), tvmaze: gateTvmaze(), search: gateSearch(), persons: gatePersons() });
  254. 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' };
  255. // mission 060 (tracker#27): the gate's own file of OLD short ids (the real one maps the old export's 702) — Gate Test Show's oldId
  256. // sh1 had '100jh'; '100ji' belonged to a show we don't have (reserved: never handed out)
  257. const OLD_SHORT_IDS = join(STORE, 'old-short-ids.json');
  258. writeFileSync(OLD_SHORT_IDS, JSON.stringify({ sh1: '100jh', 'gone-old': '100ji' }));
  259. // the short ids in the gate's storage, counted (tools/count-short-ids.hl — app stopped); per table [rows, withShortId, without, malformed]
  260. const countShortIds = () => execFileSync(join(APP, 'bin/hybriel'), ['tools/count-short-ids.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_OLD_SHORT_IDS: OLD_SHORT_IDS } }).toString();
  261. const rowsOf = (c, kind) => (c.match(new RegExp(kind + 's rows=(\\d+) withShortId=(\\d+) without=(\\d+) malformed=(\\d+)')) || []).slice(1).map(Number);
  262. // mission 062: every person / show has one, none twice (after a path that CREATES records, before the next start's backfill)
  263. const allHaveShortIds = (c) => { const p = rowsOf(c, 'person'), sh = rowsOf(c, 'show'); return p.length === 4 && p[0] > 0 && p[0] === p[1] && p[2] === 0 && p[3] === 0 && sh[0] === sh[1] && sh[2] === 0 && c.includes('duplicates across shows+persons=0'); };
  264. const sidOfHtml = (html) => (html.match(/id="shortid"[^>]*>([a-z0-9]{5})</) || [])[1] || null;
  265. const startServer = async (extra = {}) => {
  266. log = '';
  267. 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',
  268. 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_ART: '0', TRACKER_PHOTOS: '0', TRACKER_BACKFILL: '0', TRACKER_KINDS: '0', TRACKER_COLLECTIONS: '0', TRACKER_FRANCHISE_SEED: '0', TRACKER_REPAIR: '0', TRACKER_CREDITS: '0', TRACKER_DETAILS_PART_EPISODES: '1', TRACKER_OLD_SHORT_IDS: OLD_SHORT_IDS, ...extra }, stdio: ['ignore', 'pipe', 'pipe'] });
  269. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  270. for (let i = 0; ; i++) {
  271. try { if ((await fetch(BASE + '/')).ok) break; } catch {}
  272. if (i > 80) throw new Error('tracker did not come up\n' + log);
  273. await new Promise(r => setTimeout(r, 250));
  274. }
  275. };
  276. await startServer();
  277. check('tracker is up', true);
  278. // mission 060: the FIRST start gives every fixture record its short id in the background (the old one for sh1)
  279. for (let i = 0; i < 100 && !/short ids done/.test(log); i++) await new Promise(r => setTimeout(r, 100));
  280. const firstShortIds = log.split('\n').filter(l => l.startsWith('short ids')).join('\n');
  281. check('short ids: the first start finds every record without one, gives them theirs in the background, the old one kept (oldKept=1)',
  282. /short ids: (\d+) records, 0 with a short id, \1 without, 2 old ids/.test(firstShortIds) && /short ids: backfill start, \d+ records, 40 ms per batch/.test(firstShortIds)
  283. && /short ids done: assigned=\d+ oldKept=1 /.test(firstShortIds), firstShortIds);
  284. // tracker.worldapi.org#18, ONCE at the first start: a `summary` that is a copy of tmdbSummary (Gate Test Movie, as the migration
  285. // copied them) is cleared; Gate Test Show's own text stays; the marker file keeps it from running again
  286. check('#18 summaries (mission 070: moved, not deleted): the first start moves the 1 copied summary to migratedSummary, keeps the 1 own text, writes the marker',
  287. log.includes('summaries: moved 1 copies of tmdbSummary to migratedSummary, kept 1 own texts, errors 0, ') && existsSync(join(MPACKDB, 'summaries-moved.txt')), log);
  288. // tracker#37: the /people view (and the cast photos — the fixtures' casts have none yet) built before the first request
  289. check('tracker#37: the start builds the /people view before the first request (the 3 fixture people, 0 cast photos)', /people view: 3 people, 0 cast photos in \d+ ms/.test(log), log);
  290. browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  291. const page = await browser.newPage();
  292. globalThis.__gatePage = page;
  293. page.captureNetwork();
  294. await page.captureWs(); // tracker#17: which faces a season click sends (wsSent)
  295. // tracker#17: a FAKE DELAY for one face — while `window.__delayFace = { name, ms }` is set, an outgoing websocket frame
  296. // naming that face is sent `ms` later (the real socket, only held back), so the gate sees the state BEFORE the answer
  297. await page.addInitScript(`(() => {
  298. const Native = window.WebSocket;
  299. if (!Native) return;
  300. window.WebSocket = class extends Native {
  301. send(d) {
  302. const f = window.__delayFace;
  303. if (f && String(d).includes(f.name)) { setTimeout(() => super.send(d), f.ms); return; }
  304. return super.send(d);
  305. }
  306. };
  307. })()`);
  308. const ev = (e) => page.evaluate(e);
  309. const hydrated = () => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'hydrated' });
  310. // tracker#29: how a REAL check on the page looks (no copy): scrolled into view, the pointer moved off it (hover is accent);
  311. // its circle's fill + stroke and the check's stroke (computed), whether it is the topmost element there, and the SCREENSHOT
  312. // pixel inside the disc off the check mark (svg point 12,6) — background when empty, accent when solid
  313. const checkLook = async (sel) => {
  314. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 0, y: 0 });
  315. const g = JSON.parse(await ev(`JSON.stringify((() => { const b = document.querySelector(${JSON.stringify(sel)}); if (!b) return null; b.scrollIntoView({ block: 'center' });
  316. const s = b.querySelector('svg').getBoundingClientRect(), c = getComputedStyle(b.querySelector('circle')), p = getComputedStyle(b.querySelector('path'));
  317. const x = s.left + s.width * 12 / 24, y = s.top + s.height * 6 / 24, top = document.elementFromPoint(x, y);
  318. return { cls: b.getAttribute('class'), w: s.width, x: x + scrollX, y: y + scrollY, onTop: !!top && (top === b || b.contains(top)), fill: c.fill, stroke: c.stroke, mark: p.stroke }; })())`));
  319. if (!g) return null;
  320. const shot = await page.send('Page.captureScreenshot', { format: 'png', clip: { x: g.x, y: g.y, width: 1, height: 1, scale: 1 } });
  321. g.pixel = pngFirstPixel(Buffer.from(shot.data, 'base64'));
  322. return g;
  323. };
  324. // mission 047: every check icon AND season caret on the page is a real SVG element (not XHTML) and actually drawn
  325. const svgState = () => ev(`JSON.stringify((() => {
  326. const svgs = Array.from(document.querySelectorAll('button.checkicon svg, season-caret svg'));
  327. const shapes = Array.from(document.querySelectorAll('button.checkicon svg circle, button.checkicon svg path, season-caret svg path'));
  328. return { svgs: svgs.length, badNs: svgs.concat(shapes).filter(e => e.namespaceURI !== 'http://www.w3.org/2000/svg').length,
  329. undrawn: Array.from(document.querySelectorAll('button.checkicon svg circle')).filter(c => !(c.getBoundingClientRect().width > 0)).length };
  330. })())`);
  331. const svgOk = (st) => { const v = JSON.parse(st); return v.svgs > 0 && v.badNs === 0 && v.undrawn === 0; };
  332. // the caret's direction per season row, top to bottom (tracker#17): 'down' (closed, no transform) or 'up' (open,
  333. // rotate(180deg) = matrix(-1, 0, 0, -1, 0, 0)); waits out the caret's .15 s transition first
  334. const carets = async () => { await new Promise(r => setTimeout(r, 250)); return ev(`JSON.stringify(Array.from(document.querySelectorAll('season-row')).map(r => {
  335. const t = getComputedStyle(r.querySelector('season-caret')).transform;
  336. if (t === 'none') return 'down';
  337. const m = t.startsWith('matrix(') ? t.slice(7, -1).split(',').map(Number) : [];
  338. 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;
  339. }))`); };
  340. // tracker.worldapi.org#12: the header's link icons as [label, href, target, rel], in order
  341. 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')]))`);
  342. const linksAre = (got, want) => JSON.stringify(JSON.parse(got)) === JSON.stringify(want.map(([l, h]) => [l, h, '_blank', 'noopener']));
  343. 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/';
  344. // 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.
  345. // The new tab is closed again at once (it would load the real site).
  346. const clickOpensNewTab = async (sel) => {
  347. const seen = new Map();
  348. const off = browser.conn.onEvent((m) => {
  349. if ((m.method === 'Target.targetCreated' || m.method === 'Target.targetInfoChanged') && m.params.targetInfo.type === 'page') seen.set(m.params.targetInfo.targetId, m.params.targetInfo);
  350. });
  351. await browser.conn.send('Target.setDiscoverTargets', { discover: true });
  352. const known = new Set(seen.keys());
  353. const before = await ev('location.href');
  354. await page.click(sel);
  355. const fresh = () => [...seen.values()].find(i => !known.has(i.targetId) && i.url && i.url !== 'about:blank');
  356. for (let i = 0; i < 100 && !fresh(); i++) await new Promise(r => setTimeout(r, 50));
  357. const tab = fresh();
  358. off();
  359. await browser.conn.send('Target.setDiscoverTargets', { discover: false });
  360. if (tab) await browser.conn.send('Target.closeTarget', { targetId: tab.targetId }).catch(() => {});
  361. return { url: tab ? tab.url : null, canAccessOpener: tab ? tab.canAccessOpener : null, stayed: (await ev('location.href')) === before };
  362. };
  363. // tracker.worldapi.org#13: the poster tiles of a row/grid as "title|year|href|poster"
  364. 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);
  365. const titlesOf = async (sel) => (await tilesOf(sel)).map(t => t.split('|')[0]);
  366. // tracker.worldapi.org#28: the show page's cast + crew — every cast link [name, data-role, href]; what is VISIBLE, in order
  367. // (labels as "[Guest stars]"); the toggles' visible text; the ::after (character + comma) of a cast link by index
  368. const credits = () => ev(`JSON.stringify((() => {
  369. const vis = (e) => !!e && e.getClientRects().length > 0;
  370. const seq = (sel) => Array.from(document.querySelectorAll(sel)).filter(vis).map(e => /-LABEL$/.test(e.tagName) ? '[' + e.textContent + ']' : e.textContent);
  371. const cast = Array.from(document.querySelectorAll('show-cast a.artist-link'));
  372. const toggle = (id) => { const b = document.getElementById(id); return b && vis(b) ? Array.from(b.children).filter(vis).map(c => c.textContent).join('') : null; };
  373. return { cast: cast.map(a => [a.textContent, a.getAttribute('data-role'), a.getAttribute('href')]), castSeen: seq('show-cast a.artist-link, show-cast cast-label'),
  374. crew: Array.from(document.querySelectorAll('show-crew a.crew-link')).map(a => [a.textContent, a.getAttribute('href')]), crewSeen: seq('show-crew a.crew-link, show-crew crew-label'),
  375. castToggle: toggle('casttoggle'), crewToggle: toggle('crewtoggle'), after: cast.map(a => getComputedStyle(a, '::after').content), scrollW: document.documentElement.scrollWidth };
  376. })())`).then(JSON.parse);
  377. const movieRange = (from, to) => { const o = []; for (let i = from; i >= to; i--) o.push('Pager Movie ' + String(i).padStart(2, '0')); return o; };
  378. 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);
  379. // hybriel 64527baa (in the vendored ff51cf46): a face taking `session` also answers with a sync of every member derived
  380. // from session — a handler that ALSO appended the returned row would show it twice. After every face-driven change:
  381. // each row (by its button value) once. Waits 400 ms first so a late sync has landed.
  382. 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 }; })())`)); };
  383. const modalOpen = () => ev(`!!(document.querySelector('dialog#signin') && document.querySelector('dialog#signin').open)`);
  384. const loginAs = async (who) => {
  385. const code = await ident.selectorCode(who, APPKEY, BASE);
  386. await page.goto(BASE + '/login/callback?ident_code=' + code);
  387. await page.waitForSelector('#logout');
  388. await hydrated();
  389. };
  390. // tracker#32: the header's parts as boxes (null = not shown) and the content column's edges (content-first minus its padding)
  391. const headerState = () => ev(`JSON.stringify((() => {
  392. const vis = (e) => !!e && e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden' && e.getBoundingClientRect().width > 0;
  393. const box = (s) => { const e = document.querySelector(s); if (!vis(e)) return null; const r = e.getBoundingClientRect(); return { l: Math.round(r.left), r: Math.round(r.right), t: Math.round(r.top), b: Math.round(r.bottom) }; };
  394. const h = document.querySelector('application-header').getBoundingClientRect();
  395. const cf = document.querySelector('content-first'); const cs = getComputedStyle(cf); const cr = cf.getBoundingClientRect();
  396. const links = Array.from(document.querySelectorAll('application-header main-menu a')).filter(vis);
  397. return { h: Math.round(h.height), sw: document.documentElement.scrollWidth, toggle: box('button.menu-toggle'), brand: box('application-header a.brand'), loupe: box('button.search-toggle'),
  398. search: box('#app-search'), menu: links.length ? links.map(a => a.textContent).join('|') : null, menuL: links.length ? Math.round(links[0].getBoundingClientRect().left) : null,
  399. menuR: links.length ? Math.round(links[links.length - 1].getBoundingClientRect().right) : null,
  400. ident: box('button.ident-toggle'), identSvg: box('button.ident-toggle svg'), selector: box('#selector'), user: box('button.user-toggle'), userSvg: box('button.user-toggle svg'),
  401. colL: Math.round(cr.left + parseFloat(cs.paddingLeft)), colR: Math.round(cr.right - parseFloat(cs.paddingRight)) }; })())`).then(JSON.parse);
  402. let rows56 = {};
  403. // ---- signed out: header shows the selector + "Log in with ident", the page itself is empty ----
  404. await page.goto(BASE + '/');
  405. await hydrated();
  406. // ---- tracker#32 (mission 031): the WorldAPI theme's header, SIGNED OUT — the ident selector in the user icon's place ----
  407. check('signed out: the identity selector is in the header (the ident menu), no user icon', await ev(`!!document.querySelector('application-header ident-menu #selector') && !document.querySelector('button.user-toggle')`));
  408. check('signed out: no "Logout"; the ident dropdown holds "or log in at ident" (the login button flow)', (await ev(`!!document.querySelector('#ident-panel #loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));
  409. rows56 = {};
  410. for (const [w, mobile] of [[320, true], [390, true], [1280, false]]) {
  411. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: 800, deviceScaleFactor: mobile ? 2 : 1, mobile });
  412. await page.goto(BASE + '/');
  413. await hydrated();
  414. await page.waitFor(`!!document.querySelector('#selector').shadowRoot && !!document.querySelector('#selector').shadowRoot.querySelector('[part~="button"]')`, { timeout: 5000, label: 'selector drawn' });
  415. rows56[w] = await headerState();
  416. 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'));
  417. }
  418. const phoneOut = (m, w) => m.h <= 56 && m.sw <= w && m.toggle && m.brand && m.loupe && m.ident && m.toggle.r <= m.brand.l && m.brand.r <= m.loupe.l && m.loupe.r <= m.ident.l && m.ident.r <= w
  419. && m.identSvg && m.identSvg.r - m.identSvg.l > 20 && !m.search && !m.menu && !m.selector && !m.user && [m.toggle, m.brand, m.loupe, m.ident].every(b => b.t >= 0 && b.b <= m.h);
  420. check('tracker#32 signed out, phone (320/390): ☰ · brand · loupe · the ident icon far right in ONE row (≤ 56 px), no search field, no main menu, no selector until the icon is tapped, no sideways scroll',
  421. phoneOut(rows56[320], 320) && phoneOut(rows56[390], 390), JSON.stringify([rows56[320], rows56[390]]));
  422. const d56 = rows56[1280];
  423. check('tracker#32 signed out, desktop (1280): brand left · search field in the middle (centred between brand and menu) · Series | Shows | Movies | Genres | People | Franchises · the ident selector far right; no ☰, no loupe, no icon; flush with the content column',
  424. d56.h <= 56 && !d56.toggle && !d56.loupe && !d56.ident && !d56.user && d56.brand && d56.search && d56.selector && d56.menu === 'Series|Shows|Movies|Genres|People|Franchises'
  425. && d56.brand.r < d56.search.l && d56.search.r < d56.menuL && d56.menuR <= d56.selector.l && Math.abs(d56.brand.l - d56.colL) <= 1 && Math.abs(d56.selector.r - d56.colR) <= 1
  426. && Math.abs((d56.search.l + d56.search.r) / 2 - (d56.brand.r + d56.menuL) / 2) <= 2, JSON.stringify(d56));
  427. // the phone: the ident icon opens the dropdown with the selector; Esc closes it. The loupe opens the search field (focused); ☰ the
  428. // sidebar with the main menu
  429. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
  430. await page.goto(BASE + '/');
  431. await hydrated();
  432. await page.click('button.ident-toggle');
  433. await page.waitFor(`document.querySelector('#ident-panel').matches(':popover-open')`, { timeout: 5000, label: 'ident dropdown' }).catch(() => {});
  434. let st32 = await headerState();
  435. const ip = await ev(`JSON.stringify((() => { const r = document.querySelector('#ident-panel').getBoundingClientRect(); const a = document.querySelector('#loginbutton').getBoundingClientRect(); return { l: r.left, r: r.right, t: r.top, login: a.width > 0 && a.bottom <= r.bottom }; })())`).then(JSON.parse);
  436. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'header-ident-dropdown-390.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  437. check('tracker#32 phone signed out: the ident icon opens the dropdown under the bar at the right edge — the selector ("choose ident") and "or log in at ident" in it',
  438. !!st32.selector && ip.l >= 0 && ip.r <= 390 && ip.t >= st32.h - 1 && ip.login && st32.selector.r <= ip.r, JSON.stringify({ st32, ip }));
  439. await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
  440. await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
  441. check('tracker#32 phone: Esc closes the ident dropdown', await page.waitFor(`!document.querySelector('#ident-panel').matches(':popover-open')`, { timeout: 3000 }).then(() => true, () => false));
  442. await page.click('button.menu-toggle');
  443. await page.waitFor(`document.querySelector('#sidebar').matches(':popover-open')`, { timeout: 5000, label: 'sidebar' }).catch(() => {});
  444. const sb = await ev(`JSON.stringify((() => { const s = document.querySelector('#sidebar'); const r = s.getBoundingClientRect();
  445. return { open: s.matches(':popover-open'), l: r.left, w: r.width, links: Array.from(s.querySelectorAll('main-menu a')).filter(a => a.getClientRects().length > 0).map(a => a.textContent).join('|') }; })())`).then(JSON.parse);
  446. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'header-sidebar-390.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  447. check('tracker#32 phone: ☰ opens the sidebar overlay from the left with the main menu Series | Shows | Movies | Genres | People | Franchises', sb.open && sb.l === 0 && sb.w > 200 && sb.w <= 390 && sb.links === 'Series|Shows|Movies|Genres|People|Franchises', JSON.stringify(sb));
  448. await ev(`window.__m031 = 'same document'`);
  449. await page.click('#sidebar main-menu a[href="/movies"]');
  450. await page.waitFor(`location.pathname === '/movies' && !!document.querySelector('#movies-list')`, { timeout: 10000, label: 'sidebar → /movies' });
  451. await new Promise(r => setTimeout(r, 300));
  452. const mk = await ev(`JSON.stringify({ doc: window.__m031, open: document.querySelector('#sidebar').matches(':popover-open'), marked: Array.from(document.querySelectorAll('#sidebar main-menu a')).filter(a => getComputedStyle(a).color === 'rgb(78, 201, 176)').map(a => a.textContent).join('|') })`).then(JSON.parse);
  453. check('tracker#32 phone: a tap on "Movies" in the sidebar opens /movies (client-side), closes the overlay and marks Movies', mk.doc === 'same document' && !mk.open && mk.marked === 'Movies', JSON.stringify(mk));
  454. await page.click('button.search-toggle');
  455. await page.waitFor(`document.querySelector('#app-search').matches(':popover-open') && document.activeElement === document.querySelector('#app-search input')`, { timeout: 5000, label: 'search field' }).catch(() => {});
  456. const sf = await ev(`JSON.stringify((() => { const f = document.querySelector('#app-search'); const r = f.getBoundingClientRect(); const i = f.querySelector('input');
  457. return { open: f.matches(':popover-open'), focused: document.activeElement === i, t: Math.round(r.top), l: r.left, r: r.right, action: f.getAttribute('action'), name: i.name }; })())`).then(JSON.parse);
  458. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'header-search-390.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  459. check('tracker#32 phone: the loupe opens the search field under the bar, full width, focused (GET /find?q=)', sf.open && sf.focused && sf.t >= 50 && sf.l === 0 && sf.r === 390 && sf.action === '/find' && sf.name === 'q', JSON.stringify(sf));
  460. await page.type('#app-search input', 'gate');
  461. await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' });
  462. await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 });
  463. await page.waitFor(`location.pathname === '/search/gate' && !!document.querySelector('search-page') && document.querySelector('search-page').getAttribute('data-for') === 'gate'`, { timeout: 15000, label: 'header search → /search/gate' }).catch(() => {});
  464. check('tracker#32: Enter in the header\'s search field → /find?q=gate → /search/gate with our results', (await ev('location.pathname')) === '/search/gate' && (await ev(`document.querySelectorAll('search-row a.search-title').length`)) > 0, await ev('location.href'));
  465. await page.send('Emulation.clearDeviceMetricsOverride', {});
  466. await page.goto(BASE + '/');
  467. await hydrated();
  468. // ---- tracker.worldapi.org#13: the homepage signed OUT — no icon tiles, the text, "Movies" (→ /movies) with the 10
  469. // latest RELEASED movies (not the 2099 one), "Shows" (→ /shows) with the series by newest released episode ----
  470. 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
  471. && /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`)));
  472. check('home: the headings "Movies", "Series" and "Shows" link to /movies, /series and /shows (tracker#21)', (await ev(`[document.querySelector('#home-movies h2 a'), document.querySelector('#home-series h2 a'), document.querySelector('#home-shows h2 a')].map(a => a.textContent + ' ' + a.getAttribute('href')).join(',')`)) === 'Movies /movies,Series /series,Shows /shows');
  473. let tl = await tilesOf('#home-movies');
  474. 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',
  475. JSON.stringify(tl.map(t => t.split('|')[0])) === JSON.stringify(movieRange(30, 21)) && tl[0] === 'Pager Movie 30|2001|/movies/pager-movie-30|/posters/none', JSON.stringify(tl));
  476. tl = await tilesOf('#home-series');
  477. 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)',
  478. JSON.stringify(tl) === JSON.stringify(['Pager Older Series|2019|/series/pager-older-series|/posters/none', 'Gate Test Show|2024|/series/gate-test-show|/posters/sh1.png']), JSON.stringify(tl));
  479. 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))`));
  480. // ---- tracker.worldapi.org#4: /shows/:slug signed OUT — the header/genres/cast show, no checks ----
  481. await page.goto(BASE + '/series/gate-test-show');
  482. await hydrated();
  483. check('signed out: the show header renders (title, genre, cast)', (await ev(`document.querySelector('show-header h1 type-name').textContent`)) === 'Gate Test Show'
  484. && (await ev(`document.querySelector('.genre-pill').textContent`)) === 'Drama'
  485. && (await ev(`document.querySelector('.artist-link').textContent`)) === 'Test Actor');
  486. // ---- tracker.worldapi.org#12: link icons only for the ids the show has (before any sync: show 1 has tmdb + imdb) ----
  487. let lk = await extLinks();
  488. check('link icons: show 1 (tmdbId + imdbId only) has exactly TMDB (tv/1001) and IMDb (tt0000001), each target=_blank rel=noopener',
  489. linksAre(lk, [['TMDB', TMDB_TV + '1001'], ['IMDb', IMDB + 'tt0000001/']]), lk);
  490. check('link icons: monochrome badges (one colour for all, no image), small (≤ 1.6rem high)',
  491. (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) })()`)));
  492. const nt = await clickOpensNewTab('a.extlink[data-site="imdb"]');
  493. 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));
  494. await page.goto(BASE + '/series/unwatched-gate-show');
  495. await hydrated();
  496. lk = await extLinks();
  497. 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);
  498. await page.goto(BASE + '/movies/gate-test-movie');
  499. await hydrated();
  500. lk = await extLinks();
  501. check('link icons: a movie (tmdbId only) has exactly TMDB as /movie/1003', linksAre(lk, [['TMDB', TMDB_MOVIE + '1003']]), lk);
  502. await page.goto(BASE + '/series/gate-test-show');
  503. await hydrated();
  504. check('signed out: the latest season (2) is expanded, season 1 collapsed', (await ev(`document.querySelectorAll('episode-row').length`)) === 1);
  505. // ---- mission 058 (tracker#22): every episode row shows its air date (rendered on the server); (#25) the genre pill → /genres/<genre>
  506. check('#22: the episode row shows its air date (S02E01 "Season Two Opener" 2099-01-01), at the row\'s right, muted',
  507. (await ev(`document.querySelector('episode-row episode-date').textContent`)) === '2099-01-01'
  508. && (await ev(`(() => { const r = document.querySelector('episode-row'), d = r.querySelector('episode-date'), t = r.querySelector('episode-title'); return d.getBoundingClientRect().left >= t.getBoundingClientRect().right && getComputedStyle(d).color !== getComputedStyle(t).color })()`)));
  509. check('#25: the genre pill links to /genres/Drama', (await ev(`document.querySelector('.genre-pill').getAttribute('href')`)) === '/genres/Drama');
  510. // mission 047: signed out the checks and "Follow" are VISIBLE; a click opens the sign-in modal and writes nothing
  511. check('signed out (mission 047): the season + episode checks are visible (drawn SVG) and so is "Follow"',
  512. (await ev(`document.querySelectorAll('button.checkicon').length`)) === 3 && svgOk(await svgState())
  513. && (await ev(`document.querySelector('#follow').textContent`)) === 'Follow' && (await ev(`document.querySelector('#follow').getBoundingClientRect().width > 0`)), await svgState());
  514. check('signed out: the sign-in modal is closed at first', !(await modalOpen()));
  515. await page.click('button.checkicon[value="ep0000000000ep0e3"]');
  516. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after a check click' }).catch(() => {});
  517. 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',
  518. (await modalOpen()) && (await ev(`document.querySelector('dialog#signin p').textContent`)) === 'You need to sign in to follow shows and mark episodes.'
  519. && (await ev(`document.querySelector('#signin-link').getAttribute('href')`)).startsWith(ident.base + '/login?key=')
  520. && (await ev(`document.querySelector('#signin-link').getBoundingClientRect().width > 0`))
  521. && (await ev(`Array.from(document.querySelectorAll('dialog#signin footer button')).map(b => b.textContent).join()`)) === 'Close',
  522. await ev(`document.querySelector('dialog#signin') ? document.querySelector('dialog#signin').outerHTML.slice(0, 600) : 'no dialog'`));
  523. 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')); }
  524. await page.click('dialog#signin footer button');
  525. await page.waitFor(`!document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal closed' }).catch(() => {});
  526. check('signed out: Close closes the modal; the check stayed unwatched (nothing written)', !(await modalOpen())
  527. && (await ev(`document.querySelector('button.checkicon[value="ep0000000000ep0e3"]').getAttribute('class')`)) === 'checkicon regular');
  528. await page.click('#follow');
  529. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after Follow' }).catch(() => {});
  530. check('signed out: "Follow" opens the same modal and stays "Follow"', (await modalOpen()) && (await ev(`document.querySelector('#follow').textContent`)) === 'Follow');
  531. await page.click('dialog#signin footer button');
  532. await page.click('button.checkicon[value="se000000000se0s1"]');
  533. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after a season check' }).catch(() => {});
  534. check('signed out: a SEASON check opens the modal too', await modalOpen());
  535. await page.click('dialog#signin footer button');
  536. await page.goto(BASE + '/series/gate-test-show');
  537. await hydrated();
  538. check('signed out: after a reload every check is still unwatched (the clicks wrote nothing)',
  539. (await ev(`Array.from(document.querySelectorAll('button.checkicon')).every(b => b.getAttribute('class') === 'checkicon regular')`)));
  540. // ---- mission 058 (tracker#24): a MOVIE's page has a watched check (the episodes' icon); signed out a click opens the modal
  541. await page.goto(BASE + '/movies/gate-test-movie');
  542. await hydrated();
  543. const movieWatch = () => ev(`JSON.stringify((() => { const b = document.querySelector('#moviewatch'); return b ? { cls: b.getAttribute('class'), label: document.querySelector('movie-watch-label').textContent, n: document.querySelectorAll('#moviewatch').length,
  544. svg: b.querySelector('svg').namespaceURI === 'http://www.w3.org/2000/svg' && b.querySelector('circle').getBoundingClientRect().width > 0 } : null; })())`).then(JSON.parse);
  545. let mw = await movieWatch();
  546. check('#24 signed out: the movie page shows the watched check (drawn SVG, "checkicon regular") with "Not watched"; a series page has none',
  547. mw && mw.cls === 'checkicon regular' && mw.label === 'Not watched' && mw.svg && mw.n === 1, JSON.stringify(mw));
  548. await page.click('#moviewatch');
  549. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after the movie check' }).catch(() => {});
  550. check('#24 signed out: a click opens the sign-in modal ("You need to sign in to follow movies and mark them watched.", the ident link)',
  551. (await modalOpen()) && (await ev(`document.querySelector('dialog#signin p').textContent`)) === 'You need to sign in to follow movies and mark them watched.'
  552. && (await ev(`document.querySelector('#signin-link').getAttribute('href')`)).startsWith(ident.base + '/login?key='));
  553. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'movie-signed-out-modal-1280.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  554. await page.click('dialog#signin footer button');
  555. await page.goto(BASE + '/movies/gate-test-movie');
  556. await hydrated();
  557. mw = await movieWatch();
  558. check('#24 signed out: after a reload the movie is still not watched (nothing written)', mw && mw.cls === 'checkicon regular', JSON.stringify(mw));
  559. // ---- tracker.worldapi.org#5: /my/unwatched signed OUT — no rows, no checks ----
  560. await page.goto(BASE + '/my/unwatched');
  561. await hydrated();
  562. check('signed out: /my/unwatched shows no rows and asks to sign in', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 0
  563. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');
  564. // ---- tracker.worldapi.org#6: /my/schedule signed OUT — no rows, no checks ----
  565. await page.goto(BASE + '/my/schedule');
  566. await hydrated();
  567. check('signed out: /my/schedule shows no rows and asks to sign in', (await ev(`document.querySelectorAll('schedule-row').length`)) === 0
  568. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');
  569. // ---- tracker.worldapi.org#7: /my/series signed OUT — no rows, the same sign-in message ----
  570. await page.goto(BASE + '/my/series');
  571. await hydrated();
  572. check('signed out: /my/series shows no rows and asks to sign in', (await ev(`document.querySelectorAll('my-show-row').length`)) === 0
  573. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the series you follow.');
  574. // ==== tracker.worldapi.org#14: THE SEARCH, signed OUT — the header's magnifier, our database as you type, "Fetch from web"
  575. // (the fake TMDB's search/multi), "Add" → the sign-in modal ====
  576. const searchFor = () => ev(`document.querySelector('search-page') ? document.querySelector('search-page').getAttribute('data-for') : null`);
  577. 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]))`);
  578. const searchPeople = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('#people .search-person')).map(a => [a.textContent, a.getAttribute('href')]))`);
  579. 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]))`);
  580. const typeSearch = async (text) => {
  581. await page.type('#searchinput', text, { clear: true });
  582. await page.waitFor(`document.querySelector('search-page').getAttribute('data-for') === ${JSON.stringify(text)}`, { timeout: 10000, label: 'results for ' + text });
  583. };
  584. const GATE_TITLES = [['Gate Test Show (2024)', '/series/gate-test-show', 'Series'], ['Gate Test Movie (1996)', '/movies/gate-test-movie', 'Movie'], ['Unwatched Gate Show (2025)', '/series/unwatched-gate-show', 'Series']];
  585. // tracker#32: the header's search field (the theme's AppHeader form) sends GET /find?q=… → 302 /search/<text>
  586. const f32 = [];
  587. for (const q of ['gate', 'star%20trek', 'a%2Fb', '']) { const r = await fetch(BASE + '/find?q=' + q, { redirect: 'manual' }); f32.push(r.status + ' ' + r.headers.get('location')); }
  588. check('search: the header has the search field (GET /find, name q, a drawn loupe for the phone); /find?q=gate → 302 /search/gate, "a/b" → "a b", empty → /search',
  589. (await ev(`document.querySelector('#app-search').getAttribute('action') + ' ' + document.querySelector('#app-search input').name`)) === '/find q'
  590. && (await ev(`(() => { const s = document.querySelector('button.search-toggle svg'); return s.namespaceURI === 'http://www.w3.org/2000/svg' && !!s.querySelector('circle') })()`))
  591. && JSON.stringify(f32) === JSON.stringify(['302 /search/gate', '302 /search/star%20trek', '302 /search/a%20b', '302 /search']), JSON.stringify(f32));
  592. await page.goto(BASE + '/search');
  593. await hydrated();
  594. await ev(`window.__w052 = 'same document'`);
  595. check('search: /search opens with an empty search field and "Search our shows, movies and people."',
  596. (await ev('window.__w052')) === 'same document' && (await ev(`document.querySelector('#searchinput').value`)) === ''
  597. && (await ev(`document.querySelector('#searchnote').textContent`)) === 'Search our shows, movies and people.' && !(await ev(`!!document.querySelector('#fetchweb')`)));
  598. await typeSearch('g');
  599. 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')`)));
  600. await page.type('#searchinput', 'ate');
  601. await page.waitFor(`document.querySelector('search-page').getAttribute('data-for') === 'gate'`, { timeout: 10000, label: 'results for gate' });
  602. let st14 = await searchTitles();
  603. 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)',
  604. st14 === JSON.stringify(GATE_TITLES) && (await ev('location.pathname')) === '/search/gate' && (await ev('window.__w052')) === 'same document', st14);
  605. await typeSearch('test act');
  606. 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());
  607. await typeSearch('GATE tést');
  608. check('search: case and accents do not matter ("GATE tést" = gate test); the address carries the text encoded',
  609. (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)) && (await ev('location.pathname')) === '/search/GATE%20t%C3%A9st', (await searchTitles()) + ' ' + (await ev('location.pathname')));
  610. await typeSearch('zzqq');
  611. 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')`)));
  612. await page.goto(BASE + '/search/GATE%20t%C3%A9st');
  613. await hydrated();
  614. check('search: the address alone (reload / shared link) renders the same results on the server, the text in the field',
  615. (await ev(`document.querySelector('#searchinput').value`)) === 'GATE tést' && (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)) && (await searchFor()) === 'GATE tést');
  616. const bad14 = await fetch(BASE + '/search/x%zz');
  617. const bad14b = await fetch(BASE + '/search/%E0%80');
  618. check('search: a malformed address is no error — /search/x%zz 200 with the text taken literally, /search/%E0%80 (broken UTF-8) 200',
  619. bad14.status === 200 && (await bad14.text()).includes('value="x%zz"') && bad14b.status === 200 && (await bad14b.text()).includes('<search-page'), bad14.status + ' ' + bad14b.status);
  620. await typeSearch('gate/te');
  621. 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',
  622. (await ev('location.pathname')) === '/search/gate%20te' && (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)), await ev('location.pathname'));
  623. await page.goto(BASE + '/search/gate%20te');
  624. await hydrated();
  625. check('search: … reloaded', (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)) && (await ev(`document.querySelector('#searchinput').value`)) === 'gate te');
  626. await page.goto(BASE + '/search/gate');
  627. await hydrated();
  628. const searchesBefore = tmdb.searchRequests.length;
  629. await page.click('#fetchweb');
  630. await page.waitFor(`document.querySelectorAll('web-row').length > 0 || !!document.querySelector('#webnote')`, { timeout: 10000, label: 'web results' });
  631. let wl = await webList();
  632. const GATE_WEB = [['Gate Import Show (2023)', 'tv:1004', 'TV'], ['Gate Import Movie (2001)', 'movie:1005', 'Movie'], ['Gate Broken Show', 'tv:1006', 'TV']];
  633. 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',
  634. wl === JSON.stringify(GATE_WEB) && tmdb.searchRequests.length === searchesBefore + 1 && tmdb.searchRequests[searchesBefore].path === '/3/search/multi?query=gate&include_adult=false&page=1'
  635. && tmdb.searchRequests[searchesBefore].authorization === 'Bearer gate-token', wl + ' ' + JSON.stringify(tmdb.searchRequests));
  636. check('search: the web rows\' small posters come from TMDB\'s image host (w92); one without a poster gets /posters/none',
  637. (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']));
  638. const requestsBefore = tmdb.requests.length;
  639. await page.click('web-row button.add[value="tv:1004"]');
  640. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after Add' }).catch(() => {});
  641. check('search: signed out, "Add" opens the sign-in modal ("You need to sign in to add titles.", sign-in link) and sends nothing',
  642. (await modalOpen()) && (await ev(`document.querySelector('dialog#signin p').textContent`)) === 'You need to sign in to add titles.'
  643. && (await ev(`document.querySelector('#signin-link').getAttribute('href')`)).startsWith(ident.base + '/login?key=') && tmdb.requests.length === requestsBefore);
  644. 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'));
  645. await page.click('dialog#signin footer button');
  646. await page.waitFor(`!document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal closed' }).catch(() => {});
  647. check('search: Close closes it; the web list is still there', !(await modalOpen()) && (await webList()) === JSON.stringify(GATE_WEB));
  648. // ---- tracker.worldapi.org#3: no bottom border on the header, no border on filled buttons ----
  649. check('the header has no bottom border', (await ev(`getComputedStyle(document.querySelector('application-header')).borderBottomWidth`)) === '0px');
  650. check('"Log in with ident" has no border', (await ev(`getComputedStyle(document.querySelector('#loginbutton')).borderTopWidth`)) === '0px');
  651. check('the ident selector\'s "choose ident" button has no border', (await ev(`getComputedStyle(document.querySelector('#selector').shadowRoot.querySelector('[part~="button"]')).borderTopWidth`)) === '0px');
  652. // ---- tracker.worldapi.org#8: the old addresses answer a permanent redirect to the /my/ ones ----
  653. for (const [from, to] of [['/unwatched', '/my/unwatched'], ['/schedule', '/my/schedule']]) {
  654. const m = await fetch(BASE + from, { redirect: 'manual' });
  655. check(`${from} answers 301 → ${to}`, m.status === 301 && m.headers.get('location') === to, m.status + ' ' + m.headers.get('location'));
  656. }
  657. await page.goto(BASE + '/unwatched');
  658. await hydrated();
  659. check('the browser following /unwatched lands on /my/unwatched', (await ev(`location.pathname`)) === '/my/unwatched' && !!(await ev(`document.querySelector('unwatched-page')`)));
  660. // ---- tracker.worldapi.org#8: the show page says "1 episode" for one, "2 episodes" for two ----
  661. await page.goto(BASE + '/series/gate-test-show');
  662. await hydrated();
  663. 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']),
  664. JSON.stringify(await ev(`Array.from(document.querySelectorAll('season-count')).map(e => e.textContent)`)));
  665. let r = await fetch(BASE + '/login/callback', { redirect: 'manual' });
  666. check('login: /login/callback without a code → /login/failed', r.status === 302 && r.headers.get('location') === '/login/failed');
  667. // ---- sign in: the header selector flips to signed in, "Log out" appears, page stays empty ----
  668. await loginAs(alice);
  669. check('signed in (tracker#32): the user icon instead of the ident selector; "Logout" in its dropdown, no "Log in with ident"', (await ev(`!!document.querySelector('button.user-toggle') && !document.querySelector('#selector')`))
  670. && (await ev(`!!document.querySelector('#user-menu #logout')`)) && !(await ev(`!!document.querySelector('#loginbutton')`)));
  671. // tracker#32: the user menu at 390 and 1280 — the icon far right, its dropdown ends with Settings, Logout
  672. const um = {};
  673. for (const [w, mobile] of [[390, true], [1280, false]]) {
  674. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: 844, deviceScaleFactor: mobile ? 2 : 1, mobile });
  675. await page.goto(BASE + '/series/gate-test-show');
  676. await hydrated();
  677. const hs = await headerState();
  678. await page.click('button.user-toggle');
  679. await page.waitFor(`document.querySelector('#user-menu').matches(':popover-open')`, { timeout: 5000, label: 'user dropdown' }).catch(() => {});
  680. um[w] = { hs, menu: await ev(`JSON.stringify((() => { const n = document.querySelector('#user-menu'); const r = n.getBoundingClientRect();
  681. return { open: n.matches(':popover-open'), l: Math.round(r.left), r: Math.round(r.right), t: Math.round(r.top), items: Array.from(n.querySelectorAll('a, button')).map(e => e.textContent.trim()).join('|'),
  682. marked: Array.from(document.querySelectorAll('application-header main-menu a')).filter(a => getComputedStyle(a).color === 'rgb(78, 201, 176)').map(a => a.textContent).join('|') }; })())`).then(JSON.parse) };
  683. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `header-user-menu-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  684. }
  685. const ITEMS32 = 'Unwatched|Schedule|My series|My shows|My movies|Settings|Logout';
  686. check('tracker#32 phone signed in: ☰ · brand · loupe · the user icon far right in one row; its dropdown under the bar inside the screen: ' + ITEMS32,
  687. um[390].hs.toggle && um[390].hs.brand && um[390].hs.loupe && um[390].hs.user && um[390].hs.loupe.r <= um[390].hs.user.l && um[390].hs.user.r <= 390 && !um[390].hs.ident && um[390].hs.h <= 56 && um[390].hs.sw <= 390
  688. && um[390].menu.open && um[390].menu.items === ITEMS32 && um[390].menu.l >= 0 && um[390].menu.r <= 390 && um[390].menu.t >= um[390].hs.h - 1, JSON.stringify(um[390]));
  689. const d32 = um[1280].hs;
  690. check('tracker#32 desktop signed in: brand · search · main menu · the user icon far right, flush with the content column; the dropdown ends flush with it too; on a series page "Series" is marked',
  691. !d32.toggle && !d32.loupe && d32.search && d32.menu === 'Series|Shows|Movies|Genres|People|Franchises' && d32.menuR <= d32.user.l && Math.abs(d32.brand.l - d32.colL) <= 1 && Math.abs(d32.userSvg.r - d32.colR) <= 1
  692. && um[1280].menu.open && um[1280].menu.items === ITEMS32 && Math.abs(um[1280].menu.r - d32.colR) <= 1 && um[1280].menu.marked === 'Series', JSON.stringify(um[1280]));
  693. await ev(`window.__m031 = 'same document'`);
  694. await page.click('#user-menu a[href="/settings"]');
  695. await page.waitFor(`location.pathname === '/settings' && !!document.querySelector('settings-page')`, { timeout: 10000, label: 'Settings' }).catch(() => {});
  696. check('tracker#32: "Settings" in the dropdown opens /settings (client-side), the dropdown closes; tracker#37: the page is only the heading "Settings"', (await ev('window.__m031')) === 'same document'
  697. && !(await ev(`document.querySelector('#user-menu').matches(':popover-open')`)) && (await ev(`document.querySelector('settings-page').innerHTML.replace(/<!--[^>]*-->/g, '').trim()`)) === '<h1>Settings</h1>',
  698. await ev(`document.querySelector('settings-page').innerHTML`));
  699. // /genres and /people (the main menu's Genres and People)
  700. await page.click('application-header main-menu a[href="/genres"]');
  701. await page.waitFor(`location.pathname === '/genres' && !!document.querySelector('#genres-list')`, { timeout: 10000, label: '/genres' }).catch(() => {});
  702. const g32 = await ev(`JSON.stringify({ links: Array.from(document.querySelectorAll('#genres-list a.genre-link')).map(a => a.querySelector('genre-name').textContent + ' ' + a.getAttribute('href') + ' ' + a.querySelector('genre-count').textContent),
  703. marked: Array.from(document.querySelectorAll('application-header main-menu a')).filter(a => getComputedStyle(a).color === 'rgb(78, 201, 176)').map(a => a.textContent).join('|') })`).then(JSON.parse);
  704. check('tracker#32: /genres lists the genres with titles, A–Z, each with its count and link (Drama → /genres/Drama); "Genres" is marked', g32.links.length > 0 && g32.links.some(l => /^Drama \/genres\/Drama \d+$/.test(l))
  705. && JSON.stringify(g32.links) === JSON.stringify([...g32.links].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)) && g32.marked === 'Genres', JSON.stringify(g32));
  706. await page.click('application-header main-menu a[href="/people"]');
  707. await page.waitFor(`location.pathname === '/people' && !!document.querySelector('#people-list')`, { timeout: 10000, label: '/people' }).catch(() => {});
  708. // tracker#37 (mission 032): /people = everyone, the one updated last first — photo + name tiles (the name in the person colour), 24 per page
  709. const pp37 = await ev(`JSON.stringify({ count: document.querySelector('#people-list .list-count').textContent, letters: document.querySelectorAll('a.letter-link').length,
  710. tiles: Array.from(document.querySelectorAll('#people-list a.tile.person-tile')).map(a => [a.querySelector('tile-title').textContent, a.getAttribute('href'), a.querySelector('img.tile-poster').getAttribute('src'), getComputedStyle(a.querySelector('tile-title')).color]),
  711. marked: Array.from(document.querySelectorAll('application-header main-menu a')).filter(a => getComputedStyle(a).color === 'rgb(78, 201, 176)').map(a => a.textContent).join('|') })`).then(JSON.parse);
  712. check('tracker#37: /people lists the people as photo + name tiles (→ /person/<slug>, the name in the person colour), "N people, last updated first · page 1 of M", no letters; "People" marked',
  713. pp37.tiles.length > 0 && pp37.tiles.length <= 24 && pp37.tiles.every(t => /^\/person\/\S+$/.test(t[1]) && t[2] && t[3] === 'rgb(206, 145, 120)') && /^\d+ people, last updated first · page 1 of \d+$/.test(pp37.count)
  714. && pp37.letters === 0 && pp37.marked === 'People', JSON.stringify(pp37));
  715. const ph37none = [await (await fetch(BASE + '/profiles/none')).text(), await (await fetch(BASE + '/posters/none')).text()];
  716. check('tracker#37: a person without a photo shows the "no photo" placeholder (/profiles/none), a title still "no poster"',
  717. />no photo</.test(ph37none[0]) && />no poster</.test(ph37none[1]), JSON.stringify(ph37none));
  718. await page.send('Emulation.clearDeviceMetricsOverride', {});
  719. await page.goto(BASE + '/');
  720. await hydrated();
  721. check('home signed in: five icon tiles (tracker#21) → /my/unwatched, /my/schedule, /my/series, /my/shows, /my/movies, each with a drawn svg icon; the text + the rows below them, no sign-in hint',
  722. (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(',')`))
  723. === 'Unwatched /my/unwatched true,Schedule /my/schedule true,Series /my/series true,Shows /my/shows true,Movies /my/movies true'
  724. && (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`))
  725. && !/Log in with ident/.test(await ev(`document.querySelector('home-intro').textContent`)) && (await ev(`document.querySelectorAll('#home-movies a.tile').length`)) === 10);
  726. await ev(`window.__w050 = 'same document'`);
  727. await page.click('a.hometile[href="/my/movies"]');
  728. await page.waitFor(`location.pathname === '/my/movies' && !!document.querySelector('my-movies-page')`, { timeout: 10000, label: 'icon → /my/movies' });
  729. 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'
  730. && (await ev(`document.querySelector('my-movies-page p.muted').textContent`)) === 'You follow no movies yet.');
  731. check('"Logout" has no border', (await ev(`getComputedStyle(document.querySelector('#logout')).borderTopWidth`)) === '0px');
  732. check('tracker#32: the header brand "tracker" is the theme\'s (white, weight 800)', (await ev(`(() => { const c = getComputedStyle(document.querySelector('application-header .brand')); return c.fontWeight + ' ' + c.color })()`)) === '800 rgb(245, 250, 255)');
  733. // ---- tracker.worldapi.org#5: /unwatched signed IN — only already-released, unwatched episodes of
  734. // followed shows, newest release first; a click removes the row. Run BEFORE the show-page checks
  735. // below (which toggle these same fixture episodes watched) so all three still start unwatched here.
  736. await page.goto(BASE + '/my/unwatched');
  737. await hydrated();
  738. const unwatchedRowTexts = () => ev(`Array.from(document.querySelectorAll('.unwatched-show')).map(e => e.textContent)`);
  739. check('signed in: /my/unwatched lists only already-released episodes, newest release first (not the future one), titles with the year',
  740. JSON.stringify(await unwatchedRowTexts()) === JSON.stringify(['Gate Test Show (2024)', 'Gate Test Show (2024)'])
  741. && (await ev(`document.querySelectorAll('unwatched-row').length`)) === 2);
  742. check('signed in: /my/unwatched shows show title, the episode as S01E02 + title and release date, linking to the show',
  743. (await ev(`document.querySelector('unwatched-row unwatched-episode').textContent`)) === 'S01E02 · Second'
  744. && (await ev(`document.querySelector('unwatched-row unwatched-date').textContent`)) === '2020-06-15'
  745. && (await ev(`document.querySelector('unwatched-row .unwatched-show').getAttribute('href')`)) === '/series/gate-test-show');
  746. check('signed in: /my/unwatched starts with regular (unwatched) check icons', (await ev(`document.querySelectorAll('unwatched-row button.checkicon.regular').length`)) === 2);
  747. let looks = [await checkLook('unwatched-row:nth-of-type(1) button.checkicon'), await checkLook('unwatched-row:nth-of-type(2) button.checkicon')];
  748. check('#29: /my/unwatched — every row\'s check LOOKS empty on screen (muted outline, nothing filled, background pixel inside the disc)',
  749. looks.every(lookEmpty), JSON.stringify(looks));
  750. await page.click('unwatched-row button.checkicon');
  751. await page.waitFor(`document.querySelectorAll('unwatched-row').length === 1`, { timeout: 10000, label: 'unwatched row removed' });
  752. let once = await rowsOnce('unwatched-row button.checkicon');
  753. check('session-sync: after the /my/unwatched click (face unwatchedToggle) every row appears once', once.unique && once.n === 1, JSON.stringify(once));
  754. check('click an unwatched episode\'s check: it disappears from the list', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 1
  755. && (await ev(`document.querySelector('unwatched-episode').textContent`)) === 'S01E01 · Pilot');
  756. await page.goto(BASE + '/my/unwatched');
  757. await hydrated();
  758. check('reload: the watch from /my/unwatched is server-side, not just client state', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 1
  759. && (await ev(`document.querySelector('unwatched-episode').textContent`)) === 'S01E01 · Pilot');
  760. // ---- tracker.worldapi.org#7: /my/series signed IN — followed shows, newest follow first, small
  761. // poster, title linking to the show, the last watched episode (only ep 2 "Second" is watched so far)
  762. const myShows = () => ev(`Array.from(document.querySelectorAll('my-show-row')).map(r => ({
  763. title: r.querySelector('.myshow-title') && r.querySelector('.myshow-title').textContent,
  764. href: r.querySelector('.myshow-title') && r.querySelector('.myshow-title').getAttribute('href'),
  765. poster: r.querySelector('img.myshow-poster') && r.querySelector('img.myshow-poster').getAttribute('src'),
  766. posterLoaded: !!(r.querySelector('img.myshow-poster') && r.querySelector('img.myshow-poster').complete && r.querySelector('img.myshow-poster').naturalWidth > 0),
  767. episode: r.querySelector('my-show-episode') ? r.querySelector('my-show-episode').textContent : null }))`);
  768. const waitPostersLoaded = () => page.waitFor(`Array.from(document.querySelectorAll('img.myshow-poster')).every(i => i.complete && i.naturalWidth > 0)`, { timeout: 10000, label: 'posters loaded' });
  769. await page.goto(BASE + '/my/series');
  770. await hydrated();
  771. await waitPostersLoaded();
  772. let ms = await myShows();
  773. check('signed in: /my/series 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));
  774. check('signed in: /my/series titles link to the show pages', ms.length === 2 && ms[0].href === '/series/unwatched-gate-show' && ms[1].href === '/series/gate-test-show', JSON.stringify(ms));
  775. check('signed in: /my/series 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));
  776. check('signed in: /my/series 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));
  777. check('/my/series: the poster is small (2.5rem wide)', (await ev(`getComputedStyle(document.querySelector('img.myshow-poster')).width`)) === '40px');
  778. // ---- tracker.worldapi.org#6: /schedule signed IN — only NOT-yet-released episodes of followed
  779. // shows, soonest first, no check icons at all (read-only page, unaffected by any watch toggle above)
  780. await page.goto(BASE + '/my/schedule');
  781. await hydrated();
  782. check('signed in: /my/schedule lists only the not-yet-released episode, title with the year', (await ev(`document.querySelectorAll('schedule-row').length`)) === 1
  783. && (await ev(`document.querySelector('.schedule-show').textContent`)) === 'Gate Test Show (2024)');
  784. check('signed in: /my/schedule shows show title, the episode as S02E01 + title, release date, linking to the show, no checks',
  785. (await ev(`document.querySelector('schedule-row schedule-episode').textContent`)) === 'S02E01 · Season Two Opener'
  786. && (await ev(`document.querySelector('schedule-row schedule-date').textContent`)) === '2099-01-01'
  787. && (await ev(`document.querySelector('schedule-row .schedule-show').getAttribute('href')`)) === '/series/gate-test-show'
  788. && (await ev(`document.querySelectorAll('schedule-row button.checkicon').length`)) === 0);
  789. // ---- tracker.worldapi.org#4: /shows/:slug signed IN — check icons, episode toggle, season toggle,
  790. // collapse/expand, and a reload proves the watch really is server-side ----
  791. const EP3 = 'ep0000000000ep0e3'; // season 2 (expanded by default), episode 1 "Season Two Opener"
  792. const EP1 = 'ep0000000000ep0e1'; // season 1 (collapsed by default), episode 1 "Pilot"
  793. const EP2 = 'ep0000000000ep0e2'; // season 1, episode 2 "Second"
  794. const SEASON1 = 'se000000000se0s1';
  795. const checkClass = (id) => ev(`document.querySelector('button.checkicon[value="${id}"]').getAttribute('class')`);
  796. const clickValue = (sel, id) => page.click(`${sel}[value="${id}"]`);
  797. await page.goto(BASE + '/series/gate-test-show');
  798. await hydrated();
  799. check('signed in: episode check icons show, starting "regular" (not watched)', (await checkClass(EP3)) === 'checkicon regular');
  800. looks = [await checkLook(`button.checkicon[value="${EP3}"]`), await checkLook(`button.checkicon[value="${SEASON1}"]`)];
  801. check('#29: show page — an unwatched episode\'s and an unwatched season\'s check LOOK empty on screen', looks.every(lookEmpty), JSON.stringify(looks));
  802. await clickValue('button.checkicon', EP3);
  803. await page.waitFor(`document.querySelector('button.checkicon[value="${EP3}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'ep3 solid' });
  804. check('click an episode check: it turns solid', (await checkClass(EP3)) === 'checkicon solid');
  805. looks = [await checkLook(`button.checkicon[value="${EP3}"]`)];
  806. check('#29: show page — the watched episode\'s check LOOKS solid on screen (accent pixel inside the disc)', looks.every(lookSolid), JSON.stringify(looks));
  807. check('season 1 starts collapsed (no episode rows of it yet)', (await ev(`document.querySelectorAll('episode-row').length`)) === 1);
  808. await clickValue('button.checkicon', SEASON1);
  809. await page.waitFor(`document.querySelector('button.checkicon[value="${SEASON1}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'season1 solid' });
  810. check('click a season check: the season itself turns solid', (await checkClass(SEASON1)) === 'checkicon solid');
  811. await clickValue('button.season-toggle', SEASON1);
  812. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  813. check('expanding season 1 shows its episodes already solid (the season check watched them too)',
  814. (await checkClass(EP1)) === 'checkicon solid' && (await checkClass(EP2)) === 'checkicon solid');
  815. await page.goto(BASE + '/series/gate-test-show');
  816. await hydrated();
  817. check('reload: episode 3\'s watch is server-side, not just client state', (await checkClass(EP3)) === 'checkicon solid');
  818. await clickValue('button.season-toggle', SEASON1);
  819. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  820. 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');
  821. // ---- mission 047, the creator's additions on the show page (signed in) ----
  822. check('episodes are not indented: an episode\'s check is exactly under its season\'s check (same left edge)',
  823. (await ev(`Math.abs(document.querySelector('episode-row button.checkicon').getBoundingClientRect().left - document.querySelector('season-row button.checkicon').getBoundingClientRect().left) < 0.5`)));
  824. check('artist links are orange #ce9178 (not red)', (await ev(`getComputedStyle(document.querySelector('.artist-link')).color`)) === 'rgb(206, 145, 120)');
  825. // (mission 058 checked the unwatched look on a hidden COPY of a check here; tracker#29 checks REAL checks instead, above and
  826. // in the #23 block below, where an episode and its season really are unwatched)
  827. looks = [await checkLook(`button.checkicon[value="${SEASON1}"]`), await checkLook(`button.checkicon[value="${EP1}"]`)];
  828. check('a watched check is solid on screen: filled accent disc, dark check (season 1 + its episode 1)', looks.every(lookSolid), JSON.stringify(looks));
  829. // ---- tracker.worldapi.org#7: /my/series again — S02E01 was watched BEFORE season 1's bulk watch, so
  830. // this proves "last" = the highest season/episode watched, not the most recently watched one
  831. await page.goto(BASE + '/my/series');
  832. await hydrated();
  833. ms = await myShows();
  834. check('/my/series: the last episode is the HIGHEST watched (S02E01), not the most recent watch; the other show still none',
  835. ms.length === 2 && ms[1].episode === 'S02E01' && ms[0].episode === null, JSON.stringify(ms));
  836. // ---- mission 047 (the creator: "i dont see the check marks"): a CLIENT-SIDE navigation /my/series → click the show
  837. // title builds the show page in the browser; its check <svg> must be an SVG element (the old vendored build made it
  838. // in the XHTML namespace: nothing drawn, circle width 0). A window marker proves no full page load happened.
  839. await ev(`window.__w047 = 'same document'`);
  840. await page.click(`a.myshow-title[href="/series/gate-test-show"]`);
  841. await page.waitFor(`location.pathname === '/series/gate-test-show' && !!document.querySelector('button.checkicon svg circle')`, { timeout: 10000, label: 'show page after client-side navigation' });
  842. const svgNs = await ev(`JSON.stringify({ marker: window.__w047 || null, ns: document.querySelector('button.checkicon svg').namespaceURI,
  843. circleNs: document.querySelector('button.checkicon svg circle').namespaceURI,
  844. circleWidth: document.querySelector('button.checkicon svg circle').getBoundingClientRect().width })`);
  845. const sv = JSON.parse(svgNs);
  846. check('client-side navigation /my/series → show page: the check svg is in the SVG namespace and its circle is drawn (width > 0)',
  847. sv.marker === 'same document' && sv.ns === 'http://www.w3.org/2000/svg' && sv.circleNs === 'http://www.w3.org/2000/svg' && sv.circleWidth > 0, svgNs);
  848. lk = await extLinks();
  849. check('client-side navigation: the link icons are there too (TMDB + IMDb)', linksAre(lk, [['TMDB', TMDB_TV + '1001'], ['IMDb', IMDB + 'tt0000001/']]), lk);
  850. // mission 047 #11: the checks also vanished after expanding/collapsing a season — same cause; and the caret flips
  851. let cs = await carets();
  852. check('season caret at the header\'s right edge (tracker#17): UP for the open season 2, DOWN for the closed season 1',
  853. 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);
  854. await clickValue('button.season-toggle', SEASON1);
  855. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  856. let st = await svgState();
  857. cs = await carets();
  858. 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);
  859. once = await rowsOnce('show-seasons button.checkicon');
  860. 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));
  861. const airDates = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('episode-row')).map(r => r.querySelector('episode-title').textContent + ' ' + r.querySelector('episode-date').textContent))`);
  862. let ad = await airDates();
  863. check('#22: an opened season\'s episodes (filled in the browser from showSeasonEpisodes) show their air dates too', ad === JSON.stringify(['Season Two Opener 2099-01-01', 'Pilot 2020-01-01', 'Second 2020-06-15']), ad);
  864. await clickValue('button.checkicon', EP1);
  865. await page.waitFor(`document.querySelector('button.checkicon[value="${EP1}"]').getAttribute('class') === 'checkicon regular'`, { timeout: 10000, label: 'ep1 regular' });
  866. st = await svgState();
  867. check('after an EPISODE click: every check is still a drawn SVG', svgOk(st), st);
  868. once = await rowsOnce('show-seasons button.checkicon');
  869. check('session-sync: after an EPISODE click (face showToggleEpisode) every row appears once', once.unique && once.n === 5, JSON.stringify(once));
  870. // ---- mission 058 (tracker#23): the season's check = ALL its episodes watched — live with the face's answer, and after a reload
  871. ad = await airDates();
  872. check('#23: season 1 was checked, episode 1 unchecked → the season check turns OFF at once (no reload); #22: the dates are still there',
  873. (await checkClass(SEASON1)) === 'checkicon regular' && (await checkClass(EP2)) === 'checkicon solid' && ad === JSON.stringify(['Season Two Opener 2099-01-01', 'Pilot 2020-01-01', 'Second 2020-06-15']), (await checkClass(SEASON1)) + ' ' + ad);
  874. const reloadOpenSeason1 = async () => {
  875. await page.goto(BASE + '/series/gate-test-show');
  876. await hydrated();
  877. await clickValue('button.season-toggle', SEASON1);
  878. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  879. };
  880. await reloadOpenSeason1();
  881. check('#23: after a reload season 1 is still OFF (episode 1 unwatched, episode 2 watched)', (await checkClass(SEASON1)) === 'checkicon regular' && (await checkClass(EP1)) === 'checkicon regular' && (await checkClass(EP2)) === 'checkicon solid');
  882. looks = [await checkLook(`button.checkicon[value="${SEASON1}"]`), await checkLook(`button.checkicon[value="${EP1}"]`), await checkLook(`button.checkicon[value="${EP2}"]`)];
  883. check('#29: …and on screen: season 1 + episode 1 LOOK empty, episode 2 solid', lookEmpty(looks[0]) && lookEmpty(looks[1]) && lookSolid(looks[2]), JSON.stringify(looks));
  884. await clickValue('button.checkicon', EP1);
  885. await page.waitFor(`document.querySelector('button.checkicon[value="${EP1}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'ep1 solid again' });
  886. check('#23: checking episode 1 again → every episode of season 1 watched → the season check turns ON at once', (await checkClass(SEASON1)) === 'checkicon solid');
  887. once = await rowsOnce('show-seasons button.checkicon');
  888. check('#23: …every row once after it (hybriel#121)', once.unique && once.n === 5, JSON.stringify(once));
  889. await reloadOpenSeason1();
  890. check('#23: after a reload season 1 is ON (both episodes watched)', (await checkClass(SEASON1)) === 'checkicon solid' && (await checkClass(EP1)) === 'checkicon solid' && (await checkClass(EP2)) === 'checkicon solid');
  891. await clickValue('button.checkicon', EP2);
  892. await page.waitFor(`document.querySelector('button.checkicon[value="${SEASON1}"]').getAttribute('class') === 'checkicon regular'`, { timeout: 10000, label: 'season 1 off after ep2' });
  893. await clickValue('button.checkicon', SEASON1);
  894. await page.waitFor(`document.querySelector('button.checkicon[value="${SEASON1}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'season 1 on (partial → all)' }).catch(() => {});
  895. check('#23: a PARTLY watched season\'s check (episode 2 unchecked) watches the rest — season and both episodes ON',
  896. (await checkClass(SEASON1)) === 'checkicon solid' && (await checkClass(EP1)) === 'checkicon solid' && (await checkClass(EP2)) === 'checkicon solid');
  897. await clickValue('button.season-toggle', SEASON1);
  898. await page.waitFor(`!document.querySelector('button.checkicon[value="${EP1}"]')`, { timeout: 10000, label: 'season 1 collapsed' });
  899. st = await svgState();
  900. cs = await carets();
  901. 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);
  902. // ---- tracker#17: SKELETON rows while a season's episodes load. The face's frame is held back 1.5 s (`__delayFace`), so
  903. // the state right after the click is the one BEFORE the answer. Season 1 has 2 episodes → 2 skeleton rows.
  904. const skeletonState = () => ev(`JSON.stringify((() => { const sk = Array.from(document.querySelectorAll('skeleton-row'));
  905. const ep = document.querySelector('episode-row'), t = sk[0] && sk[0].querySelector('skeleton-title');
  906. return { n: sk.length, ep1: !!document.querySelector('button.checkicon[value="${EP1}"]'), anim: t ? getComputedStyle(t).animationName : null,
  907. parts: sk.every(r => r.querySelector('skeleton-check') && r.querySelector('skeleton-number') && r.querySelector('skeleton-title')),
  908. h: sk[0] ? Math.round(sk[0].getBoundingClientRect().height) : 0, epH: ep ? Math.round(ep.getBoundingClientRect().height) : 0,
  909. checkW: sk[0] ? Math.round(sk[0].querySelector('skeleton-check').getBoundingClientRect().width) : 0,
  910. afterSeason1: sk[0] ? sk[0].previousElementSibling.querySelector('button.season-toggle').value === '${SEASON1}' : false }; })())`).then(JSON.parse);
  911. const delayFace = (ms) => ev(`window.__delayFace = ${ms ? `{ name: 'showSeasonEpisodes', ms: ${ms} }` : 'null'}`);
  912. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  913. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  914. await delayFace(1500);
  915. await clickValue('button.season-toggle', SEASON1);
  916. await page.waitFor(`document.querySelectorAll('skeleton-row').length > 0`, { timeout: 1000, label: 'skeleton' }).catch(() => {});
  917. await ev(`document.querySelector('skeleton-row') && document.querySelector('skeleton-row').scrollIntoView({ block: 'center' })`);
  918. let sk = await skeletonState();
  919. cs = await carets();
  920. 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`,
  921. 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);
  922. 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')); }
  923. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  924. sk = await skeletonState();
  925. once = await rowsOnce('show-seasons button.checkicon');
  926. 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`,
  927. sk.n === 0 && once.unique && once.n === 5 && (await checkClass(EP1)) === 'checkicon solid' && (await checkClass(EP2)) === 'checkicon solid', JSON.stringify(sk) + ' ' + JSON.stringify(once));
  928. 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'));
  929. await clickValue('button.season-toggle', SEASON1);
  930. await page.waitFor(`!document.querySelector('button.checkicon[value="${EP1}"]')`, { timeout: 10000, label: 'season 1 closed' });
  931. 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'));
  932. }
  933. await page.send('Emulation.clearDeviceMetricsOverride', {});
  934. // closed again WHILE loading: the late answer must not bring the rows back
  935. const sent0 = (await page.wsSent()).length;
  936. await delayFace(1200);
  937. await clickValue('button.season-toggle', SEASON1);
  938. await page.waitFor(`document.querySelectorAll('skeleton-row').length === 2`, { timeout: 1000, label: 'skeleton 2' }).catch(() => {});
  939. await clickValue('button.season-toggle', SEASON1);
  940. await new Promise(r => setTimeout(r, 1800));
  941. once = await rowsOnce('show-seasons button.checkicon');
  942. cs = await carets();
  943. check('tracker#17: a season closed again while loading stays closed after the late answer (only season 2\'s episode, no skeleton, caret down)',
  944. once.n === 3 && !once.values.includes(EP1) && (await ev(`document.querySelectorAll('skeleton-row').length`)) === 0 && cs === JSON.stringify(['up', 'down']), JSON.stringify(once) + ' ' + cs);
  945. await delayFace(0);
  946. // closing asks the server nothing; opening sends exactly the one sessionless face (no showRows)
  947. const sentNow = (await page.wsSent()).slice(sent0);
  948. check('tracker#17: opening sends only showSeasonEpisodes (never the session face showRows), closing sends nothing',
  949. sentNow.filter(f => f.includes('showSeasonEpisodes')).length === 1 && !sentNow.some(f => f.includes('showRows')), JSON.stringify(sentNow));
  950. // ---- mission 047 #12: FOLLOW on the show page — "Following" filled; a click unfollows ("Follow", outlined in the
  951. // accent), the show leaves /my/series; a click follows again (follow date = now) → the top of /my/series; reloads keep it
  952. const followState = () => ev(`JSON.stringify((() => { const b = document.querySelector('#follow'); const c = getComputedStyle(b);
  953. return { text: b.textContent, cls: b.getAttribute('class'), bg: c.backgroundColor, border: c.borderTopWidth + ' ' + c.borderTopColor }; })())`);
  954. let fs = JSON.parse(await followState());
  955. check('follow: alice follows the show → "Following", the filled accent button (no border)', fs.text === 'Following' && fs.cls === 'follow primary' && fs.bg === 'rgb(78, 201, 176)' && fs.border.startsWith('0px'), JSON.stringify(fs));
  956. await page.click('#follow');
  957. await page.waitFor(`document.querySelector('#follow').textContent === 'Follow'`, { timeout: 10000, label: 'unfollowed' }).catch(() => {});
  958. fs = JSON.parse(await followState());
  959. check('follow: a click unfollows → "Follow", inverted: the accent on a surface, NO border (tracker#32)', fs.text === 'Follow' && fs.cls === 'follow inverted' && fs.border.startsWith('0px') && fs.bg !== 'rgba(0, 0, 0, 0)' && fs.bg !== 'rgb(78, 201, 176)'
  960. && (await ev(`getComputedStyle(document.querySelector('#follow')).color`)) === 'rgb(78, 201, 176)', JSON.stringify(fs));
  961. await page.goto(BASE + '/series/gate-test-show');
  962. await hydrated();
  963. check('follow: the unfollow survives a reload', (await ev(`document.querySelector('#follow').textContent`)) === 'Follow');
  964. await page.goto(BASE + '/my/series');
  965. await hydrated();
  966. ms = await myShows();
  967. check('follow: the unfollowed show is gone from /my/series', ms.length === 1 && ms[0].href === '/series/unwatched-gate-show', JSON.stringify(ms));
  968. await page.goto(BASE + '/series/gate-test-show');
  969. await hydrated();
  970. await page.click('#follow');
  971. await page.waitFor(`document.querySelector('#follow').textContent === 'Following'`, { timeout: 10000, label: 'followed' }).catch(() => {});
  972. check('follow: a click follows again → "Following"', (await ev(`document.querySelector('#follow').textContent`)) === 'Following');
  973. await new Promise(r => setTimeout(r, 400));
  974. check('session-sync: after the Follow toggles (face showToggleFollow) ONE Follow button, one show header, every season row once',
  975. (await ev(`document.querySelectorAll('#follow').length === 1 && document.querySelectorAll('show-header').length === 1`)) && (await rowsOnce('season-row button.checkicon')).unique);
  976. await page.goto(BASE + '/my/series');
  977. await hydrated();
  978. ms = await myShows();
  979. check('follow: followed just now → the TOP of /my/series (newest follow first), after a reload', ms.length === 2 && ms[0].href === '/series/gate-test-show' && ms[1].href === '/series/unwatched-gate-show', JSON.stringify(ms));
  980. await page.goto(BASE + '/series/gate-test-show');
  981. await hydrated();
  982. check('follow: "Following" survives a reload', (await ev(`document.querySelector('#follow').textContent`)) === 'Following');
  983. // ==== tracker.worldapi.org#13: /movies, /shows (24 per page, the copied pagination, the page in the PATH) and
  984. // /my/movies (the Follow button on a movie's page) ====
  985. await page.goto(BASE + '/');
  986. await hydrated();
  987. await ev(`window.__w050 = 'same document'`);
  988. await page.click('#home-movies h2 a');
  989. await page.waitFor(`location.pathname === '/movies' && document.querySelectorAll('a.tile').length === 24`, { timeout: 10000, label: 'heading → /movies' });
  990. let titles = await titlesOf('tile-grid');
  991. check('/movies (the homepage heading, client-side): page 1 = 24 movies, release date desc — the 2099 one first, then Pager Movie 30…08',
  992. (await ev('window.__w050')) === 'same document' && JSON.stringify(titles) === JSON.stringify(['Pager Future Movie', ...movieRange(30, 8)]), JSON.stringify(titles));
  993. let pg = await pagerState();
  994. check('/movies: the pagination says "1 2 Next", 1 current (no Previous on the first page); "33 movies … page 1 of 2"',
  995. 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));
  996. await ev(`Array.from(document.querySelectorAll('pagination button')).find(b => b.textContent.trim() === 'Next').click()`);
  997. await page.waitFor(`location.pathname === '/movies/page/2' && document.querySelectorAll('a.tile').length === 9`, { timeout: 10000, label: 'Next → page 2' });
  998. titles = await titlesOf('tile-grid');
  999. pg = await pagerState();
  1000. 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',
  1001. (await ev('window.__w050')) === 'same document' && JSON.stringify(titles) === JSON.stringify([...movieRange(7, 1), 'Gate Test Movie', 'Pager Undated Movie'])
  1002. && JSON.stringify(pg) === JSON.stringify({ items: ['Previous', '1', '2'], current: '2' }) && (await ev('document.title')) === 'Movies, page 2 — tracker', JSON.stringify({ titles, pg }));
  1003. await ev(`Array.from(document.querySelectorAll('pagination button')).find(b => b.textContent.trim() === '1').click()`);
  1004. await page.waitFor(`location.pathname === '/movies' && document.querySelectorAll('a.tile').length === 24`, { timeout: 10000, label: '1 → /movies' });
  1005. check('/movies: the page number "1" goes back to /movies (page 1)', (await pagerState()).current === '1' && (await titlesOf('tile-grid'))[0] === 'Pager Future Movie');
  1006. await ev('history.back()');
  1007. await page.waitFor(`location.pathname === '/movies/page/2' && document.querySelectorAll('a.tile').length === 9`, { timeout: 10000, label: 'Back → page 2' });
  1008. 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');
  1009. await page.goto(BASE + '/movies/page/2');
  1010. await hydrated();
  1011. check('/movies/page/2 loaded directly: the same 9 movies, 2 current', (await ev(`document.querySelectorAll('a.tile').length`)) === 9 && (await pagerState()).current === '2');
  1012. await page.goto(BASE + '/movies/page/99');
  1013. await hydrated();
  1014. const p99 = await pagerState();
  1015. await page.goto(BASE + '/movies/page/abc');
  1016. await hydrated();
  1017. 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');
  1018. await page.goto(BASE + '/');
  1019. await hydrated();
  1020. await ev(`window.__w050 = 'same document'`);
  1021. await page.click('#home-series h2 a');
  1022. await page.waitFor(`location.pathname === '/series' && !!document.querySelector('tile-grid a.tile')`, { timeout: 10000, label: 'heading → /series' });
  1023. tl = await tilesOf('tile-grid');
  1024. check('/series (the homepage heading): every series (no movies) by newest released episode, the one with none released last; one page → no pagination at all',
  1025. (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'])
  1026. && (await ev(`document.querySelectorAll('pagination').length`)) === 0, JSON.stringify(tl));
  1027. // ---- mission 058 (tracker#25): /genres/<genre> — the /movies list narrowed to a genre, movies AND series, newest first, 24
  1028. // per page, adult hidden. Action: Pager Future Movie (2099), Pager Older Series (series, newest episode 2021-01-01), Pager Movie
  1029. // 30…01 (2001) = 32; Hidden Action Film (adult, 2050) never
  1030. await page.goto(BASE + '/genres/Action');
  1031. await hydrated();
  1032. await ev(`window.__w058 = 'same document'`);
  1033. tl = await tilesOf('tile-grid');
  1034. pg = await pagerState();
  1035. check('#25 /genres/Action: h1 "Action", "32 titles, newest first · page 1 of 2", 24 tiles newest first — movies AND the series, each poster + title + year → its page; the adult one is not there',
  1036. (await ev(`document.querySelector('#genre-page h1 type-name').textContent`)) === 'Action' && (await ev(`document.querySelector('.list-count').textContent`)) === '32 titles, newest first · page 1 of 2'
  1037. && JSON.stringify(tl.map(t => t.split('|')[0])) === JSON.stringify(['Pager Future Movie', 'Pager Older Series', ...movieRange(30, 9)])
  1038. && tl[1] === 'Pager Older Series|2019|/series/pager-older-series|/posters/none' && !tl.some(t => t.includes('Hidden Action')) && JSON.stringify(pg) === JSON.stringify({ items: ['1', '2', 'Next'], current: '1' }), JSON.stringify({ tl, pg }));
  1039. await ev(`Array.from(document.querySelectorAll('pagination button')).find(b => b.textContent.trim() === 'Next').click()`);
  1040. await page.waitFor(`location.pathname === '/genres/Action/page/2' && document.querySelectorAll('a.tile').length === 8`, { timeout: 10000, label: 'genre Next → page 2' }).catch(() => {});
  1041. titles = await titlesOf('tile-grid');
  1042. check('#25: "Next" → /genres/Action/page/2 (client-side): the last 8 (Pager Movie 08…01), "Previous 1 2", title "Action, page 2 — tracker"',
  1043. (await ev('window.__w058')) === 'same document' && (await ev('location.pathname')) === '/genres/Action/page/2' && JSON.stringify(titles) === JSON.stringify(movieRange(8, 1))
  1044. && JSON.stringify(await pagerState()) === JSON.stringify({ items: ['Previous', '1', '2'], current: '2' }) && (await ev('document.title')) === 'Action, page 2 — tracker', JSON.stringify(titles));
  1045. await page.goto(BASE + '/genres/Action/page/2');
  1046. await hydrated();
  1047. check('#25: /genres/Action/page/2 loaded directly: the same 8, 2 current', JSON.stringify(await titlesOf('tile-grid')) === JSON.stringify(movieRange(8, 1)) && (await pagerState()).current === '2');
  1048. const g301 = await fetch(BASE + '/genre/Action', { redirect: 'manual' });
  1049. check('#25: the old pill address /genre/Action answers 301 → /genres/Action', g301.status === 301 && g301.headers.get('location') === '/genres/Action', g301.status + ' ' + g301.headers.get('location'));
  1050. await page.goto(BASE + '/series/gate-test-show');
  1051. await hydrated();
  1052. await ev(`window.__w058 = 'same document'`);
  1053. await page.click('.genre-pill');
  1054. await page.waitFor(`location.pathname === '/genres/Drama' && !!document.querySelector('#genre-page h1')`, { timeout: 10000, label: 'pill → /genres/Drama' });
  1055. tl = await tilesOf('tile-grid');
  1056. check('#25: the show page\'s "Drama" pill opens /genres/Drama (client-side): the one Drama title, no pagination',
  1057. (await ev('window.__w058')) === 'same document' && JSON.stringify(tl) === JSON.stringify(['Gate Test Show|2024|/series/gate-test-show|/posters/sh1.png']) && (await ev(`document.querySelectorAll('pagination').length`)) === 0, JSON.stringify(tl));
  1058. await page.goto(BASE + '/genres/No-Such-Genre');
  1059. await hydrated();
  1060. check('#25: an unknown genre says "Not found", no tiles', (await ev(`document.querySelector('#genre-page h1').textContent`)) === 'Not found' && (await ev(`document.querySelectorAll('a.tile').length`)) === 0);
  1061. await page.goto(BASE + '/series');
  1062. await hydrated();
  1063. await ev(`window.__w050 = 'same document'`);
  1064. // a movie's page has the Follow button (the show page) → /my/movies; /my/series stays series only
  1065. await page.click('a.tile[href="/series/gate-test-show"]');
  1066. await page.waitFor(`location.pathname === '/series/gate-test-show' && !!document.querySelector('#follow')`, { timeout: 10000, label: 'tile → show page' });
  1067. check('/series: a tile opens the show page (client-side)', (await ev('window.__w050')) === 'same document');
  1068. await page.goto(BASE + '/movies/gate-test-movie');
  1069. await hydrated();
  1070. check('a movie\'s page has the Follow button ("Follow": alice does not follow it yet)', (await ev(`document.querySelector('#follow').textContent`)) === 'Follow');
  1071. await page.click('#follow');
  1072. await page.waitFor(`document.querySelector('#follow').textContent === 'Following'`, { timeout: 10000, label: 'movie followed' }).catch(() => {});
  1073. // ---- mission 058 (tracker#24): the movie's watched check, signed in — a click marks it watched, a reload keeps it ----
  1074. mw = await movieWatch();
  1075. check('#24 signed in: the movie starts "Not watched" (regular check)', mw && mw.cls === 'checkicon regular' && mw.label === 'Not watched', JSON.stringify(mw));
  1076. looks = [await checkLook('#moviewatch')];
  1077. check('#29: movie page — the unwatched movie\'s check LOOKS empty on screen', looks.every(lookEmpty), JSON.stringify(looks));
  1078. await page.click('#moviewatch');
  1079. await page.waitFor(`document.querySelector('#moviewatch').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'movie watched' }).catch(() => {});
  1080. await new Promise(r => setTimeout(r, 400));
  1081. mw = await movieWatch();
  1082. check('#24 signed in: a click → the solid check and "Watched" (one check, no modal)', mw && mw.cls === 'checkicon solid' && mw.label === 'Watched' && mw.n === 1 && !(await modalOpen()), JSON.stringify(mw));
  1083. await page.goto(BASE + '/movies/gate-test-movie');
  1084. await hydrated();
  1085. mw = await movieWatch();
  1086. check('#24 signed in: the watch survives a reload', mw && mw.cls === 'checkicon solid' && mw.label === 'Watched', JSON.stringify(mw));
  1087. looks = [await checkLook('#moviewatch')];
  1088. check('#29: movie page — the watched movie\'s check LOOKS solid on screen', looks.every(lookSolid), JSON.stringify(looks));
  1089. await page.click('#moviewatch');
  1090. await page.waitFor(`document.querySelector('#moviewatch').getAttribute('class') === 'checkicon regular'`, { timeout: 10000, label: 'movie unwatched' }).catch(() => {});
  1091. mw = await movieWatch();
  1092. const mwOff = mw && mw.cls === 'checkicon regular' && mw.label === 'Not watched';
  1093. await page.goto(BASE + '/movies/gate-test-movie');
  1094. await hydrated();
  1095. mw = await movieWatch();
  1096. check('#24 signed in: a second click unchecks it ("Not watched"), also after a reload', mwOff && mw && mw.cls === 'checkicon regular', JSON.stringify(mw));
  1097. await page.click('#moviewatch');
  1098. await page.waitFor(`document.querySelector('#moviewatch').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'movie watched again' }).catch(() => {});
  1099. if (process.env.TRACKER_GATE_SHOTS) {
  1100. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  1101. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  1102. await page.goto(BASE + '/movies/gate-test-movie');
  1103. await hydrated();
  1104. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `movie-watched-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1105. }
  1106. await page.send('Emulation.clearDeviceMetricsOverride', {});
  1107. }
  1108. await page.goto(BASE + '/my/movies');
  1109. await hydrated();
  1110. const mmWatched = await ev(`JSON.stringify({ count: (document.querySelector('#mymovies-count') || {}).textContent, marks: Array.from(document.querySelectorAll('my-show-row')).map(r => !!r.querySelector('my-movie-watched svg circle')) })`).then(JSON.parse);
  1111. check('#24: /my/movies counts it — "1 movie · 1 watched", the row has the solid check', mmWatched.count === '1 movie · 1 watched' && JSON.stringify(mmWatched.marks) === '[true]', JSON.stringify(mmWatched));
  1112. looks = [await checkLook('my-movie-watched.checkicon')];
  1113. check('#29: /my/movies — the watched movie\'s mark LOOKS solid on screen (an unwatched movie has no mark, #24)', looks.every(lookSolid), JSON.stringify(looks));
  1114. 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('|')))`);
  1115. check('/my/movies: the movie alice just followed — title with year, its page, the poster (placeholder URL)', myMovies === JSON.stringify(['Gate Test Movie (1996)|/movies/gate-test-movie|/posters/none']), myMovies);
  1116. await page.goto(BASE + '/my/series');
  1117. await hydrated();
  1118. ms = await myShows();
  1119. check('/my/series stays SERIES only: the followed movie is not there', ms.length === 2 && ms.every(m => m.href !== '/movies/gate-test-movie'), JSON.stringify(ms));
  1120. // ---- mission 047, MOBILE FIRST: at a phone's 390 px the header is ONE row (brand + ident selector + "Log out"), and
  1121. // no page scrolls sideways; at 1280 px the same pages. Screenshots of both widths with TRACKER_GATE_SHOTS.
  1122. const pages = [['home', '/'], ['show', '/series/gate-test-show'], ['my-shows', '/my/series'], ['my-unwatched', '/my/unwatched'], ['my-schedule', '/my/schedule'],
  1123. ['movies', '/movies'], ['movies-page-2', '/movies/page/2'], ['series-list', '/series'], ['shows-list', '/shows'], ['my-shows-kind', '/my/shows'], ['my-movies', '/my/movies'], ['search', '/search/gate'],
  1124. ['genre-action', '/genres/Action'], ['genre-action-page-2', '/genres/Action/page/2'], ['movie', '/movies/gate-test-movie'], ['genres', '/genres'], ['people', '/people'], ['people-page-2', '/people/page/2'], ['settings', '/settings']];
  1125. if (process.env.TRACKER_GATE_SHOTS) mkdirSync(process.env.TRACKER_GATE_SHOTS, { recursive: true });
  1126. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  1127. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  1128. const bad = [];
  1129. for (const [name, path] of pages) {
  1130. await page.goto(BASE + path);
  1131. await hydrated();
  1132. const m = JSON.parse(await ev(`JSON.stringify((() => {
  1133. const hd = document.querySelector('application-header').getBoundingClientRect();
  1134. const mid = (s) => { const r = document.querySelector(s).getBoundingClientRect(); return r.top + r.height / 2; };
  1135. const mids = ['application-header .brand', 'button.user-toggle'].map(mid);
  1136. const wd = (s) => Math.round(document.querySelector(s).getBoundingClientRect().width);
  1137. return { headerH: hd.height, spread: Math.max(...mids) - Math.min(...mids), scrollW: document.documentElement.scrollWidth, widths: ['application-header .brand', 'button.user-toggle'].map(wd) };
  1138. })())`));
  1139. if (m.spread > 8 || m.headerH > 64 || m.scrollW > w) bad.push(name + ' ' + JSON.stringify(m));
  1140. if (name === 'show') {
  1141. // mission 047 #8: phone = title first, then the poster at the full content width, then genres/plot/cast;
  1142. // desktop unchanged = poster left (10rem), title right of it
  1143. 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 }; };
  1144. const main = document.querySelector('content-first'); const cs = getComputedStyle(main);
  1145. 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'),
  1146. content: main.getBoundingClientRect().width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) }; })())`));
  1147. const ok = mobile
  1148. ? 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
  1149. && L.poster.b <= L.follow.t && L.follow.b <= L.genres.t && Math.abs(L.follow.w - L.poster.w) < 1
  1150. : 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
  1151. && L.poster.b <= L.follow.t && Math.abs(L.follow.w - L.poster.w) < 1 && Math.abs(L.follow.l - L.poster.l) < 1;
  1152. // tracker#34 (mission 030): Follow moved from the title row to right under the poster, as wide as the poster
  1153. check(`${w} px: show page layout — ${mobile ? 'title first, the link icons under it, then the poster at the full content width, Follow under it as wide, then genres, plot, cast' : 'poster left (10rem) with Follow under it as wide, the title right of it, the link icons under the title'}`, ok, JSON.stringify(L));
  1154. }
  1155. if (process.env.TRACKER_GATE_SHOTS) {
  1156. if (name === 'my-shows' || name === 'my-movies') await waitPostersLoaded();
  1157. // #13: every poster tile in view loaded
  1158. 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' });
  1159. const shot = await page.send('Page.captureScreenshot', { format: 'png' });
  1160. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `${name}-${w}.png`), Buffer.from(shot.data, 'base64'));
  1161. }
  1162. }
  1163. check(`${w} px: on every page the header is one row (brand and the user icon side by side, ≤ 64 px high) and nothing scrolls sideways`, bad.length === 0, bad.join('; '));
  1164. }
  1165. await page.send('Emulation.clearDeviceMetricsOverride', {});
  1166. // ==== tracker.worldapi.org#9: THE TMDB SYNC, against the fake TMDB — first the tool (app stopped: hl:mpackdb is
  1167. // one process per storage), twice (idempotent), then the pages; then the app's own daily run ====
  1168. 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);
  1169. await page.goto('about:blank'); // leave the app first: an open page would log websocket reconnect errors
  1170. await stopServer();
  1171. // spawned, not execFileSync: the fake TMDB lives in THIS node process and must keep answering meanwhile
  1172. const runTool = () => new Promise((ok) => {
  1173. const p = spawn(join(APP, 'bin/hybriel'), ['tools/sync-tmdb.hl', '--limit', '5'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_OLD_SHORT_IDS: OLD_SHORT_IDS, ...TMDB_ENV }, stdio: ['ignore', 'pipe', 'pipe'] });
  1174. let o = '';
  1175. p.stdout.on('data', d => o += d); p.stderr.on('data', d => o += d);
  1176. p.on('exit', () => ok(o));
  1177. });
  1178. let out = await runTool();
  1179. const toolRun1 = out;
  1180. let done = (out.match(/tmdb sync done: .*/) || [''])[0];
  1181. 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',
  1182. 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);
  1183. // per show, in this order (the order BETWEEN shows follows the follow table, which the follow test above reshuffled)
  1184. // (#28: the details request also brings the credits — aggregate_credits / credits)
  1185. const perShow = [['/3/tv/1001?append_to_response=external_ids,aggregate_credits', '/3/tv/1001?append_to_response=season/1,season/2,season/3', '/t/p/w342/gate1.png'],
  1186. ['/3/tv/1002?append_to_response=external_ids,aggregate_credits', '/3/tv/1002?append_to_response=season/1', '/t/p/w342/gate2.png'], ['/3/movie/1003?append_to_response=external_ids,credits', '/t/p/w342/gate3.png']];
  1187. const paths = tmdb.requests.map(q => q.path);
  1188. 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 + the credits (no extra request), one for its seasons, the poster; the movie only via /movie/, never /tv/1003',
  1189. tmdb.requests.filter(q => q.path.startsWith('/3/')).every(q => q.authorization === 'Bearer gate-token') && !paths.some(p => p.includes('season/0'))
  1190. && 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))),
  1191. JSON.stringify(paths));
  1192. 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',
  1193. 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?embed=episodes', '/tvmaze/shows/502?embed=episodes'].sort()),
  1194. JSON.stringify(tmdb.tvmazeRequests));
  1195. const tvmazeBefore = tmdb.tvmazeRequests.length;
  1196. out = await runTool();
  1197. done = (out.match(/tmdb sync done: .*/) || [''])[0];
  1198. // tracker.worldapi.org#28: the credits came with the details request — show 1: 23 cast (Test Actor linked, 20 new; mission 062:
  1199. // the 2 guests are NO person records) + 7 crew entries (5 new people); the movie 3 cast + 13 crew (15 new); show 2 none. The
  1200. // second run adds nobody.
  1201. check('#28 sync tool: the cast + crew stored from the details request — show 1 cast 23 crew 7 (+25 people: the 2 guest stars are none), the movie cast 3 crew 13 (+15), show 2 none; the second run adds no person',
  1202. toolRun1.includes(', cast 23 crew 7 +25 people') && toolRun1.includes(', cast 3 crew 13 +15 people') && toolRun1.includes(', cast 0 crew 0 +0 people')
  1203. && (out.match(/ \+0 people/g) || []).length === 3 && !/ \+[1-9]\d* people/.test(out), toolRun1 + out);
  1204. 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',
  1205. 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')
  1206. && JSON.stringify(tmdb.tvmazeRequests.slice(tvmazeBefore).sort()) === JSON.stringify(['/tvmaze/shows/501?embed=episodes', '/tvmaze/shows/502?embed=episodes']), out);
  1207. const sidTool = countShortIds();
  1208. check('mission 062 short ids: every person the sync tool created (cast + crew of the details request) has its short id at once — none without, none twice (count-short-ids, app still stopped)',
  1209. allHaveShortIds(sidTool), sidTool);
  1210. await startServer();
  1211. // tracker#37: the cast + crew people the sync tool created keep TMDB's profile path — /search shows it (w185) as their photo; this
  1212. // start collected the photos of show 1's cast + crew entries (tracker#37 stores TMDB's profile on both) for the people stored without one
  1213. const photo37 = async (q, name) => { const h = await (await fetch(BASE + '/search/' + encodeURIComponent(q))).text(); return (h.match(new RegExp('<img class="search-poster" src="([^"]*)"[^>]*>\\s*<a class="search-person" href="[^"]*">' + name + '<')) || [])[1] || null; };
  1214. const ph37 = [await photo37('late season star', 'Late Season Star'), await photo37('show creator', 'Show Creator'), await photo37('episode director', 'Episode Director'), await photo37('lead player', 'Lead Player'), await photo37('test actor', 'Test Actor')];
  1215. check('tracker#37: new cast + crew people get TMDB\'s profile path — the search shows their photo (Late Season Star, Show Creator (created_by), Episode Director (crew): TMDB w185), without one the placeholder (Lead Player); Test Actor (stored before, no photo of his own yet) shows the one of his cast entry in show 1 — the start counted 4 photos in casts + crews (those two, Show Creator, Episode Director)',
  1216. JSON.stringify(ph37) === JSON.stringify([tmdb.base + '/t/p/w185/late3140.png', tmdb.base + '/t/p/w185/creator3150.png', tmdb.base + '/t/p/w185/director3151.png', '/profiles/none', tmdb.base + '/t/p/w185/ta-cast.png']) && /people view: \d+ people, 4 cast photos in /.test(log), JSON.stringify(ph37) + (log.match(/people view: .*/) || [''])[0]);
  1217. await page.goto(BASE + '/my/schedule');
  1218. await hydrated();
  1219. 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)`);
  1220. let rows = await scheduleRows();
  1221. check('after the sync: /my/schedule lists the new upcoming episodes of both shows, soonest first', JSON.stringify(rows) === JSON.stringify([
  1222. '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));
  1223. await page.goto(BASE + '/my/unwatched');
  1224. await hydrated();
  1225. rows = await ev(`Array.from(document.querySelectorAll('unwatched-row')).map(r => r.querySelector('unwatched-episode').textContent + ' | ' + r.querySelector('unwatched-date').textContent)`);
  1226. 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));
  1227. await page.goto(BASE + '/my/series');
  1228. await hydrated();
  1229. await waitPostersLoaded();
  1230. ms = await myShows();
  1231. check('after the sync: /my/series 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));
  1232. r = await fetch(BASE + '/posters/sh2.png');
  1233. const posterBytes = Buffer.from(await r.arrayBuffer());
  1234. 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')));
  1235. await page.goto(BASE + '/series/gate-test-show');
  1236. await hydrated();
  1237. const seasonCounts = await ev(`Array.from(document.querySelectorAll('season-row')).map(e => e.querySelector('season-label').textContent + ': ' + e.querySelector('season-count').textContent)`);
  1238. 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));
  1239. check('after the sync: the latest season shows its new episodes, the poster is the downloaded one',
  1240. JSON.stringify(await ev(`Array.from(document.querySelectorAll('episode-row episode-title')).map(e => e.textContent)`)) === JSON.stringify(['Third Season Start', 'Coming Soon'])
  1241. && (await ev(`document.querySelector('show-poster img').getAttribute('src')`)) === '/posters/sh1.png' && (await ev(`document.querySelector('show-poster img').naturalWidth`)) > 0);
  1242. await clickValue('button.season-toggle', SEASON1);
  1243. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  1244. 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'))`);
  1245. check('after the sync: S1 keeps its episodes (same ids, still watched), E2 renamed from TMDB, E3 new and unwatched',
  1246. 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));
  1247. // ---- tracker.worldapi.org#12: after the sync every show has the ids it lacked — the stored ones unchanged ----
  1248. await page.goto(BASE + '/series/gate-test-show');
  1249. await hydrated();
  1250. lk = await extLinks();
  1251. 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',
  1252. linksAre(lk, [['TMDB', TMDB_TV + '1001'], ['IMDb', IMDB + 'tt0000001/'], ['TVDB', TVDB + '4001'], ['TVmaze', TVMAZE + '501']]), lk);
  1253. await page.goto(BASE + '/series/unwatched-gate-show');
  1254. await hydrated();
  1255. lk = await extLinks();
  1256. 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)',
  1257. linksAre(lk, [['TMDB', TMDB_TV + '1002'], ['IMDb', IMDB + 'tt0000002/'], ['TVDB', TVDB + '4002'], ['TVmaze', TVMAZE + '502']]), lk);
  1258. await page.goto(BASE + '/movies/gate-test-movie');
  1259. await hydrated();
  1260. lk = await extLinks();
  1261. check('after the sync: the movie has TMDB (/movie/1003) + IMDb (tt0000003 filled), no TheTVDB/TVmaze, no seasons',
  1262. linksAre(lk, [['TMDB', TMDB_MOVIE + '1003'], ['IMDb', IMDB + 'tt0000003/']]) && (await ev(`document.querySelectorAll('season-row').length`)) === 0, lk);
  1263. // ==== tracker.worldapi.org#28: FULL CAST + CREW (stored by the sync tool above). The movie: TMDB's cast order with the
  1264. // characters, the crew grouped by job (director once, a writer too; 9 writers → 6 + "Show all crew (13)", client-side) ====
  1265. let cr = await credits();
  1266. const pool = (n) => Array.from({ length: n }, (_, k) => 'Writer Pool ' + (k + 1));
  1267. check('#28 movie: the whole cast in TMDB\'s order with the characters (none for the extra), each linked to its new person page; no toggle, no "Guest stars"',
  1268. JSON.stringify(cr.cast) === JSON.stringify([['Movie Star', ' (Hero)', '/person/Movie-Star'], ['Movie Sidekick', ' (Sidekick)', '/person/Movie-Sidekick'], ['Uncredited Extra', '', '/person/Uncredited-Extra']])
  1269. && JSON.stringify(cr.castSeen) === JSON.stringify(['Movie Star', 'Movie Sidekick', 'Uncredited Extra']) && cr.castToggle === null, JSON.stringify(cr));
  1270. check('#28 movie: the crew by job — Directed by Film Director (listed twice by TMDB: once), Written by Film Director + the first 5 of the pool (6 shown), Screenplay, Story, Music; the assistant director and the adult crew member not shown; "Show all crew (13)"',
  1271. JSON.stringify(cr.crewSeen) === JSON.stringify(['[Directed by]', 'Film Director', '[Written by]', 'Film Director', ...pool(5), '[Screenplay]', 'Screen Writer', '[Story]', 'Story Author', '[Music]', 'Film Composer'])
  1272. && cr.crew.length === 13 && cr.crew[0][1] === '/person/Film-Director' && cr.crewToggle === 'Show all crew (13)' && !cr.crew.some(c => /Assistant|Adult/.test(c[0])), JSON.stringify(cr));
  1273. let sentBefore28 = (await page.wsSent()).length;
  1274. await page.click('#crewtoggle');
  1275. await page.waitFor(`document.querySelectorAll('show-crew a.crew-link').length === 13 && Array.from(document.querySelectorAll('show-crew a.crew-link')).every(a => a.getClientRects().length > 0)`, { timeout: 3000, label: 'crew all' }).catch(() => {});
  1276. cr = await credits();
  1277. check('#28 movie: "Show all crew" shows all 9 writers at once — client-side (no request), the button says "Show less"',
  1278. JSON.stringify(cr.crewSeen) === JSON.stringify(['[Directed by]', 'Film Director', '[Written by]', 'Film Director', ...pool(8), '[Screenplay]', 'Screen Writer', '[Story]', 'Story Author', '[Music]', 'Film Composer'])
  1279. && cr.crewToggle === 'Show less' && (await page.wsSent()).length === sentBefore28, JSON.stringify(cr));
  1280. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'credits-movie-1280.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1281. // the series (aggregate_credits = every season): main cast by episode count, the later-season actor among them, then the guests
  1282. await page.goto(BASE + '/series/gate-test-show');
  1283. await hydrated();
  1284. cr = await credits();
  1285. const ensemble = Array.from({ length: 18 }, (_, k) => 'Ensemble Player ' + String(k + 1).padStart(2, '0'));
  1286. check('#28 series: all 23 of every season in TMDB\'s order (by episodes): Lead Player (his roles split, each once, "(uncredited)" dropped, 4 + "…"), Test Actor (linked, not added twice), Ensemble 01–18, Late Season Star (season 3 only, both roles), Guest Star Two, Guest Star One — the adult one left out',
  1287. JSON.stringify(cr.cast.map(c => c[0])) === JSON.stringify(['Lead Player', 'Test Actor', ...ensemble, 'Late Season Star', 'Guest Star Two', 'Guest Star One'])
  1288. && cr.cast[0][1] === ' (The Lead / Twin / Mirror / Ghost / …)' && cr.cast[1][2] === '/person/Test-Actor' && cr.cast[1][1] === ' (Someone)' && cr.cast[20][1] === ' (New Captain / Captain)' && cr.cast[20][2] === '/person/Late-Season-Star'
  1289. && cr.cast[2][1] === ' (Crew Member 1)' && !cr.cast.some(c => /Adult|Test-Actor-2/.test(c[0] + c[2])), JSON.stringify(cr.cast));
  1290. check('#28 series: collapsed after 20 — the first 20 names, no guests yet, "Show all (23)"; the character after each name, a comma after the 19th, none after the 20th',
  1291. JSON.stringify(cr.castSeen) === JSON.stringify(['Lead Player', 'Test Actor', ...ensemble]) && cr.castToggle === 'Show all (23)'
  1292. && cr.after[19] === '" (Crew Member 18)"' && cr.after[18] === '" (Crew Member 17), "', JSON.stringify(cr));
  1293. check('#28 series: the crew — Created by Show Creator, Directed by (by episodes) Episode Director + Second Director, Written by Show Creator (5) + Staff Writer (3), Story Staff Writer, Music Theme Composer; the script supervisor not shown, no crew toggle',
  1294. JSON.stringify(cr.crewSeen) === JSON.stringify(['[Created by]', 'Show Creator', '[Directed by]', 'Episode Director', 'Second Director', '[Written by]', 'Show Creator', 'Staff Writer', '[Story]', 'Staff Writer', '[Music]', 'Theme Composer'])
  1295. && cr.crew[0][1] === '/person/Show-Creator' && cr.crewToggle === null, JSON.stringify(cr));
  1296. if (process.env.TRACKER_GATE_SHOTS) {
  1297. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
  1298. await ev(`document.querySelector('show-crew').scrollIntoView()`);
  1299. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'credits-series-collapsed-390.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1300. }
  1301. sentBefore28 = (await page.wsSent()).length;
  1302. await page.click('#casttoggle');
  1303. await page.waitFor(`document.querySelector('show-cast').className === 'all'`, { timeout: 3000, label: 'cast all' }).catch(() => {});
  1304. cr = await credits();
  1305. check('#28 series: "Show all (23)" shows every name at once — client-side (no request) — the later-season actor last of the main cast (no comma before the label), then "Guest stars"; "Show less"; no sideways scroll',
  1306. JSON.stringify(cr.castSeen) === JSON.stringify(['Lead Player', 'Test Actor', ...ensemble, 'Late Season Star', '[Guest stars]', 'Guest Star Two', 'Guest Star One']) && cr.castToggle === 'Show less'
  1307. && (await page.wsSent()).length === sentBefore28 && cr.after[19] === '" (Crew Member 18), "' && cr.after[20] === '" (New Captain / Captain)"' && cr.after[22] === '" (Stranger)"' && cr.scrollW <= (process.env.TRACKER_GATE_SHOTS ? 390 : 1e9), JSON.stringify(cr));
  1308. if (process.env.TRACKER_GATE_SHOTS) {
  1309. await ev(`document.querySelector('show-cast').scrollIntoView()`);
  1310. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'credits-series-all-390.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1311. }
  1312. await page.click('#casttoggle');
  1313. await page.waitFor(`document.querySelector('show-cast').className === 'collapsed'`, { timeout: 3000, label: 'cast collapsed' }).catch(() => {});
  1314. cr = await credits();
  1315. check('#28 series: "Show less" collapses it again (20 names)', cr.castSeen.length === 20 && cr.castToggle === 'Show all (23)' && (await page.wsSent()).length === sentBefore28, JSON.stringify(cr.castSeen));
  1316. if (process.env.TRACKER_GATE_SHOTS) {
  1317. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  1318. await page.goto(BASE + '/series/gate-test-show');
  1319. await hydrated();
  1320. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'credits-series-1280.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1321. await page.send('Emulation.clearDeviceMetricsOverride', {});
  1322. }
  1323. const lateHtml = await (await fetch(BASE + '/person/Late-Season-Star')).text();
  1324. check('#28: a new cast person\'s page exists at once (/person/Late-Season-Star)', /<type-name>Late Season Star<\/type-name>/.test(lateHtml), lateHtml.slice(0, 300));
  1325. // ==== mission 062: GUEST STARS are no person records until someone opens one — their link /person/tmdb/<id>?show=<show id>
  1326. // creates the person (name + role from the title, a short id), then 302 → /person/<slug>, filled from TMDB like any new person ====
  1327. await page.goto(BASE + '/series/gate-test-show');
  1328. await hydrated();
  1329. cr = await credits();
  1330. const guestHtml0 = await (await fetch(BASE + '/person/Guest-Star-One')).text();
  1331. check('mission 062 guests: Guest Star Two/One are linked to /person/tmdb/<their TMDB id>?show=<the show> — no person page yet; the main cast links its person pages',
  1332. /^\/person\/tmdb\/3141\?show=[0-9a-z]+$/.test(cr.cast[21][2]) && /^\/person\/tmdb\/3142\?show=[0-9a-z]+$/.test(cr.cast[22][2]) && cr.cast[22][1] === ' (Stranger)'
  1333. && cr.cast.slice(0, 21).every(c => /^\/person\/[A-Z]/.test(c[2])) && !/<type-name>Guest Star One<\/type-name>/.test(guestHtml0) && /<h1>Not found<\/h1>/.test(guestHtml0), JSON.stringify(cr.cast.slice(19)));
  1334. await page.click('#casttoggle');
  1335. await page.waitFor(`document.querySelector('show-cast').className === 'all'`, { timeout: 3000, label: 'cast all' }).catch(() => {});
  1336. const guestT0 = Date.now();
  1337. await page.click('show-cast a[href^="/person/tmdb/3142"]');
  1338. await page.waitFor(`location.pathname === '/person/Guest-Star-One' && !!document.querySelector('person-meta h1') && !document.querySelector('#personloading')`, { timeout: 10000, label: 'guest person' });
  1339. const guestMs = Date.now() - guestT0;
  1340. const guestSid = await ev(`(document.querySelector('#shortid') || {}).textContent || null`);
  1341. const guestRows = JSON.parse(await 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')]))`));
  1342. check('mission 062 guests: a click on Guest Star One creates the person and lands on /person/Guest-Star-One — its short id at once, filled from TMDB (Gate Test Show, role Stranger) (' + guestMs + ' ms)',
  1343. (await ev(`document.querySelector('person-meta h1 type-name').textContent`)) === 'Guest Star One' && /^[a-z0-9]{5}$/.test(guestSid || '')
  1344. && JSON.stringify(guestRows) === JSON.stringify([['Gate Test Show', '2024', 'Series', 'Stranger', '/series/gate-test-show']])
  1345. && /guest opened: Guest Star One \(tmdb 3142\) → new person \S+ from show /.test(log), JSON.stringify({ guestSid, guestRows, log: log.split('\n').filter(l => /guest/.test(l)) }));
  1346. if (process.env.TRACKER_GATE_SHOTS) {
  1347. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  1348. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  1349. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `guest-person-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1350. }
  1351. await page.send('Emulation.clearDeviceMetricsOverride', {});
  1352. }
  1353. const guestAgain = await fetch(BASE + cr.cast[22][2], { redirect: 'manual' });
  1354. const guestPersonReq = tmdb.personRequests.length;
  1355. const guestTwo = await fetch(BASE + '/person/tmdb/3141', { redirect: 'manual' });
  1356. const guestTwoAsked = tmdb.personRequests.slice(guestPersonReq).map(q => q.path);
  1357. const guestTwoHtml = await (await fetch(BASE + '/person/Guest-Star-Two')).text();
  1358. const guest404 = [(await fetch(BASE + '/person/tmdb/999999', { redirect: 'manual' })).status, (await fetch(BASE + '/person/tmdb/abc', { redirect: 'manual' })).status];
  1359. check('mission 062 guests: the same link again → 302 to the person we have now (no second person); without ?show= (an old link) the name comes from TMDB\'s /person/<id> (Guest Star Two, its short id); unknown / not a number → 404',
  1360. guestAgain.status === 302 && guestAgain.headers.get('location') === '/person/Guest-Star-One' && (log.match(/guest opened: Guest Star One/g) || []).length === 1
  1361. && guestTwo.status === 302 && guestTwo.headers.get('location') === '/person/Guest-Star-Two' && JSON.stringify(guestTwoAsked) === JSON.stringify(['/3/person/3141'])
  1362. && /<type-name>Guest Star Two<\/type-name>/.test(guestTwoHtml) && /^[a-z0-9]{5}$/.test(sidOfHtml(guestTwoHtml) || '') && sidOfHtml(guestTwoHtml) !== guestSid && guest404[0] === 404 && guest404[1] === 404,
  1363. JSON.stringify({ again: guestAgain.status, loc: guestAgain.headers.get('location'), two: guestTwo.status, twoLoc: guestTwo.headers.get('location'), guestTwoAsked, guest404 }));
  1364. // the #16 person block below counts TMDB's person requests from zero
  1365. tmdb.personRequests.length = 0;
  1366. // ==== mission 062: a WATCH click does not re-send the cast/crew (they derive from the slug, not the session): the face's ack +
  1367. // its session sync carry the rows, never castRows / crewRows ====
  1368. await page.goto(BASE + '/series/gate-test-show');
  1369. await hydrated();
  1370. const firstEp = await ev(`document.querySelector('episode-row button.checkicon').value`);
  1371. const epClass = () => ev(`document.querySelector('episode-row button.checkicon[value="${firstEp}"]').getAttribute('class')`);
  1372. const class0 = await epClass();
  1373. await page.resetWs();
  1374. const watchT0 = Date.now();
  1375. await clickValue('episode-row button.checkicon', firstEp);
  1376. await page.waitFor(`document.querySelector('episode-row button.checkicon[value="${firstEp}"]').getAttribute('class') !== ${JSON.stringify(class0)}`, { timeout: 5000, label: 'watch click' });
  1377. const watchMs = Date.now() - watchT0;
  1378. await new Promise(r => setTimeout(r, 400));
  1379. const watchFrames = (await page.wsFrames()).join('\n');
  1380. check('mission 062: a watch click\'s answer (ack + session sync) carries the rows but NOT the cast or crew (' + watchFrames.length + ' bytes received, ' + watchMs + ' ms)',
  1381. watchFrames.includes('"rows"') && !/castRows|crewRows|Lead Player|Show Creator/.test(watchFrames) && watchFrames.length < 20000,
  1382. JSON.stringify({ rows: watchFrames.includes('"rows"'), len: watchFrames.length, keys: (watchFrames.match(/"[a-zA-Z_]+":/g) || []).filter((k, i, a) => a.indexOf(k) === i).join(' '), hit: (watchFrames.match(/.{0,120}(castRows|crewRows|Lead Player|Show Creator).{0,120}/) || [''])[0] }));
  1383. await clickValue('episode-row button.checkicon', firstEp);
  1384. await page.waitFor(`document.querySelector('episode-row button.checkicon[value="${firstEp}"]').getAttribute('class') === ${JSON.stringify(class0)}`, { timeout: 5000, label: 'watch click back' });
  1385. cr = await credits();
  1386. check('mission 062: after two watch clicks the cast and crew are still all there (23 + 7 links)', cr.cast.length === 23 && cr.crew.length === 7, JSON.stringify(cr).slice(0, 300));
  1387. // the face sends its own rows only when the page opened/closed a season (`collapsedChanged`) — else the session sync's rows are
  1388. // the answer: also after a CLIENT-SIDE navigation (/my/shows → the show)
  1389. await page.goto(BASE + '/my/series');
  1390. await hydrated();
  1391. await page.click(`a.myshow-title[href="/series/gate-test-show"]`);
  1392. await page.waitFor(`location.pathname === '/series/gate-test-show' && !!document.querySelector('episode-row button.checkicon')`, { timeout: 10000, label: 'show after client-side navigation' });
  1393. const navEp = await ev(`document.querySelector('episode-row button.checkicon').value`);
  1394. const navClass = () => ev(`document.querySelector('episode-row button.checkicon[value="${navEp}"]').getAttribute('class')`);
  1395. const navClass0 = await navClass();
  1396. await page.resetWs();
  1397. await clickValue('episode-row button.checkicon', navEp);
  1398. const navFlip = await page.waitFor(`document.querySelector('episode-row button.checkicon[value="${navEp}"]').getAttribute('class') !== ${JSON.stringify(navClass0)}`, { timeout: 5000, label: 'nav watch click' }).then(() => true, () => false);
  1399. await new Promise(r => setTimeout(r, 300));
  1400. const navFrames = (await page.wsFrames()).join('\n');
  1401. const navValue = navFrames.split('\n').map(f => { try { return JSON.parse(f); } catch { return {}; } }).filter(f => f.value).map(f => Object.keys(f.value).join(','));
  1402. await clickValue('episode-row button.checkicon', navEp);
  1403. const navBack = await page.waitFor(`document.querySelector('episode-row button.checkicon[value="${navEp}"]').getAttribute('class') === ${JSON.stringify(navClass0)}`, { timeout: 5000, label: 'nav watch click back' }).then(() => true, () => false);
  1404. once = await rowsOnce('show-seasons button.checkicon');
  1405. check('mission 062: after a client-side navigation a watch click (no season opened) flips the check and back — the face answers only ok, the rows come by the session sync; every row once',
  1406. navFlip && navBack && JSON.stringify(navValue) === JSON.stringify(['ok']) && navFrames.includes('"rows"') && once.unique, JSON.stringify({ navFlip, navBack, navValue, once }));
  1407. // ---- tracker.worldapi.org#13: the public lists are built at start — after the sync (tool, then a restart) show 1's new
  1408. // S03E01 (2021-05-05) puts it before Pager Older Series (2021-01-01); show 2 has only an upcoming episode: still last
  1409. await page.goto(BASE + '/series');
  1410. await hydrated();
  1411. tl = await titlesOf('tile-grid');
  1412. check('after the sync + restart: /series by newest released episode — Gate Test Show (S03E01 2021-05-05) first now, its synced poster; Unwatched Gate Show (only upcoming) last',
  1413. 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));
  1414. if (process.env.TRACKER_GATE_SHOTS) {
  1415. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
  1416. for (const [name, path] of [['synced-show-390', '/series/gate-test-show'], ['synced-movie-390', '/movies/gate-test-movie']]) {
  1417. await page.goto(BASE + path);
  1418. await hydrated();
  1419. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, name + '.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1420. }
  1421. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  1422. for (const [name, path] of [['synced-show', '/series/gate-test-show'], ['synced-movie', '/movies/gate-test-movie'], ['synced-my-shows', '/my/series'], ['synced-my-unwatched', '/my/unwatched'], ['synced-my-schedule', '/my/schedule']]) {
  1423. await page.goto(BASE + path);
  1424. await hydrated();
  1425. if (name === 'synced-my-shows') await waitPostersLoaded();
  1426. const shot = await page.send('Page.captureScreenshot', { format: 'png' });
  1427. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, name + '.png'), Buffer.from(shot.data, 'base64'));
  1428. }
  1429. await page.send('Emulation.clearDeviceMetricsOverride', {});
  1430. }
  1431. // ---- THE DAILY RUN INSIDE THE APP: TRACKER_SYNC_HOUR = this very UTC hour, so the app's own clock starts the
  1432. // run right after boot. TMDB now has one more episode and answers slowly (700 ms per request): the run takes
  1433. // seconds, and pages asked for meanwhile must still be answered between two shows, never after the whole run.
  1434. await page.goto('about:blank');
  1435. await stopServer();
  1436. tmdb.shows[1001].seasons[3].episodes.push({ id: 5007, episode_number: 3, name: 'Added Live', air_date: '2096-03-03' });
  1437. // tracker.worldapi.org#13: and show 2 an episode that is ALREADY OUT — the running app must move it to the top of /series
  1438. tmdb.shows[1002].seasons[0].episodes.push({ id: 7002, episode_number: 2, name: 'Out Already', air_date: '2022-02-02' });
  1439. tmdb.delayMs = 700;
  1440. const before = tmdb.requests.length, tvmazeBeforeRun = tmdb.tvmazeRequests.length;
  1441. await startServer({ TRACKER_SYNC: '1', TRACKER_SYNC_HOUR: String(new Date().getUTCHours()) });
  1442. const lat = [];
  1443. const t0 = Date.now();
  1444. while (!log.includes('tmdb sync done') && Date.now() - t0 < 30000) {
  1445. const a = Date.now();
  1446. await fetch(BASE + '/');
  1447. lat.push({ at: a - t0, ms: Date.now() - a, running: log.includes('tmdb sync: start') && !log.includes('tmdb sync done') });
  1448. await new Promise(r => setTimeout(r, 150));
  1449. }
  1450. const runDone = (log.match(/tmdb sync done: .*/) || [''])[0];
  1451. check('in-app daily run: started by the app\'s own clock at the configured hour — the FULL WALK (#18: no last run yet) — found the 2 new episodes, 0 errors',
  1452. log.includes('tmdb sync: daily at ' + new Date().getUTCHours() + ':00 UTC') && log.includes('tmdb sync: start, 3 followed shows, full walk (no last run)') && runDone.includes('newSeasons=0 newEpisodes=2 updatedEpisodes=0') && runDone.includes('errors=0'), log);
  1453. const during = lat.filter(l => l.running);
  1454. const runSecs = Number((runDone.match(/seconds=([0-9.]+)/) || [0, 0])[1]);
  1455. console.log(` (in-app run ${runSecs} s; ${during.length} pages asked during it, slowest ${Math.max(0, ...during.map(l => l.ms))} ms)`);
  1456. 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',
  1457. runSecs >= 3 && during.length >= 3 && during.every(l => l.ms < 2500), 'run ' + runSecs + ' s, during: ' + JSON.stringify(during));
  1458. 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
  1459. && JSON.stringify(tmdb.tvmazeRequests.slice(tvmazeBeforeRun).sort()) === JSON.stringify(['/tvmaze/shows/501?embed=episodes', '/tvmaze/shows/502?embed=episodes']), JSON.stringify(tmdb.requests.slice(before)) + JSON.stringify(tmdb.tvmazeRequests.slice(tvmazeBeforeRun)));
  1460. await page.goto(BASE + '/my/schedule');
  1461. await hydrated();
  1462. rows = await scheduleRows();
  1463. 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));
  1464. await page.goto(BASE + '/series');
  1465. await hydrated();
  1466. tl = await titlesOf('tile-grid');
  1467. check('after the in-app run (no restart): /series has Unwatched Gate Show on top — its new S01E02 (2022-02-02) is out; the homepage row too',
  1468. JSON.stringify(tl) === JSON.stringify(['Unwatched Gate Show', 'Gate Test Show', 'Pager Older Series']), JSON.stringify(tl));
  1469. await page.goto(BASE + '/');
  1470. await hydrated();
  1471. tl = await titlesOf('#home-series');
  1472. 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));
  1473. // ==== tracker.worldapi.org#15: THE TVMAZE MERGE — TVmaze knows more than TMDB: show 1 (TVmaze 501) gets S3E4's real
  1474. // title + air date (TMDB: "Episode 4", no date), a new S3E5 and a new season 4; TVmaze's other name for S3E1 must NOT
  1475. // replace TMDB's; its S1E1 airs on another day but has the same title (still agrees). Show 2 (502) TVmaze numbers
  1476. // differently (S1E1 another title, 7 years off — like a split pilot) → TVmaze is ignored for it. ====
  1477. await page.goto('about:blank');
  1478. await stopServer();
  1479. tmdb.delayMs = 0;
  1480. tmdb.shows[1001].seasons[3].episodes.push({ id: 5008, episode_number: 4, name: 'Episode 4' });
  1481. const tvm501 = tmdb.tvmaze.episodes[501].filter(e => !(e.season === 3 && e.number === 1));
  1482. 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' },
  1483. { 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' },
  1484. { id: 8010, season: 4, number: 1, name: 'New Season Premiere', airdate: '2096-06-06' });
  1485. tmdb.tvmaze.episodes[501] = tvm501;
  1486. 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' }];
  1487. // a date that differs (another country's premiere) but the SAME title still counts as the same numbering
  1488. tvm501.find(e => e.season === 1 && e.number === 1).airdate = '2019-12-20';
  1489. out = await runTool();
  1490. done = (out.match(/tmdb sync done: .*/) || [''])[0];
  1491. 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',
  1492. 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);
  1493. out = await runTool();
  1494. done = (out.match(/tmdb sync done: .*/) || [''])[0];
  1495. check('tvmaze merge again: idempotent — nothing new, nothing filled, no TMDB update (TMDB\'s "Episode 4" does not replace TVmaze\'s "Real Fourth")',
  1496. done.includes('newSeasons=0 newEpisodes=0 updatedEpisodes=0 updatedSeasons=0') && done.includes('errors=0 tvmazeEpisodes=0 tvmazeSeasons=0 tvmazeFilled=0 tvmazeSkipped=1'), out);
  1497. await startServer();
  1498. await page.goto(BASE + '/my/schedule');
  1499. await hydrated();
  1500. rows = await scheduleRows();
  1501. 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([
  1502. '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',
  1503. 'Gate Test Show (2024) | S04E01 · New Season Premiere | 2096-06-06', 'Unwatched Gate Show (2025) | S01E01 · Brand New | 2097-01-01',
  1504. 'Gate Test Show (2024) | S03E02 · Coming Soon | 2098-06-01', 'Gate Test Show (2024) | S02E01 · Season Two Opener | 2099-01-01']), JSON.stringify(rows));
  1505. const seasonsAndTitles = async () => {
  1506. await page.goto(BASE + '/series/gate-test-show');
  1507. await hydrated();
  1508. const seasons = await ev(`Array.from(document.querySelectorAll('season-row')).map(e => e.querySelector('season-label').textContent + ': ' + e.querySelector('season-count').textContent)`);
  1509. 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`));
  1510. await page.waitFor(`document.querySelectorAll('episode-row').length >= 6`, { timeout: 10000, label: 'S3 expanded' });
  1511. const titles = await ev(`Array.from(document.querySelectorAll('episode-row')).map(e => e.querySelector('episode-number').textContent + ' ' + e.querySelector('episode-title').textContent)`);
  1512. return { seasons, titles };
  1513. };
  1514. let tst = await seasonsAndTitles();
  1515. 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',
  1516. JSON.stringify(tst.seasons) === JSON.stringify(['Season 4: 1 episode', 'Season 3: 5 episodes', 'Season 2: 1 episode', 'Season 1: 3 episodes'])
  1517. && 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));
  1518. if (process.env.TRACKER_GATE_SHOTS) {
  1519. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  1520. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  1521. for (const [name, path] of [['tvmaze-schedule', '/my/schedule'], ['tvmaze-show', '/series/gate-test-show']]) {
  1522. await page.goto(BASE + path);
  1523. await hydrated();
  1524. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `${name}-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1525. }
  1526. }
  1527. await page.send('Emulation.clearDeviceMetricsOverride', {});
  1528. }
  1529. // TMDB catches up: it now lists S3E5 (its own title) and season 4 — the TMDB step ADOPTS TVmaze's rows by number (no
  1530. // duplicate), TMDB's values win; its "Episode 4" still never replaces "Real Fourth"
  1531. await page.goto('about:blank');
  1532. await stopServer();
  1533. tmdb.shows[1001].seasons[3].episodes.push({ id: 5009, episode_number: 5, name: 'Official Fifth', air_date: '2096-05-05' });
  1534. 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' }] });
  1535. out = await runTool();
  1536. done = (out.match(/tmdb sync done: .*/) || [''])[0];
  1537. check('tvmaze merge, TMDB catches up: TMDB adopts the 2 TVmaze episodes (updatedEpisodes=2: tmdbId set, S3E5 renamed), creates nothing, TVmaze adds nothing',
  1538. done.includes('newSeasons=0 newEpisodes=0 updatedEpisodes=2 updatedSeasons=0') && done.includes('errors=0 tvmazeEpisodes=0 tvmazeSeasons=0 tvmazeFilled=0'), out);
  1539. await startServer();
  1540. tst = await seasonsAndTitles();
  1541. check('tvmaze merge, TMDB caught up: still S4 1 / S3 5 episodes (no duplicates), S3E5 now TMDB\'s "Official Fifth", S3E4 still "Real Fourth"',
  1542. JSON.stringify(tst.seasons) === JSON.stringify(['Season 4: 1 episode', 'Season 3: 5 episodes', 'Season 2: 1 episode', 'Season 1: 3 episodes'])
  1543. && 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));
  1544. // ==== tracker.worldapi.org#14: THE SEARCH, signed IN — "Add" imports the title like the daily sync and opens its page ====
  1545. tmdb.delayMs = 0;
  1546. await page.goto(BASE + '/search/gate');
  1547. await hydrated();
  1548. await page.click('#fetchweb');
  1549. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000, label: 'web results (signed in)' });
  1550. const dbBefore = await searchTitles();
  1551. // hybriel#121/#122: a SESSION face (searchImport) that stays on the page — tv 1006's details answer 404 — must leave every
  1552. // list as it was: each row once, the route-param text still in the field and in data-for
  1553. await page.click('web-row button.add[value="tv:1006"]');
  1554. await page.waitFor(`!!document.querySelector('#importerror')`, { timeout: 10000, label: 'import error' }).catch(() => {});
  1555. await new Promise(r => setTimeout(r, 300));
  1556. 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)',
  1557. (await ev(`document.querySelector('#importerror') ? document.querySelector('#importerror').textContent : ''`)) === 'Could not add it: TMDB: HTTP 404 for /tv/1006?append_to_response=external_ids,aggregate_credits'
  1558. && (await searchTitles()) === dbBefore && dbBefore === JSON.stringify(GATE_TITLES) && (await webList()) === JSON.stringify(GATE_WEB)
  1559. && (await ev(`document.querySelector('#searchinput').value`)) === 'gate' && (await searchFor()) === 'gate' && (await ev('location.pathname')) === '/search/gate',
  1560. (await ev(`document.querySelector('search-page').outerHTML.slice(0, 1500)`)));
  1561. await typeSearch('gate t');
  1562. 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());
  1563. await typeSearch('gate');
  1564. await page.click('#fetchweb');
  1565. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000 });
  1566. const reqBefore = tmdb.requests.length;
  1567. await page.click('web-row button.add[value="tv:1004"]');
  1568. await page.waitFor(`location.pathname !== '/search/gate' && !!document.querySelector('show-header h1')`, { timeout: 15000, label: 'imported show page' });
  1569. await page.waitFor(`!!document.querySelector('show-poster img') && document.querySelector('show-poster img').complete`, { timeout: 5000 }).catch(() => {});
  1570. const imp = JSON.parse(await ev(`JSON.stringify({ path: location.pathname, h1: document.querySelector('show-header h1 type-name').textContent,
  1571. seasons: Array.from(document.querySelectorAll('season-row')).map(r => r.querySelector('season-label').textContent + ': ' + r.querySelector('season-count').textContent),
  1572. episodes: Array.from(document.querySelectorAll('episode-row episode-title')).map(e => e.textContent), genres: Array.from(document.querySelectorAll('.genre-pill')).map(g => g.textContent),
  1573. poster: document.querySelector('show-poster img') ? document.querySelector('show-poster img').getAttribute('src') : null,
  1574. posterW: document.querySelector('show-poster img') ? document.querySelector('show-poster img').naturalWidth : 0, summary: document.querySelector('show-summary').textContent })`));
  1575. lk = await extLinks();
  1576. check('search import: "Add" on tv 1004 adds it and opens its page /series/Gate-Import-Show — season, episodes, poster (downloaded), genres matched by name, the summary',
  1577. imp.path === '/series/Gate-Import-Show' && imp.h1 === 'Gate Import Show' && JSON.stringify(imp.seasons) === JSON.stringify(['Season 1: 2 episodes'])
  1578. && JSON.stringify(imp.episodes) === JSON.stringify(['Imported One', 'Imported Two']) && JSON.stringify(imp.genres) === JSON.stringify(['Drama'])
  1579. && 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));
  1580. // mission 060: the imported title has its short id AT ONCE (claimed at creation, no restart, the run's backfill was done)
  1581. const impSid = await ev(`(document.querySelector('show-poster #shortid') || {}).textContent || null`);
  1582. check('short ids: a search import has its short id at once, under the poster (5 characters a–z 0–9)', /^[a-z0-9]{5}$/.test(impSid || '') && impSid !== '100jh', impSid);
  1583. cr = await credits();
  1584. check('#28 search import: the imported series has its cast (Imported Lead as Captain) and crew (Created by Import Creator) at once',
  1585. JSON.stringify(cr.cast) === JSON.stringify([['Imported Lead', ' (Captain)', '/person/Imported-Lead']]) && JSON.stringify(cr.crewSeen) === JSON.stringify(['[Created by]', 'Import Creator'])
  1586. && /search import credits: \/series\/Gate-Import-Show cast=1 crew=1 people=2 /.test(log), JSON.stringify(cr) + log.split('\n').filter(l => /import/.test(l)).join('\n'));
  1587. const impPeople = [sidOfHtml(await (await fetch(BASE + '/person/Imported-Lead')).text()), sidOfHtml(await (await fetch(BASE + '/person/Import-Creator')).text())];
  1588. check('mission 062 short ids: the import\'s new cast + crew people (Imported Lead, Import Creator) have their short ids at once', impPeople.every(x => /^[a-z0-9]{5}$/.test(x || '')) && impPeople[0] !== impPeople[1], JSON.stringify(impPeople));
  1589. check('search import: the ids came like the daily sync\'s — TMDB, IMDb tt0000004, TVDB 4004, TVmaze 504 (looked up by thetvdb)',
  1590. linksAre(lk, [['TMDB', TMDB_TV + '1004'], ['IMDb', IMDB + 'tt0000004/'], ['TVDB', TVDB + '4004'], ['TVmaze', TVMAZE + '504']]), lk);
  1591. const impPaths = tmdb.requests.slice(reqBefore).map(q => q.path);
  1592. // (#28: ONE details request — the record, ids and credits — handed to the sync, no second one)
  1593. check('search import: TMDB asked exactly like the sync (details with external_ids + aggregate_credits, seasons, poster), the details only ONCE, all with the token',
  1594. JSON.stringify(impPaths) === JSON.stringify(['/3/tv/1004?append_to_response=external_ids,aggregate_credits', '/3/tv/1004?append_to_response=season/1', '/t/p/w342/gate4.png'])
  1595. && tmdb.requests.slice(reqBefore).filter(q => q.path.startsWith('/3/')).every(q => q.authorization === 'Bearer gate-token'), JSON.stringify(impPaths));
  1596. await page.goto(BASE + '/search/gate');
  1597. await hydrated();
  1598. st14 = await searchTitles();
  1599. check('search import: our database has it at once (the index too): "gate" now also lists Gate Import Show (2023)',
  1600. st14 === JSON.stringify([GATE_TITLES[0], GATE_TITLES[1], ['Gate Import Show (2023)', '/series/Gate-Import-Show', 'Series'], GATE_TITLES[2]]), st14);
  1601. await page.click('#fetchweb');
  1602. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000 });
  1603. wl = await webList();
  1604. check('search import: the web list leaves it out now (we have it)', wl === JSON.stringify(GATE_WEB.slice(1)), wl);
  1605. await page.click('web-row button.add[value="movie:1005"]');
  1606. await page.waitFor(`location.pathname === '/movies/Gate-Import-Movie' && !!document.querySelector('show-header h1')`, { timeout: 15000, label: 'imported movie page' });
  1607. lk = await extLinks();
  1608. check('search import: a MOVIE (movie 1005) → /movies/Gate-Import-Movie, synced via /movie/ (TMDB /movie/1005 + IMDb), no seasons',
  1609. (await ev(`document.querySelector('show-header h1 type-name').textContent`)) === 'Gate Import Movie' && linksAre(lk, [['TMDB', TMDB_MOVIE + '1005'], ['IMDb', IMDB + 'tt0000005/']])
  1610. && (await ev(`document.querySelectorAll('season-row').length`)) === 0 && !tmdb.requests.some(q => q.path.startsWith('/3/tv/1005')), lk);
  1611. await page.goto(BASE + '/search/gate%20import');
  1612. await hydrated();
  1613. st14 = await searchTitles();
  1614. check('search import: both imported titles are found, each once', st14 === JSON.stringify([['Gate Import Show (2023)', '/series/Gate-Import-Show', 'Series'], ['Gate Import Movie (2001)', '/movies/Gate-Import-Movie', 'Movie']]), st14);
  1615. // merge of #13 + #14: an imported title is in the public lists at once (catalog.hl refreshCatalogShow), no restart
  1616. await page.goto(BASE + '/movies');
  1617. await hydrated();
  1618. titles = await titlesOf('tile-grid');
  1619. await page.goto(BASE + '/series');
  1620. await hydrated();
  1621. tl = await titlesOf('tile-grid');
  1622. check('search import → the lists at once: /movies has Gate Import Movie (2001-05-05) right after the 2099 one, /series has Gate Import Show',
  1623. 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 }));
  1624. // mission 058 (#25): the genre lists too (catalog.hl refreshGenres) — both imports are Drama (TMDB genre 18 = ours by name)
  1625. await page.goto(BASE + '/genres/Drama');
  1626. await hydrated();
  1627. titles = await titlesOf('tile-grid');
  1628. check('#25: imported titles are in their genre at once — /genres/Drama: Gate Import Show (episode 2023-02-09), Gate Test Show (S03E01 2021-05-05), Gate Import Movie (2001-05-05)',
  1629. JSON.stringify(titles) === JSON.stringify(['Gate Import Show', 'Gate Test Show', 'Gate Import Movie']), JSON.stringify(titles));
  1630. if (process.env.TRACKER_GATE_SHOTS) {
  1631. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  1632. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  1633. await page.goto(BASE + '/search/gate');
  1634. await hydrated();
  1635. await page.click('#fetchweb');
  1636. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000 });
  1637. await page.waitFor(`Array.from(document.querySelectorAll('img')).every(i => i.complete)`, { timeout: 5000 }).catch(() => {});
  1638. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `search-web-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1639. }
  1640. await page.send('Emulation.clearDeviceMetricsOverride', {});
  1641. }
  1642. 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'));
  1643. // ==== mission 054: ADULT TITLES — the public lists and the search show only titles TMDB says are NOT adult (unknown =
  1644. // hidden); a followed adult title stays on /my/* and its page; the in-app BACKFILL asks TMDB title by title, resumes
  1645. // after a restart, and pages stay fast meanwhile. tests/seed-adult.hl adds the titles (app stopped) ====
  1646. await page.goto('about:blank'); // mission 056: a tab left on a page logs websocket reconnect errors while the server is down
  1647. await stopServer();
  1648. const seedAdult = execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-adult.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_USER: 'gateu0000000000u1' } }).toString();
  1649. 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);
  1650. await startServer();
  1651. const ADULT_ALL = ['Adult Gate Movie', 'Flagless Gate Movie', 'Flagless Adult Movie', 'Flagless Gate Series', 'Gone Gate Movie', 'Backfill Movie'];
  1652. // the signed-OUT HTML of a page (node fetch, no cookie): which of these titles it names
  1653. const namesIn = async (path) => { const h = await (await fetch(BASE + path)).text(); return ADULT_ALL.filter(t => h.includes(t)); };
  1654. const allMoviesHtml = async () => { let h = ''; for (const p of ['/movies', '/movies/page/2']) h += await (await fetch(BASE + p)).text(); return h; };
  1655. const mh = await allMoviesHtml();
  1656. let seen = { home: await namesIn('/'), movies: ADULT_ALL.filter(t => mh.includes(t)), shows: await namesIn('/series') };
  1657. check('adult (backfill off): signed out, the homepage, every /movies page and /series list NONE of the adult/unknown titles; the count is unchanged (34 movies)',
  1658. seen.home.length === 0 && seen.movies.length === 0 && seen.shows.length === 0 && mh.includes('34 movies, newest first'), JSON.stringify(seen));
  1659. let sr = await (await fetch(BASE + '/search/flagless')).text();
  1660. const sr2 = await (await fetch(BASE + '/search/adult%20gate')).text();
  1661. check('adult (backfill off): the search finds none of them ("flagless", "adult gate" → "Nothing in our database yet.")',
  1662. 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));
  1663. await page.goto(BASE + '/my/movies');
  1664. await hydrated();
  1665. const myMv = await ev(`JSON.stringify(Array.from(document.querySelectorAll('my-show-row')).map(r => r.querySelector('a.myshow-title').textContent))`);
  1666. 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);
  1667. await page.click('a.myshow-title[href="/movies/adult-gate-movie"]');
  1668. await page.waitFor(`location.pathname === '/movies/adult-gate-movie' && !!document.querySelector('show-page')`, { timeout: 10000, label: 'adult page (alice)' });
  1669. await page.waitFor(`!!document.querySelector('show-header h1')`, { timeout: 5000 }).catch(() => {});
  1670. check('adult: …and its page opens for her (client-side from /my/movies, and on a reload) with "Following"',
  1671. (await ev(`document.querySelector('show-header h1') ? document.querySelector('show-header h1 type-name').textContent : 'none'`)) === 'Adult Gate Movie'
  1672. && (await (async () => { await page.goto(BASE + '/movies/adult-gate-movie'); await hydrated(); return ev(`(document.querySelector('show-header h1 type-name') || {}).textContent + '|' + (document.querySelector('#follow') || {}).textContent`); })()) === 'Adult Gate Movie|Following');
  1673. // mission 056: the adult title's PAGE is "Not found" for anyone who does not follow it — signed out (no cookie) here
  1674. const adultOut = await (await fetch(BASE + '/movies/adult-gate-movie')).text();
  1675. // the page's h1: "Not found", or the title (tracker#20: its <type-name> after the type word)
  1676. const pageH1 = (h) => { const m = h.match(/<show-page[^>]*>[\s\S]*?<h1[^>]*>([\s\S]*?)<\/h1>/); if (!m) return undefined; const n = m[1].match(/<type-name[^>]*>([^<]*)</); return (n ? n[1] : m[1]).trim(); };
  1677. check('adult (056): signed out, /movies/adult-gate-movie says "Not found" and names no title (a non-adult page still opens: Flagless Gate Movie is unknown, not hidden)',
  1678. pageH1(adultOut) === 'Not found' && !adultOut.includes('Adult Gate Movie') && pageH1(await (await fetch(BASE + '/movies/flagless-gate-movie')).text()) === 'Flagless Gate Movie', pageH1(adultOut));
  1679. await page.goto(BASE + '/');
  1680. await hydrated();
  1681. const homeIn = await ev(`document.querySelector('home').textContent`);
  1682. check('adult: signed in too, the homepage rows have none of them (only /my/* is personal)', ADULT_ALL.every(t => !homeIn.includes(t)), homeIn);
  1683. await page.goto(BASE + '/search/gate');
  1684. await hydrated();
  1685. const wsBefore = tmdb.searchRequests.length;
  1686. await page.click('#fetchweb');
  1687. await page.waitFor(`document.querySelectorAll('web-row').length > 0 || !!document.querySelector('#webnote')`, { timeout: 10000, label: 'web results (adult)' });
  1688. wl = await webList();
  1689. check('adult: "Fetch from web" asks TMDB with include_adult=false and leaves out the result TMDB marks adult (Gate Adult Web)',
  1690. !wl.includes('Gate Adult Web') && wl.includes('Gate Broken Show') && tmdb.searchRequests.length === wsBefore + 1 && tmdb.searchRequests[wsBefore].path.includes('include_adult=false'), wl);
  1691. 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);
  1692. // ---- the BACKFILL: every answer slowed down to 300 ms; stopped after 6 titles, started again → it goes on with the rest ----
  1693. await page.goto('about:blank'); // mission 056: a tab left on a page logs websocket reconnect errors while the server is down
  1694. await stopServer();
  1695. tmdb.delayMs = 300;
  1696. const bfReq = tmdb.requests.length;
  1697. await startServer({ TRACKER_BACKFILL: '1', TRACKER_BACKFILL_LOG_EVERY: '3' });
  1698. 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); };
  1699. const got6 = await waitLog(/adult backfill: 6\/14 titles/, 30000);
  1700. const log1 = log;
  1701. await page.goto('about:blank'); // mission 056: a tab left on a page logs websocket reconnect errors while the server is down
  1702. await stopServer();
  1703. const asked1 = tmdb.requests.slice(bfReq).map(q => q.path).filter(p => /^\/3\/(movie|tv)\//.test(p));
  1704. 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',
  1705. got6 && log1.includes('adult backfill: start, 14 titles without an adult flag') && /adult backfill: 3\/14 titles/.test(log1)
  1706. && 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'));
  1707. const bfReq2 = tmdb.requests.length;
  1708. await startServer({ TRACKER_BACKFILL: '1', TRACKER_BACKFILL_LOG_EVERY: '3' });
  1709. // the start line comes with the clock's first tick, which can be after the first page answer (mission 056: it was)
  1710. await waitLog(/adult backfill: start, \d+ titles/, 5000);
  1711. const left = Number((log.match(/adult backfill: start, (\d+) titles/) || [])[1]);
  1712. // pages DURING the run: signed out, the homepage, /movies and a search, back to back, timed
  1713. const during54 = [];
  1714. const t054 = Date.now();
  1715. while (!/adult backfill done/.test(log) && Date.now() - t054 < 60000) {
  1716. for (const p of ['/', '/movies', '/search/backfill']) { const t = Date.now(); await (await fetch(BASE + p)).text(); during54.push({ p, ms: Date.now() - t }); }
  1717. }
  1718. const bfSecs = (Date.now() - t054) / 1000;
  1719. const asked2 = tmdb.requests.slice(bfReq2).map(q => q.path).filter(p => /^\/3\/(movie|tv)\//.test(p));
  1720. const askedIds = [...asked1, ...asked2].map(p => p.match(/\/3\/(?:movie|tv)\/(\d+)/)[1]);
  1721. const twice = askedIds.filter((x, k) => askedIds.indexOf(x) !== k);
  1722. const doneLine = (log.match(/adult backfill done: .*/) || [''])[0];
  1723. 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})`);
  1724. 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',
  1725. 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 }));
  1726. check('backfill: done — it stops when nothing is unknown (the done line with its counts); the 404 (1120) was logged',
  1727. /^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'));
  1728. check('backfill: pages stay fast during the run (300 ms per TMDB answer) — every page < 1.2 s, and pages were asked during it',
  1729. during54.length >= 6 && during54.every(l => l.ms < 1200), JSON.stringify(during54.filter(l => l.ms >= 500)));
  1730. // the lists updated AS the flags arrived (no restart since the run)
  1731. seen = { home: await namesIn('/'), shows: await namesIn('/series') };
  1732. const mh2 = await allMoviesHtml();
  1733. seen.movies = ADULT_ALL.filter(t => mh2.includes(t));
  1734. 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; /series and the homepage have Flagless Gate Series (+ the homepage Flagless Gate Movie)',
  1735. 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')
  1736. && JSON.stringify(seen.shows) === JSON.stringify(['Flagless Gate Series']) && JSON.stringify(seen.home) === JSON.stringify(['Flagless Gate Movie', 'Flagless Gate Series']), JSON.stringify(seen));
  1737. sr = await (await fetch(BASE + '/search/flagless')).text();
  1738. check('backfill → the search at once: "flagless" finds Flagless Gate Series + Flagless Gate Movie, not Flagless Adult Movie',
  1739. 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));
  1740. 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)',
  1741. !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?')),
  1742. (log1 + log).split('\n').filter(l => /backfill/.test(l)).join('\n'));
  1743. const pr = await fetch(BASE + '/posters/admvfl.png');
  1744. 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')));
  1745. // a third start: nothing unknown is left that TMDB can answer (the 404 is marked) → no request at all
  1746. await page.goto('about:blank'); // mission 056: a tab left on a page logs websocket reconnect errors while the server is down
  1747. await stopServer();
  1748. const bfReq3 = tmdb.requests.length;
  1749. tmdb.delayMs = 0;
  1750. await startServer({ TRACKER_BACKFILL: '1' });
  1751. await waitLog(/adult backfill: nothing to do/, 5000);
  1752. await new Promise(r => setTimeout(r, 500));
  1753. check('backfill: a restart afterwards has nothing to do (the 404 title is marked, not asked again) — no TMDB request',
  1754. 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'));
  1755. check('adult: the server log has no error', !/error absorbed/i.test(log), log);
  1756. await page.goto(BASE + '/');
  1757. await hydrated();
  1758. 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'));
  1759. // mission 056: alice UNFOLLOWS the adult movie on its page (a session face → hl:web's session sync re-derives `found` with
  1760. // the page's slug, hybriel#122): she no longer follows it → "Not found" at once, on a reload, and it leaves /my/movies
  1761. await page.goto(BASE + '/movies/adult-gate-movie');
  1762. await hydrated();
  1763. const adultBefore = await ev(`(document.querySelector('show-header h1 type-name') || {}).textContent + '|' + (document.querySelector('#follow') || {}).textContent`);
  1764. await page.click('#follow');
  1765. const adultGone = await page.waitFor(`(document.querySelector('show-page h1') || {}).textContent === 'Not found'`, { timeout: 5000, label: 'adult page hidden after unfollow' }).then(() => true, () => false);
  1766. const adultAfter = await ev(`(document.querySelector('show-page h1') || {}).textContent + '|' + ((document.querySelector('#follow') || {}).textContent || '')`);
  1767. await page.goto(BASE + '/movies/adult-gate-movie');
  1768. await hydrated();
  1769. const adultReload = await ev(`(document.querySelector('show-page h1') || {}).textContent`);
  1770. await page.goto(BASE + '/my/movies');
  1771. await hydrated();
  1772. const myMv2 = await ev(`Array.from(document.querySelectorAll('a.myshow-title')).map(a => a.textContent).join(',')`);
  1773. 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',
  1774. adultBefore === 'Adult Gate Movie|Following' && adultGone && adultAfter === 'Not found|' && adultReload === 'Not found' && !myMv2.includes('Adult Gate Movie'), JSON.stringify({ adultBefore, adultAfter, adultReload, myMv2 }));
  1775. // and a normal show page still works after a session face (mission 054's failure: the browser re-derived `found` → `rows`)
  1776. await page.goto(BASE + '/series/gate-test-show');
  1777. await hydrated();
  1778. const ep56 = await ev(`(() => { const b = document.querySelector('episode-row button.checkicon'); return b ? b.value + '|' + b.className : null; })()`);
  1779. await page.click('episode-row button.checkicon');
  1780. 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);
  1781. await page.click('episode-row button.checkicon');
  1782. 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);
  1783. 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);
  1784. // ==== tracker.worldapi.org#16: PERSON PAGES — a complete person at once; an incomplete one fetches TMDB on the first visit
  1785. // ("Loading filmography…", filled without a reload, step by step), titles added once, adult hidden, the links, client-side
  1786. // navigation show → person → show → Back ====
  1787. 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);
  1788. const personFacts = () => ev(`JSON.stringify({ h1: (document.querySelector('person-meta h1 type-name') || {}).textContent || null, facts: Array.from(document.querySelectorAll('person-fact')).map(f => f.textContent),
  1789. bio: (document.querySelector('person-bio') || {}).textContent || null, photo: (document.querySelector('person-photo img') || { getAttribute: () => null }).getAttribute('src'),
  1790. photoLoaded: !!(document.querySelector('person-photo img') && document.querySelector('person-photo img').complete && document.querySelector('person-photo img').naturalWidth > 0),
  1791. loading: !!document.querySelector('#personloading'), error: (document.querySelector('#fillerror') || {}).textContent || null })`).then(JSON.parse);
  1792. await page.goto(BASE + '/person/Complete-Person');
  1793. await hydrated();
  1794. await new Promise(r => setTimeout(r, 600));
  1795. let pf = await personFacts();
  1796. let fr = await filmRows();
  1797. 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',
  1798. 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.'
  1799. && pf.photo === null && !pf.loading && JSON.stringify(fr) === JSON.stringify([['Gate Test Show', '2024', 'Series', 'Old Role', '/series/gate-test-show'], ['Gate Test Movie', '1996', 'Movie', 'Movie Role', '/movies/gate-test-movie']])
  1800. && tmdb.personRequests.length === 0, JSON.stringify({ pf, fr, req: tmdb.personRequests }));
  1801. await page.goto(BASE + '/person/Nobody-Here');
  1802. await hydrated();
  1803. check('person: an unknown slug says "Not found"', (await ev(`document.querySelector('person-page h1').textContent`)) === 'Not found');
  1804. check('person: /profiles/<bad name> answers 400', (await fetch(BASE + '/profiles/..%2Fx')).status === 400 || (await fetch(BASE + '/profiles/a%20b')).status === 400);
  1805. // client-side: the show page's cast link → the person page (a window marker proves no reload); TMDB answers slowly (900 ms)
  1806. // so the loading state is seen; then it fills in by itself
  1807. tmdb.personDelayMs = 900;
  1808. await page.goto(BASE + '/series/gate-test-show');
  1809. await hydrated();
  1810. await ev(`window.__w055 = 'same document'`);
  1811. const fillT0 = Date.now();
  1812. await page.click('a.artist-link[href="/person/Test-Actor"]');
  1813. await page.waitFor(`location.pathname === '/person/Test-Actor' && !!document.querySelector('person-meta h1')`, { timeout: 10000, label: 'person page after the cast click' });
  1814. pf = await personFacts();
  1815. const rowsBefore = (await filmRows()).length;
  1816. // meanwhile another request: answered while the fill runs (the fetch steps yield)
  1817. check('person: first visit (client-side from the cast link) shows "Loading filmography…" and no rows yet',
  1818. pf.h1 === 'Test Actor' && pf.loading && (await ev(`document.querySelector('#personloading').textContent`)).startsWith('Loading filmography') && rowsBefore === 0, JSON.stringify({ pf, rowsBefore }));
  1819. await page.waitFor(`!document.querySelector('#personloading') && document.querySelectorAll('a.film-row').length > 0`, { timeout: 20000, label: 'filmography filled in' });
  1820. const fillMs = Date.now() - fillT0;
  1821. await page.waitFor(`document.querySelector('person-photo img') && document.querySelector('person-photo img').complete`, { timeout: 5000, label: 'photo' }).catch(() => {});
  1822. pf = await personFacts();
  1823. fr = await filmRows();
  1824. const marker16 = await ev('window.__w055 || null');
  1825. check('person: the filmography filled in WITHOUT a reload (same document) — facts and the photo from TMDB too',
  1826. 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.'
  1827. && pf.photo === '/profiles/p1.png' && pf.photoLoaded, JSON.stringify({ marker16, pf, fillMs }));
  1828. const bio0 = await ev(`document.querySelector('person-bio').className`);
  1829. await page.click('person-bio');
  1830. await page.waitFor(`document.querySelector('person-bio').className === 'full'`, { timeout: 5000, label: 'bio open' }).catch(() => {});
  1831. const bio1 = await ev(`document.querySelector('person-bio').className + '|' + getComputedStyle(document.querySelector('person-bio')).webkitLineClamp`);
  1832. check('person: the bio is SHORT (four lines) until clicked, then whole', bio0 === 'short' && bio1 === 'full|none', bio0 + ' → ' + bio1);
  1833. const titles16 = fr.map(r => r[0]);
  1834. check('person: 86 rows (87 TMDB titles − the adult one), newest first, undated last; roles of cast + crew merged; the adult title hidden',
  1835. fr.length === 86 && JSON.stringify(fr.slice(0, 4)) === JSON.stringify([['Gate Test Show', '2024', 'Series', 'Someone', '/series/gate-test-show'], ['Directed Only', '2018', 'Movie', 'Writer', '/movies/Directed-Only'],
  1836. ['Person Series', '2015', 'Series', 'Lead', '/series/Person-Series'], ['Person Film One', '2010', 'Movie', 'Hero / Director', '/movies/Person-Film-One']])
  1837. && titles16[4] === 'Bulk Film 01' && titles16[83] === 'Bulk Film 80' && JSON.stringify(fr[84]) === JSON.stringify(['Gate Test Movie', '1996', 'Movie', 'Cameo', '/movies/gate-test-movie'])
  1838. && JSON.stringify(fr[85]) === JSON.stringify(['Announced Film', '', 'Movie', 'Villain', '/movies/Announced-Film']) && !titles16.includes('Hidden Adult Film'), JSON.stringify(fr.slice(0, 6)) + ' … ' + JSON.stringify(fr.slice(-3)));
  1839. 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')))`));
  1840. check('person: posters — our file when we have one (show 1), else TMDB\'s small one (w92), else the placeholder',
  1841. posters16[0] === '/posters/sh1.png' && posters16[1] === '/posters/none' && posters16[3] === tmdb.base + '/t/p/w92/pf1.png', JSON.stringify(posters16));
  1842. const pr16 = tmdb.personRequests.map(q => q.path);
  1843. const fillLines = log.split('\n').filter(l => l.startsWith('person fill: '));
  1844. 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)',
  1845. 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'
  1846. && fillLines.length === 1 && /^person fill: Test Actor \(tmdb 3002\) credits=87 added=85 requests=2 steps=4 /.test(fillLines[0]), JSON.stringify({ pr16, fillLines }));
  1847. tmdb.personDelayMs = 0;
  1848. // tracker#37: the fill updated Test Actor → FIRST on /people at once (no restart), with the photo the fill downloaded; 24 per
  1849. // page, page 2 goes on where page 1 ended; the search shows the same photo
  1850. const people37 = async (path) => { const h = await (await fetch(BASE + path)).text();
  1851. return { count: (h.match(/class="muted list-count"[^>]*>([^<]*)</) || [])[1], tiles: [...h.matchAll(/<a class="tile person-tile" href="([^"]+)"[^>]*>\s*<img class="tile-poster" src="([^"]*)"[^>]*>\s*<tile-title>([^<]*)</g)].map(m => [m[3], m[1], m[2]]) }; };
  1852. const pa37 = await people37('/people'), pb37 = await people37('/people/page/2');
  1853. const total37 = Number((pa37.count || '').split(' ')[0]);
  1854. check('tracker#37: after the fill Test Actor is FIRST on /people (no restart) with the downloaded photo; 24 per page, page 2 = the next ones (none twice); the search row shows the photo too',
  1855. JSON.stringify(pa37.tiles[0]) === JSON.stringify(['Test Actor', '/person/Test-Actor', '/profiles/p1.png']) && total37 > 24 && pa37.tiles.length === 24
  1856. && pb37.tiles.length === Math.min(24, total37 - 24) && pb37.count === total37 + ' people, last updated first · page 2 of ' + Math.ceil(total37 / 24)
  1857. && !pb37.tiles.some(t => pa37.tiles.some(u => u[1] === t[1])) && (await photo37('test actor', 'Test Actor')) === '/profiles/p1.png', JSON.stringify({ pa37, pb37 }));
  1858. // the links: a row → its show page (client-side); Back → the person page, complete now (no loading)
  1859. await page.click('a.film-row[href="/series/gate-test-show"]');
  1860. await page.waitFor(`location.pathname === '/series/gate-test-show' && !!document.querySelector('show-header h1')`, { timeout: 10000, label: 'show page from the filmography' });
  1861. const back1 = await ev(`document.querySelector('show-header h1 type-name').textContent + '|' + (window.__w055 || '')`);
  1862. await ev('history.back()');
  1863. await page.waitFor(`location.pathname === '/person/Test-Actor' && document.querySelectorAll('a.film-row').length === 86`, { timeout: 10000, label: 'Back to the person' });
  1864. pf = await personFacts();
  1865. check('person: a filmography row → the show page (client-side), Back → the person page, filled, no loading again',
  1866. back1 === 'Gate Test Show|same document' && !pf.loading && (await ev('window.__w055 || null')) === 'same document', JSON.stringify({ back1, pf }));
  1867. // ==== tracker.worldapi.org#26: a title the fill added is MINIMAL — opening it shows what it has + skeletons and completes it
  1868. // without a reload (face showComplete held back 1.5 s so the skeleton state is seen) ====
  1869. const titleParts = () => ev(`JSON.stringify({ h1: (document.querySelector('show-header h1 type-name') || {}).textContent || null, summary: (document.querySelector('show-summary') || {}).textContent || null,
  1870. loading: !!document.querySelector('#showloading'), skPoster: !!document.querySelector('skeleton-poster'), skCast: !!document.querySelector('skeleton-cast'),
  1871. skRows: document.querySelectorAll('show-loading skeleton-row').length, poster: (document.querySelector('show-poster img') || { getAttribute: () => null }).getAttribute('src'),
  1872. posterLoaded: !!(document.querySelector('show-poster img') && document.querySelector('show-poster img').complete && document.querySelector('show-poster img').naturalWidth > 0),
  1873. genres: Array.from(document.querySelectorAll('a.genre-pill')).map(a => a.textContent).join(','), cast: Array.from(document.querySelectorAll('a.artist-link')).map(a => a.textContent + ' ' + a.getAttribute('href')).join(','),
  1874. seasons: Array.from(document.querySelectorAll('season-row')).map(r => r.querySelector('season-label').textContent + ': ' + r.querySelector('season-count').textContent),
  1875. episodes: Array.from(document.querySelectorAll('episode-row')).map(r => r.querySelector('episode-number').textContent + ' ' + r.querySelector('episode-title').textContent), marker: window.__w055 || null })`).then(JSON.parse);
  1876. const asked = (re) => tmdb.requests.filter(q => re.test(q.path)).map(q => q.path);
  1877. const sentBefore26 = (await page.wsSent()).length;
  1878. await ev(`window.__delayFace = { name: 'showComplete', ms: 1500 }`);
  1879. await page.click('a.film-row[href="/movies/Person-Film-One"]');
  1880. await page.waitFor(`location.pathname === '/movies/Person-Film-One' && !!document.querySelector('show-header h1')`, { timeout: 10000, label: 'new title page' });
  1881. let tp = await titleParts();
  1882. check('#26: a title the fill added opens (client-side) with what it has — title, the credit\'s overview, TMDB link, genre Drama, Test Actor — plus the skeleton (poster, cast line; a movie: no season rows); TMDB not asked yet',
  1883. tp.h1 === 'Person Film One' && tp.summary === 'a film of the person' && tp.loading && tp.skPoster && tp.skCast && tp.skRows === 0 && tp.poster === null && tp.genres === 'Drama'
  1884. && tp.cast === 'Test Actor /person/Test-Actor' && linksAre(await extLinks(), [['TMDB', TMDB_MOVIE + '2001']]) && asked(/\/3\/movie\/2001/).length === 0 && tp.marker === 'same document', JSON.stringify(tp));
  1885. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'title-skeleton-1280.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1886. await page.waitFor(`!document.querySelector('#showloading')`, { timeout: 15000, label: 'title complete' });
  1887. await page.waitFor(`document.querySelector('show-poster img') && document.querySelector('show-poster img').complete`, { timeout: 5000, label: 'poster' }).catch(() => {});
  1888. await ev(`window.__delayFace = null`);
  1889. tp = await titleParts();
  1890. lk = await extLinks();
  1891. // (tracker#18: `summary` is the creator's own text — the fill and the completion write tmdbSummary only, so the page shows
  1892. // TMDB's details overview now, not the filmography credit's)
  1893. check('#26: … then complete WITHOUT a reload: poster (downloaded), TMDB + IMDb links, both genres, TMDB\'s overview (#18), cast = Test Actor (linked, no duplicate) + New Cast Member (a new person) — the adult performer left out; skeletons gone',
  1894. tp.marker === 'same document' && !tp.loading && !tp.skPoster && !tp.skCast && tp.poster === '/posters/tmdb-movie-2001.png' && tp.posterLoaded && tp.summary === 'TMDB overview of the film'
  1895. && linksAre(lk, [['TMDB', TMDB_MOVIE + '2001'], ['IMDb', IMDB + 'tt0002001/']]) && tp.genres === 'Drama'
  1896. && tp.cast === 'Test Actor /person/Test-Actor,New Cast Member /person/New-Cast-Member', JSON.stringify(tp) + lk);
  1897. const pc37 = await people37('/people');
  1898. check('tracker#37: the completion created New Cast Member → now first on /people (TMDB\'s profile from the cast as the photo), Test Actor second',
  1899. JSON.stringify(pc37.tiles.slice(0, 2)) === JSON.stringify([['New Cast Member', '/person/New-Cast-Member', tmdb.base + '/t/p/w185/ncm3010.png'], ['Test Actor', '/person/Test-Actor', '/profiles/p1.png']]), JSON.stringify(pc37.tiles.slice(0, 3)));
  1900. cr = await credits();
  1901. check('#28: the completed title also shows its crew at once (Directed by Test Actor), no toggle', JSON.stringify(cr.crewSeen) === JSON.stringify(['[Directed by]', 'Test Actor']) && cr.castToggle === null, JSON.stringify(cr));
  1902. const sent26 = (await page.wsSent()).slice(sentBefore26).filter(f => /showComplete/.test(f));
  1903. check('#26: ONE TMDB details request (external_ids + credits, Bearer token) + the poster; the face showComplete sent once; the log line',
  1904. JSON.stringify(asked(/\/(movie\/2001|w342\/pf1)/)) === JSON.stringify(['/3/movie/2001?append_to_response=external_ids,credits', '/t/p/w342/pf1.png'])
  1905. && tmdb.requests.filter(q => q.path.startsWith('/3/movie/2001')).every(q => q.authorization === 'Bearer gate-token') && sent26.length === 1
  1906. && /details on open: "Person Film One" steps=1 requests=2 tvmazeRequests=0 cast=2 newSeasons=0 newEpisodes=0 posters=1 ids=1 /.test(log), JSON.stringify({ a: asked(/2001|pf1/), sent26 }) + log.split('\n').filter(l => /details/.test(l)).join('\n'));
  1907. await page.goto(BASE + '/movies/Person-Film-One');
  1908. await hydrated();
  1909. await new Promise(r => setTimeout(r, 500));
  1910. const tpReload = await titleParts();
  1911. check('#26: a reload is complete at once (no skeleton, same parts; Drama only — our genres have no Comedy), TMDB not asked again',
  1912. !tpReload.loading && tpReload.poster === tp.poster && tpReload.cast === tp.cast && tpReload.genres === tp.genres && asked(/\/3\/movie\/2001/).length === 1, JSON.stringify(tpReload));
  1913. await page.click('a.artist-link[href="/person/New-Cast-Member"]');
  1914. await page.waitFor(`location.pathname === '/person/New-Cast-Member' && !!document.querySelector('person-meta h1') && !document.querySelector('#personloading')`, { timeout: 10000, label: 'new person' });
  1915. check('#26: the new cast member has a person page (its slug works at once), filled from TMDB like any other: Person Film One, role Partner; mission 062: its short id at once (created in this run, after the start\'s backfill)',
  1916. /^[a-z0-9]{5}$/.test(await ev(`(document.querySelector('#shortid') || {}).textContent || ''`)) && (await ev(`document.querySelector('person-meta h1 type-name').textContent`)) === 'New Cast Member' && JSON.stringify(await filmRows()) === JSON.stringify([['Person Film One', '2010', 'Movie', 'Partner', '/movies/Person-Film-One']]), JSON.stringify(await filmRows()));
  1917. // a SERIES: skeleton season rows, then its season + episodes (TMDB 2 + TVmaze 1), all four links, cast from aggregate_credits
  1918. await page.goto(BASE + '/person/Test-Actor');
  1919. await hydrated();
  1920. await ev(`window.__w055 = 'same document'`);
  1921. await ev(`window.__delayFace = { name: 'showComplete', ms: 1500 }`);
  1922. await page.click('a.film-row[href="/series/Person-Series"]');
  1923. await page.waitFor(`location.pathname === '/series/Person-Series' && !!document.querySelector('show-header h1')`, { timeout: 10000, label: 'series page' });
  1924. tp = await titleParts();
  1925. check('#26: an incomplete SERIES opens with 3 skeleton season rows, no seasons yet', tp.h1 === 'Person Series' && tp.loading && tp.skRows === 3 && tp.seasons.length === 0, JSON.stringify(tp));
  1926. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'series-skeleton-1280.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  1927. await page.waitFor(`!document.querySelector('#showloading')`, { timeout: 15000, label: 'series complete' });
  1928. await ev(`window.__delayFace = null`);
  1929. await page.waitFor(`document.querySelector('show-poster img') && document.querySelector('show-poster img').complete`, { timeout: 5000, label: 'poster' }).catch(() => {});
  1930. tp = await titleParts();
  1931. lk = await extLinks();
  1932. const sentSeries = (await page.wsSent()).filter(f => /showComplete/.test(f)).length; // (wsSent: since this document's load)
  1933. check('#26: … complete without a reload: Season 2 (open) + Season 1 with 3 episodes (2 TMDB + 1 TVmaze, as the daily sync merges), TMDB/IMDb/TVDB/TVmaze links, poster, cast Test Actor + Series Newcomer',
  1934. tp.marker === 'same document' && !tp.loading && JSON.stringify(tp.seasons) === JSON.stringify(['Season 2: 1 episode', 'Season 1: 3 episodes']) && JSON.stringify(tp.episodes) === JSON.stringify(['1 Second Season Start'])
  1935. && linksAre(lk, [['TMDB', TMDB_TV + '2002'], ['IMDb', IMDB + 'tt0002002/'], ['TVDB', TVDB + '4202'], ['TVmaze', TVMAZE + '602']]) && tp.poster === '/posters/tmdb-tv-2002.png' && tp.posterLoaded
  1936. && tp.cast === 'Test Actor /person/Test-Actor,Series Newcomer /person/Series-Newcomer', JSON.stringify(tp) + lk);
  1937. check('#26: a series is completed in STEPS (one face call each; TRACKER_DETAILS_PART_EPISODES=1 → a season per step): details, season 1, season 2, the rest (ids, TVmaze, poster) = 4 calls, ONE season request each',
  1938. sentSeries === 4 && /details on open: "Person Series" steps=4 requests=4 tvmazeRequests=2 cast=2 newSeasons=2 newEpisodes=4 posters=1 ids=3 /.test(log)
  1939. && JSON.stringify(asked(/\/3\/tv\/2002/)) === JSON.stringify(['/3/tv/2002?append_to_response=external_ids,aggregate_credits', '/3/tv/2002?append_to_response=season/1', '/3/tv/2002?append_to_response=season/2']),
  1940. JSON.stringify({ sentSeries, asked: asked(/2002/) }) + log.split('\n').filter(l => /details on open/.test(l)).join('\n'));
  1941. await page.click('season-row:nth-of-type(2) button.season-toggle');
  1942. await page.waitFor(`document.querySelectorAll('episode-row').length === 4 && !document.querySelector('skeleton-row')`, { timeout: 5000, label: 'season 1 open' }).catch(() => {});
  1943. const open26 = (await titleParts()).episodes;
  1944. await page.click('season-row:nth-of-type(2) button.season-toggle');
  1945. await page.waitFor(`document.querySelectorAll('episode-row').length === 1`, { timeout: 5000, label: 'season 1 closed' }).catch(() => {});
  1946. check('#26: the completed series\' season 1 opens (showSeasonEpisodes: its 3 episodes, each once) and closes again', JSON.stringify(open26) === JSON.stringify(['1 Second Season Start', '1 Series Pilot', '2 Series Second', '3 Only On TVmaze'])
  1947. && (await titleParts()).episodes.length === 1, JSON.stringify(open26));
  1948. await page.goto(BASE + '/search/series%20newcomer');

Only the first lines are shown.

Branches

Latest commits

  • e7305014tracker: report 032 (art + photos)mre
  • fbb903cctracker#33/#37 (mission 032): a title without a TMDB poster gets its backdrop (w780, posterFromBackdrop, shown 2:3 centre-cropped); movies store runtime, the page shows release date + runtime; art backfill (public titles without poster file / movies without runtime) and person photo backfill (tmdbProfile / photoCheck) as the last start jobs (TRACKER_ART, TRACKER_PHOTOS; off in every gate start); gates 379/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy: art 4977 titles in 42 min (115 posters, 15 backdrops, 4609 runtimes), The Remaining shows its backdrop + 7 minmre
  • 4ecc67b2tracker: STATUS/LOG for the t38 + t40 merge (gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy checks)mre
  • e5945d2fMerge t40 (tracker#40 curated franchises, Franchises menu, superseded collections) into main: deploy.sh lists all six gates (browser, kinds, franchises, pager, franchiseseed, check-theme); search.hl keeps #37's personPhotoOf + #40's importWithCredits; pager gate runs with TRACKER_FRANCHISE_SEED=0; gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0mre
  • b638e99dMerge t38 (tracker#38 pagination, #35 Returning/Airing label) into main: LOG/STATUS keep both sides; pager gate follows #37's /people (everyone, last updated first: seed updatedAt); gates pager 229/0, browser 374/0mre
  • f8ffa4dbtracker: report 032 + The Remaining + #39mre
  • 7565a863tracker: LOG timemre
  • b10f00c8tracker#39: double episodes — migrated episodes whose TMDB id TMDB replaced are adopted by their number in the sync (old id -> migratedTmdbId); merge.hl step 3 merges each season's doubles at start (keeper: most watches > synced > first; watches moved/parked; tombstones into mergedEpisodes, nothing deleted); tools/count-duplicate-episodes.hl; gate fixture + paths-m039; live copy 850 -> 0 in 64 s; gates 373/0, 32/0, 52/0mre
  • 8f1d4542tracker#40: superseded collections — a TMDB collection timeline whose titles are all in one curated timeline is hidden (supersededBy; kept: own page + editor finder), set by the collection seed when it makes one and by the curated build (lifted when the cover is gone); partly covered ones join that franchise; no second widget (First Contact: only Star Trek — Prime); gate franchiseseed 26/0, browser 365/0, kinds 32/0, franchises 53/0, check-theme 0; real copy 24 supersededmre
  • 9448d643tracker#35 follow-up (mission 033): TVmaze 'Running' is labelled 'Returning', or 'Airing' while a non-special episode of the two newest seasons is released within today +-7 days (data unchanged); gate fixtures Running/Airing/Aired + a special; browser 366/0, kinds 32/0, franchises 52/0, pager 229/0, check-theme 0mre
  • 7d4b293dtracker#40: "Franchises" in the main menu (desktop header after People, phone sidebar) → /franchises, marked on franchise and timeline pages; gates 365/0, 32/0, 53/0, 24/0, check-theme 0mre
  • 8751adb8tracker: report 032mre
  • 9bce1f65tracker mission 032: STATUS gate files + the hour-boundary flakemre
  • 718bfb89tracker#37 (mission 032): /people = everyone, last updated first (updatedAt stamped by the person fill; view built at boot, touched people first at once), photo + name tiles (person colour) with the /movies pagination, /people/<letter> removed; photo = our file, tmdbProfile, a cast/crew entry's profile (in-memory map at boot), else the new 'no photo' placeholder; new cast/crew/created_by people keep tmdbProfile; search people rows with the photo; /settings = the heading only; util.hl sortDesc starts from sorted runs (same result, 105k: 1.6 s -> 0.15 s); gates 369/0, 32/0, 52/0, check-theme 0; README/STATUS/LOGmre
  • 93dfb0batracker#40 (mission 034): the curated franchises — data/franchises.json (17 franchises, 31 timelines, 285 TMDB titles, movies + series, in-universe/release order, 12 TMDB collections attached); lib/franchiseseed.hl + jobs.hl franchiseSeedTick (last start job, imports missing titles via details.hl importWithCredits = the search's Add, one per step paced, then one build; franchiseseed.db: editor changes win, the creator's same-name franchise adopted / timeline left alone, 404 remembered, resumable, idempotent); timeline heads 'N titles · in-universe order' (orderKind) and wrap on a phone; series pages show the widget; new gate tests/franchiseseed.mjs (5th in deploy.sh), the others run with TRACKER_FRANCHISE_SEED=0; gates 365/0, 32/0, 52/0, 24/0, check-theme 0; real copy 196 imported, 0 failed, 7 min, restart unchanged=31mre
  • 03ec792ftracker#38 (mission 033): pagination goes exactly to the clicked page — tilelist read the clicked button's text after pagination.hl's own handler had rebuilt the buttons (real clicks only); now li.current, else the button's own text; new gate tests/pager.mjs (5 lists x 11 pages, 390/1280, real + script clicks, Back/Forward) 229/0; browser 365/0, kinds 32/0, franchises 52/0, check-theme 0mre
  • 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
  • eb3b9205tracker: report 031mre
  • 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre
  • 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre