// TEMP check (phase 3, delete after): on the phone, a frame swiped to in the // SINGLE preview is the frame the ALL THUMBS grid opens selected on. import { spawn } from 'node:child_process'; import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); const SAMPLE_FILE = process.env.SAMPLE_FILE ?? path.resolve(FRONTEND, '../sample/_3279791-develop-check.jpg'); const APP_PORT = Number(process.env.APP_PORT ?? 5902); const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5903); const FRAMES = Number(process.env.FRAMES ?? 12); const FOLDER = 'MobRoll'; const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(2, '0')}.jpg`); const DAY_MS = 86400000; const DAY0 = Date.UTC(2023, 0, 12, 12); const dayOfName = (name) => DAY0 + names.indexOf(name) * DAY_MS; let fails = 0; const step = (what, ok, detail = '') => { if (!ok) fails++; console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${detail ? ` — ${detail}` : ''}`); }; const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); if (!playwright) { console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE='); process.exit(0); } const jpg = await readFile(SAMPLE_FILE); const sampleServer = createServer((req, res) => { const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0])); if (!names.includes(name)) { res.writeHead(404).end(); return; } res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Content-Length': jpg.length, 'Access-Control-Allow-Origin': '*', }); res.end(jpg); }); await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve)); const app = process.env.BASE_URL ? null : spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { cwd: FRONTEND, stdio: 'ignore', detached: true, }); const stopApp = () => { if (!app?.pid) return; try { process.kill(-app.pid, 'SIGTERM'); } catch {} }; const base = process.env.BASE_URL ?? `http://127.0.0.1:${APP_PORT}`; let up = !app; for (let i = 0; i < 60 && !up; i++) { up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false); if (!up) await new Promise((r) => setTimeout(r, 500)); } if (!up) { stopApp(); sampleServer.close(); console.log(`FAIL no preview on ${base} — run \`npm run build\` first`); process.exit(1); } const browser = await playwright.chromium.launch({ executablePath: process.env.CHROME || undefined, args: ['--no-sandbox', '--enable-unsafe-swiftshader'], }); const context = await browser.newContext({ viewport: { width: 1500, height: 950 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2, }); await context.route('**/api/**', (route) => { const url = route.request().url(); const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check', verified: true, pro: true, admin: false } }); if (url.includes('/photos')) return json({ photos: [] }); if (url.includes('/place')) return json({ place: 'Hanoi' }); if (url.includes('/ratings')) return json({ ratings: [] }); if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } }); if (url.includes('/recipes')) return json({ recipes: [] }); return json({}); }); await context.addInitScript( ({ origin, names, folder, days }) => { const dayOfNameIn = (name) => days[names.indexOf(name)] ?? Date.UTC(2026, 0, 1); const fileHandle = (name) => { const handle = { kind: 'file', name, __fake: true }; Object.defineProperty(handle, 'getFile', { value: async () => new File([await (await fetch(`${origin}/${name}`)).arrayBuffer()], name, { type: 'image/jpeg', lastModified: dayOfNameIn(name), }), }); return handle; }; const dirHandle = (label, node) => { const handle = { kind: 'directory', name: label, __fake: true }; Object.defineProperties(handle, { values: { value: () => [...node.files.map(fileHandle)][Symbol.iterator]() }, queryPermission: { value: async () => 'granted' }, requestPermission: { value: async () => 'granted' }, getDirectoryHandle: { value: async () => { throw new Error('no such folder'); } }, getFileHandle: { value: async (name) => { if (!node.files.includes(name)) throw new Error(`no frame named ${name}`); return fileHandle(name); }, }, }); return handle; }; const root = () => dirHandle(folder, { files: names }); window.showDirectoryPicker = async () => root(); const revive = (row) => { if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) { row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name); } return row; }; for (const method of ['get', 'getAll']) { const original = IDBObjectStore.prototype[method]; IDBObjectStore.prototype[method] = function (...args) { const request = original.apply(this, args); const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result'); Object.defineProperty(request, 'result', { configurable: true, get() { const value = descriptor.get.call(this); return Array.isArray(value) ? value.map(revive) : revive(value); }, }); return request; }; } }, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER, days: names.map(dayOfName) } ); const page = await context.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200))); await page.goto(`${base}/library`); await page.click('[data-key="lib-add"]'); await page.waitForSelector(`[data-key="lib-node-${FOLDER}"]`, { timeout: 60_000 }); await page .waitForFunction((n) => (document.querySelector('[data-key="lib-count"]')?.textContent ?? '').includes(String(n)), FRAMES, { timeout: 240_000, polling: 250, }) .catch(() => {}); // The phone frame: the same roll, read on a narrow window. await page.setViewportSize({ width: 390, height: 844 }); await page.waitForSelector('.mob-lib-content', { timeout: 30_000 }); await page.waitForSelector('.mob-lib-thumb-cell', { timeout: 60_000 }); step('the phone shows the thumb grid', (await page.$$('.mob-lib-thumb-cell')).length === FRAMES); // SINGLE, then a frame previewed off the one-at-a-time list. await page.click('.mob-lib-tab-bar .mob-lib-tab-item:nth-child(2)'); await page.waitForSelector('.mob-lib-view-btn[title="Single"]', { timeout: 20_000 }); await page.click('.mob-lib-view-btn[title="Single"]'); await page.waitForSelector('.mob-lib-single-card', { timeout: 20_000 }); await page.click('.mob-lib-single-card'); await page.waitForSelector('.mob-lib-preview-modal', { timeout: 20_000 }); const first = (await page.$eval('.mob-lib-preview-title', (e) => e.textContent?.trim() ?? '')) || ''; // A finger across the picture: left goes the way the list is read. await page.$eval('[data-key="mob-lib-preview-body"]', (el) => { const touch = (x, y) => new Touch({ identifier: 1, target: el, clientX: x, clientY: y }); const fire = (type, x, y) => { const t = touch(x, y); el.dispatchEvent( new TouchEvent(type, { touches: type === 'touchend' ? [] : [t], changedTouches: [t], bubbles: true, cancelable: true, }) ); }; fire('touchstart', 300, 400); fire('touchend', 100, 400); }); await page.waitForFunction((was) => (document.querySelector('.mob-lib-preview-title')?.textContent ?? '') !== was, first, { timeout: 10_000, }); const swiped = (await page.$eval('.mob-lib-preview-title', (e) => e.textContent?.trim() ?? '')) || ''; // Closing it. await page.click('.mob-lib-preview-modal .mob-lib-status-btn'); await page.waitForSelector('.mob-lib-preview-modal', { state: 'detached', timeout: 10_000 }); await page.waitForSelector('.mob-lib-grid', { timeout: 10_000 }); const read = await page.evaluate(() => { const cells = [...document.querySelectorAll('.mob-lib-thumb-cell')]; const sel = cells.filter((c) => c.classList.contains('selected')); const box = document.querySelector('.mob-lib-content')?.getBoundingClientRect(); const r = sel[0]?.getBoundingClientRect(); return { cells: cells.length, selected: sel.map((c) => c.querySelector('img')?.getAttribute('alt') ?? ''), outline: sel[0] ? getComputedStyle(sel[0]).outlineColor : '', accent: (() => { const probe = document.createElement('div'); probe.style.background = 'var(--accent)'; document.body.append(probe); const c = getComputedStyle(probe).backgroundColor; probe.remove(); return c; })(), inView: !!(box && r && r.top >= box.top - 4 && r.bottom <= box.bottom + 4), gridMode: !!document.querySelector('.mob-lib-grid'), singles: document.querySelectorAll('.mob-lib-single-card').length, }; }); step('closing hands the reader to ALL THUMBS', read.gridMode && read.singles === 0); step('exactly one frame is selected', read.selected.length === 1, `${read.selected.length}`); step('the frame swiped to is the selected one', read.selected[0] === swiped, `swiped=${swiped} selected=${read.selected[0]}`); step('the selected frame is on screen', read.inView); step('the selected frame is marked in the accent', read.outline === read.accent && read.accent.startsWith('rgb('), `${read.outline} vs accent ${read.accent}`); step('no page error', errors.length === 0, errors.join(' | ')); await browser.close(); stopApp(); sampleServer.close(); console.log(fails ? `${fails} FAIL` : 'all ok'); process.exit(fails ? 1 : 0);