tracker
All repositories: gitoria
37.1 KB
// tests/franchises.mjs — THE GATE of tracker.worldapi.org#19 (mission 066): franchises, timelines, the widget, the editor —// a gate of its own beside tests/browser.mjs (same parts: its own ident from identkit.mjs, the fake TMDB, a real headless// Chrome), on its own storage (.scratch/franchise-store). Proves:// - THE SEED from fake TMDB collections: one timeline per collection with ≥ 2 of our movies, parts in release order, a part// we don't have / an adult one left out, a 1-title collection gives none, 404 and "no collection" are remembered; every// part of a known collection is checked WITHOUT its own request; ≥ 260 ms between TMDB requests; it RESUMES after a stop// (no movie asked twice, no timeline twice) and has nothing to do on the next start// - the WIDGET: "‹ Prequel | name | Sequel ›" links, the ends greyed out, a Sequel click (client-side) moves along, the// franchise above it once the timeline has one → its index// - /franchises, /franchises/<slug> (each timeline sortable: timeline order | release date, a series by its LAST episode),// /timelines/<slug>// - THE EDITOR only for the creator (TRACKER_EDITORS = alice's ident id here; az5b2 in production): signed out and bob see// "Only the creator …" and no forms; alice creates a franchise and a timeline, attaches, adds (search), moves, removes// - 390 / 1280 px: nothing scrolls sideways; screenshots with TRACKER_GATE_SHOTS//// TRACKER_GATE_SHOTS=/tmp/w066/shots TRACKER_GATE_PORT=8732 TRACKER_GATE_IDENT_PORT=8733 TRACKER_GATE_CHROME=8734-8736 \// TRACKER_GATE_TMDB_PORT=8737 node tests/franchises.mjsimport { 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 } 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 SHOTS = process.env.TRACKER_GATE_SHOTS || '';const BASE = `http://127.0.0.1:${PORT}`;const STORE = join(APP, '.scratch', 'franchise-store');const SESSIONS = join(APP, '.scratch', 'franchise-sessions');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')));rmSync(STORE, { recursive: true, force: true });rmSync(SESSIONS, { recursive: true, force: true });mkdirSync(SESSIONS, { recursive: true });if (SHOTS) mkdirSync(SHOTS, { 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++;}const sleep = (ms) => new Promise(r => setTimeout(r, ms));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; }}// what the fake TMDB says (tests/seed-franchises.hl has the movies): collection 900's parts OUT of release order, with a part// we don't have (9999) and an adult one (9105); 901 has one part of ours; 9106 has none; 9107 is unknown (404)const coll900 = { id: 900, name: 'Gate Alien Collection' };const gateMovies = () => ({1003: { id: 1003, title: 'Gate Test Movie', poster_path: null, belongs_to_collection: null },9101: { id: 9101, title: 'Gate Alien', belongs_to_collection: coll900 },9102: { id: 9102, title: 'Gate Aliens', belongs_to_collection: coll900 },9103: { id: 9103, title: 'Gate Alien 3', belongs_to_collection: coll900 },9104: { id: 9104, title: 'Gate Loner', belongs_to_collection: { id: 901, name: 'Gate Lonely Collection' } },9105: { id: 9105, title: 'Gate Adult Alien', adult: true, belongs_to_collection: coll900 },9106: { id: 9106, title: 'Gate Standalone', belongs_to_collection: null },});const gateCollections = () => ({900: { id: 900, name: 'Gate Alien Collection', parts: [{ id: 9103, title: 'Gate Alien 3', release_date: '1992-05-22', adult: false },{ id: 9999, title: 'Not Ours', release_date: '1980-01-01', adult: false },{ id: 9101, title: 'Gate Alien', release_date: '1979-05-25', adult: false },{ id: 9105, title: 'Gate Adult Alien', release_date: '1990-01-01', adult: true },{ id: 9102, title: 'Gate Aliens', release_date: '1986-07-18', adult: false }] },901: { id: 901, name: 'Gate Lonely Collection', parts: [{ id: 9104, title: 'Gate Loner', release_date: '2000-01-01' }, { id: 9998, title: 'Not Ours Either', release_date: '2003-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 bob = await ident.signIn('[email protected]');const APPKEY = await ident.registerApp(alice, 'tracker (franchise gate)', [BASE]);const aliceIdentity = await ident.exchange(APPKEY, await ident.selectorCode(alice, APPKEY, BASE));check('ident: our own ident runs, the app is registered, alice\'s ident id known (she is the "creator" here)', /^pk_[0-9a-f]{32}$/.test(APPKEY.key) && !!aliceIdentity);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()+ execFileSync(join(APP, 'bin/hybriel'), ['tests/seed-franchises.hl'], { cwd: APP, env: { ...process.env, TRACKER_STORAGE: MPACKDB } }).toString();check('seed: the fixture shows (tests/seed-show.hl) + 7 collection movies (tests/seed-franchises.hl)', seedOut.includes('gate-test-show') && seedOut.includes('seed-franchises: 7 movies'), seedOut);tmdb = await startFakeTmdb({ port: TMDB_PORT, shows: {}, movies: gateMovies(), collections: gateCollections() });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_READ_TOKEN: 'gate-token', TMDB_BASE_URL: tmdb.base + '/3', TMDB_IMAGE_URL: tmdb.base + '/t/p', TVMAZE_BASE_URL: tmdb.base + '/tvmaze',TRACKER_SYNC: '0', TRACKER_BACKFILL: '0', TRACKER_SHORTIDS: '0', TRACKER_REPAIR: '0', TRACKER_KINDS: '0', TRACKER_CREDITS: '0', TRACKER_EDITORS: aliceIdentity, TRACKER_COLLECTIONS_LOG_EVERY: '1', TRACKER_COLLECTIONS_PERSIST_EVERY: '1', ...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 sleep(250);}};const waitLog = async (re, ms, label) => {for (let t = 0; t < ms && !re.test(log); t += 100) await sleep(100);if (!re.test(log)) throw new Error('log never showed ' + label + '\n' + log.slice(-3000));};// ==== THE SEED, stopped in the middle (TMDB answers slowly), then resumed ====tmdb.delayMs = 700;await startServer();await waitLog(/collections: start, 7 movies to check/, 10000, 'the seed start');check('seed: starts by itself, 7 movies to check (Gate Test Movie + 6 of the 7 fixtures: the adult one is skipped)', true);await waitLog(/collections: [23]\/7 movies/, 20000, 'two steps');await stopServer();const firstRun = log;const firstRequests = tmdb.requests.length;tmdb.delayMs = 0;await startServer();await waitLog(/collections done: /, 20000, 'the seed done');const startLine = (log.match(/collections: start, (\d+) movies to check/) || [])[1];check('seed RESUMES after a stop: the second start has fewer movies left than 7', startLine && Number(startLine) < 7 && Number(startLine) > 0, 'second start: ' + startLine + '\nfirst run:\n' + firstRun.split('\n').filter(l => l.startsWith('collections')).join('\n'));const paths = tmdb.requests.map(r => r.path);const moviePaths = paths.filter(p => p.startsWith('/3/movie/'));const dup = moviePaths.filter((p, i) => moviePaths.indexOf(p) !== i);check('seed: no movie asked twice across the stop and the restart; never the adult one (9105) nor a part of a known collection on its own (two of 9101/9102/9103 never asked)',dup.length === 0 && !moviePaths.includes('/3/movie/9105') && ['9101', '9102', '9103'].filter(id => moviePaths.includes('/3/movie/' + id)).length === 1, JSON.stringify(paths));check('seed: each collection asked exactly once (900, 901), 7 TMDB requests in all (5 movies + 2 collections)',paths.filter(p => p === '/3/collection/900').length === 1 && paths.filter(p => p === '/3/collection/901').length === 1 && paths.length === 7, JSON.stringify(paths) + ' (first run: ' + firstRequests + ')');const second = tmdb.requests.slice(firstRequests);// a step's 1–2 requests, then 260 ms per request made: any 3 requests in a row span ≥ 500 ms (≤ 40 / 10 s)const spans = second.slice(2).map((r, i) => r.at - second[i].at);check('seed: paced like the adult backfill — any 3 TMDB requests in a row span ≥ 500 ms (≤ 40 / 10 s)', spans.length > 0 && spans.every(g => g >= 500), JSON.stringify(spans));// the counters are per start: the first run's last line + the done line add up to the whole seedconst done = log.match(/collections done: .*/)[0];const lastFirst = firstRun.split('\n').filter(l => /^collections: \d+\/7 movies/.test(l)).pop() || '';const sum = (k) => Number((lastFirst.match(new RegExp(' ' + k + '=(\\d+)')) || [0, 0])[1]) + Number((done.match(new RegExp(' ' + k + '=(\\d+)')) || [0, 0])[1]);check('seed totals (both starts): 1 timeline with 3 titles, 1 too-small collection, 2 without a collection, 1 TMDB 404, 0 retries',sum('timelines') === 1 && sum('titles') === 3 && sum('small') === 1 && sum('noCollection') === 2 && sum('notFound') === 1 && sum('retries') === 0, lastFirst + '\n' + done);await stopServer();await startServer();await waitLog(/collections: (nothing to do|start, 0 movies)/, 10000, 'nothing to do');check('seed: the next start finds every movie checked — nothing to do, no TMDB request', /collections: start, 0 movies to check/.test(log) && /collections: nothing to do/.test(log) && tmdb.requests.length === 7, log.split('\n').filter(l => l.startsWith('collections')).join('\n'));// ==== the pages, signed OUT ====browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });const page = await browser.newPage();globalThis.__gatePage = page;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 open = async (path) => { await page.goto(BASE + path); await hydrated(); };const widgets = () => ev(`JSON.stringify(Array.from(document.querySelectorAll('franchise-widget')).map(w => ({franchise: w.querySelector('a.fw-franchise') ? w.querySelector('a.fw-franchise').textContent + ' ' + w.querySelector('a.fw-franchise').getAttribute('href') : '',prev: w.querySelector('.fw-prev') ? (w.querySelector('.fw-prev').tagName === 'A' ? 'A ' + w.querySelector('.fw-prev').getAttribute('href') + ' ' + w.querySelector('.fw-prev fw-title').textContent : 'OFF ' + w.querySelector('.fw-prev').textContent.trim()) : '',name: w.querySelector('a.fw-timeline').textContent + ' ' + w.querySelector('a.fw-timeline').getAttribute('href'),next: w.querySelector('.fw-next') ? (w.querySelector('.fw-next').tagName === 'A' ? 'A ' + w.querySelector('.fw-next').getAttribute('href') + ' ' + w.querySelector('.fw-next fw-title').textContent : 'OFF ' + w.querySelector('.fw-next').textContent.trim()) : '' })))`).then(JSON.parse);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();};// tracker#32: Logout sits in the user icon's dropdownconst logout = async () => { await page.click('button.user-toggle'); await page.waitFor(`document.querySelector('#user-menu').matches(':popover-open')`, { timeout: 5000 }); await page.click('#logout'); await page.waitForSelector('#loginbutton', { timeout: 15000 }); };await open('/movies/gate-aliens');let w = await widgets();check('widget (Gate Aliens): ONE widget, no franchise yet, "‹ Prequel" → Gate Alien (1979), the timeline → /timelines/Gate-Alien-Collection, "Sequel ›" → Gate Alien 3 (1992)',w.length === 1 && w[0].franchise === '' && w[0].prev === 'A /movies/gate-alien Gate Alien (1979)' && w[0].name === 'Gate Alien Collection /timelines/Gate-Alien-Collection'&& w[0].next === 'A /movies/gate-alien-3 Gate Alien 3 (1992)', JSON.stringify(w));// mission 025: right under the title/poster block — phone: below the poster, above the plot; wide: in the right column under the titleconst widgetPos = () => ev(`JSON.stringify((() => { const r = (s) => document.querySelector(s).getBoundingClientRect(), w = r('franchise-widgets');return { inMeta: !!document.querySelector('show-meta franchise-widgets'), title: r('show-title').bottom, poster: r('show-poster').bottom, posterRight: r('show-poster').right, top: w.top, bottom: w.bottom, left: w.left, summary: r('show-summary').top }; })())`).then(JSON.parse);await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });const posPhone = await widgetPos();await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });const posWide = await widgetPos();await page.send('Emulation.clearDeviceMetricsOverride', {});check('widget on a phone (390 px): under the title and the poster, above the plot (mission 025: it was below the cast)', posPhone.inMeta && posPhone.title <= posPhone.top && posPhone.poster <= posPhone.top && posPhone.bottom <= posPhone.summary, JSON.stringify(posPhone));check('widget at 1280 px: right of the poster, under the title, above the plot', posWide.left >= posWide.posterRight && posWide.title <= posWide.top && posWide.bottom <= posWide.summary, JSON.stringify(posWide));await open('/movies/gate-alien');w = await widgets();check('widget (Gate Alien, the first): the Prequel end is greyed out (no link), Sequel → Gate Aliens', w.length === 1 && w[0].prev === 'OFF ‹ Prequel' && w[0].next === 'A /movies/gate-aliens Gate Aliens (1986)'&& (await ev(`getComputedStyle(document.querySelector('.fw-prev.off')).opacity`)) === '0.5', JSON.stringify(w));await ev(`window.__w066 = 'same document'`);await page.click('a.fw-next');await page.waitFor(`location.pathname === '/movies/gate-aliens' && document.querySelector('show-title h1 type-name') && document.querySelector('show-title h1 type-name').textContent === 'Gate Aliens'`, { timeout: 10000, label: 'sequel click' });await sleep(300);w = await widgets();check('widget: a "Sequel ›" click opens Gate Aliens client-side (no reload) and its widget follows (Prequel → Gate Alien)', (await ev('window.__w066')) === 'same document'&& w.length === 1 && w[0].prev === 'A /movies/gate-alien Gate Alien (1979)' && w[0].next === 'A /movies/gate-alien-3 Gate Alien 3 (1992)', JSON.stringify(w));await open('/movies/gate-alien-3');w = await widgets();check('widget (Gate Alien 3, the last): Sequel greyed out', w.length === 1 && w[0].next === 'OFF Sequel ›' && w[0].prev === 'A /movies/gate-aliens Gate Aliens (1986)', JSON.stringify(w));for (const [slug, why] of [['gate-loner', 'a 1-title collection'], ['gate-standalone', 'no collection'], ['gate-test-show', 'in no timeline']]) {await open('/shows/' + slug);check(`widget: none on ${slug} (${why})`, (await ev(`document.querySelectorAll('franchise-widget').length`)) === 0);}await open('/franchises');const loose = await ev(`Array.from(document.querySelectorAll('#timeline-list a.timeline-link')).map(a => a.querySelector('timeline-link-name').textContent + ' (' + a.querySelector('timeline-link-count').textContent + ') ' + a.getAttribute('href')).join('|')`);check('/franchises signed out: "No franchises yet.", the timelines without one: only Gate Alien Collection (3 titles; the 1-title one made none), no Edit link',(await ev(`document.querySelector('franchises-page p.muted').textContent`)) === 'No franchises yet.'&& loose === 'Gate Alien Collection (3 titles) /timelines/Gate-Alien-Collection' && !(await ev(`!!document.querySelector('#franchises-edit')`)), loose);await open('/timelines/Gate-Alien-Collection');const items = () => ev(`Array.from(document.querySelectorAll('a.timeline-item')).map(a => a.getAttribute('data-timeline').slice(0, 4) + ':' + a.querySelector('film-title').textContent + '@' + a.querySelector('.timeline-date').textContent).join('|')`);check('/timelines/Gate-Alien-Collection: the parts in release order (TMDB gave them mixed), without the one we lack and the adult one; no franchise above, no Edit link signed out',(await items()).replace(/^[^:]*:/, '').split('|').map(x => x.replace(/^[^:]*:/, '')).join('|') === 'Gate Alien (1979)@1979-05-25|Gate Aliens (1986)@1986-07-18|Gate Alien 3 (1992)@1992-05-22'&& !(await ev(`!!document.querySelector('#franchise-up')`)) && !(await ev(`!!document.querySelector('#timeline-edit')`)), await items());await open('/franchises/edit');check('/franchises/edit signed out: "Only the creator …", no form', (await ev(`document.querySelector('#edit-refused') ? document.querySelector('#edit-refused').textContent : ''`)) === 'Only the creator can edit franchises and timelines.'&& !(await ev(`!!document.querySelector('#fe-name')`)));await open('/franchises/nope');check('/franchises/<unknown> and /timelines/<unknown>: Not found', (await ev(`document.querySelector('h1').textContent`)) === 'Not found'&& (await (await fetch(BASE + '/timelines/nope')).text()).includes('Not found'));// ==== bob (signed in, not the creator): no editor ====await loginAs(bob);await open('/franchises/edit');const bobRefused = (await ev(`!!document.querySelector('#edit-refused')`)) && !(await ev(`!!document.querySelector('#fe-name')`));await open('/timelines/Gate-Alien-Collection/edit');check('bob (signed in, not the creator): /franchises/edit and /timelines/…/edit say "Only the creator …", no forms, no chips, no ↑↓✕',bobRefused && (await ev(`!!document.querySelector('#edit-refused')`)) && !(await ev(`!!document.querySelector('#franchise-chips') || !!document.querySelector('button.mv')`)));await open('/franchises');check('bob: no Edit link on /franchises', !(await ev(`!!document.querySelector('#franchises-edit')`)));await logout();// ==== alice = the creator: the editor ====await loginAs(alice);await open('/franchises');check('creator: /franchises has the Edit link → /franchises/edit', (await ev(`document.querySelector('#franchises-edit') ? document.querySelector('#franchises-edit').getAttribute('href') : ''`)) === '/franchises/edit');await page.click('#franchises-edit');await page.waitFor(`location.pathname === '/franchises/edit' && !!document.querySelector('#fe-name')`, { timeout: 10000, label: 'editor' });await page.type('#fe-name', 'Gate Alien Franchise');await page.type('#fe-summary', 'Facehuggers, all of them.');await page.click('#fe-create');await page.waitFor(`location.pathname === '/franchises/Gate-Alien-Franchise' && document.querySelector('h1 type-name') && document.querySelector('h1 type-name').textContent === 'Gate Alien Franchise'`, { timeout: 10000, label: 'new franchise page' });check('editor: "Create franchise" opens the new franchise\'s index (/franchises/Gate-Alien-Franchise): name, summary, "No timelines yet."',(await ev(`document.querySelector('p.franchise-summary').textContent`)) === 'Facehuggers, all of them.' && (await ev(`document.querySelector('franchise-page p.muted').textContent`)) === 'No timelines yet.');await open('/franchises/edit');await page.click('#fe-create');await page.waitFor(`!!document.querySelector('#edit-message')`, { timeout: 5000, label: 'empty name refused' });check('editor: an empty name is refused with a message', /needs a name/.test(await ev(`document.querySelector('#edit-message').textContent`)));// attach the seeded timeline to the franchiseawait open('/timelines/Gate-Alien-Collection/edit');const chips = () => ev(`Array.from(document.querySelectorAll('#franchise-chips button')).map(b => b.textContent + (b.classList.contains('on') ? '*' : '')).join('|')`);check('editor (Gate Alien Collection): the franchise chips — None (on) | Gate Alien Franchise; the 3 titles in order with ↑ ↓ ✕', (await chips()) === 'None*|Gate Alien Franchise'&& (await ev(`Array.from(document.querySelectorAll('#edit-rows edit-row film-title')).map(x => x.textContent).join('|')`)) === 'Gate Alien (1979)|Gate Aliens (1986)|Gate Alien 3 (1992)', await chips());await page.click('#franchise-chips button[value]:not(.on)');await page.waitFor(`document.querySelector('#franchise-chips button.on') && document.querySelector('#franchise-chips button.on').textContent === 'Gate Alien Franchise'`, { timeout: 5000, label: 'chip on' });await open('/timelines/Gate-Alien-Collection/edit');check('editor: a tap on the franchise chip attaches the timeline (still on after a reload)', (await chips()) === 'None|Gate Alien Franchise*', await chips());await open('/movies/gate-aliens');w = await widgets();check('widget: the franchise ABOVE the row now, linking to its index', w.length === 1 && w[0].franchise === 'Gate Alien Franchise /franchises/Gate-Alien-Franchise'&& (await ev(`document.querySelector('a.fw-franchise').getBoundingClientRect().bottom <= document.querySelector('fw-row').getBoundingClientRect().top`)), JSON.stringify(w));await page.click('a.fw-franchise');await page.waitFor(`location.pathname === '/franchises/Gate-Alien-Franchise' && document.querySelectorAll('timeline-head').length === 1`, { timeout: 10000, label: 'franchise index via the widget' });check('widget: a click on the franchise opens its index with the timeline', (await ev(`document.querySelector('a.timeline-name').textContent`)) === 'Gate Alien Collection');// a new hand-made timeline: series + movie, attached, titles added by search, moved, one removedawait open('/franchises/edit');await page.type('#te-name', 'Gate Mixed Timeline');await page.click('#te-create');await page.waitFor(`location.pathname === '/timelines/Gate-Mixed-Timeline/edit' && !!document.querySelector('#ted-search')`, { timeout: 10000, label: 'new timeline editor' });check('editor: "Create timeline" opens its editor, no titles yet', /No titles yet/.test(await ev(`document.querySelector('timeline-edit-page').textContent`)));await page.click('#franchise-chips button[value]:not(.on)');await page.waitFor(`document.querySelector('#franchise-chips button.on') && document.querySelector('#franchise-chips button.on').textContent === 'Gate Alien Franchise'`, { timeout: 5000, label: 'chip on (mixed)' });const addBySearch = async (text, title) => {await page.type('#ted-search', text, { clear: true });await page.waitFor(`Array.from(document.querySelectorAll('#ted-results edit-row')).some(r => r.querySelector('film-title').textContent === ${JSON.stringify(title)})`, { timeout: 8000, label: 'result ' + title });const before = await ev(`document.querySelectorAll('#edit-rows edit-row').length`);await ev(`Array.from(document.querySelectorAll('#ted-results edit-row')).find(r => r.querySelector('film-title').textContent === ${JSON.stringify(title)}).querySelector('button.add').click()`);await page.waitFor(`document.querySelectorAll('#edit-rows edit-row').length === ${before + 1}`, { timeout: 8000, label: 'added ' + title });};const editRows = () => ev(`Array.from(document.querySelectorAll('#edit-rows edit-row')).map(r => r.querySelector('edit-pos').textContent + '.' + r.querySelector('film-title').textContent).join('|')`);await addBySearch('pager older', 'Pager Older Series (2019)');await addBySearch('gate test', 'Gate Test Show (2024)');await addBySearch('gate test', 'Gate Test Movie (1996)');await addBySearch('alien 3', 'Gate Alien 3 (1992)');check('editor: four titles added by search, each at the end', (await editRows()) === '1.Pager Older Series (2019)|2.Gate Test Show (2024)|3.Gate Test Movie (1996)|4.Gate Alien 3 (1992)', await editRows());check('editor: a title already in the timeline is not offered again', !(await ev(`Array.from(document.querySelectorAll('#ted-results film-title')).some(t => t.textContent === 'Gate Alien 3 (1992)')`)));await ev(`document.querySelector('#edit-rows edit-row[data-show="fr0000000000f103"] button.rm').click()`);await page.waitFor(`document.querySelectorAll('#edit-rows edit-row').length === 3`, { timeout: 8000, label: 'removed' });await ev(`document.querySelectorAll('#edit-rows edit-row')[2].querySelector('button[value="up"]').click()`);await page.waitFor(`document.querySelectorAll('#edit-rows edit-row')[1].querySelector('film-title').textContent === 'Gate Test Movie (1996)'`, { timeout: 8000, label: 'moved up' });check('editor: ✕ removes, ↑ moves one place up: Pager Older Series, Gate Test Movie, Gate Test Show', (await editRows()) === '1.Pager Older Series (2019)|2.Gate Test Movie (1996)|3.Gate Test Show (2024)', await editRows());check('editor: the first row\'s ↑ and the last row\'s ↓ are greyed out (not clickable)', (await ev(`document.querySelectorAll('#edit-rows edit-row')[0].querySelector('button[value="up"]').classList.contains('off') && document.querySelectorAll('#edit-rows edit-row')[2].querySelector('button[value="down"]').classList.contains('off') && getComputedStyle(document.querySelectorAll('#edit-rows edit-row')[0].querySelector('button[value="up"]')).pointerEvents === 'none'`)));if (SHOTS) {for (const [wd, h, mobile] of [[390, 844, true], [1280, 900, false]]) {await page.send('Emulation.setDeviceMetricsOverride', { width: wd, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });await page.type('#ted-search', 'gate', { clear: true });await page.waitFor(`document.querySelectorAll('#ted-results edit-row').length > 0`, { timeout: 8000, label: 'results for the shot' });writeFileSync(join(SHOTS, `timeline-edit-${wd}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true })).data, 'base64'));}await page.send('Emulation.clearDeviceMetricsOverride', {});}await open('/timelines/Gate-Mixed-Timeline/edit');check('editor: after a reload the order and the franchise are stored', (await editRows()) === '1.Pager Older Series (2019)|2.Gate Test Movie (1996)|3.Gate Test Show (2024)' && (await chips()) === 'None|Gate Alien Franchise*', await editRows());// the franchise index: two timelines, each sortable on its ownawait open('/franchises/Gate-Alien-Franchise');const blockItems = (tid) => ev(`Array.from(document.querySelectorAll('a.timeline-item[data-timeline="' + ${tid} + '"]')).map(a => a.querySelector('film-title').textContent + '@' + a.querySelector('.timeline-date').textContent).join('|')`);const heads = JSON.parse(await ev(`JSON.stringify(Array.from(document.querySelectorAll('timeline-head')).map(h => ({ id: h.getAttribute('data-timeline'), name: h.querySelector('a.timeline-name').textContent, on: h.querySelector('button.sortbtn.on').textContent })))`));check('index: both timelines of the franchise, the older first (by first release), each sorted by "Timeline"', heads.map(h => h.name + ':' + h.on).join('|') === 'Gate Alien Collection:Timeline|Gate Mixed Timeline:Timeline', JSON.stringify(heads));const mixedId = JSON.stringify((heads.find(h => h.name === 'Gate Mixed Timeline') || {}).id);const alienId = JSON.stringify((heads.find(h => h.name === 'Gate Alien Collection') || {}).id);const mixedTimelineOrder = await blockItems(mixedId);check('index: the mixed timeline in timeline order; a series shows the date of its LAST released episode', mixedTimelineOrder === 'Pager Older Series (2019)@last episode 2021-01-01|Gate Test Movie (1996)@1996-11-18|Gate Test Show (2024)@last episode 2020-06-15', mixedTimelineOrder);await ev(`document.querySelector('timeline-head[data-timeline=' + JSON.stringify(${mixedId}) + '] button[value="release"]').click()`);await page.waitFor(`document.querySelector('timeline-head[data-timeline=' + JSON.stringify(${mixedId}) + '] button.sortbtn.on').textContent === 'Release'`, { timeout: 5000, label: 'release toggle' });const mixedRelease = await blockItems(mixedId);check('index: "Release" sorts that timeline by release date — Gate Test Show (last episode 2020-06-15, not its 2099 one nor its 2024 year) between the 1996 movie and the series ending 2021',mixedRelease === 'Gate Test Movie (1996)@1996-11-18|Gate Test Show (2024)@last episode 2020-06-15|Pager Older Series (2019)@last episode 2021-01-01', mixedRelease);check('index: the other timeline keeps its own toggle ("Timeline")', (await ev(`document.querySelector('timeline-head[data-timeline=' + JSON.stringify(${alienId}) + '] button.sortbtn.on').textContent`)) === 'Timeline');await ev(`document.querySelector('timeline-head[data-timeline=' + JSON.stringify(${mixedId}) + '] button[value="timeline"]').click()`);await page.waitFor(`document.querySelector('timeline-head[data-timeline=' + JSON.stringify(${mixedId}) + '] button.sortbtn.on').textContent === 'Timeline'`, { timeout: 5000, label: 'timeline toggle' });check('index: "Timeline" brings the in-world order back', (await blockItems(mixedId)) === mixedTimelineOrder);await open('/timelines/Gate-Mixed-Timeline');check('/timelines/Gate-Mixed-Timeline: the franchise above it (→ its index), the creator\'s Edit link', (await ev(`document.querySelector('#franchise-up').textContent + ' ' + document.querySelector('#franchise-up').getAttribute('href')`)) === 'Gate Alien Franchise /franchises/Gate-Alien-Franchise'&& (await ev(`document.querySelector('#timeline-edit').getAttribute('href')`)) === '/timelines/Gate-Mixed-Timeline/edit');await open('/movies/gate-test-movie');w = await widgets();check('widget on a movie in the mixed timeline: Prequel → the SERIES before it, Sequel → the series after it', w.length === 1 && w[0].prev === 'A /series/pager-older-series Pager Older Series (2019)'&& w[0].next === 'A /series/gate-test-show Gate Test Show (2024)' && w[0].franchise === 'Gate Alien Franchise /franchises/Gate-Alien-Franchise', JSON.stringify(w));await open('/series/gate-test-show');w = await widgets();check('widget on a series page too (last of its timeline: Sequel greyed out)', w.length === 1 && w[0].next === 'OFF Sequel ›' && w[0].prev === 'A /movies/gate-test-movie Gate Test Movie (1996)', JSON.stringify(w));// a title in TWO timelines: Gate Alien 3 into the mixed one again → two widgetsawait open('/timelines/Gate-Mixed-Timeline/edit');await addBySearch('alien 3', 'Gate Alien 3 (1992)');await open('/movies/gate-alien-3');w = await widgets();check('a title in TWO timelines has two widgets (one per timeline)', w.length === 2 && w.map(x => x.name.split(' /')[0]).sort().join('|') === 'Gate Alien Collection|Gate Mixed Timeline' && w.every(x => x.franchise.startsWith('Gate Alien Franchise')), JSON.stringify(w));// ==== mission 070 (merge with #20/#21): typed headings "Franchise | …" / "Timeline | …" in their colours; title links → /movies|/series ====const typedH1 = () => ev(`JSON.stringify((() => { const h = document.querySelector('h1.typed'); if (!h) return null; const w = h.querySelector('type-word'), n = h.querySelector('type-name');return { text: h.textContent.replace(/\\s+/g, ' ').trim(), word: w ? [w.textContent, getComputedStyle(w).color] : null, name: n ? n.textContent : null }; })())`).then(JSON.parse);const typedHeads = {};for (const p of ['/franchises', '/franchises/Gate-Alien-Franchise', '/timelines/Gate-Alien-Collection', '/timelines/Gate-Mixed-Timeline/edit']) { await open(p); typedHeads[p] = await typedH1(); }check('#20 headings on #19 pages: "Franchises" (blueLight), "Franchise | Gate Alien Franchise" (blueLight), "Timeline | Gate Alien Collection" (greenLight), the editor "Timeline | Gate Mixed Timeline"',typedHeads['/franchises'] && typedHeads['/franchises'].word[0] === 'Franchises' && typedHeads['/franchises'].word[1] === 'rgb(156, 220, 254)'&& typedHeads['/franchises/Gate-Alien-Franchise'].text === 'Franchise | Gate Alien Franchise' && typedHeads['/franchises/Gate-Alien-Franchise'].word[1] === 'rgb(156, 220, 254)'&& typedHeads['/timelines/Gate-Alien-Collection'].text === 'Timeline | Gate Alien Collection' && typedHeads['/timelines/Gate-Alien-Collection'].word[1] === 'rgb(181, 206, 168)'&& typedHeads['/timelines/Gate-Mixed-Timeline/edit'].text === 'Timeline | Gate Mixed Timeline', JSON.stringify(typedHeads));await open('/timelines/Gate-Alien-Collection');const nameCount = await ev(`document.querySelector('franchise-page').innerText.split('Gate Alien Collection').length - 1`);check('timeline page: its name once (the heading), not again over the list (mission 025); count and sort toggle still there', nameCount === 1 && (await ev(`document.querySelectorAll('a.timeline-name').length`)) === 0&& (await ev(`!!document.querySelector('timeline-head timeline-count') && document.querySelectorAll('timeline-head button.sortbtn').length === 2`)), 'name ' + nameCount + '×');await open('/timelines/Gate-Mixed-Timeline');const mixedHrefs = await ev(`Array.from(document.querySelectorAll('a.timeline-item')).map(a => a.getAttribute('href')).join(',')`);check('#21 links on #19 pages: the mixed timeline links its series to /series/<slug> and its movie to /movies/<slug>, none to /shows/',mixedHrefs.includes('/series/pager-older-series') && mixedHrefs.includes('/movies/gate-test-movie') && mixedHrefs.includes('/series/gate-test-show') && !mixedHrefs.includes('/shows/'), mixedHrefs);// ==== 390 / 1280: nothing scrolls sideways; screenshots ====const pages = [['franchises', '/franchises'], ['franchise', '/franchises/Gate-Alien-Franchise'], ['timeline', '/timelines/Gate-Alien-Collection'], ['widget-movie', '/movies/gate-alien-3'],['widget-aliens', '/movies/gate-aliens'], ['franchise-edit', '/franchises/edit'], ['timeline-edit-reload', '/timelines/Gate-Mixed-Timeline/edit']];for (const [wd, h, mobile] of [[390, 844, true], [1280, 900, false]]) {await page.send('Emulation.setDeviceMetricsOverride', { width: wd, height: h, deviceScaleFactor: mobile ? 2 : 1, mobile });const bad = [];for (const [name, path] of pages) {await open(path);if (name.startsWith('widget')) await ev(`document.querySelector('franchise-widget').scrollIntoView({ block: 'center' })`);const sw = await ev('document.documentElement.scrollWidth');if (sw > wd) bad.push(name + ' ' + sw);if (name.startsWith('widget')) {const row = JSON.parse(await ev(`JSON.stringify((() => { const r = (s) => document.querySelector(s).getBoundingClientRect(); return { p: r('.fw-prev'), t: r('a.fw-timeline'), n: r('.fw-next') }; })())`));if (!(row.p.right <= row.t.left + 1 && row.t.right <= row.n.left + 1 && Math.abs((row.p.top + row.p.bottom) / 2 - (row.n.top + row.n.bottom) / 2) < 30)) bad.push(name + ' widget not one row ' + JSON.stringify(row));}if (SHOTS) writeFileSync(join(SHOTS, `${name}-${wd}.png`), Buffer.from((await page.send('Page.captureScreenshot', { format: 'png' })).data, 'base64'));}check(`${wd} px: no franchise page scrolls sideways; the widget is ONE row (Prequel | name | Sequel side by side)`, bad.length === 0, bad.join('; '));}await page.send('Emulation.clearDeviceMetricsOverride', {});await 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)) + (globalThis.__gatePage ? '\nbrowser console:\n' + globalThis.__gatePage.dumpConsole() : '') + '\nserver log (tail):\n' + log.slice(-2500));} finally {await stop();}console.log(`\n${passes} passed, ${failures} failed`);process.exit(failures === 0 ? 0 : 1);
Branches
- mainmain branch
Latest commits
- 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
- eb3b9205tracker: report 031mre
- 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre
- 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre
- a386dc92tracker: reports 029 + 030mre
- 71e0fd7dtracker missions 029 + 030: README (What it does, Files, gate count), STATUS (real-copy numbers, how to repeat, open points), LOGmre
- d36ea6eatracker#34 + #35 (mission 030): Follow directly under the poster, as wide as the poster (show.hl, styles.hl); the status pill next to a series' title — TVmaze's status (new tvmazeStatus, stored by the sync's TVmaze merge) else TMDB's, TVmaze Ended + TMDB Canceled = Canceled, inverted (filled, dark text, no border), green running / yellow pending / red canceled / muted ended (shows.hl statusOf); the daily delta asks TVmaze's status of an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep, sync.hl syncTvmazeStatus); the status backfill after the details repair (backfill.hl, jobs.hl statusTick; resumable, 550 ms per TVmaze request); gates 354/0, 32/0, 52/0mre
- 7d7d4487tracker#33 (mission 029): reduced titles — every title TMDB's details never went through this app (no detailsAt, no tmdbSync) is incomplete (shows.hl isIncomplete; the old tracker's migrated rows passed #26's test: 5,697 non-adult on the live copy, 691 series without seasons); the repair job does the visibly reduced first (shows.hl missingParts), the page completes one on open; a title TMDB has no poster for (The Remaining) shows the placeholder; tools/count-incomplete.hl; gate fixtures stand for synced titles (tmdbSync), tests/seed-reduced.hl + #33 checks; gates 347/0, 32/0, 52/0mre
- 661c2592tracker: report 028mre
- 27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre
- d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre
- 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
- 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
- 4f47f181tracker: report 027mre
- dc1d4be4tracker mission 027: Hybriel master 06617221 vendored (plugin allocator fixes 3a781359 + 413f60e4); real copy RSS through first-start jobs + 400 loads flat ~2.55 GB (190aa11d 2.3 -> 5.6 GB), page times <= 1.1x; gates 342/0, 32/0, 52/0mre
- 84e1b3e1tracker: reports 025 + 026mre
- dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre