diff --git a/e2e/README.md b/e2e/README.md index 8d5f79459..79cb33bf8 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -20,11 +20,15 @@ or lower might be useful for less RAM / CPU (it uses a ton of cpu in a vm like c the html report is drawn with the web's own components, so it needs `pnpm install` in `web/` (or `--web-modules=`). -the web runs as a prod build (`localinfra/docker-compose.web-build.yml`), made when the web -container starts. `pnpm e2e` and `pnpm e2e:up` rebuild it by themselves when anything under -`web/` changed. +the web runs as a prod build (`localinfra/docker-compose.web-build.yml` for compose, the web +service's `setup` for pc), made when the web starts. `pnpm e2e` and `pnpm e2e:up` rebuild it by +themselves when anything under `web/` changed. you need the localinfra stack, its dev ca in `localinfra/certs`, and `pnpm install` here. +the stack is either compose or the container-free one in `localinfra/pc`. `E2E_STACK=compose` or +`E2E_STACK=pc` picks, otherwise it is whichever is running, otherwise compose when docker or podman +is installed. for pc run `pnpm e2e:up` (the nix app brings go and cargo), it +starts the stack in the background, and `localinfra/pc/bin/pc down` stops it. service urls come from `E2E_*_URL` (see `harness/config.ts`) and default to the localinfra hostnames. making accounts needs the pds admin password, read from `localinfra/pds.env` or `E2E_PDS_ADMIN_PASSWORD`. @@ -38,7 +42,7 @@ add in case a crash or something occurs. ``` run checks the stack, rebuilds the web if it changed, starts playwright -up brings the stack up +up brings the stack up, compose or pc harness/ flow.ts what a spec is written with, flow("...").open(...).click(...) vocabulary.ts the things a spec points at, like buttons, pages and records diff --git a/e2e/harness/cobgit.ts b/e2e/harness/cobgit.ts index 16aff8473..f82ae4ddb 100644 --- a/e2e/harness/cobgit.ts +++ b/e2e/harness/cobgit.ts @@ -20,7 +20,12 @@ const installed = (program: string) => () => false ); -const findKnot = async () => { +type Knot = { scan: string } | { engine: string; container: string; scan: string }; + +const findKnot = async (): Promise => { + // outside a container the knot's repos sit on this disk, so E2E_KNOT_SCAN points straight at them + const own = process.env.E2E_KNOT_SCAN; + if (own) return { scan: own }; const engine = process.env.E2E_ENGINE ?? ((await installed("podman")) ? "podman" : "docker"); const root = await realpath(".."); const listed = await exec(engine, [ @@ -67,9 +72,9 @@ const LOOP = `check() { echo "change $change $type $author $(git ls-tree --name-only "$change" | tr '\\n' ,)" done if git cat-file -e "$tip:record" 2>/dev/null; then - echo "record $(git cat-file blob "$tip:record" | base64 -w0)" + echo "record $(git cat-file blob "$tip:record" | base64 | tr -d '\\n')" fi - echo "payload $(git cat-file blob "$3:payload" | base64 -w0)" + echo "payload $(git cat-file blob "$3:payload" | base64 | tr -d '\\n')" } while read -r repo nsid root; do (check "$repo" "$nsid" "$root") 2>&1 || echo "failed $?" @@ -83,28 +88,42 @@ interface Asked { fail: (cause: Error) => void; } +// the knot's repos are owned by another user, so git refuses them without safe.directory +const SAFE_DIRECTORY = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "safe.directory", + GIT_CONFIG_VALUE_0: "*" +}; + +const spawnGit = (box: Knot) => + "engine" in box + ? spawn( + box.engine, + [ + "run", + "-i", + "--rm", + "--network=none", + `--volumes-from=${box.container}:ro`, + ...Object.entries(SAFE_DIRECTORY).map( + ([key, value]) => `--env=${key}=${value}` + ), + "--entrypoint=sh", + GIT_IMAGE, + "-c", + LOOP + ], + { stdio: ["pipe", "pipe", "pipe"] } + ) + : spawn("sh", ["-c", LOOP], { + env: { ...process.env, ...SAFE_DIRECTORY }, + stdio: ["pipe", "pipe", "pipe"] + }); + const startHelper = async () => { const box = await findKnot(); - const child = spawn( - box.engine, - [ - "run", - "-i", - "--rm", - "--network=none", - `--volumes-from=${box.container}:ro`, - // git refuses repos owned by another user, the knot's are - "--env=GIT_CONFIG_COUNT=1", - "--env=GIT_CONFIG_KEY_0=safe.directory", - "--env=GIT_CONFIG_VALUE_0=*", - "--entrypoint=sh", - GIT_IMAGE, - "-c", - LOOP - ], - { stdio: ["pipe", "pipe", "pipe"] } - ); - // closing stdin ends it and --rm takes the container with it + const child = spawnGit(box); + // the loop only ends when stdin closes, and --rm cleans the container up then const answers = child.stdout as unknown as Socket; child.unref(); for (const pipe of [child.stdin, child.stdout, child.stderr]) @@ -121,8 +140,7 @@ const startHelper = async () => { asked.done(asked.lines); }); const ended = new Promise((resolve) => { - const gone = (why: string) => - resolve(new Error(`the git helper in ${GIT_IMAGE} ${why}: ${said.trim()}`)); + const gone = (why: string) => resolve(new Error(`the git helper ${why}: ${said.trim()}`)); child.on("error", (cause) => gone(cause.message)); child.on("exit", (code) => gone(`exited with ${code}`)); }); diff --git a/e2e/run b/e2e/run index 1b46eb439..e9f2cb8ae 100755 --- a/e2e/run +++ b/e2e/run @@ -27,6 +27,14 @@ while [ "$n" -gt 0 ]; do esac done +E2E_STACK="$("$here/up" --stack)" +export E2E_STACK +if [ "$E2E_STACK" = pc ]; then + root="$(cd "$here/.." && pwd)" + E2E_KNOT_SCAN="${E2E_KNOT_SCAN:-$(here="$root/localinfra/pc" && . "$here/bin/env.sh" && echo "$TNGL_STATE/data/knot2/repositories")}" + export E2E_KNOT_SCAN +fi + status=0 "$here/up" --check || status=$? case $status in diff --git a/e2e/up b/e2e/up index e782a04ee..ba3f669ec 100755 --- a/e2e/up +++ b/e2e/up @@ -1,14 +1,98 @@ #!/bin/sh # starts the localinfra stack the way e2e needs it, with the web as a production build. # --check says whether it is up and its web build is current: 0 yes, 1 not up, 2 web out of date +# a live pc stack wins over compose, so this check has to come first set -eu here="$(cd "$(dirname "$0")" && pwd)" cd "$here/.." -# the web container builds what is in web/ when it starts, so it is recreated when web/ changes E2E_WEB_SRC="$(node "$here/harness/web-hash.ts")" export E2E_WEB_SRC +pcdir="$PWD/localinfra/pc" +state="$(here="$pcdir" && . "$pcdir/bin/env.sh" && echo "$TNGL_STATE")" +sock="$state/run/pc.sock" +pc() { curl -sf --unix-socket "$sock" "$@"; } +pc_running() { pc http://localhost/live >/dev/null 2>&1; } + +stack="${E2E_STACK:-}" +if [ -z "$stack" ]; then + if pc_running; then + stack=pc + elif command -v podman >/dev/null 2>&1 || command -v docker >/dev/null 2>&1; then + stack=compose + else + stack=pc + fi +fi + +if [ "${1:-}" = "--stack" ]; then + echo "$stack" + exit +fi + +# only the browser knows these names, so curl needs --resolve to reach caddy +alice_resolves() { + curl -sf --cacert localinfra/certs/root.crt --resolve pds.tngl.boltless.dev:443:127.0.0.1 \ + "https://pds.tngl.boltless.dev/xrpc/com.atproto.identity.resolveHandle?handle=alice.pds.tngl.boltless.dev" >/dev/null +} + +if [ "$stack" = pc ]; then + # setup writes the hash of what it built next to a hash of the web's env + built="$(cut -d ' ' -f 1 "$state/data/web/built" 2>/dev/null || true)" + + if [ "${1:-}" = "--check" ]; then + pc_running || exit 1 + [ "$built" = "$E2E_WEB_SRC" ] || exit 2 + exit + fi + + if pc_running; then + if [ "$built" != "$E2E_WEB_SRC" ]; then + (here="$pcdir" && . "$pcdir/bin/env.sh" && sh "$pcdir/services/web/setup") + pc -X POST http://localhost/process/restart/web >/dev/null + fi + elif command -v nix >/dev/null 2>&1; then + nix run .#localinfra -- -t=false -D + else + "$pcdir/bin/up" -t=false -D + fi + + # every process is ready or has finished, and nothing has failed + node -e ' + const http = require("node:http"); + const list = () => + new Promise((resolve, reject) => + http + .get({ socketPath: process.argv[1], path: "/processes" }, (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => resolve(JSON.parse(body).data)); + }) + .on("error", reject) + ); + const failed = (p) => p.status === "Error" || (p.status === "Completed" && p.exit_code !== 0); + (async () => { + for (;;) { + const all = await list().catch(() => []); + const bad = all.find(failed); + if (bad) { + console.error(`${bad.name} failed, its log is in the stack`); + process.exit(1); + } + if (all.length && all.every((p) => p.status === "Completed" || p.is_ready === "Ready")) return; + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + })(); + ' "$sock" + + if ! alice_resolves; then + echo "alice doesn't resolve, the stack's init-data hasn't run" >&2 + exit 1 + fi + exit +fi + # the web is a production build here, so no page waits on a module compiling when it is first asked for web="-f localinfra/docker-compose.web-build.yml" if command -v podman >/dev/null 2>&1; then @@ -35,11 +119,10 @@ if [ ! -f localinfra/certs/root.crt ]; then exit 1 fi +# compose only recreates the web when this label value changes $compose up -d --wait -# the browser resolves the stack's names itself, node and curl need the host's caddy -if ! curl -sf --cacert localinfra/certs/root.crt --resolve pds.tngl.boltless.dev:443:127.0.0.1 \ - "https://pds.tngl.boltless.dev/xrpc/com.atproto.identity.resolveHandle?handle=alice.pds.tngl.boltless.dev" >/dev/null; then +if ! alice_resolves; then echo "alice doesn't resolve, the stack's init-data hasn't run" >&2 exit 1 fi