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:
Evan Jarrett
2026-08-25 16:34:25 -05:00
co-authored by Claude Opus 5
parent 4c04983e23
commit 5aefa85048
8 changed files with 499 additions and 2 deletions
+67 -2
View File
@@ -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",
+1
View File
@@ -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",
+85
View File
@@ -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.
+109
View File
@@ -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);
+27
View File
@@ -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);
+95
View File
@@ -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;
},
};
}
+42
View File
@@ -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();
+73
View File
@@ -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)"