diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e29d3f7012..cee5cc86d2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -26,9 +26,10 @@ updates: - "npm" open-pull-requests-limit: 5 - # npm - website + # npm - website (apps/website; the old /website path died in the apps/ + # reorganization and Dependabot failed every run on the missing file) - package-ecosystem: "npm" - directory: "/website" + directory: "/apps/website" schedule: interval: "weekly" commit-message: diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index 0c08b7b1fc..6ddbf232b9 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -708,7 +708,7 @@ jobs: - name: Queen viewport matrix env: CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} - run: npm run check:queen-viewport -- --no-build + run: npm run test:queen-viewport-readiness && npm run check:queen-viewport -- --no-build # Real touch events through CDP on the built board: a tap selects a unit, # a drag pans, two fingers zoom. It exits 2 (NOT MEASURED) when the page diff --git a/apps/website/package.json b/apps/website/package.json index 100b3aa939..c1ce8f81ae 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -140,6 +140,7 @@ "check:boot-shell": "node --experimental-strip-types qa/boot-shell-contract.mjs", "og-image": "node scripts/og-image.mjs", "test:viewport-spec": "node --test scripts/viewport-from-spec.test.mjs", + "test:queen-viewport-readiness": "node --test qa/queen-viewport-readiness.test.mjs", "test:card-chat": "node --experimental-strip-types --test scripts/queen-card-chat.test.mjs", "check:explorer-viewport": "node qa/explorer-viewport-contract.mjs", "check:passport": "node --experimental-strip-types qa/passport-figures.mjs" diff --git a/apps/website/qa/queen-viewport-contract.mjs b/apps/website/qa/queen-viewport-contract.mjs index bbe7c4c3a6..e90d23c274 100644 --- a/apps/website/qa/queen-viewport-contract.mjs +++ b/apps/website/qa/queen-viewport-contract.mjs @@ -22,6 +22,7 @@ import { createServer } from 'node:http'; import { readFileSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join, extname, normalize } from 'node:path'; import { tmpdir } from 'node:os'; +import { readinessDataExpr, sectorsAssertedAt } from './queen-viewport-readiness.mjs'; const ROOT = new URL('..', import.meta.url).pathname; const DIST = join(ROOT, 'dist'); @@ -94,7 +95,7 @@ if (TRI_BUTTONS.length < 1) { process.exit(1); } const RAIL_COMMANDS = RAIL_VIEW_COUNT - (RAIL_VIEWS.includes('tri') ? 1 : 0) + TRI_BUTTONS.length; -const DATA_WAIT_MS = 60000; // under the gate chain's load the sectors rows render late (the "sectors=0" readiness flake, cycles 015 and 035): a minute, like the windows gate +const DATA_WAIT_MS = 60000; // under the gate chain's load the sectors rows render late (the "sectors=0" readiness flake, cycles 015 and 035): a minute, like the windows gate -- and since 2026-10-24 readiness holds out for the rows themselves at the sizes that assert them, not the pill alone const SETTLE_MS = 700; const CHROMES = [ @@ -613,9 +614,12 @@ for (const [w, h] of (DEAD ? SIZES.filter(([w]) => w === 1440 || w === 390) : SI const t = setTimeout(r, 20000); listeners.push(m => { if (m.sessionId === sessionId && m.method === 'Page.loadEventFired') { clearTimeout(t); r(); } }); }); - // Wait for the round slot and, when the backend answers, the live pill; the - // countdown reads "—" until both endpoints have answered (and stays "—" - // with the scheduler off), so the pill is the data signal, not the clock. + // Wait for the round slot and the data signal; the countdown reads "—" until + // both endpoints have answered (and stays "—" with the scheduler off), so the + // pill is the usual data signal, not the clock. At the sizes whose probe + // asserts six sector rows, the rows are the signal: the pill answered before + // the rows rendered on the loaded gate chain and wars counted zero + // (qa/queen-viewport-readiness.mjs holds both ends to one predicate). const start = Date.now(); let ready = false; while (Date.now() - start < DATA_WAIT_MS) { @@ -625,7 +629,7 @@ for (const [w, h] of (DEAD ? SIZES.filter(([w]) => w === 1440 || w === 390) : SI const live = !!document.querySelector('#stat-status.is-live'); const dead = !!document.querySelector('#stat-status.is-cold'); const rows = document.querySelectorAll('.queen27-sectors-row').length; - const data = ${DEAD} ? dead : (live || rows === 6); + const data = ${readinessDataExpr(w, phone, DEAD)}; const rail = document.querySelector('.queen27-hud-command'); const declared = rail ? Number(rail.getAttribute('data-views')) : 0; // the rail declares its own count (data-views from commandItems); the gate follows it rather than a hardcoded eight @@ -680,7 +684,7 @@ for (const [w, h] of (DEAD ? SIZES.filter(([w]) => w === 1440 || w === 390) : SI // qa/agents-spec-contract.mjs holds HUD_VIEWS itself to the modules. if (counts.commands !== RAIL_COMMANDS) zero.push(`commands=${counts.commands} (the rail draws ${RAIL_COMMANDS}: ${RAIL_VIEW_COUNT} doors, tri as ${TRI_BUTTONS.length} screens)`); if (counts.resources < 7) zero.push(`resources=${counts.resources}`); - if (!DEAD && !phone && w > 1100 && counts.sectors !== 6) zero.push(`sectors=${counts.sectors}`); + if (sectorsAssertedAt(w, phone, DEAD) && counts.sectors !== 6) zero.push(`sectors=${counts.sectors}`); if (DEAD && counts.sectors !== 0) fail.push(`sectors rendered without a board: ${counts.sectors}`); if (DEAD && result.live) fail.push('status pill reads LIVE with a dead API'); if (DEAD) for (const z of result.zeros) fail.push('BARE ZERO ' + z); diff --git a/apps/website/qa/queen-viewport-readiness.mjs b/apps/website/qa/queen-viewport-readiness.mjs new file mode 100644 index 0000000000..bcd2f5f815 --- /dev/null +++ b/apps/website/qa/queen-viewport-readiness.mjs @@ -0,0 +1,20 @@ +// The one sentence both ends of qa/queen-viewport-contract.mjs must agree on: +// at which sizes the probe asserts six sector rows. +// +// The probe asserts the rows only above 1100 px of width on a non-phone +// viewport -- the context column only claims that room there -- and never in +// the dead-API build, where zero rows is the expected shape. The readiness +// wait before the first click used to accept the live pill alone at every +// size, so on a loaded gate chain the pill answered before the rows rendered +// and the wars probe counted zero (main, 2026-10-24: 1440x900 wars FAIL +// COUNT sectors=0, with kanban and widgets passing seconds later on the same +// page). Both ends now read this predicate, so the wait cannot drift from +// the assertion it precedes. +export const sectorsAssertedAt = (w, phone, dead) => !dead && !phone && w > 1100; + +// What "the data has answered" means in the readiness probe, interpolated +// into the page: in dead-api mode the cold pill; at the sizes that assert the +// rows, the rows themselves; everywhere else the live pill or the rows, as +// before. +export const readinessDataExpr = (w, phone, dead) => + dead ? 'dead' : sectorsAssertedAt(w, phone, dead) ? 'rows === 6' : '(live || rows === 6)'; diff --git a/apps/website/qa/queen-viewport-readiness.test.mjs b/apps/website/qa/queen-viewport-readiness.test.mjs new file mode 100644 index 0000000000..687bc1f77c --- /dev/null +++ b/apps/website/qa/queen-viewport-readiness.test.mjs @@ -0,0 +1,44 @@ +// The predicate qa/queen-viewport-contract.mjs reads at both ends: which +// sizes assert six sector rows, and what its readiness wait must hold out +// for there (qa/queen-viewport-readiness.mjs). +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readinessDataExpr, sectorsAssertedAt } from './queen-viewport-readiness.mjs' + +const QA = dirname(fileURLToPath(import.meta.url)) + +test('desktop sizes assert the six rows; the phone and the dead build do not', () => { + for (const [w, h] of [[1920, 1080], [1440, 900], [1272, 806], [1280, 700], [1280, 600]]) + assert.equal(sectorsAssertedAt(w, false, false), true, `${w}x${h} must assert the rows`) + for (const [w, h] of [[390, 844], [390, 640], [390, 560]]) + assert.equal(sectorsAssertedAt(w, true, false), false, `${w}x${h} must not assert the rows`) + // the dead-API build expects zero rows, never six + assert.equal(sectorsAssertedAt(1440, false, true), false) +}) + +test('readiness holds out for the rows exactly where the probe asserts them', () => { + assert.equal(readinessDataExpr(1440, false, false), 'rows === 6') + assert.equal(readinessDataExpr(1920, false, false), 'rows === 6') + // below the assertion threshold the live pill alone still counts + assert.equal(readinessDataExpr(390, true, false), '(live || rows === 6)') + // and the dead build reads its own pill + assert.equal(readinessDataExpr(1440, false, true), 'dead') +}) + +test('the contract interpolates the predicate at both ends', () => { + const text = readFileSync(join(QA, 'queen-viewport-contract.mjs'), 'utf8') + assert.match(text, /\$\{readinessDataExpr\(w, phone, DEAD\)\}/, + 'the readiness wait must read the shared predicate') + assert.match(text, /sectorsAssertedAt\(w, phone, DEAD\) && counts\.sectors !== 6/, + 'the sectors assertion must read the shared predicate') + // the old inline forms must be gone, or the two ends can drift apart again + // (the drift is the bug: main, 2026-10-24, 1440x900 wars FAIL COUNT sectors=0 + // -- readiness accepted the pill while the probe was waiting for the rows) + assert.ok(!text.includes('live || rows === 6'), + 'the readiness escape hatch must come from the module, not stay inline') + assert.ok(!text.includes('!DEAD && !phone && w > 1100'), + 'the assertion threshold must come from the module, not stay inline') +})