morphit/apps/web/scripts/chat-blocks-race-guard-smoke.ts

112 lines
4.2 KiB
TypeScript

/**
* cp398 — blocks store: optimistic-overwrite race guard + Settings
* refresh-on-mount.
*
* Two coupled invariants, both surfaced by the cp398 "Blocked Accounts
* went stale" bug:
*
* 1. Settings → Blocked Accounts must call refreshBlocks() on mount, not
* loadBlocks() — the latter early-returns once the session has loaded
* the store once, so after navigating away and back the card showed a
* stale/empty cached set until the user hit Refresh manually.
*
* 2. A loadBlocks()/refreshBlocks() fetch must NOT clobber an optimistic
* markBlocked()/markUnblocked() that happened while the fetch was in
* flight. Without the guard, a Block/Unblock clicked mid-load
* "un-sticks" when the (now stale) indexer snapshot lands. Guarded via
* a mutation-generation counter: loadBlocks snapshots it before the
* fetch and only adopts the indexer set if no mutation intervened.
*
* Structural smoke (source-pattern + tamper), matching the house style for
* store/wiring invariants that have no pure-function seam to unit-test.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const BLOCKS = resolve(here, '../src/lib/chat/blocks.ts');
const SETTINGS = resolve(here, '../src/routes/[lang]/settings/+page.svelte');
const blocksSrc = readFileSync(BLOCKS, 'utf8');
const settingsSrc = readFileSync(SETTINGS, 'utf8');
interface Check {
name: string;
/** Runs against (blocksSrc, settingsSrc). */
holds: (blocks: string, settings: string) => boolean;
}
const checks: Check[] = [
{
name: 'mutation-generation counter is declared',
holds: (b) => /let mutationGen = 0;/.test(b)
},
{
name: 'markBlocked bumps the generation (optimistic add is observable to the guard)',
holds: (b) => /export function markBlocked[\s\S]{0,140}mutationGen\+\+;/.test(b)
},
{
name: 'markUnblocked bumps the generation (optimistic remove is observable to the guard)',
holds: (b) => /export function markUnblocked[\s\S]{0,140}mutationGen\+\+;/.test(b)
},
{
name: 'loadBlocks snapshots the generation BEFORE the fetch',
holds: (b) => /const startGen = mutationGen;[\s\S]{0,160}getBlocks\(/.test(b)
},
{
name: 'fetch result only adopted when no mutation intervened (no clobber)',
holds: (b) => /if \(mutationGen === startGen\)\s*\{[\s\S]{0,200}blockedSet\.set\(/.test(b)
},
{
name: 'refreshBlocks still force-refetches (resets loaded, delegates to loadBlocks)',
holds: (b) => /export async function refreshBlocks[\s\S]{0,160}loaded = false;[\s\S]{0,80}return loadBlocks\(/.test(b)
},
{
name: 'Settings refreshes the blocked list on mount (refreshBlocks, NOT the no-op-if-loaded loadBlocks)',
holds: (_b, s) => /void refreshBlocks\(me\);/.test(s) && !/void loadBlocks\(me\);/.test(s)
}
];
let failing = 0;
for (const c of checks) {
const ok = c.holds(blocksSrc, settingsSrc);
console.log(` ${ok ? '✓' : '✗'} ${c.name}`);
if (!ok) failing++;
}
// ── Tamper checks: break the source in-memory, confirm the guard flips red.
interface Tamper {
name: string;
mutateBlocks?: (b: string) => string;
mutateSettings?: (s: string) => string;
check: (typeof checks)[number];
}
const tampers: Tamper[] = [
{
name: 'drop the generation guard from the fetch',
mutateBlocks: (b) => b.replace('if (mutationGen === startGen)', 'if (true)'),
check: checks[4]
},
{
name: 'revert Settings mount to loadBlocks',
mutateSettings: (s) => s.replace('void refreshBlocks(me);', 'void loadBlocks(me);'),
check: checks[6]
}
];
for (const t of tampers) {
const b = t.mutateBlocks ? t.mutateBlocks(blocksSrc) : blocksSrc;
const s = t.mutateSettings ? t.mutateSettings(settingsSrc) : settingsSrc;
const stillHolds = t.check.holds(b, s);
const caught = !stillHolds;
console.log(` ${caught ? '✓' : '✗'} tamper caught: "${t.name}" turns its check red`);
if (!caught) failing++;
}
const total = checks.length + tampers.length;
console.log(`\n${total - failing} ok, ${failing} failing`);
if (failing > 0) {
console.error('chat-blocks-race-guard smoke FAILED');
process.exit(1);
}
console.log(`✓ all ${total} scenarios passed`);