Files
at-container-registry/test/e2e/batch00-admin-jobs.mjs
T
Evan JarrettandClaude Opus 5 5aefa85048 test/e2e: cover the admin job wiring ab4a4eb changed
jobs_test.go covers the job framework thoroughly, but nothing covers the
wiring: whether the kickoff handler renders the progress fragment into the
right hx-target, and whether the loop actually outlives the request it was
started from. Both are what ab4a4eb changed, and both are invisible to Go
tests — a typo in an hx-target or a fragment that renders blank passes every
assertion we have.

The load-bearing check drives crew import rather than the tier remap. A
one-member remap completes in under a second, so closing the tab "mid-run"
proves nothing; import does a PDS write plus a network PLC lookup per entry,
which leaves a real window to close the browser and watch the job keep going.
It is caught mid-flight at a progress tick with no admin page open.

Seeded members are created on the local-only dev hold and removed in a
finally block. README records the environment traps found while building
this: 127.0.0.1 vs localhost, in-memory sessions dying on every hold rebuild,
UA/IP pinning that makes curl log you out, and the forward-only appview
migrations that require a per-batch DB reset.

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

110 lines
5.1 KiB
JavaScript

// batch00-admin-jobs.mjs — validates ab4a4eb (val/00-mixed): the three admin
// loops moved off the request context into detached background jobs.
//
// pkg/hold/admin/jobs_test.go already covers the job framework well. What has
// no coverage is the wiring: whether the kickoff handler renders the progress
// fragment into the right hx-target, and whether the work actually outlives the
// request. Both are invisible to Go tests and both are what ab4a4eb changed.
//
// The load-bearing check is "job seen still running with the admin tab closed".
// A one-member remap finishes in under a second, so closing the tab after it
// proves nothing — that is why this drives crew import with N entries instead,
// which does a PDS write plus a network PLC lookup per entry.
//
// Seeded members use did:plc:val00…, are created on a local-only hold
// (did:web:localhost%3A8080, in no relay), and are deleted in a finally block.
//
// node test/e2e/login.mjs && node test/e2e/batch00-admin-jobs.mjs
import { open, adminPage, csrfOf, openCrew, purgeSeeded, reporter, BASE } from './lib.mjs';
const N = Number(process.env.ATCR_E2E_SEED ?? 40);
const MARK = 'val00';
const seedDID = (i) => `did:plc:${(MARK + String(i).padStart(3, '0')).padEnd(24, 'x')}`;
const { record, summarize } = reporter();
const ctx = await open();
let page = await adminPage(ctx);
const csrf = await csrfOf(page);
try {
const [pre, preFound] = await purgeSeeded(ctx, page, MARK, csrf);
if (preFound) console.log(`(pre-clean: removed ${pre}/${preFound} leftover seeded members)`);
const file = JSON.stringify({
version: 1,
exportedAt: new Date().toISOString(),
holdDID: 'did:web:localhost%3A8080',
crew: Array.from({ length: N }, (_, i) => ({
did: seedDID(i), role: 'member', permissions: ['blob:read'], tier: 'deckhand',
})),
});
const t0 = Date.now();
const res = await ctx.request.post(`${BASE}/admin/crew/import`, {
multipart: {
csrf_token: csrf,
crew_file: { name: 'crew.json', mimeType: 'application/json', buffer: Buffer.from(file) },
},
});
const kickoff = (await res.text()).replace(/\s+/g, ' ');
record('import kickoff returns a 200 fragment immediately', res.status() === 200,
`HTTP ${res.status()} in ${Date.now() - t0}ms for ${N} entries`);
record('kickoff renders job_progress (a poller), not a finished result',
/hx-get[^>]*jobs\/crew-import\/status/i.test(kickoff), kickoff.slice(0, 100));
// Keep one blank page: closing EVERY page disposes ctx.request with
// "Request context disposed". The kickoff request has already returned, so
// this is still a fair test of the job outliving it.
const blank = await ctx.newPage();
await blank.goto('about:blank');
for (const p of ctx.pages()) if (p !== blank) await p.close();
record('admin tab closed while the job runs', ctx.pages().length === 1,
`${ctx.pages().length} page open (about:blank)`);
let running = false, done = false, body = '';
const ticks = new Set();
for (let i = 0; i < 180; i++) {
const s = await ctx.request.get(`${BASE}/admin/api/jobs/crew-import/status`);
if (s.status() !== 200) { body = `HTTP ${s.status()}`; break; }
body = (await s.text()).replace(/\s+/g, ' ');
const m = body.match(/(\d+)\s*(?:\/|of)\s*(\d+)/);
if (m) { ticks.add(m[1]); if (Number(m[1]) < Number(m[2])) running = true; }
if (!/hx-get[^>]*jobs\/crew-import\/status/i.test(body)) { done = true; break; }
await new Promise((r) => setTimeout(r, 1000));
}
record('job seen still running with the admin tab closed', running,
running ? `progress ticks: ${[...ticks].slice(0, 8).join(',')}` : 'never caught it mid-run');
record('job ran to completion with no admin page open', done,
`${((Date.now() - t0) / 1000).toFixed(1)}s — ${body.slice(0, 120)}`);
page = await ctx.newPage();
await openCrew(page);
const added = await page.evaluate(
(m) => (document.body.textContent.match(new RegExp(m, 'g')) || []).length, MARK);
record('imported members appear in the crew tab', added > 0, `${added} references`);
// Early no-op path renders job_result, not a poller.
const noop = await ctx.request.post(`${BASE}/admin/crew/remap-tier`, {
form: { csrf_token: csrf, from: 'tier-that-never-existed', to: 'deckhand' },
});
const noopBody = (await noop.text()).replace(/\s+/g, ' ');
record('unknown source tier returns the no-op result, not a job',
/No crew were on tier/i.test(noopBody), noopBody.slice(0, 110));
} finally {
const p = ctx.pages()[0] ?? (await ctx.newPage());
const [deleted, found] = await purgeSeeded(ctx, p, MARK, csrf);
record('cleanup: seeded members removed', found === 0 || deleted === found, `${deleted}/${found} deleted`);
// Re-open before counting: purgeSeeded's DOM predates the deletes it issued,
// so asserting on the stale page reports success either way.
await openCrew(p);
const left = await p.evaluate(
(m) => (document.body.textContent.match(new RegExp(m, 'g')) || []).length, MARK);
record('cleanup: no seeded members left in crew tab', left === 0, `${left} references`);
}
const failures = summarize('batch 00 — admin background jobs');
await ctx.close();
process.exit(failures ? 1 : 0);