Skip to content
Open
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
5 changes: 3 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/website-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 10 additions & 6 deletions apps/website/qa/queen-viewport-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions apps/website/qa/queen-viewport-readiness.mjs
Original file line number Diff line number Diff line change
@@ -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)';
44 changes: 44 additions & 0 deletions apps/website/qa/queen-viewport-readiness.test.mjs
Original file line number Diff line number Diff line change
@@ -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')
})
Loading