diff --git a/e2e/ARCHITECTURE.md b/e2e/ARCHITECTURE.md index 218f6d04..d7549035 100644 --- a/e2e/ARCHITECTURE.md +++ b/e2e/ARCHITECTURE.md @@ -389,6 +389,18 @@ and refreshed on the way up — zooid saves a relay's toml back when a NIP-86 ca every test starts against the same relays with nothing in them. The container is a worker fixture, so the docker start-up cost is paid once per worker rather than once per test. +It is recreated in the teardown of the test that finishes, not the setup of the one that starts. +Creating a container tears down a veth pair and builds another, and the bridge behind it loses +carrier with them; chromium watches the host's interfaces and aborts everything it has in flight +when they move, which the app meets as a route chunk that failed to import and, with `ssr = false`, +a 500 page. +Recreating from teardown puts the next test's seeding — an authenticated socket per identity, and +every fixture written over it — between the churn and the first page that test opens. That is not +on its own enough: every netlink event lands before `compose up --wait` returns, but chromium +coalesces interface changes for up to two seconds before acting on one. So a recreate also stamps +the moment it settles, and a page waits out whatever is left of that window when it opens — which +for most tests is nothing, the teardown and the seeding having spent it already. + Seeding then writes over a real authenticated socket, one per identity per relay, held open for the rest of the test. A fixture must therefore be signed by an identity `harness/keys.ts` holds, since zooid authenticates every write. diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts index 2f820363..35a41b74 100644 --- a/e2e/harness/index.ts +++ b/e2e/harness/index.ts @@ -172,8 +172,8 @@ export const test = base.extend({ "them. Anything the app fetches belongs in a mock installed by `as(user, path)`.", ) }, - // One container per worker, torn down when the worker ends. It is reset between tests rather - // than recreated, so the docker start-up cost is paid once. + // One container per worker, torn down when the worker ends. It is recreated between tests, in + // the teardown of the test that finishes rather than the setup of the one that starts. zooid: [ // playwright reads a fixture's dependencies off this pattern, so it has to stay a pattern // even when there are none. @@ -192,7 +192,7 @@ export const test = base.extend({ testInfo.skip(Boolean(dockerProblem), dockerProblem) await zooid.start() - await zooid.reset() + await zooid.ensure() const contexts: BrowserContext[] = [] const faults = watchFaults() @@ -207,6 +207,8 @@ export const test = base.extend({ const open = async (path: string, options: PageOptions, user?: TestUser) => { const {urls, indexerUrls, cache} = requireScenario() + + await zooid.settle() // The project's own `use` first, so a viewport or device descriptor set in // playwright.config.ts reaches the context rather than being dropped. // @@ -291,6 +293,11 @@ export const test = base.extend({ await context.close() } + // Before the assertions below, which throw: a test that fails still owes the next one a + // container, and with every page already closed there is nothing left for the recreate's + // network churn to interrupt. + await zooid.reset() + if (faults.found.length > 0 || testInfo.status !== testInfo.expectedStatus) { // A path rather than a body: the list reporter truncates an inline attachment, and the // nightly run on the box keeps test-results and nothing else. diff --git a/e2e/harness/zooid/relay.ts b/e2e/harness/zooid/relay.ts index 8151ee59..b350335c 100644 --- a/e2e/harness/zooid/relay.ts +++ b/e2e/harness/zooid/relay.ts @@ -22,6 +22,12 @@ import type {ZooidConnection} from "./transport" const image = "gitea.coracle.social/coracle/zooid:latest" +// How long after a recreate chromium can still abort what a page has in flight. Every netlink +// event a create-and-destroy produces lands before `compose up --wait` returns — measured at 26 of +// them, all of them within a second, none after — but chromium coalesces interface changes for up +// to two seconds before acting on one, so the abort arrives well after the harness has moved on. +const SETTLE = 2500 + const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url)) const configSource = fileURLToPath(new URL("docker/config", import.meta.url)) @@ -167,6 +173,12 @@ export class Zooid { private started = false + // Whether a container is up. `reset` is what creates one, so this stays false until it has run. + private running = false + + // When the last recreate's interface churn stops being able to abort a request. + private settledAt = 0 + // Verifies docker rather than bringing the container up, which `reset` does. Repeat calls are // free, so the fixture can call this per test. start = async () => { @@ -226,10 +238,36 @@ export class Zooid { } } + // Leaves a fresh container for whoever runs next. Recreating one churns the host's network + // interfaces — a veth pair torn down and rebuilt, the project's bridge losing carrier with it — + // and chromium aborts every request it has in flight when it acts on that, which reaches the app + // as a route chunk that failed to import and, with `ssr = false`, a 500 page. So this is called + // from teardown rather than setup, and everything after it counts towards `settle` below. reset = async () => { this.closeSessions() await this.up() + + this.running = true + this.settledAt = Date.now() + SETTLE + } + + // The container the test about to run will use. Only the first test of a worker finds none. + ensure = async () => { + if (!this.running) { + await this.reset() + } + } + + // Waited out before a page is opened, which is as late as it can be left: teardown, the next + // test's fixtures and its seeding are all inside the window already, so most tests pay nothing + // here. + settle = async () => { + const remaining = this.settledAt - Date.now() + + if (remaining > 0) { + await sleep(remaining) + } } stop = async () => { @@ -239,6 +277,7 @@ export class Zooid { await compose("down") this.started = false + this.running = false } }