morphit/apps/web/scripts/profile-freshness-smoke.ts

119 lines
9.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env tsx
/**
* Smoke: display name + avatar no longer fall back to "@account" + identicon
* and STAY that way across refreshes (Ken #2). Anchor 2026-07-08.
*
* Two independent causes, both guarded here:
*
* 1. SERVER — the batch profiles endpoint sent `max-age=90,
* stale-while-revalidate=60` on EVERY response, including ones that
* omitted a requested account. An omitted account is usually just indexer
* lag (12 blocks after a profile broadcast / signup), and the browser's
* HTTP cache replayed that negative result for up to 150s — surviving page
* refreshes, because a reload clears the in-memory cache but not the disk
* cache. Partial batches are now `no-store`.
*
* 2. CLIENT — `getProfileCached` collapses "fetch failed" and "no profile"
* into the same bare null, so the selfProfile store CLEARED a good avatar
* to the identicon on any transient blip, and it stuck for the whole
* session (the store only refreshes on account change / broadcast). The
* store now uses `getProfileCachedDetailed`, keeps the prior value on
* failure, retries, and force-reloads the HTTP cache after a broadcast.
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB = join(__dirname, '..');
const REPO = join(WEB, '..', '..');
const serverProfiles = readFileSync(join(REPO, 'apps', 'indexer', 'src', 'api', 'profiles.ts'), 'utf8');
const cacheMod = readFileSync(join(WEB, 'src', 'lib', 'indexer', 'profileCache.ts'), 'utf8');
const echoMod = readFileSync(join(WEB, 'src', 'lib', 'stores', 'pendingEcho.ts'), 'utf8');
const store = readFileSync(join(WEB, 'src', 'lib', 'stores', 'selfProfile.ts'), 'utf8');
const settings = readFileSync(join(WEB, 'src', 'routes', '[lang]', 'settings', '+page.svelte'), 'utf8');
const orderbook = readFileSync(join(WEB, 'src', 'routes', '[lang]', 'orderbook', '+page.svelte'), 'utf8');
let pass = 0;
let fail = 0;
function check(name: string, ok: boolean): void {
if (ok) {
pass++;
console.log(` \u2713 ${name}`);
} else {
fail++;
console.error(` \u2717 ${name}`);
}
}
// ─── 1. Server: negative batch results are never cached ──────────────
check('server defines a separate no-store header for partial batches', /BATCH_CACHE_CONTROL_PARTIAL = 'no-store'/.test(serverProfiles));
check('server keeps the 90s header for complete batches', /BATCH_CACHE_CONTROL = 'public, max-age=90, stale-while-revalidate=60'/.test(serverProfiles));
// v1.5.5 (t155): completeness is measured by has_profile, NOT row count.
// The batch was re-anchored on `accounts` so a key-only account resolves (its
// posting key was previously unreachable — the query started at `profiles` and
// returned nothing for anyone who never set one). That silently broke THIS
// policy: a profile-less account now comes back as a ROW, so a row-count test
// would call such a batch complete and pin "no profile" for the full 90s —
// exactly the negative-caching failure cp428 exists to prevent. Row presence
// stopped meaning "has a profile"; only has_profile does.
check('server picks the header by batch completeness (cache-served + queried positives, never a row count)', /const complete = servedFromCache \+ queriedWithProfile === accounts\.length;/.test(serverProfiles) && /if \(row\.has_profile\)\s*\{[\s\S]{0,120}queriedWithProfile\+\+;/.test(serverProfiles) && /complete \? BATCH_CACHE_CONTROL : BATCH_CACHE_CONTROL_PARTIAL/.test(serverProfiles));
check('completeness cannot regress to a row count (a profile-less row is not a profile)', !/const complete = result\.rows\.length === accounts\.length;/.test(serverProfiles));
check('the single-profile 404 is no-store too (404s are heuristically cacheable)', /c\.header\('Cache-Control', BATCH_CACHE_CONTROL_PARTIAL\);[\s\S]{0,160}errorBody\('not_found'/.test(serverProfiles));
// ─── 2. Client cache: failure is distinguishable + reload path ───────
check('cache exposes getProfileCachedDetailed with a `failed` flag', /export async function getProfileCachedDetailed/.test(cacheMod) && /readonly failed: boolean/.test(cacheMod));
check('`failed` is derived from the cp428 soft-null marker', /cache\.get\(account\)\?\.soft === true/.test(cacheMod));
check('fetchBatch can bypass the browser HTTP cache (cache: reload)', /reload = false/.test(cacheMod) && /cache: 'reload' as RequestCache/.test(cacheMod));
check('a reload request skips the in-memory cache + in-flight sharing', /if \(opts\?\.reload\) \{[\s\S]{0,120}needsFetch\.push\(account\);/.test(cacheMod));
check('reload is threaded batch → fetchBatch', /fetchBatch\(chunkOfAccounts, signal, opts\?\.reload === true\)/.test(cacheMod));
// ─── 3. selfProfile store: never clobber a good avatar on a blip ─────
check('store uses the detailed fetch (not the null-collapsing one)', /getProfileCachedDetailed/.test(store) && !/\bgetProfileCached\b(?!Detailed)/.test(store));
check('on failure the store keeps the current account\u2019s value', /if \(failed\) \{[\s\S]{0,260}cur\.account === account \? cur :/.test(store));
check('on failure during an account SWITCH the store blanks (no leaking the old avatar)', /cur\.account === account \? cur : \{ account, displayName: null, avatarSvg: null, avatarDataUri: null \}/.test(store));
check('a failed fetch is retried (a blip must not blank the session)', /SELF_PROFILE_RETRIES = 2/.test(store) && /SELF_PROFILE_RETRY_DELAY_MS = 6_000/.test(store));
check('the retry delay outlives the 5s soft-null TTL', /6_000/.test(store));
check('bustCache also force-reloads the browser HTTP cache', /reload: opts\?\.bustCache === true/.test(store));
check('an authoritative "no profile" is still applied (avatar really removed)', /const props = extractLabelPropsFromProfile\(profile\);/.test(store));
// ─── 4. cp452: optimistic prime + orderbook re-read (t.txt 2 + 3) ────
// A settings edit must show INSTANTLY (not after the 90s TTL), and the user's
// own orderbook cards must recover their avatar/name after a SW-upgrade reload
// race WITHOUT a manual refresh.
check('cache exports primeProfile (optimistic write for the user\u2019s own edit)', /export function primeProfile\(/.test(cacheMod));
// v1.7.0 — this check's NAME was right and its ASSERTION was wrong, which is the
// worst combination: it pinned `PRIME_HOLD_MS = 12_000`, so it was actively
// holding the bug in place. 12s cannot "hold through indexer catch-up" —
// `profiles` is written only by handlers/profile.ts, which runs from the poller's
// applyBlock, and the poller applies blocks only up to last-irreversible
// (ADR-0008), 45-63s behind head. The hold expired ~40s BEFORE the indexer could
// know about the edit, and the next fetch reverted the user's own just-saved
// name — the exact "I saved it but it reverted" flicker (t.txt 2+3) this exists
// to prevent. Now pins the REQUIREMENT (a window that outlasts irreversibility,
// via the shared chain constant) rather than a literal that was never right.
check('a prime is held through indexer catch-up (isPrimeHeld + PRIME_HOLD_MS)', /const PRIME_HOLD_MS = PENDING_TTL_MS/.test(cacheMod) && /function isPrimeHeld\(/.test(cacheMod));
check('the hold uses the shared chain constant, not a hand-tuned copy', /import \{ PENDING_TTL_MS \} from '\$lib\/stores\/pendingEcho'/.test(cacheMod));
check('PENDING_TTL_MS actually outlasts Blurt irreversibility (45-63s)', /export const PENDING_TTL_MS = 150_000/.test(echoMod));
check('BOTH fetch-resolution branches defer to a held prime (no stale clobber)', (cacheMod.match(/if \(isPrimeHeld\(account\)\)/g) || []).length >= 2);
check('primeProfile writes exactly the json_metadata keys extractLabelPropsFromProfile reads', /jsonMetadata\.avatar_svg =/.test(cacheMod) && /jsonMetadata\.avatar_data_uri =/.test(cacheMod) && /jsonMetadata\.short_bio =/.test(cacheMod) && /jsonMetadata\.nostr_url =/.test(cacheMod) && /jsonMetadata\.streaming_url =/.test(cacheMod) && /jsonMetadata\.website_url =/.test(cacheMod));
check('clearing the cache also drops the prime hold (no stale-prime leak)', /primedAt\.clear\(\)/.test(cacheMod) && /primedAt\.delete\(account\)/.test(cacheMod));
check('settings imports primeProfile', /import \{ primeProfile \} from '\$lib\/indexer\/profileCache'/.test(settings));
check('settings primes the WHOLE current profile after a confirmed broadcast', /function primeSelfProfile\(\)/.test(settings) && /primeProfile\(getUserBlurtAccount\(\) \?\? '', \{/.test(settings));
check('every confirmed broadcast path primes (name/bio/media/nostr/avatar/remove = 6)', (settings.match(/primeSelfProfile\(\);/g) || []).length >= 6);
check('orderbook imports the selfProfile store', /import \{ selfProfile \} from '\$lib\/stores\/selfProfile'/.test(orderbook));
check('orderbook re-reads self on selfProfile change (SW-upgrade late arrival)', /selfProfile\.subscribe\(/.test(orderbook) && /void rehydrateSelf\(/.test(orderbook));
check('orderbook rehydrateSelf merges ONLY a non-null profile (never blanks a good one)', /async function rehydrateSelf\(account: string\)/.test(orderbook) && /if \(profile !== null\)/.test(orderbook));
check('orderbook unsubscribes selfProfile on cleanup (no leak)', /unsubSelf\(\)/.test(orderbook));
console.log('');
if (fail === 0) {
console.log(`\u2713 all ${pass} profile-freshness scenarios passed`);
} else {
console.error(`\u2717 ${fail} of ${pass + fail} profile-freshness checks FAILED`);
process.exit(1);
}