Files
at-container-registry/test/e2e/lib.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

96 lines
3.8 KiB
JavaScript

// Shared helpers for the browser-driven validation checks.
//
// These exist because a class of admin-panel bug is structurally invisible to
// Go tests: a typo in an hx-target, a fragment that renders as a blank panel, a
// 500 dressed up as an empty state. The Go tests cover the job framework
// (pkg/hold/admin/jobs_test.go); these cover the wiring.
//
// Session model (pkg/hold/admin/auth.go), which dictates the whole design:
// * Sessions live in an in-memory map, so ANY hold rebuild invalidates them.
// Air rebuilds on checkout, so expect to re-run login.mjs after a switch.
// * Sessions are pinned to User-Agent and client IP prefix. A cookie replayed
// from curl or another browser is not merely rejected — it DELETES the
// session server-side. Always drive through ctx.request, which inherits the
// browser's cookie jar and UA.
import { chromium } from '@playwright/test';
export const BASE = process.env.ATCR_HOLD_URL ?? 'http://127.0.0.1:8080';
export const APPVIEW = process.env.ATCR_APPVIEW_URL ?? 'http://127.0.0.1:5000';
export const PROFILE = process.env.ATCR_E2E_PROFILE ?? '/tmp/atcr-e2e-profile';
export async function open({ headless = false } = {}) {
return chromium.launchPersistentContext(PROFILE, {
headless,
viewport: null,
args: ['--window-size=1400,1000'],
});
}
// Returns a page on an authenticated /admin, or exits 2 if the session is gone.
export async function adminPage(ctx) {
const page = ctx.pages()[0] ?? (await ctx.newPage());
await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' });
if (page.url().includes('/auth/login')) {
console.error('SESSION DEAD — the hold rebuilt (sessions are in-memory).');
console.error('Re-run: node test/e2e/login.mjs');
await ctx.close();
process.exit(2);
}
return page;
}
export const csrfOf = (page) =>
page.locator('input[name="csrf_token"]').first().getAttribute('value');
// Crew rows hydrate individually via hx-trigger="load", so the tab needs a real
// settle window before anything is scrapeable.
export async function openCrew(page, settleMs = 6000) {
await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' });
await page.click('a[href="/admin#crew"]');
await page.waitForTimeout(settleMs);
}
// Crew delete is a <button hx-post="/admin/crew/{rkey}/delete">, NOT a <form>.
// Scraping for forms finds nothing and reports a clean tab while every seeded
// member is still live.
export async function seededDeleteUrls(page, mark) {
return page.evaluate((m) => {
const out = [];
for (const b of document.querySelectorAll('[hx-post*="/delete"]')) {
const row = b.closest('tr') ?? b.closest('[id^="crew-"]') ?? b.parentElement;
const label = (b.getAttribute('aria-label') || '') + ' ' + (row ? row.textContent : '');
if (label.includes(m)) out.push(b.getAttribute('hx-post'));
}
return out;
}, mark);
}
export async function purgeSeeded(ctx, page, mark, csrf) {
await openCrew(page);
const doomed = await seededDeleteUrls(page, mark);
let deleted = 0;
for (const url of doomed) {
const res = await ctx.request.post(`${BASE}${url}`, { form: { csrf_token: csrf } });
if (res.status() < 400) deleted++;
}
return [deleted, doomed.length];
}
export function reporter() {
const results = [];
return {
results,
record(name, pass, detail) {
results.push({ name, pass, detail });
console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
},
summarize(title) {
const failed = results.filter((r) => !r.pass);
console.log(`\n===== ${title} =====`);
console.log(`${results.length - failed.length}/${results.length} passed`);
failed.forEach((f) => console.log(` FAILED: ${f.name}${f.detail}`));
return failed.length;
},
};
}