gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitb8bd1157b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mreb8bd1157/tests/browser.mjs

79.5 KB

  1. // tests/browser.mjs — THE GATE of tracker.worldapi.org#1: this app's own server on its own storage, its OWN ident
  2. // (a copy of ident's code without .env, codes to a mail sink: tests/identkit.mjs, same as calendar's gate), a real
  3. // headless Chrome. Proves: signed out (header shows "Log in with ident") -> sign in -> the header selector shows
  4. // signed in, "Log out" appears, the page itself stays empty -> sign out -> signed out again (a reload confirms it).
  5. //
  6. // node tests/browser.mjs (TRACKER_GATE_PORT server, default 8700; TRACKER_GATE_IDENT_PORT ident, default 8701;
  7. // TRACKER_GATE_CHROME "8702-8709" = Chrome debug ports; TRACKER_GATE_TMDB_PORT the fake
  8. // TMDB, default 8710)
  9. import { spawn, execFileSync } from 'node:child_process';
  10. import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
  11. import { dirname, join, resolve } from 'node:path';
  12. import { fileURLToPath } from 'node:url';
  13. import { launchBrowser } from './cdp.mjs';
  14. import { startIdent } from './identkit.mjs';
  15. import { startFakeTmdb, PNG } from './faketmdb.mjs';
  16. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  17. const HERE = dirname(fileURLToPath(import.meta.url));
  18. const APP = resolve(HERE, '..');
  19. const PORT = Number(process.env.TRACKER_GATE_PORT || 8700);
  20. const [CH_FROM, CH_TO] = (process.env.TRACKER_GATE_CHROME || '8702-8709').split('-').map(Number);
  21. const IDENT_PORT = Number(process.env.TRACKER_GATE_IDENT_PORT || 8701);
  22. const TMDB_PORT = Number(process.env.TRACKER_GATE_TMDB_PORT || 8710);
  23. const BASE = `http://127.0.0.1:${PORT}`;
  24. const STORE = join(APP, '.scratch', 'gate-store');
  25. const IDENT_DIR = process.env.TRACKER_GATE_IDENT_DIR || [resolve(APP, '../ident.worldapi.org'), '/media/STORAGE/projects/ident.worldapi.org'].find(d => existsSync(join(d, 'project.hl')));
  26. const SCRATCH = join(APP, '.scratch');
  27. const SESSIONS = join(SCRATCH, 'gate-sessions');
  28. rmSync(SESSIONS, { recursive: true, force: true });
  29. rmSync(STORE, { recursive: true, force: true });
  30. mkdirSync(SESSIONS, { recursive: true });
  31. let failures = 0, passes = 0;
  32. function check(label, ok, detail = '') {
  33. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  34. if (ok) passes++; else failures++;
  35. }
  36. let ident = null, server = null, browser = null, log = '', tmdb = null;
  37. async function stopServer() {
  38. if (server) {
  39. const s = server;
  40. try { s.kill('SIGTERM'); } catch {}
  41. await new Promise(r => { if (s.exitCode !== null || s.signalCode !== null) return r(); s.once('exit', r); setTimeout(r, 3000); });
  42. server = null;
  43. }
  44. }
  45. async function stop() {
  46. if (ident) { try { await ident.stop(); } catch {} ident = null; }
  47. if (browser) { try { await browser.close(); } catch {} browser = null; }
  48. await stopServer();
  49. if (tmdb) { try { await tmdb.stop(); } catch {} tmdb = null; }
  50. }
  51. // tracker.worldapi.org#9: what the fake TMDB (tests/faketmdb.mjs) knows about the two fixture shows (tmdbId 1001,
  52. // 1002, tests/seed-show.hl). Show 1: season 0 (must NOT be synced — the show has no season 0), S1 with the two
  53. // fixture episodes (no tmdbId locally → matched by number; E2 renamed "Second Episode") + a new E3 (released),
  54. // S2 unchanged, a NEW S3 (E1 released, E2 upcoming 2098-06-01). Show 2 (no seasons yet): a NEW S1 E1 (upcoming
  55. // 2097-01-01). Both have a poster.
  56. // tracker.worldapi.org#12: TMDB's external_ids — show 1 a DIFFERENT imdb id than the stored tt0000001 (must not overwrite)
  57. // + a tvdb id (fills); show 2 an imdb id (fills) + a different tvdb id than the stored 4002 (must not overwrite). The movie
  58. // 1003 (only via /movie/): an imdb id. The fake TVmaze knows imdb tt0000001 → 501 and thetvdb 4002 → 502 (tt0000002 is unknown).
  59. const gateTmdbMovies = () => ({ 1003: { id: 1003, title: 'Gate Test Movie', poster_path: '/gate3.png', external_ids: { imdb_id: 'tt0000003', wikidata_id: 'Q1' } },
  60. // tracker.worldapi.org#14: a movie only TMDB has — imported from the search
  61. 1005: { id: 1005, title: 'Gate Import Movie', poster_path: '/gate5.png', release_date: '2001-05-05', overview: 'an imported movie', genres: [{ id: 18, name: 'Drama' }], external_ids: { imdb_id: 'tt0000005' } } });
  62. const gateTvmaze = () => ({ imdb: { tt0000001: 501 }, thetvdb: { 4002: 502, 4004: 504 } });
  63. // tracker.worldapi.org#14: TMDB's search/multi for "gate": the two titles we HAVE (tv 1001, movie 1003 — left out of the web
  64. // list), a person (dropped), and three new ones: tv 1004 + movie 1005 (importable) and tv 1006 (in the search, but its details
  65. // answer 404 — the failed import)
  66. const gateSearch = () => ({ gate: [
  67. { media_type: 'tv', id: 1001, name: 'Gate Test Show', first_air_date: '2024-01-01', poster_path: '/gate1.png', overview: 'have it' },
  68. { media_type: 'tv', id: 1004, name: 'Gate Import Show', first_air_date: '2023-02-02', poster_path: '/gate4.png', overview: 'new series' },
  69. { media_type: 'person', id: 77, name: 'Gate Person' },
  70. { media_type: 'movie', id: 1003, title: 'Gate Test Movie', release_date: '1996-11-18', poster_path: '/gate3.png' },
  71. { media_type: 'movie', id: 1005, title: 'Gate Import Movie', release_date: '2001-05-05', poster_path: null, overview: 'new movie' },
  72. { media_type: 'tv', id: 1006, name: 'Gate Broken Show', first_air_date: '', poster_path: '/gate6.png' }] });
  73. const gateTmdbShows = () => ({
  74. 1001: { id: 1001, name: 'Gate Test Show', overview: 'TMDB overview', status: 'Returning Series', poster_path: '/gate1.png', external_ids: { imdb_id: 'tt9999999', tvdb_id: 4001, tvrage_id: null }, seasons: [
  75. { 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' }] },
  76. { id: 91, season_number: 1, name: 'Season 1', air_date: '2020-01-01', episodes: [
  77. { id: 5001, episode_number: 1, name: 'Pilot', air_date: '2020-01-01' },
  78. { id: 5002, episode_number: 2, name: 'Second Episode', air_date: '2020-06-15' },
  79. { id: 5003, episode_number: 3, name: 'Third', air_date: '2020-09-01' }] },
  80. { 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' }] },
  81. { id: 93, season_number: 3, name: 'Season 3', air_date: '2021-05-05', episodes: [
  82. { id: 5005, episode_number: 1, name: 'Third Season Start', air_date: '2021-05-05' },
  83. { id: 5006, episode_number: 2, name: 'Coming Soon', air_date: '2098-06-01' }] }] },
  84. // tracker.worldapi.org#14: NOT in our database — imported from the search (no one follows it, the sync never asks for it)
  85. 1004: { id: 1004, name: 'Gate Import Show', overview: 'an imported series', status: 'Returning Series', poster_path: '/gate4.png', first_air_date: '2023-02-02',
  86. original_language: 'en', genres: [{ id: 18, name: 'Drama' }, { id: 10765, name: 'Sci-Fi & Fantasy' }], external_ids: { imdb_id: 'tt0000004', tvdb_id: 4004 }, seasons: [
  87. { id: 95, season_number: 1, name: 'Season 1', air_date: '2023-02-02', episodes: [
  88. { 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' }] }] },
  89. 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: [
  90. { 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' }] }] },
  91. });
  92. async function main() {
  93. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  94. const alice = await ident.signIn('[email protected]');
  95. const APPKEY = await ident.registerApp(alice, 'tracker (gate)', [BASE]);
  96. 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')));
  97. // tracker.worldapi.org#5: learn alice's per-app identity (a throwaway code, consumed here) so the
  98. // fixture below can seed a follow for the SAME user the real login flow will later resolve to
  99. // (users.hl `ensureUser` matches an existing users.db row by `identity`, reusing its id) — this is
  100. // the only way to give /unwatched a known followed show before the browser ever signs in.
  101. const preCode = await ident.selectorCode(alice, APPKEY, BASE);
  102. const aliceIdentity = await ident.exchange(APPKEY, preCode);
  103. // tracker.worldapi.org#4/#5: a tiny fixture show (tests/seed-show.hl) — written BEFORE the server
  104. // starts, self-assigned ids only (never update(), see that file's header)
  105. const MPACKDB = join(STORE, 'mpackdb');
  106. mkdirSync(MPACKDB, { recursive: true });
  107. const seedOut = execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-show.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_IDENTITY: aliceIdentity } }).toString();
  108. check('seed: the fixture show was written, alice follows it', seedOut.includes('gate-test-show') && seedOut.includes('follows it'), seedOut);
  109. // tracker.worldapi.org#8: the fixture show's poster FILE exists (a 1×1 png), the second show has none →
  110. // /my/shows must give the first its own /posters/sh1.png and the second the one placeholder URL /posters/none
  111. mkdirSync(join(MPACKDB, 'posters'), { recursive: true });
  112. writeFileSync(join(MPACKDB, 'posters', 'sh1.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64'));
  113. // tracker.worldapi.org#9: the fake TMDB. EVERY run of the app/tool in this gate points at it with the gate's own
  114. // token — the runtime loads the project's real .env (TMDB_READ_TOKEN) by itself, the environment given here wins
  115. tmdb = await startFakeTmdb({ port: TMDB_PORT, shows: gateTmdbShows(), movies: gateTmdbMovies(), tvmaze: gateTvmaze(), search: gateSearch() });
  116. 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' };
  117. const startServer = async (extra = {}) => {
  118. log = '';
  119. 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',
  120. 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', ...extra }, stdio: ['ignore', 'pipe', 'pipe'] });
  121. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  122. for (let i = 0; ; i++) {
  123. try { if ((await fetch(BASE + '/')).ok) break; } catch {}
  124. if (i > 80) throw new Error('tracker did not come up\n' + log);
  125. await new Promise(r => setTimeout(r, 250));
  126. }
  127. };
  128. await startServer();
  129. check('tracker is up', true);
  130. browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  131. const page = await browser.newPage();
  132. page.captureNetwork();
  133. const ev = (e) => page.evaluate(e);
  134. const hydrated = () => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'hydrated' });
  135. // mission 047: every check icon AND season caret on the page is a real SVG element (not XHTML) and actually drawn
  136. const svgState = () => ev(`JSON.stringify((() => {
  137. const svgs = Array.from(document.querySelectorAll('button.checkicon svg, season-caret svg'));
  138. const shapes = Array.from(document.querySelectorAll('button.checkicon svg circle, button.checkicon svg path, season-caret svg path'));
  139. return { svgs: svgs.length, badNs: svgs.concat(shapes).filter(e => e.namespaceURI !== 'http://www.w3.org/2000/svg').length,
  140. undrawn: Array.from(document.querySelectorAll('button.checkicon svg circle')).filter(c => !(c.getBoundingClientRect().width > 0)).length };
  141. })())`);
  142. const svgOk = (st) => { const v = JSON.parse(st); return v.svgs > 0 && v.badNs === 0 && v.undrawn === 0; };
  143. // the caret's rotation per season row, top to bottom: 'down' (open) or 'right' (collapsed, rotate(-90deg))
  144. const carets = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('season-row')).map(r => {
  145. const t = getComputedStyle(r.querySelector('season-caret')).transform;
  146. return t === 'none' ? 'down' : (t.startsWith('matrix(') && Math.abs(Number(t.slice(7).split(',')[1]) + 1) < 1e-6 ? 'right' : t);
  147. }))`);
  148. // tracker.worldapi.org#12: the header's link icons as [label, href, target, rel], in order
  149. 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')]))`);
  150. const linksAre = (got, want) => JSON.stringify(JSON.parse(got)) === JSON.stringify(want.map(([l, h]) => [l, h, '_blank', 'noopener']));
  151. 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/';
  152. // 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.
  153. // The new tab is closed again at once (it would load the real site).
  154. const clickOpensNewTab = async (sel) => {
  155. const seen = new Map();
  156. const off = browser.conn.onEvent((m) => {
  157. if ((m.method === 'Target.targetCreated' || m.method === 'Target.targetInfoChanged') && m.params.targetInfo.type === 'page') seen.set(m.params.targetInfo.targetId, m.params.targetInfo);
  158. });
  159. await browser.conn.send('Target.setDiscoverTargets', { discover: true });
  160. const known = new Set(seen.keys());
  161. const before = await ev('location.href');
  162. await page.click(sel);
  163. const fresh = () => [...seen.values()].find(i => !known.has(i.targetId) && i.url && i.url !== 'about:blank');
  164. for (let i = 0; i < 100 && !fresh(); i++) await new Promise(r => setTimeout(r, 50));
  165. const tab = fresh();
  166. off();
  167. await browser.conn.send('Target.setDiscoverTargets', { discover: false });
  168. if (tab) await browser.conn.send('Target.closeTarget', { targetId: tab.targetId }).catch(() => {});
  169. return { url: tab ? tab.url : null, canAccessOpener: tab ? tab.canAccessOpener : null, stayed: (await ev('location.href')) === before };
  170. };
  171. const modalOpen = () => ev(`!!(document.querySelector('dialog#signin') && document.querySelector('dialog#signin').open)`);
  172. const loginAs = async (who) => {
  173. const code = await ident.selectorCode(who, APPKEY, BASE);
  174. await page.goto(BASE + '/login/callback?ident_code=' + code);
  175. await page.waitForSelector('#logout');
  176. await hydrated();
  177. };
  178. // ---- signed out: header shows the selector + "Log in with ident", the page itself is empty ----
  179. await page.goto(BASE + '/');
  180. await hydrated();
  181. check('signed out: the identity selector is in the header', await ev(`!!document.querySelector('#selector')`));
  182. check('signed out: "Log in with ident" shows, no "Log out"', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));
  183. check('the page itself stays empty (no centered sign-in)', (await ev(`document.querySelector('main').textContent.trim()`)) === '');
  184. // ---- tracker.worldapi.org#4: /shows/:slug signed OUT — the header/genres/cast show, no checks ----
  185. await page.goto(BASE + '/shows/gate-test-show');
  186. await hydrated();
  187. check('signed out: the show header renders (title, genre, cast)', (await ev(`document.querySelector('show-header h1').textContent`)) === 'Gate Test Show'
  188. && (await ev(`document.querySelector('.genre-pill').textContent`)) === 'Drama'
  189. && (await ev(`document.querySelector('.artist-link').textContent`)) === 'Test Actor');
  190. // ---- tracker.worldapi.org#12: link icons only for the ids the show has (before any sync: show 1 has tmdb + imdb) ----
  191. let lk = await extLinks();
  192. check('link icons: show 1 (tmdbId + imdbId only) has exactly TMDB (tv/1001) and IMDb (tt0000001), each target=_blank rel=noopener',
  193. linksAre(lk, [['TMDB', TMDB_TV + '1001'], ['IMDb', IMDB + 'tt0000001/']]), lk);
  194. check('link icons: monochrome badges (one colour for all, no image), small (≤ 1.6rem high)',
  195. (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) })()`)));
  196. const nt = await clickOpensNewTab('a.extlink[data-site="imdb"]');
  197. 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));
  198. await page.goto(BASE + '/shows/unwatched-gate-show');
  199. await hydrated();
  200. lk = await extLinks();
  201. 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);
  202. await page.goto(BASE + '/shows/gate-test-movie');
  203. await hydrated();
  204. lk = await extLinks();
  205. check('link icons: a movie (tmdbId only) has exactly TMDB as /movie/1003', linksAre(lk, [['TMDB', TMDB_MOVIE + '1003']]), lk);
  206. await page.goto(BASE + '/shows/gate-test-show');
  207. await hydrated();
  208. check('signed out: the latest season (2) is expanded, season 1 collapsed', (await ev(`document.querySelectorAll('episode-row').length`)) === 1);
  209. // mission 047: signed out the checks and "Follow" are VISIBLE; a click opens the sign-in modal and writes nothing
  210. check('signed out (mission 047): the season + episode checks are visible (drawn SVG) and so is "Follow"',
  211. (await ev(`document.querySelectorAll('button.checkicon').length`)) === 3 && svgOk(await svgState())
  212. && (await ev(`document.querySelector('#follow').textContent`)) === 'Follow' && (await ev(`document.querySelector('#follow').getBoundingClientRect().width > 0`)), await svgState());
  213. check('signed out: the sign-in modal is closed at first', !(await modalOpen()));
  214. await page.click('button.checkicon[value="ep0000000000ep0e3"]');
  215. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after a check click' }).catch(() => {});
  216. 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',
  217. (await modalOpen()) && (await ev(`document.querySelector('dialog#signin p').textContent`)) === 'You need to sign in to follow shows and mark episodes.'
  218. && (await ev(`document.querySelector('#signin-link').getAttribute('href')`)).startsWith(ident.base + '/login?key=')
  219. && (await ev(`document.querySelector('#signin-link').getBoundingClientRect().width > 0`))
  220. && (await ev(`Array.from(document.querySelectorAll('dialog#signin footer button')).map(b => b.textContent).join()`)) === 'Close',
  221. await ev(`document.querySelector('dialog#signin') ? document.querySelector('dialog#signin').outerHTML.slice(0, 600) : 'no dialog'`));
  222. 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')); }
  223. await page.click('dialog#signin footer button');
  224. await page.waitFor(`!document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal closed' }).catch(() => {});
  225. check('signed out: Close closes the modal; the check stayed unwatched (nothing written)', !(await modalOpen())
  226. && (await ev(`document.querySelector('button.checkicon[value="ep0000000000ep0e3"]').getAttribute('class')`)) === 'checkicon regular');
  227. await page.click('#follow');
  228. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after Follow' }).catch(() => {});
  229. check('signed out: "Follow" opens the same modal and stays "Follow"', (await modalOpen()) && (await ev(`document.querySelector('#follow').textContent`)) === 'Follow');
  230. await page.click('dialog#signin footer button');
  231. await page.click('button.checkicon[value="se000000000se0s1"]');
  232. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after a season check' }).catch(() => {});
  233. check('signed out: a SEASON check opens the modal too', await modalOpen());
  234. await page.click('dialog#signin footer button');
  235. await page.goto(BASE + '/shows/gate-test-show');
  236. await hydrated();
  237. check('signed out: after a reload every check is still unwatched (the clicks wrote nothing)',
  238. (await ev(`Array.from(document.querySelectorAll('button.checkicon')).every(b => b.getAttribute('class') === 'checkicon regular')`)));
  239. // ---- tracker.worldapi.org#5: /my/unwatched signed OUT — no rows, no checks ----
  240. await page.goto(BASE + '/my/unwatched');
  241. await hydrated();
  242. check('signed out: /my/unwatched shows no rows and asks to sign in', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 0
  243. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');
  244. // ---- tracker.worldapi.org#6: /my/schedule signed OUT — no rows, no checks ----
  245. await page.goto(BASE + '/my/schedule');
  246. await hydrated();
  247. check('signed out: /my/schedule shows no rows and asks to sign in', (await ev(`document.querySelectorAll('schedule-row').length`)) === 0
  248. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');
  249. // ---- tracker.worldapi.org#7: /my/shows signed OUT — no rows, the same sign-in message ----
  250. await page.goto(BASE + '/my/shows');
  251. await hydrated();
  252. check('signed out: /my/shows shows no rows and asks to sign in', (await ev(`document.querySelectorAll('my-show-row').length`)) === 0
  253. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');
  254. // ==== tracker.worldapi.org#14: THE SEARCH, signed OUT — the header's magnifier, our database as you type, "Fetch from web"
  255. // (the fake TMDB's search/multi), "Add" → the sign-in modal ====
  256. const searchFor = () => ev(`document.querySelector('search-page') ? document.querySelector('search-page').getAttribute('data-for') : null`);
  257. 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]))`);
  258. const searchPeople = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('#people .search-person')).map(a => [a.textContent, a.getAttribute('href')]))`);
  259. 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]))`);
  260. const typeSearch = async (text) => {
  261. await page.type('#searchinput', text, { clear: true });
  262. await page.waitFor(`document.querySelector('search-page').getAttribute('data-for') === ${JSON.stringify(text)}`, { timeout: 10000, label: 'results for ' + text });
  263. };
  264. const GATE_TITLES = [['Gate Test Show (2024)', '/shows/gate-test-show', 'Series'], ['Gate Test Movie (1996)', '/shows/gate-test-movie', 'Movie'], ['Unwatched Gate Show (2025)', '/shows/unwatched-gate-show', 'Series']];
  265. check('search: the header has the magnifier (a drawn SVG) linking to /search',
  266. (await ev(`document.querySelector('#searchlink').getAttribute('href')`)) === '/search'
  267. && (await ev(`(() => { const s = document.querySelector('#searchlink svg'); return s.namespaceURI === 'http://www.w3.org/2000/svg' && s.querySelector('circle').getBoundingClientRect().width > 0 })()`)));
  268. await ev(`window.__w052 = 'same document'`);
  269. await page.click('#searchlink');
  270. await page.waitFor(`location.pathname === '/search' && !!document.querySelector('#searchinput')`, { timeout: 10000, label: 'search page' });
  271. check('search: the magnifier opens /search (client-side), an empty search field and "Search our shows, movies and people."',
  272. (await ev('window.__w052')) === 'same document' && (await ev(`document.querySelector('#searchinput').value`)) === ''
  273. && (await ev(`document.querySelector('#searchnote').textContent`)) === 'Search our shows, movies and people.' && !(await ev(`!!document.querySelector('#fetchweb')`)));
  274. await typeSearch('g');
  275. 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')`)));
  276. await page.type('#searchinput', 'ate');
  277. await page.waitFor(`document.querySelector('search-page').getAttribute('data-for') === 'gate'`, { timeout: 10000, label: 'results for gate' });
  278. let st14 = await searchTitles();
  279. 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)',
  280. st14 === JSON.stringify(GATE_TITLES) && (await ev('location.pathname')) === '/search/gate' && (await ev('window.__w052')) === 'same document', st14);
  281. await typeSearch('test act');
  282. 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());
  283. await typeSearch('GATE tést');
  284. check('search: case and accents do not matter ("GATE tést" = gate test); the address carries the text encoded',
  285. (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)) && (await ev('location.pathname')) === '/search/GATE%20t%C3%A9st', (await searchTitles()) + ' ' + (await ev('location.pathname')));
  286. await typeSearch('zzqq');
  287. 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')`)));
  288. await page.goto(BASE + '/search/GATE%20t%C3%A9st');
  289. await hydrated();
  290. check('search: the address alone (reload / shared link) renders the same results on the server, the text in the field',
  291. (await ev(`document.querySelector('#searchinput').value`)) === 'GATE tést' && (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)) && (await searchFor()) === 'GATE tést');
  292. const bad14 = await fetch(BASE + '/search/x%zz');
  293. const bad14b = await fetch(BASE + '/search/%E0%80');
  294. check('search: a malformed address is no error — /search/x%zz 200 with the text taken literally, /search/%E0%80 (broken UTF-8) 200',
  295. bad14.status === 200 && (await bad14.text()).includes('value="x%zz"') && bad14b.status === 200 && (await bad14b.text()).includes('<search-page'), bad14.status + ' ' + bad14b.status);
  296. await typeSearch('gate/te');
  297. 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',
  298. (await ev('location.pathname')) === '/search/gate%20te' && (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)), await ev('location.pathname'));
  299. await page.goto(BASE + '/search/gate%20te');
  300. await hydrated();
  301. check('search: … reloaded', (await searchTitles()) === JSON.stringify(GATE_TITLES.slice(0, 2)) && (await ev(`document.querySelector('#searchinput').value`)) === 'gate te');
  302. await page.goto(BASE + '/search/gate');
  303. await hydrated();
  304. const searchesBefore = tmdb.searchRequests.length;
  305. await page.click('#fetchweb');
  306. await page.waitFor(`document.querySelectorAll('web-row').length > 0 || !!document.querySelector('#webnote')`, { timeout: 10000, label: 'web results' });
  307. let wl = await webList();
  308. const GATE_WEB = [['Gate Import Show (2023)', 'tv:1004', 'Series'], ['Gate Import Movie (2001)', 'movie:1005', 'Movie'], ['Gate Broken Show', 'tv:1006', 'Series']];
  309. 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',
  310. wl === JSON.stringify(GATE_WEB) && tmdb.searchRequests.length === searchesBefore + 1 && tmdb.searchRequests[searchesBefore].path === '/3/search/multi?query=gate&include_adult=false&page=1'
  311. && tmdb.searchRequests[searchesBefore].authorization === 'Bearer gate-token', wl + ' ' + JSON.stringify(tmdb.searchRequests));
  312. check('search: the web rows\' small posters come from TMDB\'s image host (w92); one without a poster gets /posters/none',
  313. (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']));
  314. const requestsBefore = tmdb.requests.length;
  315. await page.click('web-row button.add[value="tv:1004"]');
  316. await page.waitFor(`document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal after Add' }).catch(() => {});
  317. check('search: signed out, "Add" opens the sign-in modal ("You need to sign in to add titles.", sign-in link) and sends nothing',
  318. (await modalOpen()) && (await ev(`document.querySelector('dialog#signin p').textContent`)) === 'You need to sign in to add titles.'
  319. && (await ev(`document.querySelector('#signin-link').getAttribute('href')`)).startsWith(ident.base + '/login?key=') && tmdb.requests.length === requestsBefore);
  320. 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'));
  321. await page.click('dialog#signin footer button');
  322. await page.waitFor(`!document.querySelector('dialog#signin').open`, { timeout: 5000, label: 'modal closed' }).catch(() => {});
  323. check('search: Close closes it; the web list is still there', !(await modalOpen()) && (await webList()) === JSON.stringify(GATE_WEB));
  324. // ---- tracker.worldapi.org#3: no bottom border on the header, no border on filled buttons ----
  325. check('the header has no bottom border', (await ev(`getComputedStyle(document.querySelector('application-header')).borderBottomWidth`)) === '0px');
  326. check('"Log in with ident" has no border', (await ev(`getComputedStyle(document.querySelector('#loginbutton')).borderTopWidth`)) === '0px');
  327. check('the ident selector\'s "choose ident" button has no border', (await ev(`getComputedStyle(document.querySelector('#selector').shadowRoot.querySelector('[part~="button"]')).borderTopWidth`)) === '0px');
  328. // ---- tracker.worldapi.org#8: the old addresses answer a permanent redirect to the /my/ ones ----
  329. for (const [from, to] of [['/unwatched', '/my/unwatched'], ['/schedule', '/my/schedule']]) {
  330. const m = await fetch(BASE + from, { redirect: 'manual' });
  331. check(`${from} answers 301 → ${to}`, m.status === 301 && m.headers.get('location') === to, m.status + ' ' + m.headers.get('location'));
  332. }
  333. await page.goto(BASE + '/unwatched');
  334. await hydrated();
  335. check('the browser following /unwatched lands on /my/unwatched', (await ev(`location.pathname`)) === '/my/unwatched' && !!(await ev(`document.querySelector('unwatched-page')`)));
  336. // ---- tracker.worldapi.org#8: the show page says "1 episode" for one, "2 episodes" for two ----
  337. await page.goto(BASE + '/shows/gate-test-show');
  338. await hydrated();
  339. 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']),
  340. JSON.stringify(await ev(`Array.from(document.querySelectorAll('season-count')).map(e => e.textContent)`)));
  341. let r = await fetch(BASE + '/login/callback', { redirect: 'manual' });
  342. check('login: /login/callback without a code → /login/failed', r.status === 302 && r.headers.get('location') === '/login/failed');
  343. // ---- sign in: the header selector flips to signed in, "Log out" appears, page stays empty ----
  344. await loginAs(alice);
  345. check('signed in: the selector is still in the header, now signed in', (await ev(`!!document.querySelector('#selector')`)) && (await ev(`document.querySelector('#selector').classList.contains('in')`)));
  346. check('signed in: "Log out" shows, no "Log in with ident"', (await ev(`!!document.querySelector('#logout')`)) && !(await ev(`!!document.querySelector('#loginbutton')`)));
  347. check('the page itself stays empty while signed in too', (await ev(`document.querySelector('main').textContent.trim()`)) === '');
  348. check('"Log out" has no border (only inverted buttons have one)', (await ev(`getComputedStyle(document.querySelector('#logout')).borderTopWidth`)) === '0px');
  349. check('the ident selector\'s signed-in status has no border', (await ev(`(() => { const e = document.querySelector('#selector').shadowRoot.querySelector('[part~="status"]'); return e ? getComputedStyle(e).borderTopWidth : '0px' })()`)) === '0px');
  350. check('the header brand "tracker" has font weight 100', (await ev(`getComputedStyle(document.querySelector('application-header .brand')).fontWeight`)) === '100');
  351. // ---- tracker.worldapi.org#5: /unwatched signed IN — only already-released, unwatched episodes of
  352. // followed shows, newest release first; a click removes the row. Run BEFORE the show-page checks
  353. // below (which toggle these same fixture episodes watched) so all three still start unwatched here.
  354. await page.goto(BASE + '/my/unwatched');
  355. await hydrated();
  356. const unwatchedRowTexts = () => ev(`Array.from(document.querySelectorAll('.unwatched-show')).map(e => e.textContent)`);
  357. check('signed in: /my/unwatched lists only already-released episodes, newest release first (not the future one), titles with the year',
  358. JSON.stringify(await unwatchedRowTexts()) === JSON.stringify(['Gate Test Show (2024)', 'Gate Test Show (2024)'])
  359. && (await ev(`document.querySelectorAll('unwatched-row').length`)) === 2);
  360. check('signed in: /my/unwatched shows show title, the episode as S01E02 + title and release date, linking to the show',
  361. (await ev(`document.querySelector('unwatched-row unwatched-episode').textContent`)) === 'S01E02 · Second'
  362. && (await ev(`document.querySelector('unwatched-row unwatched-date').textContent`)) === '2020-06-15'
  363. && (await ev(`document.querySelector('unwatched-row .unwatched-show').getAttribute('href')`)) === '/shows/gate-test-show');
  364. check('signed in: /my/unwatched starts with regular (unwatched) check icons', (await ev(`document.querySelectorAll('unwatched-row button.checkicon.regular').length`)) === 2);
  365. await page.click('unwatched-row button.checkicon');
  366. await page.waitFor(`document.querySelectorAll('unwatched-row').length === 1`, { timeout: 10000, label: 'unwatched row removed' });
  367. check('click an unwatched episode\'s check: it disappears from the list', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 1
  368. && (await ev(`document.querySelector('unwatched-episode').textContent`)) === 'S01E01 · Pilot');
  369. await page.goto(BASE + '/my/unwatched');
  370. await hydrated();
  371. check('reload: the watch from /my/unwatched is server-side, not just client state', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 1
  372. && (await ev(`document.querySelector('unwatched-episode').textContent`)) === 'S01E01 · Pilot');
  373. // ---- tracker.worldapi.org#7: /my/shows signed IN — followed shows, newest follow first, small
  374. // poster, title linking to the show, the last watched episode (only ep 2 "Second" is watched so far)
  375. const myShows = () => ev(`Array.from(document.querySelectorAll('my-show-row')).map(r => ({
  376. title: r.querySelector('.myshow-title') && r.querySelector('.myshow-title').textContent,
  377. href: r.querySelector('.myshow-title') && r.querySelector('.myshow-title').getAttribute('href'),
  378. poster: r.querySelector('img.myshow-poster') && r.querySelector('img.myshow-poster').getAttribute('src'),
  379. posterLoaded: !!(r.querySelector('img.myshow-poster') && r.querySelector('img.myshow-poster').complete && r.querySelector('img.myshow-poster').naturalWidth > 0),
  380. episode: r.querySelector('my-show-episode') ? r.querySelector('my-show-episode').textContent : null }))`);
  381. const waitPostersLoaded = () => page.waitFor(`Array.from(document.querySelectorAll('img.myshow-poster')).every(i => i.complete && i.naturalWidth > 0)`, { timeout: 10000, label: 'posters loaded' });
  382. await page.goto(BASE + '/my/shows');
  383. await hydrated();
  384. await waitPostersLoaded();
  385. let ms = await myShows();
  386. check('signed in: /my/shows lists both followed shows, newest follow first, with the year (2025 from `release` — that show has no `year`)', JSON.stringify(ms.map(m => m.title)) === JSON.stringify(['Unwatched Gate Show (2025)', 'Gate Test Show (2024)']), JSON.stringify(ms));
  387. check('signed in: /my/shows titles link to the show pages', ms.length === 2 && ms[0].href === '/shows/unwatched-gate-show' && ms[1].href === '/shows/gate-test-show', JSON.stringify(ms));
  388. check('signed in: /my/shows shows a loaded poster per row (its own file, else the one placeholder URL /posters/none)', ms.length === 2 && ms.every(m => m.posterLoaded) && ms[1].poster === '/posters/sh1.png' && ms[0].poster === '/posters/none', JSON.stringify(ms));
  389. check('signed in: /my/shows shows the last watched episode as S01E02, none for the never-watched show', ms.length === 2 && ms[1].episode === 'S01E02' && ms[0].episode === null, JSON.stringify(ms));
  390. check('/my/shows: the poster is small (2.5rem wide)', (await ev(`getComputedStyle(document.querySelector('img.myshow-poster')).width`)) === '40px');
  391. // ---- tracker.worldapi.org#6: /schedule signed IN — only NOT-yet-released episodes of followed
  392. // shows, soonest first, no check icons at all (read-only page, unaffected by any watch toggle above)
  393. await page.goto(BASE + '/my/schedule');
  394. await hydrated();
  395. check('signed in: /my/schedule lists only the not-yet-released episode, title with the year', (await ev(`document.querySelectorAll('schedule-row').length`)) === 1
  396. && (await ev(`document.querySelector('.schedule-show').textContent`)) === 'Gate Test Show (2024)');
  397. check('signed in: /my/schedule shows show title, the episode as S02E01 + title, release date, linking to the show, no checks',
  398. (await ev(`document.querySelector('schedule-row schedule-episode').textContent`)) === 'S02E01 · Season Two Opener'
  399. && (await ev(`document.querySelector('schedule-row schedule-date').textContent`)) === '2099-01-01'
  400. && (await ev(`document.querySelector('schedule-row .schedule-show').getAttribute('href')`)) === '/shows/gate-test-show'
  401. && (await ev(`document.querySelectorAll('schedule-row button.checkicon').length`)) === 0);
  402. // ---- tracker.worldapi.org#4: /shows/:slug signed IN — check icons, episode toggle, season toggle,
  403. // collapse/expand, and a reload proves the watch really is server-side ----
  404. const EP3 = 'ep0000000000ep0e3'; // season 2 (expanded by default), episode 1 "Season Two Opener"
  405. const EP1 = 'ep0000000000ep0e1'; // season 1 (collapsed by default), episode 1 "Pilot"
  406. const EP2 = 'ep0000000000ep0e2'; // season 1, episode 2 "Second"
  407. const SEASON1 = 'se000000000se0s1';
  408. const checkClass = (id) => ev(`document.querySelector('button.checkicon[value="${id}"]').getAttribute('class')`);
  409. const clickValue = (sel, id) => page.click(`${sel}[value="${id}"]`);
  410. await page.goto(BASE + '/shows/gate-test-show');
  411. await hydrated();
  412. check('signed in: episode check icons show, starting "regular" (not watched)', (await checkClass(EP3)) === 'checkicon regular');
  413. await clickValue('button.checkicon', EP3);
  414. await page.waitFor(`document.querySelector('button.checkicon[value="${EP3}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'ep3 solid' });
  415. check('click an episode check: it turns solid', (await checkClass(EP3)) === 'checkicon solid');
  416. check('season 1 starts collapsed (no episode rows of it yet)', (await ev(`document.querySelectorAll('episode-row').length`)) === 1);
  417. await clickValue('button.checkicon', SEASON1);
  418. await page.waitFor(`document.querySelector('button.checkicon[value="${SEASON1}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'season1 solid' });
  419. check('click a season check: the season itself turns solid', (await checkClass(SEASON1)) === 'checkicon solid');
  420. await clickValue('button.season-toggle', SEASON1);
  421. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  422. check('expanding season 1 shows its episodes already solid (the season check watched them too)',
  423. (await checkClass(EP1)) === 'checkicon solid' && (await checkClass(EP2)) === 'checkicon solid');
  424. await page.goto(BASE + '/shows/gate-test-show');
  425. await hydrated();
  426. check('reload: episode 3\'s watch is server-side, not just client state', (await checkClass(EP3)) === 'checkicon solid');
  427. await clickValue('button.season-toggle', SEASON1);
  428. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  429. 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');
  430. // ---- mission 047, the creator's additions on the show page (signed in) ----
  431. check('episodes are not indented: an episode\'s check is exactly under its season\'s check (same left edge)',
  432. (await ev(`Math.abs(document.querySelector('episode-row button.checkicon').getBoundingClientRect().left - document.querySelector('season-row button.checkicon').getBoundingClientRect().left) < 0.5`)));
  433. check('artist links are orange #ce9178 (not red)', (await ev(`getComputedStyle(document.querySelector('.artist-link')).color`)) === 'rgb(206, 145, 120)');
  434. check('an unwatched check is the inverted solid icon: accent outline circle AND accent check mark; watched = filled accent disc, dark check',
  435. (await ev(`(() => { const b = document.querySelector('button.checkicon.regular'); const c = getComputedStyle(b.querySelector('circle')), p = getComputedStyle(b.querySelector('path'));
  436. return c.stroke === 'rgb(78, 201, 176)' && c.fill !== 'rgb(78, 201, 176)' && p.stroke === 'rgb(78, 201, 176)' })()`))
  437. && (await ev(`(() => { const b = document.querySelector('button.checkicon.solid'); const c = getComputedStyle(b.querySelector('circle')), p = getComputedStyle(b.querySelector('path'));
  438. return c.fill === 'rgb(78, 201, 176)' && p.stroke === 'rgb(15, 20, 25)' })()`)));
  439. // ---- tracker.worldapi.org#7: /my/shows again — S02E01 was watched BEFORE season 1's bulk watch, so
  440. // this proves "last" = the highest season/episode watched, not the most recently watched one
  441. await page.goto(BASE + '/my/shows');
  442. await hydrated();
  443. ms = await myShows();
  444. check('/my/shows: the last episode is the HIGHEST watched (S02E01), not the most recent watch; the other show still none',
  445. ms.length === 2 && ms[1].episode === 'S02E01' && ms[0].episode === null, JSON.stringify(ms));
  446. // ---- mission 047 (the creator: "i dont see the check marks"): a CLIENT-SIDE navigation /my/shows → click the show
  447. // title builds the show page in the browser; its check <svg> must be an SVG element (the old vendored build made it
  448. // in the XHTML namespace: nothing drawn, circle width 0). A window marker proves no full page load happened.
  449. await ev(`window.__w047 = 'same document'`);
  450. await page.click(`a.myshow-title[href="/shows/gate-test-show"]`);
  451. await page.waitFor(`location.pathname === '/shows/gate-test-show' && !!document.querySelector('button.checkicon svg circle')`, { timeout: 10000, label: 'show page after client-side navigation' });
  452. const svgNs = await ev(`JSON.stringify({ marker: window.__w047 || null, ns: document.querySelector('button.checkicon svg').namespaceURI,
  453. circleNs: document.querySelector('button.checkicon svg circle').namespaceURI,
  454. circleWidth: document.querySelector('button.checkicon svg circle').getBoundingClientRect().width })`);
  455. const sv = JSON.parse(svgNs);
  456. check('client-side navigation /my/shows → show page: the check svg is in the SVG namespace and its circle is drawn (width > 0)',
  457. sv.marker === 'same document' && sv.ns === 'http://www.w3.org/2000/svg' && sv.circleNs === 'http://www.w3.org/2000/svg' && sv.circleWidth > 0, svgNs);
  458. lk = await extLinks();
  459. check('client-side navigation: the link icons are there too (TMDB + IMDb)', linksAre(lk, [['TMDB', TMDB_TV + '1001'], ['IMDb', IMDB + 'tt0000001/']]), lk);
  460. // mission 047 #11: the checks also vanished after expanding/collapsing a season — same cause; and the caret flips
  461. let cs = await carets();
  462. check('season caret at the header\'s right edge: down for the open season 2, right for the collapsed season 1',
  463. cs === JSON.stringify(['down', 'right']) && (await ev(`(() => { const r = document.querySelector('season-row'); return Math.abs(r.getBoundingClientRect().right - r.querySelector('season-caret').getBoundingClientRect().right) < 2 })()`)), cs);
  464. await clickValue('button.season-toggle', SEASON1);
  465. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  466. let st = await svgState();
  467. cs = await carets();
  468. check('after EXPANDING season 1 (client-side): every check + caret is a drawn SVG, season 1\'s caret now points down', svgOk(st) && cs === JSON.stringify(['down', 'down']), st + ' ' + cs);
  469. await clickValue('button.checkicon', EP1);
  470. await page.waitFor(`document.querySelector('button.checkicon[value="${EP1}"]').getAttribute('class') === 'checkicon regular'`, { timeout: 10000, label: 'ep1 regular' });
  471. st = await svgState();
  472. check('after an EPISODE click: every check is still a drawn SVG', svgOk(st), st);
  473. await clickValue('button.checkicon', EP1);
  474. await page.waitFor(`document.querySelector('button.checkicon[value="${EP1}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'ep1 solid again' });
  475. await clickValue('button.season-toggle', SEASON1);
  476. await page.waitFor(`!document.querySelector('button.checkicon[value="${EP1}"]')`, { timeout: 10000, label: 'season 1 collapsed' });
  477. st = await svgState();
  478. cs = await carets();
  479. check('after COLLAPSING season 1 again: every check + caret is a drawn SVG, its caret points right again', svgOk(st) && cs === JSON.stringify(['down', 'right']), st + ' ' + cs);
  480. // ---- mission 047 #12: FOLLOW on the show page — "Following" filled; a click unfollows ("Follow", outlined in the
  481. // accent), the show leaves /my/shows; a click follows again (follow date = now) → the top of /my/shows; reloads keep it
  482. const followState = () => ev(`JSON.stringify((() => { const b = document.querySelector('#follow'); const c = getComputedStyle(b);
  483. return { text: b.textContent, cls: b.getAttribute('class'), bg: c.backgroundColor, border: c.borderTopWidth + ' ' + c.borderTopColor }; })())`);
  484. let fs = JSON.parse(await followState());
  485. check('follow: alice follows the show → "Following", the filled accent button (no border)', fs.text === 'Following' && fs.cls === 'follow' && fs.bg === 'rgb(78, 201, 176)' && fs.border.startsWith('0px'), JSON.stringify(fs));
  486. await page.click('#follow');
  487. await page.waitFor(`document.querySelector('#follow').textContent === 'Follow'`, { timeout: 10000, label: 'unfollowed' }).catch(() => {});
  488. fs = JSON.parse(await followState());
  489. check('follow: a click unfollows → "Follow", outlined (1px accent border, transparent)', fs.text === 'Follow' && fs.cls === 'follow inverted' && fs.border === '1px rgb(78, 201, 176)' && fs.bg === 'rgba(0, 0, 0, 0)', JSON.stringify(fs));
  490. await page.goto(BASE + '/shows/gate-test-show');
  491. await hydrated();
  492. check('follow: the unfollow survives a reload', (await ev(`document.querySelector('#follow').textContent`)) === 'Follow');
  493. await page.goto(BASE + '/my/shows');
  494. await hydrated();
  495. ms = await myShows();
  496. check('follow: the unfollowed show is gone from /my/shows', ms.length === 1 && ms[0].href === '/shows/unwatched-gate-show', JSON.stringify(ms));
  497. await page.goto(BASE + '/shows/gate-test-show');
  498. await hydrated();
  499. await page.click('#follow');
  500. await page.waitFor(`document.querySelector('#follow').textContent === 'Following'`, { timeout: 10000, label: 'followed' }).catch(() => {});
  501. check('follow: a click follows again → "Following"', (await ev(`document.querySelector('#follow').textContent`)) === 'Following');
  502. await page.goto(BASE + '/my/shows');
  503. await hydrated();
  504. ms = await myShows();
  505. check('follow: followed just now → the TOP of /my/shows (newest follow first), after a reload', ms.length === 2 && ms[0].href === '/shows/gate-test-show' && ms[1].href === '/shows/unwatched-gate-show', JSON.stringify(ms));
  506. await page.goto(BASE + '/shows/gate-test-show');
  507. await hydrated();
  508. check('follow: "Following" survives a reload', (await ev(`document.querySelector('#follow').textContent`)) === 'Following');
  509. // ---- mission 047, MOBILE FIRST: at a phone's 390 px the header is ONE row (brand + ident selector + "Log out"), and
  510. // no page scrolls sideways; at 1280 px the same pages. Screenshots of both widths with TRACKER_GATE_SHOTS.
  511. const pages = [['home', '/'], ['show', '/shows/gate-test-show'], ['my-shows', '/my/shows'], ['my-unwatched', '/my/unwatched'], ['my-schedule', '/my/schedule'], ['search', '/search/gate']];
  512. if (process.env.TRACKER_GATE_SHOTS) mkdirSync(process.env.TRACKER_GATE_SHOTS, { recursive: true });
  513. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  514. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  515. const bad = [];
  516. for (const [name, path] of pages) {
  517. await page.goto(BASE + path);
  518. await hydrated();
  519. const m = JSON.parse(await ev(`JSON.stringify((() => {
  520. const hd = document.querySelector('application-header').getBoundingClientRect();
  521. const mid = (s) => { const r = document.querySelector(s).getBoundingClientRect(); return r.top + r.height / 2; };
  522. const mids = ['application-header .brand', '#selector', '#logout'].map(mid);
  523. const wd = (s) => Math.round(document.querySelector(s).getBoundingClientRect().width);
  524. return { headerH: hd.height, spread: Math.max(...mids) - Math.min(...mids), scrollW: document.documentElement.scrollWidth, widths: ['application-header .brand', '#userbox', '#selector', '#logout'].map(wd) };
  525. })())`));
  526. if (m.spread > 8 || m.headerH > 64 || m.scrollW > w) bad.push(name + ' ' + JSON.stringify(m));
  527. if (name === 'show') {
  528. // mission 047 #8: phone = title first, then the poster at the full content width, then genres/plot/cast;
  529. // desktop unchanged = poster left (10rem), title right of it
  530. 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 }; };
  531. const main = document.querySelector('main'); const cs = getComputedStyle(main);
  532. 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'),
  533. content: main.getBoundingClientRect().width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) }; })())`));
  534. const ok = mobile
  535. ? L.h1.b <= L.links.t && L.links.b <= L.poster.t && Math.abs(L.poster.w - L.content) < 1 && L.poster.b <= L.genres.t && L.genres.b <= L.summary.t && L.summary.b <= L.cast.t && L.follow.t < L.poster.t
  536. : 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;
  537. check(`${w} px: show page layout — ${mobile ? 'title (+ Follow) first, the link icons under it, then the poster at the full content width, then genres, plot, cast' : 'poster left (10rem), the title right of it, the link icons under the title'}`, ok, JSON.stringify(L));
  538. }
  539. if (process.env.TRACKER_GATE_SHOTS) {
  540. if (name === 'my-shows') await waitPostersLoaded();
  541. const shot = await page.send('Page.captureScreenshot', { format: 'png' });
  542. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `${name}-${w}.png`), Buffer.from(shot.data, 'base64'));
  543. }
  544. }
  545. check(`${w} px: on every page the header is one row (brand, ident selector, "Log out" side by side, ≤ 64 px high) and nothing scrolls sideways`, bad.length === 0, bad.join('; '));
  546. }
  547. await page.send('Emulation.clearDeviceMetricsOverride', {});
  548. // ==== tracker.worldapi.org#9: THE TMDB SYNC, against the fake TMDB — first the tool (app stopped: hl:mpackdb is
  549. // one process per storage), twice (idempotent), then the pages; then the app's own daily run ====
  550. 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);
  551. await page.goto('about:blank'); // leave the app first: an open page would log websocket reconnect errors
  552. await stopServer();
  553. // spawned, not execFileSync: the fake TMDB lives in THIS node process and must keep answering meanwhile
  554. const runTool = () => new Promise((ok) => {
  555. const p = spawn(join(APP, 'bin/hybriel'), ['tools/sync-tmdb.hl', '--limit', '5'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, ...TMDB_ENV }, stdio: ['ignore', 'pipe', 'pipe'] });
  556. let o = '';
  557. p.stdout.on('data', d => o += d); p.stderr.on('data', d => o += d);
  558. p.on('exit', () => ok(o));
  559. });
  560. let out = await runTool();
  561. let done = (out.match(/tmdb sync done: .*/) || [''])[0];
  562. 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, 0 errors',
  563. done.includes('shows=3 newSeasons=2 newEpisodes=4 updatedEpisodes=3') && done.includes('posters=3 ids=5') && done.includes('tvmazeRequests=3 errors=0'), out);
  564. // per show, in this order (the order BETWEEN shows follows the follow table, which the follow test above reshuffled)
  565. const perShow = [['/3/tv/1001?append_to_response=external_ids', '/3/tv/1001?append_to_response=season/1,season/2,season/3', '/t/p/w342/gate1.png'],
  566. ['/3/tv/1002?append_to_response=external_ids', '/3/tv/1002?append_to_response=season/1', '/t/p/w342/gate2.png'], ['/3/movie/1003?append_to_response=external_ids', '/t/p/w342/gate3.png']];
  567. const paths = tmdb.requests.map(q => q.path);
  568. check('sync tool: every TMDB call carried the Bearer token, season 0 was never asked for; per show ONE details request that also brings external_ids (no extra request), one for its seasons, the poster; the movie only via /movie/, never /tv/1003',
  569. tmdb.requests.filter(q => q.path.startsWith('/3/')).every(q => q.authorization === 'Bearer gate-token') && !paths.some(p => p.includes('season/0'))
  570. && 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))),
  571. JSON.stringify(paths));
  572. 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); never for the movie',
  573. 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'].sort()),
  574. JSON.stringify(tmdb.tvmazeRequests));
  575. const tvmazeBefore = tmdb.tvmazeRequests.length;
  576. out = await runTool();
  577. done = (out.match(/tmdb sync done: .*/) || [''])[0];
  578. 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)',
  579. done.includes('newSeasons=0 newEpisodes=0 updatedEpisodes=0 updatedSeasons=0 posters=0 ids=0') && done.includes('tvmazeRequests=0 errors=0') && tmdb.tvmazeRequests.length === tvmazeBefore, out);
  580. await startServer();
  581. await page.goto(BASE + '/my/schedule');
  582. await hydrated();
  583. 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)`);
  584. let rows = await scheduleRows();
  585. check('after the sync: /my/schedule lists the new upcoming episodes of both shows, soonest first', JSON.stringify(rows) === JSON.stringify([
  586. '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));
  587. await page.goto(BASE + '/my/unwatched');
  588. await hydrated();
  589. rows = await ev(`Array.from(document.querySelectorAll('unwatched-row')).map(r => r.querySelector('unwatched-episode').textContent + ' | ' + r.querySelector('unwatched-date').textContent)`);
  590. 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));
  591. await page.goto(BASE + '/my/shows');
  592. await hydrated();
  593. await waitPostersLoaded();
  594. ms = await myShows();
  595. check('after the sync: /my/shows shows both real posters (/posters/sh2.png is new), loaded', ms.length === 2 && ms.every(m => m.posterLoaded) && ms[0].poster === '/posters/sh1.png' && ms[1].poster === '/posters/sh2.png', JSON.stringify(ms));
  596. r = await fetch(BASE + '/posters/sh2.png');
  597. const posterBytes = Buffer.from(await r.arrayBuffer());
  598. 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')));
  599. await page.goto(BASE + '/shows/gate-test-show');
  600. await hydrated();
  601. const seasonCounts = await ev(`Array.from(document.querySelectorAll('season-row')).map(e => e.querySelector('season-label').textContent + ': ' + e.querySelector('season-count').textContent)`);
  602. 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));
  603. check('after the sync: the latest season shows its new episodes, the poster is the downloaded one',
  604. JSON.stringify(await ev(`Array.from(document.querySelectorAll('episode-row episode-title')).map(e => e.textContent)`)) === JSON.stringify(['Third Season Start', 'Coming Soon'])
  605. && (await ev(`document.querySelector('show-poster img').getAttribute('src')`)) === '/posters/sh1.png' && (await ev(`document.querySelector('show-poster img').naturalWidth`)) > 0);
  606. await clickValue('button.season-toggle', SEASON1);
  607. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  608. 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'))`);
  609. check('after the sync: S1 keeps its episodes (same ids, still watched), E2 renamed from TMDB, E3 new and unwatched',
  610. 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));
  611. // ---- tracker.worldapi.org#12: after the sync every show has the ids it lacked — the stored ones unchanged ----
  612. await page.goto(BASE + '/shows/gate-test-show');
  613. await hydrated();
  614. lk = await extLinks();
  615. 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',
  616. linksAre(lk, [['TMDB', TMDB_TV + '1001'], ['IMDb', IMDB + 'tt0000001/'], ['TVDB', TVDB + '4001'], ['TVmaze', TVMAZE + '501']]), lk);
  617. await page.goto(BASE + '/shows/unwatched-gate-show');
  618. await hydrated();
  619. lk = await extLinks();
  620. 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)',
  621. linksAre(lk, [['TMDB', TMDB_TV + '1002'], ['IMDb', IMDB + 'tt0000002/'], ['TVDB', TVDB + '4002'], ['TVmaze', TVMAZE + '502']]), lk);
  622. await page.goto(BASE + '/shows/gate-test-movie');
  623. await hydrated();
  624. lk = await extLinks();
  625. check('after the sync: the movie has TMDB (/movie/1003) + IMDb (tt0000003 filled), no TheTVDB/TVmaze, no seasons',
  626. linksAre(lk, [['TMDB', TMDB_MOVIE + '1003'], ['IMDb', IMDB + 'tt0000003/']]) && (await ev(`document.querySelectorAll('season-row').length`)) === 0, lk);
  627. if (process.env.TRACKER_GATE_SHOTS) {
  628. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
  629. for (const [name, path] of [['synced-show-390', '/shows/gate-test-show'], ['synced-movie-390', '/shows/gate-test-movie']]) {
  630. await page.goto(BASE + path);
  631. await hydrated();
  632. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, name + '.png'), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  633. }
  634. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  635. for (const [name, path] of [['synced-show', '/shows/gate-test-show'], ['synced-movie', '/shows/gate-test-movie'], ['synced-my-shows', '/my/shows'], ['synced-my-unwatched', '/my/unwatched'], ['synced-my-schedule', '/my/schedule']]) {
  636. await page.goto(BASE + path);
  637. await hydrated();
  638. if (name === 'synced-my-shows') await waitPostersLoaded();
  639. const shot = await page.send('Page.captureScreenshot', { format: 'png' });
  640. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, name + '.png'), Buffer.from(shot.data, 'base64'));
  641. }
  642. await page.send('Emulation.clearDeviceMetricsOverride', {});
  643. }
  644. // ---- THE DAILY RUN INSIDE THE APP: TRACKER_SYNC_HOUR = this very UTC hour, so the app's own clock starts the
  645. // run right after boot. TMDB now has one more episode and answers slowly (700 ms per request): the run takes
  646. // seconds, and pages asked for meanwhile must still be answered between two shows, never after the whole run.
  647. await page.goto('about:blank');
  648. await stopServer();
  649. tmdb.shows[1001].seasons[3].episodes.push({ id: 5007, episode_number: 3, name: 'Added Live', air_date: '2096-03-03' });
  650. tmdb.delayMs = 700;
  651. const before = tmdb.requests.length;
  652. await startServer({ TRACKER_SYNC: '1', TRACKER_SYNC_HOUR: String(new Date().getUTCHours()) });
  653. const lat = [];
  654. const t0 = Date.now();
  655. while (!log.includes('tmdb sync done') && Date.now() - t0 < 30000) {
  656. const a = Date.now();
  657. await fetch(BASE + '/');
  658. lat.push({ at: a - t0, ms: Date.now() - a, running: log.includes('tmdb sync: start') && !log.includes('tmdb sync done') });
  659. await new Promise(r => setTimeout(r, 150));
  660. }
  661. const runDone = (log.match(/tmdb sync done: .*/) || [''])[0];
  662. check('in-app daily run: started by the app\'s own clock at the configured hour, found the new episode, 0 errors',
  663. log.includes('tmdb sync: daily at ' + new Date().getUTCHours() + ':00 UTC') && log.includes('tmdb sync: start, 3 followed shows') && runDone.includes('newSeasons=0 newEpisodes=1 updatedEpisodes=0') && runDone.includes('errors=0'), log);
  664. const during = lat.filter(l => l.running);
  665. const runSecs = Number((runDone.match(/seconds=([0-9.]+)/) || [0, 0])[1]);
  666. console.log(` (in-app run ${runSecs} s; ${during.length} pages asked during it, slowest ${Math.max(0, ...during.map(l => l.ms))} ms)`);
  667. 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',
  668. runSecs >= 3 && during.length >= 3 && during.every(l => l.ms < 2500), 'run ' + runSecs + ' s, during: ' + JSON.stringify(during));
  669. 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', tmdb.requests.slice(before).filter(q => q.path.startsWith('/3/')).every(q => q.authorization === 'Bearer gate-token') && tmdb.requests.length - before === 5 && tmdb.tvmazeRequests.length === tvmazeBefore, JSON.stringify(tmdb.requests.slice(before)));
  670. await page.goto(BASE + '/my/schedule');
  671. await hydrated();
  672. rows = await scheduleRows();
  673. 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));
  674. // ==== tracker.worldapi.org#14: THE SEARCH, signed IN — "Add" imports the title like the daily sync and opens its page ====
  675. tmdb.delayMs = 0;
  676. await page.goto(BASE + '/search/gate');
  677. await hydrated();
  678. await page.click('#fetchweb');
  679. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000, label: 'web results (signed in)' });
  680. const dbBefore = await searchTitles();
  681. // hybriel#121/#122: a SESSION face (searchImport) that stays on the page — tv 1006's details answer 404 — must leave every
  682. // list as it was: each row once, the route-param text still in the field and in data-for
  683. await page.click('web-row button.add[value="tv:1006"]');
  684. await page.waitFor(`!!document.querySelector('#importerror')`, { timeout: 10000, label: 'import error' }).catch(() => {});
  685. await new Promise(r => setTimeout(r, 300));
  686. 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)',
  687. (await ev(`document.querySelector('#importerror') ? document.querySelector('#importerror').textContent : ''`)) === 'Could not add it: TMDB: HTTP 404 for /tv/1006'
  688. && (await searchTitles()) === dbBefore && dbBefore === JSON.stringify(GATE_TITLES) && (await webList()) === JSON.stringify(GATE_WEB)
  689. && (await ev(`document.querySelector('#searchinput').value`)) === 'gate' && (await searchFor()) === 'gate' && (await ev('location.pathname')) === '/search/gate',
  690. (await ev(`document.querySelector('search-page').outerHTML.slice(0, 1500)`)));
  691. await typeSearch('gate t');
  692. 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());
  693. await typeSearch('gate');
  694. await page.click('#fetchweb');
  695. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000 });
  696. const reqBefore = tmdb.requests.length;
  697. await page.click('web-row button.add[value="tv:1004"]');
  698. await page.waitFor(`location.pathname !== '/search/gate' && !!document.querySelector('show-header h1')`, { timeout: 15000, label: 'imported show page' });
  699. await page.waitFor(`!!document.querySelector('show-poster img') && document.querySelector('show-poster img').complete`, { timeout: 5000 }).catch(() => {});
  700. const imp = JSON.parse(await ev(`JSON.stringify({ path: location.pathname, h1: document.querySelector('show-header h1').textContent,
  701. seasons: Array.from(document.querySelectorAll('season-row')).map(r => r.querySelector('season-label').textContent + ': ' + r.querySelector('season-count').textContent),
  702. episodes: Array.from(document.querySelectorAll('episode-row episode-title')).map(e => e.textContent), genres: Array.from(document.querySelectorAll('.genre-pill')).map(g => g.textContent),
  703. poster: document.querySelector('show-poster img') ? document.querySelector('show-poster img').getAttribute('src') : null,
  704. posterW: document.querySelector('show-poster img') ? document.querySelector('show-poster img').naturalWidth : 0, summary: document.querySelector('show-summary').textContent })`));
  705. lk = await extLinks();
  706. check('search import: "Add" on tv 1004 adds it and opens its page /shows/Gate-Import-Show — season, episodes, poster (downloaded), genres matched by name, the summary',
  707. imp.path === '/shows/Gate-Import-Show' && imp.h1 === 'Gate Import Show' && JSON.stringify(imp.seasons) === JSON.stringify(['Season 1: 2 episodes'])
  708. && JSON.stringify(imp.episodes) === JSON.stringify(['Imported One', 'Imported Two']) && JSON.stringify(imp.genres) === JSON.stringify(['Drama'])
  709. && 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));
  710. check('search import: the ids came like the daily sync\'s — TMDB, IMDb tt0000004, TVDB 4004, TVmaze 504 (looked up by thetvdb)',
  711. linksAre(lk, [['TMDB', TMDB_TV + '1004'], ['IMDb', IMDB + 'tt0000004/'], ['TVDB', TVDB + '4004'], ['TVmaze', TVMAZE + '504']]), lk);
  712. const impPaths = tmdb.requests.slice(reqBefore).map(q => q.path);
  713. check('search import: TMDB asked exactly like the sync (details, seasons, poster) + one details request for the new record, all with the token',
  714. JSON.stringify(impPaths) === JSON.stringify(['/3/tv/1004', '/3/tv/1004?append_to_response=external_ids', '/3/tv/1004?append_to_response=season/1', '/t/p/w342/gate4.png'])
  715. && tmdb.requests.slice(reqBefore).filter(q => q.path.startsWith('/3/')).every(q => q.authorization === 'Bearer gate-token'), JSON.stringify(impPaths));
  716. await page.goto(BASE + '/search/gate');
  717. await hydrated();
  718. st14 = await searchTitles();
  719. check('search import: our database has it at once (the index too): "gate" now also lists Gate Import Show (2023)',
  720. st14 === JSON.stringify([GATE_TITLES[0], GATE_TITLES[1], ['Gate Import Show (2023)', '/shows/Gate-Import-Show', 'Series'], GATE_TITLES[2]]), st14);
  721. await page.click('#fetchweb');
  722. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000 });
  723. wl = await webList();
  724. check('search import: the web list leaves it out now (we have it)', wl === JSON.stringify(GATE_WEB.slice(1)), wl);
  725. await page.click('web-row button.add[value="movie:1005"]');
  726. await page.waitFor(`location.pathname === '/shows/Gate-Import-Movie' && !!document.querySelector('show-header h1')`, { timeout: 15000, label: 'imported movie page' });
  727. lk = await extLinks();
  728. check('search import: a MOVIE (movie 1005) → /shows/Gate-Import-Movie, synced via /movie/ (TMDB /movie/1005 + IMDb), no seasons',
  729. (await ev(`document.querySelector('show-header h1').textContent`)) === 'Gate Import Movie' && linksAre(lk, [['TMDB', TMDB_MOVIE + '1005'], ['IMDb', IMDB + 'tt0000005/']])
  730. && (await ev(`document.querySelectorAll('season-row').length`)) === 0 && !tmdb.requests.some(q => q.path.startsWith('/3/tv/1005')), lk);
  731. await page.goto(BASE + '/search/gate%20import');
  732. await hydrated();
  733. st14 = await searchTitles();
  734. check('search import: both imported titles are found, each once', st14 === JSON.stringify([['Gate Import Show (2023)', '/shows/Gate-Import-Show', 'Series'], ['Gate Import Movie (2001)', '/shows/Gate-Import-Movie', 'Movie']]), st14);
  735. if (process.env.TRACKER_GATE_SHOTS) {
  736. for (const [w, h, mobile] of [[390, 844, true], [1280, 900, false]]) {
  737. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });
  738. await page.goto(BASE + '/search/gate');
  739. await hydrated();
  740. await page.click('#fetchweb');
  741. await page.waitFor(`document.querySelectorAll('web-row').length > 0`, { timeout: 10000 });
  742. await page.waitFor(`Array.from(document.querySelectorAll('img')).every(i => i.complete)`, { timeout: 5000 }).catch(() => {});
  743. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, `search-web-${w}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  744. }
  745. await page.send('Emulation.clearDeviceMetricsOverride', {});
  746. }
  747. 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'));
  748. // ==== tracker.worldapi.org#10: THE INSTALLABLE APP — hl:web's own manifest + service worker (project.hl appIcons /
  749. // offline = [ Home ]). The manifest and every icon over HTTP; Chrome's own verdict (installability, manifest parse);
  750. // the worker registered for the whole app; then OFFLINE: the tab loses the network (the note appears), the server
  751. // is stopped too (CDP's offline emulation does not reach the service worker's own fetches — measured — so only a
  752. // dead server proves the cache), and `/` still opens: header + the note. A data page gets hl:web's "Unavailable
  753. // offline" page. A separate tab, so its expected socket errors stay out of the main tab's console check.
  754. const html = await (await fetch(BASE + '/')).text();
  755. const mhref = (html.match(/<link rel="manifest" href="([^"]+)"/) || [])[1];
  756. check('pwa: every page head links the manifest, the apple-touch-icon, the favicon and the theme colour (the token `darker`)',
  757. mhref === '/__hl/manifest.webmanifest' && html.includes('<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">')
  758. && html.includes('<link rel="icon" href="/icons/favicon.svg">') && html.includes('<meta name="theme-color" content="rgb(15, 20, 25)">'), html.slice(0, 900));
  759. const mres = await fetch(BASE + mhref);
  760. const man = await mres.json();
  761. check('pwa: the manifest is served as application/manifest+json: name "tracker", start_url /, scope /, display standalone, the token colours',
  762. /manifest\+json/.test(mres.headers.get('content-type') || '') && man.name === 'tracker' && man.short_name === 'tracker' && man.start_url === '/' && man.scope === '/'
  763. && man.display === 'standalone' && man.theme_color === 'rgb(15, 20, 25)' && man.background_color === 'rgb(25, 30, 35)', JSON.stringify(man));
  764. const iconOk = [];
  765. for (const ic of man.icons || []) {
  766. const ir = await fetch(BASE + ic.src);
  767. const b = Buffer.from(await ir.arrayBuffer());
  768. iconOk.push(ir.ok && ic.type === 'image/png' && b.readUInt32BE(16) === Number(ic.sizes.split('x')[0]) && b.readUInt32BE(20) === Number(ic.sizes.split('x')[1]));
  769. }
  770. check('pwa: every manifest icon loads and is a real PNG of its declared size (192 + 512, any + maskable)',
  771. iconOk.length === 4 && iconOk.every(Boolean) && ['any', 'maskable'].every(pu => ['192x192', '512x512'].every(sz => man.icons.some(i => i.purpose === pu && i.sizes === sz))), JSON.stringify(man.icons) + ' ' + JSON.stringify(iconOk));
  772. const touch = await fetch(BASE + '/icons/apple-touch-icon.png');
  773. const touchBytes = Buffer.from(await touch.arrayBuffer());
  774. const fav = await fetch(BASE + '/icons/favicon.svg');
  775. const favText = await fav.text();
  776. const ico = await fetch(BASE + '/favicon.ico');
  777. const icoBytes = Buffer.from(await ico.arrayBuffer());
  778. check('pwa: apple-touch-icon is a 180×180 PNG, the favicon an SVG in tracker green, /favicon.ico a real icon file',
  779. touch.ok && touchBytes.readUInt32BE(16) === 180 && fav.ok && /image\/svg\+xml/.test(fav.headers.get('content-type') || '') && favText.includes('#4ec9b0')
  780. && ico.ok && icoBytes.readUInt32BE(0) === 0x00000100, `${touch.status} ${fav.status} ${ico.status}`);
  781. await page.goto('about:blank'); // the server goes down below: the main tab must not sit on a page meanwhile
  782. const app = await browser.newPage();
  783. const aev = (e) => app.evaluate(e);
  784. const appHydrated = () => app.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'app tab hydrated' });
  785. await app.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
  786. await app.goto(BASE + '/');
  787. await appHydrated();
  788. await app.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && r.active))`, { timeout: 15000, label: 'service worker active' });
  789. await app.waitFor(`!!navigator.serviceWorker.controller`, { timeout: 10000, label: 'page controlled by the worker' });
  790. const inst = await app.send('Page.getInstallabilityErrors');
  791. check('pwa: Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, JSON.stringify(inst));
  792. const am = await app.send('Page.getAppManifest');
  793. check('pwa: Chrome parses the manifest without errors', am.url && am.url.endsWith('/__hl/manifest.webmanifest') && (am.errors || []).length === 0, JSON.stringify(am.errors));
  794. check('pwa: hl:web\'s service worker is registered for the whole app (scope /) and controls the page',
  795. (await aev(`navigator.serviceWorker.getRegistration().then(r => new URL(r.scope).pathname + ' ' + new URL(r.active.scriptURL).pathname)`)) === '/ /__hl/sw.js' && (await aev('!!navigator.serviceWorker.controller')));
  796. check('pwa: online, no offline note', !(await aev(`!!document.querySelector('#offline')`)));
  797. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'pwa-phone-online.png'), Buffer.from((await app.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  798. await app.setOffline(true);
  799. await app.waitFor(`!!document.querySelector('#offline')`, { timeout: 5000, label: 'offline note (live)' }).catch(() => {});
  800. check('offline: the open page says so within seconds (the shell\'s tick)', (await aev(`document.querySelector('#offline') ? document.querySelector('#offline').textContent : ''`)) === 'You are offline. Your shows and lists need the network.');
  801. await stopServer();
  802. let down = false;
  803. try { await fetch(BASE + '/'); } catch { down = true; }
  804. await app.goto(BASE + '/');
  805. await app.waitFor(`!!document.querySelector('#offline')`, { timeout: 8000, label: 'offline note after reload' }).catch(() => {});
  806. check('offline: with the server gone too, a reload of / still opens the shell from the worker\'s cache — header, brand, the note, the empty home',
  807. down && (await aev(`document.querySelector('application-header .brand') ? document.querySelector('application-header .brand').textContent : ''`)) === 'tracker'
  808. && (await aev(`!!document.querySelector('#offline')`)) && (await aev(`document.querySelector('main').textContent.trim()`)) === ''
  809. && (await aev(`getComputedStyle(document.querySelector('application-header')).backgroundColor`)) === 'rgb(15, 20, 25)', 'server down: ' + down);
  810. if (process.env.TRACKER_GATE_SHOTS) writeFileSync(join(process.env.TRACKER_GATE_SHOTS, 'pwa-phone-offline.png'), Buffer.from((await app.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
  811. await app.goto(BASE + '/my/shows');
  812. check('offline: a data page (/my/shows) is not kept — hl:web\'s "Unavailable offline" page', (await aev(`!!document.querySelector('main[data-hl-offline]') && document.querySelector('h1').textContent`)) === 'Unavailable offline');
  813. await app.setOffline(false);
  814. await startServer();
  815. await app.goto(BASE + '/');
  816. await appHydrated();
  817. await app.waitFor(`!document.querySelector('#offline')`, { timeout: 5000, label: 'note gone' }).catch(() => {});
  818. check('back online: / from the server again, no offline note', !(await aev(`!!document.querySelector('#offline')`)));
  819. await app.close();
  820. await page.goto(BASE + '/my/schedule');
  821. await hydrated();
  822. // ---- sign out: back to signed out, a reload confirms the app's own session (not just client state) ----
  823. await page.click('#logout');
  824. await page.waitForSelector('#loginbutton', { timeout: 15000 });
  825. check('sign out returns to the signed-out header', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));
  826. await page.goto(BASE + '/');
  827. await hydrated();
  828. check('reload after sign-out stays signed out', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));
  829. const problems = page.problems();
  830. check('no console errors/warnings', problems.length === 0, page.dumpConsole());
  831. }
  832. try {
  833. await main();
  834. } catch (e) {
  835. check('gate ran without throwing', false, e.stack || String(e));
  836. } finally {
  837. await stop();
  838. }
  839. console.log(`\n${passes} passed, ${failures} failed`);
  840. process.exit(failures === 0 ? 0 : 1);

Branches

Latest commits

  • b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
  • 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
  • cbdc4ea7tracker#12: link icons TMDB/IMDb/TVDB/TVmaze; sync fills missing ids (TVmaze lookup); movies fetched via /movie/mre
  • b105bcd8tracker#11: Hybriel master ff51cf46 (checks no longer vanish), mobile-first styles, carets, follow button, sign-in modal, inverted check, orange castmre
  • 31b758aatracker#10: installable app (manifest, service worker, offline shell), own icon + faviconmre
  • 2fa9d997tracker#9: TMDB sync (followed shows: seasons, episodes, posters), tools/sync-tmdb.hl + daily run 04:00 UTC, fake TMDB in gatemre
  • 49e1f61edeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 54070a4etracker#8: /my/unwatched + /my/schedule (301 from old), S01E01, title (year), 1 episode, watched-set lookup (unwatched 15s -> 1s)mre
  • 3251488atracker#7: /my/shows (followed shows, newest follow first, poster, title, last watched SxxEyy); gate can take screenshots (TRACKER_GATE_SHOTS)mre
  • 44b7d9f9tracker#6: /schedule — upcoming episodes of followed shows, soonest firstmre
  • 91c9fc8ctracker#5: /unwatched — unwatched released episodes of followed shows, newest firstmre
  • a97c0295tracker#4: show page /shows/:slug (header, seasons, episodes, watch checks) + tools/relink-episode-seasons.hlmre
  • 05f407c5tracker: no border on any button except inverted ones (Log out, ident status and identities too); header brand weight 100mre
  • 17375427tracker#2: tools/migrate.hl + tools/verify.hl — old MongoDB data into mpackdb with new idsmre
  • 31aac936tracker#3: no border on the header and on filled buttons; inverted buttons keep theirsmre
  • 2ad9d29ctracker#1: login exactly like calendar (identity selector in the header, empty homepage)mre
  • 3691e176tracker#1: empty tracker with the ident login (state of 2026-09-27)mre