diff --git a/scripts/store-service/names-contract-run.sh b/scripts/store-service/names-contract-run.sh new file mode 100755 index 0000000000..1b1a08328b --- /dev/null +++ b/scripts/store-service/names-contract-run.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# Runs the names webapp's end-to-end cases that need no payment against this service, all in one, on a +# local anvil chain deployed by the webapp's dev/deploy.ts. See the README's "Running the webapp's +# cases against the service". Arguments, if any, replace the default cases' title filter as +# Playwright test arguments. +set -euo pipefail +shopt -s inherit_errexit + +: "${WEBAPP_DIR:?set WEBAPP_DIR to the names webapp (simplex.domains/webapp)}" +: "${ENS_CONTRACTS_DIR:?set ENS_CONTRACTS_DIR to a compiled ens-contracts checkout, as for the webapp dev/start.sh}" +: "${ISSUER_INDEX:?set ISSUER_INDEX to the index of an issuer key the service trusts}" +: "${ISSUER_SECRET:?set ISSUER_SECRET to the secret of that key}" +ANVIL="${ANVIL:-anvil}" +SERVICE_PORT="${SERVICE_PORT:-8180}" +CODES_PORT="${CODES_PORT:-8199}" +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +STORE_SERVICE_BIN="${STORE_SERVICE_BIN:-$(cd "$REPO" && cabal list-bin exe:simplex-store-service)}" + +# The e2e helpers read the chain from the webapp's dev/local-chain-0.json, which slot 0 of dev/start.sh +# writes: anvil on 8545, a 5-second commitment age, and so the same contract addresses. +RPC_PORT=8545 +RPC_URL="http://127.0.0.1:$RPC_PORT" +CHAIN_ID=31337 +MIN_COMMITMENT_AGE=5 +# as dev/start.sh: a block a second, and a few states kept in memory rather than gigabytes under ~/.foundry +MEMORY_STATES=8 +SERVICE_URL="http://127.0.0.1:$SERVICE_PORT" +CODES_URL="http://127.0.0.1:$CODES_PORT" +STARTUP_SECONDS=120 +CLI_TIMEOUT_SECONDS=120 + +# The cases at webapp f53b765, run from one client, so they stay within the service's 10 code checks +# a minute. Each takes its prepaid code from POST /control/code, which the code server below answers +# from the codes issued here, so CODES holds one per case that asks for one. "search answers every kind +# of name" is not among them: it expects the mock's made-up profiles and sets /control/search. +CASES=( + "a search link's name is shown folded to lower case" + "Back to a search from the links step shows its answer at once" + "Check your code tells a prepaid code, a name code and anything else apart, and a code for longer names says so on the home" + "Check your code opened again shows a prepaid code typed there, never a name code" + "Check your code says nothing of a code being typed, and refuses one too short once Enter is pressed or focus leaves" + "a name code the service finds no order for says so as its verdict, offers no Add, and keeps focus in the code's field" + "a prepaid code from Check your code shows a name as Free and registers it with nothing to pay" + "the registering page keeps its heading still on a phone from the commit to the name being registered" + "a registration's stages are said as each is reached, not each second of the wait" + "Edit links keeps several links on chain as the contact address they hold, and emptying the field removes them" +) +CODES=("name 8" "name 6" "name 6 years 5" "name 6" "name 6") + +RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/names-contract-run.XXXXXX")" +INI="$RUN_DIR/store_service.ini" +DB="$RUN_DIR/db" + +# Only jobs still running are stopped, so a PID reused since one ended is never killed. The run's +# folder is kept when the run fails, for its logs. +cleanup() { + local status=$? + local pids + pids="$(jobs -p)" + if [ -n "$pids" ]; then + # shellcheck disable=SC2086 # one PID per word + kill $pids 2>/dev/null || true + wait 2>/dev/null || true + fi + if [ "$status" -eq 0 ]; then rm -rf "$RUN_DIR"; else echo "failed; logs in $RUN_DIR" >&2; fi +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +listening() { + curl -s -o /dev/null --retry "$STARTUP_SECONDS" --retry-delay 1 --retry-connrefused --retry-all-errors "$@" +} + +start_chain() { + "$ANVIL" --chain-id "$CHAIN_ID" --host 127.0.0.1 --port "$RPC_PORT" --block-time 1 --prune-history "$MEMORY_STATES" --silent \ + > "$RUN_DIR/anvil.log" 2>&1 & + listening -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_chainId"}' "$RPC_URL" + (cd "$WEBAPP_DIR" && RPC_URL="$RPC_URL" LOCAL_CHAIN_OUT="$RUN_DIR/chain.json" MIN_COMMITMENT_AGE="$MIN_COMMITMENT_AGE" \ + ENS_CONTRACTS_DIR="$ENS_CONTRACTS_DIR" node dev/deploy.ts) > "$RUN_DIR/deploy.log" 2>&1 + if ! cmp -s "$RUN_DIR/chain.json" "$WEBAPP_DIR/dev/local-chain-0.json"; then + echo "the deployment differs from $WEBAPP_DIR/dev/local-chain-0.json, which the e2e helpers read; run its dev/start.sh with SLOTS=1 MIN_COMMITMENT_AGE=$MIN_COMMITMENT_AGE once" >&2 + exit 1 + fi +} + +build_site() { + (cd "$WEBAPP_DIR" && npx vite build --outDir "$RUN_DIR/names" --emptyOutDir --logLevel warn) > "$RUN_DIR/build.log" 2>&1 +} + +# No [btcpay] and no [stripe]: these cases pay for nothing. The names site is served on the host the +# tests open; the badges site, on any other host, is not used. +write_ini() { + local chain="$RUN_DIR/chain.json" + cat > "$INI" <> "$RUN_DIR/service.log" 2>&1 & + SERVICE_PID=$! + listening "$SERVICE_URL/" +} + +# The console started on a new database leaves its terminal in line mode and reads no keys, so the +# service's first start makes the database. +make_database() { + start_service + kill "$SERVICE_PID" + wait "$SERVICE_PID" || true +} + +# //issue in --run-cli, with the service stopped, as the README says. The console reads a terminal, so +# each command is typed into a pseudo-terminal, and its "Code: ..." answer read. +issue_codes() { + python3 -I - "$CLI_TIMEOUT_SECONDS" "$RUN_DIR/codes.json" "${CODES[@]}" -- \ + "$STORE_SERVICE_BIN" --run-cli --no-address --service-config "$INI" -d "$DB" > "$RUN_DIR/issue.log" 2>&1 <<'PY' +import fcntl, json, os, pty, re, select, struct, sys, termios, time + +args = sys.argv[1:] +timeout, out = float(args[0]), args[1] +split = args.index("--") +commands, cli = args[2:split], args[split + 1:] +ANSI = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]") +CODE = re.compile(rb"Code: (SN(\d+)(?:-[0-9A-Z]+)+)") +REFUSED = re.compile(rb"bad chat command|chat error|[Ee]rror:") + +pid, fd = pty.fork() +if pid == 0: + os.execvp(cli[0], cli) +# the console's terminal library divides by the window's size +fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 50, 200, 0, 0)) +seen = b"" + +def expect(pattern): + global seen + deadline = time.monotonic() + timeout + while True: + text = ANSI.sub(b"", seen) + if (refused := REFUSED.search(text)): + sys.exit(f"refused: {text[refused.start():].splitlines()[0].decode(errors='replace')}") + if (found := pattern.search(text)): + seen = text[found.end():] + return found + left = deadline - time.monotonic() + if left <= 0 or not select.select([fd], [], [], left)[0]: + sys.exit(f"no {pattern.pattern!r} within {timeout} s") + try: + chunk = os.read(fd, 65536) + except OSError: + chunk = b"" + if not chunk: + sys.exit(f"the console ended before {pattern.pattern!r}") + sys.stdout.buffer.write(chunk) + seen += chunk + +# The console loses keys sent in a burst, so each key waits for the echo of the one before. +def type_line(line): + for typed in range(1, len(line) + 1): + os.write(fd, line[typed - 1].encode()) + expect(re.compile(b"> " + re.escape(line[:typed].encode()))) + os.write(fd, b"\r") + +# the empty input line, which the welcome text's "> " lines are not +expect(re.compile(rb"Current user: .*\n(?:.*\n)*?.*> ")) +codes = [] +for command in commands: + type_line(f"//issue {command}") + found = expect(CODE) + years = re.search(r"years (\d+)", command) + codes.append({"code": found[1].decode(), "minLength": int(found[2]), "years": int(years[1]) if years else 2}) +type_line("/quit") +os.waitpid(pid, 0) +with open(out, "w") as f: + json.dump(codes, f) +PY +} + +# The tests' MOCK_URL. POST /control/code hands out the issued codes; the reset of /control/search +# that search.spec.ts sends after each case is the service's own state, so it is answered as done. +# Any other control answers 501, failing the case that sends it. /api is the service's. +start_code_server() { + node --input-type=module - "$CODES_PORT" "$SERVICE_URL" "$RUN_DIR/codes.json" > "$RUN_DIR/codes.log" 2>&1 <<'JS' & +import { createServer } from "node:http" +import { readFileSync } from "node:fs" + +const [port, service, codesFile] = process.argv.slice(2) +const codes = JSON.parse(readFileSync(codesFile, "utf8")) +const SEARCH_RESET = { chainDown: false, limitSeconds: 0, invoiceError: null, findLimitSeconds: 0 } +const sameJson = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort()) + +async function bodyOf(req) { + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + return Buffer.concat(chunks) +} + +function json(res, status, value) { + res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value)) +} + +function control(path, body) { + if (path === "/control/code" && body.state === undefined) { + const years = body.years ?? 2 + const at = codes.findIndex((c) => c.minLength === body.minLength && c.years === years) + if (at < 0) return [501, { error: `no issued code left of ${body.minLength}+ letters for ${years} years` }] + return [200, { code: codes.splice(at, 1)[0].code }] + } + if (path === "/control/search" && sameJson(body, SEARCH_RESET)) return [200, {}] + return [501, { error: `${path} ${JSON.stringify(body)} is the mock's control, which the service has not` }] +} + +createServer(async (req, res) => { + const body = await bodyOf(req) + if (req.url.startsWith("/control/")) { + const [status, answer] = control(req.url, body.length ? JSON.parse(body) : {}) + console.log(req.url, status, JSON.stringify(answer)) + return json(res, status, answer) + } + if (!req.url.startsWith("/api/")) return json(res, 404, { error: "not_found" }) + const answer = await fetch(`${service}${req.url}`, { + method: req.method, + headers: { "content-type": req.headers["content-type"] ?? "application/json" }, + body: req.method === "GET" ? undefined : body, + }) + res.writeHead(answer.status, { "content-type": answer.headers.get("content-type") ?? "application/json" }).end(Buffer.from(await answer.arrayBuffer())) +}).listen(Number(port), "127.0.0.1") +JS + listening -X POST "$CODES_URL/control/none" +} + +escape_regex() { + sed 's/[][\\.*+?^${}()|/]/\\&/g' <<< "$1" +} + +run_cases() { + local filter=() + if [ "$#" -gt 0 ]; then + filter=("$@") + else + local titles=() + for title in "${CASES[@]}"; do titles+=("$(escape_regex "$title")"); done + # Playwright matches the project, the file and the title, joined by spaces + filter=(--grep " ($(IFS='|'; echo "${titles[*]}"))\$") + fi + (cd "$WEBAPP_DIR" && BASE_URL="$SERVICE_URL" MOCK_URL="$CODES_URL" \ + npx playwright test --project=chromium --output "$RUN_DIR/test-results" "${filter[@]}") +} + +start_chain +build_site +write_ini +make_database +issue_codes +start_service +start_code_server +run_cases "$@"