From d4262f213a43bcf424e3b7cfd2ff1efc860571e4 Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Thu, 30 Apr 2026 18:08:37 -0500 Subject: [PATCH 01/11] Add local network docker compose harness --- .docker/local-network.Dockerfile | 51 ++++ .dockerignore | 14 ++ .gitignore | 3 + README.md | 46 ++++ docker-compose.local.yml | 40 +++ docs/local-network-handoff.md | 301 ++++++++++++++++++++++ local-network.env.example | 24 ++ playwright-tests/.env.example | 3 + playwright-tests/helpers/global-setup.js | 50 ++-- playwright-tests/playwright.config.ts | 10 +- scripts/local-network/healthcheck.sh | 12 + scripts/local-network/start.sh | 308 +++++++++++++++++++++++ 12 files changed, 826 insertions(+), 36 deletions(-) create mode 100644 .docker/local-network.Dockerfile create mode 100644 .dockerignore create mode 100644 docker-compose.local.yml create mode 100644 docs/local-network-handoff.md create mode 100644 local-network.env.example create mode 100644 playwright-tests/.env.example create mode 100644 scripts/local-network/healthcheck.sh create mode 100644 scripts/local-network/start.sh diff --git a/.docker/local-network.Dockerfile b/.docker/local-network.Dockerfile new file mode 100644 index 0000000..6801d38 --- /dev/null +++ b/.docker/local-network.Dockerfile @@ -0,0 +1,51 @@ +FROM ubuntu:22.04 + +ARG NODE_VERSION=20.19.3 +ARG SERVER_RUST_VERSION=1.82.0 +ARG PROXY_RUST_VERSION=stable + +ENV DEBIAN_FRONTEND=noninteractive +ENV PATH="/opt/node/bin:/root/.cargo/bin:${PATH}" +ENV LIBERDUS_SERVER_RUST_TOOLCHAIN="${SERVER_RUST_VERSION}" +ENV LIBERDUS_PROXY_RUST_TOOLCHAIN="${PROXY_RUST_VERSION}" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + jq \ + libssl-dev \ + lsof \ + build-essential \ + pkg-config \ + procps \ + python3 \ + rsync \ + xz-utils \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz \ + && mkdir -p /opt/node \ + && tar -xJf /tmp/node.tar.xz -C /opt/node --strip-components=1 \ + && rm /tmp/node.tar.xz \ + && npm install -g http-server \ + && npm cache clean --force + +RUN git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" \ + && git config --global url."https://github.com/".insteadOf "git@github.com:" + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain "${SERVER_RUST_VERSION}" --profile minimal \ + && rustup toolchain install "${PROXY_RUST_VERSION}" --profile minimal + +RUN npm install -g https://github.com/shardus/tools-cli-shardus-network.git \ + && npm cache clean --force + +WORKDIR /workspace/client-testing + +COPY scripts/local-network /usr/local/bin/liberdus-local-network +RUN chmod +x /usr/local/bin/liberdus-local-network/*.sh + +CMD ["/usr/local/bin/liberdus-local-network/start.sh"] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..14dce1e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +.devcontainer +playwright-tests/node_modules +playwright-tests/playwright-report +playwright-tests/test-results +playwright-tests/.cache +local-network.env +server +liberdus-proxy +tools-cli-shardus-network +web-client-v2 +*.log +*.pid diff --git a/.gitignore b/.gitignore index 6dda183..6fe351c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ xvfb.pid fluxbox.log fluxbox.pid playwright-tests/.cache/network-params.json +playwright-tests/node_modules/ +playwright-tests/.env +local-network.env diff --git a/README.md b/README.md index 8e15028..630ac14 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,52 @@ https://drive.google.com/file/d/19oWRpK_AJUo3G3hHYYGN_ZryAT-CSjyd/view?usp=shari 12. Run `./vnc-stop.sh` in the workspace root directory to stop the VNC server in the container. +## Local Network with Docker Compose + +The Playwright tests default to `https://liberdus.com/dev/`, but can be pointed at a local network with `PLAYWRIGHT_BASE_URL` or `playwright-tests/.env`. + +1. Copy the local network env template and adjust paths or ports if needed: + + ```powershell + Copy-Item local-network.env.example local-network.env + ``` + +2. Start the local network stack: + + ```powershell + docker compose --env-file local-network.env -f docker-compose.local.yml up --build + ``` + + This starts a 10-node Shardus network, the Liberdus proxy, and a static `web-client-v2` server. The source repos are mounted read-only and copied into a Docker volume before generated local config is written. The image uses Node.js 20.19.3, Rust 1.82.0 for server dependencies, and stable Rust for proxy dependencies. The web client is served only after the network reports `processing` mode. + +3. In a second shell, configure Playwright for the local web client: + + ```powershell + Copy-Item playwright-tests\.env.example playwright-tests\.env + ``` + +4. Run tests: + + ```powershell + cd playwright-tests + npm install + npm test + ``` + +5. Stop and remove the local network stack: + + ```powershell + docker compose --env-file local-network.env -f docker-compose.local.yml down + ``` + +Default local URLs: + +- Web client: `http://127.0.0.1:8080/` +- Liberdus proxy: `http://127.0.0.1:3030/` +- Shardus monitor: `http://127.0.0.1:3000/` + +If you change `LIBERDUS_NODE_COUNT`, also update `LIBERDUS_VALIDATOR_CONTAINER_PORT_END` and `LIBERDUS_VALIDATOR_HOST_PORT_END` so each range has one port per node. + ## Manually Setting up a Local Liberdus Network with web-client-v2 1. Setup an environment with the following software: diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..2da92d1 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,40 @@ +services: + local-network: + build: + context: . + dockerfile: .docker/local-network.Dockerfile + init: true + environment: + LIBERDUS_SERVER_SOURCE: /sources/server + LIBERDUS_PROXY_SOURCE: /sources/liberdus-proxy + LIBERDUS_WEB_CLIENT_SOURCE: /sources/web-client-v2 + LIBERDUS_RUNTIME_ROOT: /workspace/runtime + LIBERDUS_NODE_COUNT: ${LIBERDUS_NODE_COUNT:-10} + LIBERDUS_EXTERNAL_PORT_START: ${LIBERDUS_VALIDATOR_CONTAINER_PORT_START:-9001} + LIBERDUS_INTERNAL_PORT_START: ${LIBERDUS_INTERNAL_PORT_START:-10001} + LIBERDUS_PUBLIC_HOST: ${LIBERDUS_PUBLIC_HOST:-127.0.0.1} + LIBERDUS_MONITOR_PUBLIC_PORT: ${LIBERDUS_MONITOR_HOST_PORT:-3000} + LIBERDUS_PROXY_PUBLIC_PORT: ${LIBERDUS_PROXY_HOST_PORT:-3030} + LIBERDUS_PROXY_WS_PUBLIC_PORT: ${LIBERDUS_PROXY_WS_HOST_PORT:-3031} + LIBERDUS_WEB_PUBLIC_PORT: ${LIBERDUS_WEB_CLIENT_HOST_PORT:-8080} + volumes: + - ${LIBERDUS_SERVER_DIR:?Set LIBERDUS_SERVER_DIR in local-network.env}:/sources/server:ro + - ${LIBERDUS_PROXY_DIR:?Set LIBERDUS_PROXY_DIR in local-network.env}:/sources/liberdus-proxy:ro + - ${LIBERDUS_WEB_CLIENT_DIR:?Set LIBERDUS_WEB_CLIENT_DIR in local-network.env}:/sources/web-client-v2:ro + - local-network-runtime:/workspace/runtime + ports: + - "${LIBERDUS_MONITOR_HOST_PORT:-3000}:3000" + - "${LIBERDUS_ARCHIVER_HOST_PORT:-4000}:4000" + - "${LIBERDUS_PROXY_HOST_PORT:-3030}:3030" + - "${LIBERDUS_PROXY_WS_HOST_PORT:-3031}:3031" + - "${LIBERDUS_WEB_CLIENT_HOST_PORT:-8080}:8080" + - "${LIBERDUS_VALIDATOR_HOST_PORT_START:-9101}-${LIBERDUS_VALIDATOR_HOST_PORT_END:-9110}:${LIBERDUS_VALIDATOR_CONTAINER_PORT_START:-9001}-${LIBERDUS_VALIDATOR_CONTAINER_PORT_END:-9010}" + healthcheck: + test: ["CMD", "/usr/local/bin/liberdus-local-network/healthcheck.sh"] + interval: 15s + timeout: 10s + retries: 40 + start_period: 2m + +volumes: + local-network-runtime: diff --git a/docs/local-network-handoff.md b/docs/local-network-handoff.md new file mode 100644 index 0000000..b35fdb0 --- /dev/null +++ b/docs/local-network-handoff.md @@ -0,0 +1,301 @@ +# Local Network Docker Compose Handoff + +Date: 2026-04-30 +Branch: `client-testing-local-network` +Repository: `Liberdus/client-testing` + +## Goal + +Enable the Playwright tests in this repo to run against a locally started Liberdus network instead of only defaulting to `https://liberdus.com/dev/`. + +The current implementation focuses on standing up the local network locally with Docker Compose. The next phase can wire the same approach into GitHub Actions once the local harness is stable. + +## Current Status + +The branch contains a Docker Compose based local-network harness and Playwright configuration changes. The local stack reached Docker `healthy` before the final readiness fix, and the web client served from: + +- `http://127.0.0.1:8080/` +- proxy at `http://127.0.0.1:3030/` +- monitor at `http://127.0.0.1:3000/` + +A Playwright smoke test was then run against the local web client. It reached account creation but failed because the Shardus network was still in `forming` mode and rejected app transactions with: + +```text +Error injecting transaction: Application transactions are only allowed in processing Mode. +``` + +After that failure, the startup script and healthcheck were changed so the local stack waits for a `processing` cycle before declaring itself ready or serving the web client. That final processing-mode readiness change has had syntax/config validation but still needs a full Docker rerun. + +## Files Changed + +### Docker Compose Harness + +- `.docker/local-network.Dockerfile` + - Builds an Ubuntu 22.04 image. + - Installs Node.js 20.19.3. + - Installs two Rust toolchains: + - Rust 1.82.0 for `server` native dependencies. + - stable Rust for `liberdus-proxy`. + - Installs the Shardus network CLI from `tools-cli-shardus-network`. + - Copies the local-network scripts into the image. + +- `docker-compose.local.yml` + - Defines one `local-network` service. + - Mounts the three sibling repos read-only: + - `server` + - `liberdus-proxy` + - `web-client-v2` + - Copies those repos into a Docker volume before writing generated config. + - Publishes: + - host `3000` -> monitor + - host `4000` -> archiver + - host `3030` -> proxy HTTP + - host `3031` -> proxy WebSocket + - host `8080` -> web client + - host `9101-9110` -> container validator ports `9001-9010` + +- `local-network.env.example` + - Windows-oriented default paths for Chris's local repos. + - Documents the host validator port range separately from the container validator port range. + - On macOS, copy this to `local-network.env` and replace the repo paths with local macOS paths. + +- `.dockerignore` + - Keeps local dependency folders, test output, and env files out of the image build context. + +### Local Network Scripts + +- `scripts/local-network/start.sh` + - Syncs mounted source repos into `/workspace/runtime`. + - Writes generated proxy config and archiver seed. + - Installs/compiles the server with Rust 1.82.0. + - Starts a 10-node Shardus network. + - Waits for: + - active archiver + - active nodelist + - `cycleinfo` mode `processing` + - Builds and starts the Rust proxy with stable Rust. + - Fetches the local network ID from the proxy. + - Writes `web-client-v2/network.js`. + - Serves the web client on port `8080`. + +- `scripts/local-network/healthcheck.sh` + - Checks `network.js`. + - Checks the zero account through the proxy. + - Checks the current cycle is in `processing` mode. + +### Playwright Configuration + +- `playwright-tests/playwright.config.ts` + - Loads `playwright-tests/.env`. + - Uses: + - `PLAYWRIGHT_BASE_URL`, then + - `BASE_URL`, then + - `https://liberdus.com/dev/` + +- `playwright-tests/helpers/global-setup.js` + - Uses the Playwright config/env base URL instead of parsing `playwright.config.ts` as text. + +- `playwright-tests/.env.example` + - Sets: + +```env +PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080/ +``` + +- `.gitignore` + - Ignores: + - `playwright-tests/node_modules/` + - `playwright-tests/.env` + - `local-network.env` + +### README + +- `README.md` + - Adds a "Local Network with Docker Compose" section with setup, run, test, and shutdown commands. + +## How To Run Locally + +### Windows + +From the repo root: + +```powershell +Copy-Item local-network.env.example local-network.env +docker compose --env-file local-network.env -f docker-compose.local.yml up --build +``` + +In another shell: + +```powershell +Copy-Item playwright-tests\.env.example playwright-tests\.env +cd playwright-tests +npm install +npx playwright test +``` + +To stop: + +```powershell +docker compose --env-file local-network.env -f docker-compose.local.yml down +``` + +### macOS + +From the repo root: + +```bash +cp local-network.env.example local-network.env +``` + +Edit `local-network.env` to point at the local macOS repo paths, for example: + +```env +LIBERDUS_SERVER_DIR=/Users//Documents/Code/liberdus/server +LIBERDUS_PROXY_DIR=/Users//Documents/Code/liberdus/liberdus-proxy +LIBERDUS_WEB_CLIENT_DIR=/Users//Documents/Code/liberdus/web-client-v2 +``` + +Then run: + +```bash +docker compose --env-file local-network.env -f docker-compose.local.yml up --build +``` + +In another shell: + +```bash +cp playwright-tests/.env.example playwright-tests/.env +cd playwright-tests +npm install +npx playwright test +``` + +To stop: + +```bash +docker compose --env-file local-network.env -f docker-compose.local.yml down +``` + +## Recommended Smoke Test + +The smallest useful local-network smoke target is: + +```powershell +$env:PLAYWRIGHT_BASE_URL='http://127.0.0.1:8080/'; npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 --reporter=line +``` + +macOS equivalent: + +```bash +PLAYWRIGHT_BASE_URL='http://127.0.0.1:8080/' npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 --reporter=line +``` + +This test creates and signs in a fresh user, then checks Contacts and Wallet navigation. It is a better smoke than payment/transfer tests because it avoids additional funding and recipient setup. + +## Validation Run So Far + +Commands that passed before this handoff: + +```powershell +bash -n scripts/local-network/start.sh +bash -n scripts/local-network/healthcheck.sh +docker compose --env-file local-network.env.example -f docker-compose.local.yml config --quiet +cd playwright-tests +npm ci +npx playwright test --list tests/createAccount.e2e.test.js --project=chromium +``` + +Docker image build succeeded after resolving toolchain issues. The stack reached `healthy` before the final processing-mode gate was added. + +The targeted Playwright smoke was run and failed because the network had not reached processing mode yet. That failure is the reason the final readiness gate was added. + +## Important Findings + +### Toolchain Split Is Necessary + +The server and proxy currently need different Rust behavior: + +- The server dependency tree built successfully with Rust 1.82.0. +- Newer stable Rust failed the server build because a native dependency denies warnings. +- The proxy has no committed `Cargo.lock`, and current dependency resolution pulls Rust-2024-era crates that need a newer stable compiler. + +The Dockerfile therefore installs both Rust 1.82.0 and stable Rust, and the startup script selects the toolchain explicitly. + +### Shardus Port Behavior + +The Shardus CLI still creates node folders and validator listeners on `9001-9010`. Compose maps those to host ports `9101-9110` to avoid common host conflicts: + +```text +host 9101 -> container 9001 +host 9102 -> container 9002 +... +host 9110 -> container 9010 +``` + +Do not expect the folder names to match the host-facing ports. + +### Readiness Needs Processing Mode + +The proxy can serve the zero account while the network is still in `forming` mode. That is not enough for tests that submit app transactions. + +The latest script now waits for: + +```text +/cycleinfo/1 -> cycleInfo[0].mode == "processing" +``` + +and requires the active count to be at least `LIBERDUS_NODE_COUNT`. + +This is the most important next thing to verify with a clean full rerun. + +## Known Gaps / Next Steps + +1. Rebuild and rerun the compose stack after the processing-mode readiness patch. + + ```bash + docker compose --env-file local-network.env -f docker-compose.local.yml up --build + ``` + +2. Confirm Docker health does not turn healthy until the latest cycle is in processing mode. + + Useful checks: + + ```bash + docker compose --env-file local-network.env -f docker-compose.local.yml ps + docker exec client-testing-local-network-1 bash -lc 'curl -fsS http://127.0.0.1:4000/cycleinfo/1 | jq ".cycleInfo[0] | {counter, mode, active, desired, syncing, target}"' + ``` + +3. Rerun the smoke test. + +4. If the network never reaches processing, inspect Shardus node logs: + + ```bash + docker exec client-testing-local-network-1 bash -lc 'tail -160 /workspace/runtime/server/instances/shardus-instance-9001/logs/cycle.log' + docker exec client-testing-local-network-1 bash -lc 'tail -160 /workspace/runtime/server/instances/shardus-instance-9001/logs/p2p.log' + docker exec client-testing-local-network-1 bash -lc 'tail -160 /workspace/runtime/server/instances/archiver-logs/127.0.0.1_4000/main.log' + ``` + +5. Once the local path is stable, adapt this into GitHub Actions. + + The likely workflow shape is: + + - checkout `client-testing` + - checkout sibling `server`, `liberdus-proxy`, and `web-client-v2` + - run `docker compose -f docker-compose.local.yml up --build -d` + - wait for Docker health + - set `PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080/` + - run a smoke test first + - expand to the broader suite after stability is proven + +## Notes For The Next Thread + +Start by reading this file and checking the branch diff. The most recent unverified change is the processing-mode readiness gate in: + +- `scripts/local-network/start.sh` +- `scripts/local-network/healthcheck.sh` + +There is an unrelated untracked file in this worktree that was intentionally not included: + +```text +playwright-tests/tests/web-client-v2.code-workspace +``` diff --git a/local-network.env.example b/local-network.env.example new file mode 100644 index 0000000..d577dc1 --- /dev/null +++ b/local-network.env.example @@ -0,0 +1,24 @@ +# Copy this file to local-network.env and edit paths if your repos live elsewhere. +# Use forward slashes for Windows paths so Docker Compose parses them consistently. + +LIBERDUS_SERVER_DIR=C:/Users/Chris/Documents/Code/liberdus/server +LIBERDUS_PROXY_DIR=C:/Users/Chris/Documents/Code/liberdus/liberdus-proxy +LIBERDUS_WEB_CLIENT_DIR=C:/Users/Chris/Documents/Code/liberdus/web-client-v2 + +# The Shardus network needs one external validator port per node. +# The container runs validators on the Shardus defaults, 9001-9010. +# The host publishes them on 9101-9110 to avoid common local conflicts. +LIBERDUS_NODE_COUNT=10 +LIBERDUS_VALIDATOR_CONTAINER_PORT_START=9001 +LIBERDUS_VALIDATOR_CONTAINER_PORT_END=9010 +LIBERDUS_VALIDATOR_HOST_PORT_START=9101 +LIBERDUS_VALIDATOR_HOST_PORT_END=9110 +LIBERDUS_INTERNAL_PORT_START=10001 + +# Host-facing ports. +LIBERDUS_PUBLIC_HOST=127.0.0.1 +LIBERDUS_MONITOR_HOST_PORT=3000 +LIBERDUS_ARCHIVER_HOST_PORT=4000 +LIBERDUS_PROXY_HOST_PORT=3030 +LIBERDUS_PROXY_WS_HOST_PORT=3031 +LIBERDUS_WEB_CLIENT_HOST_PORT=8080 diff --git a/playwright-tests/.env.example b/playwright-tests/.env.example new file mode 100644 index 0000000..da3c15e --- /dev/null +++ b/playwright-tests/.env.example @@ -0,0 +1,3 @@ +# Defaults remain devnet when this file is absent. +# Copy to .env to run tests against the Docker Compose local network. +PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080/ diff --git a/playwright-tests/helpers/global-setup.js b/playwright-tests/helpers/global-setup.js index a87ec0f..7df0e99 100644 --- a/playwright-tests/helpers/global-setup.js +++ b/playwright-tests/helpers/global-setup.js @@ -2,7 +2,7 @@ * Playwright global setup * * Purpose: - * - Read `baseURL` from Playwright config + * - Read `baseURL` from Playwright's resolved config * - Fetch `${baseURL}/network.js` and extract the first gateway web URL * - Fetch the zero-account from the gateway and read network parameters * - Convert USD values to LIB using stabilityFactor @@ -10,7 +10,7 @@ * * Strictness: * - If any step fails, throw to abort the test run - * - No env or hardcoded fallbacks for baseURL or network params + * - No hardcoded fallbacks for network params */ const fs = require('fs'); @@ -18,33 +18,19 @@ const path = require('path'); const http = require('http'); const https = require('https'); -/** - * Extract the active baseURL from `playwright.config.ts`. - * - Ignores commented lines - * - Uses the last valid occurrence if multiple - * - Throws if not found (no fallback) - */ -function readBaseURLFromPlaywrightConfig() { - try { - const cfgPath = path.resolve(__dirname, '..', 'playwright.config.ts'); - const text = fs.readFileSync(cfgPath, 'utf8'); - const lines = text.split(/\r?\n/); - let candidate; - for (const line of lines) { - const idx = line.indexOf('baseURL:'); - if (idx === -1) continue; - const trimmed = line.trim(); - if (/^\/\//.test(trimmed)) continue; - const cmt = line.indexOf('//'); - if (cmt !== -1 && cmt < idx) continue; - const m = line.match(/baseURL:\s*['\"]([^'\"]+)['\"]/); - if (m && m[1]) candidate = m[1]; - } - if (candidate) return candidate; - throw new Error('baseURL not found in playwright.config.ts'); - } catch (e) { - throw new Error(`Failed to read baseURL from config: ${e && e.message ? e.message : e}`); - } +function resolveBaseURL(config) { + const envBaseURL = process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL; + if (envBaseURL) return envBaseURL; + + const projectBaseURL = config && config.projects && config.projects[0] && config.projects[0].use + ? config.projects[0].use.baseURL + : undefined; + if (projectBaseURL) return projectBaseURL; + + const sharedBaseURL = config && config.use ? config.use.baseURL : undefined; + if (sharedBaseURL) return sharedBaseURL; + + throw new Error('baseURL not found in Playwright config'); } /** @@ -83,7 +69,7 @@ function fetchText(url, timeoutMs = 15000, maxRedirects = 5) { }); } -async function globalSetup() { +async function globalSetup(config) { const outDir = path.resolve(__dirname, '..', '.cache'); // Clear any previous cache to ensure fresh fetch each run try { @@ -91,8 +77,8 @@ async function globalSetup() { } catch {} fs.mkdirSync(outDir, { recursive: true }); - // 1) Discover base URL from Playwright config - const baseURL = readBaseURLFromPlaywrightConfig(); + // 1) Discover base URL from Playwright's resolved config + const baseURL = resolveBaseURL(config); const networkJsUrl = baseURL.replace(/\/+$/, '') + '/network.js'; // 2) Fetch and evaluate network.js to extract gateway web URL diff --git a/playwright-tests/playwright.config.ts b/playwright-tests/playwright.config.ts index 3264602..1bc963b 100644 --- a/playwright-tests/playwright.config.ts +++ b/playwright-tests/playwright.config.ts @@ -4,9 +4,11 @@ import { defineConfig, devices } from '@playwright/test'; * Read environment variables from file. * https://github.com/motdotla/dotenv */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +const baseURL = process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL || 'https://liberdus.com/dev/'; /** * See https://playwright.dev/docs/test-configuration. @@ -27,7 +29,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: 'https://liberdus.com/dev/', + baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', diff --git a/scripts/local-network/healthcheck.sh b/scripts/local-network/healthcheck.sh new file mode 100644 index 0000000..e7d715c --- /dev/null +++ b/scripts/local-network/healthcheck.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +web_port="${LIBERDUS_WEB_CLIENT_PORT:-8080}" +proxy_port="${LIBERDUS_PROXY_HTTP_PORT:-3030}" +zero_account="0000000000000000000000000000000000000000000000000000000000000000" + +curl -fsS "http://127.0.0.1:${web_port}/network.js" >/dev/null +curl -fsS "http://127.0.0.1:${proxy_port}/account/${zero_account}" \ + | jq -e '.account.current.stabilityFactorStr and .account.networkId' >/dev/null +curl -fsS "http://127.0.0.1:4000/cycleinfo/1" \ + | jq -e '((.cycleInfo // []) | length) > 0 and .cycleInfo[0].mode == "processing"' >/dev/null diff --git a/scripts/local-network/start.sh b/scripts/local-network/start.sh new file mode 100644 index 0000000..26e084b --- /dev/null +++ b/scripts/local-network/start.sh @@ -0,0 +1,308 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +log() { + printf '[liberdus-local] %s\n' "$*" >&2 +} + +require_dir() { + local label="$1" + local dir="$2" + if [ ! -d "$dir" ]; then + log "Missing ${label}: ${dir}" + exit 1 + fi +} + +sync_repo() { + local src="$1" + local dest="$2" + shift 2 + mkdir -p "$dest" + rsync -a --delete "$@" "${src}/" "${dest}/" +} + +hash_file() { + local path="$1" + if [ -f "$path" ]; then + sha256sum "$path" | awk '{print $1}' + else + printf 'missing' + fi +} + +prepare_build_cache() { + local marker_file="$1" + local expected_marker="$2" + shift 2 + + if [ -f "$marker_file" ] && [ "$(cat "$marker_file")" = "$expected_marker" ]; then + return 0 + fi + + log "Clearing stale build cache for ${marker_file}" + rm -rf "$@" +} + +wait_for_network_id() { + local url="$1" + local timeout_seconds="$2" + local started + started="$(date +%s)" + + while true; do + local body + body="$(curl -fsS "$url" 2>/dev/null || true)" + if [ -n "$body" ]; then + local network_id + network_id="$(printf '%s' "$body" | jq -r '.account.networkId // empty' 2>/dev/null || true)" + local stability_factor + stability_factor="$(printf '%s' "$body" | jq -r '.account.current.stabilityFactorStr // empty' 2>/dev/null || true)" + if [ -n "$network_id" ] && [ -n "$stability_factor" ]; then + printf '%s' "$network_id" + return 0 + fi + fi + + if [ -n "${PROXY_PID:-}" ] && ! kill -0 "$PROXY_PID" >/dev/null 2>&1; then + log "Proxy exited while waiting for network parameters; see ${LOG_DIR}/proxy.log" + return 1 + fi + + if [ "$(( $(date +%s) - started ))" -ge "$timeout_seconds" ]; then + log "Timed out waiting for network parameters from ${url}" + return 1 + fi + + sleep 5 + done +} + +wait_for_json_field() { + local url="$1" + local predicate="$2" + local timeout_seconds="$3" + local label="$4" + local started + started="$(date +%s)" + + while true; do + local body + body="$(curl -fsS "$url" 2>/dev/null || true)" + if [ -n "$body" ] && printf '%s' "$body" | jq -e "$predicate" >/dev/null 2>&1; then + return 0 + fi + + if [ "$(( $(date +%s) - started ))" -ge "$timeout_seconds" ]; then + log "Timed out waiting for ${label} from ${url}" + return 1 + fi + + sleep 5 + done +} + +write_proxy_config() { + local proxy_dir="$1" + + jq \ + '.http_port = 3030 + | .archiver_seed_path = "./src/archiver_seed.json" + | .standalone_network.enabled = true + | .standalone_network.replacement_ip = "127.0.0.1" + | .shardus_monitor.upstream_ip = "127.0.0.1" + | .shardus_monitor.upstream_port = 3000 + | .shardus_monitor.https = false + | .local_source.collector_api_ip = "127.0.0.1" + | .local_source.collector_api_port = 6101 + | .local_source.collector_event_server_ip = "127.0.0.1" + | .local_source.collector_event_server_port = 4444' \ + "${proxy_dir}/src/config.json" > "${proxy_dir}/src/config.local.json" + mv "${proxy_dir}/src/config.local.json" "${proxy_dir}/src/config.json" + + cat > "${proxy_dir}/src/archiver_seed.json" <<'JSON' +[{"publicKey":"758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3","port":4000,"ip":"127.0.0.1"}] +JSON +} + +write_web_network() { + local web_dir="$1" + local network_id="$2" + local public_host="$3" + local proxy_public_port="$4" + local proxy_ws_public_port="$5" + + cat > "${web_dir}/network.js" </dev/null 2>&1) + fi + if [ -n "${PROXY_PID:-}" ]; then + kill "$PROXY_PID" >/dev/null 2>&1 + fi + if [ -n "${HTTP_PID:-}" ]; then + kill "$HTTP_PID" >/dev/null 2>&1 + fi +} + +trap stop_stack EXIT INT TERM + +RUNTIME_ROOT="${LIBERDUS_RUNTIME_ROOT:-/workspace/runtime}" +SERVER_SOURCE="${LIBERDUS_SERVER_SOURCE:-/sources/server}" +PROXY_SOURCE="${LIBERDUS_PROXY_SOURCE:-/sources/liberdus-proxy}" +WEB_CLIENT_SOURCE="${LIBERDUS_WEB_CLIENT_SOURCE:-/sources/web-client-v2}" + +NODE_COUNT="${LIBERDUS_NODE_COUNT:-10}" +EXTERNAL_PORT_START="${LIBERDUS_EXTERNAL_PORT_START:-9001}" +INTERNAL_PORT_START="${LIBERDUS_INTERNAL_PORT_START:-10001}" +PUBLIC_HOST="${LIBERDUS_PUBLIC_HOST:-127.0.0.1}" +PROXY_PUBLIC_PORT="${LIBERDUS_PROXY_PUBLIC_PORT:-3030}" +PROXY_WS_PUBLIC_PORT="${LIBERDUS_PROXY_WS_PUBLIC_PORT:-3031}" +WEB_PUBLIC_PORT="${LIBERDUS_WEB_PUBLIC_PORT:-8080}" +SERVER_RUST_TOOLCHAIN="${LIBERDUS_SERVER_RUST_TOOLCHAIN:-1.82.0}" +PROXY_RUST_TOOLCHAIN="${LIBERDUS_PROXY_RUST_TOOLCHAIN:-stable}" + +SERVER_DIR="${RUNTIME_ROOT}/server" +PROXY_DIR="${RUNTIME_ROOT}/liberdus-proxy" +WEB_DIR="${RUNTIME_ROOT}/web-client-v2" +LOG_DIR="${RUNTIME_ROOT}/logs" + +require_dir "server source" "$SERVER_SOURCE" +require_dir "liberdus-proxy source" "$PROXY_SOURCE" +require_dir "web-client-v2 source" "$WEB_CLIENT_SOURCE" + +mkdir -p "$LOG_DIR" + +log "Syncing source repos into Docker volume" +sync_repo "$SERVER_SOURCE" "$SERVER_DIR" \ + --exclude .git --exclude node_modules --exclude instances --exclude dist +sync_repo "$PROXY_SOURCE" "$PROXY_DIR" \ + --exclude .git --exclude target +sync_repo "$WEB_CLIENT_SOURCE" "$WEB_DIR" \ + --exclude .git --exclude node_modules + +write_proxy_config "$PROXY_DIR" + +SERVER_BUILD_MARKER="node=$(node --version);rust=$(rustc +"$SERVER_RUST_TOOLCHAIN" --version);package=$(hash_file "${SERVER_DIR}/package.json");lock=$(hash_file "${SERVER_DIR}/package-lock.json")" +PROXY_BUILD_MARKER="rust=$(rustc +"$PROXY_RUST_TOOLCHAIN" --version);manifest=$(hash_file "${PROXY_DIR}/Cargo.toml")" + +prepare_build_cache "${SERVER_DIR}/node_modules/.local-network-build" "$SERVER_BUILD_MARKER" \ + "${SERVER_DIR}/node_modules" "${SERVER_DIR}/dist" +prepare_build_cache "${PROXY_DIR}/target/.local-network-build" "$PROXY_BUILD_MARKER" \ + "${PROXY_DIR}/target" + +log "Installing and compiling server dependencies" +( + cd "$SERVER_DIR" + export RUSTUP_TOOLCHAIN="$SERVER_RUST_TOOLCHAIN" + npm install + npm run compile + mkdir -p node_modules + printf '%s' "$SERVER_BUILD_MARKER" > node_modules/.local-network-build +) + +log "Resetting any previous Shardus instances" +( + cd "$SERVER_DIR" + shardus-network stop >/dev/null 2>&1 || true + rm -rf instances +) + +export minNodes="$NODE_COUNT" +export baselineNodes="$NODE_COUNT" +export maxNodes="$(( NODE_COUNT * 2 ))" + +log "Creating and starting ${NODE_COUNT}-node Shardus network on validator ports ${EXTERNAL_PORT_START}-$(( EXTERNAL_PORT_START + NODE_COUNT - 1 ))" +( + cd "$SERVER_DIR" + shardus-network create \ + --starting-external-port "$EXTERNAL_PORT_START" \ + --starting-internal-port "$INTERNAL_PORT_START" \ + "$NODE_COUNT" \ + pm2--no-autorestart +) + +log "Waiting for archiver and active nodelist" +wait_for_json_field "http://127.0.0.1:4000/archivers" '((.activeArchivers // .archivers // []) | length) > 0' 600 "active archivers" +wait_for_json_field "http://127.0.0.1:4000/full-nodelist?activeOnly=true" '((.nodeList // .nodes // .nodelist // []) | length) > 0' 600 "active nodelist" +wait_for_json_field "http://127.0.0.1:4000/cycleinfo/1" "((.cycleInfo // []) | length) > 0 and (.cycleInfo[0].mode == \"processing\") and ((.cycleInfo[0].active // 0) >= ${NODE_COUNT})" 2400 "processing cycle" + +log "Building Liberdus proxy" +( + cd "$PROXY_DIR" + cargo +"$PROXY_RUST_TOOLCHAIN" build + mkdir -p target + printf '%s' "$PROXY_BUILD_MARKER" > target/.local-network-build +) + +log "Starting Liberdus proxy" +( + cd "$PROXY_DIR" + cargo +"$PROXY_RUST_TOOLCHAIN" run > "${LOG_DIR}/proxy.log" 2>&1 +) & +PROXY_PID=$! + +ZERO_ACCOUNT="0000000000000000000000000000000000000000000000000000000000000000" +NETWORK_ID="$(wait_for_network_id "http://127.0.0.1:3030/account/${ZERO_ACCOUNT}" 600)" +log "Detected local network id: ${NETWORK_ID}" + +write_web_network "$WEB_DIR" "$NETWORK_ID" "$PUBLIC_HOST" "$PROXY_PUBLIC_PORT" "$PROXY_WS_PUBLIC_PORT" + +log "Starting web client on http://${PUBLIC_HOST}:${WEB_PUBLIC_PORT}/" +( + cd "$WEB_DIR" + python3 -m http.server 8080 --bind 0.0.0.0 > "${LOG_DIR}/web-client.log" 2>&1 +) & +HTTP_PID=$! + +log "Local network is ready" +log "Monitor: http://${PUBLIC_HOST}:${LIBERDUS_MONITOR_PUBLIC_PORT:-3000}/" +log "Proxy: http://${PUBLIC_HOST}:${PROXY_PUBLIC_PORT}/" +log "Web client: http://${PUBLIC_HOST}:${WEB_PUBLIC_PORT}/" + +while true; do + if ! kill -0 "$PROXY_PID" >/dev/null 2>&1; then + log "Proxy exited; see ${LOG_DIR}/proxy.log in the local-network-runtime volume" + exit 1 + fi + if ! kill -0 "$HTTP_PID" >/dev/null 2>&1; then + log "Web client server exited; see ${LOG_DIR}/web-client.log in the local-network-runtime volume" + exit 1 + fi + sleep 5 +done From 128edd551ad52dcbdf7ea0d18f23b526c9a17e60 Mon Sep 17 00:00:00 2001 From: chrypnotoad Date: Fri, 1 May 2026 13:04:12 -0500 Subject: [PATCH 02/11] Improve local network startup caching --- .docker/local-network.Dockerfile | 19 +- README.md | 18 +- docker-compose.local.yml | 4 +- docs/local-network-handoff.md | 253 +++++++++++++++--- local-network.env.example | 10 +- playwright-tests/helpers/global-setup.js | 34 ++- scripts/local-network/healthcheck.sh | 8 +- scripts/local-network/start.sh | 311 ++++++++++++++++++----- 8 files changed, 529 insertions(+), 128 deletions(-) diff --git a/.docker/local-network.Dockerfile b/.docker/local-network.Dockerfile index 6801d38..c888011 100644 --- a/.docker/local-network.Dockerfile +++ b/.docker/local-network.Dockerfile @@ -1,8 +1,11 @@ FROM ubuntu:22.04 -ARG NODE_VERSION=20.19.3 -ARG SERVER_RUST_VERSION=1.82.0 -ARG PROXY_RUST_VERSION=stable +ARG NODE_VERSION=18.19.1 +ARG TARGETARCH +# The server's native dependency tree currently pulls time 0.3.31, which fails +# with rustc >= 1.80. Keep this pin until the server dependencies move forward. +ARG SERVER_RUST_VERSION=1.79.0 +ARG PROXY_RUST_VERSION=1.82.0 ENV DEBIAN_FRONTEND=noninteractive ENV PATH="/opt/node/bin:/root/.cargo/bin:${PATH}" @@ -26,7 +29,12 @@ RUN apt-get update \ xz-utils \ && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz \ +RUN case "${TARGETARCH:-amd64}" in \ + amd64) node_arch="x64" ;; \ + arm64) node_arch="arm64" ;; \ + *) echo "Unsupported Docker target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" -o /tmp/node.tar.xz \ && mkdir -p /opt/node \ && tar -xJf /tmp/node.tar.xz -C /opt/node --strip-components=1 \ && rm /tmp/node.tar.xz \ @@ -40,9 +48,6 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --default-toolchain "${SERVER_RUST_VERSION}" --profile minimal \ && rustup toolchain install "${PROXY_RUST_VERSION}" --profile minimal -RUN npm install -g https://github.com/shardus/tools-cli-shardus-network.git \ - && npm cache clean --force - WORKDIR /workspace/client-testing COPY scripts/local-network /usr/local/bin/liberdus-local-network diff --git a/README.md b/README.md index 630ac14..bb912d1 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The Playwright tests default to `https://liberdus.com/dev/`, but can be pointed docker compose --env-file local-network.env -f docker-compose.local.yml up --build ``` - This starts a 10-node Shardus network, the Liberdus proxy, and a static `web-client-v2` server. The source repos are mounted read-only and copied into a Docker volume before generated local config is written. The image uses Node.js 20.19.3, Rust 1.82.0 for server dependencies, and stable Rust for proxy dependencies. The web client is served only after the network reports `processing` mode. + This starts a 5-node Shardus network, the Liberdus proxy, and a static `web-client-v2` server. The source repos are mounted read-only and copied into a Docker volume before generated local config is written. The image uses Node.js 18.19.1, Rust 1.79.0 for server dependencies, and Rust 1.82.0 for proxy dependencies. The web client is served only after the network reports `processing` mode. Warm runs reuse cached server dependencies, server build output, and proxy build output from the Docker volume unless the source, lockfiles, toolchains, or volume change. 3. In a second shell, configure Playwright for the local web client: @@ -76,6 +76,8 @@ Default local URLs: If you change `LIBERDUS_NODE_COUNT`, also update `LIBERDUS_VALIDATOR_CONTAINER_PORT_END` and `LIBERDUS_VALIDATOR_HOST_PORT_END` so each range has one port per node. +The 5-node default is intended to speed up local startup. If e2e account-creation tests hang on `Creating account...`, use a 10-node env while the 5-node transaction path is being investigated. + ## Manually Setting up a Local Liberdus Network with web-client-v2 1. Setup an environment with the following software: @@ -88,9 +90,9 @@ If you change `LIBERDUS_NODE_COUNT`, also update `LIBERDUS_VALIDATOR_CONTAINER_P * pkg-config 1.8.0 - * Node.js 18.16.1 + * Node.js 18.19.1 - * Rust 1.74 + * Rust 1.79.0 for the server, plus Rust 1.82.0 for `liberdus-proxy` * Python 3.x @@ -100,12 +102,6 @@ If you change `LIBERDUS_NODE_COUNT`, also update `LIBERDUS_VALIDATOR_CONTAINER_P * Navigate to the repo's root directory and run `npm install`. - * https://github.com/shardus/tools-cli-shardus-network.git - - - Navigate to the repo's root directory and run `npm install`. - - - Run `npm link` in the root directory of this repo after installing dependencies to put the `shardus-network` command into the path. - * https://github.com/Liberdus/web-client-v2.git * No dependencies to install. @@ -116,13 +112,13 @@ If you change `LIBERDUS_NODE_COUNT`, also update `LIBERDUS_VALIDATOR_CONTAINER_P 3. Start a local network of Liberdus nodes. - * Navigate to the `Liberdus/server` repo and run `shardus-network start 10` to start a local network of 10 nodes. + * Navigate to the `Liberdus/server` repo and run `shardus start 5` to start a local network of 5 nodes. * The minimum number of nodes needed to process transactions is defined by the `minNodes` property in `server/src/config/index.ts`: ```js ... - minNodes: process.env.minNodes ? parseInt(process.env.minNodes) : 10, + minNodes: process.env.minNodes ? parseInt(process.env.minNodes) : 5, ... ``` diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 2da92d1..a64b9a5 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -9,7 +9,7 @@ services: LIBERDUS_PROXY_SOURCE: /sources/liberdus-proxy LIBERDUS_WEB_CLIENT_SOURCE: /sources/web-client-v2 LIBERDUS_RUNTIME_ROOT: /workspace/runtime - LIBERDUS_NODE_COUNT: ${LIBERDUS_NODE_COUNT:-10} + LIBERDUS_NODE_COUNT: ${LIBERDUS_NODE_COUNT:-5} LIBERDUS_EXTERNAL_PORT_START: ${LIBERDUS_VALIDATOR_CONTAINER_PORT_START:-9001} LIBERDUS_INTERNAL_PORT_START: ${LIBERDUS_INTERNAL_PORT_START:-10001} LIBERDUS_PUBLIC_HOST: ${LIBERDUS_PUBLIC_HOST:-127.0.0.1} @@ -28,7 +28,7 @@ services: - "${LIBERDUS_PROXY_HOST_PORT:-3030}:3030" - "${LIBERDUS_PROXY_WS_HOST_PORT:-3031}:3031" - "${LIBERDUS_WEB_CLIENT_HOST_PORT:-8080}:8080" - - "${LIBERDUS_VALIDATOR_HOST_PORT_START:-9101}-${LIBERDUS_VALIDATOR_HOST_PORT_END:-9110}:${LIBERDUS_VALIDATOR_CONTAINER_PORT_START:-9001}-${LIBERDUS_VALIDATOR_CONTAINER_PORT_END:-9010}" + - "${LIBERDUS_VALIDATOR_HOST_PORT_START:-9101}-${LIBERDUS_VALIDATOR_HOST_PORT_END:-9105}:${LIBERDUS_VALIDATOR_CONTAINER_PORT_START:-9001}-${LIBERDUS_VALIDATOR_CONTAINER_PORT_END:-9005}" healthcheck: test: ["CMD", "/usr/local/bin/liberdus-local-network/healthcheck.sh"] interval: 15s diff --git a/docs/local-network-handoff.md b/docs/local-network-handoff.md index b35fdb0..69f36f3 100644 --- a/docs/local-network-handoff.md +++ b/docs/local-network-handoff.md @@ -1,6 +1,6 @@ # Local Network Docker Compose Handoff -Date: 2026-04-30 +Date: 2026-05-01 Branch: `client-testing-local-network` Repository: `Liberdus/client-testing` @@ -12,19 +12,108 @@ The current implementation focuses on standing up the local network locally with ## Current Status -The branch contains a Docker Compose based local-network harness and Playwright configuration changes. The local stack reached Docker `healthy` before the final readiness fix, and the web client served from: +The branch contains a Docker Compose based local-network harness and Playwright configuration changes. The default local Docker network has been changed to 5 validators to reduce startup time, and startup now uses `shardus start` plus source-aware cache markers for: -- `http://127.0.0.1:8080/` -- proxy at `http://127.0.0.1:3030/` -- monitor at `http://127.0.0.1:3000/` +- server dependencies +- server `dist` +- Rust proxy `target` -A Playwright smoke test was then run against the local web client. It reached account creation but failed because the Shardus network was still in `forming` mode and rejected app transactions with: +Current state as of this handoff: + +- A 5-node Docker Compose run reaches Docker `healthy`. +- The archiver reports `processing` with 5 active validators. +- The local web client is served successfully. +- The single account-create smoke does **not** pass on 5 nodes yet; it hangs on `Creating account...`. + +The currently running local stack was left up for inspection on non-default host ports from `local-network.env`: + +- web client: `http://127.0.0.1:8088/` +- proxy: `http://127.0.0.1:3038/` +- proxy WebSocket: `ws://127.0.0.1:3039/` +- archiver: `http://127.0.0.1:4008/` +- monitor: `http://127.0.0.1:3008/` +- validators: host `9101-9105` -> container `9001-9005` + +The most recent status check showed: + +```json +{ + "mode": "processing", + "active": 5, + "standby": 0, + "syncing": 0, + "desired": 5, + "target": 5 +} +``` + +Known-good e2e baseline: 10 nodes passed the same single smoke both outside Docker and in Docker Compose. + +Known-bad current 5-node e2e behavior: the network becomes ready, but account creation stalls after the register transaction. Validator logs show the register tx applying on all 5 validators, then receipt/read-repair trouble: + +- `Receipt does not have the required majority` +- `txSafelyRemoved_3 stuck_in_consensus_3` +- repeated `isInSync = false` +- repeated `getAccountRepairData no node avail` +- proxy collector/read errors with `Connection refused (os error 111)` + +So the branch is better for startup speed, but 5 nodes should not be treated as e2e-ready until the consensus/read-after-write issue is solved. For GitHub Actions, the practical choice is probably to run 10 nodes for now, or keep this branch at 5 nodes while investigating the server-side config needed to make 5-node e2e reliable. + +## Validation Timeline + +The first local smoke failure happened before the processing readiness gate. The web client reached account creation while the network was still in `forming`, and the server rejected app transactions with: ```text Error injecting transaction: Application transactions are only allowed in processing Mode. ``` -After that failure, the startup script and healthcheck were changed so the local stack waits for a `processing` cycle before declaring itself ready or serving the web client. That final processing-mode readiness change has had syntax/config validation but still needs a full Docker rerun. +After that failure, the startup script and healthcheck were changed so the local stack waits for a `processing` cycle before declaring itself ready or serving the web client. + +The stack was then run outside Docker using the same startup script logic and `shardus start`. The network reached `processing` with 10 active validators, the Rust proxy served the zero account, and this single Playwright smoke passed: + +```bash +PLAYWRIGHT_BASE_URL='http://127.0.0.1:8088/' npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 --reporter=line +``` + +Result: + +```text +1 passed (25.9s) +``` + +A clean Docker Compose 10-node run was started with: + +```bash +docker compose --env-file local-network.env -f docker-compose.local.yml down -v --remove-orphans +docker compose --env-file local-network.env -f docker-compose.local.yml up --build +``` + +The container became Docker `healthy`, reached `processing` with 10 active validators, served `network.js`, and the same single Playwright smoke passed against the Docker stack: + +```text +1 passed (23.0s) +``` + +The first clean Docker run paid the full server and proxy native build cost. Server `npm install` took about 16 minutes, and proxy `cargo build` took about 2 minutes. + +After the cache/default update, a warm 5-node rerun reused server dependencies, rebuilt server `dist` because the marker format changed, reached Docker `healthy` and `processing` with 5 active validators, and then rebuilt the proxy once because the proxy marker also changed. Proxy rebuild took 1m 46s; the next warm run should skip that proxy build too. + +The single Playwright smoke was then run against the 5-node stack: + +```bash +PLAYWRIGHT_BASE_URL='http://127.0.0.1:8088/' npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 --reporter=line +``` + +Result: + +```text +1 failed after 5.0m +Test timeout of 300000ms exceeded while setting up "page". +waiting for locator('.toast.loading.show') to be detached +locator resolved to visible: Creating account... +``` + +The 5-node network should not be treated as e2e-ready yet. It can start and report healthy, but account creation currently hangs after the register transaction. ## Files Changed @@ -32,11 +121,11 @@ After that failure, the startup script and healthcheck were changed so the local - `.docker/local-network.Dockerfile` - Builds an Ubuntu 22.04 image. - - Installs Node.js 20.19.3. + - Installs Node.js 18.19.1. - Installs two Rust toolchains: - - Rust 1.82.0 for `server` native dependencies. - - stable Rust for `liberdus-proxy`. - - Installs the Shardus network CLI from `tools-cli-shardus-network`. + - Rust 1.79.0 for `server` native dependencies. + - Rust 1.82.0 for `liberdus-proxy`. + - Uses the `shardus` CLI installed by the server repo dependencies. - Copies the local-network scripts into the image. - `docker-compose.local.yml` @@ -52,7 +141,7 @@ After that failure, the startup script and healthcheck were changed so the local - host `3030` -> proxy HTTP - host `3031` -> proxy WebSocket - host `8080` -> web client - - host `9101-9110` -> container validator ports `9001-9010` + - host `9101-9105` -> container validator ports `9001-9005` - `local-network.env.example` - Windows-oriented default paths for Chris's local repos. @@ -67,13 +156,18 @@ After that failure, the startup script and healthcheck were changed so the local - `scripts/local-network/start.sh` - Syncs mounted source repos into `/workspace/runtime`. - Writes generated proxy config and archiver seed. - - Installs/compiles the server with Rust 1.82.0. - - Starts a 10-node Shardus network. + - Reuses cached server dependencies and build output when source, lockfiles, and toolchains are unchanged. + - Installs/compiles the server with Rust 1.79.0 when the cache is stale. + - Starts a 5-node Shardus network with `shardus start`. + - Restarts `monitor-server` once if it starts under PM2 but does not bind port `3000`. + - Restarts validator PM2 processes once if their validator ports do not bind after startup. - Waits for: - active archiver + - monitor HTTP response - active nodelist - `cycleinfo` mode `processing` - - Builds and starts the Rust proxy with stable Rust. + - Reuses the cached Rust proxy build when source, lockfile, and toolchain are unchanged. + - Builds and starts the Rust proxy with Rust 1.82.0 when the cache is stale. - Fetches the local network ID from the proxy. - Writes `web-client-v2/network.js`. - Serves the web client on port `8080`. @@ -94,6 +188,7 @@ After that failure, the startup script and healthcheck were changed so the local - `playwright-tests/helpers/global-setup.js` - Uses the Playwright config/env base URL instead of parsing `playwright.config.ts` as text. + - Accepts the current local gateway account shape where amount fields are bigint objects and `stabilityFactorStr` may be missing. - `playwright-tests/.env.example` - Sets: @@ -192,44 +287,116 @@ PLAYWRIGHT_BASE_URL='http://127.0.0.1:8080/' npx playwright test tests/smoke.e2e This test creates and signs in a fresh user, then checks Contacts and Wallet navigation. It is a better smoke than payment/transfer tests because it avoids additional funding and recipient setup. -## Validation Run So Far +## Validation Commands -Commands that passed before this handoff: +Commands that passed on May 1 after the latest changes: -```powershell +```bash bash -n scripts/local-network/start.sh bash -n scripts/local-network/healthcheck.sh docker compose --env-file local-network.env.example -f docker-compose.local.yml config --quiet -cd playwright-tests -npm ci -npx playwright test --list tests/createAccount.e2e.test.js --project=chromium +git diff --check ``` -Docker image build succeeded after resolving toolchain issues. The stack reached `healthy` before the final processing-mode gate was added. +Runtime validation: -The targeted Playwright smoke was run and failed because the network had not reached processing mode yet. That failure is the reason the final readiness gate was added. +- 10-node outside-Docker smoke passed: `1 passed (25.9s)`. +- 10-node Docker Compose smoke passed: `1 passed (23.0s)`. +- 5-node Docker Compose startup passed readiness: Docker `healthy`, cycle `processing`, `active: 5`. +- 5-node Docker Compose smoke failed during account creation: + + ```text + 1 failed after 5.0m + Test timeout of 300000ms exceeded while setting up "page". + waiting for locator('.toast.loading.show') to be detached + locator resolved to visible: Creating account... + ``` ## Important Findings +### 5-Node Transaction Issue + +The default 5-node stack reaches `processing` and Docker `healthy`, but the account-create transaction does not complete from the web client's point of view. + +Observed during the failed smoke: + +- Current cycle stayed healthy: + +```json +{ + "mode": "processing", + "active": 5, + "standby": 0, + "syncing": 0, + "desired": 5, + "target": 5 +} +``` + +- Browser stayed on `Creating account...` for the full 300s Playwright timeout. +- Validator app logs showed `Applied register tx` on all 5 validators for username `c58100289776418`. +- Validator error/fatal logs then showed: + - `Receipt does not have the required majority for txid: 9b9f7521aaa06a5dd200dc6577de6b39917cc63165110819225d596d0a9ee115` + - `txSafelyRemoved_3 stuck_in_consensus_3` + - repeated `isInSync = false` + - repeated `getAccountRepairData no node avail` +- Proxy logs showed repeated collector/read errors after the client activity: + - `Error handling collector request: Connection refused (os error 111)` + +Likely interpretation: 5 nodes are enough for startup/readiness, but not enough for the current server/shardus data-sync or read-after-write path used by account creation. The prior 10-node stack passed the same smoke, so the 5-node default is faster but currently exposes a consensus/repair visibility issue that needs a config fix or a higher node count for e2e. + +### Dev Network Account Comparison + +The dev network zero account was checked at: + +```text +https://dev.liberdus.com:3030/account/0000000000000000000000000000000000000000000000000000000000000000 +``` + +The local zero account has the same top-level `NetworkAccount` shape, but the local server checkout is older (`2.3.4`) than dev (`2.4.8`). Exact-matching dev's version fields on the older checkout is risky because validators can reject app versions outside the network account's allowed range. + +Notable dev values: + +- `activeVersion`, `latestVersion`, `minVersion`: `2.4.8` +- `stabilityScaleMul`: `125` +- `stabilityScaleDiv`: `1` +- `stabilityFactorStr`: `0.008` +- `tollTimeout`: `60000` +- `transactionFeeUsdStr`: `0.01` +- `minTollUsdStr`: `0.05` +- `defaultTollUsdStr`: `0.05` +- `messageRetentionDays`: `7` +- `messageMaxLength`: `500` + +The local outside-Docker run had: + +- `activeVersion`, `latestVersion`, `minVersion`: `2.3.4` +- `stabilityScaleMul`: `1000` +- `stabilityScaleDiv`: `1000` +- no string fee fields +- `tollTimeout`: `604800000` + +Shardus runtime knobs such as `minNodes`, `baselineNodes`, and `forceBogonFilteringOn` are not in the network account. They come from `server/src/config/index.ts` / the local-network patch path. + ### Toolchain Split Is Necessary The server and proxy currently need different Rust behavior: -- The server dependency tree built successfully with Rust 1.82.0. -- Newer stable Rust failed the server build because a native dependency denies warnings. -- The proxy has no committed `Cargo.lock`, and current dependency resolution pulls Rust-2024-era crates that need a newer stable compiler. +- The server dependency tree needs Rust 1.79.0 because current native dependencies pull `time` 0.3.31, which fails with rustc 1.80+. +- Newer Rust failed the server build in that dependency before the local network could start. +- The proxy repo pins Rust 1.82.0 and has a committed `Cargo.lock`. -The Dockerfile therefore installs both Rust 1.82.0 and stable Rust, and the startup script selects the toolchain explicitly. +The Dockerfile therefore installs both Rust 1.79.0 and Rust 1.82.0, and the startup script selects the toolchain explicitly. ### Shardus Port Behavior -The Shardus CLI still creates node folders and validator listeners on `9001-9010`. Compose maps those to host ports `9101-9110` to avoid common host conflicts: +The Shardus CLI still creates node folders and validator listeners on `9001-9005` for the default 5-node stack. Compose maps those to host ports `9101-9105` to avoid common host conflicts: ```text host 9101 -> container 9001 host 9102 -> container 9002 ... -host 9110 -> container 9010 +host 9105 -> container 9005 ``` Do not expect the folder names to match the host-facing ports. @@ -246,17 +413,28 @@ The latest script now waits for: and requires the active count to be at least `LIBERDUS_NODE_COUNT`. -This is the most important next thing to verify with a clean full rerun. +This has now been verified with both a 10-node run and a 5-node warm-cache run. ## Known Gaps / Next Steps -1. Rebuild and rerun the compose stack after the processing-mode readiness patch. +1. Investigate why the 5-node stack applies account registration but leaves the browser stuck waiting for create-account completion. + + Useful checks from the failed run: + + ```bash + docker exec client-testing-local-network-1 bash -lc 'grep -R "Receipt does not have the required majority\|stuck_in_consensus\|isInSync = false\|getAccountRepairData no node avail" -n /workspace/runtime/server/instances/shardus-instance-900*/logs' + docker exec client-testing-local-network-1 bash -lc 'tr "\r" "\n" < /workspace/runtime/logs/proxy.log | grep -Eiv "Active Connection Streams: 0|^$" | tail -n 200' + ``` + +2. Decide whether CI should use 10 nodes for reliable e2e while 5-node consensus/read repair is investigated, or whether the server config should be changed to make 5 nodes transaction-safe. + +3. Run one more warm Docker-volume pass to confirm all build caches skip together after the new marker files have been written. ```bash docker compose --env-file local-network.env -f docker-compose.local.yml up --build ``` -2. Confirm Docker health does not turn healthy until the latest cycle is in processing mode. +4. Confirm Docker health does not turn healthy until the latest cycle is in processing mode. Useful checks: @@ -265,9 +443,9 @@ This is the most important next thing to verify with a clean full rerun. docker exec client-testing-local-network-1 bash -lc 'curl -fsS http://127.0.0.1:4000/cycleinfo/1 | jq ".cycleInfo[0] | {counter, mode, active, desired, syncing, target}"' ``` -3. Rerun the smoke test. +5. Rerun the smoke test after choosing the node count/config path. -4. If the network never reaches processing, inspect Shardus node logs: +6. If the network never reaches processing, inspect Shardus node logs: ```bash docker exec client-testing-local-network-1 bash -lc 'tail -160 /workspace/runtime/server/instances/shardus-instance-9001/logs/cycle.log' @@ -275,7 +453,7 @@ This is the most important next thing to verify with a clean full rerun. docker exec client-testing-local-network-1 bash -lc 'tail -160 /workspace/runtime/server/instances/archiver-logs/127.0.0.1_4000/main.log' ``` -5. Once the local path is stable, adapt this into GitHub Actions. +7. Once the local path is stable, adapt this into GitHub Actions. The likely workflow shape is: @@ -289,10 +467,11 @@ This is the most important next thing to verify with a clean full rerun. ## Notes For The Next Thread -Start by reading this file and checking the branch diff. The most recent unverified change is the processing-mode readiness gate in: +Start by reading this file and checking the branch diff. The most recent change is the default 5-node stack plus source-aware build cache markers in: - `scripts/local-network/start.sh` -- `scripts/local-network/healthcheck.sh` +- `docker-compose.local.yml` +- `local-network.env.example` There is an unrelated untracked file in this worktree that was intentionally not included: diff --git a/local-network.env.example b/local-network.env.example index d577dc1..f426f57 100644 --- a/local-network.env.example +++ b/local-network.env.example @@ -6,13 +6,13 @@ LIBERDUS_PROXY_DIR=C:/Users/Chris/Documents/Code/liberdus/liberdus-proxy LIBERDUS_WEB_CLIENT_DIR=C:/Users/Chris/Documents/Code/liberdus/web-client-v2 # The Shardus network needs one external validator port per node. -# The container runs validators on the Shardus defaults, 9001-9010. -# The host publishes them on 9101-9110 to avoid common local conflicts. -LIBERDUS_NODE_COUNT=10 +# The container runs validators on the Shardus defaults, 9001-9005. +# The host publishes them on 9101-9105 to avoid common local conflicts. +LIBERDUS_NODE_COUNT=5 LIBERDUS_VALIDATOR_CONTAINER_PORT_START=9001 -LIBERDUS_VALIDATOR_CONTAINER_PORT_END=9010 +LIBERDUS_VALIDATOR_CONTAINER_PORT_END=9005 LIBERDUS_VALIDATOR_HOST_PORT_START=9101 -LIBERDUS_VALIDATOR_HOST_PORT_END=9110 +LIBERDUS_VALIDATOR_HOST_PORT_END=9105 LIBERDUS_INTERNAL_PORT_START=10001 # Host-facing ports. diff --git a/playwright-tests/helpers/global-setup.js b/playwright-tests/helpers/global-setup.js index 7df0e99..df566e5 100644 --- a/playwright-tests/helpers/global-setup.js +++ b/playwright-tests/helpers/global-setup.js @@ -69,6 +69,28 @@ function fetchText(url, timeoutMs = 15000, maxRedirects = 5) { }); } +function parseNumber(value, fallback = 0) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function parseLiberdusAmount(value, fallback = 0) { + if (typeof value === 'number') return Number.isFinite(value) ? value : fallback; + if (typeof value === 'string') return parseNumber(value, fallback); + if (!value || typeof value !== 'object') return fallback; + + if (value.dataType === 'bi' && typeof value.value === 'string') { + try { + return Number(BigInt('0x' + value.value)) / 1e18; + } catch { + return fallback; + } + } + + if ('value' in value) return parseNumber(value.value, fallback); + return fallback; +} + async function globalSetup(config) { const outDir = path.resolve(__dirname, '..', '.cache'); // Clear any previous cache to ensure fresh fetch each run @@ -107,9 +129,15 @@ async function globalSetup(config) { const account = JSON.parse(acctText); const current = account && account.account && account.account.current ? account.account.current : {}; - const stabilityFactor = parseFloat(current.stabilityFactorStr || '0'); - const feeUsd = parseFloat(current.transactionFeeUsdStr || '0'); - const minTollUsd = parseFloat(current.minTollUsdStr || '0'); + const stabilityFactor = current.stabilityFactorStr + ? parseNumber(current.stabilityFactorStr) + : parseLiberdusAmount(current.stabilityScaleMul) / parseLiberdusAmount(current.stabilityScaleDiv, 1); + const feeUsd = current.transactionFeeUsdStr + ? parseNumber(current.transactionFeeUsdStr) + : parseLiberdusAmount(current.transactionFee); + const minTollUsd = current.minTollUsdStr + ? parseNumber(current.minTollUsdStr) + : parseLiberdusAmount(current.defaultToll); const networkTollTaxPercent = Number(current.tollNetworkTaxPercent || 0); // USD → LIB conversions using: LIB = USD / stabilityFactor diff --git a/scripts/local-network/healthcheck.sh b/scripts/local-network/healthcheck.sh index e7d715c..d19ac29 100644 --- a/scripts/local-network/healthcheck.sh +++ b/scripts/local-network/healthcheck.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash set -euo pipefail -web_port="${LIBERDUS_WEB_CLIENT_PORT:-8080}" -proxy_port="${LIBERDUS_PROXY_HTTP_PORT:-3030}" +web_port="${LIBERDUS_WEB_BIND_PORT:-${LIBERDUS_WEB_CLIENT_PORT:-8080}}" +proxy_port="${LIBERDUS_PROXY_BIND_PORT:-${LIBERDUS_PROXY_HTTP_PORT:-3030}}" zero_account="0000000000000000000000000000000000000000000000000000000000000000" curl -fsS "http://127.0.0.1:${web_port}/network.js" >/dev/null curl -fsS "http://127.0.0.1:${proxy_port}/account/${zero_account}" \ - | jq -e '.account.current.stabilityFactorStr and .account.networkId' >/dev/null + | jq -e '.account.type == "NetworkAccount"' >/dev/null curl -fsS "http://127.0.0.1:4000/cycleinfo/1" \ - | jq -e '((.cycleInfo // []) | length) > 0 and .cycleInfo[0].mode == "processing"' >/dev/null + | jq -e '((.cycleInfo // []) | length) > 0 and .cycleInfo[0].mode == "processing" and (.cycleInfo[0].networkId | type == "string")' >/dev/null diff --git a/scripts/local-network/start.sh b/scripts/local-network/start.sh index 26e084b..0f86f98 100644 --- a/scripts/local-network/start.sh +++ b/scripts/local-network/start.sh @@ -31,36 +31,65 @@ hash_file() { fi } -prepare_build_cache() { +hash_paths() { + local root="$1" + shift + + if [ ! -d "$root" ]; then + printf 'missing' + return 0 + fi + + ( + cd "$root" + find "$@" -type f -print0 2>/dev/null \ + | sort -z \ + | xargs -0 -r sha256sum \ + | sha256sum \ + | awk '{print $1}' + ) +} + +cache_valid() { local marker_file="$1" local expected_marker="$2" shift 2 - if [ -f "$marker_file" ] && [ "$(cat "$marker_file")" = "$expected_marker" ]; then - return 0 - fi + [ -f "$marker_file" ] || return 1 + [ "$(cat "$marker_file")" = "$expected_marker" ] || return 1 - log "Clearing stale build cache for ${marker_file}" - rm -rf "$@" + local required_path + for required_path in "$@"; do + [ -e "$required_path" ] || return 1 + done + + return 0 } wait_for_network_id() { - local url="$1" - local timeout_seconds="$2" + local account_url="$1" + local cycle_url="$2" + local timeout_seconds="$3" local started started="$(date +%s)" while true; do local body - body="$(curl -fsS "$url" 2>/dev/null || true)" + body="$(curl -fsS "$account_url" 2>/dev/null || true)" if [ -n "$body" ]; then - local network_id - network_id="$(printf '%s' "$body" | jq -r '.account.networkId // empty' 2>/dev/null || true)" - local stability_factor - stability_factor="$(printf '%s' "$body" | jq -r '.account.current.stabilityFactorStr // empty' 2>/dev/null || true)" - if [ -n "$network_id" ] && [ -n "$stability_factor" ]; then - printf '%s' "$network_id" - return 0 + local has_network_account + has_network_account="$(printf '%s' "$body" | jq -r '(.account.type == "NetworkAccount") // false' 2>/dev/null || true)" + if [ "$has_network_account" = "true" ]; then + local cycle_body + cycle_body="$(curl -fsS "$cycle_url" 2>/dev/null || true)" + if [ -n "$cycle_body" ]; then + local network_id + network_id="$(printf '%s' "$cycle_body" | jq -r '.cycleInfo[0].networkId // empty' 2>/dev/null || true)" + if [ -n "$network_id" ]; then + printf '%s' "$network_id" + return 0 + fi + fi fi fi @@ -70,7 +99,7 @@ wait_for_network_id() { fi if [ "$(( $(date +%s) - started ))" -ge "$timeout_seconds" ]; then - log "Timed out waiting for network parameters from ${url}" + log "Timed out waiting for network parameters from ${account_url}" return 1 fi @@ -102,11 +131,136 @@ wait_for_json_field() { done } +http_responds() { + local url="$1" + curl --connect-timeout 2 --max-time 3 -sS -o /dev/null "$url" >/dev/null 2>&1 +} + +wait_for_http_response() { + local url="$1" + local timeout_seconds="$2" + local label="$3" + local quiet="${4:-false}" + local started + started="$(date +%s)" + + while true; do + if http_responds "$url"; then + return 0 + fi + + if [ "$(( $(date +%s) - started ))" -ge "$timeout_seconds" ]; then + if [ "$quiet" != "true" ]; then + log "Timed out waiting for ${label} from ${url}" + fi + return 1 + fi + + sleep 2 + done +} + +pm2_process_id_by_name() { + local name="$1" + local process_list + process_list="$( + { PM2_HOME="${SERVER_DIR}/instances/.pm2" pm2 jlist 2>/dev/null || true; } \ + | awk 'found || /^\[\{/ || /^\[\]/ { found = 1; print }' + )" + if [ -z "$process_list" ]; then + process_list="[]" + fi + + printf '%s' "$process_list" \ + | jq -r --arg name "$name" '.[] | select((.name | gsub("\""; "")) == $name) | .pm_id' 2>/dev/null \ + | head -n 1 \ + || true +} + +restart_pm2_process_by_name() { + local name="$1" + local pm2_id + pm2_id="$(pm2_process_id_by_name "$name")" + + if [ -z "$pm2_id" ]; then + log "Could not find PM2 process named ${name}" + return 1 + fi + + PM2_HOME="${SERVER_DIR}/instances/.pm2" pm2 restart "$pm2_id" --no-color >/dev/null +} + +ensure_monitor_ready() { + local monitor_url="http://127.0.0.1:3000/" + + if wait_for_http_response "$monitor_url" 20 "monitor" true; then + return 0 + fi + + log "Monitor did not bind on port 3000; restarting monitor-server once" + restart_pm2_process_by_name "monitor-server" + wait_for_http_response "$monitor_url" 120 "monitor" +} + +port_listening() { + local port="$1" + if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 + return $? + fi + + http_responds "http://127.0.0.1:${port}/" +} + +ensure_validator_ports_ready() { + local missing=() + local port + + for (( port = EXTERNAL_PORT_START; port < EXTERNAL_PORT_START + NODE_COUNT; port++ )); do + if ! port_listening "$port"; then + missing+=("$port") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + return 0 + fi + + log "Restarting validators that did not bind: ${missing[*]}" + for port in "${missing[@]}"; do + restart_pm2_process_by_name "shardus-instance-${port}" + done + + local started + started="$(date +%s)" + while true; do + missing=() + for (( port = EXTERNAL_PORT_START; port < EXTERNAL_PORT_START + NODE_COUNT; port++ )); do + if ! port_listening "$port"; then + missing+=("$port") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + return 0 + fi + + if [ "$(( $(date +%s) - started ))" -ge 180 ]; then + log "Timed out waiting for validator ports to bind: ${missing[*]}" + return 1 + fi + + sleep 5 + done +} + write_proxy_config() { local proxy_dir="$1" + local proxy_bind_port="$2" jq \ - '.http_port = 3030 + --argjson proxy_bind_port "$proxy_bind_port" \ + '.http_port = $proxy_bind_port | .archiver_seed_path = "./src/archiver_seed.json" | .standalone_network.enabled = true | .standalone_network.replacement_ip = "127.0.0.1" @@ -170,7 +324,7 @@ stop_stack() { set +e if [ -n "${SERVER_DIR:-}" ] && [ -d "$SERVER_DIR" ]; then log "Stopping Shardus network" - (cd "$SERVER_DIR" && shardus-network stop >/dev/null 2>&1) + (cd "$SERVER_DIR" && PATH="${SERVER_DIR}/node_modules/.bin:${PATH}" shardus stop >/dev/null 2>&1) fi if [ -n "${PROXY_PID:-}" ]; then kill "$PROXY_PID" >/dev/null 2>&1 @@ -187,15 +341,18 @@ SERVER_SOURCE="${LIBERDUS_SERVER_SOURCE:-/sources/server}" PROXY_SOURCE="${LIBERDUS_PROXY_SOURCE:-/sources/liberdus-proxy}" WEB_CLIENT_SOURCE="${LIBERDUS_WEB_CLIENT_SOURCE:-/sources/web-client-v2}" -NODE_COUNT="${LIBERDUS_NODE_COUNT:-10}" +NODE_COUNT="${LIBERDUS_NODE_COUNT:-5}" EXTERNAL_PORT_START="${LIBERDUS_EXTERNAL_PORT_START:-9001}" INTERNAL_PORT_START="${LIBERDUS_INTERNAL_PORT_START:-10001}" PUBLIC_HOST="${LIBERDUS_PUBLIC_HOST:-127.0.0.1}" +PROXY_BIND_PORT="${LIBERDUS_PROXY_BIND_PORT:-3030}" +PROXY_WS_BIND_PORT="$(( PROXY_BIND_PORT + 1 ))" PROXY_PUBLIC_PORT="${LIBERDUS_PROXY_PUBLIC_PORT:-3030}" PROXY_WS_PUBLIC_PORT="${LIBERDUS_PROXY_WS_PUBLIC_PORT:-3031}" +WEB_BIND_PORT="${LIBERDUS_WEB_BIND_PORT:-8080}" WEB_PUBLIC_PORT="${LIBERDUS_WEB_PUBLIC_PORT:-8080}" -SERVER_RUST_TOOLCHAIN="${LIBERDUS_SERVER_RUST_TOOLCHAIN:-1.82.0}" -PROXY_RUST_TOOLCHAIN="${LIBERDUS_PROXY_RUST_TOOLCHAIN:-stable}" +SERVER_RUST_TOOLCHAIN="${LIBERDUS_SERVER_RUST_TOOLCHAIN:-1.79.0}" +PROXY_RUST_TOOLCHAIN="${LIBERDUS_PROXY_RUST_TOOLCHAIN:-1.82.0}" SERVER_DIR="${RUNTIME_ROOT}/server" PROXY_DIR="${RUNTIME_ROOT}/liberdus-proxy" @@ -216,30 +373,53 @@ sync_repo "$PROXY_SOURCE" "$PROXY_DIR" \ sync_repo "$WEB_CLIENT_SOURCE" "$WEB_DIR" \ --exclude .git --exclude node_modules -write_proxy_config "$PROXY_DIR" - -SERVER_BUILD_MARKER="node=$(node --version);rust=$(rustc +"$SERVER_RUST_TOOLCHAIN" --version);package=$(hash_file "${SERVER_DIR}/package.json");lock=$(hash_file "${SERVER_DIR}/package-lock.json")" -PROXY_BUILD_MARKER="rust=$(rustc +"$PROXY_RUST_TOOLCHAIN" --version);manifest=$(hash_file "${PROXY_DIR}/Cargo.toml")" - -prepare_build_cache "${SERVER_DIR}/node_modules/.local-network-build" "$SERVER_BUILD_MARKER" \ - "${SERVER_DIR}/node_modules" "${SERVER_DIR}/dist" -prepare_build_cache "${PROXY_DIR}/target/.local-network-build" "$PROXY_BUILD_MARKER" \ - "${PROXY_DIR}/target" +write_proxy_config "$PROXY_DIR" "$PROXY_BIND_PORT" + +SERVER_DEP_MARKER="node=$(node --version);rust=$(rustc +"$SERVER_RUST_TOOLCHAIN" --version);package=$(hash_file "${SERVER_DIR}/package.json");lock=$(hash_file "${SERVER_DIR}/package-lock.json")" +SERVER_BUILD_MARKER="${SERVER_DEP_MARKER};source=$(hash_paths "$SERVER_DIR" package.json package-lock.json tsconfig.json src client.js)" +PROXY_BUILD_MARKER="rust=$(rustc +"$PROXY_RUST_TOOLCHAIN" --version);source=$(hash_paths "$PROXY_DIR" Cargo.toml Cargo.lock src)" + +SERVER_DEPS_CACHED=false +if cache_valid "${SERVER_DIR}/node_modules/.local-network-build" "$SERVER_DEP_MARKER" \ + "${SERVER_DIR}/node_modules/.bin/shardus"; then + SERVER_DEPS_CACHED=true + log "Reusing cached server dependencies" +else + log "Installing server dependencies" + rm -rf "${SERVER_DIR}/node_modules" + ( + cd "$SERVER_DIR" + export RUSTUP_TOOLCHAIN="$SERVER_RUST_TOOLCHAIN" + npm install + mkdir -p node_modules + printf '%s' "$SERVER_DEP_MARKER" > node_modules/.local-network-build + ) +fi + +if cache_valid "${SERVER_DIR}/dist/.local-network-build" "$SERVER_BUILD_MARKER" \ + "${SERVER_DIR}/dist/index.js"; then + log "Reusing cached server build" +else + if [ "$SERVER_DEPS_CACHED" = "false" ] && [ -f "${SERVER_DIR}/dist/index.js" ]; then + log "Using server build produced during dependency install" + else + log "Compiling server" + ( + cd "$SERVER_DIR" + export RUSTUP_TOOLCHAIN="$SERVER_RUST_TOOLCHAIN" + npm run compile + ) + fi + mkdir -p "${SERVER_DIR}/dist" + printf '%s' "$SERVER_BUILD_MARKER" > "${SERVER_DIR}/dist/.local-network-build" +fi -log "Installing and compiling server dependencies" -( - cd "$SERVER_DIR" - export RUSTUP_TOOLCHAIN="$SERVER_RUST_TOOLCHAIN" - npm install - npm run compile - mkdir -p node_modules - printf '%s' "$SERVER_BUILD_MARKER" > node_modules/.local-network-build -) +export PATH="${SERVER_DIR}/node_modules/.bin:${PATH}" log "Resetting any previous Shardus instances" ( cd "$SERVER_DIR" - shardus-network stop >/dev/null 2>&1 || true + shardus stop >/dev/null 2>&1 || true rm -rf instances ) @@ -247,38 +427,51 @@ export minNodes="$NODE_COUNT" export baselineNodes="$NODE_COUNT" export maxNodes="$(( NODE_COUNT * 2 ))" -log "Creating and starting ${NODE_COUNT}-node Shardus network on validator ports ${EXTERNAL_PORT_START}-$(( EXTERNAL_PORT_START + NODE_COUNT - 1 ))" +log "Starting ${NODE_COUNT}-node Shardus network on validator ports ${EXTERNAL_PORT_START}-$(( EXTERNAL_PORT_START + NODE_COUNT - 1 ))" ( cd "$SERVER_DIR" - shardus-network create \ - --starting-external-port "$EXTERNAL_PORT_START" \ - --starting-internal-port "$INTERNAL_PORT_START" \ - "$NODE_COUNT" \ - pm2--no-autorestart + if [ "$EXTERNAL_PORT_START" != "9001" ] || [ "$INTERNAL_PORT_START" != "10001" ]; then + shardus create \ + --no-start \ + --starting-external-port "$EXTERNAL_PORT_START" \ + --starting-internal-port "$INTERNAL_PORT_START" \ + "$NODE_COUNT" \ + pm2--no-autorestart + fi + + shardus start "$NODE_COUNT" pm2--no-autorestart ) -log "Waiting for archiver and active nodelist" +log "Waiting for archiver, monitor, and active nodelist" wait_for_json_field "http://127.0.0.1:4000/archivers" '((.activeArchivers // .archivers // []) | length) > 0' 600 "active archivers" +ensure_monitor_ready +ensure_validator_ports_ready wait_for_json_field "http://127.0.0.1:4000/full-nodelist?activeOnly=true" '((.nodeList // .nodes // .nodelist // []) | length) > 0' 600 "active nodelist" wait_for_json_field "http://127.0.0.1:4000/cycleinfo/1" "((.cycleInfo // []) | length) > 0 and (.cycleInfo[0].mode == \"processing\") and ((.cycleInfo[0].active // 0) >= ${NODE_COUNT})" 2400 "processing cycle" -log "Building Liberdus proxy" -( - cd "$PROXY_DIR" - cargo +"$PROXY_RUST_TOOLCHAIN" build - mkdir -p target - printf '%s' "$PROXY_BUILD_MARKER" > target/.local-network-build -) +if cache_valid "${PROXY_DIR}/target/.local-network-build" "$PROXY_BUILD_MARKER" \ + "${PROXY_DIR}/target/debug/liberdus-proxy"; then + log "Reusing cached Liberdus proxy build" +else + log "Building Liberdus proxy" + rm -rf "${PROXY_DIR}/target" + ( + cd "$PROXY_DIR" + cargo +"$PROXY_RUST_TOOLCHAIN" build + mkdir -p target + printf '%s' "$PROXY_BUILD_MARKER" > target/.local-network-build + ) +fi log "Starting Liberdus proxy" ( cd "$PROXY_DIR" - cargo +"$PROXY_RUST_TOOLCHAIN" run > "${LOG_DIR}/proxy.log" 2>&1 + ./target/debug/liberdus-proxy > "${LOG_DIR}/proxy.log" 2>&1 ) & PROXY_PID=$! ZERO_ACCOUNT="0000000000000000000000000000000000000000000000000000000000000000" -NETWORK_ID="$(wait_for_network_id "http://127.0.0.1:3030/account/${ZERO_ACCOUNT}" 600)" +NETWORK_ID="$(wait_for_network_id "http://127.0.0.1:${PROXY_BIND_PORT}/account/${ZERO_ACCOUNT}" "http://127.0.0.1:4000/cycleinfo/1" 600)" log "Detected local network id: ${NETWORK_ID}" write_web_network "$WEB_DIR" "$NETWORK_ID" "$PUBLIC_HOST" "$PROXY_PUBLIC_PORT" "$PROXY_WS_PUBLIC_PORT" @@ -286,13 +479,13 @@ write_web_network "$WEB_DIR" "$NETWORK_ID" "$PUBLIC_HOST" "$PROXY_PUBLIC_PORT" " log "Starting web client on http://${PUBLIC_HOST}:${WEB_PUBLIC_PORT}/" ( cd "$WEB_DIR" - python3 -m http.server 8080 --bind 0.0.0.0 > "${LOG_DIR}/web-client.log" 2>&1 + python3 -m http.server "$WEB_BIND_PORT" --bind 0.0.0.0 > "${LOG_DIR}/web-client.log" 2>&1 ) & HTTP_PID=$! log "Local network is ready" log "Monitor: http://${PUBLIC_HOST}:${LIBERDUS_MONITOR_PUBLIC_PORT:-3000}/" -log "Proxy: http://${PUBLIC_HOST}:${PROXY_PUBLIC_PORT}/" +log "Proxy: http://${PUBLIC_HOST}:${PROXY_PUBLIC_PORT}/ (binds ${PROXY_BIND_PORT}/${PROXY_WS_BIND_PORT})" log "Web client: http://${PUBLIC_HOST}:${WEB_PUBLIC_PORT}/" while true; do From d14a7c8d5ecbb78a75f6cdd28ec5a1375e1c442d Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Tue, 5 May 2026 16:29:32 -0500 Subject: [PATCH 03/11] ci: add local network smoke workflow --- .docker/local-network.Dockerfile | 4 +- .dockerignore | 2 + .github/workflows/local-network-smoke.yml | 166 +++++ .gitignore | 2 + README.md | 52 +- docker-compose.local.yml | 19 +- docs/local-network-handoff.md | 480 -------------- docs/local-network.md | 61 ++ local-network.env.example | 24 - scripts/local-network/start.js | 729 ++++++++++++++++++++++ scripts/local-network/start.sh | 500 +-------------- 11 files changed, 984 insertions(+), 1055 deletions(-) create mode 100644 .github/workflows/local-network-smoke.yml delete mode 100644 docs/local-network-handoff.md create mode 100644 docs/local-network.md delete mode 100644 local-network.env.example create mode 100644 scripts/local-network/start.js diff --git a/.docker/local-network.Dockerfile b/.docker/local-network.Dockerfile index c888011..db3a1dc 100644 --- a/.docker/local-network.Dockerfile +++ b/.docker/local-network.Dockerfile @@ -5,7 +5,7 @@ ARG TARGETARCH # The server's native dependency tree currently pulls time 0.3.31, which fails # with rustc >= 1.80. Keep this pin until the server dependencies move forward. ARG SERVER_RUST_VERSION=1.79.0 -ARG PROXY_RUST_VERSION=1.82.0 +ARG PROXY_RUST_VERSION=1.86.0 ENV DEBIAN_FRONTEND=noninteractive ENV PATH="/opt/node/bin:/root/.cargo/bin:${PATH}" @@ -53,4 +53,4 @@ WORKDIR /workspace/client-testing COPY scripts/local-network /usr/local/bin/liberdus-local-network RUN chmod +x /usr/local/bin/liberdus-local-network/*.sh -CMD ["/usr/local/bin/liberdus-local-network/start.sh"] +CMD ["node", "/usr/local/bin/liberdus-local-network/start.js"] diff --git a/.dockerignore b/.dockerignore index 14dce1e..0ece63f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,8 @@ playwright-tests/playwright-report playwright-tests/test-results playwright-tests/.cache local-network.env +.deps +.artifacts server liberdus-proxy tools-cli-shardus-network diff --git a/.github/workflows/local-network-smoke.yml b/.github/workflows/local-network-smoke.yml new file mode 100644 index 0000000..6cedf1c --- /dev/null +++ b/.github/workflows/local-network-smoke.yml @@ -0,0 +1,166 @@ +name: Local Network Smoke + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/local-network-smoke.yml" + - ".docker/local-network.Dockerfile" + - "docker-compose.local.yml" + - "scripts/local-network/**" + - "playwright-tests/**" + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + CI: "true" + LIBERDUS_PROXY_REF: new-nodelist-response + PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080/ + + steps: + - name: Checkout client-testing + uses: actions/checkout@v4 + + - name: Checkout server + uses: actions/checkout@v4 + with: + repository: Liberdus/server + path: .deps/server + + - name: Checkout liberdus-proxy + uses: actions/checkout@v4 + with: + repository: Liberdus/liberdus-proxy + ref: ${{ env.LIBERDUS_PROXY_REF }} + path: .deps/liberdus-proxy + + - name: Checkout web-client-v2 + uses: actions/checkout@v4 + with: + repository: Liberdus/web-client-v2 + path: .deps/web-client-v2 + + - name: Normalize bind paths for act on Windows + if: ${{ env.ACT == 'true' }} + run: | + set -euo pipefail + + case "$GITHUB_WORKSPACE" in + /mnt/[a-zA-Z]/*) + drive="${GITHUB_WORKSPACE#/mnt/}" + drive="${drive%%/*}" + prefix="/mnt/${drive}/" + rest="${GITHUB_WORKSPACE#"$prefix"}" + win_workspace="${drive^^}:\\${rest//\//\\}" + + { + echo "LIBERDUS_SERVER_DIR=${win_workspace}\\.deps\\server" + echo "LIBERDUS_PROXY_DIR=${win_workspace}\\.deps\\liberdus-proxy" + echo "LIBERDUS_WEB_CLIENT_DIR=${win_workspace}\\.deps\\web-client-v2" + } >> "$GITHUB_ENV" + ;; + esac + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: playwright-tests/package-lock.json + + - name: Show Docker versions + run: | + docker --version + docker compose version + + - name: Validate local network compose config + run: docker compose -f docker-compose.local.yml config --quiet + + - name: Clean previous local network state + run: docker compose -f docker-compose.local.yml down -v --remove-orphans + + - name: Install Playwright dependencies + working-directory: playwright-tests + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Start local network + run: docker compose -f docker-compose.local.yml up -d --build + + - name: Wait for local network health + run: | + set -euo pipefail + + for _ in {1..240}; do + cid="$(docker compose -f docker-compose.local.yml ps -q local-network || true)" + if [ -z "$cid" ]; then + echo "local-network container not found" + docker compose -f docker-compose.local.yml ps || true + docker compose -f docker-compose.local.yml logs --no-color --tail=400 local-network || true + exit 1 + fi + + status="$(docker inspect --format='{{.State.Status}}' "$cid")" + health="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid")" + + echo "container status=${status} health=${health}" + + if [ "$health" = "healthy" ]; then + exit 0 + fi + + if [ "$status" != "running" ]; then + docker compose -f docker-compose.local.yml logs --no-color --tail=400 local-network + exit 1 + fi + + sleep 15 + done + + docker compose -f docker-compose.local.yml logs --no-color --tail=400 local-network + exit 1 + + - name: Show local network status + run: | + docker compose -f docker-compose.local.yml ps + curl -fsS http://127.0.0.1:4000/cycleinfo/1 | jq '.cycleInfo[0] | {counter, mode, active, standby, syncing, desired, target, networkId}' + curl -fsS http://127.0.0.1:3030/account/0000000000000000000000000000000000000000000000000000000000000000 >/dev/null + curl -fsS http://127.0.0.1:8080/ >/dev/null + + - name: Run Playwright smoke + working-directory: playwright-tests + run: npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 --reporter=github,line + + - name: Upload Playwright HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: local-network-playwright-report-${{ github.run_id }} + path: playwright-tests/playwright-report/ + if-no-files-found: warn + retention-days: 14 + + - name: Collect local network logs + if: always() + run: | + mkdir -p .artifacts + docker compose -f docker-compose.local.yml logs --no-color local-network > .artifacts/local-network.log || true + docker compose -f docker-compose.local.yml ps > .artifacts/local-network-ps.txt || true + + - name: Upload local network logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: local-network-logs-${{ github.run_id }} + path: | + .artifacts/ + playwright-tests/test-results/ + if-no-files-found: warn + retention-days: 14 + + - name: Stop local network + if: always() + run: docker compose -f docker-compose.local.yml down -v --remove-orphans diff --git a/.gitignore b/.gitignore index 6fe351c..9fefcd0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ playwright-tests/.cache/network-params.json playwright-tests/node_modules/ playwright-tests/.env local-network.env +.deps/ +.artifacts/ diff --git a/README.md b/README.md index bb912d1..27df78a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ https://drive.google.com/file/d/19oWRpK_AJUo3G3hHYYGN_ZryAT-CSjyd/view?usp=sharing -## Usage +## Legacy Dev Container Usage + +This older flow uses the root `liberdus-*.sh` scripts inside the dev container. It is separate from the Docker Compose local-network stack used for CI smoke testing. 1. Make sure you have the dev containers extension for vscode. @@ -32,51 +34,9 @@ https://drive.google.com/file/d/19oWRpK_AJUo3G3hHYYGN_ZryAT-CSjyd/view?usp=shari ## Local Network with Docker Compose -The Playwright tests default to `https://liberdus.com/dev/`, but can be pointed at a local network with `PLAYWRIGHT_BASE_URL` or `playwright-tests/.env`. - -1. Copy the local network env template and adjust paths or ports if needed: - - ```powershell - Copy-Item local-network.env.example local-network.env - ``` - -2. Start the local network stack: - - ```powershell - docker compose --env-file local-network.env -f docker-compose.local.yml up --build - ``` - - This starts a 5-node Shardus network, the Liberdus proxy, and a static `web-client-v2` server. The source repos are mounted read-only and copied into a Docker volume before generated local config is written. The image uses Node.js 18.19.1, Rust 1.79.0 for server dependencies, and Rust 1.82.0 for proxy dependencies. The web client is served only after the network reports `processing` mode. Warm runs reuse cached server dependencies, server build output, and proxy build output from the Docker volume unless the source, lockfiles, toolchains, or volume change. - -3. In a second shell, configure Playwright for the local web client: - - ```powershell - Copy-Item playwright-tests\.env.example playwright-tests\.env - ``` - -4. Run tests: - - ```powershell - cd playwright-tests - npm install - npm test - ``` - -5. Stop and remove the local network stack: - - ```powershell - docker compose --env-file local-network.env -f docker-compose.local.yml down - ``` - -Default local URLs: - -- Web client: `http://127.0.0.1:8080/` -- Liberdus proxy: `http://127.0.0.1:3030/` -- Shardus monitor: `http://127.0.0.1:3000/` - -If you change `LIBERDUS_NODE_COUNT`, also update `LIBERDUS_VALIDATOR_CONTAINER_PORT_END` and `LIBERDUS_VALIDATOR_HOST_PORT_END` so each range has one port per node. +The Playwright tests default to `https://liberdus.com/dev/`, but the Docker Compose stack can run the smoke test against a local network. This is the CI-focused local-network path. -The 5-node default is intended to speed up local startup. If e2e account-creation tests hang on `Creating account...`, use a 10-node env while the 5-node transaction path is being investigated. +See [docs/local-network.md](docs/local-network.md) for the short version of what runs and how to run it locally. ## Manually Setting up a Local Liberdus Network with web-client-v2 @@ -92,7 +52,7 @@ The 5-node default is intended to speed up local startup. If e2e account-creatio * Node.js 18.19.1 - * Rust 1.79.0 for the server, plus Rust 1.82.0 for `liberdus-proxy` + * Rust 1.79.0 for the server, plus Rust 1.86.0 for `liberdus-proxy` * Python 3.x diff --git a/docker-compose.local.yml b/docker-compose.local.yml index a64b9a5..46c6bbe 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -9,7 +9,7 @@ services: LIBERDUS_PROXY_SOURCE: /sources/liberdus-proxy LIBERDUS_WEB_CLIENT_SOURCE: /sources/web-client-v2 LIBERDUS_RUNTIME_ROOT: /workspace/runtime - LIBERDUS_NODE_COUNT: ${LIBERDUS_NODE_COUNT:-5} + LIBERDUS_NODE_COUNT: ${LIBERDUS_NODE_COUNT:-10} LIBERDUS_EXTERNAL_PORT_START: ${LIBERDUS_VALIDATOR_CONTAINER_PORT_START:-9001} LIBERDUS_INTERNAL_PORT_START: ${LIBERDUS_INTERNAL_PORT_START:-10001} LIBERDUS_PUBLIC_HOST: ${LIBERDUS_PUBLIC_HOST:-127.0.0.1} @@ -18,9 +18,18 @@ services: LIBERDUS_PROXY_WS_PUBLIC_PORT: ${LIBERDUS_PROXY_WS_HOST_PORT:-3031} LIBERDUS_WEB_PUBLIC_PORT: ${LIBERDUS_WEB_CLIENT_HOST_PORT:-8080} volumes: - - ${LIBERDUS_SERVER_DIR:?Set LIBERDUS_SERVER_DIR in local-network.env}:/sources/server:ro - - ${LIBERDUS_PROXY_DIR:?Set LIBERDUS_PROXY_DIR in local-network.env}:/sources/liberdus-proxy:ro - - ${LIBERDUS_WEB_CLIENT_DIR:?Set LIBERDUS_WEB_CLIENT_DIR in local-network.env}:/sources/web-client-v2:ro + - type: bind + source: ${LIBERDUS_SERVER_DIR:-./.deps/server} + target: /sources/server + read_only: true + - type: bind + source: ${LIBERDUS_PROXY_DIR:-./.deps/liberdus-proxy} + target: /sources/liberdus-proxy + read_only: true + - type: bind + source: ${LIBERDUS_WEB_CLIENT_DIR:-./.deps/web-client-v2} + target: /sources/web-client-v2 + read_only: true - local-network-runtime:/workspace/runtime ports: - "${LIBERDUS_MONITOR_HOST_PORT:-3000}:3000" @@ -28,7 +37,7 @@ services: - "${LIBERDUS_PROXY_HOST_PORT:-3030}:3030" - "${LIBERDUS_PROXY_WS_HOST_PORT:-3031}:3031" - "${LIBERDUS_WEB_CLIENT_HOST_PORT:-8080}:8080" - - "${LIBERDUS_VALIDATOR_HOST_PORT_START:-9101}-${LIBERDUS_VALIDATOR_HOST_PORT_END:-9105}:${LIBERDUS_VALIDATOR_CONTAINER_PORT_START:-9001}-${LIBERDUS_VALIDATOR_CONTAINER_PORT_END:-9005}" + - "${LIBERDUS_VALIDATOR_HOST_PORT_START:-9101}-${LIBERDUS_VALIDATOR_HOST_PORT_END:-9110}:${LIBERDUS_VALIDATOR_CONTAINER_PORT_START:-9001}-${LIBERDUS_VALIDATOR_CONTAINER_PORT_END:-9010}" healthcheck: test: ["CMD", "/usr/local/bin/liberdus-local-network/healthcheck.sh"] interval: 15s diff --git a/docs/local-network-handoff.md b/docs/local-network-handoff.md deleted file mode 100644 index 69f36f3..0000000 --- a/docs/local-network-handoff.md +++ /dev/null @@ -1,480 +0,0 @@ -# Local Network Docker Compose Handoff - -Date: 2026-05-01 -Branch: `client-testing-local-network` -Repository: `Liberdus/client-testing` - -## Goal - -Enable the Playwright tests in this repo to run against a locally started Liberdus network instead of only defaulting to `https://liberdus.com/dev/`. - -The current implementation focuses on standing up the local network locally with Docker Compose. The next phase can wire the same approach into GitHub Actions once the local harness is stable. - -## Current Status - -The branch contains a Docker Compose based local-network harness and Playwright configuration changes. The default local Docker network has been changed to 5 validators to reduce startup time, and startup now uses `shardus start` plus source-aware cache markers for: - -- server dependencies -- server `dist` -- Rust proxy `target` - -Current state as of this handoff: - -- A 5-node Docker Compose run reaches Docker `healthy`. -- The archiver reports `processing` with 5 active validators. -- The local web client is served successfully. -- The single account-create smoke does **not** pass on 5 nodes yet; it hangs on `Creating account...`. - -The currently running local stack was left up for inspection on non-default host ports from `local-network.env`: - -- web client: `http://127.0.0.1:8088/` -- proxy: `http://127.0.0.1:3038/` -- proxy WebSocket: `ws://127.0.0.1:3039/` -- archiver: `http://127.0.0.1:4008/` -- monitor: `http://127.0.0.1:3008/` -- validators: host `9101-9105` -> container `9001-9005` - -The most recent status check showed: - -```json -{ - "mode": "processing", - "active": 5, - "standby": 0, - "syncing": 0, - "desired": 5, - "target": 5 -} -``` - -Known-good e2e baseline: 10 nodes passed the same single smoke both outside Docker and in Docker Compose. - -Known-bad current 5-node e2e behavior: the network becomes ready, but account creation stalls after the register transaction. Validator logs show the register tx applying on all 5 validators, then receipt/read-repair trouble: - -- `Receipt does not have the required majority` -- `txSafelyRemoved_3 stuck_in_consensus_3` -- repeated `isInSync = false` -- repeated `getAccountRepairData no node avail` -- proxy collector/read errors with `Connection refused (os error 111)` - -So the branch is better for startup speed, but 5 nodes should not be treated as e2e-ready until the consensus/read-after-write issue is solved. For GitHub Actions, the practical choice is probably to run 10 nodes for now, or keep this branch at 5 nodes while investigating the server-side config needed to make 5-node e2e reliable. - -## Validation Timeline - -The first local smoke failure happened before the processing readiness gate. The web client reached account creation while the network was still in `forming`, and the server rejected app transactions with: - -```text -Error injecting transaction: Application transactions are only allowed in processing Mode. -``` - -After that failure, the startup script and healthcheck were changed so the local stack waits for a `processing` cycle before declaring itself ready or serving the web client. - -The stack was then run outside Docker using the same startup script logic and `shardus start`. The network reached `processing` with 10 active validators, the Rust proxy served the zero account, and this single Playwright smoke passed: - -```bash -PLAYWRIGHT_BASE_URL='http://127.0.0.1:8088/' npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 --reporter=line -``` - -Result: - -```text -1 passed (25.9s) -``` - -A clean Docker Compose 10-node run was started with: - -```bash -docker compose --env-file local-network.env -f docker-compose.local.yml down -v --remove-orphans -docker compose --env-file local-network.env -f docker-compose.local.yml up --build -``` - -The container became Docker `healthy`, reached `processing` with 10 active validators, served `network.js`, and the same single Playwright smoke passed against the Docker stack: - -```text -1 passed (23.0s) -``` - -The first clean Docker run paid the full server and proxy native build cost. Server `npm install` took about 16 minutes, and proxy `cargo build` took about 2 minutes. - -After the cache/default update, a warm 5-node rerun reused server dependencies, rebuilt server `dist` because the marker format changed, reached Docker `healthy` and `processing` with 5 active validators, and then rebuilt the proxy once because the proxy marker also changed. Proxy rebuild took 1m 46s; the next warm run should skip that proxy build too. - -The single Playwright smoke was then run against the 5-node stack: - -```bash -PLAYWRIGHT_BASE_URL='http://127.0.0.1:8088/' npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 --reporter=line -``` - -Result: - -```text -1 failed after 5.0m -Test timeout of 300000ms exceeded while setting up "page". -waiting for locator('.toast.loading.show') to be detached -locator resolved to visible: Creating account... -``` - -The 5-node network should not be treated as e2e-ready yet. It can start and report healthy, but account creation currently hangs after the register transaction. - -## Files Changed - -### Docker Compose Harness - -- `.docker/local-network.Dockerfile` - - Builds an Ubuntu 22.04 image. - - Installs Node.js 18.19.1. - - Installs two Rust toolchains: - - Rust 1.79.0 for `server` native dependencies. - - Rust 1.82.0 for `liberdus-proxy`. - - Uses the `shardus` CLI installed by the server repo dependencies. - - Copies the local-network scripts into the image. - -- `docker-compose.local.yml` - - Defines one `local-network` service. - - Mounts the three sibling repos read-only: - - `server` - - `liberdus-proxy` - - `web-client-v2` - - Copies those repos into a Docker volume before writing generated config. - - Publishes: - - host `3000` -> monitor - - host `4000` -> archiver - - host `3030` -> proxy HTTP - - host `3031` -> proxy WebSocket - - host `8080` -> web client - - host `9101-9105` -> container validator ports `9001-9005` - -- `local-network.env.example` - - Windows-oriented default paths for Chris's local repos. - - Documents the host validator port range separately from the container validator port range. - - On macOS, copy this to `local-network.env` and replace the repo paths with local macOS paths. - -- `.dockerignore` - - Keeps local dependency folders, test output, and env files out of the image build context. - -### Local Network Scripts - -- `scripts/local-network/start.sh` - - Syncs mounted source repos into `/workspace/runtime`. - - Writes generated proxy config and archiver seed. - - Reuses cached server dependencies and build output when source, lockfiles, and toolchains are unchanged. - - Installs/compiles the server with Rust 1.79.0 when the cache is stale. - - Starts a 5-node Shardus network with `shardus start`. - - Restarts `monitor-server` once if it starts under PM2 but does not bind port `3000`. - - Restarts validator PM2 processes once if their validator ports do not bind after startup. - - Waits for: - - active archiver - - monitor HTTP response - - active nodelist - - `cycleinfo` mode `processing` - - Reuses the cached Rust proxy build when source, lockfile, and toolchain are unchanged. - - Builds and starts the Rust proxy with Rust 1.82.0 when the cache is stale. - - Fetches the local network ID from the proxy. - - Writes `web-client-v2/network.js`. - - Serves the web client on port `8080`. - -- `scripts/local-network/healthcheck.sh` - - Checks `network.js`. - - Checks the zero account through the proxy. - - Checks the current cycle is in `processing` mode. - -### Playwright Configuration - -- `playwright-tests/playwright.config.ts` - - Loads `playwright-tests/.env`. - - Uses: - - `PLAYWRIGHT_BASE_URL`, then - - `BASE_URL`, then - - `https://liberdus.com/dev/` - -- `playwright-tests/helpers/global-setup.js` - - Uses the Playwright config/env base URL instead of parsing `playwright.config.ts` as text. - - Accepts the current local gateway account shape where amount fields are bigint objects and `stabilityFactorStr` may be missing. - -- `playwright-tests/.env.example` - - Sets: - -```env -PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080/ -``` - -- `.gitignore` - - Ignores: - - `playwright-tests/node_modules/` - - `playwright-tests/.env` - - `local-network.env` - -### README - -- `README.md` - - Adds a "Local Network with Docker Compose" section with setup, run, test, and shutdown commands. - -## How To Run Locally - -### Windows - -From the repo root: - -```powershell -Copy-Item local-network.env.example local-network.env -docker compose --env-file local-network.env -f docker-compose.local.yml up --build -``` - -In another shell: - -```powershell -Copy-Item playwright-tests\.env.example playwright-tests\.env -cd playwright-tests -npm install -npx playwright test -``` - -To stop: - -```powershell -docker compose --env-file local-network.env -f docker-compose.local.yml down -``` - -### macOS - -From the repo root: - -```bash -cp local-network.env.example local-network.env -``` - -Edit `local-network.env` to point at the local macOS repo paths, for example: - -```env -LIBERDUS_SERVER_DIR=/Users//Documents/Code/liberdus/server -LIBERDUS_PROXY_DIR=/Users//Documents/Code/liberdus/liberdus-proxy -LIBERDUS_WEB_CLIENT_DIR=/Users//Documents/Code/liberdus/web-client-v2 -``` - -Then run: - -```bash -docker compose --env-file local-network.env -f docker-compose.local.yml up --build -``` - -In another shell: - -```bash -cp playwright-tests/.env.example playwright-tests/.env -cd playwright-tests -npm install -npx playwright test -``` - -To stop: - -```bash -docker compose --env-file local-network.env -f docker-compose.local.yml down -``` - -## Recommended Smoke Test - -The smallest useful local-network smoke target is: - -```powershell -$env:PLAYWRIGHT_BASE_URL='http://127.0.0.1:8080/'; npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 --reporter=line -``` - -macOS equivalent: - -```bash -PLAYWRIGHT_BASE_URL='http://127.0.0.1:8080/' npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 --reporter=line -``` - -This test creates and signs in a fresh user, then checks Contacts and Wallet navigation. It is a better smoke than payment/transfer tests because it avoids additional funding and recipient setup. - -## Validation Commands - -Commands that passed on May 1 after the latest changes: - -```bash -bash -n scripts/local-network/start.sh -bash -n scripts/local-network/healthcheck.sh -docker compose --env-file local-network.env.example -f docker-compose.local.yml config --quiet -git diff --check -``` - -Runtime validation: - -- 10-node outside-Docker smoke passed: `1 passed (25.9s)`. -- 10-node Docker Compose smoke passed: `1 passed (23.0s)`. -- 5-node Docker Compose startup passed readiness: Docker `healthy`, cycle `processing`, `active: 5`. -- 5-node Docker Compose smoke failed during account creation: - - ```text - 1 failed after 5.0m - Test timeout of 300000ms exceeded while setting up "page". - waiting for locator('.toast.loading.show') to be detached - locator resolved to visible: Creating account... - ``` - -## Important Findings - -### 5-Node Transaction Issue - -The default 5-node stack reaches `processing` and Docker `healthy`, but the account-create transaction does not complete from the web client's point of view. - -Observed during the failed smoke: - -- Current cycle stayed healthy: - -```json -{ - "mode": "processing", - "active": 5, - "standby": 0, - "syncing": 0, - "desired": 5, - "target": 5 -} -``` - -- Browser stayed on `Creating account...` for the full 300s Playwright timeout. -- Validator app logs showed `Applied register tx` on all 5 validators for username `c58100289776418`. -- Validator error/fatal logs then showed: - - `Receipt does not have the required majority for txid: 9b9f7521aaa06a5dd200dc6577de6b39917cc63165110819225d596d0a9ee115` - - `txSafelyRemoved_3 stuck_in_consensus_3` - - repeated `isInSync = false` - - repeated `getAccountRepairData no node avail` -- Proxy logs showed repeated collector/read errors after the client activity: - - `Error handling collector request: Connection refused (os error 111)` - -Likely interpretation: 5 nodes are enough for startup/readiness, but not enough for the current server/shardus data-sync or read-after-write path used by account creation. The prior 10-node stack passed the same smoke, so the 5-node default is faster but currently exposes a consensus/repair visibility issue that needs a config fix or a higher node count for e2e. - -### Dev Network Account Comparison - -The dev network zero account was checked at: - -```text -https://dev.liberdus.com:3030/account/0000000000000000000000000000000000000000000000000000000000000000 -``` - -The local zero account has the same top-level `NetworkAccount` shape, but the local server checkout is older (`2.3.4`) than dev (`2.4.8`). Exact-matching dev's version fields on the older checkout is risky because validators can reject app versions outside the network account's allowed range. - -Notable dev values: - -- `activeVersion`, `latestVersion`, `minVersion`: `2.4.8` -- `stabilityScaleMul`: `125` -- `stabilityScaleDiv`: `1` -- `stabilityFactorStr`: `0.008` -- `tollTimeout`: `60000` -- `transactionFeeUsdStr`: `0.01` -- `minTollUsdStr`: `0.05` -- `defaultTollUsdStr`: `0.05` -- `messageRetentionDays`: `7` -- `messageMaxLength`: `500` - -The local outside-Docker run had: - -- `activeVersion`, `latestVersion`, `minVersion`: `2.3.4` -- `stabilityScaleMul`: `1000` -- `stabilityScaleDiv`: `1000` -- no string fee fields -- `tollTimeout`: `604800000` - -Shardus runtime knobs such as `minNodes`, `baselineNodes`, and `forceBogonFilteringOn` are not in the network account. They come from `server/src/config/index.ts` / the local-network patch path. - -### Toolchain Split Is Necessary - -The server and proxy currently need different Rust behavior: - -- The server dependency tree needs Rust 1.79.0 because current native dependencies pull `time` 0.3.31, which fails with rustc 1.80+. -- Newer Rust failed the server build in that dependency before the local network could start. -- The proxy repo pins Rust 1.82.0 and has a committed `Cargo.lock`. - -The Dockerfile therefore installs both Rust 1.79.0 and Rust 1.82.0, and the startup script selects the toolchain explicitly. - -### Shardus Port Behavior - -The Shardus CLI still creates node folders and validator listeners on `9001-9005` for the default 5-node stack. Compose maps those to host ports `9101-9105` to avoid common host conflicts: - -```text -host 9101 -> container 9001 -host 9102 -> container 9002 -... -host 9105 -> container 9005 -``` - -Do not expect the folder names to match the host-facing ports. - -### Readiness Needs Processing Mode - -The proxy can serve the zero account while the network is still in `forming` mode. That is not enough for tests that submit app transactions. - -The latest script now waits for: - -```text -/cycleinfo/1 -> cycleInfo[0].mode == "processing" -``` - -and requires the active count to be at least `LIBERDUS_NODE_COUNT`. - -This has now been verified with both a 10-node run and a 5-node warm-cache run. - -## Known Gaps / Next Steps - -1. Investigate why the 5-node stack applies account registration but leaves the browser stuck waiting for create-account completion. - - Useful checks from the failed run: - - ```bash - docker exec client-testing-local-network-1 bash -lc 'grep -R "Receipt does not have the required majority\|stuck_in_consensus\|isInSync = false\|getAccountRepairData no node avail" -n /workspace/runtime/server/instances/shardus-instance-900*/logs' - docker exec client-testing-local-network-1 bash -lc 'tr "\r" "\n" < /workspace/runtime/logs/proxy.log | grep -Eiv "Active Connection Streams: 0|^$" | tail -n 200' - ``` - -2. Decide whether CI should use 10 nodes for reliable e2e while 5-node consensus/read repair is investigated, or whether the server config should be changed to make 5 nodes transaction-safe. - -3. Run one more warm Docker-volume pass to confirm all build caches skip together after the new marker files have been written. - - ```bash - docker compose --env-file local-network.env -f docker-compose.local.yml up --build - ``` - -4. Confirm Docker health does not turn healthy until the latest cycle is in processing mode. - - Useful checks: - - ```bash - docker compose --env-file local-network.env -f docker-compose.local.yml ps - docker exec client-testing-local-network-1 bash -lc 'curl -fsS http://127.0.0.1:4000/cycleinfo/1 | jq ".cycleInfo[0] | {counter, mode, active, desired, syncing, target}"' - ``` - -5. Rerun the smoke test after choosing the node count/config path. - -6. If the network never reaches processing, inspect Shardus node logs: - - ```bash - docker exec client-testing-local-network-1 bash -lc 'tail -160 /workspace/runtime/server/instances/shardus-instance-9001/logs/cycle.log' - docker exec client-testing-local-network-1 bash -lc 'tail -160 /workspace/runtime/server/instances/shardus-instance-9001/logs/p2p.log' - docker exec client-testing-local-network-1 bash -lc 'tail -160 /workspace/runtime/server/instances/archiver-logs/127.0.0.1_4000/main.log' - ``` - -7. Once the local path is stable, adapt this into GitHub Actions. - - The likely workflow shape is: - - - checkout `client-testing` - - checkout sibling `server`, `liberdus-proxy`, and `web-client-v2` - - run `docker compose -f docker-compose.local.yml up --build -d` - - wait for Docker health - - set `PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080/` - - run a smoke test first - - expand to the broader suite after stability is proven - -## Notes For The Next Thread - -Start by reading this file and checking the branch diff. The most recent change is the default 5-node stack plus source-aware build cache markers in: - -- `scripts/local-network/start.sh` -- `docker-compose.local.yml` -- `local-network.env.example` - -There is an unrelated untracked file in this worktree that was intentionally not included: - -```text -playwright-tests/tests/web-client-v2.code-workspace -``` diff --git a/docs/local-network.md b/docs/local-network.md new file mode 100644 index 0000000..d0d1404 --- /dev/null +++ b/docs/local-network.md @@ -0,0 +1,61 @@ +# Local Network Test Stack + +This starts a local Liberdus network, serves `web-client-v2`, and points Playwright at it instead of `https://liberdus.com/dev/`. + +## What Runs + +### GitHub Workflow + +`.github/workflows/local-network-smoke.yml` checks out the needed repos into `.deps/`, installs Playwright, starts Docker Compose, waits for Docker health, runs one smoke test, uploads logs, and stops the stack. + +### Docker Compose + +`docker-compose.local.yml` builds the local-network image, mounts the dependency repos, exposes local ports, and uses `healthcheck.sh` to decide when the stack is test-ready. + +### Startup Script + +`scripts/local-network/start.js` copies the mounted repos into a writable Docker volume, writes local proxy/web config, reuses cached builds when possible, starts a 10-node Shardus network, waits for `processing`, starts `liberdus-proxy`, writes `web-client-v2/network.js`, and serves the web client. + +### Healthcheck + +`scripts/local-network/healthcheck.sh` checks that `network.js` is served, the proxy can read the zero account, and the archiver reports `processing`. + +## Run Locally + +Create the dependency checkouts: + +```powershell +New-Item -ItemType Directory -Force .deps +git clone https://github.com/Liberdus/server.git .deps/server +git clone --branch new-nodelist-response https://github.com/Liberdus/liberdus-proxy.git .deps/liberdus-proxy +git clone https://github.com/Liberdus/web-client-v2.git .deps/web-client-v2 +``` + +Start the stack: + +```powershell +docker compose -f docker-compose.local.yml up --build +``` + +Run the smoke test in another shell: + +```powershell +cd playwright-tests +npm ci +$env:PLAYWRIGHT_BASE_URL='http://127.0.0.1:8080/' +npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 +``` + +Stop the stack: + +```powershell +docker compose -f docker-compose.local.yml down +``` + +Useful local URLs: + +- Web client: `http://127.0.0.1:8080/` +- Proxy: `http://127.0.0.1:3030/` +- Monitor: `http://127.0.0.1:3000/` + +If your repos are somewhere else, set `LIBERDUS_SERVER_DIR`, `LIBERDUS_PROXY_DIR`, and `LIBERDUS_WEB_CLIENT_DIR` before running Compose. diff --git a/local-network.env.example b/local-network.env.example deleted file mode 100644 index f426f57..0000000 --- a/local-network.env.example +++ /dev/null @@ -1,24 +0,0 @@ -# Copy this file to local-network.env and edit paths if your repos live elsewhere. -# Use forward slashes for Windows paths so Docker Compose parses them consistently. - -LIBERDUS_SERVER_DIR=C:/Users/Chris/Documents/Code/liberdus/server -LIBERDUS_PROXY_DIR=C:/Users/Chris/Documents/Code/liberdus/liberdus-proxy -LIBERDUS_WEB_CLIENT_DIR=C:/Users/Chris/Documents/Code/liberdus/web-client-v2 - -# The Shardus network needs one external validator port per node. -# The container runs validators on the Shardus defaults, 9001-9005. -# The host publishes them on 9101-9105 to avoid common local conflicts. -LIBERDUS_NODE_COUNT=5 -LIBERDUS_VALIDATOR_CONTAINER_PORT_START=9001 -LIBERDUS_VALIDATOR_CONTAINER_PORT_END=9005 -LIBERDUS_VALIDATOR_HOST_PORT_START=9101 -LIBERDUS_VALIDATOR_HOST_PORT_END=9105 -LIBERDUS_INTERNAL_PORT_START=10001 - -# Host-facing ports. -LIBERDUS_PUBLIC_HOST=127.0.0.1 -LIBERDUS_MONITOR_HOST_PORT=3000 -LIBERDUS_ARCHIVER_HOST_PORT=4000 -LIBERDUS_PROXY_HOST_PORT=3030 -LIBERDUS_PROXY_WS_HOST_PORT=3031 -LIBERDUS_WEB_CLIENT_HOST_PORT=8080 diff --git a/scripts/local-network/start.js b/scripts/local-network/start.js new file mode 100644 index 0000000..3eada2f --- /dev/null +++ b/scripts/local-network/start.js @@ -0,0 +1,729 @@ +#!/usr/bin/env node +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const net = require('net'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); + +const ZERO_ACCOUNT = '0000000000000000000000000000000000000000000000000000000000000000'; +const ARCHIVER_SEED = [{ publicKey: '758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3', port: 4000, ip: '127.0.0.1' }]; +const SOURCE_EXCLUDES = { + server: ['.git', 'node_modules', 'instances', 'dist'], + proxy: ['.git', 'target'], + web: ['.git', 'node_modules'], +}; + +const state = { + serverDir: undefined, + proxyProcess: undefined, + webProcess: undefined, + shuttingDown: false, +}; + +// ----------------------------- basic helpers ----------------------------- + +// Prefixes every line we own so Docker/CI logs are easy to scan. +function log(message) { + process.stderr.write(`[liberdus-local] ${message}\n`); +} + +// Reads an environment variable with a fallback. +function env(name, fallback) { + return process.env[name] || fallback; +} + +// Reads an integer environment variable with validation. +function intEnv(name, fallback) { + const value = Number.parseInt(env(name, String(fallback)), 10); + if (!Number.isFinite(value)) throw new Error(`Expected ${name} to be an integer`); + return value; +} + +// Pauses async polling loops. +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Builds the runtime configuration once so the startup flow can pass one object around. +function buildConfig() { + const runtimeRoot = env('LIBERDUS_RUNTIME_ROOT', '/workspace/runtime'); + const nodeCount = intEnv('LIBERDUS_NODE_COUNT', 10); + const proxyBindPort = intEnv('LIBERDUS_PROXY_BIND_PORT', 3030); + + return { + runtimeRoot, + nodeCount, + sources: { + server: env('LIBERDUS_SERVER_SOURCE', '/sources/server'), + proxy: env('LIBERDUS_PROXY_SOURCE', '/sources/liberdus-proxy'), + web: env('LIBERDUS_WEB_CLIENT_SOURCE', '/sources/web-client-v2'), + }, + paths: { + server: path.join(runtimeRoot, 'server'), + proxy: path.join(runtimeRoot, 'liberdus-proxy'), + web: path.join(runtimeRoot, 'web-client-v2'), + logs: path.join(runtimeRoot, 'logs'), + }, + ports: { + externalStart: intEnv('LIBERDUS_EXTERNAL_PORT_START', 9001), + internalStart: intEnv('LIBERDUS_INTERNAL_PORT_START', 10001), + proxyBind: proxyBindPort, + proxyWsBind: proxyBindPort + 1, + proxyPublic: intEnv('LIBERDUS_PROXY_PUBLIC_PORT', 3030), + proxyWsPublic: intEnv('LIBERDUS_PROXY_WS_PUBLIC_PORT', 3031), + webBind: intEnv('LIBERDUS_WEB_BIND_PORT', 8080), + webPublic: intEnv('LIBERDUS_WEB_PUBLIC_PORT', 8080), + monitorPublic: intEnv('LIBERDUS_MONITOR_PUBLIC_PORT', 3000), + }, + publicHost: env('LIBERDUS_PUBLIC_HOST', '127.0.0.1'), + toolchains: { + serverRust: env('LIBERDUS_SERVER_RUST_TOOLCHAIN', '1.79.0'), + proxyRust: env('LIBERDUS_PROXY_RUST_TOOLCHAIN', '1.86.0'), + }, + }; +} + +// Runs a command synchronously, streaming output by default. +function run(command, args = [], options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: { ...process.env, ...(options.env || {}) }, + encoding: options.encoding === false ? undefined : 'utf8', + stdio: options.stdio || 'inherit', + }); + + if (result.error && !options.allowFailure) throw result.error; + if (result.status !== 0 && !options.allowFailure) { + throw new Error(`Command failed with exit ${result.status}: ${command} ${args.join(' ')}`); + } + + return result; +} + +// Runs a command and returns trimmed stdout. +function capture(command, args, options = {}) { + const result = run(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); + return String(result.stdout || '').trim(); +} + +// Checks whether a command is available in the container. +function commandExists(command) { + return spawnSync('bash', ['-lc', `command -v ${command}`], { stdio: 'ignore' }).status === 0; +} + +// Ensures a required source checkout exists before the expensive work begins. +function requireDir(label, dir) { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { + throw new Error(`Missing ${label}: ${dir}`); + } +} + +// Writes a file, creating parent directories when needed. +function writeFile(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +// Normalizes a directory path for rsync source and destination arguments. +function trailingSlash(dir) { + return dir.endsWith(path.sep) || dir.endsWith('/') ? dir : `${dir}/`; +} + +// ----------------------------- hashing/cache ------------------------------ + +// Hashes one file, returning a stable marker for optional missing files. +function hashFile(filePath) { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile() + ? crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex') + : 'missing'; +} + +// Recursively lists the requested files/directories that actually exist. +function listFiles(root, requestedPaths) { + const files = []; + + function walk(absolutePath, relativePath) { + const stat = fs.statSync(absolutePath); + if (stat.isFile()) return files.push(relativePath); + if (!stat.isDirectory()) return; + for (const entry of fs.readdirSync(absolutePath).sort()) { + walk(path.join(absolutePath, entry), path.posix.join(relativePath, entry)); + } + } + + for (const requestedPath of requestedPaths) { + const absolutePath = path.join(root, requestedPath); + if (fs.existsSync(absolutePath)) walk(absolutePath, requestedPath); + } + + return files.sort(); +} + +// Hashes a selected source surface to decide whether a cached build is reusable. +function hashPaths(root, requestedPaths) { + if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return 'missing'; + + const files = listFiles(root, requestedPaths); + if (files.length === 0) return 'missing'; + + const digest = crypto.createHash('sha256'); + for (const relativePath of files) { + digest.update(`${relativePath}\0${hashFile(path.join(root, relativePath))}\0`); + } + return digest.digest('hex'); +} + +// Checks a cache marker and the concrete files required by that cache. +function cacheValid(markerFile, expectedMarker, requiredPaths) { + return fs.existsSync(markerFile) + && fs.readFileSync(markerFile, 'utf8') === expectedMarker + && requiredPaths.every((requiredPath) => fs.existsSync(requiredPath)); +} + +// Computes all cache keys from source, lockfiles, and toolchain versions. +function buildCacheMarkers(cfg) { + const serverDepMarker = [ + `node=${capture('node', ['--version'])}`, + `rust=${capture('rustc', [`+${cfg.toolchains.serverRust}`, '--version'])}`, + `package=${hashFile(path.join(cfg.paths.server, 'package.json'))}`, + `lock=${hashFile(path.join(cfg.paths.server, 'package-lock.json'))}`, + ].join(';'); + + return { + serverDeps: serverDepMarker, + serverBuild: `${serverDepMarker};source=${hashPaths(cfg.paths.server, ['package.json', 'package-lock.json', 'tsconfig.json', 'src', 'client.js'])}`, + proxyBuild: [ + `rust=${capture('rustc', [`+${cfg.toolchains.proxyRust}`, '--version'])}`, + `source=${hashPaths(cfg.paths.proxy, ['Cargo.toml', 'Cargo.lock', 'src'])}`, + ].join(';'), + }; +} + +// ----------------------------- HTTP/waiting ------------------------------- + +// Fetches text over HTTP or HTTPS with a short timeout. +function requestText(url, options = {}) { + const timeoutMs = options.timeoutMs || 15000; + const failOnStatus = options.failOnStatus !== false; + const client = url.startsWith('https:') ? https : http; + + return new Promise((resolve, reject) => { + const request = client.get(url, (response) => { + const chunks = []; + if (failOnStatus && (response.statusCode < 200 || response.statusCode >= 300)) { + response.resume(); + return reject(new Error(`HTTP ${response.statusCode} from ${url}`)); + } + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }); + + request.setTimeout(timeoutMs, () => request.destroy(new Error(`Timed out requesting ${url}`))); + request.on('error', reject); + }); +} + +// Polls until a check returns a truthy value, then returns that value. +async function waitUntil(label, timeoutSeconds, check, intervalMs = 5000) { + const deadline = Date.now() + timeoutSeconds * 1000; + + while (Date.now() < deadline) { + const result = await check(); + if (result) return result; + await sleep(intervalMs); + } + + throw new Error(`Timed out waiting for ${label}`); +} + +// Polls a JSON endpoint until it satisfies a predicate. +function waitForJson(url, predicate, timeoutSeconds, label) { + return waitUntil(`${label} from ${url}`, timeoutSeconds, async () => { + try { + return predicate(JSON.parse(await requestText(url, { timeoutMs: 5000 }))); + } catch { + return false; + } + }); +} + +// Returns true when an HTTP endpoint responds at all. +async function httpResponds(url) { + try { + await requestText(url, { timeoutMs: 3000, failOnStatus: false }); + return true; + } catch { + return false; + } +} + +// Waits for an HTTP endpoint, optionally suppressing the timeout log. +async function waitForHttp(url, timeoutSeconds, label, quiet = false) { + try { + await waitUntil(`${label} from ${url}`, timeoutSeconds, () => httpResponds(url), 2000); + return true; + } catch (error) { + if (!quiet) log(error.message); + return false; + } +} + +// Waits until the proxy and archiver expose the local network id. +function waitForNetworkId(cfg) { + const accountUrl = `http://127.0.0.1:${cfg.ports.proxyBind}/account/${ZERO_ACCOUNT}`; + const cycleUrl = 'http://127.0.0.1:4000/cycleinfo/1'; + + return waitUntil(`network parameters from ${accountUrl}`, 600, async () => { + if (childExited(state.proxyProcess)) { + throw new Error(`Proxy exited while waiting for network parameters; see ${path.join(cfg.paths.logs, 'proxy.log')}`); + } + + try { + const account = JSON.parse(await requestText(accountUrl, { timeoutMs: 5000 })); + const cycle = JSON.parse(await requestText(cycleUrl, { timeoutMs: 5000 })); + return account.account && account.account.type === 'NetworkAccount' + ? cycle.cycleInfo && cycle.cycleInfo[0] && cycle.cycleInfo[0].networkId + : ''; + } catch { + return ''; + } + }); +} + +// ----------------------------- PM2/readiness ------------------------------ + +// Returns true when a spawned child has already exited. +function childExited(child) { + return child && (child.exitCode !== null || child.signalCode !== null); +} + +// Returns the Shardus PM2 home for this runtime volume. +function pm2Home(cfg) { + return path.join(cfg.paths.server, 'instances', '.pm2'); +} + +// Extracts the first complete JSON array from noisy PM2 stdout. +function extractJsonArray(text) { + const start = String(text).indexOf('['); + if (start === -1) return '[]'; + + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = start; index < text.length; index += 1) { + const char = text[index]; + if (escaped) { escaped = false; continue; } + if (char === '\\') { escaped = true; continue; } + if (char === '"') { inString = !inString; continue; } + if (inString) continue; + if (char === '[') depth += 1; + if (char === ']' && --depth === 0) return text.slice(start, index + 1); + } + + return '[]'; +} + +// Reads PM2 process metadata, tolerating empty or noisy output. +function pm2Processes(cfg) { + const result = run('pm2', ['jlist'], { + allowFailure: true, + env: { PM2_HOME: pm2Home(cfg) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + try { + return JSON.parse(extractJsonArray(result.stdout || '')); + } catch { + return []; + } +} + +// Finds a PM2 process id by Shardus process name. +function pm2ProcessIdByName(cfg, name) { + const processEntry = pm2Processes(cfg).find((entry) => String(entry.name || '').replace(/"/g, '') === name); + return processEntry ? String(processEntry.pm_id) : ''; +} + +// Restarts one PM2 process by name. +function restartPm2ProcessByName(cfg, name) { + const pm2Id = pm2ProcessIdByName(cfg, name); + if (!pm2Id) throw new Error(`Could not find PM2 process named ${name}`); + run('pm2', ['restart', pm2Id, '--no-color'], { env: { PM2_HOME: pm2Home(cfg) }, stdio: 'ignore' }); +} + +// Restarts the monitor once if PM2 says it is online but the port never binds. +async function ensureMonitorReady(cfg) { + const initialTimeout = intEnv('LIBERDUS_MONITOR_READY_TIMEOUT', 60); + const restartTimeout = intEnv('LIBERDUS_MONITOR_RESTART_READY_TIMEOUT', 120); + const monitorUrl = 'http://127.0.0.1:3000/'; + + if (await waitForHttp(monitorUrl, initialTimeout, 'monitor', true)) return; + + log(`Monitor did not bind on port 3000 after ${initialTimeout}s; restarting monitor-server once`); + restartPm2ProcessByName(cfg, 'monitor-server'); + if (!await waitForHttp(monitorUrl, restartTimeout, 'monitor')) { + throw new Error('Monitor did not become ready after restart'); + } +} + +// Checks a local TCP listener, preferring lsof to handle IPv6 wildcard sockets. +async function portListening(port) { + if (commandExists('lsof')) { + return run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], { allowFailure: true, stdio: 'ignore' }).status === 0; + } + + return new Promise((resolve) => { + const socket = net.createConnection({ host: '127.0.0.1', port, timeout: 1000 }, () => { + socket.destroy(); + resolve(true); + }); + socket.on('timeout', () => { socket.destroy(); resolve(false); }); + socket.on('error', () => resolve(false)); + }); +} + +// Lists validator ports that are not listening yet. +async function missingValidatorPorts(cfg) { + const missing = []; + const { externalStart } = cfg.ports; + + for (let port = externalStart; port < externalStart + cfg.nodeCount; port += 1) { + if (!await portListening(port)) missing.push(port); + } + + return missing; +} + +// Waits for every validator port to bind. +async function waitForValidatorPorts(cfg, timeoutSeconds) { + let missing = []; + + try { + await waitUntil('validator ports to bind', timeoutSeconds, async () => { + missing = await missingValidatorPorts(cfg); + return missing.length === 0; + }); + return true; + } catch { + log(`Timed out waiting for validator ports to bind: ${missing.join(' ')}`); + return false; + } +} + +// Restarts validators once if their PM2 process is online but the port is not bound. +async function ensureValidatorPortsReady(cfg) { + const initialTimeout = intEnv('LIBERDUS_VALIDATOR_PORT_READY_TIMEOUT', 60); + const restartTimeout = intEnv('LIBERDUS_VALIDATOR_RESTART_READY_TIMEOUT', 180); + + if (await waitForValidatorPorts(cfg, initialTimeout)) return; + + const missing = await missingValidatorPorts(cfg); + log(`Restarting validators that did not bind after ${initialTimeout}s: ${missing.join(' ')}`); + for (const port of missing) restartPm2ProcessByName(cfg, `shardus-instance-${port}`); + + if (!await waitForValidatorPorts(cfg, restartTimeout)) { + throw new Error('Validator ports did not become ready after restart'); + } +} + +// ----------------------------- config writers ----------------------------- + +// Ensures a nested object exists before assigning generated local config. +function ensureObject(parent, key) { + if (!parent[key] || typeof parent[key] !== 'object' || Array.isArray(parent[key])) parent[key] = {}; + return parent[key]; +} + +// Writes proxy config for a standalone local network. +function writeProxyConfig(cfg) { + const configPath = path.join(cfg.paths.proxy, 'src', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + config.http_port = cfg.ports.proxyBind; + config.archiver_seed_path = './src/archiver_seed.json'; + Object.assign(ensureObject(config, 'standalone_network'), { enabled: true, replacement_ip: '127.0.0.1' }); + Object.assign(ensureObject(config, 'node_filtering'), { + enabled: false, + remove_top_nodes: 0, + remove_bottom_nodes: 0, + min_nodes_for_filtering: 0, + }); + Object.assign(ensureObject(config, 'shardus_monitor'), { upstream_ip: '127.0.0.1', upstream_port: 3000, https: false }); + Object.assign(ensureObject(config, 'local_source'), { + collector_api_ip: '127.0.0.1', + collector_api_port: 6101, + collector_event_server_ip: '127.0.0.1', + collector_event_server_port: 4444, + }); + Object.assign(ensureObject(config, 'notifier'), { ip: '127.0.0.1', port: 4444 }); + + writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); + writeFile(path.join(cfg.paths.proxy, 'src', 'archiver_seed.json'), `${JSON.stringify(ARCHIVER_SEED)}\n`); +} + +// Writes the web client's local network.js from the detected network id. +function writeWebNetwork(cfg, networkId) { + const network = { + name: 'Localnet', + netid: networkId, + netids: [networkId], + gateways: [{ web: `http://${cfg.publicHost}:${cfg.ports.proxyPublic}`, ws: `ws://${cfg.publicHost}:${cfg.ports.proxyWsPublic}` }], + bridges: [ + { name: 'Polygon', username: 'bridgepolygon' }, + { name: 'Ethereum', username: 'bridgeeth' }, + { name: 'BSC', username: 'bridgebsc' }, + ], + farmUrl: 'https://liberdus.com/farm', + validatorUrl: 'https://liberdus.com/validator', + bridgeUrl: './bridge', + }; + + writeFile(path.join(cfg.paths.web, 'network.js'), `const network = ${JSON.stringify(network, null, 2)}\n`); +} + +// ----------------------------- startup phases ----------------------------- + +// Validates source checkouts and the proxy branch requirement. +function validateSources(cfg) { + requireDir('server source', cfg.sources.server); + requireDir('liberdus-proxy source', cfg.sources.proxy); + requireDir('web-client-v2 source', cfg.sources.web); + + const liberdusRs = path.join(cfg.sources.proxy, 'src', 'liberdus.rs'); + const proxySource = fs.existsSync(liberdusRs) ? fs.readFileSync(liberdusRs, 'utf8') : ''; + if (!proxySource.includes('foundationNode')) { + throw new Error([ + 'The configured liberdus-proxy checkout does not support the current archiver nodelist response.', + 'Use a liberdus-proxy branch that includes the foundationNode nodelist fix, such as origin/new-nodelist-response.', + ].join('\n')); + } +} + +// Copies read-only mounted repos into the writable runtime volume. +function syncSources(cfg) { + fs.mkdirSync(cfg.paths.logs, { recursive: true }); + log('Syncing source repos into Docker volume'); + + for (const [name, destination] of Object.entries(cfg.paths)) { + if (name === 'logs') continue; + const excludes = SOURCE_EXCLUDES[name].flatMap((entry) => ['--exclude', entry]); + run('rsync', ['-a', '--delete', ...excludes, trailingSlash(cfg.sources[name]), trailingSlash(destination)]); + } +} + +// Installs server dependencies and compiles dist when the cache marker is stale. +function prepareServer(cfg, markers) { + const nodeModules = path.join(cfg.paths.server, 'node_modules'); + const dist = path.join(cfg.paths.server, 'dist'); + const depsMarker = path.join(nodeModules, '.local-network-build'); + const buildMarker = path.join(dist, '.local-network-build'); + let depsWereCached = false; + + if (cacheValid(depsMarker, markers.serverDeps, [path.join(nodeModules, '.bin', 'shardus')])) { + depsWereCached = true; + log('Reusing cached server dependencies'); + } else { + log('Installing server dependencies'); + fs.rmSync(nodeModules, { recursive: true, force: true }); + run('npm', ['install'], { cwd: cfg.paths.server, env: { RUSTUP_TOOLCHAIN: cfg.toolchains.serverRust } }); + writeFile(depsMarker, markers.serverDeps); + } + + if (cacheValid(buildMarker, markers.serverBuild, [path.join(dist, 'index.js')])) { + log('Reusing cached server build'); + } else if (!depsWereCached && fs.existsSync(path.join(dist, 'index.js'))) { + log('Using server build produced during dependency install'); + writeFile(buildMarker, markers.serverBuild); + } else { + log('Compiling server'); + run('npm', ['run', 'compile'], { cwd: cfg.paths.server, env: { RUSTUP_TOOLCHAIN: cfg.toolchains.serverRust } }); + writeFile(buildMarker, markers.serverBuild); + } +} + +// Clears old Shardus/PM2 state before starting a fresh network. +function resetShardusState(cfg) { + process.env.PATH = `${path.join(cfg.paths.server, 'node_modules', '.bin')}:${process.env.PATH}`; + log('Resetting any previous Shardus instances'); + + run('shardus', ['stop'], { allowFailure: true, cwd: cfg.paths.server, stdio: 'ignore' }); + if (fs.existsSync(pm2Home(cfg))) { + run('pm2', ['kill'], { allowFailure: true, env: { PM2_HOME: pm2Home(cfg) }, stdio: 'ignore' }); + } + fs.rmSync(path.join(cfg.paths.server, 'instances'), { recursive: true, force: true }); +} + +// Starts Shardus using the normal shardus start command. +function startShardus(cfg) { + process.env.minNodes = String(cfg.nodeCount); + process.env.baselineNodes = String(cfg.nodeCount); + process.env.maxNodes = String(cfg.nodeCount * 2); + + log(`Starting ${cfg.nodeCount}-node Shardus network on validator ports ${cfg.ports.externalStart}-${cfg.ports.externalStart + cfg.nodeCount - 1}`); + if (cfg.ports.externalStart !== 9001 || cfg.ports.internalStart !== 10001) { + run('shardus', [ + 'create', + '--no-start', + '--starting-external-port', String(cfg.ports.externalStart), + '--starting-internal-port', String(cfg.ports.internalStart), + String(cfg.nodeCount), + 'pm2--no-autorestart', + ], { cwd: cfg.paths.server }); + } + + run('shardus', ['start', String(cfg.nodeCount), 'pm2--no-autorestart'], { cwd: cfg.paths.server }); +} + +// Waits for the network state needed before app transactions can succeed. +async function waitForShardusReady(cfg) { + log('Waiting for archiver, monitor, and active nodelist'); + await waitForJson('http://127.0.0.1:4000/archivers', (data) => ((data.activeArchivers || data.archivers || []).length) > 0, 600, 'active archivers'); + await ensureMonitorReady(cfg); + await ensureValidatorPortsReady(cfg); + await waitForJson('http://127.0.0.1:4000/full-nodelist?activeOnly=true', (data) => ((data.nodeList || data.nodes || data.nodelist || []).length) > 0, 600, 'active nodelist'); + await waitForJson('http://127.0.0.1:4000/cycleinfo/1', (data) => ( + Array.isArray(data.cycleInfo) + && data.cycleInfo.length > 0 + && data.cycleInfo[0].mode === 'processing' + && Number(data.cycleInfo[0].active || 0) >= cfg.nodeCount + ), 2400, 'processing cycle'); +} + +// Builds liberdus-proxy when its source or Rust toolchain marker changes. +function prepareProxy(cfg, markers) { + const target = path.join(cfg.paths.proxy, 'target'); + const proxyBin = path.join(target, 'debug', 'liberdus-proxy'); + const marker = path.join(target, '.local-network-build'); + + if (cacheValid(marker, markers.proxyBuild, [proxyBin])) { + log('Reusing cached Liberdus proxy build'); + return; + } + + log('Building Liberdus proxy'); + fs.rmSync(target, { recursive: true, force: true }); + run('cargo', [`+${cfg.toolchains.proxyRust}`, 'build'], { cwd: cfg.paths.proxy }); + writeFile(marker, markers.proxyBuild); +} + +// Starts a child process with stdout/stderr redirected to a runtime log file. +function startLoggedProcess(label, command, args, cwd, logFile) { + const fd = fs.openSync(logFile, 'a'); + const child = spawn(command, args, { cwd, env: process.env, stdio: ['ignore', fd, fd] }); + + child.on('exit', (code, signal) => { + if (!state.shuttingDown) log(`${label} exited with ${signal || code}; see ${logFile}`); + }); + child.on('error', (error) => { + if (!state.shuttingDown) log(`${label} failed to start: ${error.message}`); + }); + + return child; +} + +// Starts the proxy, waits for network id, writes network.js, then serves the web client. +async function startProxyAndWeb(cfg) { + log('Starting Liberdus proxy'); + state.proxyProcess = startLoggedProcess('Liberdus proxy', './target/debug/liberdus-proxy', [], cfg.paths.proxy, path.join(cfg.paths.logs, 'proxy.log')); + + const networkId = await waitForNetworkId(cfg); + log(`Detected local network id: ${networkId}`); + writeWebNetwork(cfg, networkId); + + log(`Starting web client on http://${cfg.publicHost}:${cfg.ports.webPublic}/`); + state.webProcess = startLoggedProcess( + 'Web client server', + 'python3', + ['-m', 'http.server', String(cfg.ports.webBind), '--bind', '0.0.0.0'], + cfg.paths.web, + path.join(cfg.paths.logs, 'web-client.log'), + ); +} + +// Keeps the container alive and fails if a foreground child exits. +async function watchChildren(cfg) { + log('Local network is ready'); + log(`Monitor: http://${cfg.publicHost}:${cfg.ports.monitorPublic}/`); + log(`Proxy: http://${cfg.publicHost}:${cfg.ports.proxyPublic}/ (binds ${cfg.ports.proxyBind}/${cfg.ports.proxyWsBind})`); + log(`Web client: http://${cfg.publicHost}:${cfg.ports.webPublic}/`); + + while (true) { + if (childExited(state.proxyProcess)) throw new Error(`Proxy exited; see ${path.join(cfg.paths.logs, 'proxy.log')} in the local-network-runtime volume`); + if (childExited(state.webProcess)) throw new Error(`Web client server exited; see ${path.join(cfg.paths.logs, 'web-client.log')} in the local-network-runtime volume`); + await sleep(5000); + } +} + +// Stops child services when the container exits or receives a signal. +function stopStack() { + if (state.shuttingDown) return; + state.shuttingDown = true; + + if (state.serverDir && fs.existsSync(state.serverDir)) { + log('Stopping Shardus network'); + run('shardus', ['stop'], { + allowFailure: true, + cwd: state.serverDir, + env: { PATH: `${path.join(state.serverDir, 'node_modules', '.bin')}:${process.env.PATH}` }, + stdio: 'ignore', + }); + } + + if (state.proxyProcess && !childExited(state.proxyProcess)) state.proxyProcess.kill('SIGTERM'); + if (state.webProcess && !childExited(state.webProcess)) state.webProcess.kill('SIGTERM'); +} + +// Installs shutdown handlers once at process startup. +function installSignalHandlers() { + process.once('exit', stopStack); + for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { + stopStack(); + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + } +} + +// ----------------------------- main runbook ------------------------------- + +// Runs the local-network startup sequence from sources to ready web client. +async function main() { + installSignalHandlers(); + + const cfg = buildConfig(); + state.serverDir = cfg.paths.server; + + // Validate source checkouts before doing expensive install/build work. + validateSources(cfg); + + // Copy mounted repos into the writable runtime volume and write generated config. + syncSources(cfg); + writeProxyConfig(cfg); + + // Reuse server/proxy builds when source, lockfiles, and toolchains match. + const markers = buildCacheMarkers(cfg); + prepareServer(cfg, markers); + + // Start Shardus and wait until the network is in processing mode. + resetShardusState(cfg); + startShardus(cfg); + await waitForShardusReady(cfg); + + // Start proxy/web only after Shardus can accept app transactions. + prepareProxy(cfg, markers); + await startProxyAndWeb(cfg); + + // Keep PID 1 alive while the child services stay healthy. + await watchChildren(cfg); +} + +main().catch((error) => { + log(error && error.stack ? error.stack : String(error)); + stopStack(); + process.exit(1); +}); diff --git a/scripts/local-network/start.sh b/scripts/local-network/start.sh index 0f86f98..6f8c528 100644 --- a/scripts/local-network/start.sh +++ b/scripts/local-network/start.sh @@ -1,501 +1,5 @@ #!/usr/bin/env bash set -Eeuo pipefail -log() { - printf '[liberdus-local] %s\n' "$*" >&2 -} - -require_dir() { - local label="$1" - local dir="$2" - if [ ! -d "$dir" ]; then - log "Missing ${label}: ${dir}" - exit 1 - fi -} - -sync_repo() { - local src="$1" - local dest="$2" - shift 2 - mkdir -p "$dest" - rsync -a --delete "$@" "${src}/" "${dest}/" -} - -hash_file() { - local path="$1" - if [ -f "$path" ]; then - sha256sum "$path" | awk '{print $1}' - else - printf 'missing' - fi -} - -hash_paths() { - local root="$1" - shift - - if [ ! -d "$root" ]; then - printf 'missing' - return 0 - fi - - ( - cd "$root" - find "$@" -type f -print0 2>/dev/null \ - | sort -z \ - | xargs -0 -r sha256sum \ - | sha256sum \ - | awk '{print $1}' - ) -} - -cache_valid() { - local marker_file="$1" - local expected_marker="$2" - shift 2 - - [ -f "$marker_file" ] || return 1 - [ "$(cat "$marker_file")" = "$expected_marker" ] || return 1 - - local required_path - for required_path in "$@"; do - [ -e "$required_path" ] || return 1 - done - - return 0 -} - -wait_for_network_id() { - local account_url="$1" - local cycle_url="$2" - local timeout_seconds="$3" - local started - started="$(date +%s)" - - while true; do - local body - body="$(curl -fsS "$account_url" 2>/dev/null || true)" - if [ -n "$body" ]; then - local has_network_account - has_network_account="$(printf '%s' "$body" | jq -r '(.account.type == "NetworkAccount") // false' 2>/dev/null || true)" - if [ "$has_network_account" = "true" ]; then - local cycle_body - cycle_body="$(curl -fsS "$cycle_url" 2>/dev/null || true)" - if [ -n "$cycle_body" ]; then - local network_id - network_id="$(printf '%s' "$cycle_body" | jq -r '.cycleInfo[0].networkId // empty' 2>/dev/null || true)" - if [ -n "$network_id" ]; then - printf '%s' "$network_id" - return 0 - fi - fi - fi - fi - - if [ -n "${PROXY_PID:-}" ] && ! kill -0 "$PROXY_PID" >/dev/null 2>&1; then - log "Proxy exited while waiting for network parameters; see ${LOG_DIR}/proxy.log" - return 1 - fi - - if [ "$(( $(date +%s) - started ))" -ge "$timeout_seconds" ]; then - log "Timed out waiting for network parameters from ${account_url}" - return 1 - fi - - sleep 5 - done -} - -wait_for_json_field() { - local url="$1" - local predicate="$2" - local timeout_seconds="$3" - local label="$4" - local started - started="$(date +%s)" - - while true; do - local body - body="$(curl -fsS "$url" 2>/dev/null || true)" - if [ -n "$body" ] && printf '%s' "$body" | jq -e "$predicate" >/dev/null 2>&1; then - return 0 - fi - - if [ "$(( $(date +%s) - started ))" -ge "$timeout_seconds" ]; then - log "Timed out waiting for ${label} from ${url}" - return 1 - fi - - sleep 5 - done -} - -http_responds() { - local url="$1" - curl --connect-timeout 2 --max-time 3 -sS -o /dev/null "$url" >/dev/null 2>&1 -} - -wait_for_http_response() { - local url="$1" - local timeout_seconds="$2" - local label="$3" - local quiet="${4:-false}" - local started - started="$(date +%s)" - - while true; do - if http_responds "$url"; then - return 0 - fi - - if [ "$(( $(date +%s) - started ))" -ge "$timeout_seconds" ]; then - if [ "$quiet" != "true" ]; then - log "Timed out waiting for ${label} from ${url}" - fi - return 1 - fi - - sleep 2 - done -} - -pm2_process_id_by_name() { - local name="$1" - local process_list - process_list="$( - { PM2_HOME="${SERVER_DIR}/instances/.pm2" pm2 jlist 2>/dev/null || true; } \ - | awk 'found || /^\[\{/ || /^\[\]/ { found = 1; print }' - )" - if [ -z "$process_list" ]; then - process_list="[]" - fi - - printf '%s' "$process_list" \ - | jq -r --arg name "$name" '.[] | select((.name | gsub("\""; "")) == $name) | .pm_id' 2>/dev/null \ - | head -n 1 \ - || true -} - -restart_pm2_process_by_name() { - local name="$1" - local pm2_id - pm2_id="$(pm2_process_id_by_name "$name")" - - if [ -z "$pm2_id" ]; then - log "Could not find PM2 process named ${name}" - return 1 - fi - - PM2_HOME="${SERVER_DIR}/instances/.pm2" pm2 restart "$pm2_id" --no-color >/dev/null -} - -ensure_monitor_ready() { - local monitor_url="http://127.0.0.1:3000/" - - if wait_for_http_response "$monitor_url" 20 "monitor" true; then - return 0 - fi - - log "Monitor did not bind on port 3000; restarting monitor-server once" - restart_pm2_process_by_name "monitor-server" - wait_for_http_response "$monitor_url" 120 "monitor" -} - -port_listening() { - local port="$1" - if command -v lsof >/dev/null 2>&1; then - lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 - return $? - fi - - http_responds "http://127.0.0.1:${port}/" -} - -ensure_validator_ports_ready() { - local missing=() - local port - - for (( port = EXTERNAL_PORT_START; port < EXTERNAL_PORT_START + NODE_COUNT; port++ )); do - if ! port_listening "$port"; then - missing+=("$port") - fi - done - - if [ "${#missing[@]}" -eq 0 ]; then - return 0 - fi - - log "Restarting validators that did not bind: ${missing[*]}" - for port in "${missing[@]}"; do - restart_pm2_process_by_name "shardus-instance-${port}" - done - - local started - started="$(date +%s)" - while true; do - missing=() - for (( port = EXTERNAL_PORT_START; port < EXTERNAL_PORT_START + NODE_COUNT; port++ )); do - if ! port_listening "$port"; then - missing+=("$port") - fi - done - - if [ "${#missing[@]}" -eq 0 ]; then - return 0 - fi - - if [ "$(( $(date +%s) - started ))" -ge 180 ]; then - log "Timed out waiting for validator ports to bind: ${missing[*]}" - return 1 - fi - - sleep 5 - done -} - -write_proxy_config() { - local proxy_dir="$1" - local proxy_bind_port="$2" - - jq \ - --argjson proxy_bind_port "$proxy_bind_port" \ - '.http_port = $proxy_bind_port - | .archiver_seed_path = "./src/archiver_seed.json" - | .standalone_network.enabled = true - | .standalone_network.replacement_ip = "127.0.0.1" - | .shardus_monitor.upstream_ip = "127.0.0.1" - | .shardus_monitor.upstream_port = 3000 - | .shardus_monitor.https = false - | .local_source.collector_api_ip = "127.0.0.1" - | .local_source.collector_api_port = 6101 - | .local_source.collector_event_server_ip = "127.0.0.1" - | .local_source.collector_event_server_port = 4444' \ - "${proxy_dir}/src/config.json" > "${proxy_dir}/src/config.local.json" - mv "${proxy_dir}/src/config.local.json" "${proxy_dir}/src/config.json" - - cat > "${proxy_dir}/src/archiver_seed.json" <<'JSON' -[{"publicKey":"758b1c119412298802cd28dbfa394cdfeecc4074492d60844cc192d632d84de3","port":4000,"ip":"127.0.0.1"}] -JSON -} - -write_web_network() { - local web_dir="$1" - local network_id="$2" - local public_host="$3" - local proxy_public_port="$4" - local proxy_ws_public_port="$5" - - cat > "${web_dir}/network.js" </dev/null 2>&1) - fi - if [ -n "${PROXY_PID:-}" ]; then - kill "$PROXY_PID" >/dev/null 2>&1 - fi - if [ -n "${HTTP_PID:-}" ]; then - kill "$HTTP_PID" >/dev/null 2>&1 - fi -} - -trap stop_stack EXIT INT TERM - -RUNTIME_ROOT="${LIBERDUS_RUNTIME_ROOT:-/workspace/runtime}" -SERVER_SOURCE="${LIBERDUS_SERVER_SOURCE:-/sources/server}" -PROXY_SOURCE="${LIBERDUS_PROXY_SOURCE:-/sources/liberdus-proxy}" -WEB_CLIENT_SOURCE="${LIBERDUS_WEB_CLIENT_SOURCE:-/sources/web-client-v2}" - -NODE_COUNT="${LIBERDUS_NODE_COUNT:-5}" -EXTERNAL_PORT_START="${LIBERDUS_EXTERNAL_PORT_START:-9001}" -INTERNAL_PORT_START="${LIBERDUS_INTERNAL_PORT_START:-10001}" -PUBLIC_HOST="${LIBERDUS_PUBLIC_HOST:-127.0.0.1}" -PROXY_BIND_PORT="${LIBERDUS_PROXY_BIND_PORT:-3030}" -PROXY_WS_BIND_PORT="$(( PROXY_BIND_PORT + 1 ))" -PROXY_PUBLIC_PORT="${LIBERDUS_PROXY_PUBLIC_PORT:-3030}" -PROXY_WS_PUBLIC_PORT="${LIBERDUS_PROXY_WS_PUBLIC_PORT:-3031}" -WEB_BIND_PORT="${LIBERDUS_WEB_BIND_PORT:-8080}" -WEB_PUBLIC_PORT="${LIBERDUS_WEB_PUBLIC_PORT:-8080}" -SERVER_RUST_TOOLCHAIN="${LIBERDUS_SERVER_RUST_TOOLCHAIN:-1.79.0}" -PROXY_RUST_TOOLCHAIN="${LIBERDUS_PROXY_RUST_TOOLCHAIN:-1.82.0}" - -SERVER_DIR="${RUNTIME_ROOT}/server" -PROXY_DIR="${RUNTIME_ROOT}/liberdus-proxy" -WEB_DIR="${RUNTIME_ROOT}/web-client-v2" -LOG_DIR="${RUNTIME_ROOT}/logs" - -require_dir "server source" "$SERVER_SOURCE" -require_dir "liberdus-proxy source" "$PROXY_SOURCE" -require_dir "web-client-v2 source" "$WEB_CLIENT_SOURCE" - -mkdir -p "$LOG_DIR" - -log "Syncing source repos into Docker volume" -sync_repo "$SERVER_SOURCE" "$SERVER_DIR" \ - --exclude .git --exclude node_modules --exclude instances --exclude dist -sync_repo "$PROXY_SOURCE" "$PROXY_DIR" \ - --exclude .git --exclude target -sync_repo "$WEB_CLIENT_SOURCE" "$WEB_DIR" \ - --exclude .git --exclude node_modules - -write_proxy_config "$PROXY_DIR" "$PROXY_BIND_PORT" - -SERVER_DEP_MARKER="node=$(node --version);rust=$(rustc +"$SERVER_RUST_TOOLCHAIN" --version);package=$(hash_file "${SERVER_DIR}/package.json");lock=$(hash_file "${SERVER_DIR}/package-lock.json")" -SERVER_BUILD_MARKER="${SERVER_DEP_MARKER};source=$(hash_paths "$SERVER_DIR" package.json package-lock.json tsconfig.json src client.js)" -PROXY_BUILD_MARKER="rust=$(rustc +"$PROXY_RUST_TOOLCHAIN" --version);source=$(hash_paths "$PROXY_DIR" Cargo.toml Cargo.lock src)" - -SERVER_DEPS_CACHED=false -if cache_valid "${SERVER_DIR}/node_modules/.local-network-build" "$SERVER_DEP_MARKER" \ - "${SERVER_DIR}/node_modules/.bin/shardus"; then - SERVER_DEPS_CACHED=true - log "Reusing cached server dependencies" -else - log "Installing server dependencies" - rm -rf "${SERVER_DIR}/node_modules" - ( - cd "$SERVER_DIR" - export RUSTUP_TOOLCHAIN="$SERVER_RUST_TOOLCHAIN" - npm install - mkdir -p node_modules - printf '%s' "$SERVER_DEP_MARKER" > node_modules/.local-network-build - ) -fi - -if cache_valid "${SERVER_DIR}/dist/.local-network-build" "$SERVER_BUILD_MARKER" \ - "${SERVER_DIR}/dist/index.js"; then - log "Reusing cached server build" -else - if [ "$SERVER_DEPS_CACHED" = "false" ] && [ -f "${SERVER_DIR}/dist/index.js" ]; then - log "Using server build produced during dependency install" - else - log "Compiling server" - ( - cd "$SERVER_DIR" - export RUSTUP_TOOLCHAIN="$SERVER_RUST_TOOLCHAIN" - npm run compile - ) - fi - mkdir -p "${SERVER_DIR}/dist" - printf '%s' "$SERVER_BUILD_MARKER" > "${SERVER_DIR}/dist/.local-network-build" -fi - -export PATH="${SERVER_DIR}/node_modules/.bin:${PATH}" - -log "Resetting any previous Shardus instances" -( - cd "$SERVER_DIR" - shardus stop >/dev/null 2>&1 || true - rm -rf instances -) - -export minNodes="$NODE_COUNT" -export baselineNodes="$NODE_COUNT" -export maxNodes="$(( NODE_COUNT * 2 ))" - -log "Starting ${NODE_COUNT}-node Shardus network on validator ports ${EXTERNAL_PORT_START}-$(( EXTERNAL_PORT_START + NODE_COUNT - 1 ))" -( - cd "$SERVER_DIR" - if [ "$EXTERNAL_PORT_START" != "9001" ] || [ "$INTERNAL_PORT_START" != "10001" ]; then - shardus create \ - --no-start \ - --starting-external-port "$EXTERNAL_PORT_START" \ - --starting-internal-port "$INTERNAL_PORT_START" \ - "$NODE_COUNT" \ - pm2--no-autorestart - fi - - shardus start "$NODE_COUNT" pm2--no-autorestart -) - -log "Waiting for archiver, monitor, and active nodelist" -wait_for_json_field "http://127.0.0.1:4000/archivers" '((.activeArchivers // .archivers // []) | length) > 0' 600 "active archivers" -ensure_monitor_ready -ensure_validator_ports_ready -wait_for_json_field "http://127.0.0.1:4000/full-nodelist?activeOnly=true" '((.nodeList // .nodes // .nodelist // []) | length) > 0' 600 "active nodelist" -wait_for_json_field "http://127.0.0.1:4000/cycleinfo/1" "((.cycleInfo // []) | length) > 0 and (.cycleInfo[0].mode == \"processing\") and ((.cycleInfo[0].active // 0) >= ${NODE_COUNT})" 2400 "processing cycle" - -if cache_valid "${PROXY_DIR}/target/.local-network-build" "$PROXY_BUILD_MARKER" \ - "${PROXY_DIR}/target/debug/liberdus-proxy"; then - log "Reusing cached Liberdus proxy build" -else - log "Building Liberdus proxy" - rm -rf "${PROXY_DIR}/target" - ( - cd "$PROXY_DIR" - cargo +"$PROXY_RUST_TOOLCHAIN" build - mkdir -p target - printf '%s' "$PROXY_BUILD_MARKER" > target/.local-network-build - ) -fi - -log "Starting Liberdus proxy" -( - cd "$PROXY_DIR" - ./target/debug/liberdus-proxy > "${LOG_DIR}/proxy.log" 2>&1 -) & -PROXY_PID=$! - -ZERO_ACCOUNT="0000000000000000000000000000000000000000000000000000000000000000" -NETWORK_ID="$(wait_for_network_id "http://127.0.0.1:${PROXY_BIND_PORT}/account/${ZERO_ACCOUNT}" "http://127.0.0.1:4000/cycleinfo/1" 600)" -log "Detected local network id: ${NETWORK_ID}" - -write_web_network "$WEB_DIR" "$NETWORK_ID" "$PUBLIC_HOST" "$PROXY_PUBLIC_PORT" "$PROXY_WS_PUBLIC_PORT" - -log "Starting web client on http://${PUBLIC_HOST}:${WEB_PUBLIC_PORT}/" -( - cd "$WEB_DIR" - python3 -m http.server "$WEB_BIND_PORT" --bind 0.0.0.0 > "${LOG_DIR}/web-client.log" 2>&1 -) & -HTTP_PID=$! - -log "Local network is ready" -log "Monitor: http://${PUBLIC_HOST}:${LIBERDUS_MONITOR_PUBLIC_PORT:-3000}/" -log "Proxy: http://${PUBLIC_HOST}:${PROXY_PUBLIC_PORT}/ (binds ${PROXY_BIND_PORT}/${PROXY_WS_BIND_PORT})" -log "Web client: http://${PUBLIC_HOST}:${WEB_PUBLIC_PORT}/" - -while true; do - if ! kill -0 "$PROXY_PID" >/dev/null 2>&1; then - log "Proxy exited; see ${LOG_DIR}/proxy.log in the local-network-runtime volume" - exit 1 - fi - if ! kill -0 "$HTTP_PID" >/dev/null 2>&1; then - log "Web client server exited; see ${LOG_DIR}/web-client.log in the local-network-runtime volume" - exit 1 - fi - sleep 5 -done +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "${script_dir}/start.js" From 33df72690623c3f06e484a4902ba9a29c49606dd Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Tue, 5 May 2026 16:47:45 -0500 Subject: [PATCH 04/11] ci: make local network smoke manual --- .github/workflows/local-network-smoke.yml | 7 ------- docs/local-network.md | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/local-network-smoke.yml b/.github/workflows/local-network-smoke.yml index 6cedf1c..ddaba89 100644 --- a/.github/workflows/local-network-smoke.yml +++ b/.github/workflows/local-network-smoke.yml @@ -2,13 +2,6 @@ name: Local Network Smoke on: workflow_dispatch: - pull_request: - paths: - - ".github/workflows/local-network-smoke.yml" - - ".docker/local-network.Dockerfile" - - "docker-compose.local.yml" - - "scripts/local-network/**" - - "playwright-tests/**" jobs: smoke: diff --git a/docs/local-network.md b/docs/local-network.md index d0d1404..56cd181 100644 --- a/docs/local-network.md +++ b/docs/local-network.md @@ -6,7 +6,7 @@ This starts a local Liberdus network, serves `web-client-v2`, and points Playwri ### GitHub Workflow -`.github/workflows/local-network-smoke.yml` checks out the needed repos into `.deps/`, installs Playwright, starts Docker Compose, waits for Docker health, runs one smoke test, uploads logs, and stops the stack. +`.github/workflows/local-network-smoke.yml` is manual-only in this repo. It checks out the needed repos into `.deps/`, installs Playwright, starts Docker Compose, waits for Docker health, runs one smoke test, uploads logs, and stops the stack. ### Docker Compose From 1cf55289374013f01c9c0dab9f50ac4c5bc3a884 Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Tue, 5 May 2026 16:56:09 -0500 Subject: [PATCH 05/11] chore: remove local network start wrapper --- .docker/local-network.Dockerfile | 2 +- scripts/local-network/start.sh | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 scripts/local-network/start.sh diff --git a/.docker/local-network.Dockerfile b/.docker/local-network.Dockerfile index db3a1dc..bfe4360 100644 --- a/.docker/local-network.Dockerfile +++ b/.docker/local-network.Dockerfile @@ -51,6 +51,6 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ WORKDIR /workspace/client-testing COPY scripts/local-network /usr/local/bin/liberdus-local-network -RUN chmod +x /usr/local/bin/liberdus-local-network/*.sh +RUN chmod +x /usr/local/bin/liberdus-local-network/healthcheck.sh CMD ["node", "/usr/local/bin/liberdus-local-network/start.js"] diff --git a/scripts/local-network/start.sh b/scripts/local-network/start.sh deleted file mode 100644 index 6f8c528..0000000 --- a/scripts/local-network/start.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec node "${script_dir}/start.js" From 9c9add683613d6d6c37a5814e414a5e2a1ebca02 Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Tue, 5 May 2026 17:13:16 -0500 Subject: [PATCH 06/11] ci: clarify local network startup wait --- .github/workflows/local-network-smoke.yml | 20 ++++++++++++++++---- docker-compose.local.yml | 4 ++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/local-network-smoke.yml b/.github/workflows/local-network-smoke.yml index ddaba89..f18e042 100644 --- a/.github/workflows/local-network-smoke.yml +++ b/.github/workflows/local-network-smoke.yml @@ -80,14 +80,23 @@ jobs: npm ci npx playwright install --with-deps chromium + - name: Build local network image + run: docker compose -f docker-compose.local.yml build --progress=plain local-network + - name: Start local network - run: docker compose -f docker-compose.local.yml up -d --build + run: docker compose -f docker-compose.local.yml up -d --no-build local-network - - name: Wait for local network health + - name: Wait for local network readiness run: | set -euo pipefail - for _ in {1..240}; do + echo "Streaming local-network container logs while waiting for Docker health." + docker compose -f docker-compose.local.yml logs --no-color --timestamps -f local-network & + logs_pid="$!" + trap 'kill "$logs_pid" >/dev/null 2>&1 || true' EXIT + + previous_health="" + for attempt in {1..240}; do cid="$(docker compose -f docker-compose.local.yml ps -q local-network || true)" if [ -z "$cid" ]; then echo "local-network container not found" @@ -99,7 +108,10 @@ jobs: status="$(docker inspect --format='{{.State.Status}}' "$cid")" health="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid")" - echo "container status=${status} health=${health}" + if [ "$health" != "$previous_health" ] || [ $((attempt % 4)) -eq 1 ]; then + echo "container status=${status} health=${health}" + previous_health="$health" + fi if [ "$health" = "healthy" ]; then exit 0 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 46c6bbe..70978ca 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -42,8 +42,8 @@ services: test: ["CMD", "/usr/local/bin/liberdus-local-network/healthcheck.sh"] interval: 15s timeout: 10s - retries: 40 - start_period: 2m + retries: 3 + start_period: 30m volumes: local-network-runtime: From dbf5259b58baf1cc7d19b3bfa4aeaebddbc98292 Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Tue, 5 May 2026 17:31:29 -0500 Subject: [PATCH 07/11] ci: report local network readiness checks --- .github/workflows/local-network-smoke.yml | 26 +++++++++--- scripts/local-network/healthcheck.sh | 50 ++++++++++++++++++++--- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/.github/workflows/local-network-smoke.yml b/.github/workflows/local-network-smoke.yml index f18e042..46076d1 100644 --- a/.github/workflows/local-network-smoke.yml +++ b/.github/workflows/local-network-smoke.yml @@ -90,12 +90,9 @@ jobs: run: | set -euo pipefail - echo "Streaming local-network container logs while waiting for Docker health." - docker compose -f docker-compose.local.yml logs --no-color --timestamps -f local-network & - logs_pid="$!" - trap 'kill "$logs_pid" >/dev/null 2>&1 || true' EXIT - previous_health="" + previous_phase="" + previous_healthcheck="" for attempt in {1..240}; do cid="$(docker compose -f docker-compose.local.yml ps -q local-network || true)" if [ -z "$cid" ]; then @@ -107,11 +104,30 @@ jobs: status="$(docker inspect --format='{{.State.Status}}' "$cid")" health="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid")" + phase="$( + docker compose -f docker-compose.local.yml logs --no-color --tail=200 local-network 2>/dev/null \ + | awk '/\[liberdus-local\]/ { line=$0 } END { print line }' \ + | sed -E 's/^.*\[liberdus-local\] //' + )" + healthcheck="$( + docker inspect "$cid" \ + | jq -r '.[0].State.Health.Log[-1].Output // ""' \ + | tr '\n' ' ' \ + | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//' + )" if [ "$health" != "$previous_health" ] || [ $((attempt % 4)) -eq 1 ]; then echo "container status=${status} health=${health}" previous_health="$health" fi + if [ -n "$phase" ] && [ "$phase" != "$previous_phase" ]; then + echo "startup phase: ${phase}" + previous_phase="$phase" + fi + if [ -n "$healthcheck" ] && [ "$healthcheck" != "$previous_healthcheck" ]; then + echo "${healthcheck}" + previous_healthcheck="$healthcheck" + fi if [ "$health" = "healthy" ]; then exit 0 diff --git a/scripts/local-network/healthcheck.sh b/scripts/local-network/healthcheck.sh index d19ac29..82471c9 100644 --- a/scripts/local-network/healthcheck.sh +++ b/scripts/local-network/healthcheck.sh @@ -4,9 +4,49 @@ set -euo pipefail web_port="${LIBERDUS_WEB_BIND_PORT:-${LIBERDUS_WEB_CLIENT_PORT:-8080}}" proxy_port="${LIBERDUS_PROXY_BIND_PORT:-${LIBERDUS_PROXY_HTTP_PORT:-3030}}" zero_account="0000000000000000000000000000000000000000000000000000000000000000" +web_url="http://127.0.0.1:${web_port}/network.js" +proxy_url="http://127.0.0.1:${proxy_port}/account/${zero_account}" +cycle_url="http://127.0.0.1:4000/cycleinfo/1" -curl -fsS "http://127.0.0.1:${web_port}/network.js" >/dev/null -curl -fsS "http://127.0.0.1:${proxy_port}/account/${zero_account}" \ - | jq -e '.account.type == "NetworkAccount"' >/dev/null -curl -fsS "http://127.0.0.1:4000/cycleinfo/1" \ - | jq -e '((.cycleInfo // []) | length) > 0 and .cycleInfo[0].mode == "processing" and (.cycleInfo[0].networkId | type == "string")' >/dev/null +say() { + echo "healthcheck: $*" +} + +if ! curl -fsS "${web_url}" >/dev/null 2>&1; then + say "waiting for web network.js (${web_url})" + exit 1 +fi + +if ! account_json="$(curl -fsS "${proxy_url}" 2>/dev/null)"; then + say "waiting for proxy zero account (${proxy_url})" + exit 1 +fi + +if ! jq -e '.account.type == "NetworkAccount"' >/dev/null <<<"${account_json}"; then + account_type="$(jq -r '.account.type // "missing"' <<<"${account_json}" 2>/dev/null || echo "unknown")" + say "waiting for proxy NetworkAccount response (account.type=${account_type})" + exit 1 +fi + +if ! cycle_json="$(curl -fsS "${cycle_url}" 2>/dev/null)"; then + say "waiting for archiver cycle (${cycle_url})" + exit 1 +fi + +cycle_summary="$( + jq -r ' + (.cycleInfo // [])[0] as $cycle + | if $cycle then + "mode=\($cycle.mode // "missing") active=\($cycle.active // "missing") desired=\($cycle.desired // "missing") target=\($cycle.target // "missing") networkId=\(if ($cycle.networkId | type) == "string" then "present" else "missing" end)" + else + "cycleInfo=missing" + end + ' <<<"${cycle_json}" 2>/dev/null || echo "cycleInfo=unreadable" +)" + +if ! jq -e '((.cycleInfo // []) | length) > 0 and .cycleInfo[0].mode == "processing" and (.cycleInfo[0].networkId | type == "string")' >/dev/null <<<"${cycle_json}"; then + say "waiting for archiver processing cycle (${cycle_summary})" + exit 1 +fi + +say "healthy: web network.js reachable; proxy zero account is NetworkAccount; archiver ${cycle_summary}" From 74939b8756af442f115817578559ca6f58700830 Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Tue, 5 May 2026 18:13:41 -0500 Subject: [PATCH 08/11] test: add smoke grep tag --- .github/workflows/local-network-smoke.yml | 2 +- README.md | 7 +++++++ docs/local-network.md | 2 +- playwright-tests/package.json | 3 ++- playwright-tests/tests/smoke.e2e.test.js | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/local-network-smoke.yml b/.github/workflows/local-network-smoke.yml index 46076d1..bf157d8 100644 --- a/.github/workflows/local-network-smoke.yml +++ b/.github/workflows/local-network-smoke.yml @@ -153,7 +153,7 @@ jobs: - name: Run Playwright smoke working-directory: playwright-tests - run: npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 --reporter=github,line + run: npx playwright test --project=chromium --grep '@smoke' --workers=1 --retries=0 --reporter=github,line - name: Upload Playwright HTML report if: always() diff --git a/README.md b/README.md index 27df78a..386d32a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,13 @@ This older flow uses the root `liberdus-*.sh` scripts inside the dev container. The Playwright tests default to `https://liberdus.com/dev/`, but the Docker Compose stack can run the smoke test against a local network. This is the CI-focused local-network path. +To run the tagged smoke test against the default dev network, leave `PLAYWRIGHT_BASE_URL` unset and run: + +```bash +cd playwright-tests +npm run test:smoke +``` + See [docs/local-network.md](docs/local-network.md) for the short version of what runs and how to run it locally. ## Manually Setting up a Local Liberdus Network with web-client-v2 diff --git a/docs/local-network.md b/docs/local-network.md index 56cd181..68d7d97 100644 --- a/docs/local-network.md +++ b/docs/local-network.md @@ -43,7 +43,7 @@ Run the smoke test in another shell: cd playwright-tests npm ci $env:PLAYWRIGHT_BASE_URL='http://127.0.0.1:8080/' -npx playwright test tests/smoke.e2e.test.js --project=chromium --grep 'should navigate to Contacts and Wallet views' --workers=1 --retries=0 +npm run test:smoke -- --workers=1 --retries=0 ``` Stop the stack: diff --git a/playwright-tests/package.json b/playwright-tests/package.json index e139725..2969d79 100644 --- a/playwright-tests/package.json +++ b/playwright-tests/package.json @@ -4,7 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "test": "playwright test --project=chromium" + "test": "playwright test --project=chromium", + "test:smoke": "playwright test --project=chromium --grep @smoke" }, "keywords": [], "author": "", diff --git a/playwright-tests/tests/smoke.e2e.test.js b/playwright-tests/tests/smoke.e2e.test.js index d846a15..dd54f3d 100644 --- a/playwright-tests/tests/smoke.e2e.test.js +++ b/playwright-tests/tests/smoke.e2e.test.js @@ -100,7 +100,7 @@ test.describe('Tests requiring recipient user', () => { }); }); -test('should navigate to Contacts and Wallet views', async ({ page }) => { +test('should navigate to Contacts and Wallet views @smoke', async ({ page }) => { await page.click('#switchToContacts'); await expect(page.locator('#contactsScreen.active')).toBeVisible(); await page.click('#switchToWallet'); From 628e93638e800269853a058aa3ac037e7e9d8c76 Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Tue, 5 May 2026 18:17:28 -0500 Subject: [PATCH 09/11] test: tag all smoke tests --- playwright-tests/tests/smoke.e2e.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/playwright-tests/tests/smoke.e2e.test.js b/playwright-tests/tests/smoke.e2e.test.js index dd54f3d..f078c29 100644 --- a/playwright-tests/tests/smoke.e2e.test.js +++ b/playwright-tests/tests/smoke.e2e.test.js @@ -5,7 +5,7 @@ const { createAndSignInUser, generateUsername } = require('../helpers/userHelper let RECIPIENT; -test.describe('Tests requiring recipient user', () => { +test.describe('Tests requiring recipient user @smoke', () => { // Create the recipient user once before all tests test.beforeAll(async ({ browser, browserName }) => { const page = await browser.newPage(); @@ -109,7 +109,7 @@ test('should navigate to Contacts and Wallet views @smoke', async ({ page }) => await expect(page.locator('#chatsScreen.active')).toBeVisible(); }); -test('should sign out successfully', async ({ page }) => { +test('should sign out successfully @smoke', async ({ page }) => { // wait for UI animation await page.waitForTimeout(1000); await page.click('#toggleMenu'); @@ -118,7 +118,7 @@ test('should sign out successfully', async ({ page }) => { await expect(page.locator('#welcomeScreen')).toBeVisible({ timeout: 30_000 }); }); -test('Should set toll', async ({ page }) => { +test('Should set toll @smoke', async ({ page }) => { const toll = 5; await page.click('#toggleSettings'); await page.waitForSelector('#settingsModal', { timeout: 5_000 }); @@ -131,7 +131,7 @@ test('Should set toll', async ({ page }) => { expect(tollText.trim().startsWith(toll.toString())).toBeTruthy(); }); -test('Should update profile', async ({ page, username }) => { +test('Should update profile @smoke', async ({ page, username }) => { const name = "Testername"; const linkedin = "testerlinkedin"; const x = "testerx"; From bba4fd9ddc10b521aefc250fae4d9c3ed6697384 Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Wed, 6 May 2026 09:26:15 -0500 Subject: [PATCH 10/11] test: tag lock unlock smoke test --- playwright-tests/tests/lock.e2e.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright-tests/tests/lock.e2e.test.js b/playwright-tests/tests/lock.e2e.test.js index 8242176..d9ccf7e 100644 --- a/playwright-tests/tests/lock.e2e.test.js +++ b/playwright-tests/tests/lock.e2e.test.js @@ -42,7 +42,7 @@ const test = base.extend({ } }); -test('Lock and Unlock Account', async ({ browser, browserName}) => { +test('Lock and Unlock Account @smoke', async ({ browser, browserName}) => { // 1 create a user const username = generateUsername(browserName); const ctx = await newContext(browser); From 0ce79694df05c80a2a668823c0cd0eeabe7d45c5 Mon Sep 17 00:00:00 2001 From: Chris Freels Date: Wed, 6 May 2026 10:28:14 -0500 Subject: [PATCH 11/11] test: require modern network params --- playwright-tests/helpers/global-setup.js | 35 +++++++----------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/playwright-tests/helpers/global-setup.js b/playwright-tests/helpers/global-setup.js index df566e5..8aa7f9a 100644 --- a/playwright-tests/helpers/global-setup.js +++ b/playwright-tests/helpers/global-setup.js @@ -69,26 +69,17 @@ function fetchText(url, timeoutMs = 15000, maxRedirects = 5) { }); } -function parseNumber(value, fallback = 0) { +function parseNumber(value) { const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : fallback; + return Number.isFinite(parsed) ? parsed : NaN; } -function parseLiberdusAmount(value, fallback = 0) { - if (typeof value === 'number') return Number.isFinite(value) ? value : fallback; - if (typeof value === 'string') return parseNumber(value, fallback); - if (!value || typeof value !== 'object') return fallback; - - if (value.dataType === 'bi' && typeof value.value === 'string') { - try { - return Number(BigInt('0x' + value.value)) / 1e18; - } catch { - return fallback; - } +function requirePositiveNetworkNumber(current, fieldName) { + const value = parseNumber(current[fieldName]); + if (!(value > 0)) { + throw new Error(`Missing or invalid positive network parameter: ${fieldName}`); } - - if ('value' in value) return parseNumber(value.value, fallback); - return fallback; + return value; } async function globalSetup(config) { @@ -129,15 +120,9 @@ async function globalSetup(config) { const account = JSON.parse(acctText); const current = account && account.account && account.account.current ? account.account.current : {}; - const stabilityFactor = current.stabilityFactorStr - ? parseNumber(current.stabilityFactorStr) - : parseLiberdusAmount(current.stabilityScaleMul) / parseLiberdusAmount(current.stabilityScaleDiv, 1); - const feeUsd = current.transactionFeeUsdStr - ? parseNumber(current.transactionFeeUsdStr) - : parseLiberdusAmount(current.transactionFee); - const minTollUsd = current.minTollUsdStr - ? parseNumber(current.minTollUsdStr) - : parseLiberdusAmount(current.defaultToll); + const stabilityFactor = requirePositiveNetworkNumber(current, 'stabilityFactorStr'); + const feeUsd = requirePositiveNetworkNumber(current, 'transactionFeeUsdStr'); + const minTollUsd = requirePositiveNetworkNumber(current, 'minTollUsdStr'); const networkTollTaxPercent = Number(current.tollNetworkTaxPercent || 0); // USD → LIB conversions using: LIB = USD / stabilityFactor