mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 10:47:32 +00:00
356 lines
14 KiB
Bash
Executable File
356 lines
14 KiB
Bash
Executable File
#!/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="${WEBAPP_DIR:-$(cd "$(dirname "$0")/../../.." && pwd)/apps/simplex-domains/webapp}"
|
|
: "${ENS_CONTRACTS_DIR:?set ENS_CONTRACTS_DIR to a compiled ens-contracts checkout, as for the webapp dev/start.sh}"
|
|
ANVIL="${ANVIL:-anvil}"
|
|
SERVICE_PORT="${SERVICE_PORT:-8180}"
|
|
BADGES_PORT="${BADGES_PORT:-8181}"
|
|
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"
|
|
BADGES_URL="http://127.0.0.1:$BADGES_PORT"
|
|
CODES_URL="http://127.0.0.1:$CODES_PORT"
|
|
STARTUP_SECONDS=120
|
|
CLI_TIMEOUT_SECONDS=120
|
|
# The test suite's public issuer secret (issuerSecret in tests/Bots/StoreService/ConfigTests.hs), trusted
|
|
# through [issuer] dev_keys, which the service accepts only on chain 31337. Never use it elsewhere.
|
|
DEV_ISSUER_INDEX=999
|
|
DEV_ISSUER_SECRET=Ea5wG-J2mQjPBu9YfSJRKPnGnzoIdEE-8VaMh_wY2Bg=
|
|
TERMS_VERSION=2026-10-23
|
|
TERMS_URL=https://simplex.chat/
|
|
# the community name the webapp's community cases search for
|
|
COMMUNITY_LABEL=bakers
|
|
COMMUNITY_TEXT="bakers and bakeries in Portugal"
|
|
|
|
# The cases at webapp 71725c8. Each case is its own client by X-Forwarded-For, which the service trusts
|
|
# here, as the mock does. 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 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"
|
|
"a prepaid code registers a name with its links, which its records on chain then hold"
|
|
"a code order's name code, entered in another browser before the reveal, lets that browser reveal and register the name"
|
|
"Edit links on a name registered with a code adds its links on chain, then changes one"
|
|
"a code check past the service's limit counts down its wait, then checks the code again by itself"
|
|
"a name's link whose data cannot be read now shows as its kind's default picture, and opens by the chat core's form"
|
|
"a community name shows its marker after the name, a button whose popup says what the name is for"
|
|
"Check your order of a community name asks for the community wording, its terms linked and its ⓘ saying what the name is for, and Pay waits for the tick"
|
|
)
|
|
CODES=("name 8" "name 6" "name 6 years 5" "name 6" "name 6" "name 6" "name 6" "name 6" "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
|
|
|
|
# Waits until the server started as PID answers the curl arguments, failing at once if it exits first.
|
|
answering() {
|
|
local pid=$1 probe ended status=0
|
|
shift
|
|
curl -s -o /dev/null --retry "$STARTUP_SECONDS" --retry-delay 1 --retry-connrefused --retry-all-errors "$@" &
|
|
probe=$!
|
|
wait -n -p ended "$pid" "$probe" || status=$?
|
|
if [ "$ended" != "$probe" ]; then
|
|
echo "process $pid ended before it answered; see the logs" >&2
|
|
exit 1
|
|
fi
|
|
return "$status"
|
|
}
|
|
|
|
# Another chain on the port would take the deployment, and the cases would read that chain.
|
|
refuse_taken_ports() {
|
|
local url
|
|
for url in "$RPC_URL" "$SERVICE_URL" "$BADGES_URL" "$CODES_URL"; do
|
|
if curl -s -o /dev/null "$url"; then
|
|
echo "something already answers on $url; stop it first" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
}
|
|
|
|
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 &
|
|
answering $! -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 port the
|
|
# tests open; the badges site, on its own port, is not used, so its build is an empty folder.
|
|
write_ini() {
|
|
local chain="$RUN_DIR/chain.json"
|
|
mkdir -p "$RUN_DIR/badges"
|
|
cat > "$INI" <<INI
|
|
[listener]
|
|
host = 127.0.0.1
|
|
badges_port = $BADGES_PORT
|
|
badges_static_dir = $RUN_DIR/badges
|
|
serve_webapp = on
|
|
names_static_dir = $RUN_DIR/names
|
|
names_port = $SERVICE_PORT
|
|
trust_forwarded_for = on
|
|
|
|
[issuer]
|
|
index = $DEV_ISSUER_INDEX
|
|
private_key = $DEV_ISSUER_SECRET
|
|
dev_keys = $DEV_ISSUER_INDEX:$DEV_ISSUER_SECRET
|
|
|
|
[names]
|
|
tld = $(jq -r .tld "$chain")
|
|
terms_version = $TERMS_VERSION
|
|
terms_url = $TERMS_URL
|
|
|
|
[community]
|
|
$COMMUNITY_LABEL = $COMMUNITY_TEXT
|
|
|
|
[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=$!
|
|
answering "$SERVICE_PID" "$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, signal, 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 fail(message):
|
|
os.kill(pid, signal.SIGTERM)
|
|
sys.exit(message)
|
|
|
|
def expect(pattern):
|
|
global seen
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
text = ANSI.sub(b"", seen)
|
|
if (refused := REFUSED.search(text)):
|
|
fail(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]:
|
|
fail(f"no {pattern.pattern!r} within {timeout} s")
|
|
try:
|
|
chunk = os.read(fd, 65536)
|
|
except OSError:
|
|
chunk = b""
|
|
if not chunk:
|
|
fail(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` }]
|
|
}
|
|
|
|
// fetch has decoded and measured the body itself, so these headers no longer describe it
|
|
const NOT_PASSED = new Set(["content-encoding", "content-length", "transfer-encoding", "connection"])
|
|
|
|
// the case's client, so a case's own requests and its page's share the service's rate limits
|
|
const forwardedFor = (req) => (req.headers["x-forwarded-for"] ? { "x-forwarded-for": req.headers["x-forwarded-for"] } : {})
|
|
|
|
async function proxy(req, res, body) {
|
|
const answer = await fetch(`${service}${req.url}`, {
|
|
method: req.method,
|
|
headers: { "content-type": req.headers["content-type"] ?? "application/json", ...forwardedFor(req) },
|
|
body: req.method === "GET" ? undefined : body,
|
|
})
|
|
const headers = Object.fromEntries([...answer.headers].filter(([name]) => !NOT_PASSED.has(name)))
|
|
res.writeHead(answer.status, headers).end(Buffer.from(await answer.arrayBuffer()))
|
|
}
|
|
|
|
async function serve(req, res) {
|
|
const body = await bodyOf(req)
|
|
if (req.url.startsWith("/control/")) {
|
|
let parsed
|
|
try {
|
|
parsed = body.length ? JSON.parse(body) : {}
|
|
} catch {
|
|
return json(res, 400, { error: "bad_request" })
|
|
}
|
|
const [status, answer] = control(req.url, parsed)
|
|
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" })
|
|
await proxy(req, res, body)
|
|
}
|
|
|
|
createServer((req, res) => {
|
|
serve(req, res).catch((e) => {
|
|
console.error(req.url, e)
|
|
if (!res.headersSent) json(res, 502, { error: "the service did not answer" })
|
|
else res.destroy()
|
|
})
|
|
}).listen(Number(port), "127.0.0.1")
|
|
JS
|
|
answering $! -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[@]}")
|
|
}
|
|
|
|
refuse_taken_ports
|
|
start_chain
|
|
build_site
|
|
write_ini
|
|
make_database
|
|
issue_codes
|
|
start_service
|
|
start_code_server
|
|
run_cases "$@"
|