Skip to content

Commit 5697508

Browse files
radimclaude
andcommitted
test(snapshot-e2e): cross-version end-to-end harness
Docker-based black-box suite for `dryrun snapshot push/pull` against real Postgres, comparing local HEAD with the v0.6.1 release fetched via git tag at image build. 10 scenarios cover the happy paths (UC1/UC2/UC3, round-trip identity), v0.6.1 read compatibility, filesystem edge cases (corruption detection, read-only mount, stale .tmp), and concurrent writers (same-hash and different-hash races). Two run modes: - ./harness.sh keeps the stack up between invocations (~1.6 s warm). - ./run-native.sh skips the Docker runner image, building the local HEAD binary against a single pg-a (~1.8 s warm). Scenarios that need v0.6.1 self-tag with `# NATIVE: skip`. pg-a/b/c run on tmpfs; runner sleeps infinity so harness.sh can docker compose exec into it. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7c7f534 commit 5697508

19 files changed

Lines changed: 766 additions & 0 deletions

tests/snapshot-e2e/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
shared/
2+
workstations/
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Two-stage build producing one image with both v0.6.1 and HEAD binaries.
2+
# Build context = repo root.
3+
4+
FROM rust:1.88-bookworm AS old
5+
WORKDIR /build
6+
RUN apt-get update && apt-get install -y --no-install-recommends \
7+
git pkg-config libssl-dev clang libclang-dev \
8+
&& rm -rf /var/lib/apt/lists/*
9+
RUN git clone --depth 1 --branch v0.6.1 https://github.com/boringSQL/dryrun.git src
10+
WORKDIR /build/src
11+
RUN cargo build --release --bin dryrun
12+
RUN cp target/release/dryrun /dryrun-old
13+
14+
FROM rust:1.88-bookworm AS new
15+
WORKDIR /build
16+
RUN apt-get update && apt-get install -y --no-install-recommends \
17+
pkg-config libssl-dev clang libclang-dev \
18+
&& rm -rf /var/lib/apt/lists/*
19+
COPY . .
20+
RUN cargo build --release --bin dryrun
21+
RUN cp target/release/dryrun /dryrun-new
22+
23+
FROM debian:bookworm-slim
24+
RUN apt-get update && apt-get install -y --no-install-recommends \
25+
ca-certificates jq postgresql-client bash coreutils && rm -rf /var/lib/apt/lists/*
26+
COPY --from=old /dryrun-old /usr/local/bin/dryrun-old
27+
COPY --from=new /dryrun-new /usr/local/bin/dryrun-new
28+
RUN ln -s /usr/local/bin/dryrun-new /usr/local/bin/dryrun
29+
WORKDIR /work
30+
CMD ["bash"]

tests/snapshot-e2e/README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# snapshot-e2e
2+
3+
End-to-end black-box tests for the shared-filesystem snapshot store
4+
(`dryrun snapshot push --to-path` / `pull --from-path`), including
5+
cross-version compatibility against the **v0.6.1** baseline.
6+
7+
The Rust unit tests in `crates/dry_run_core/src/history/filesystem_store.rs`
8+
cover internal correctness. This suite covers the things only a real
9+
multi-process / multi-binary / real-Postgres setup can: cross-version
10+
compat, concurrent writers, filesystem corruption, multi-database flows.
11+
12+
## Running
13+
14+
```sh
15+
./harness.sh # all scenarios, full Docker (HEAD + v0.6.1)
16+
./harness.sh 's04*.sh' # filter scenarios by glob
17+
./harness.sh -- bash # drop into the runner container
18+
./harness.sh down # stop the stack
19+
./harness.sh rebuild # rebuild after CLI code changes
20+
21+
./run-native.sh # HEAD only, host cargo build, single pg-a
22+
./run-native.sh 's01*.sh' # filter
23+
```
24+
25+
`harness.sh` keeps the runner container alive between invocations
26+
(`up -d` + `exec`), so warm runs land in **~1.5–2 s**. `run-native.sh`
27+
skips Docker for the dryrun binary entirely — best for iterating while
28+
authoring new scenarios.
29+
30+
Scenarios that need the v0.6.1 binary tag themselves
31+
`# NATIVE: skip`; `run-native.sh` honors that.
32+
33+
## Layout
34+
35+
```
36+
snapshot-e2e/
37+
├── docker-compose.yml # pg-a, pg-b, pg-c (tmpfs), persistent runner
38+
├── Dockerfile.dryrun # multi-stage: dryrun-old (v0.6.1) + dryrun-new (HEAD)
39+
├── harness.sh # full-Docker entrypoint
40+
├── run-native.sh # native fast-feedback entrypoint
41+
├── run.sh # invoked inside the runner; aggregates scenarios
42+
├── lib.sh # shared helpers: scenario / reset_* / ws_run / assert_*
43+
├── fixtures/schemas/*.sql # seed SQL
44+
├── scenarios/sNN_*.sh # one bash script per scenario
45+
├── shared/ # bind-mounted "team filesystem" (gitignored)
46+
└── workstations/{devA,devB}/ # per-workstation HOMEs (gitignored)
47+
```
48+
49+
## Adding a scenario
50+
51+
Each scenario is ~30 lines of bash. Copy an existing one in `scenarios/`
52+
and tweak. Helpers from `lib.sh`:
53+
54+
| Helper | What it does |
55+
| ------------------------------- | ------------------------------------------------------------------------ |
56+
| `scenario "TITLE"` | Names the scenario; `ok` / `fail` print TAP-ish output. |
57+
| `reset_shared` | Wipes the shared dir. |
58+
| `reset_workstation devA` | Clears `workstations/devA/` and writes a fresh `dryrun.toml`. |
59+
| `reset_db / seed_db <url> <sql>`| Drops `public` and re-seeds. |
60+
| `ws_run devA <argv...>` | Runs a command with `cd` + `HOME` set to the workstation dir. |
61+
| `assert_eq`, `assert_contains`, `assert_no_tmp_files`, `assert_jq`, … | Cheap assertions; failures print but don't `exit`. |
62+
63+
Naming: `sNN_<id>_<short-description>.sh` where `<id>` matches a row in
64+
the design doc (`internal-docs/snapshot-share-tests.md`). Scenarios that
65+
require the v0.6.1 binary should put `# NATIVE: skip` near the top.
66+
67+
## Tearing down
68+
69+
```sh
70+
./harness.sh down # stop containers
71+
docker compose down -v # also drop networks (rare)
72+
```
73+
74+
The `shared/` and `workstations/` bind-mount roots persist between runs;
75+
`reset_*` clears their contents at scenario start.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
services:
2+
pg-a:
3+
image: postgres:16-alpine
4+
environment:
5+
POSTGRES_PASSWORD: dryrun
6+
POSTGRES_DB: app
7+
# Ephemeral host port for run-native.sh — `docker compose port pg-a 5432`
8+
# resolves it; in the runner container we still use service DNS.
9+
ports:
10+
- "5432"
11+
tmpfs:
12+
- /var/lib/postgresql/data
13+
healthcheck:
14+
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
15+
interval: 1s
16+
timeout: 3s
17+
retries: 30
18+
19+
pg-b:
20+
image: postgres:16-alpine
21+
environment:
22+
POSTGRES_PASSWORD: dryrun
23+
POSTGRES_DB: app
24+
tmpfs:
25+
- /var/lib/postgresql/data
26+
healthcheck:
27+
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
28+
interval: 1s
29+
timeout: 3s
30+
retries: 30
31+
32+
pg-c:
33+
image: postgres:16-alpine
34+
environment:
35+
POSTGRES_PASSWORD: dryrun
36+
POSTGRES_DB: app
37+
tmpfs:
38+
- /var/lib/postgresql/data
39+
healthcheck:
40+
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
41+
interval: 1s
42+
timeout: 3s
43+
retries: 30
44+
45+
runner:
46+
build:
47+
context: ../..
48+
dockerfile: tests/snapshot-e2e/Dockerfile.dryrun
49+
depends_on:
50+
pg-a: { condition: service_healthy }
51+
pg-b: { condition: service_healthy }
52+
pg-c: { condition: service_healthy }
53+
environment:
54+
PG_A_URL: postgres://postgres:dryrun@pg-a:5432/app
55+
PG_B_URL: postgres://postgres:dryrun@pg-b:5432/app
56+
PG_C_URL: postgres://postgres:dryrun@pg-c:5432/app
57+
volumes:
58+
- ./shared:/shared
59+
- ./workstations:/workstations
60+
- ./fixtures:/fixtures:ro
61+
- ./scenarios:/scenarios:ro
62+
- ./lib.sh:/lib.sh:ro
63+
- ./run.sh:/run.sh:ro
64+
working_dir: /work
65+
# Stay alive so `harness.sh` can `docker compose exec` repeatedly.
66+
command: ["sleep", "infinity"]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
CREATE TABLE users (
2+
user_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
3+
email TEXT NOT NULL UNIQUE,
4+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
5+
);
6+
7+
CREATE TABLE orders (
8+
order_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
9+
user_id INT NOT NULL REFERENCES users(user_id),
10+
total NUMERIC(12,2) NOT NULL CHECK (total >= 0),
11+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
12+
);
13+
14+
CREATE INDEX orders_by_user ON orders(user_id);

tests/snapshot-e2e/harness.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
# Persistent-runner wrapper. Brings the stack up once, then `exec`s the
3+
# scenarios against the running container — saves ~3-5s per invocation
4+
# vs `docker compose run --rm`.
5+
#
6+
# Usage:
7+
# ./harness.sh # run all scenarios
8+
# ./harness.sh 's03*.sh' # filter scenarios by glob
9+
# ./harness.sh -- bash # drop into a shell in the runner
10+
# ./harness.sh down # stop the stack
11+
# ./harness.sh rebuild # rebuild the runner image (after code changes)
12+
13+
set -uo pipefail
14+
cd "$(dirname "$0")"
15+
# Bind-mount roots — must exist before `docker compose up` so the
16+
# container's /shared and /workstations land on a writable host dir.
17+
mkdir -p shared workstations
18+
19+
case "${1:-run}" in
20+
down)
21+
exec docker compose down
22+
;;
23+
rebuild)
24+
exec docker compose build runner
25+
;;
26+
--)
27+
shift
28+
docker compose up -d --wait >/dev/null
29+
exec docker compose exec runner "$@"
30+
;;
31+
*)
32+
docker compose up -d --wait >/dev/null
33+
docker compose exec runner bash /run.sh "$@"
34+
;;
35+
esac

tests/snapshot-e2e/lib.sh

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Shared helpers for scenario scripts. Sourced, not executed.
2+
3+
: "${SHARED_DIR:=/shared}"
4+
: "${WORKSTATIONS_DIR:=/workstations}"
5+
: "${FIXTURES_DIR:=/fixtures}"
6+
7+
SCENARIO_NAME=""
8+
SCENARIO_FAILED=0
9+
10+
scenario() {
11+
SCENARIO_NAME="$1"
12+
SCENARIO_FAILED=0
13+
echo "# --- $SCENARIO_NAME"
14+
}
15+
16+
ok() {
17+
if [ "$SCENARIO_FAILED" -eq 0 ]; then
18+
echo "ok - $SCENARIO_NAME"
19+
else
20+
echo "not ok - $SCENARIO_NAME"
21+
exit 1
22+
fi
23+
}
24+
25+
fail() {
26+
SCENARIO_FAILED=1
27+
echo " FAIL: $*" >&2
28+
}
29+
30+
reset_shared() {
31+
mkdir -p "$SHARED_DIR"
32+
find "$SHARED_DIR" -mindepth 1 -delete 2>/dev/null || true
33+
}
34+
35+
reset_workstation() {
36+
local name="$1"
37+
mkdir -p "$WORKSTATIONS_DIR/$name"
38+
find "$WORKSTATIONS_DIR/$name" -mindepth 1 -delete 2>/dev/null || true
39+
mkdir -p "$WORKSTATIONS_DIR/$name/.dryrun"
40+
# Shared project_id across workstations so pushes/pulls land in the same
41+
# /shared/<project>/<database>/ subtree.
42+
cat > "$WORKSTATIONS_DIR/$name/dryrun.toml" <<EOF
43+
[project]
44+
id = "shared"
45+
46+
[default]
47+
profile = "primary"
48+
49+
[profiles.primary]
50+
db_url = "\${DATABASE_URL}"
51+
database_id = "app"
52+
EOF
53+
}
54+
55+
ws_env() {
56+
local name="$1"
57+
echo "HOME=$WORKSTATIONS_DIR/$name"
58+
}
59+
60+
# Run a dryrun command in the context of a "workstation". The binary uses
61+
# CWD/.dryrun/history.db, not $HOME, so we cd into the workstation dir.
62+
# usage: ws_run devA dryrun-new snapshot take --db "$PG_A_URL"
63+
ws_run() {
64+
local ws="$1"; shift
65+
(cd "$WORKSTATIONS_DIR/$ws" && HOME="$WORKSTATIONS_DIR/$ws" "$@")
66+
}
67+
68+
seed_db() {
69+
local pg_url="$1" sql_file="$2"
70+
psql "$pg_url" -v ON_ERROR_STOP=1 -f "$sql_file" >/dev/null
71+
}
72+
73+
reset_db() {
74+
local pg_url="$1"
75+
psql "$pg_url" -v ON_ERROR_STOP=1 -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" >/dev/null
76+
}
77+
78+
assert_eq() {
79+
local got="$1" want="$2" msg="${3:-assert_eq}"
80+
if [ "$got" != "$want" ]; then
81+
fail "$msg: got=[$got] want=[$want]"
82+
fi
83+
}
84+
85+
assert_contains() {
86+
local haystack="$1" needle="$2" msg="${3:-assert_contains}"
87+
case "$haystack" in
88+
*"$needle"*) : ;;
89+
*) fail "$msg: missing [$needle] in output" ;;
90+
esac
91+
}
92+
93+
assert_jq() {
94+
local json="$1" expr="$2" msg="${3:-assert_jq}"
95+
if ! echo "$json" | jq -e "$expr" >/dev/null 2>&1; then
96+
fail "$msg: jq expression failed: $expr"
97+
echo " json was: $json" >&2
98+
fi
99+
}
100+
101+
assert_file_exists() {
102+
[ -f "$1" ] || fail "expected file: $1"
103+
}
104+
105+
assert_no_tmp_files() {
106+
local dir="$1"
107+
local found
108+
found="$(find "$dir" -name '*.tmp' 2>/dev/null | head -n 1)"
109+
[ -z "$found" ] || fail "stray .tmp file: $found"
110+
}
111+
112+
# Verify filename hash equals recomputed content_hash for every snapshot
113+
# in a directory. Catches C5 / C6 corruption silently slipping through.
114+
assert_filenames_match_hash() {
115+
local dir="$1"
116+
while IFS= read -r f; do
117+
local fname expected_hash recomputed
118+
fname="$(basename "$f")"
119+
expected_hash="${fname##*-}"
120+
expected_hash="${expected_hash%.json.zst}"
121+
recomputed="$(zstd -dc "$f" | sha256sum | awk '{print $1}')"
122+
# The plan stores hash of the SchemaSnapshot JSON, not file bytes —
123+
# so this assertion needs the dryrun binary to verify properly.
124+
# Placeholder: just check the field is hex of correct length.
125+
if ! echo "$expected_hash" | grep -Eq '^[0-9a-f]{64}$'; then
126+
fail "bad hash format in filename: $fname"
127+
fi
128+
done < <(find "$dir" -name '*.json.zst' -type f)
129+
}

0 commit comments

Comments
 (0)