Add AGENTS.md and move the verification tools into the repo
The checks that were being run by hand lived in /tmp and would have been lost, and the colour-contrast pass was ad-hoc enough that it missed --dim2 entirely. - tools/verify.js: headless smoke test. Boots the page against a stubbed DOM/canvas and checks all 9 tabs render, all 10 maps draw with no non-finite geometry and no squashed labels, dog-tag sub-checkboxes cover every tag in every list, no photo appears in two steps, every theme has a CSS block, and no personal data shipped. - tools/theme-contrast.py: WCAG check across all themes, now also covering --on-acc on --acc. Running the stricter check found real problems: --dim2 (small captions) was 2.3-3.0:1 in four themes and --acc-d was 2.7-2.8:1 in two. Lightened those. Worst theme is now 3.2:1, all on-accent pairs are 4.8:1 or better. - AGENTS.md documents the architecture, build stages, assertions, data provenance, deployment and the traps already hit, so a future session does not rediscover them the hard way.
This commit is contained in:
parent
a63606b5c8
commit
fbb5f02f49
6 changed files with 530 additions and 17 deletions
217
tools/verify.js
Normal file
217
tools/verify.js
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify.js — smoke-test the built page without a browser.
|
||||
*
|
||||
* node tools/verify.js
|
||||
*
|
||||
* Loads index.html, runs its <script> against a stubbed DOM/canvas, and checks that
|
||||
* every tab renders, all the maps draw, the state plumbing works and no personal
|
||||
* data shipped. Exits non-zero on failure, so it is safe to run after a build.
|
||||
*
|
||||
* Why a stub instead of a real browser: browser screenshot tooling in this project's
|
||||
* agent harness crashes the GUI process, and this is faster anyway. It cannot check
|
||||
* layout or styling — only that the logic runs and produces sensible output.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const FILE = path.join(ROOT, 'index.html');
|
||||
|
||||
let failures = 0;
|
||||
const ok = (label, pass, detail) => {
|
||||
if (!pass) failures++;
|
||||
console.log(` ${pass ? 'ok ' : 'FAIL'} ${label}${detail ? ' ' + detail : ''}`);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- load the page
|
||||
const html = fs.readFileSync(FILE, 'utf8');
|
||||
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]);
|
||||
if (!scripts.length) {
|
||||
console.error('no <script> block found in index.html');
|
||||
process.exit(1);
|
||||
}
|
||||
const js = scripts[scripts.length - 1];
|
||||
|
||||
// ---------------------------------------------------------------- DOM stub
|
||||
const renders = {};
|
||||
const draw = { fillRect: 0, fillText: 0, setTransform: 0, badRect: 0, colours: new Set() };
|
||||
const ctx = {
|
||||
_font: '',
|
||||
set fillStyle(v) { draw.colours.add(v); },
|
||||
get fillStyle() { return '#000'; },
|
||||
set font(v) { this._font = v; },
|
||||
get font() { return this._font; },
|
||||
set textBaseline(v) {},
|
||||
setTransform() { draw.setTransform++; },
|
||||
clearRect() {},
|
||||
fillRect(x, y, w, h) {
|
||||
draw.fillRect++;
|
||||
if (![x, y, w, h].every(Number.isFinite)) draw.badRect++;
|
||||
},
|
||||
measureText(t) { return { width: t.length * (parseFloat(this._font) || 11) * 0.55 }; },
|
||||
fillText(t, x, y, maxW) {
|
||||
draw.fillText++;
|
||||
if (maxW !== undefined && t.length * (parseFloat(this._font) || 11) * 0.55 > maxW + 0.5) {
|
||||
draw.condensed = (draw.condensed || 0) + 1; // canvas squashes to honour maxWidth
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const els = {};
|
||||
function makeEl(id) {
|
||||
const classes = new Set();
|
||||
return {
|
||||
id, style: {}, dataset: {}, textContent: '', checked: true, hidden: false,
|
||||
clientWidth: 1200, clientHeight: 800, width: 0, height: 0, offsetWidth: 1920, offsetHeight: 1080,
|
||||
scrollTop: 0, scrollLeft: 0,
|
||||
set innerHTML(v) { renders[id] = v; },
|
||||
get innerHTML() { return renders[id] || ''; },
|
||||
addEventListener() {}, appendChild() {}, replaceWith() {}, remove() {}, focus() {},
|
||||
setSelectionRange() {}, closest() { return null; },
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: 1920, height: 1080 }; },
|
||||
querySelectorAll() { return []; }, querySelector() { return null; },
|
||||
classList: {
|
||||
add: (c) => classes.add(c), remove: (c) => classes.delete(c),
|
||||
contains: (c) => classes.has(c),
|
||||
toggle: (c, f) => { const on = f === undefined ? !classes.has(c) : f; on ? classes.add(c) : classes.delete(c); return on; },
|
||||
},
|
||||
_classes: classes,
|
||||
getContext() { return ctx; },
|
||||
};
|
||||
}
|
||||
const store = {};
|
||||
const document = {
|
||||
getElementById: (id) => els[id] || (els[id] = makeEl(id)),
|
||||
querySelectorAll: () => [], querySelector: () => null,
|
||||
createElement: () => makeEl('tmp'),
|
||||
addEventListener() {},
|
||||
body: makeEl('body'),
|
||||
documentElement: {
|
||||
_a: {},
|
||||
setAttribute(k, v) { this._a[k] = v; },
|
||||
removeAttribute(k) { delete this._a[k]; },
|
||||
getAttribute(k) { return this._a[k] ?? null; },
|
||||
},
|
||||
};
|
||||
|
||||
function context(hostname, protocol, proxy) {
|
||||
let src = js;
|
||||
if (proxy !== undefined) src = src.replace(/const STEAM_PROXY = '[^']*';/, `const STEAM_PROXY = '${proxy}';`);
|
||||
const sb = {
|
||||
console, document, Date, JSON, Math, Object, Array, String, Number, isFinite, Set, Map,
|
||||
window: { devicePixelRatio: 1, scrollTo() {}, addEventListener() {} },
|
||||
location: { hostname, protocol, hash: '' },
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
removeItem: (k) => { delete store[k]; },
|
||||
},
|
||||
setTimeout() {}, clearTimeout() {}, requestAnimationFrame: (f) => f(),
|
||||
fetch: () => Promise.reject(new Error('stub: no network in verify')),
|
||||
Blob: function () {}, URL: { createObjectURL: () => '' }, FileReader: function () {},
|
||||
confirm: () => false,
|
||||
};
|
||||
sb.globalThis = sb;
|
||||
vm.createContext(sb);
|
||||
vm.runInContext(src, sb, { filename: 'index.html<script>' });
|
||||
return sb;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- run the checks
|
||||
console.log('verifying', path.relative(process.cwd(), FILE), '\n');
|
||||
|
||||
let sb;
|
||||
try {
|
||||
sb = context('127.0.0.1', 'http:');
|
||||
} catch (e) {
|
||||
console.error('boot() threw:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('boot');
|
||||
ok('boot() completed without throwing', true);
|
||||
|
||||
console.log('\nrendering');
|
||||
const TABS = ['roadmap', 'ach', 'tags', 'maps', 'steam', 'boss', 'miss', 'vr', 'src'];
|
||||
for (const t of TABS) {
|
||||
vm.runInContext(`go("${t}")`, sb);
|
||||
const n = (renders['tab-' + t] || '').length;
|
||||
ok(`tab ${t.padEnd(10)}`, n > 200, `${n} bytes`);
|
||||
}
|
||||
|
||||
console.log('\ncontent');
|
||||
vm.runInContext('go("roadmap")', sb);
|
||||
const roadmap = renders['tab-roadmap'] || '';
|
||||
const count = (hay, re) => (hay.match(re) || []).length;
|
||||
const stats = {
|
||||
achievements: vm.runInContext('ACHIEVEMENTS.length', sb),
|
||||
dogtags: vm.runInContext('DOGTAGS.flatMap(l=>l.tags).length', sb),
|
||||
lists: vm.runInContext('DOGTAGS.length', sb),
|
||||
maps: vm.runInContext('MAPS.length', sb),
|
||||
roadmapSteps: vm.runInContext('ROADMAP.flatMap(p=>p.steps).length', sb),
|
||||
themes: vm.runInContext('THEMES.length', sb),
|
||||
photos: count(roadmap, /class="lb"/g),
|
||||
stepPhotos: count(roadmap, /<div class="shots">/g),
|
||||
subBoxes: count(roadmap, /data-sub="/g),
|
||||
subBlocks: count(roadmap, /class="subs"/g),
|
||||
colourSpans: count(roadmap, /class="k-/g),
|
||||
};
|
||||
for (const [k, v] of Object.entries(stats)) ok(`${k.padEnd(14)}`, v > 0, String(v));
|
||||
|
||||
// every data-sub value must be one the change handler understands
|
||||
const kinds = vm.runInContext(
|
||||
`(()=>{const s=new Set();ROADMAP.forEach(p=>p.steps.forEach(st=>{for(const m of subsHTML(st).matchAll(/data-sub="([^"]*)"/g))s.add(m[1]);}));return [...s];})()`, sb);
|
||||
ok('data-sub values are handler keys', kinds.every((k) => ['ach', 'tag', 'item'].includes(k)), JSON.stringify(kinds));
|
||||
|
||||
// dog-tag sub-checkboxes must cover every tag in every list, exactly once
|
||||
console.log('\ndog-tag coverage (sub-checkboxes vs the lists)');
|
||||
const expect = { 'Very Easy': [24, 43], Easy: [26, 44], Normal: [33, 49], Hard: [35, 52], Extreme: [34, 54] };
|
||||
for (const [diff, [tank, plant]] of Object.entries(expect)) {
|
||||
const n = vm.runInContext(
|
||||
`(()=>{S.roadDiff=${JSON.stringify(diff)};let n=0;ROADMAP.forEach(p=>p.steps.forEach(s=>{n+=(subsHTML(s).match(/data-tagbox=/g)||[]).length;}));return n;})()`, sb);
|
||||
ok(`${diff.padEnd(10)} tags`, n === tank + plant, `${n}/${tank + plant}`);
|
||||
}
|
||||
|
||||
// no photo may appear in two steps
|
||||
vm.runInContext('S.roadDiff="Very Easy"', sb);
|
||||
const dupPhotos = vm.runInContext(
|
||||
`(()=>{const seen=new Set(),dupes=[];ROADMAP.forEach(p=>p.steps.forEach(s=>(s.photos||[]).forEach(x=>{if(seen.has(x.u))dupes.push(x.u);else seen.add(x.u);})));return dupes;})()`, sb);
|
||||
ok('no photo appears twice', dupPhotos.length === 0, dupPhotos.length ? dupPhotos.slice(0, 2).join(', ') : '');
|
||||
|
||||
console.log('\nmaps');
|
||||
let drawn = 0;
|
||||
const N = vm.runInContext('MAPS.length', sb);
|
||||
for (let i = 0; i < N; i++) {
|
||||
try { vm.runInContext(`mapIdx=${i}; mapZoom=1.5; drawMap();`, sb); drawn++; }
|
||||
catch (e) { ok(`map ${i}`, false, e.message); }
|
||||
}
|
||||
ok(`all ${N} maps draw`, drawn === N, `${draw.fillRect} fillRect, ${draw.fillText} fillText`);
|
||||
ok('no non-finite geometry', draw.badRect === 0, String(draw.badRect));
|
||||
ok('no squashed labels', !draw.condensed, draw.condensed ? `${draw.condensed} condensed` : '');
|
||||
|
||||
console.log('\nthemes');
|
||||
const themeNames = vm.runInContext('THEMES.map(t=>t[0])', sb);
|
||||
ok('themes defined', themeNames.length > 0, themeNames.join(', '));
|
||||
const css = (html.match(/<style>([\s\S]*?)<\/style>/) || [, ''])[1];
|
||||
const themeBlocks = [...css.matchAll(/html\[data-theme="([^"]+)"\]/g)].map((m) => m[1]);
|
||||
ok('every listed theme has a CSS block', themeNames.filter((t) => t !== 'codec').every((t) => themeBlocks.includes(t)),
|
||||
`${themeBlocks.length} blocks`);
|
||||
ok('no light themes', !themeNames.some((t) => /light/i.test(t)));
|
||||
|
||||
console.log('\nprivacy / sync');
|
||||
ok('no Steam handle shipped', vm.runInContext('DEFAULT_PROFILE', sb) === '', JSON.stringify(vm.runInContext('DEFAULT_PROFILE', sb)));
|
||||
const endpoints = {
|
||||
local: vm.runInContext('syncEndpoint()', context('127.0.0.1', 'http:')),
|
||||
pages: vm.runInContext('syncEndpoint()', context('rain.pages.melonbread.xyz', 'https:')),
|
||||
disk: vm.runInContext('syncEndpoint()', context('', 'file:')),
|
||||
};
|
||||
ok('local uses same-origin api', endpoints.local === '/api/steam', endpoints.local);
|
||||
ok('hosted falls back to helper', String(endpoints.pages).startsWith('http://127.0.0.1:8765'), endpoints.pages);
|
||||
ok('file:// uses helper', String(endpoints.disk).startsWith('http://127.0.0.1:8765'), endpoints.disk);
|
||||
const worker = fs.readFileSync(path.join(ROOT, 'steam-proxy-worker.js'), 'utf8');
|
||||
ok('worker origin is pinned', !/ALLOW_ORIGIN\s*=\s*'\*'/.test(worker),
|
||||
(worker.match(/const ALLOW_ORIGIN = '([^']*)'/) || [, '?'])[1]);
|
||||
|
||||
console.log('\n' + (failures ? `${failures} CHECK(S) FAILED` : 'all checks passed'));
|
||||
process.exit(failures ? 1 : 0);
|
||||
Loading…
Add table
Add a link
Reference in a new issue