|
| 1 | +#!/usr/bin/env bash |
| 2 | +# End-to-end proof that `paperclipai update` works ACROSS VERSIONS, including |
| 3 | +# database migrations, against a real managed install with a live service. |
| 4 | +# |
| 5 | +# Journey (real GitHub, real embedded Postgres, real systemd/launchd service): |
| 6 | +# install --ref BASE (payload with N migrations) -> onboard + service active |
| 7 | +# -> point the tracked ref at NEXT (payload with N+1 migrations; NEXT adds a |
| 8 | +# probe migration creating table `e2e_update_probe`) |
| 9 | +# -> update --check (exit 10) -> update --yes |
| 10 | +# -> assert: pre-update DB backup written, payload flipped, service healthy, |
| 11 | +# probe migration applied to the real database (visible in a pg dump), |
| 12 | +# the database cluster was reused (no re-initialization) |
| 13 | +# -> update --rollback -> old payload must still boot against the migrated DB |
| 14 | +# |
| 15 | +# The tracked-ref edit in install.json simulates the real user state "installed |
| 16 | +# from this ref a while ago, the ref has since gained migrations" without having |
| 17 | +# to mutate refs on GitHub while the test runs. |
| 18 | +# |
| 19 | +# Env knobs: |
| 20 | +# E2E_REPO GitHub repo (default: paperclipai/paperclip) |
| 21 | +# E2E_UPDATE_BASE_REF ref to install first (required; e.g. test/e2e-update-base) |
| 22 | +# E2E_UPDATE_NEXT_REF ref to update to (required; BASE + one probe migration) |
| 23 | +# E2E_BOOTSTRAP_CLI path to an already-built bootstrap CLI entry point |
| 24 | +# (dist/index.js); when unset, builds one from BASE |
| 25 | +# E2E_SERVICE_TIMEOUT_SECS service active/health wait (default 300) |
| 26 | +set -uo pipefail |
| 27 | + |
| 28 | +E2E_REPO="${E2E_REPO:-paperclipai/paperclip}" |
| 29 | +BASE_REF="${E2E_UPDATE_BASE_REF:?E2E_UPDATE_BASE_REF is required}" |
| 30 | +NEXT_REF="${E2E_UPDATE_NEXT_REF:?E2E_UPDATE_NEXT_REF is required}" |
| 31 | +E2E_SERVICE_TIMEOUT_SECS="${E2E_SERVICE_TIMEOUT_SECS:-300}" |
| 32 | + |
| 33 | +# Clean environment, then isolate ALL Paperclip state (managed store, config, |
| 34 | +# embedded Postgres, backups) under a dedicated home for this test. |
| 35 | +for var in $(env | grep -o '^PAPERCLIP_[A-Z_]*' || true); do unset "$var"; done |
| 36 | +unset NODE_ENV npm_config_prefix 2>/dev/null || true |
| 37 | +export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 |
| 38 | +export CI="${CI:-1}" |
| 39 | +export PAPERCLIP_HOME="$HOME/.paperclip-e2e-update" |
| 40 | + |
| 41 | +SHIM="$HOME/.local/bin/paperclipai" |
| 42 | +STORE="$PAPERCLIP_HOME/cli" |
| 43 | +BACKUP_DIR="$PAPERCLIP_HOME/instances/default/data/backups" |
| 44 | +RESULTS=() |
| 45 | +FAILED=0 |
| 46 | + |
| 47 | +note() { printf '\n\033[1;34m== %s ==\033[0m\n' "$*"; } |
| 48 | +pass() { RESULTS+=("PASS $1"); printf '\033[1;32mPASS\033[0m %s\n' "$1"; } |
| 49 | +fail_() { RESULTS+=("FAIL $1"); printf '\033[1;31mFAIL\033[0m %s\n' "$1"; FAILED=1; } |
| 50 | + |
| 51 | +shim() { "$SHIM" "$@"; } |
| 52 | +current_target() { readlink "$STORE/current" 2>/dev/null || echo "<missing>"; } |
| 53 | +backup_count() { ls "$BACKUP_DIR" 2>/dev/null | grep -c -E '\.sql(\.gz)?$'; } |
| 54 | +newest_backup() { ls -t "$BACKUP_DIR"/*.sql* 2>/dev/null | head -1; } |
| 55 | +dump_text() { case "$1" in *.gz) gunzip -c "$1" ;; *) cat "$1" ;; esac; } |
| 56 | +first_run_count() { shim service logs -n 2000 2>/dev/null | grep -c "first-run embedded PostgreSQL" || true; } |
| 57 | + |
| 58 | +wait_active() { |
| 59 | + local deadline=$(( $(date +%s) + E2E_SERVICE_TIMEOUT_SECS )) status_json="" |
| 60 | + while [ "$(date +%s)" -lt "$deadline" ]; do |
| 61 | + status_json="$(shim service status --json 2>/dev/null || true)" |
| 62 | + if echo "$status_json" | grep -q '"active"[[:space:]]*:[[:space:]]*true'; then return 0; fi |
| 63 | + sleep 5 |
| 64 | + done |
| 65 | + echo "last status: ${status_json:-<none>}" |
| 66 | + shim service logs -n 80 || true |
| 67 | + return 1 |
| 68 | +} |
| 69 | + |
| 70 | +cleanup() { |
| 71 | + shim service stop >/dev/null 2>&1 || true |
| 72 | + shim service uninstall >/dev/null 2>&1 || true |
| 73 | +} |
| 74 | +trap cleanup EXIT |
| 75 | + |
| 76 | +note "0. Preflight" |
| 77 | +uname -a |
| 78 | +node --version && npm --version |
| 79 | +command -v corepack >/dev/null || npm install -g corepack |
| 80 | +[ -e "$SHIM" ] && { echo "shim already exists at $SHIM — refusing to run"; exit 2; } |
| 81 | +[ -d "$PAPERCLIP_HOME" ] && { echo "$PAPERCLIP_HOME already exists — refusing to run"; exit 2; } |
| 82 | +if [ "$(uname -s)" = "Linux" ] && [ ! -S "/run/user/$(id -u)/bus" ]; then |
| 83 | + echo "no systemd user bus at /run/user/$(id -u)/bus — this test needs a real service"; exit 2 |
| 84 | +fi |
| 85 | +echo "repo=$E2E_REPO base=$BASE_REF next=$NEXT_REF paperclip_home=$PAPERCLIP_HOME" |
| 86 | + |
| 87 | +if [ -n "${E2E_BOOTSTRAP_CLI:-}" ] && [ -f "$E2E_BOOTSTRAP_CLI" ]; then |
| 88 | + BOOTSTRAP_CLI="$E2E_BOOTSTRAP_CLI" |
| 89 | + note "1. Bootstrap CLI reused: $BOOTSTRAP_CLI" |
| 90 | +else |
| 91 | + note "1. Bootstrap: build the CLI from the GitHub tarball of $BASE_REF" |
| 92 | + BOOT="$HOME/e2e-upd-bootstrap" |
| 93 | + mkdir -p "$BOOT" |
| 94 | + curl --fail --silent --show-error --location \ |
| 95 | + "https://codeload.github.com/$E2E_REPO/tar.gz/$BASE_REF" \ |
| 96 | + | tar -xz --strip-components=1 -C "$BOOT" || { fail_ "1a bootstrap tarball"; exit 1; } |
| 97 | + ( cd "$BOOT" \ |
| 98 | + && corepack pnpm install --frozen-lockfile > "$HOME/e2e-upd-bootstrap-install.log" 2>&1 \ |
| 99 | + && bash scripts/build-npm.sh --skip-checks --skip-typecheck > "$HOME/e2e-upd-bootstrap-build.log" 2>&1 ) \ |
| 100 | + || { tail -40 "$HOME"/e2e-upd-bootstrap-*.log; fail_ "1b bootstrap build"; exit 1; } |
| 101 | + TARBALL="$(cd "$BOOT/cli" && npm pack --silent 2>/dev/null | tail -1)" |
| 102 | + mkdir -p "$HOME/e2e-upd-bootstrap-cli" |
| 103 | + ( cd "$HOME/e2e-upd-bootstrap-cli" && npm install --no-fund --no-audit "$BOOT/cli/$TARBALL" > "$HOME/e2e-upd-bootstrap-npm.log" 2>&1 ) \ |
| 104 | + || { tail -40 "$HOME/e2e-upd-bootstrap-npm.log"; fail_ "1c bootstrap npm install"; exit 1; } |
| 105 | + BOOTSTRAP_CLI="$HOME/e2e-upd-bootstrap-cli/node_modules/paperclipai/dist/index.js" |
| 106 | +fi |
| 107 | +node "$BOOTSTRAP_CLI" --version >/dev/null || { fail_ "1d bootstrap CLI smoke"; exit 1; } |
| 108 | +pass "1 bootstrap CLI ready" |
| 109 | + |
| 110 | +note "2. install --ref $BASE_REF (the 'old version' the user installed a while ago)" |
| 111 | +if node "$BOOTSTRAP_CLI" install --repo "$E2E_REPO" --ref "$BASE_REF" --yes; then |
| 112 | + pass "2a install base ref exits 0" |
| 113 | +else |
| 114 | + fail_ "2a install base ref exits 0"; exit 1 |
| 115 | +fi |
| 116 | +BASE_TARGET="$(current_target)" |
| 117 | +case "$BASE_TARGET" in |
| 118 | + *"installs/git/"*) pass "2b current -> git payload ($(basename "$BASE_TARGET"))" ;; |
| 119 | + *) fail_ "2b current -> git payload (got: $BASE_TARGET)"; exit 1 ;; |
| 120 | +esac |
| 121 | + |
| 122 | +note "3. onboard + service start on the old version (initializes the database)" |
| 123 | +if shim onboard --yes --install-service; then |
| 124 | + pass "3a onboard --yes --install-service exits 0" |
| 125 | +else |
| 126 | + fail_ "3a onboard --yes --install-service exits 0"; exit 1 |
| 127 | +fi |
| 128 | +if wait_active; then |
| 129 | + pass "3b service active on base version" |
| 130 | +else |
| 131 | + fail_ "3b service active on base version"; exit 1 |
| 132 | +fi |
| 133 | +FIRST_RUN_BEFORE="$(first_run_count)" |
| 134 | +PG_VERSION_FILE="$(find "$PAPERCLIP_HOME" -name PG_VERSION 2>/dev/null | head -1)" |
| 135 | +PG_INODE_BEFORE="$([ -n "$PG_VERSION_FILE" ] && ls -i "$PG_VERSION_FILE" | awk '{print $1}')" |
| 136 | +[ -n "$PG_VERSION_FILE" ] && pass "3c embedded Postgres cluster initialized" || fail_ "3c embedded Postgres cluster found" |
| 137 | + |
| 138 | +note "4. baseline dump: probe table must NOT exist on the old schema" |
| 139 | +if shim db-backup >/dev/null 2>&1; then |
| 140 | + pass "4a db-backup on base version exits 0" |
| 141 | +else |
| 142 | + fail_ "4a db-backup on base version exits 0" |
| 143 | +fi |
| 144 | +BASELINE_DUMP="$(newest_backup)" |
| 145 | +if [ -n "$BASELINE_DUMP" ] && ! dump_text "$BASELINE_DUMP" | grep -q "e2e_update_probe"; then |
| 146 | + pass "4b baseline schema has no e2e_update_probe table" |
| 147 | +else |
| 148 | + fail_ "4b baseline schema has no e2e_update_probe table (dump: ${BASELINE_DUMP:-<none>})" |
| 149 | +fi |
| 150 | +BACKUPS_BEFORE="$(backup_count)" |
| 151 | + |
| 152 | +note "5. simulate time passing: tracked ref now points at $NEXT_REF (adds one migration)" |
| 153 | +node -e ' |
| 154 | + const fs = require("fs"); |
| 155 | + const p = process.argv[1]; |
| 156 | + const m = JSON.parse(fs.readFileSync(p, "utf8")); |
| 157 | + m.ref = process.argv[2]; |
| 158 | + fs.writeFileSync(p, JSON.stringify(m, null, 2)); |
| 159 | +' "$STORE/install.json" "$NEXT_REF" && pass "5a tracked ref updated in install.json" || fail_ "5a tracked ref update" |
| 160 | + |
| 161 | +shim update --check --json; CHECK_EXIT=$? |
| 162 | +if [ "$CHECK_EXIT" -eq 10 ]; then |
| 163 | + pass "5b update --check sees the new version (exit 10)" |
| 164 | +else |
| 165 | + fail_ "5b update --check sees the new version (got exit $CHECK_EXIT)" |
| 166 | +fi |
| 167 | + |
| 168 | +note "6. update --yes (backup -> build new payload -> flip -> restart service)" |
| 169 | +if shim update --yes > "$HOME/e2e-upd-update.log" 2>&1; then |
| 170 | + pass "6a update --yes exits 0" |
| 171 | +else |
| 172 | + tail -40 "$HOME/e2e-upd-update.log"; fail_ "6a update --yes exits 0"; exit 1 |
| 173 | +fi |
| 174 | +tail -5 "$HOME/e2e-upd-update.log" |
| 175 | +BACKUPS_AFTER="$(backup_count)" |
| 176 | +if [ "$BACKUPS_AFTER" -gt "$BACKUPS_BEFORE" ]; then |
| 177 | + pass "6b pre-update database backup was written ($BACKUPS_BEFORE -> $BACKUPS_AFTER)" |
| 178 | +else |
| 179 | + fail_ "6b pre-update database backup was written ($BACKUPS_BEFORE -> $BACKUPS_AFTER)" |
| 180 | +fi |
| 181 | +NEXT_TARGET="$(current_target)" |
| 182 | +if [ "$NEXT_TARGET" != "$BASE_TARGET" ] && [ "${NEXT_TARGET#*installs/git/}" != "$NEXT_TARGET" ]; then |
| 183 | + pass "6c payload flipped to new git payload ($(basename "$NEXT_TARGET"))" |
| 184 | +else |
| 185 | + fail_ "6c payload flipped (before: $BASE_TARGET after: $NEXT_TARGET)" |
| 186 | +fi |
| 187 | + |
| 188 | +note "7. service healthy on the new version, with the migration applied" |
| 189 | +if wait_active; then |
| 190 | + pass "7a service active after update" |
| 191 | +else |
| 192 | + fail_ "7a service active after update"; exit 1 |
| 193 | +fi |
| 194 | +if shim service logs -n 2000 2>/dev/null | grep -q "e2e_update_probe"; then |
| 195 | + pass "7b service logs mention applying the probe migration" |
| 196 | +else |
| 197 | + fail_ "7b service logs mention applying the probe migration" |
| 198 | +fi |
| 199 | +if shim db-backup >/dev/null 2>&1; then |
| 200 | + pass "7c db-backup on new version exits 0" |
| 201 | +else |
| 202 | + fail_ "7c db-backup on new version exits 0" |
| 203 | +fi |
| 204 | +POST_DUMP="$(newest_backup)" |
| 205 | +if [ -n "$POST_DUMP" ] && dump_text "$POST_DUMP" | grep -q "e2e_update_probe"; then |
| 206 | + pass "7d probe migration table exists in the real database after update" |
| 207 | +else |
| 208 | + fail_ "7d probe migration table exists in the real database (dump: ${POST_DUMP:-<none>})" |
| 209 | +fi |
| 210 | + |
| 211 | +note "8. data continuity: same database cluster, not re-initialized" |
| 212 | +FIRST_RUN_AFTER="$(first_run_count)" |
| 213 | +if [ "$FIRST_RUN_AFTER" = "$FIRST_RUN_BEFORE" ]; then |
| 214 | + pass "8a no new first-run database initialization after update ($FIRST_RUN_BEFORE -> $FIRST_RUN_AFTER)" |
| 215 | +else |
| 216 | + fail_ "8a no new first-run database initialization ($FIRST_RUN_BEFORE -> $FIRST_RUN_AFTER)" |
| 217 | +fi |
| 218 | +PG_INODE_AFTER="$([ -n "$PG_VERSION_FILE" ] && ls -i "$PG_VERSION_FILE" 2>/dev/null | awk '{print $1}')" |
| 219 | +if [ -n "$PG_INODE_BEFORE" ] && [ "$PG_INODE_AFTER" = "$PG_INODE_BEFORE" ]; then |
| 220 | + pass "8b embedded Postgres cluster reused (same PG_VERSION inode)" |
| 221 | +else |
| 222 | + fail_ "8b embedded Postgres cluster reused (inode $PG_INODE_BEFORE -> ${PG_INODE_AFTER:-<gone>})" |
| 223 | +fi |
| 224 | + |
| 225 | +note "9. rollback: old payload must still boot against the migrated database" |
| 226 | +if shim update --rollback; then |
| 227 | + pass "9a update --rollback exits 0" |
| 228 | +else |
| 229 | + fail_ "9a update --rollback exits 0" |
| 230 | +fi |
| 231 | +case "$(current_target)" in |
| 232 | + "$BASE_TARGET") pass "9b rollback restored the base payload" ;; |
| 233 | + *) fail_ "9b rollback restored the base payload (got: $(current_target))" ;; |
| 234 | +esac |
| 235 | +if wait_active; then |
| 236 | + pass "9c rolled-back service is active against the migrated schema" |
| 237 | +else |
| 238 | + fail_ "9c rolled-back service is active against the migrated schema" |
| 239 | +fi |
| 240 | + |
| 241 | +note "10. cleanup" |
| 242 | +shim service stop >/dev/null 2>&1 || true |
| 243 | +if shim service uninstall && shim uninstall; then |
| 244 | + pass "10a service uninstall + uninstall exit 0" |
| 245 | +else |
| 246 | + fail_ "10a service uninstall + uninstall exit 0" |
| 247 | +fi |
| 248 | +trap - EXIT |
| 249 | + |
| 250 | +note "RESULTS ($E2E_REPO $BASE_REF -> $NEXT_REF on $(uname -sm))" |
| 251 | +printf '%s\n' "${RESULTS[@]}" |
| 252 | +if [ "$FAILED" = "1" ]; then echo; echo "OVERALL: FAIL"; exit 1; fi |
| 253 | +echo; echo "OVERALL: PASS" |
0 commit comments