diff --git a/test/e2e/batch07-gc.mjs b/test/e2e/batch07-gc.mjs new file mode 100644 index 0000000..d3343b8 --- /dev/null +++ b/test/e2e/batch07-gc.mjs @@ -0,0 +1,193 @@ +// 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);