← 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));
})();