mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
test/e2e: check the GC preview panel and hand-check its orphan claim
Drives "Scan for Orphans" through the admin panel and asserts the wiring the
Go tests structurally cannot: that the progress fragment swaps into
#gc-results and hands off to the preview fragment, that every advertised stat
renders a value, and that each table's row count agrees with the stat above
it. A GC that decides correctly and renders a blank panel still gets someone
to click delete on the wrong thing.
Then it hand-checks the claim itself, which is the part that matters: for
each distinct manifest behind an orphaned record, resolve the owner's PDS and
ask whether that manifest is really gone.
Two instrument bugs were found writing this, both in the script rather than
the product, and both worth keeping as comments:
* The three tables overlap on a Digest column, so classifying by "has
Digest but no RKey" swallowed Missing Records as orphaned blobs and
reported 3 blobs against a stat of 0. Classification is now by exact
header set.
* Asserting the manifest is ABSENT from the PDS is too strong. A manifest
can be alive and name a different hold, which is exactly what happens
when defaultHold is repointed and the image re-pushed. Those records are
legitimately orphaned here. The only state that means GC is staged to
destroy live data is a manifest that exists AND still names this hold.
Against the dev hold: 387 orphaned records over 68 distinct manifests, 25
sampled — 17 gone, 8 alive but now pointing at the production hold, 0 still
naming this hold. Orphaned blobs 0, referenced 15, and the counts agree with
the tables.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4542897f08
commit
0071528b8f
@@ -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(() => '<unreadable>')).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);
|
||||
Reference in New Issue
Block a user