gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit54070a4e54070a4etracker#8: /my/unwatched + /my/schedule (301 from old), S01E01, title (year), 1 episode, watched-set lookup (unwatched 15s -> 1s)mre54070a4e/tests/browser.mjs

21.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)
  8. import { spawn, execFileSync } from 'node:child_process';
  9. import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
  10. import { dirname, join, resolve } from 'node:path';
  11. import { fileURLToPath } from 'node:url';
  12. import { launchBrowser } from './cdp.mjs';
  13. import { startIdent } from './identkit.mjs';
  14. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  15. const HERE = dirname(fileURLToPath(import.meta.url));
  16. const APP = resolve(HERE, '..');
  17. const PORT = Number(process.env.TRACKER_GATE_PORT || 8700);
  18. const [CH_FROM, CH_TO] = (process.env.TRACKER_GATE_CHROME || '8702-8709').split('-').map(Number);
  19. const IDENT_PORT = Number(process.env.TRACKER_GATE_IDENT_PORT || 8701);
  20. const BASE = `http://127.0.0.1:${PORT}`;
  21. const STORE = join(APP, '.scratch', 'gate-store');
  22. 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')));
  23. const SCRATCH = join(APP, '.scratch');
  24. const SESSIONS = join(SCRATCH, 'gate-sessions');
  25. rmSync(SESSIONS, { recursive: true, force: true });
  26. rmSync(STORE, { recursive: true, force: true });
  27. mkdirSync(SESSIONS, { recursive: true });
  28. let failures = 0, passes = 0;
  29. function check(label, ok, detail = '') {
  30. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  31. if (ok) passes++; else failures++;
  32. }
  33. let ident = null, server = null, browser = null, log = '';
  34. async function stop() {
  35. if (ident) { try { await ident.stop(); } catch {} ident = null; }
  36. if (browser) { try { await browser.close(); } catch {} browser = null; }
  37. if (server) {
  38. try { server.kill('SIGTERM'); } catch {}
  39. await new Promise(r => { if (server.exitCode !== null || server.signalCode !== null) return r(); server.once('exit', r); setTimeout(r, 3000); });
  40. server = null;
  41. }
  42. }
  43. async function main() {
  44. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  45. const alice = await ident.signIn('[email protected]');
  46. const APPKEY = await ident.registerApp(alice, 'tracker (gate)', [BASE]);
  47. 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')));
  48. // tracker.worldapi.org#5: learn alice's per-app identity (a throwaway code, consumed here) so the
  49. // fixture below can seed a follow for the SAME user the real login flow will later resolve to
  50. // (users.hl `ensureUser` matches an existing users.db row by `identity`, reusing its id) — this is
  51. // the only way to give /unwatched a known followed show before the browser ever signs in.
  52. const preCode = await ident.selectorCode(alice, APPKEY, BASE);
  53. const aliceIdentity = await ident.exchange(APPKEY, preCode);
  54. // tracker.worldapi.org#4/#5: a tiny fixture show (tests/seed-show.hl) — written BEFORE the server
  55. // starts, self-assigned ids only (never update(), see that file's header)
  56. const MPACKDB = join(STORE, 'mpackdb');
  57. mkdirSync(MPACKDB, { recursive: true });
  58. const seedOut = execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-show.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_IDENTITY: aliceIdentity } }).toString();
  59. check('seed: the fixture show was written, alice follows it', seedOut.includes('gate-test-show') && seedOut.includes('follows it'), seedOut);
  60. // tracker.worldapi.org#8: the fixture show's poster FILE exists (a 1×1 png), the second show has none →
  61. // /my/shows must give the first its own /posters/sh1.png and the second the one placeholder URL /posters/none
  62. mkdirSync(join(MPACKDB, 'posters'), { recursive: true });
  63. writeFileSync(join(MPACKDB, 'posters', 'sh1.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64'));
  64. 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',
  65. TRACKER_URL: BASE, IDENT_URL: ident.base, IDENT_EXCHANGE_URL: ident.base, TRACKER_KEY: APPKEY.key, TRACKER_SECRET: APPKEY.secret }, stdio: ['ignore', 'pipe', 'pipe'] });
  66. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  67. for (let i = 0; ; i++) {
  68. try { if ((await fetch(BASE + '/')).ok) break; } catch {}
  69. if (i > 80) throw new Error('tracker did not come up\n' + log);
  70. await new Promise(r => setTimeout(r, 250));
  71. }
  72. check('tracker is up', true);
  73. browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  74. const page = await browser.newPage();
  75. page.captureNetwork();
  76. const ev = (e) => page.evaluate(e);
  77. const hydrated = () => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'hydrated' });
  78. const loginAs = async (who) => {
  79. const code = await ident.selectorCode(who, APPKEY, BASE);
  80. await page.goto(BASE + '/login/callback?ident_code=' + code);
  81. await page.waitForSelector('#logout');
  82. await hydrated();
  83. };
  84. // ---- signed out: header shows the selector + "Log in with ident", the page itself is empty ----
  85. await page.goto(BASE + '/');
  86. await hydrated();
  87. check('signed out: the identity selector is in the header', await ev(`!!document.querySelector('#selector')`));
  88. check('signed out: "Log in with ident" shows, no "Log out"', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));
  89. check('the page itself stays empty (no centered sign-in)', (await ev(`document.querySelector('main').textContent.trim()`)) === '');
  90. // ---- tracker.worldapi.org#4: /shows/:slug signed OUT — the header/genres/cast show, no checks ----
  91. await page.goto(BASE + '/shows/gate-test-show');
  92. await hydrated();
  93. check('signed out: the show header renders (title, genre, cast)', (await ev(`document.querySelector('show-header h1').textContent`)) === 'Gate Test Show'
  94. && (await ev(`document.querySelector('.genre-pill').textContent`)) === 'Drama'
  95. && (await ev(`document.querySelector('.artist-link').textContent`)) === 'Test Actor');
  96. check('signed out: no check icons at all', (await ev(`document.querySelectorAll('button.checkicon').length`)) === 0);
  97. check('signed out: the latest season (2) is expanded, season 1 collapsed', (await ev(`document.querySelectorAll('episode-row').length`)) === 1);
  98. // ---- tracker.worldapi.org#5: /my/unwatched signed OUT — no rows, no checks ----
  99. await page.goto(BASE + '/my/unwatched');
  100. await hydrated();
  101. check('signed out: /my/unwatched shows no rows and asks to sign in', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 0
  102. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');
  103. // ---- tracker.worldapi.org#6: /my/schedule signed OUT — no rows, no checks ----
  104. await page.goto(BASE + '/my/schedule');
  105. await hydrated();
  106. check('signed out: /my/schedule shows no rows and asks to sign in', (await ev(`document.querySelectorAll('schedule-row').length`)) === 0
  107. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');
  108. // ---- tracker.worldapi.org#7: /my/shows signed OUT — no rows, the same sign-in message ----
  109. await page.goto(BASE + '/my/shows');
  110. await hydrated();
  111. check('signed out: /my/shows shows no rows and asks to sign in', (await ev(`document.querySelectorAll('my-show-row').length`)) === 0
  112. && (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');
  113. // ---- tracker.worldapi.org#3: no bottom border on the header, no border on filled buttons ----
  114. check('the header has no bottom border', (await ev(`getComputedStyle(document.querySelector('application-header')).borderBottomWidth`)) === '0px');
  115. check('"Log in with ident" has no border', (await ev(`getComputedStyle(document.querySelector('#loginbutton')).borderTopWidth`)) === '0px');
  116. check('the ident selector\'s "choose ident" button has no border', (await ev(`getComputedStyle(document.querySelector('#selector').shadowRoot.querySelector('[part~="button"]')).borderTopWidth`)) === '0px');
  117. // ---- tracker.worldapi.org#8: the old addresses answer a permanent redirect to the /my/ ones ----
  118. for (const [from, to] of [['/unwatched', '/my/unwatched'], ['/schedule', '/my/schedule']]) {
  119. const m = await fetch(BASE + from, { redirect: 'manual' });
  120. check(`${from} answers 301 → ${to}`, m.status === 301 && m.headers.get('location') === to, m.status + ' ' + m.headers.get('location'));
  121. }
  122. await page.goto(BASE + '/unwatched');
  123. await hydrated();
  124. check('the browser following /unwatched lands on /my/unwatched', (await ev(`location.pathname`)) === '/my/unwatched' && !!(await ev(`document.querySelector('unwatched-page')`)));
  125. // ---- tracker.worldapi.org#8: the show page says "1 episode" for one, "2 episodes" for two ----
  126. await page.goto(BASE + '/shows/gate-test-show');
  127. await hydrated();
  128. 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']),
  129. JSON.stringify(await ev(`Array.from(document.querySelectorAll('season-count')).map(e => e.textContent)`)));
  130. let r = await fetch(BASE + '/login/callback', { redirect: 'manual' });
  131. check('login: /login/callback without a code → /login/failed', r.status === 302 && r.headers.get('location') === '/login/failed');
  132. // ---- sign in: the header selector flips to signed in, "Log out" appears, page stays empty ----
  133. await loginAs(alice);
  134. 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')`)));
  135. check('signed in: "Log out" shows, no "Log in with ident"', (await ev(`!!document.querySelector('#logout')`)) && !(await ev(`!!document.querySelector('#loginbutton')`)));
  136. check('the page itself stays empty while signed in too', (await ev(`document.querySelector('main').textContent.trim()`)) === '');
  137. check('"Log out" has no border (only inverted buttons have one)', (await ev(`getComputedStyle(document.querySelector('#logout')).borderTopWidth`)) === '0px');
  138. 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');
  139. check('the header brand "tracker" has font weight 100', (await ev(`getComputedStyle(document.querySelector('application-header .brand')).fontWeight`)) === '100');
  140. // ---- tracker.worldapi.org#5: /unwatched signed IN — only already-released, unwatched episodes of
  141. // followed shows, newest release first; a click removes the row. Run BEFORE the show-page checks
  142. // below (which toggle these same fixture episodes watched) so all three still start unwatched here.
  143. await page.goto(BASE + '/my/unwatched');
  144. await hydrated();
  145. const unwatchedRowTexts = () => ev(`Array.from(document.querySelectorAll('.unwatched-show')).map(e => e.textContent)`);
  146. check('signed in: /my/unwatched lists only already-released episodes, newest release first (not the future one), titles with the year',
  147. JSON.stringify(await unwatchedRowTexts()) === JSON.stringify(['Gate Test Show (2024)', 'Gate Test Show (2024)'])
  148. && (await ev(`document.querySelectorAll('unwatched-row').length`)) === 2);
  149. check('signed in: /my/unwatched shows show title, the episode as S01E02 + title and release date, linking to the show',
  150. (await ev(`document.querySelector('unwatched-row unwatched-episode').textContent`)) === 'S01E02 · Second'
  151. && (await ev(`document.querySelector('unwatched-row unwatched-date').textContent`)) === '2020-06-15'
  152. && (await ev(`document.querySelector('unwatched-row .unwatched-show').getAttribute('href')`)) === '/shows/gate-test-show');
  153. check('signed in: /my/unwatched starts with regular (unwatched) check icons', (await ev(`document.querySelectorAll('unwatched-row button.checkicon.regular').length`)) === 2);
  154. await page.click('unwatched-row button.checkicon');
  155. await page.waitFor(`document.querySelectorAll('unwatched-row').length === 1`, { timeout: 10000, label: 'unwatched row removed' });
  156. check('click an unwatched episode\'s check: it disappears from the list', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 1
  157. && (await ev(`document.querySelector('unwatched-episode').textContent`)) === 'S01E01 · Pilot');
  158. await page.goto(BASE + '/my/unwatched');
  159. await hydrated();
  160. check('reload: the watch from /my/unwatched is server-side, not just client state', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 1
  161. && (await ev(`document.querySelector('unwatched-episode').textContent`)) === 'S01E01 · Pilot');
  162. // ---- tracker.worldapi.org#7: /my/shows signed IN — followed shows, newest follow first, small
  163. // poster, title linking to the show, the last watched episode (only ep 2 "Second" is watched so far)
  164. const myShows = () => ev(`Array.from(document.querySelectorAll('my-show-row')).map(r => ({
  165. title: r.querySelector('.myshow-title') && r.querySelector('.myshow-title').textContent,
  166. href: r.querySelector('.myshow-title') && r.querySelector('.myshow-title').getAttribute('href'),
  167. poster: r.querySelector('img.myshow-poster') && r.querySelector('img.myshow-poster').getAttribute('src'),
  168. posterLoaded: !!(r.querySelector('img.myshow-poster') && r.querySelector('img.myshow-poster').complete && r.querySelector('img.myshow-poster').naturalWidth > 0),
  169. episode: r.querySelector('my-show-episode') ? r.querySelector('my-show-episode').textContent : null }))`);
  170. const waitPostersLoaded = () => page.waitFor(`Array.from(document.querySelectorAll('img.myshow-poster')).every(i => i.complete && i.naturalWidth > 0)`, { timeout: 10000, label: 'posters loaded' });
  171. await page.goto(BASE + '/my/shows');
  172. await hydrated();
  173. await waitPostersLoaded();
  174. let ms = await myShows();
  175. 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));
  176. 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));
  177. 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));
  178. 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));
  179. check('/my/shows: the poster is small (2.5rem wide)', (await ev(`getComputedStyle(document.querySelector('img.myshow-poster')).width`)) === '40px');
  180. // ---- tracker.worldapi.org#6: /schedule signed IN — only NOT-yet-released episodes of followed
  181. // shows, soonest first, no check icons at all (read-only page, unaffected by any watch toggle above)
  182. await page.goto(BASE + '/my/schedule');
  183. await hydrated();
  184. check('signed in: /my/schedule lists only the not-yet-released episode, title with the year', (await ev(`document.querySelectorAll('schedule-row').length`)) === 1
  185. && (await ev(`document.querySelector('.schedule-show').textContent`)) === 'Gate Test Show (2024)');
  186. check('signed in: /my/schedule shows show title, the episode as S02E01 + title, release date, linking to the show, no checks',
  187. (await ev(`document.querySelector('schedule-row schedule-episode').textContent`)) === 'S02E01 · Season Two Opener'
  188. && (await ev(`document.querySelector('schedule-row schedule-date').textContent`)) === '2099-01-01'
  189. && (await ev(`document.querySelector('schedule-row .schedule-show').getAttribute('href')`)) === '/shows/gate-test-show'
  190. && (await ev(`document.querySelectorAll('schedule-row button.checkicon').length`)) === 0);
  191. // ---- tracker.worldapi.org#4: /shows/:slug signed IN — check icons, episode toggle, season toggle,
  192. // collapse/expand, and a reload proves the watch really is server-side ----
  193. const EP3 = 'ep0000000000ep0e3'; // season 2 (expanded by default), episode 1 "Season Two Opener"
  194. const EP1 = 'ep0000000000ep0e1'; // season 1 (collapsed by default), episode 1 "Pilot"
  195. const EP2 = 'ep0000000000ep0e2'; // season 1, episode 2 "Second"
  196. const SEASON1 = 'se000000000se0s1';
  197. const checkClass = (id) => ev(`document.querySelector('button.checkicon[value="${id}"]').getAttribute('class')`);
  198. const clickValue = (sel, id) => page.click(`${sel}[value="${id}"]`);
  199. await page.goto(BASE + '/shows/gate-test-show');
  200. await hydrated();
  201. check('signed in: episode check icons show, starting "regular" (not watched)', (await checkClass(EP3)) === 'checkicon regular');
  202. await clickValue('button.checkicon', EP3);
  203. await page.waitFor(`document.querySelector('button.checkicon[value="${EP3}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'ep3 solid' });
  204. check('click an episode check: it turns solid', (await checkClass(EP3)) === 'checkicon solid');
  205. check('season 1 starts collapsed (no episode rows of it yet)', (await ev(`document.querySelectorAll('episode-row').length`)) === 1);
  206. await clickValue('button.checkicon', SEASON1);
  207. await page.waitFor(`document.querySelector('button.checkicon[value="${SEASON1}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'season1 solid' });
  208. check('click a season check: the season itself turns solid', (await checkClass(SEASON1)) === 'checkicon solid');
  209. await clickValue('button.season-toggle', SEASON1);
  210. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  211. check('expanding season 1 shows its episodes already solid (the season check watched them too)',
  212. (await checkClass(EP1)) === 'checkicon solid' && (await checkClass(EP2)) === 'checkicon solid');
  213. await page.goto(BASE + '/shows/gate-test-show');
  214. await hydrated();
  215. check('reload: episode 3\'s watch is server-side, not just client state', (await checkClass(EP3)) === 'checkicon solid');
  216. await clickValue('button.season-toggle', SEASON1);
  217. await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });
  218. 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');
  219. // ---- tracker.worldapi.org#7: /my/shows again — S02E01 was watched BEFORE season 1's bulk watch, so
  220. // this proves "last" = the highest season/episode watched, not the most recently watched one
  221. await page.goto(BASE + '/my/shows');
  222. await hydrated();
  223. ms = await myShows();
  224. check('/my/shows: the last episode is the HIGHEST watched (S02E01), not the most recent watch; the other show still none',
  225. ms.length === 2 && ms[1].episode === 'S02E01' && ms[0].episode === null, JSON.stringify(ms));
  226. // ---- optional: screenshots of the signed-in pages (TRACKER_GATE_SHOTS=<dir>) so a human/architect can SEE them ----
  227. if (process.env.TRACKER_GATE_SHOTS) {
  228. mkdirSync(process.env.TRACKER_GATE_SHOTS, { recursive: true });
  229. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  230. for (const [name, path] of [['home', '/'], ['show', '/shows/gate-test-show'], ['my-shows', '/my/shows'], ['my-unwatched', '/my/unwatched'], ['my-schedule', '/my/schedule']]) {
  231. await page.goto(BASE + path);
  232. await hydrated();
  233. const shot = await page.send('Page.captureScreenshot', { format: 'png' });
  234. writeFileSync(join(process.env.TRACKER_GATE_SHOTS, name + '.png'), Buffer.from(shot.data, 'base64'));
  235. }
  236. await page.send('Emulation.clearDeviceMetricsOverride', {});
  237. }
  238. // ---- sign out: back to signed out, a reload confirms the app's own session (not just client state) ----
  239. await page.click('#logout');
  240. await page.waitForSelector('#loginbutton', { timeout: 15000 });
  241. check('sign out returns to the signed-out header', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));
  242. await page.goto(BASE + '/');
  243. await hydrated();
  244. check('reload after sign-out stays signed out', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));
  245. const problems = page.problems();
  246. check('no console errors/warnings', problems.length === 0, page.dumpConsole());
  247. }
  248. try {
  249. await main();
  250. } catch (e) {
  251. check('gate ran without throwing', false, e.stack || String(e));
  252. } finally {
  253. await stop();
  254. }
  255. console.log(`\n${passes} passed, ${failures} failed`);
  256. process.exit(failures === 0 ? 0 : 1);

Branches

Latest commits

  • 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