Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/website-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# The three checks that exist and never run.
#
# anomaly-register A48: this project does not fail by missing things. Its site
# audit, its claim-guard and its five-label framework all caught the same
# overclaims -- and every one of them failed at the step AFTER the finding. The
# audit was written, then deleted, and the numbers stayed for six months.
#
# So these run on every PR that touches the site. Each was written the day its
# failure was found and each has been run by hand since, which is another way
# of saying each has been forgotten at least once.
#
# typecheck:ratchet errors may fall, never rise (A31: `npm run build` claimed
# to typecheck, never ran in the deploy path, and 231
# errors accrued unseen -- among them seven React branches
# that were statically false, so four panels rendered
# nothing in production)
# check:api a field the UI formats that no server emits, and any
# fallback not tagged with sample() (A36/A37: novelty:0.342
# rendered as an FPGA measurement on a public route)
# check:render real Chrome over CDP, every panel on all nine canvas
# layers, fails on the first uncaught error (A39: vite
# build was green through all of the above -- it proves
# the bundler succeeded, not that the page renders)
name: Website checks

on:
pull_request:
paths: ['apps/website/**', '.github/workflows/website-checks.yml']
push:
branches: [main]
paths: ['apps/website/**']
workflow_dispatch:

jobs:
checks:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/website
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22' # render-check uses the WebSocket client built into 22
cache: npm
cache-dependency-path: apps/website/package-lock.json

- run: npm ci

# Not a gate on the count, a gate on the direction. 184 today.
- name: typecheck ratchet
run: npm run typecheck:ratchet

# Compares names only -- it cannot see units, which is why A34's five
# mappable fields stay unwired rather than relabelled as microseconds.
- name: API contract
run: npm run check:api

- name: Build
run: npx vite build

# Skips itself if no Chrome is found rather than failing: a missing
# browser is an environment fact, not a defect in the change.
- uses: browser-actions/setup-chrome@v1
id: chrome
- name: Render check
env:
CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }}
run: npm run check:render -- --no-build
92 changes: 75 additions & 17 deletions apps/website/scripts/render-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,23 +69,29 @@ import { tmpdir } from 'node:os';

const ROOT = new URL('..', import.meta.url).pathname;
const DIST = join(ROOT, 'dist');
const ROUTE = '#/canvas';
const ROUTE = "#/canvas";
const HEADED = process.argv.includes('--head');
const MAX_PANELS = 80; // a click that never expands anything must not loop forever
const SETTLE_MS = 250; // per click: React commit + a frame, not a data fetch

// Chrome, wherever this Mac keeps it. No download, no bundled browser: if the
// developer has no Chrome, that is a fact to state, not to fix with 100 MB.
const CHROMES = [
// First, so it can override rather than only fill a gap -- CI sets it, and
// "set CHROME_PATH to force it" has to mean force.
process.env.CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
process.env.CHROME_PATH,
'/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium',
].filter(Boolean);
const CHROME = CHROMES.find(p => existsSync(p));
if (!CHROME) {
console.error(' no Chrome found. Set CHROME_PATH, or install Chrome — this check drives the one you already have.');
process.exit(1);
// Skip, do not fail. A missing browser is a fact about the environment, not
// a defect in the change being made -- and a check that fails for a reason
// it already knows about gets muted within a week.
console.log(' no Chrome found — skipping the render check. Set CHROME_PATH to force it.');
process.exit(0);
}

if (!process.argv.includes('--no-build')) {
Expand Down Expand Up @@ -129,6 +135,12 @@ const chrome = spawn(CHROME, [
'--no-first-run', '--no-default-browser-check', '--disable-extensions',
'--disable-background-networking', '--disable-sync', '--mute-audio',
'--window-size=1440,900',
// CI runs Chrome as root in a container with no user namespaces, where the
// sandbox cannot start and Chrome dies before it prints a debugging port --
// "Chrome exited (null) before listening", which reads like a broken check
// rather than a missing kernel feature. Only on Linux: dropping the sandbox
// on a developer's own machine is a real cost for no benefit.
...(process.platform === 'linux' ? ['--no-sandbox', '--disable-dev-shm-usage'] : []),
// three.js is on this page. Without a software GL fallback every WebGL
// context fails to create and the check reports the headless environment as
// a rendering bug.
Expand Down Expand Up @@ -252,6 +264,22 @@ const LAYERS = 9;
// mount, so every digit after that gets TYPED INTO THE BOX instead of reaching
// the window handler. The symptom is indistinguishable from nine empty layers,
// which is exactly what the first run of this loop reported.
// Escape returns to 'petals' (TrinityCanvas line 684). Going home before each
// layer makes every switch a single hop from a known state.
//
// Walking 1..9 in sequence chains them, and one missed press shifts everything
// after it: a run reported the tools panels on layer 8 instead of 7, and the
// next run found nothing at all. A check whose results depend on nine
// consecutive successes is a check that reports the transition lock, not the
// page.
const escape = async () => {
await evaluate('document.activeElement && document.activeElement.blur(), 1');
for (const type of ['keyDown', 'keyUp'])
await call('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape',
windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 });
await wait(700);
};

const key = async (text) => {
await evaluate('document.activeElement && document.activeElement.blur(), 1');
for (const type of ['keyDown', 'keyUp'])
Expand Down Expand Up @@ -290,28 +318,58 @@ for (let layer = 1; layer <= LAYERS; layer++) {
// panels on one run and 0 on the next. A timing-flaky check is muted within
// a week, so this presses, watches the DOM for a change, and presses again
// if nothing moved -- and says so if it never does.
await escape();
const before = await evaluate('document.body.innerText.length');
await key(String(layer));
let changed = false;
for (let attempt = 0; attempt < 4 && !changed; attempt++) {
for (let t = 0; t < 14 && !changed; t++) {
await wait(150);
changed = (await evaluate('document.body.innerText.length')) !== before;
}
// ONE retry, and only after the transition lock has had time to clear.
//
// The first version pressed up to four times. Layer 1 is 'petals', which is
// where /canvas already starts, so the press is a no-op and it pressed
// again three more times -- each call re-entering switchLayer, which holds a
// `transitioning` flag across a 200ms fade and returns early while it is
// set. Hammering it left the flag stuck and every LATER layer silently
// refused to switch: nine empty layers, reported twice in a row, from a
// page that was fine.
//
// No change is also what "already on this layer" looks like, and that is not
// a failure. Say so and move on rather than retrying into the lock.
if (!changed) {
await wait(700);
await key(String(layer));
for (let t = 0; t < 12 && !changed; t++) {
await wait(150);
changed = (await evaluate('document.body.innerText.length')) !== before;
}
await wait(900);
changed = (await evaluate('document.body.innerText.length')) !== before;
if (!changed) console.log(` layer ${layer}: no DOM change (already there, or the switch is broken)`);
}
if (!changed) console.log(` layer ${layer}: no DOM change after 4 presses`);
await wait(600); // the first data tick of whatever mounted
const arriving = drain();
if (arriving.length) report(`on switching to layer ${layer}`, arriving);

// Probe, and if a layer yields nothing, do the whole hop again before
// believing it. Zero panels is the answer for most layers and a symptom on
// the one that has them, and the check cannot tell those apart from a single
// attempt -- runs alternated between 4 panels and 0 on an unchanged build.
const countBefore = expanded.length;
for (let i = 0; i < MAX_PANELS; i++) {
const label = await evaluate(PROBE);
if (label === null) break;
expanded.push(`${layer}:${label}`);
await wait(SETTLE_MS);
const found = drain();
if (found.length) report(`after expanding "${label}" on layer ${layer}`, found);
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt) {
await escape();
await key(String(layer));
await wait(1400);
drain();
}
for (let i = 0; i < MAX_PANELS; i++) {
const label = await evaluate(PROBE);
if (label === null) break;
expanded.push(`${layer}:${label}`);
await wait(SETTLE_MS);
const found = drain();
if (found.length) report(`after expanding "${label}" on layer ${layer}`, found);
}
if (expanded.length > countBefore) break;
}
console.log(` layer ${layer}: ${expanded.length - countBefore} panel(s)`);
}
Expand Down
Loading