scripts: run the names webapp cases on the service

This commit is contained in:
shum committed 2026-10-22 18:00:19 +00:00
1 parent e09180cb44
commit 8307544378
1 file changed
+281
+281
View File
@@ -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" <<INI
[listener]
host = 127.0.0.1
port = $SERVICE_PORT
static_dir = $REPO/apps/simplex-store-service/web/dist
serve_webapp = on
names_static_dir = $RUN_DIR/names
names_hosts = 127.0.0.1
trust_forwarded_for = off
[issuer]
index = $ISSUER_INDEX
private_key = $ISSUER_SECRET
[names]
tld = $(jq -r .tld "$chain")
[chain]
rpc_url = $RPC_URL
chain_id = $(jq -r .chainId "$chain")
controller = $(jq -r .controller "$chain")
sponsor = $(jq -r .sponsor "$chain")
base_registrar = $(jq -r .baseRegistrar "$chain")
resolver = $(jq -r .resolver "$chain")
service_key = $(jq -r .registrarKey "$chain")
INI
chmod 600 "$INI"
}
start_service() {
"$STORE_SERVICE_BIN" --no-address --service-config "$INI" -d "$DB" >> "$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 "$@"