// batch07-gc.mjs — drive the GC preview through the admin panel. // // The Go tests cover the sweep's decisions (pkg/hold/gc). What they cannot // cover is the panel: whether "Scan for Orphans" reaches the right handler, // whether the progress fragment swaps into #gc-results and then hands off to // the preview fragment, and whether the orphan tables render what the analysis // actually found. A GC that decides correctly and renders a blank panel still // gets someone to click delete on the wrong thing. // // Read-only: preview performs no writes. Nothing here touches /admin/api/gc/run // or delete-blobs. // // node test/e2e/batch07-gc.mjs import { open, adminPage, BASE, reporter } from './lib.mjs'; const ctx = await open(); const page = await adminPage(ctx); const r = reporter(); // --- Storage tab ----------------------------------------------------------- await page.click('a[href="/admin#storage"]'); await page.waitForTimeout(1500); const scanBtn = page.locator('button:has-text("Scan for Orphans")').first(); r.record('storage tab renders the scan control', (await scanBtn.count()) > 0); // The button is disabled while an operation is already running; a disabled // control would make the click below a silent no-op. r.record('scan control is enabled', !(await scanBtn.isDisabled())); // --- Preview --------------------------------------------------------------- await scanBtn.click(); // gc_progress.html polls /admin/api/gc/status every 2s and swaps itself out for // gc_preview.html when the analysis finishes. Waiting on the preview's own // markup (rather than a fixed sleep) is what proves the handoff happened. const results = page.locator('#gc-results'); let settled = false; const deadline = Date.now() + 180_000; while (Date.now() < deadline) { const text = await results.innerText().catch(() => ''); if (text.includes('Referenced Blobs')) { settled = true; break; } if (text.includes('GC not available') || text.toLowerCase().includes('error')) break; await page.waitForTimeout(2000); } r.record('preview fragment replaced the progress fragment', settled); if (!settled) { console.error('#gc-results never reached the preview fragment. Contents:'); console.error((await results.innerText().catch(() => '')).slice(0, 2000)); const failed = r.summarize('batch 07 — GC admin panel'); await ctx.close(); process.exit(failed ? 1 : 0); } // --- What it found --------------------------------------------------------- const stats = await page.evaluate(() => { const out = {}; for (const s of document.querySelectorAll('.stat-title')) { const v = s.parentElement?.querySelector('.stat-value'); if (v) out[s.textContent.trim()] = v.textContent.trim().split('—')[0].trim(); } return out; }); console.log('\nPreview stats:'); for (const [k, v] of Object.entries(stats)) console.log(` ${k.padEnd(18)} ${v}`); // Every stat the fragment advertises must actually render a value. A missing // key here is the blank-panel failure this script exists to catch. for (const key of ['Orphaned Records', 'Orphaned Blobs', 'Missing Records', 'Referenced Blobs']) { r.record(`stat rendered: ${key}`, stats[key] !== undefined && stats[key] !== ''); } // A hold serving live images must have found something referenced. Zero here is // the shape of the did:web-escaping defect: analysis "succeeds" and considers // the whole bucket collectable. r.record('referenced blob count is non-zero', Number(stats['Referenced Blobs']) > 0, `Referenced Blobs = ${stats['Referenced Blobs']}`); // --- Orphan rows, for hand-checking against the PDS ------------------------- // Classify by the exact header set. The three tables overlap on Digest, and // "has Digest but no RKey" quietly captures Missing Records as blobs — which is // how the first run of this script reported 3 orphaned blobs against a stat of // 0. The panel was right; the scrape was not. const rows = await page.evaluate(() => { const key = (heads) => heads.join('|'); const out = { records: [], blobs: [], missing: [] }; for (const t of document.querySelectorAll('table')) { const heads = [...t.querySelectorAll('th')].map((h) => h.textContent.trim()); const body = [...t.querySelectorAll('tbody tr')].map((tr) => [...tr.querySelectorAll('td')].map((td) => { const c = td.querySelector('code'); return (c?.getAttribute('title') || td.textContent).trim(); }), ); switch (key(heads)) { case 'Collection|RKey|Digest|Manifest|Size': out.records.push(...body); break; case 'Digest|Size': out.blobs.push(...body); break; case 'Digest|Manifest|User|Size': out.missing.push(...body); break; } } return out; }); console.log(`\nOrphaned record rows: ${rows.records.length}`); for (const row of rows.records.slice(0, 5)) console.log(' ' + row.join(' | ')); console.log(`Orphaned blob rows: ${rows.blobs.length}`); for (const row of rows.blobs.slice(0, 5)) console.log(' ' + row.join(' | ')); console.log(`Missing record rows: ${rows.missing.length}`); for (const row of rows.missing.slice(0, 5)) console.log(' ' + row.join(' | ')); // The tables are conditional on there being anything to show, so the row count // has to agree with the stat. A table rendering rows the stat says do not exist // (or vice versa) is exactly the wiring bug worth catching. r.record('orphaned-record rows match the stat', rows.records.length === Number(stats['Orphaned Records']), `${rows.records.length} rows vs stat ${stats['Orphaned Records']}`); r.record('orphaned-blob rows match the stat', rows.blobs.length === Number(stats['Orphaned Blobs']), `${rows.blobs.length} rows vs stat ${stats['Orphaned Blobs']}`); r.record('missing-record rows match the stat', rows.missing.length === Number(stats['Missing Records']), `${rows.missing.length} rows vs stat ${stats['Missing Records']}`); // --- Hand-check the orphan claim against the PDS --------------------------- // The panel rendering a number consistently says nothing about whether the // number is right. A layer record is orphaned only if the manifest it names is // genuinely gone from its owner's PDS; if any of these still resolve, GC is // staged to delete records for live images. const manifestURIs = [...new Set(rows.records.map((row) => row[3]).filter(Boolean))]; console.log(`\nDistinct manifests behind the orphaned records: ${manifestURIs.length}`); const pdsCache = new Map(); async function resolvePDS(did) { if (pdsCache.has(did)) return pdsCache.get(did); let endpoint = null; try { const doc = await (await fetch(`https://plc.directory/${did}`)).json(); endpoint = doc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint ?? null; } catch { endpoint = null; } pdsCache.set(did, endpoint); return endpoint; } // Existence alone is not the question. A manifest can be perfectly alive on the // PDS while naming a DIFFERENT hold — which is precisely what happens when a // user repoints defaultHold and re-pushes, as this account has done repeatedly // during validation. Those layer records are legitimately orphaned HERE. The // only state that means GC is about to destroy live data is a manifest that // exists AND still names this hold. const OUR_HOLD = process.env.ATCR_HOLD_DID ?? 'did:web:localhost%3A8080'; let checked = 0, liveOnThisHold = 0, liveElsewhere = 0, confirmedGone = 0, unresolved = 0; const elsewhereHolds = new Map(); for (const uri of manifestURIs.slice(0, 25)) { const m = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); if (!m) { unresolved++; continue; } const [, did, collection, rkey] = m; const pds = await resolvePDS(did); if (!pds) { unresolved++; continue; } const url = `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}` + `&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`; let res; try { res = await fetch(url); } catch { unresolved++; continue; } checked++; if (res.status === 400 || res.status === 404) { confirmedGone++; continue; } if (res.status !== 200) { unresolved++; console.log(` status ${res.status} ${uri}`); continue; } const body = await res.json().catch(() => null); const holdDid = body?.value?.holdDid ?? body?.value?.holdEndpoint ?? '(none)'; if (holdDid === OUR_HOLD) { liveOnThisHold++; console.log(` LIVE ON THIS HOLD ${uri}`); } else { liveElsewhere++; elsewhereHolds.set(holdDid, (elsewhereHolds.get(holdDid) ?? 0) + 1); } } console.log(`Hand-check: ${checked} manifests queried — ${confirmedGone} gone, ` + `${liveElsewhere} live but on another hold, ${liveOnThisHold} live on THIS hold, ${unresolved} unresolved`); for (const [h, n] of elsewhereHolds) console.log(` ${n} now point at ${h}`); r.record('no orphaned record belongs to a manifest still living on this hold', checked > 0 && liveOnThisHold === 0, `${liveOnThisHold} of ${checked} sampled manifests still name this hold`); console.log(`\nPreview is now staged. ${BASE}/admin#storage`); const failed = r.summarize('batch 07 — GC admin panel'); await ctx.close(); process.exit(failed ? 1 : 0);