Files
RecipesCam/docker/frontend/scripts/mobile-pick-check.mjs
T

237 lines
9.5 KiB
JavaScript

// 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=<path to its index.mjs>');
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 : '',
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 CSS', read.outline === 'rgb(245, 158, 11)', read.outline);
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);