#!/usr/bin/env bash # End-to-end checks of hosted projects: two people, a reader, two machines, # takeovers, waiting changes, offline work and 32 agents landing at once. # Against the self-hosted vcs-server by default; with VCS_TARGET=worker, # against the hosted service (apps/vcs) under `wrangler dev`, in its test # sign-in mode. # Usage: [VCS_TARGET=worker] tests/hosted.sh [path/to/target/debug] set -uo pipefail ROOT=$(cd "$(dirname "$0")/.." && pwd) TESTS=$ROOT/tests BIN=${1:-$ROOT/target/debug} VCS=$BIN/vcs SERVER=$BIN/vcs-server TARGET=${VCS_TARGET:-server} WORK=$(mktemp -d) export VCS_SAVER=0 PORT=$((20000 + RANDOM % 20000)) HOST="127.0.0.1:$PORT" SERVER_PID= stop_server() { [ -z "$SERVER_PID" ] && return if [ "$TARGET" = worker ]; then kill -- "-$SERVER_PID" 2>/dev/null; else kill "$SERVER_PID" 2>/dev/null; fi wait "$SERVER_PID" 2>/dev/null SERVER_PID= } cleanup() { stop_server rm -rf "$WORK" } trap cleanup EXIT cd "$WORK" if [ "$TARGET" = worker ]; then APP=$ROOT/../apps/vcs ALICE=vcs_test_alice_0123456789 BOB=vcs_test_bob_0123456789 CAROL=vcs_test_carol_0123456789 USERS="{\"$ALICE\":{\"handle\":\"alice\",\"name\":\"Alice\",\"email\":\"alice@acme.test\"},\"$BOB\":{\"handle\":\"bob\",\"name\":\"Bob\",\"email\":\"bob@acme.test\"},\"$CAROL\":{\"handle\":\"carol\",\"name\":\"Carol\",\"email\":\"carol@acme.test\"}}" start_server() { # Its own process group, so stopping it stops workerd too, and its own # name, so a dev process left from another run never stands in for it. WRANGLER_SEND_METRICS=false setsid "$APP/node_modules/.bin/wrangler" dev --cwd "$APP" --name "vcs-test-$PORT" --port "$PORT" --ip 127.0.0.1 --inspector-port "$((PORT + 1))" \ --persist-to "$WORK/wrangler" --var AUTH_MODE:test --var "TEST_USERS:$USERS" --var COMPACT_AT:4096 --var MAX_PROJECT_BYTES:60000000 >"$WORK/server.log" 2>&1 & SERVER_PID=$! for _ in $(seq 300); do curl -s "http://$HOST/.well-known/vcs.json" 2>/dev/null | grep -q api && return; sleep 0.2; done cat "$WORK/server.log"; echo "worker didn't start"; exit 1 } add_member() { # project, handle, role curl -sf -X POST -H "authorization: Bearer $ALICE" -H 'content-type: application/json' -d "{\"handle\":\"$2\",\"role\":\"$3\"}" "http://$HOST/v1/projects/$1/members" >/dev/null } else start_server() { "$SERVER" serve --data "$WORK/server" --listen "$HOST" --threads 32 2>"$WORK/server.log" & SERVER_PID=$! for _ in $(seq 50); do curl -s "http://$HOST/.well-known/vcs.json" >/dev/null && return; sleep 0.1; done echo "server didn't start"; exit 1 } add_member() { "$SERVER" member add "$1" "$2" --role "$3" --data "$WORK/server" } ALICE=$("$SERVER" user add alice --name Alice --email alice@acme.test --data "$WORK/server") BOB=$("$SERVER" user add bob --name Bob --email bob@acme.test --data "$WORK/server") CAROL=$("$SERVER" user add carol --name Carol --email carol@acme.test --data "$WORK/server") fi pass=0 fail=0 check() { local name=$1 shift if "$@" >/dev/null 2>&1; then pass=$((pass + 1)); else fail=$((fail + 1)); echo "FAIL: $name"; fi } code() { local want=$1 shift "$@" >/dev/null 2>&1 [ "$?" = "$want" ] } jq_() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)"; } # People and machines: each has a VCS_HOME. as() { # who, command... local who=$1 shift case $who in laptop) VCS_HOME="$WORK/laptop" VCS_NAME="Alice" VCS_EMAIL="alice@acme.test" "$@" ;; cloud) VCS_HOME="$WORK/cloud" VCS_NAME="Alice" VCS_EMAIL="alice@acme.test" "$@" ;; bob) VCS_HOME="$WORK/bob" VCS_NAME="Bob" VCS_EMAIL="bob@acme.test" "$@" ;; carol) VCS_HOME="$WORK/carol" VCS_NAME="Carol" VCS_EMAIL="carol@acme.test" "$@" ;; esac } start_server check "laptop login" as laptop "$VCS" login "$HOST" --token "$ALICE" check "cloud login" as cloud "$VCS" login "$HOST" --token "$ALICE" check "bob login" as bob "$VCS" login "$HOST" --token "$BOB" check "carol login" as carol "$VCS" login "$HOST" --token "$CAROL" # Signing in through the browser (a stand-in sign-in page approves it). LPORT=$((PORT + 3)) python3 "$TESTS/login-server.py" "$LPORT" "$ALICE" & LOGIN_PID=$! for _ in $(seq 100); do curl -s -o /dev/null -X POST "http://127.0.0.1:$LPORT/login" && break; sleep 0.1; done check "browser login" sh -c "VCS_HOME=$WORK/browser VCS_LOGIN=http://127.0.0.1:$LPORT/login '$VCS' login '$HOST' 2>/dev/null | grep -q 'as alice'" kill "$LOGIN_PID" 2>/dev/null check "no browser login without a sign-in page" sh -c "VCS_HOME=$WORK/browser2 '$VCS' login '$HOST' 2>&1 | grep -q -- '--token'" # Alice starts locally and publishes. mkdir shop && cd shop printf 'line 1\nline 2\nline 3\n' > shared.txt printf 'SECRET=alice\n' > .env check "init" as laptop "$VCS" init check "first version" as laptop "$VCS" land -m "First version" printf 'line 1\nline 2\nline 3\nline 4\n' > shared.txt check "second version" as laptop "$VCS" land -m "Second version" check "publish" as laptop "$VCS" publish "$HOST/acme/shop" check "main is 2 on the host" test "$(curl -s -H "authorization: Bearer $ALICE" "http://$HOST/v1/projects/acme/shop" | jq_ 'd["main"]["n"]')" = 2 add_member acme/shop bob member add_member acme/shop carol reader # Bob gets it. cd "$WORK" check "bob gets" as bob "$VCS" get "$HOST/acme/shop" bobshop check "bob has the files" test "$(tail -1 bobshop/shared.txt)" = "line 4" check "bob doesn't get alice's .env" test ! -e bobshop/.env # Parallel landings from two people. printf 'alice\n' > shop/alice.txt printf 'bob\n' > bobshop/bob.txt check "alice lands" as laptop "$VCS" -C shop land -m "Alice's file" check "bob lands without syncing" as bob "$VCS" -C bobshop land -m "Bob's file" check "main is 4" test "$(as bob "$VCS" --json -C bobshop log | jq_ 'd["versions"][0]["n"]')" = 4 # A conflict between them. as laptop "$VCS" -C shop sync >/dev/null 2>&1 as bob "$VCS" -C bobshop sync >/dev/null 2>&1 perl -pi -e 's/^line 2$/line two (alice)/' shop/shared.txt perl -pi -e 's/^line 2$/line two (bob)/' bobshop/shared.txt check "alice lands line 2" as laptop "$VCS" -C shop land -m "Alice's line 2" check "bob conflicts (exit 3)" code 3 as bob "$VCS" -C bobshop land -m "Bob's line 2" check "bob syncs (exit 3)" code 3 as bob "$VCS" -C bobshop sync check "markers" grep -q '<<<<<<< main' bobshop/shared.txt perl -ni -e 'print unless /^(<<<<<<< |=======$|>>>>>>> )/ or /line two \(alice\)/' bobshop/shared.txt check "bob lands after fixing" as bob "$VCS" -C bobshop land # A reader's change waits for someone who can land it. check "carol gets" as carol "$VCS" get "$HOST/acme/shop" carolshop printf 'fix from carol\n' > carolshop/carol.txt check "carol's land waits (exit 5)" code 5 as carol "$VCS" -C carolshop land -m "Carol's fix" CAROL_CHANGE=$(as carol "$VCS" --json -C carolshop log --changes | jq_ '[c["change"]["id"] for c in d["changes"] if c["change"]["state"]=="waiting"][0]') check "alice sees the waiting change" sh -c "VCS_HOME=$WORK/laptop $VCS -C $WORK/shop log --changes | grep -q waiting" as laptop "$VCS" -C shop land "$CAROL_CHANGE" >"$WORK/carol-land.out" 2>&1; cat "$WORK/carol-land.out" >&2 check "alice lands carol's change" grep -q landed "$WORK/carol-land.out" check "carol is the author" test "$(as laptop "$VCS" --json -C shop log -n 1 | jq_ 'd["versions"][0]["author"].split(" <")[0]')" = Carol # Alice moves from her laptop to a cloud machine mid-change. as laptop "$VCS" -C shop sync >/dev/null 2>&1 printf 'started on the laptop\n' > shop/feature.txt check "describe on laptop" as laptop "$VCS" -C shop describe "Feature" FEATURE=$(as laptop "$VCS" --json -C shop status | jq_ 'd["change"]["id"]') check "cloud picks it up" as cloud "$VCS" get "$HOST/acme/shop" cloudshop --change "$FEATURE" check "the edit came along" test "$(cat cloudshop/feature.txt)" = "started on the laptop" check ".env came along" test "$(cat cloudshop/.env)" = "SECRET=alice" printf 'continued in the cloud\n' >> cloudshop/feature.txt check "cloud saves" as cloud "$VCS" -C cloudshop status printf 'late laptop edit\n' > shop/late.txt OUT=$(as laptop "$VCS" --json -C shop status 2>/dev/null) check "laptop's late edit is rescued into a new change" sh -c "echo '$OUT' | grep -q 'Rescued from'" check "cloud lands the feature" as cloud "$VCS" -C cloudshop land check "feature has both edits" test "$(as bob "$VCS" -C bobshop sync >/dev/null 2>&1; cat bobshop/feature.txt | wc -l | tr -d " ")" = 2 # Sharing a change. printf 'draft\n' > bobshop/draft.txt check "bob describes" as bob "$VCS" -C bobshop describe "Draft for Alice" DRAFT=$(as bob "$VCS" --json -C bobshop status | jq_ 'd["change"]["id"]') check "bob shares it" as bob "$VCS" -C bobshop access "$DRAFT" @alice check "alice sees it" sh -c "VCS_HOME=$WORK/laptop $VCS -C $WORK/shop log --changes | grep -q 'Draft for Alice'" # Private paths: only Alice sees billing/. (First drop the rescued change, # which still has the takeover's conflict in it.) as laptop "$VCS" -C shop drop >/dev/null 2>&1 as laptop "$VCS" -C shop sync >/dev/null 2>&1 mkdir -p shop/billing && printf 'rate 10\n' > shop/billing/rates.txt check "alice lands billing" as laptop "$VCS" -C shop land -m "Billing rates" check "bob sees billing before the rule" sh -c "VCS_HOME=$WORK/bob $VCS -C $WORK/bobshop sync >/dev/null 2>&1; test -e $WORK/bobshop/billing/rates.txt" check "alice makes billing/ hers" as laptop "$VCS" -C shop access billing @alice check "bob's sync takes billing away" sh -c "VCS_HOME=$WORK/bob $VCS -C $WORK/bobshop sync >/dev/null 2>&1; test ! -e $WORK/bobshop/billing" printf 'bob was here\n' >> bobshop/README.md 2>/dev/null || printf 'bob was here\n' > bobshop/README.md check "bob lands outside billing" as bob "$VCS" -C bobshop land -m "Bob's readme" check "alice still has billing" sh -c "VCS_HOME=$WORK/laptop $VCS -C $WORK/shop sync >/dev/null 2>&1; test -e $WORK/shop/billing/rates.txt" mkdir -p bobshop/billing && printf 'sneaky\n' > bobshop/billing/new.txt check "bob can't write into billing (exit 7)" code 7 as bob "$VCS" -C bobshop land -m "Sneaky" rm -rf bobshop/billing as bob "$VCS" -C bobshop drop >/dev/null 2>&1 printf 'rate 12\n' > shop/billing/rates.txt check "alice changes billing" as laptop "$VCS" -C shop land -m "New billing rates" check "bob's log shows a private change" sh -c "VCS_HOME=$WORK/bob $VCS -C $WORK/bobshop log -n 1 | grep -q 'a private change'" check "carol never gets billing" sh -c "VCS_HOME=$WORK/carol $VCS get $HOST/acme/shop $WORK/carol2 >/dev/null 2>&1; test -e $WORK/carol2/README.md -o -e $WORK/carol2/shared.txt; test ! -e $WORK/carol2/billing" check "bob can't read billing over the API" sh -c "! curl -sf -H 'authorization: Bearer $BOB' http://$HOST/v1/projects/acme/shop/trees/\$(curl -s -H 'authorization: Bearer $ALICE' http://$HOST/v1/projects/acme/shop | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"main\"][\"tree\"])') >/dev/null" # Naming the full tree in a change of his own doesn't get bob in either. FULL=$(curl -s -H "authorization: Bearer $ALICE" "http://$HOST/v1/projects/acme/shop" | jq_ 'd["main"]["tree"]') CLAIM="{\"change\":{\"id\":\"bcdfghjkmnpq\",\"base_tree\":\"$FULL\",\"head_tree\":\"$FULL\"},\"workspace\":\"w1\"}" check "bob can't claim the full tree" sh -c "curl -s -o /dev/null -w '%{http_code}' -X PUT -H 'authorization: Bearer $BOB' -H 'content-type: application/json' -d '$CLAIM' http://$HOST/v1/projects/acme/shop/changes/bcdfghjkmnpq | grep -q 403" check "and still can't read it" sh -c "! curl -sf -H 'authorization: Bearer $BOB' http://$HOST/v1/projects/acme/shop/trees/$FULL >/dev/null" # Undo a hosted land. as cloud "$VCS" -C cloudshop sync >/dev/null 2>&1 printf 'oops\n' > cloudshop/oops.txt check "cloud lands oops" as cloud "$VCS" -C cloudshop land -m "Oops" check "cloud undoes it" as cloud "$VCS" -C cloudshop undo check "main took it back" sh -c "VCS_HOME=$WORK/bob $VCS -C $WORK/bobshop log -n 1 | grep -q 'Take back'" # Offline: saves stay local, land says offline, later it all goes up. stop_server printf 'written offline\n' > cloudshop/offline.txt check "status works offline" as cloud "$VCS" -C cloudshop status check "land says offline (exit 6)" code 6 as cloud "$VCS" -C cloudshop land -m "Offline work" start_server check "land works once back" as cloud "$VCS" -C cloudshop land -m "Offline work" # 32 agents land at once on one main, none refused. BEFORE=$(as bob "$VCS" --json -C bobshop log -n 1 | jq_ 'd["versions"][0]["n"]') for i in $(seq 1 32); do as bob "$VCS" get "$HOST/acme/shop" "agent$i" >/dev/null 2>&1 printf 'agent %s\n' "$i" > "agent$i/agent-$i.txt" done pids=() for i in $(seq 1 32); do (VCS_HOME="$WORK/bob" VCS_NAME="Bob" VCS_AGENT="agent-$i" "$VCS" -C "agent$i" land -m "Agent $i" >"$WORK/land$i.out" 2>&1; echo $? >"$WORK/land$i.code") & pids+=($!) done wait "${pids[@]}" OK=$(grep -l '^0$' "$WORK"/land*.code | wc -l | tr -d " ") check "all 32 landed" test "$OK" = 32 for i in $(seq 1 32); do [ "$(cat "$WORK/land$i.code")" = 0 ] || { echo "agent $i:"; cat "$WORK/land$i.out"; } done AFTER=$(as bob "$VCS" --json -C bobshop log -n 1 | jq_ 'd["versions"][0]["n"]') check "main moved by 32" test "$((AFTER - BEFORE))" = 32 # An agent that landed in the middle still sees every version, in order. check "log has no gaps" sh -c "VCS_HOME=$WORK/bob '$VCS' --json -C agent1 log -n 40 | python3 -c 'import json,sys; n=[v[\"n\"] for v in json.load(sys.stdin)[\"versions\"]]; assert n == list(range(n[0], n[0]-len(n), -1)), n'" # Landing without a workspace: file edits in one call. edits() { # token, json curl -s -X POST -H "authorization: Bearer $1" -H 'content-type: application/json' -d "$2" "http://$HOST/v1/projects/acme/shop/edits" } check "edits land" sh -c "$(declare -f edits); HOST=$HOST; edits $ALICE '{\"title\":\"From the API\",\"files\":{\"api/hello.txt\":\"hello\\n\",\"api/run.sh\":\"echo hi\\n\"},\"executable\":[\"api/run.sh\"]}' | grep -q '\"outcome\":\"landed\"'" as bob "$VCS" -C bobshop sync >/dev/null 2>&1 check "edits reach workspaces" test "$(cat bobshop/api/hello.txt 2>/dev/null)" = hello check "edits keep the executable bit" test -x bobshop/api/run.sh check "edits can delete" sh -c "$(declare -f edits); HOST=$HOST; edits $ALICE '{\"title\":\"Remove hello\",\"files\":{\"api/hello.txt\":null}}' | grep -q '\"outcome\":\"landed\"'" check "secrets refused in edits" sh -c "$(declare -f edits); HOST=$HOST; edits $ALICE '{\"title\":\"Key\",\"files\":{\"api/key.txt\":\"sk_live_$(printf 'a%.0s' $(seq 1 24))\"}}' | grep -q '\"code\":\"secret\"'" check "bad paths refused in edits" sh -c "$(declare -f edits); HOST=$HOST; edits $ALICE '{\"title\":\"Escape\",\"files\":{\"../x\":\"x\"}}' | grep -q bad_usage" check "a reader's edits wait" sh -c "$(declare -f edits); HOST=$HOST; edits $CAROL '{\"title\":\"Suggestion\",\"files\":{\"docs/idea.txt\":\"idea\\n\"}}' | grep -q '\"outcome\":\"waiting\"'" # Groups: a folder for a group. as laptop "$VCS" -C shop sync >/dev/null 2>&1 check "alice makes a group" as laptop "$VCS" -C shop group ops @bob check "the group lists bob" sh -c "VCS_HOME=$WORK/laptop $VCS -C $WORK/shop group | grep -q '@ops.*@bob'" mkdir -p shop/ops && printf 'runbook\n' > shop/ops/runbook.txt check "alice lands ops/" as laptop "$VCS" -C shop land -m "Runbook" check "ops/ is the group's" as laptop "$VCS" -C shop access ops @ops check "bob, in the group, sees ops/" sh -c "VCS_HOME=$WORK/bob $VCS -C $WORK/bobshop sync >/dev/null 2>&1; test -e $WORK/bobshop/ops/runbook.txt" check "carol, outside it, doesn't" sh -c "VCS_HOME=$WORK/carol $VCS -C $WORK/carolshop sync >/dev/null 2>&1; test ! -e $WORK/carolshop/ops" check "only owners change groups (exit 7)" code 7 as bob "$VCS" -C bobshop group ops @bob @carol # Approvals: a land waits until someone else approves it. check "alice requires an approval" as laptop "$VCS" -C shop settings approvals=1 printf 'reviewed\n' > shop/reviewed.txt check "alice's land waits (exit 5)" code 5 as laptop "$VCS" -C shop land -m "Needs review" REVIEW=$(as laptop "$VCS" --json -C shop log --changes | jq_ '[r["change"]["id"] for r in d["changes"] if r["change"].get("title") == "Needs review"][0]') check "alice can't approve her own (exit 7)" code 7 as laptop "$VCS" -C shop approve "$REVIEW" check "bob approves, and it lands" sh -c "VCS_HOME=$WORK/bob $VCS -C $WORK/bobshop approve $REVIEW | grep -q landed" check "main has it" sh -c "VCS_HOME=$WORK/bob $VCS -C $WORK/bobshop sync >/dev/null 2>&1; test -e $WORK/bobshop/reviewed.txt" check "approvals off again" as laptop "$VCS" -C shop settings approvals=0 # MCP, for agents without a workspace (Upstream's host). if [ "$TARGET" = worker ]; then rpc() { curl -s -X POST -H "authorization: Bearer $ALICE" -H 'content-type: application/json' -d "$1" "http://$HOST/mcp"; } check "mcp initialize" sh -c "$(declare -f rpc); ALICE=$ALICE HOST=$HOST; rpc '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}' | grep -q protocolVersion" check "mcp lists the tools" sh -c "$(declare -f rpc); ALICE=$ALICE HOST=$HOST; rpc '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}' | grep -q vcs_land" check "mcp lands edits" sh -c "$(declare -f rpc); ALICE=$ALICE HOST=$HOST; rpc '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"vcs_land\",\"arguments\":{\"project\":\"acme/shop\",\"title\":\"From MCP\",\"files\":{\"mcp/hi.txt\":\"hi from mcp\\n\"}}}}' | grep -q landed" check "mcp reads it back" sh -c "$(declare -f rpc); ALICE=$ALICE HOST=$HOST; rpc '{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"vcs_read\",\"arguments\":{\"project\":\"acme/shop\",\"path\":\"mcp/hi.txt\"}}}' | grep -q 'hi from mcp'" check "mcp lists a folder" sh -c "$(declare -f rpc); ALICE=$ALICE HOST=$HOST; rpc '{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"vcs_list\",\"arguments\":{\"project\":\"acme/shop\",\"path\":\"mcp\"}}}' | grep -q 'hi.txt'" check "mcp log has it" sh -c "$(declare -f rpc); ALICE=$ALICE HOST=$HOST; rpc '{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"vcs_log\",\"arguments\":{\"project\":\"acme/shop\",\"limit\":3}}}' | grep -q 'From MCP'" check "mcp needs a token" sh -c "curl -s -X POST -H 'content-type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"vcs_log\",\"arguments\":{\"project\":\"acme/shop\"}}}' http://$HOST/mcp | grep -q 'isError'" check "rest reads a file" sh -c "curl -s -H 'authorization: Bearer $ALICE' 'http://$HOST/v1/projects/acme/shop/file?path=mcp/hi.txt' | grep -q base64" fi # A project has a storage limit (60 MB in this test; 100 GiB for real). if [ "$TARGET" = worker ]; then head -c 70000000 /dev/urandom > shop/huge.bin check "a land past the limit is refused" sh -c "VCS_HOME=$WORK/laptop $VCS -C $WORK/shop land -m Huge 2>&1 | grep -q limit" rm -f shop/huge.bin fi [ "$fail" != 0 ] && [ -f "$WORK/server.log" ] && { echo "server log:"; tail -n 60 "$WORK/server.log"; } echo "hosted: $pass passed, $fail failed" [ "$fail" = 0 ]