screentinker/server/smoke-ui.js
ScreenTinker 3e3d0081fe Keep the smoke test out of npm test, and update the lockfile
Two mistakes in the previous commit, both of which broke CI.

The lockfile was not regenerated after adding puppeteer-core to
devDependencies, and `npm ci` requires the two to agree — so every job that
installs dependencies failed before running anything.

The smoke test was also placed in test/, which I described as keeping it out of
`npm test`. It does not: `node --test` globs that directory, so the runner
picked it up regardless of intent, tried to drive a browser as a unit test, and
failed. It now lives beside the server as smoke-ui.js, with a note saying why,
so the next person does not put it back.

Verified the way it should have been the first time: npm ci succeeds, native
modules still load, npm test is 807/807 with no browser involved, and
`npm run smoke` is 32/32 on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:34:34 -05:00

159 lines
7.8 KiB
JavaScript

'use strict';
// Browser smoke test. NOT part of `npm test` — it needs a real Chrome, which CI does not have,
// and it boots a server and drives it. Run it deliberately:
//
// npm run smoke (skips cleanly if no Chrome is installed)
//
// It lives OUTSIDE test/ on purpose: `node --test` globs that directory, so a file placed
// there is picked up by `npm test` no matter what the intent was — which is exactly what
// happened the first time, and it broke CI.
// CHROME=/path/to/chrome npm run smoke
//
// It exists because a whole class of defect is invisible to the unit suite, to a syntax check
// and to review, and only appears in front of a browser. Every check below is here because it
// caught something real:
//
// * a context menu whose only item read "schedule.ctx_new" — t() returns the KEY when a string
// is missing, so a missing key ships as user-facing text
// * pointer handlers stacking on every calendar render — five renders meant five PUTs on one
// drop, which no unit test would ever notice
// * the week grid forcing a horizontal scroll on a phone
// * an uncaught error blanking a view
//
// Keep it fast and keep every assertion tied to a real past failure, or it becomes noise nobody
// runs.
const { spawn, execSync } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const VIEWS = ['#/', '#/content', '#/playlists', '#/layouts', '#/widgets', '#/schedule',
'#/walls', '#/reports', '#/kiosk', '#/designer', '#/activity', '#/members', '#/help', '#/settings'];
function findChrome() {
if (process.env.CHROME && fs.existsSync(process.env.CHROME)) return process.env.CHROME;
for (const c of ['/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium',
'/snap/bin/chromium', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']) {
try { if (fs.existsSync(c)) return c; } catch { /* */ }
}
try { return execSync('which google-chrome chromium 2>/dev/null | head -1').toString().trim() || null; }
catch { return null; }
}
let puppeteer;
try { puppeteer = require('puppeteer-core'); } catch { puppeteer = null; }
const results = [];
const check = (name, ok, detail) => {
results.push({ name, ok, detail });
console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : `\n ${detail}`}`);
};
(async () => {
const chrome = findChrome();
if (!puppeteer || !chrome) {
console.log(`SKIP: ui smoke needs puppeteer-core${chrome ? '' : ' and a Chrome binary'}.`);
console.log(' npm i -D puppeteer-core # and install Chrome, or set CHROME=/path/to/chrome');
process.exit(0);
}
const DATA_DIR = path.join(os.tmpdir(), 'st-smoke-' + crypto.randomBytes(4).toString('hex'));
const PORT = 4000 + Math.floor(Math.random() * 900);
const BASE = `http://127.0.0.1:${PORT}`;
const logFile = path.join(os.tmpdir(), 'st-smoke.log');
const srv = spawn('node', ['server.js'], {
cwd: __dirname,
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'production' },
stdio: ['ignore', fs.openSync(logFile, 'w'), fs.openSync(logFile, 'a')],
});
const stop = () => { try { srv.kill('SIGKILL'); } catch { /* */ } };
process.on('exit', stop);
let up = false;
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ }
await new Promise(r => setTimeout(r, 250));
}
if (!up) { console.error('server did not start:\n' + fs.readFileSync(logFile, 'utf8').slice(-1500)); stop(); process.exit(1); }
const browser = await puppeteer.launch({ executablePath: chrome, headless: 'new',
args: ['--no-sandbox', '--disable-dev-shm-usage'] });
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => { if (m.type() === 'error' && !/favicon|404 \(Not Found\)/.test(m.text())) errors.push(m.text().slice(0, 140)); });
await page.goto(BASE + '/app#/login', { waitUntil: 'networkidle2' });
const registered = await page.evaluate(async () => {
const r = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'smoke' + Date.now() + '@x.test', password: 'Passw0rd123' }) });
const d = await r.json();
if (d.token) localStorage.setItem('token', d.token);
return !!d.token;
});
check('an account can be created', registered);
// ---- every view: renders, no raw keys, no uncaught errors ----------------------------------
const rawKeyOffenders = [];
for (const hash of VIEWS) {
const before = errors.length;
await page.goto(BASE + '/app' + hash, { waitUntil: 'networkidle2' });
await page.reload({ waitUntil: 'networkidle2' });
await new Promise(r => setTimeout(r, 900));
const info = await page.evaluate(() => {
const bare = [...document.querySelectorAll('body *')]
.filter(e => e.children.length === 0)
.map(e => (e.textContent || '').trim())
// A bare i18n key: dotted, lower-case, no spaces. Version strings (v1.2.3) are excluded.
.filter(s => /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/.test(s) && !/^v?\d/.test(s));
return { chars: (document.body.innerText || '').trim().length, bare: [...new Set(bare)] };
});
check(`renders ${hash}`, info.chars > 60, `only ${info.chars} chars of text`);
if (info.bare.length) rawKeyOffenders.push(`${hash}: ${info.bare.join(', ')}`);
check(`no uncaught error on ${hash}`, errors.length === before, errors.slice(before).join(' | '));
}
check('no untranslated keys rendered as text', rawKeyOffenders.length === 0, rawKeyOffenders.join('\n '));
// ---- the calendar binds its pointer handlers ONCE -------------------------------------------
await page.goto(BASE + '/app#/schedule', { waitUntil: 'networkidle2' });
await page.reload({ waitUntil: 'networkidle2' });
await new Promise(r => setTimeout(r, 1200));
const cdp = await page.target().createCDPSession();
const countListeners = async () => {
const { result } = await cdp.send('Runtime.evaluate', { expression: 'document.getElementById("calendar")' });
const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
return listeners.filter(l => l.type === 'pointerdown').length;
};
const first = await countListeners();
for (let i = 0; i < 3; i++) {
await page.evaluate(() => document.getElementById('nextWeek')?.click());
await new Promise(r => setTimeout(r, 500));
}
const later = await countListeners();
check('calendar handlers do not stack per render', first === 1 && later === 1,
`pointerdown listeners: ${first} then ${later} — each extra one repeats the drag's PUT`);
// ---- a phone-width viewport must not scroll sideways -----------------------------------------
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
const overflowing = [];
for (const hash of ['#/', '#/schedule', '#/content', '#/settings']) {
await page.goto(BASE + '/app' + hash, { waitUntil: 'networkidle2' });
await page.reload({ waitUntil: 'networkidle2' });
await new Promise(r => setTimeout(r, 900));
const m = await page.evaluate(() => ({ doc: document.documentElement.scrollWidth, win: window.innerWidth }));
if (m.doc > m.win + 4) overflowing.push(`${hash} (${m.doc}px in ${m.win}px)`);
}
check('no horizontal overflow at phone width', overflowing.length === 0, overflowing.join(', '));
await browser.close();
stop();
const failed = results.filter(r => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} passed`);
process.exit(failed.length ? 1 : 0);
})().catch(e => { console.error('FATAL', e && e.stack || e); process.exit(1); });