diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml new file mode 100644 index 0000000000..56c9758607 --- /dev/null +++ b/.github/workflows/website-checks.yml @@ -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 diff --git a/apps/website/scripts/render-check.mjs b/apps/website/scripts/render-check.mjs index 98a1442a7e..44b2fd5dbc 100644 --- a/apps/website/scripts/render-check.mjs +++ b/apps/website/scripts/render-check.mjs @@ -69,7 +69,7 @@ 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 @@ -77,15 +77,21 @@ const SETTLE_MS = 250; // per click: React commit + a frame, not a data fet // 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, '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/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '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/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')) { @@ -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. @@ -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']) @@ -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)`); }