master
Colby Knox 0.21.2: render view is read-only, slotSize on own change only, real render errors (Ray, #57 / PR #1); test:render; bran… e02c729 28d ago
#!/usr/bin/env node
// Brand-guide tests: node test/brand.mjs
//
// The guide resolver is a network contract, so every case here runs against a
// STUB registry rather than staging. The stub is the contract from the
// handoff: GET /api/owners/:owner/brand answers the resolved guide, POST
// /api/pages/:owner/brand/files takes the confirm push. That lets each status
// (confirmed, starter, missing, invalid, private, unreachable) be exercised
// deterministically, and lets the confirm push body be inspected byte for
// byte without writing to any real owner's guide.
import http from 'http';
import { spawn, execFile } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(DIR, '..');

let pass = 0;
const fails = [];
function ok(name, cond, detail) {
  if (cond) { pass++; console.log('  ok   ' + name); }
  else { fails.push(name + (detail ? ': ' + detail : '')); console.log('  FAIL ' + name + (detail ? ': ' + detail : '')); }
}
const eq = (name, got, want) => ok(name, got === want, 'got ' + JSON.stringify(got) + ', want ' + JSON.stringify(want));

// ---- the stub registry ----

const GUIDES = {
  goodco: {
    status: 'confirmed', version: 3, sha: 'a'.repeat(40), can_edit: true,
    owner_kind: 'org', visibility: 'public',
    page: 'goodco/brand', url: 'http://stub/goodco/brand',
    editable_by: { rule: 'org-members', owner_kind: 'org', who: ['goodco'], text: 'Any member of goodco can edit goodco/brand.' },
    brand: {
      brand_manifest: '1.0', owner: 'goodco', name: 'GoodCo', status: 'confirmed',
      confirmed_by: 'someone', confirmed_at: '2026-01-01T00:00:00Z',
      colors: { primary: '#ff0000', background: '#101010', text: '#ffffff' },
      typography: { display: { family: 'Inter' }, body: { family: 'Inter' } },
      logos: { mark: 'assets/mark.svg' },
    },
    assets: { mark: 'http://stub/goodco/brand/files/assets/mark.svg', fonts: [] },
    hints: [], warnings: [], errors: [],
  },
  starterco: {
    status: 'starter', version: 1, sha: 'b'.repeat(40), can_edit: true,
    owner_kind: 'user', visibility: 'public',
    page: 'starterco/brand', url: 'http://stub/starterco/brand',
    editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['starterco'], text: 'Only starterco can edit starterco/brand.' },
    brand: {
      brand_manifest: '1.0', owner: 'starterco', name: 'Starterco', status: 'starter',
      colors: { primary: '#00ff00', background: '#181818', text: '#ffffff' },
      typography: { display: { family: 'Inter' }, body: { family: 'Inter' } },
      logos: { mark: 'assets/mark.png' },
    },
    assets: { mark: 'http://stub/starterco/brand/files/assets/mark.png', fonts: [] },
    hints: [{ level: 'warning', code: 'BRAND_GUIDE_STARTER', message: 'starterco/brand is a starter guide nobody has approved yet.' }],
    warnings: [], errors: [],
  },
  // can_edit false: a thread looking at someone else's guide
  lockedco: {
    status: 'starter', version: 1, sha: 'c'.repeat(40), can_edit: false,
    owner_kind: 'user', visibility: 'public',
    page: 'lockedco/brand', url: 'http://stub/lockedco/brand',
    editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['lockedco'], text: 'Only lockedco can edit lockedco/brand.' },
    brand: {
      brand_manifest: '1.0', owner: 'lockedco', name: 'Lockedco', status: 'starter',
      colors: { primary: '#0000ff', background: '#181818', text: '#ffffff' },
      typography: { display: { family: 'Inter' }, body: { family: 'Inter' } },
      logos: { mark: 'assets/mark.png' },
    },
    assets: { mark: 'http://stub/lockedco/brand/files/assets/mark.png', fonts: [] },
    hints: [], warnings: [], errors: [],
  },
  invalidco: {
    status: 'invalid', version: 1, sha: 'd'.repeat(40), can_edit: true,
    owner_kind: 'user', visibility: 'public',
    page: 'invalidco/brand', url: 'http://stub/invalidco/brand',
    editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['invalidco'], text: 'Only invalidco can edit invalidco/brand.' },
    brand: null, assets: null,
    errors: [{ field: 'colors.primary', message: 'not a hex color' }],
    warnings: [], hints: [],
  },
  privateco: {
    status: 'private', version: null, sha: null, can_edit: false,
    owner_kind: 'user', visibility: 'private',
    page: 'privateco/brand', url: null,
    editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['privateco'], text: 'Only privateco can edit privateco/brand.' },
    brand: null, assets: null, errors: [], warnings: [], hints: [],
  },
};

const pushes = [];
let brandRouteBroken = false;
// starter route (0.21.0): who gets a draft, who is refused
const DRAFTED = new Set();
const STARTER_REFUSED = new Set(['nobodyelse']);
// the registry says exists, the resolver keeps saying missing: two identities
const STARTER_EXISTS_UNREADABLE = new Set(['ghostco']);
const starterCalls = [];

const stub = http.createServer((req, res) => {
  const u = new URL(req.url, 'http://stub');
  const send = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
  let m = u.pathname.match(/^\/api\/owners\/([^/]+)\/brand$/);
  if (m && req.method === 'GET') {
    if (brandRouteBroken) return send(500, { error: 'stub is down' });
    const o = decodeURIComponent(m[1]);
    const g = GUIDES[o];
    if (!g && DRAFTED.has(o)) {
      // what the registry drafts: a starter the owner can edit
      return send(200, { status: 'starter', version: 1, sha: 'f'.repeat(40), can_edit: true, owner_kind: 'user', visibility: 'public',
        page: o + '/brand', url: 'http://stub/' + o + '/brand', editable_by: { rule: 'owner-only', owner_kind: 'user', who: [o], text: 'Only ' + o + ' can edit ' + o + '/brand.' },
        brand: { brand_manifest: '1.0', owner: o, name: o, status: 'starter', colors: { primary: '#394dd0', background: '#0c0d18', text: '#ffffff' },
          typography: { display: { family: 'Inter' }, body: { family: 'Inter' } }, logos: { mark: 'assets/mark.svg' } },
        assets: { mark: 'http://stub/' + o + '/brand/files/assets/mark.svg', fonts: [] }, hints: [], warnings: [], errors: [] });
    }
    if (!g) return send(404, { error: 'no brand guide', hints: [{ level: 'warning', code: 'BRAND_GUIDE_STARTER', message: 'no guide yet' }] });
    return send(200, g);
  }
  m = u.pathname.match(/^\/api\/owners\/([^/]+)\/brand\/starter$/);
  if (m && req.method === 'POST') {
    const o = decodeURIComponent(m[1]);
    starterCalls.push(o);
    if (STARTER_REFUSED.has(o)) return send(403, { error: 'Only ' + o + ' can edit ' + o + '/brand.', code: 'BRAND_EDITORS', hint: 'ask ' + o });
    if (STARTER_EXISTS_UNREADABLE.has(o)) return send(200, { ok: true, owner: o, status: 'exists', drafting: false });
    if (GUIDES[o] || DRAFTED.has(o)) return send(200, { ok: true, owner: o, status: 'exists', drafting: false });
    // 202 now; the draft "lands" on the next resolver read
    setTimeout(() => DRAFTED.add(o), 300);
    return send(202, { ok: true, owner: o, status: 'drafting', drafting: true });
  }
  m = u.pathname.match(/^\/api\/pages\/([^/]+)\/brand\/files$/);
  if (m && req.method === 'POST') {
    let body = '';
    req.on('data', (c) => { body += c; });
    req.on('end', () => {
      let parsed = null;
      try { parsed = JSON.parse(body); } catch {}
      const o = decodeURIComponent(m[1]);
      pushes.push({ owner: o, body: parsed });
      try {
        const bj = (parsed.files || []).find((f) => f.path === 'brand.json');
        const b = JSON.parse(bj.encoding === 'base64' ? Buffer.from(bj.content, 'base64').toString('utf8') : bj.content);
        if (b.status === 'confirmed' && GUIDES[o]) { GUIDES[o].status = 'confirmed'; GUIDES[o].brand = b; GUIDES[o].sha = 'e'.repeat(40); }
      } catch {}
      send(200, { ok: true, sha: 'e'.repeat(40) });
    });
    return;
  }
  // the human at the gate, as the registry names them
  if (u.pathname === '/auth/me') return send(200, { user: { username: 'starterco' } });
  // the page rows the studio pulls for titles: answer empty so nothing hangs
  if (u.pathname.startsWith('/api/pages')) return send(404, { error: 'not found' });
  send(404, { error: 'not found' });
});

// ---- harness ----

const listen = (srv) => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port)));

async function main() {
  const stubPort = await listen(stub);
  const REG = 'http://127.0.0.1:' + stubPort;

  const srv = spawn(process.execPath, [path.join(ROOT, 'server.js'), '--ai-thread', 'brand test', '--ttl', '3'], {
    cwd: ROOT,
    env: { ...process.env, ADOM_WIKI_REGISTRY: REG, HERO_STUDIO_PORT: 'auto', HERO_RENDERER: 'local' },
    stdio: ['ignore', 'pipe', 'pipe'],
  });
  let port = 0;
  let log = '';
  srv.stdout.on('data', (d) => { log += d; const m = /hero-studio on (\d+)/.exec(String(d)); if (m && !port) port = +m[1]; });
  srv.stderr.on('data', (d) => { log += d; const m = /hero-studio on (\d+)/.exec(String(d)); if (m && !port) port = +m[1]; });
  for (let i = 0; i < 100 && !port; i++) await new Promise((r) => setTimeout(r, 100));
  if (!port) { console.error('server never printed its port. log:\n' + log); srv.kill('SIGTERM'); process.exit(1); }
  const BASE = 'http://127.0.0.1:' + port + '/';
  // the server answers only once it is actually listening
  for (let i = 0; i < 100; i++) {
    try { await fetch(BASE + 'version'); break; } catch { await new Promise((r) => setTimeout(r, 100)); }
  }

  const get = async (p) => (await fetch(BASE + p)).json();
  const post = async (p, body) => (await fetch(BASE + p, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ aiThread: 'brand test', ...(body || {}) }),
  })).json();
  const pose = async (ref) => { await post('state', { mode: 'app', page: ref, comp: { id: null } }); return (await get('brand?fresh=1')).guide; };

  try {
    // 1. every status the contract names resolves to itself, and a guide is
    // only usable when the server says so.
    console.log('the resolver, per status:');
    let g = await pose('goodco/brand');
    eq('confirmed resolves', g.status, 'confirmed');
    eq('  carries the sha', g.sha, 'a'.repeat(40));
    eq('  carries the version', g.version, 3);
    ok('  carries the brand body', !!(g.brand && g.brand.colors.primary === '#ff0000'));

    g = await pose('starterco/brand');
    eq('starter resolves', g.status, 'starter');
    ok('  relays the server hints', (g.hints || []).some((h) => h.code === 'BRAND_GUIDE_STARTER'));
    ok('  relays editable_by.text verbatim', (g.editable_by || {}).text === 'Only starterco can edit starterco/brand.');

    // (nobodyelse: the registry refuses to draft for this identity, so the
    // 404 stays missing; nobodyco, drafted on pose, is covered in 4b)
    g = await pose('nobodyelse/brand');
    eq('a 404 is missing, not an error', g.status, 'missing');
    ok('  and never borrows Adom\'s brand body', !g.brand);

    g = await pose('invalidco/brand');
    eq('invalid resolves', g.status, 'invalid');
    ok('  and names the bad fields', (g.errors || []).some((e) => e.field === 'colors.primary'));
    ok('  and applies nothing', !g.brand);

    g = await pose('privateco/brand');
    eq('private resolves', g.status, 'private');
    ok('  and applies nothing', !g.brand);

    // 2. an unhappy registry must say so, NOT silently become "use Adom's
    // look": a wrong brand published under someone else's name is worse than
    // a hero that holds.
    brandRouteBroken = true;
    g = await pose('goodco/brand');
    eq('a 500 from the registry is unavailable', g.status, 'unavailable');
    eq('  and is marked unreachable', g.reachable, false);
    ok('  and applies nothing', !g.brand);
    brandRouteBroken = false;

    // 3. confirm is a human act, and only for a guide this identity may edit.
    console.log('confirm:');
    await pose('lockedco/brand');
    let r = await post('brand/confirm', { fromGate: true, human: 'someone' });
    ok('can_edit:false cannot confirm', r.ok === false, JSON.stringify(r).slice(0, 160));
    ok('  and the refusal names who may', /Only lockedco can edit/.test(JSON.stringify(r)), JSON.stringify(r).slice(0, 160));
    eq('  and nothing was pushed', pushes.length, 0);

    r = await post('brand/confirm', {});
    ok('an unattested confirm is refused', r.ok === false, JSON.stringify(r).slice(0, 160));
    eq('  and nothing was pushed', pushes.length, 0);

    // 4. the confirm push body is brand.json v1, with the human recorded.
    await pose('starterco/brand');
    r = await post('brand/confirm', { fromGate: true, human: 'starterco' });
    ok('a confirm on an editable starter guide pushes', r.ok === true, JSON.stringify(r).slice(0, 200));
    eq('  pushed once', pushes.length, 1);
    const push = pushes[0] || { body: {} };
    eq('  to the right owner', push.owner, 'starterco');
    const files = (push.body && push.body.files) || [];
    const bj = files.find((f) => f.path === 'brand.json');
    ok('  the push writes brand.json', !!bj, JSON.stringify(files.map((f) => f.path)));
    ok('  with a commit message', !!(push.body && push.body.message));
    let written = null;
    try { written = JSON.parse(bj.encoding === 'base64' ? Buffer.from(bj.content, 'base64').toString('utf8') : bj.content); } catch {}
    ok('  the written brand.json parses', !!written);
    if (written) {
      eq('  brand_manifest is 1.0', written.brand_manifest, '1.0');
      eq('  owner matches the page owner', written.owner, 'starterco');
      eq('  status becomes confirmed', written.status, 'confirmed');
      eq('  confirmed_by names the human', written.confirmed_by, 'starterco');
      ok('  confirmed_at is an ISO instant', typeof written.confirmed_at === 'string' && !isNaN(Date.parse(written.confirmed_at)), String(written.confirmed_at));
      // v1 required keys survive the confirm: a confirm must not drop fields
      ok('  colors survive the confirm', !!(written.colors && written.colors.primary === '#00ff00'));
      ok('  typography survives the confirm', !!(written.typography && written.typography.display.family === 'Inter'));
      ok('  logos survive the confirm', !!(written.logos && written.logos.mark));
    }

    // 4b. 0.21.0: a missing guide is drafted on pose, once, so the Brand step
    // is never empty; a refused draft is carried on the guide with the reason.
    console.log('starter auto-draft:');
    g = await pose('nobodyco/brand');
    eq('a missing guide is drafted and resolves as starter', g.status, 'starter');
    eq('  the starter route was called once for that owner', starterCalls.filter((o) => o === 'nobodyco').length, 1);
    ok('  the outcome rides on the guide', g.starter && g.starter.result === 'drafting', JSON.stringify(g.starter));
    g = await pose('nobodyelse/brand');
    eq('a refused draft leaves the guide missing', g.status, 'missing');
    eq('  and was tried exactly once for that owner across both poses', starterCalls.filter((o) => o === 'nobodyelse').length, 1);
    ok('  and names the refusal', g.starter && g.starter.result === 'refused' && g.starter.code === 'BRAND_EDITORS', JSON.stringify(g.starter));
    r = await post('generate', { fromGate: true });
    ok('  Generate stays locked on it', r.ok === false && /no brand guide/.test(r.error || ''), JSON.stringify(r).slice(0, 200));
    g = await pose('ghostco/brand');
    eq('exists on the registry but unreadable here stays missing', g.status, 'missing');
    ok('  and is named as an identity problem, not a draft in progress', g.starter && g.starter.result === 'exists_unreadable' && /cannot read/.test(g.starter.error || ''), JSON.stringify(g.starter));
    r = await post('generate', { fromGate: true });
    ok('  Generate stays locked and says so', r.ok === false && /cannot read it/.test(JSON.stringify(r)), JSON.stringify(r).slice(0, 220));

    // 4c. 0.21.0: Generate on a starter the human may edit confirms it in the
    // same click; the stamp is what the registry holds AFTER the push.
    console.log('confirm inside Generate:');
    GUIDES.starterco.status = 'starter'; GUIDES.starterco.brand.status = 'starter';
    const before = pushes.length;
    await pose('starterco/brand');
    let gen21 = await post('generate', { fromGate: true, renderer: 'local' });
    ok('Generate on an editable starter renders', gen21.ok === true, JSON.stringify(gen21).slice(0, 300));
    eq('  and pushed the confirm', pushes.length, before + 1);
    let lastPush = pushes[pushes.length - 1];
    let wb = null; try { const bj = lastPush.body.files.find((f) => f.path === 'brand.json'); wb = JSON.parse(bj.content); } catch {}
    ok('  brand.json went out confirmed, by the human', wb && wb.status === 'confirmed' && wb.confirmed_by === 'starterco' && !!wb.confirmed_at, JSON.stringify(wb).slice(0, 200));
    if (gen21.ok && gen21.file) {
      const meta = await readPngComment(path.join(ROOT, 'generated', gen21.file));
      eq('  the stamp says confirmed (post-push), not starter', meta && meta.brand_status, 'confirmed');
      eq('  branding is the owner', meta && meta.branding, 'starterco');
      eq('  and the stamp carries this package version so the wiki judges it', meta && meta.version, JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
      try { fs.unlinkSync(path.join(ROOT, 'generated', gen21.file)); } catch {}
    }
    await pose('lockedco/brand');
    const lockedBefore = pushes.length;
    gen21 = await post('generate', { fromGate: true });
    ok('Generate on a starter this identity cannot edit is refused', gen21.ok === false && /locked/.test(gen21.error || ''), JSON.stringify(gen21).slice(0, 200));
    ok('  with the resolver\'s own editor rule', /Only lockedco can edit/.test(JSON.stringify(gen21)));
    eq('  and nothing was pushed', pushes.length, lockedBefore);

    // 5. provenance names the brand the hero was drawn with, under exactly
    // the four keys the handoff fixes: branding, brand_sha, brand_version,
    // brand_status. A consumer reading a published hero can tell which guide
    // (and which revision of it) produced those pixels.
    console.log('provenance:');
    await pose('goodco/brand');
    const gen = await post('generate', { fromGate: true });
    ok('a gate generate renders', gen.ok === true, JSON.stringify(gen).slice(0, 300));
    if (gen.ok && gen.file) {
      const png = path.join(ROOT, 'generated', gen.file);
      const meta = await readPngComment(png);
      ok('  the PNG carries a provenance comment', !!meta, String(meta).slice(0, 120));
      if (meta) {
        eq('  branding names the page owner', meta.branding, 'goodco');
        eq('  brand_sha is the resolved sha', meta.brand_sha, 'a'.repeat(40));
        eq('  brand_version is the resolved version', meta.brand_version, 3);
        eq('  brand_status is the resolved status', meta.brand_status, 'confirmed');
        ok('  the four keys sit at the top level of the meta', ['branding', 'brand_sha', 'brand_version', 'brand_status'].every((k) => k in meta));
      }
      try { fs.unlinkSync(png); } catch {}
    }
  } finally {
    try { await post('shutdown', {}); } catch {}
    setTimeout(() => srv.kill('SIGKILL'), 3000);
    srv.kill('SIGTERM');
    stub.close();
  }

  console.log('');
  if (fails.length) {
    console.log(pass + ' passed, ' + fails.length + ' FAILED:');
    for (const f of fails) console.log('  - ' + f);
    process.exit(1);
  }
  console.log(pass + ' passed');
  process.exit(0);
}

/// The provenance comment out of a PNG's tEXt chunk, parsed.
function readPngComment(file) {
  return new Promise((resolve) => {
    const py = 'import sys, json\n'
      + 'from PIL import Image\n'
      + 'im = Image.open(sys.argv[1])\n'
      + 'print(im.info.get("comment") or im.text.get("comment") or "")\n';
    execFile('python3', ['-c', py, file], (err, stdout) => {
      if (err) return resolve(null);
      try { resolve(JSON.parse(String(stdout).trim())); } catch { resolve(null); }
    });
  });
}

main().catch((e) => { console.error(e); process.exit(1); });