tracker
All repositories: gitoria
33.4 KB
// tests/browser.mjs — THE GATE of tracker.worldapi.org#1: this app's own server on its own storage, its OWN ident// (a copy of ident's code without .env, codes to a mail sink: tests/identkit.mjs, same as calendar's gate), a real// headless Chrome. Proves: signed out (header shows "Log in with ident") -> sign in -> the header selector shows// signed in, "Log out" appears, the page itself stays empty -> sign out -> signed out again (a reload confirms it).//// node tests/browser.mjs (TRACKER_GATE_PORT server, default 8700; TRACKER_GATE_IDENT_PORT ident, default 8701;// TRACKER_GATE_CHROME "8702-8709" = Chrome debug ports; TRACKER_GATE_TMDB_PORT the fake// TMDB, default 8710)import { spawn, execFileSync } from 'node:child_process';import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';import { dirname, join, resolve } from 'node:path';import { fileURLToPath } from 'node:url';import { launchBrowser } from './cdp.mjs';import { startIdent } from './identkit.mjs';import { startFakeTmdb, PNG } from './faketmdb.mjs';if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';const HERE = dirname(fileURLToPath(import.meta.url));const APP = resolve(HERE, '..');const PORT = Number(process.env.TRACKER_GATE_PORT || 8700);const [CH_FROM, CH_TO] = (process.env.TRACKER_GATE_CHROME || '8702-8709').split('-').map(Number);const IDENT_PORT = Number(process.env.TRACKER_GATE_IDENT_PORT || 8701);const TMDB_PORT = Number(process.env.TRACKER_GATE_TMDB_PORT || 8710);const BASE = `http://127.0.0.1:${PORT}`;const STORE = join(APP, '.scratch', 'gate-store');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')));const SCRATCH = join(APP, '.scratch');const SESSIONS = join(SCRATCH, 'gate-sessions');rmSync(SESSIONS, { recursive: true, force: true });rmSync(STORE, { recursive: true, force: true });mkdirSync(SESSIONS, { recursive: true });let failures = 0, passes = 0;function check(label, ok, detail = '') {console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);if (ok) passes++; else failures++;}let ident = null, server = null, browser = null, log = '', tmdb = null;async function stopServer() {if (server) {const s = server;try { s.kill('SIGTERM'); } catch {}await new Promise(r => { if (s.exitCode !== null || s.signalCode !== null) return r(); s.once('exit', r); setTimeout(r, 3000); });server = null;}}async function stop() {if (ident) { try { await ident.stop(); } catch {} ident = null; }if (browser) { try { await browser.close(); } catch {} browser = null; }await stopServer();if (tmdb) { try { await tmdb.stop(); } catch {} tmdb = null; }}// tracker.worldapi.org#9: what the fake TMDB (tests/faketmdb.mjs) knows about the two fixture shows (tmdbId 1001,// 1002, tests/seed-show.hl). Show 1: season 0 (must NOT be synced — the show has no season 0), S1 with the two// fixture episodes (no tmdbId locally → matched by number; E2 renamed "Second Episode") + a new E3 (released),// S2 unchanged, a NEW S3 (E1 released, E2 upcoming 2098-06-01). Show 2 (no seasons yet): a NEW S1 E1 (upcoming// 2097-01-01). Both have a poster.const gateTmdbShows = () => ({1001: { id: 1001, name: 'Gate Test Show', overview: 'TMDB overview', status: 'Returning Series', poster_path: '/gate1.png', seasons: [{ 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' }] },{ id: 91, season_number: 1, name: 'Season 1', air_date: '2020-01-01', episodes: [{ id: 5001, episode_number: 1, name: 'Pilot', air_date: '2020-01-01' },{ id: 5002, episode_number: 2, name: 'Second Episode', air_date: '2020-06-15' },{ id: 5003, episode_number: 3, name: 'Third', air_date: '2020-09-01' }] },{ 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' }] },{ id: 93, season_number: 3, name: 'Season 3', air_date: '2021-05-05', episodes: [{ id: 5005, episode_number: 1, name: 'Third Season Start', air_date: '2021-05-05' },{ id: 5006, episode_number: 2, name: 'Coming Soon', air_date: '2098-06-01' }] }] },1002: { id: 1002, name: 'Unwatched Gate Show', overview: 'second', status: 'Returning Series', poster_path: '/gate2.png', seasons: [{ 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' }] }] },});async function main() {ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });const alice = await ident.signIn('[email protected]');const APPKEY = await ident.registerApp(alice, 'tracker (gate)', [BASE]);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')));// tracker.worldapi.org#5: learn alice's per-app identity (a throwaway code, consumed here) so the// fixture below can seed a follow for the SAME user the real login flow will later resolve to// (users.hl `ensureUser` matches an existing users.db row by `identity`, reusing its id) — this is// the only way to give /unwatched a known followed show before the browser ever signs in.const preCode = await ident.selectorCode(alice, APPKEY, BASE);const aliceIdentity = await ident.exchange(APPKEY, preCode);// tracker.worldapi.org#4/#5: a tiny fixture show (tests/seed-show.hl) — written BEFORE the server// starts, self-assigned ids only (never update(), see that file's header)const MPACKDB = join(STORE, 'mpackdb');mkdirSync(MPACKDB, { recursive: true });const seedOut = execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-show.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB, TRACKER_SEED_IDENTITY: aliceIdentity } }).toString();check('seed: the fixture show was written, alice follows it', seedOut.includes('gate-test-show') && seedOut.includes('follows it'), seedOut);// tracker.worldapi.org#8: the fixture show's poster FILE exists (a 1×1 png), the second show has none →// /my/shows must give the first its own /posters/sh1.png and the second the one placeholder URL /posters/nonemkdirSync(join(MPACKDB, 'posters'), { recursive: true });writeFileSync(join(MPACKDB, 'posters', 'sh1.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64'));// tracker.worldapi.org#9: the fake TMDB. EVERY run of the app/tool in this gate points at it with the gate's own// token — the runtime loads the project's real .env (TMDB_READ_TOKEN) by itself, the environment given here winstmdb = await startFakeTmdb({ port: TMDB_PORT, shows: gateTmdbShows() });const TMDB_ENV = { TMDB_READ_TOKEN: 'gate-token', TMDB_BASE_URL: tmdb.base + '/3', TMDB_IMAGE_URL: tmdb.base + '/t/p' };const startServer = async (extra = {}) => {log = '';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',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'] });server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);for (let i = 0; ; i++) {try { if ((await fetch(BASE + '/')).ok) break; } catch {}if (i > 80) throw new Error('tracker did not come up\n' + log);await new Promise(r => setTimeout(r, 250));}};await startServer();check('tracker is up', true);browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });const page = await browser.newPage();page.captureNetwork();const ev = (e) => page.evaluate(e);const hydrated = () => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'hydrated' });const loginAs = async (who) => {const code = await ident.selectorCode(who, APPKEY, BASE);await page.goto(BASE + '/login/callback?ident_code=' + code);await page.waitForSelector('#logout');await hydrated();};// ---- signed out: header shows the selector + "Log in with ident", the page itself is empty ----await page.goto(BASE + '/');await hydrated();check('signed out: the identity selector is in the header', await ev(`!!document.querySelector('#selector')`));check('signed out: "Log in with ident" shows, no "Log out"', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));check('the page itself stays empty (no centered sign-in)', (await ev(`document.querySelector('main').textContent.trim()`)) === '');// ---- tracker.worldapi.org#4: /shows/:slug signed OUT — the header/genres/cast show, no checks ----await page.goto(BASE + '/shows/gate-test-show');await hydrated();check('signed out: the show header renders (title, genre, cast)', (await ev(`document.querySelector('show-header h1').textContent`)) === 'Gate Test Show'&& (await ev(`document.querySelector('.genre-pill').textContent`)) === 'Drama'&& (await ev(`document.querySelector('.artist-link').textContent`)) === 'Test Actor');check('signed out: no check icons at all', (await ev(`document.querySelectorAll('button.checkicon').length`)) === 0);check('signed out: the latest season (2) is expanded, season 1 collapsed', (await ev(`document.querySelectorAll('episode-row').length`)) === 1);// ---- tracker.worldapi.org#5: /my/unwatched signed OUT — no rows, no checks ----await page.goto(BASE + '/my/unwatched');await hydrated();check('signed out: /my/unwatched shows no rows and asks to sign in', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 0&& (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');// ---- tracker.worldapi.org#6: /my/schedule signed OUT — no rows, no checks ----await page.goto(BASE + '/my/schedule');await hydrated();check('signed out: /my/schedule shows no rows and asks to sign in', (await ev(`document.querySelectorAll('schedule-row').length`)) === 0&& (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');// ---- tracker.worldapi.org#7: /my/shows signed OUT — no rows, the same sign-in message ----await page.goto(BASE + '/my/shows');await hydrated();check('signed out: /my/shows shows no rows and asks to sign in', (await ev(`document.querySelectorAll('my-show-row').length`)) === 0&& (await ev(`document.querySelector('.muted').textContent`)) === 'Sign in to see the shows you follow.');// ---- tracker.worldapi.org#3: no bottom border on the header, no border on filled buttons ----check('the header has no bottom border', (await ev(`getComputedStyle(document.querySelector('application-header')).borderBottomWidth`)) === '0px');check('"Log in with ident" has no border', (await ev(`getComputedStyle(document.querySelector('#loginbutton')).borderTopWidth`)) === '0px');check('the ident selector\'s "choose ident" button has no border', (await ev(`getComputedStyle(document.querySelector('#selector').shadowRoot.querySelector('[part~="button"]')).borderTopWidth`)) === '0px');// ---- tracker.worldapi.org#8: the old addresses answer a permanent redirect to the /my/ ones ----for (const [from, to] of [['/unwatched', '/my/unwatched'], ['/schedule', '/my/schedule']]) {const m = await fetch(BASE + from, { redirect: 'manual' });check(`${from} answers 301 → ${to}`, m.status === 301 && m.headers.get('location') === to, m.status + ' ' + m.headers.get('location'));}await page.goto(BASE + '/unwatched');await hydrated();check('the browser following /unwatched lands on /my/unwatched', (await ev(`location.pathname`)) === '/my/unwatched' && !!(await ev(`document.querySelector('unwatched-page')`)));// ---- tracker.worldapi.org#8: the show page says "1 episode" for one, "2 episodes" for two ----await page.goto(BASE + '/shows/gate-test-show');await hydrated();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']),JSON.stringify(await ev(`Array.from(document.querySelectorAll('season-count')).map(e => e.textContent)`)));let r = await fetch(BASE + '/login/callback', { redirect: 'manual' });check('login: /login/callback without a code → /login/failed', r.status === 302 && r.headers.get('location') === '/login/failed');// ---- sign in: the header selector flips to signed in, "Log out" appears, page stays empty ----await loginAs(alice);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')`)));check('signed in: "Log out" shows, no "Log in with ident"', (await ev(`!!document.querySelector('#logout')`)) && !(await ev(`!!document.querySelector('#loginbutton')`)));check('the page itself stays empty while signed in too', (await ev(`document.querySelector('main').textContent.trim()`)) === '');check('"Log out" has no border (only inverted buttons have one)', (await ev(`getComputedStyle(document.querySelector('#logout')).borderTopWidth`)) === '0px');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');check('the header brand "tracker" has font weight 100', (await ev(`getComputedStyle(document.querySelector('application-header .brand')).fontWeight`)) === '100');// ---- tracker.worldapi.org#5: /unwatched signed IN — only already-released, unwatched episodes of// followed shows, newest release first; a click removes the row. Run BEFORE the show-page checks// below (which toggle these same fixture episodes watched) so all three still start unwatched here.await page.goto(BASE + '/my/unwatched');await hydrated();const unwatchedRowTexts = () => ev(`Array.from(document.querySelectorAll('.unwatched-show')).map(e => e.textContent)`);check('signed in: /my/unwatched lists only already-released episodes, newest release first (not the future one), titles with the year',JSON.stringify(await unwatchedRowTexts()) === JSON.stringify(['Gate Test Show (2024)', 'Gate Test Show (2024)'])&& (await ev(`document.querySelectorAll('unwatched-row').length`)) === 2);check('signed in: /my/unwatched shows show title, the episode as S01E02 + title and release date, linking to the show',(await ev(`document.querySelector('unwatched-row unwatched-episode').textContent`)) === 'S01E02 · Second'&& (await ev(`document.querySelector('unwatched-row unwatched-date').textContent`)) === '2020-06-15'&& (await ev(`document.querySelector('unwatched-row .unwatched-show').getAttribute('href')`)) === '/shows/gate-test-show');check('signed in: /my/unwatched starts with regular (unwatched) check icons', (await ev(`document.querySelectorAll('unwatched-row button.checkicon.regular').length`)) === 2);await page.click('unwatched-row button.checkicon');await page.waitFor(`document.querySelectorAll('unwatched-row').length === 1`, { timeout: 10000, label: 'unwatched row removed' });check('click an unwatched episode\'s check: it disappears from the list', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 1&& (await ev(`document.querySelector('unwatched-episode').textContent`)) === 'S01E01 · Pilot');await page.goto(BASE + '/my/unwatched');await hydrated();check('reload: the watch from /my/unwatched is server-side, not just client state', (await ev(`document.querySelectorAll('unwatched-row').length`)) === 1&& (await ev(`document.querySelector('unwatched-episode').textContent`)) === 'S01E01 · Pilot');// ---- tracker.worldapi.org#7: /my/shows signed IN — followed shows, newest follow first, small// poster, title linking to the show, the last watched episode (only ep 2 "Second" is watched so far)const myShows = () => ev(`Array.from(document.querySelectorAll('my-show-row')).map(r => ({title: r.querySelector('.myshow-title') && r.querySelector('.myshow-title').textContent,href: r.querySelector('.myshow-title') && r.querySelector('.myshow-title').getAttribute('href'),poster: r.querySelector('img.myshow-poster') && r.querySelector('img.myshow-poster').getAttribute('src'),posterLoaded: !!(r.querySelector('img.myshow-poster') && r.querySelector('img.myshow-poster').complete && r.querySelector('img.myshow-poster').naturalWidth > 0),episode: r.querySelector('my-show-episode') ? r.querySelector('my-show-episode').textContent : null }))`);const waitPostersLoaded = () => page.waitFor(`Array.from(document.querySelectorAll('img.myshow-poster')).every(i => i.complete && i.naturalWidth > 0)`, { timeout: 10000, label: 'posters loaded' });await page.goto(BASE + '/my/shows');await hydrated();await waitPostersLoaded();let ms = await myShows();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));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));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));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));check('/my/shows: the poster is small (2.5rem wide)', (await ev(`getComputedStyle(document.querySelector('img.myshow-poster')).width`)) === '40px');// ---- tracker.worldapi.org#6: /schedule signed IN — only NOT-yet-released episodes of followed// shows, soonest first, no check icons at all (read-only page, unaffected by any watch toggle above)await page.goto(BASE + '/my/schedule');await hydrated();check('signed in: /my/schedule lists only the not-yet-released episode, title with the year', (await ev(`document.querySelectorAll('schedule-row').length`)) === 1&& (await ev(`document.querySelector('.schedule-show').textContent`)) === 'Gate Test Show (2024)');check('signed in: /my/schedule shows show title, the episode as S02E01 + title, release date, linking to the show, no checks',(await ev(`document.querySelector('schedule-row schedule-episode').textContent`)) === 'S02E01 · Season Two Opener'&& (await ev(`document.querySelector('schedule-row schedule-date').textContent`)) === '2099-01-01'&& (await ev(`document.querySelector('schedule-row .schedule-show').getAttribute('href')`)) === '/shows/gate-test-show'&& (await ev(`document.querySelectorAll('schedule-row button.checkicon').length`)) === 0);// ---- tracker.worldapi.org#4: /shows/:slug signed IN — check icons, episode toggle, season toggle,// collapse/expand, and a reload proves the watch really is server-side ----const EP3 = 'ep0000000000ep0e3'; // season 2 (expanded by default), episode 1 "Season Two Opener"const EP1 = 'ep0000000000ep0e1'; // season 1 (collapsed by default), episode 1 "Pilot"const EP2 = 'ep0000000000ep0e2'; // season 1, episode 2 "Second"const SEASON1 = 'se000000000se0s1';const checkClass = (id) => ev(`document.querySelector('button.checkicon[value="${id}"]').getAttribute('class')`);const clickValue = (sel, id) => page.click(`${sel}[value="${id}"]`);await page.goto(BASE + '/shows/gate-test-show');await hydrated();check('signed in: episode check icons show, starting "regular" (not watched)', (await checkClass(EP3)) === 'checkicon regular');await clickValue('button.checkicon', EP3);await page.waitFor(`document.querySelector('button.checkicon[value="${EP3}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'ep3 solid' });check('click an episode check: it turns solid', (await checkClass(EP3)) === 'checkicon solid');check('season 1 starts collapsed (no episode rows of it yet)', (await ev(`document.querySelectorAll('episode-row').length`)) === 1);await clickValue('button.checkicon', SEASON1);await page.waitFor(`document.querySelector('button.checkicon[value="${SEASON1}"]').getAttribute('class') === 'checkicon solid'`, { timeout: 10000, label: 'season1 solid' });check('click a season check: the season itself turns solid', (await checkClass(SEASON1)) === 'checkicon solid');await clickValue('button.season-toggle', SEASON1);await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });check('expanding season 1 shows its episodes already solid (the season check watched them too)',(await checkClass(EP1)) === 'checkicon solid' && (await checkClass(EP2)) === 'checkicon solid');await page.goto(BASE + '/shows/gate-test-show');await hydrated();check('reload: episode 3\'s watch is server-side, not just client state', (await checkClass(EP3)) === 'checkicon solid');await clickValue('button.season-toggle', SEASON1);await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });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');// ---- tracker.worldapi.org#7: /my/shows again — S02E01 was watched BEFORE season 1's bulk watch, so// this proves "last" = the highest season/episode watched, not the most recently watched oneawait page.goto(BASE + '/my/shows');await hydrated();ms = await myShows();check('/my/shows: the last episode is the HIGHEST watched (S02E01), not the most recent watch; the other show still none',ms.length === 2 && ms[1].episode === 'S02E01' && ms[0].episode === null, JSON.stringify(ms));// ---- optional: screenshots of the signed-in pages (TRACKER_GATE_SHOTS=<dir>) so a human/architect can SEE them ----if (process.env.TRACKER_GATE_SHOTS) {mkdirSync(process.env.TRACKER_GATE_SHOTS, { recursive: true });await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });for (const [name, path] of [['home', '/'], ['show', '/shows/gate-test-show'], ['my-shows', '/my/shows'], ['my-unwatched', '/my/unwatched'], ['my-schedule', '/my/schedule']]) {await page.goto(BASE + path);await hydrated();const shot = await page.send('Page.captureScreenshot', { format: 'png' });writeFileSync(join(process.env.TRACKER_GATE_SHOTS, name + '.png'), Buffer.from(shot.data, 'base64'));}await page.send('Emulation.clearDeviceMetricsOverride', {});}// ==== tracker.worldapi.org#9: THE TMDB SYNC, against the fake TMDB — first the tool (app stopped: hl:mpackdb is// one process per storage), twice (idempotent), then the pages; then the app's own daily run ====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);await page.goto('about:blank'); // leave the app first: an open page would log websocket reconnect errorsawait stopServer();// spawned, not execFileSync: the fake TMDB lives in THIS node process and must keep answering meanwhileconst runTool = () => new Promise((ok) => {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'] });let o = '';p.stdout.on('data', d => o += d); p.stderr.on('data', d => o += d);p.on('exit', () => ok(o));});let out = await runTool();let done = (out.match(/tmdb sync done: .*/) || [''])[0];check('sync tool: 2 followed shows → 2 new seasons (show 1 S3, show 2 S1), 4 new episodes, the 3 fixture episodes updated (tmdbId by S/E number, E2 renamed), 2 posters, 0 errors',done.includes('shows=2 newSeasons=2 newEpisodes=4 updatedEpisodes=3') && done.includes('posters=2') && done.includes('errors=0'), out);check('sync tool: every TMDB call carried the Bearer token, season 0 was never asked for, one request per show for details + one for its seasons',tmdb.requests.filter(q => q.path.startsWith('/3/')).every(q => q.authorization === 'Bearer gate-token') && !tmdb.requests.some(q => q.path.includes('season/0'))&& JSON.stringify(tmdb.requests.map(q => q.path)) === JSON.stringify(['/3/tv/1001', '/3/tv/1001?append_to_response=season/1,season/2,season/3', '/t/p/w342/gate1.png', '/3/tv/1002', '/3/tv/1002?append_to_response=season/1', '/t/p/w342/gate2.png']),JSON.stringify(tmdb.requests));out = await runTool();done = (out.match(/tmdb sync done: .*/) || [''])[0];check('sync tool again: idempotent — nothing new, nothing updated, no poster downloaded again', done.includes('newSeasons=0 newEpisodes=0 updatedEpisodes=0 updatedSeasons=0 posters=0') && done.includes('errors=0'), out);await startServer();await page.goto(BASE + '/my/schedule');await hydrated();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)`);let rows = await scheduleRows();check('after the sync: /my/schedule lists the new upcoming episodes of both shows, soonest first', JSON.stringify(rows) === JSON.stringify(['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));await page.goto(BASE + '/my/unwatched');await hydrated();rows = await ev(`Array.from(document.querySelectorAll('unwatched-row')).map(r => r.querySelector('unwatched-episode').textContent + ' | ' + r.querySelector('unwatched-date').textContent)`);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));await page.goto(BASE + '/my/shows');await hydrated();await waitPostersLoaded();ms = await myShows();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/sh2.png' && ms[1].poster === '/posters/sh1.png', JSON.stringify(ms));r = await fetch(BASE + '/posters/sh2.png');const posterBytes = Buffer.from(await r.arrayBuffer());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')));await page.goto(BASE + '/shows/gate-test-show');await hydrated();const seasonCounts = await ev(`Array.from(document.querySelectorAll('season-row')).map(e => e.querySelector('season-label').textContent + ': ' + e.querySelector('season-count').textContent)`);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));check('after the sync: the latest season shows its new episodes, the poster is the downloaded one',JSON.stringify(await ev(`Array.from(document.querySelectorAll('episode-row episode-title')).map(e => e.textContent)`)) === JSON.stringify(['Third Season Start', 'Coming Soon'])&& (await ev(`document.querySelector('show-poster img').getAttribute('src')`)) === '/posters/sh1.png' && (await ev(`document.querySelector('show-poster img').naturalWidth`)) > 0);await clickValue('button.season-toggle', SEASON1);await page.waitForSelector(`button.checkicon[value="${EP1}"]`, { timeout: 10000 });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'))`);check('after the sync: S1 keeps its episodes (same ids, still watched), E2 renamed from TMDB, E3 new and unwatched',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));if (process.env.TRACKER_GATE_SHOTS) {await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });for (const [name, path] of [['synced-show', '/shows/gate-test-show'], ['synced-my-shows', '/my/shows'], ['synced-my-unwatched', '/my/unwatched'], ['synced-my-schedule', '/my/schedule']]) {await page.goto(BASE + path);await hydrated();if (name === 'synced-my-shows') await waitPostersLoaded();const shot = await page.send('Page.captureScreenshot', { format: 'png' });writeFileSync(join(process.env.TRACKER_GATE_SHOTS, name + '.png'), Buffer.from(shot.data, 'base64'));}await page.send('Emulation.clearDeviceMetricsOverride', {});}// ---- THE DAILY RUN INSIDE THE APP: TRACKER_SYNC_HOUR = this very UTC hour, so the app's own clock starts the// run right after boot. TMDB now has one more episode and answers slowly (700 ms per request): the run takes// seconds, and pages asked for meanwhile must still be answered between two shows, never after the whole run.await page.goto('about:blank');await stopServer();tmdb.shows[1001].seasons[3].episodes.push({ id: 5007, episode_number: 3, name: 'Added Live', air_date: '2096-03-03' });tmdb.delayMs = 700;const before = tmdb.requests.length;await startServer({ TRACKER_SYNC: '1', TRACKER_SYNC_HOUR: String(new Date().getUTCHours()) });const lat = [];const t0 = Date.now();while (!log.includes('tmdb sync done') && Date.now() - t0 < 30000) {const a = Date.now();await fetch(BASE + '/');lat.push({ at: a - t0, ms: Date.now() - a, running: log.includes('tmdb sync: start') && !log.includes('tmdb sync done') });await new Promise(r => setTimeout(r, 150));}const runDone = (log.match(/tmdb sync done: .*/) || [''])[0];check('in-app daily run: started by the app\'s own clock at the configured hour, found the new episode, 0 errors',log.includes('tmdb sync: daily at ' + new Date().getUTCHours() + ':00 UTC') && log.includes('tmdb sync: start, 2 followed shows') && runDone.includes('newSeasons=0 newEpisodes=1 updatedEpisodes=0') && runDone.includes('errors=0'), log);const during = lat.filter(l => l.running);const runSecs = Number((runDone.match(/seconds=([0-9.]+)/) || [0, 0])[1]);console.log(` (in-app run ${runSecs} s; ${during.length} pages asked during it, slowest ${Math.max(0, ...during.map(l => l.ms))} ms)`);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',runSecs >= 3 && during.length >= 3 && during.every(l => l.ms < 2500), 'run ' + runSecs + ' s, during: ' + JSON.stringify(during));check('in-app daily run: the Bearer token on every call, 4 requests (no poster again: unchanged)', tmdb.requests.slice(before).filter(q => q.path.startsWith('/3/')).every(q => q.authorization === 'Bearer gate-token') && tmdb.requests.length - before === 4, JSON.stringify(tmdb.requests.slice(before)));await page.goto(BASE + '/my/schedule');await hydrated();rows = await scheduleRows();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));// ---- sign out: back to signed out, a reload confirms the app's own session (not just client state) ----await page.click('#logout');await page.waitForSelector('#loginbutton', { timeout: 15000 });check('sign out returns to the signed-out header', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));await page.goto(BASE + '/');await hydrated();check('reload after sign-out stays signed out', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));const problems = page.problems();check('no console errors/warnings', problems.length === 0, page.dumpConsole());}try {await main();} catch (e) {check('gate ran without throwing', false, e.stack || String(e));} finally {await stop();}console.log(`\n${passes} passed, ${failures} failed`);process.exit(failures === 0 ? 0 : 1);
Branches
- mainmain branch
Latest commits
- 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