mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
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>
96 lines
3.8 KiB
JavaScript
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;
|
|
},
|
|
};
|
|
}
|