Files
at-container-registry/test/e2e/batch07-stale-preview.mjs
T
Evan JarrettandClaude Opus 5 5f74299bd7 test/e2e: exercise the stale-preview refusal and the real GC sweep
Three scripts, split by what they cost to run.

batch07-stale-preview.mjs stages a preview and waits out the 30-minute
maxPreviewAgeForDelete constant. batch07-stale-click.mjs is the resumable
half: it re-renders whatever preview the hold already holds and clicks delete
on it. GET /admin/api/gc/status re-renders lastPreview WITHOUT touching
lastPreviewAt, so showing an old preview does not reset its age — which is
what makes a failed run cost seconds instead of another 31 minutes.

Result against the dev hold: "preview is 34m0s old (limit 30m0s) — run Scan
again before deleting" rendered through the progress-to-error fragment chain,
with all 387 records still there afterwards. That chain is the point; the
refusal logic itself already has a Go test, but a refusal that renders as
nothing is indistinguishable from "there was nothing to delete".

batch07-sweep.mjs then runs the destructive path for real: 387 records
deleted of 387 staged, orphaned count to zero, referenced blobs unchanged at
15. Safe only against the dev hold on Storj; production is Bunny + UpCloud
and is not reachable from here.

page.on('dialog') did not reliably intercept hx-confirm on this page, and an
unaccepted native dialog blocks every later evaluate() and innerText(), so
the script hangs rather than fails — the worst failure mode for an unattended
check. Both scripts now strip the hx-confirm attribute before clicking. The
confirm is not what is under test.

Two findings worth carrying, neither introduced by this range:

  * deleteOrphanedBlobs is still unexercised. The bucket holds 19 objects,
    of which 8 are past the 7-day blob grace, and none are unreferenced — so
    there is nothing for it to collect. More pushes cannot help: fresh blobs
    are inside the grace window by definition.

  * Storage accounting is derived from layer records, so this sweep moved the
    dashboard from 1.3 GB to 1.1 KB while the bucket held 147 MB throughout.
    It was overstating by ~9x before (records for blobs held by another hold)
    and understates now (referenced blobs with no layer records). Quotas and
    billing read the same number. Belongs to batch 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 16:34:25 -05:00

118 lines
4.8 KiB
JavaScript

// batch07-stale-preview.mjs — the stale-preview refusal, end to end.
//
// pkg/hold/gc covers the refusal itself (TestDeleteOrphanedRecords_StalePreviewIsRefused).
// What no Go test can cover is whether the refusal ever reaches a human: the
// delete handler returns gc_progress.html, which polls /admin/api/gc/status,
// which renders the error through gc_error.html. A break anywhere in that chain
// turns a refusal into a silent no-op, and a silent no-op reads exactly like
// "nothing needed deleting".
//
// maxPreviewAgeForDelete is a package constant at 30 minutes, so this takes its
// own preview and then waits it out. Roughly 32 minutes. It deliberately does
// NOT reuse an existing preview: taking its own is the only way to know the age
// of the thing it is about to click on.
//
// The click is destructive if the refusal is broken. That is the point, and the
// blast radius is bounded: the records staged here were confirmed orphaned
// against their owners' PDSes by batch07-gc.mjs.
//
// Do not touch a tracked .go/.html/.css/.js file while this runs. Air rebuilds
// the hold and a rebuild takes the in-memory admin session with it.
//
// node test/e2e/batch07-stale-preview.mjs
import { open, adminPage, reporter } from './lib.mjs';
const STALE_WAIT_MS = 31.5 * 60 * 1000;
const ctx = await open();
const page = await adminPage(ctx);
const r = reporter();
const results = page.locator('#gc-results');
const waitForPreview = async (timeoutMs = 180_000) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const text = await results.innerText().catch(() => '');
if (text.includes('Referenced Blobs')) return text;
await page.waitForTimeout(2000);
}
return null;
};
const orphanCount = (text) => {
const m = text.match(/Delete (\d+) Orphaned Records/);
return m ? Number(m[1]) : null;
};
// --- Stage a preview and start the clock -----------------------------------
await page.click('a[href="/admin#storage"]');
await page.waitForTimeout(1500);
await page.locator('button:has-text("Scan for Orphans")').first().click();
const staged = await waitForPreview();
r.record('preview staged', staged !== null);
if (!staged) {
await ctx.close();
process.exit(1);
}
const previewAt = Date.now();
const before = orphanCount(staged);
r.record('preview offers a delete control', before !== null && before > 0, `${before} orphaned records`);
console.log(`Preview staged at ${new Date(previewAt).toLocaleTimeString()} with ${before} orphaned records.`);
console.log(`Waiting ${(STALE_WAIT_MS / 60000).toFixed(1)} minutes for it to go stale...`);
// --- Wait it out -----------------------------------------------------------
// Idle only. Navigating here would reload the fragment and lose the button,
// and re-scanning would reset the very age under test.
while (Date.now() - previewAt < STALE_WAIT_MS) {
await page.waitForTimeout(30_000);
const left = Math.ceil((STALE_WAIT_MS - (Date.now() - previewAt)) / 60000);
if (left % 5 === 0) console.log(` ${left} minutes left`);
}
// --- Click delete on a preview that is now too old -------------------------
// The button carries hx-confirm, which is a native confirm() dialog. Without a
// handler Playwright dismisses it and the request is never sent, which would
// look like a passing refusal while proving nothing.
let dialogSeen = false;
page.on('dialog', async (d) => {
dialogSeen = true;
await d.accept();
});
await page.locator('button:has-text("Delete")').first().click();
await page.waitForTimeout(1000);
r.record('hx-confirm dialog was raised and accepted', dialogSeen);
// --- The refusal has to become visible -------------------------------------
let refusal = '';
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
const text = await results.innerText().catch(() => '');
if (/preview is .* old \(limit/.test(text)) { refusal = text; break; }
if (text.includes('Deleted') || text.includes('Records Deleted')) { refusal = text; break; }
await page.waitForTimeout(2000);
}
console.log('\n#gc-results after the click:\n' + refusal.slice(0, 600));
r.record('stale preview is refused, visibly',
/preview is .* old \(limit .*\)/.test(refusal),
refusal ? refusal.split('\n')[0] : 'nothing rendered');
r.record('refusal names the remedy',
refusal.includes('run Scan again'),
'the message should tell the operator what to do next');
// --- And nothing was actually deleted --------------------------------------
await page.locator('button:has-text("Scan for Orphans")').first().click();
const after = await waitForPreview();
const afterCount = after ? orphanCount(after) : null;
r.record('no records were deleted by the refused click',
afterCount === before,
`${before} before, ${afterCount} after`);
const failed = r.summarize('batch 07 — stale-preview refusal');
await ctx.close();
process.exit(failed ? 1 : 0);