mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 09:14:16 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4c04983e23
commit
5aefa85048
Generated
+67
-2
@@ -9,15 +9,16 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"htmx-ext-json-enc": "^2.0.3",
|
||||
"htmx.org": "^2.0.8",
|
||||
"lucide": "^1.8.0"
|
||||
"htmx.org": "^2.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/cli": "^4.2.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"daisyui": "^5.5.19",
|
||||
"esbuild": "^0.28.0",
|
||||
"glob": "^13.0.6",
|
||||
"lucide": "^1.8.0",
|
||||
"tailwindcss": "^4.2.2"
|
||||
}
|
||||
},
|
||||
@@ -822,6 +823,22 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/cli": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.2.tgz",
|
||||
@@ -1223,6 +1240,21 @@
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
@@ -1570,6 +1602,7 @@
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide/-/lucide-1.8.0.tgz",
|
||||
"integrity": "sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
@@ -1662,6 +1695,38 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"watch": "npm run css:watch & npm run js:watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/cli": "^4.2.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"daisyui": "^5.5.19",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Browser-driven batch validation
|
||||
|
||||
Checks for the `val/*` validation stack — the branch-per-batch series used to
|
||||
sign off the range between the deployed commit and `main`.
|
||||
|
||||
These complement, and do not replace, `go test` and the in-process integration
|
||||
harness (`internal/testharness`, `test/integration`, `make integration-test`).
|
||||
Pick by what needs proving:
|
||||
|
||||
| Prove | Use |
|
||||
|---|---|
|
||||
| A function's logic, a query's shape, a guard's behaviour | Go unit test |
|
||||
| A push/pull/delete round trip across appview + hold + S3 | `make integration-test` |
|
||||
| A fragment renders into the right target, a job outlives its request, a 500 dressed as an empty state | these scripts |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
npm i -D @playwright/test && npx playwright install chromium
|
||||
|
||||
node test/e2e/login.mjs # interactive, once per hold rebuild
|
||||
node test/e2e/batch00-admin-jobs.mjs # then the batch checks
|
||||
```
|
||||
|
||||
Env overrides: `ATCR_HOLD_URL`, `ATCR_APPVIEW_URL`, `ATCR_E2E_PROFILE`,
|
||||
`ATCR_E2E_SEED`.
|
||||
|
||||
## Things that will cost you an afternoon
|
||||
|
||||
**Use `127.0.0.1`, never `localhost`.** The appview canonicalises to
|
||||
`http://127.0.0.1:5000` and answers `localhost` with a 307. Any snippet written
|
||||
against `localhost:5000` measures the redirect, not the endpoint.
|
||||
|
||||
**Admin sessions are in-memory and die on every hold rebuild.** They live in
|
||||
`ui.sessions` (`pkg/hold/admin/admin.go`), not the `admin_sessions` table, which
|
||||
is vestigial for this path. Air rebuilds the hold whenever tracked source
|
||||
changes — including a batch checkout — so budget one interactive login per
|
||||
switch. There is no test-mode bypass; `server.test_mode` only affects OAuth
|
||||
redirect URLs.
|
||||
|
||||
**Never drive the admin panel with curl.** Sessions are pinned to User-Agent and
|
||||
client IP prefix, and a mismatch does not merely reject the request — it calls
|
||||
`deleteSession` and logs you out. Drive everything through `ctx.request`, which
|
||||
inherits the browser's cookie jar and UA.
|
||||
|
||||
**Closing every Playwright page disposes `ctx.request`.** It fails with "Request
|
||||
context disposed". Keep one `about:blank` page open when the test needs the
|
||||
browser out of the way.
|
||||
|
||||
**Crew delete is a `<button hx-post>`, not a `<form>`.** Scraping for forms
|
||||
finds nothing, deletes nothing, and cheerfully reports a clean tab while every
|
||||
seeded member is still live. Assert against page text after a reload, not
|
||||
against the scrape that just ran.
|
||||
|
||||
**Crew rows hydrate per-row via `hx-trigger="load"`.** The tab needs a real
|
||||
settle window (~6s here) before anything is scrapeable.
|
||||
|
||||
**A seeded fixture makes the second run lie.** Crew import skips DIDs that
|
||||
already exist, so a re-run finishes instantly and the detachment check silently
|
||||
passes without ever exercising a running job. Purge before re-running.
|
||||
|
||||
**Preconditions are easy to miss.** The tier reconciliation card only renders
|
||||
for crew on a tier absent from quota config (`handleCrewList`), so it is invisible
|
||||
on a healthy hold. Crew add/update do not validate the tier against config,
|
||||
which is how these tests manufacture the condition without restarting the hold.
|
||||
|
||||
## Per-batch stack switching
|
||||
|
||||
Use `val-switch.sh`. The appview DB migrates **forward only**, so older batch
|
||||
code hits a schema from the future: batch 00 selects `tags.id` (dropped by
|
||||
0032) and cannot write `manifests.manifest_key` (added NOT NULL by 0033/0034,
|
||||
which kills every backfill insert). The script destroys and re-migrates the
|
||||
appview volume so the DB matches the branch.
|
||||
|
||||
It deliberately does **not** touch `atcrio_atcr-hold`, which holds the hold's
|
||||
did:web signing key and the CAR store — captain, crew, layer, stats and scan
|
||||
records. Losing it means a new hold identity and every pushed layer gone.
|
||||
|
||||
`docker-compose.yml` is pinned to `main` throughout. `a7c7db6` (batch 01) is
|
||||
what makes the appview share the hold's netns so `did:web:localhost%3A8080`
|
||||
resolves, and every compose-based batch needs it — including batch 00, which
|
||||
lands before it. The file is dev-only, so pinning it is a fixture decision
|
||||
rather than a change to what is under validation. Never commit it from a batch
|
||||
branch; the script unstages it for you, because `git checkout main -- <path>`
|
||||
stages what it restores.
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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);
|
||||
@@ -0,0 +1,27 @@
|
||||
// cleanup-seeded-crew.mjs — remove crew members left behind by a batch check
|
||||
// that crashed before its finally block ran.
|
||||
//
|
||||
// Worth knowing: a re-run of the seeding test will NOT recreate the condition
|
||||
// it needs, because the import loop reports "already exists" and skips, which
|
||||
// finishes instantly and quietly turns the detachment check into a no-op.
|
||||
// Purge first, then re-run.
|
||||
//
|
||||
// node test/e2e/cleanup-seeded-crew.mjs [marker] # marker defaults to val00
|
||||
import { open, adminPage, csrfOf, openCrew, purgeSeeded, BASE } from './lib.mjs';
|
||||
|
||||
const MARK = process.argv[2] ?? 'val00';
|
||||
const ctx = await open();
|
||||
const page = await adminPage(ctx);
|
||||
const csrf = await csrfOf(page);
|
||||
|
||||
const [deleted, found] = await purgeSeeded(ctx, page, MARK, csrf);
|
||||
console.log(`found ${found} matching "${MARK}", deleted ${deleted}`);
|
||||
|
||||
await openCrew(page);
|
||||
const left = await page.evaluate(
|
||||
(m) => (document.body.textContent.match(new RegExp(m, 'g')) || []).length, MARK);
|
||||
console.log(`remaining references: ${left}`);
|
||||
console.log(`hold: ${BASE}`);
|
||||
|
||||
await ctx.close();
|
||||
process.exit(left === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// login.mjs — open a headed chromium on the hold admin panel and wait for a
|
||||
// human to finish the ATProto OAuth login.
|
||||
//
|
||||
// This is unavoidably interactive. Admin sessions are pinned to User-Agent and
|
||||
// IP prefix, so a session established anywhere else cannot be transplanted in;
|
||||
// the login has to happen inside this exact browser profile. The profile is
|
||||
// reused by every other script here.
|
||||
//
|
||||
// Re-run after any hold rebuild — sessions are in-memory and do not survive one.
|
||||
//
|
||||
// node test/e2e/login.mjs
|
||||
import { open, BASE, PROFILE } from './lib.mjs';
|
||||
|
||||
const ctx = await open();
|
||||
const page = ctx.pages()[0] ?? (await ctx.newPage());
|
||||
await page.goto(`${BASE}/admin`, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
console.log(`Opened ${BASE}/admin (profile: ${PROFILE})`);
|
||||
console.log('Complete the ATProto OAuth login as the hold owner if prompted.');
|
||||
console.log('Waiting up to 10 minutes...');
|
||||
|
||||
const deadline = Date.now() + 10 * 60 * 1000;
|
||||
let ok = false;
|
||||
while (Date.now() < deadline) {
|
||||
const url = page.url();
|
||||
if (url.includes('/admin') && !url.includes('/admin/auth/login')) {
|
||||
if ((await page.locator('a[href*="/admin"]').count()) > 0) { ok = true; break; }
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
console.error('FAILED: no authenticated admin page within the timeout.');
|
||||
await ctx.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Authenticated:', page.url());
|
||||
console.log('User-Agent:', await page.evaluate(() => navigator.userAgent));
|
||||
console.log('\nSession is live. Run the batch checks now, e.g.:');
|
||||
console.log(' node test/e2e/batch00-admin-jobs.mjs');
|
||||
await ctx.close();
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# val-switch.sh <branch> — move the dev stack to a validation branch.
|
||||
#
|
||||
# Why each step exists (learned the hard way on batch 00):
|
||||
#
|
||||
# * The appview DB is migrated FORWARD ONLY. The live volume sits at whatever
|
||||
# migration main last applied (34 today). Older batch code then hits columns
|
||||
# that no longer exist (batch 00 selects tags.id, dropped by 0032) and
|
||||
# columns it never learned to write (manifests.manifest_key NOT NULL, added
|
||||
# by 0033/0034 — backfill dies on every insert). So the appview volume gets
|
||||
# destroyed on every switch and re-migrated from scratch to the branch level.
|
||||
#
|
||||
# * The HOLD volume must survive. It holds the hold's did:web signing key and
|
||||
# the CAR store (captain, crew, layer, stats, scan records). Nuking it gives
|
||||
# the hold a new identity and drops every pushed layer.
|
||||
#
|
||||
# * docker-compose.yml is pinned to main. a7c7db6 (batch 01) is what makes the
|
||||
# appview share the hold's netns so did:web:localhost%3A8080 resolves; every
|
||||
# compose-based batch needs it, including batch 00, which lands before it.
|
||||
# The file is dev-only (nothing in deploy/ or CI reads it), so pinning it is
|
||||
# a test-fixture decision, not a change to what is being validated.
|
||||
# NEVER `git add docker-compose.yml` while validating.
|
||||
#
|
||||
# Usage: ./val-switch.sh val/00-mixed
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="${1:?usage: val-switch.sh <branch>}"
|
||||
REPO=/home/data/atcr.io
|
||||
PROJECT=atcrio
|
||||
UI_VOLUME=${PROJECT}_atcr-ui
|
||||
|
||||
cd "$REPO"
|
||||
|
||||
echo "==> restoring pinned dev compose before checkout"
|
||||
git checkout -- docker-compose.yml 2>/dev/null || true
|
||||
|
||||
echo "==> checking out ${BRANCH}"
|
||||
git checkout "$BRANCH"
|
||||
|
||||
echo "==> pinning docker-compose.yml to main (dev topology only, do not commit)"
|
||||
git checkout main -- docker-compose.yml
|
||||
# `git checkout <ref> -- <path>` STAGES the file. Left staged, the next commit
|
||||
# on the batch branch silently swallows main's compose file. Unstage it so it
|
||||
# shows as an ordinary working-tree modification.
|
||||
git restore --staged docker-compose.yml
|
||||
|
||||
echo "==> tearing down appview + its DB volume"
|
||||
docker compose rm -sf atcr-appview
|
||||
docker volume rm "$UI_VOLUME" 2>/dev/null || echo " (volume already gone)"
|
||||
|
||||
echo "==> starting appview on a fresh DB"
|
||||
docker compose up -d atcr-appview
|
||||
|
||||
echo "==> waiting for appview to answer on 127.0.0.1:5000"
|
||||
for i in $(seq 1 90); do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5000/v2/ || true)
|
||||
if [ "$code" != "000" ]; then
|
||||
echo " up after ${i}s (HTTP ${code})"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "==> migration level on the fresh DB"
|
||||
docker exec atcr-appview sh -c \
|
||||
'sqlite3 /var/lib/atcr/ui.db "select max(version) from schema_migrations"' 2>/dev/null \
|
||||
|| echo " (db not created yet — check: docker logs atcr-appview)"
|
||||
|
||||
echo
|
||||
echo "branch: $(git log --oneline -1)"
|
||||
echo "compose: pinned to main (expect it to show as modified; leave it unstaged)"
|
||||
echo "appview: http://127.0.0.1:5000 (NOT localhost — that 307s)"
|
||||
echo "hold admin: http://127.0.0.1:8080/admin (OAuth as did:plc:pddp4xt5lgnv2qsegbzzs4xg / evan.jarrett.net)"
|
||||
Reference in New Issue
Block a user