← back to Dw Domain Fleet
scripts/assert-grids.js
179 lines
#!/usr/bin/env node
/**
* assert-grids.js — prove every catalog-serving fleet site actually RENDERS products.
*
* WHY THIS EXISTS (TK-11463). For two days the 8 catalog-serving sites served a
* 200 with a completely empty product grid: shared/catalog.js's display_variant
* junk rule went from rejecting 2,166/15,000 products to rejecting 100% of them
* (a silent tag-meaning drift), so CLEAN === 0 and every grid was empty by
* construction. Nothing caught it:
* - deploy-fleet.sh's smoke test asserts only "https://<d>/ answers 200". A
* zero-product store answers 200. It reported "ok" through the whole outage.
* - dw-uptime-probe doesn't enumerate ANY of these 8 domains, and its
* blank-grid assertion keys off Shopify /products.json, which these Express
* sites don't serve; its fallback is body.length < 1000 and they serve ~21KB.
* That is the false-green class from CLAUDE.md: a check reported success for
* something it did not measure. This script measures the thing that matters —
* rendered product cards — and refuses to report PASS on an input it could not
* read.
*
* THREE STATES, NEVER TWO (CLAUDE.md amendment 1). Absence of bad news is not
* good news, so every site lands in exactly one of:
* PASS cards >= MIN_CARDS AND health.serving > 0 (measured good)
* FAIL cards === 0, or health.serving === 0 (measured bad)
* NOT_MEASURED health/catalog unreachable, non-2xx, unparseable, or missing
* the `serving` field (never green)
* `cards 0 of pool 0` and `cards 0 of pool 11242` are different failures and are
* reported differently; a site we could not reach is never silently a pass.
*
* SCOPE is derived from sites/*.json (monetize !== true && adsense !== true), not
* a hand-maintained list, so a newly added catalog site is covered the day it
* ships and cannot be forgotten.
*
* Usage:
* node scripts/assert-grids.js # probe live https://<domain>
* node scripts/assert-grids.js --json # machine-readable (canary/rollup)
* node scripts/assert-grids.js --min 12 # raise the card floor
* node scripts/assert-grids.js --test --base http://127.0.0.1:PORT
*
* --base is the TESTABILITY SEAM and is INERT without --test (CLAUDE.md
* amendment 3: ship the seam, then guard it so a scheduled job can never
* silently measure a fixture and become the next false green). deploy-fleet.sh
* must never pass --test.
*
* Exit 0 only when every site is PASS. Any FAIL or NOT_MEASURED exits non-zero.
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const argv = process.argv.slice(2);
const has = (f) => argv.includes(f);
const val = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
const AS_JSON = has('--json');
const TEST_MODE = has('--test');
const MIN_CARDS = Number(val('--min', 1));
const TIMEOUT = Number(val('--timeout', 20000));
// Seam is inert unless --test is explicitly passed.
const BASE = TEST_MODE ? val('--base', '') : '';
function fetchText(url) {
return new Promise((resolve) => {
const lib = url.startsWith('https:') ? https : http;
const req = lib.get(url, {
headers: { 'User-Agent': 'dw-domain-fleet/assert-grids' },
timeout: TIMEOUT,
}, (res) => {
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () => resolve({ status: res.statusCode, body }));
});
// A transport error is NOT a 200 and must not be swallowed into a pass.
req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '', err: 'timeout' }); });
req.on('error', (e) => resolve({ status: 0, body: '', err: e.message }));
});
}
/** Server-rendered cards come from shared/render.js card(): `<div class="card" data-handle=...`. */
function countCards(html) {
return (html.match(/<div class="card"/g) || []).length;
}
function catalogSites() {
const dir = path.join(__dirname, '..', 'sites');
return fs.readdirSync(dir)
.filter((f) => f.endsWith('.json'))
.map((f) => {
const cfg = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
return { slug: f.replace(/\.json$/, ''), domain: cfg.domain, monetize: cfg.monetize === true || cfg.adsense === true };
})
.filter((s) => !s.monetize && s.domain)
.sort((a, b) => a.slug.localeCompare(b.slug));
}
async function probe(site) {
const origin = BASE || ('https://' + site.domain);
const r = { site: site.slug, domain: site.domain, origin, cards: null, serving: null, state: null, why: '' };
const health = await fetchText(origin + '/health');
if (health.status !== 200) {
r.state = 'NOT_MEASURED';
r.why = `/health ${health.err ? health.err : 'HTTP ' + health.status} — pool size unread, cannot claim healthy`;
return r;
}
let hj;
try { hj = JSON.parse(health.body); }
catch { r.state = 'NOT_MEASURED'; r.why = '/health returned unparseable body — pool size unread'; return r; }
if (typeof hj.serving !== 'number') {
// Distinguish "no such field" from "field says zero" — the whole point of amendment 1.
r.state = 'NOT_MEASURED';
r.why = '/health has no numeric `serving` field (contract changed?) — pool size unread';
return r;
}
r.serving = hj.serving;
const cat = await fetchText(origin + '/catalog');
if (cat.status !== 200) {
r.state = 'NOT_MEASURED';
r.why = `/catalog ${cat.err ? cat.err : 'HTTP ' + cat.status} — rendered grid unread`;
return r;
}
r.cards = countCards(cat.body);
if (r.serving === 0) {
r.state = 'FAIL';
r.why = `pool is EMPTY (serving 0) — every grid on this site is empty by construction`;
return r;
}
if (r.cards === 0) {
r.state = 'FAIL';
r.why = `/catalog rendered 0 cards of a ${r.serving}-product pool — grid is broken downstream of the pool`;
return r;
}
if (r.cards < MIN_CARDS) {
r.state = 'FAIL';
r.why = `/catalog rendered ${r.cards} cards of a ${r.serving}-product pool, below floor ${MIN_CARDS}`;
return r;
}
r.state = 'PASS';
r.why = `${r.cards} cards rendered of a ${r.serving}-product pool`;
return r;
}
(async () => {
const sites = catalogSites();
if (!sites.length) {
// Zero sites enumerated is itself an unmeasured input, not a clean run.
const out = { verdict: 'WARN', status: 'WARN', reason: 'no catalog-serving sites enumerated from sites/*.json', sites: [] };
console.log(AS_JSON ? JSON.stringify(out, null, 2) : 'NOT_MEASURED: no catalog-serving sites enumerated');
process.exit(2);
}
const results = [];
for (const s of sites) results.push(await probe(s));
const fails = results.filter((r) => r.state === 'FAIL');
const unmeasured = results.filter((r) => r.state === 'NOT_MEASURED');
const verdict = fails.length ? 'FAIL' : (unmeasured.length ? 'WARN' : 'PASS');
if (AS_JSON) {
console.log(JSON.stringify({
verdict, status: verdict, ts: new Date().toISOString(),
population: sites.length, passed: results.length - fails.length - unmeasured.length,
failed: fails.length, not_measured: unmeasured.length,
min_cards: MIN_CARDS, test_mode: TEST_MODE, origin_override: BASE || null,
sites: results,
}, null, 2));
} else {
console.log(`==> grid assertion · ${sites.length} catalog-serving sites · floor ${MIN_CARDS} card(s)`);
for (const r of results) {
const mark = r.state === 'PASS' ? ' ok ' : (r.state === 'FAIL' ? ' FAIL' : ' ????');
console.log(`${mark} ${r.domain} — ${r.why}`);
}
console.log(`==> ${verdict} · ${results.length - fails.length - unmeasured.length} pass, ${fails.length} fail, ${unmeasured.length} not-measured`);
}
process.exit(verdict === 'PASS' ? 0 : (verdict === 'FAIL' ? 1 : 2));
})();