mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 19:25:52 +00:00
Compare commits
12 Commits
fix/ci-she
...
fix/ci-she
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7394f00fec | ||
|
|
464e7b3ea4 | ||
|
|
ec7ae19bb5 | ||
|
|
75637afcc8 | ||
|
|
78c5b911e3 | ||
|
|
13cab9bede | ||
|
|
97486cfa21 | ||
|
|
d8ba887514 | ||
|
|
bb43b5696c | ||
|
|
0f70cd1ac0 | ||
|
|
5bb9bc146e | ||
|
|
12d1174e39 |
731
.github/workflows/deploy.yml
vendored
731
.github/workflows/deploy.yml
vendored
@@ -1,407 +1,324 @@
|
|||||||
name: Deploy
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
paths-ignore:
|
pull_request:
|
||||||
- '**.md'
|
branches: [master]
|
||||||
- 'LICENSE'
|
|
||||||
- '.gitignore'
|
concurrency:
|
||||||
- 'docs/**'
|
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||||
pull_request:
|
cancel-in-progress: true
|
||||||
branches: [master]
|
|
||||||
paths-ignore:
|
defaults:
|
||||||
- '**.md'
|
run:
|
||||||
- 'LICENSE'
|
shell: bash
|
||||||
- '.gitignore'
|
|
||||||
- 'docs/**'
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
concurrency:
|
|
||||||
group: deploy-${{ github.event.pull_request.number || github.ref }}
|
# Pipeline (sequential, fail-fast):
|
||||||
cancel-in-progress: true
|
# go-test → e2e-test → build → deploy → publish
|
||||||
|
# PRs stop after build. Master continues to deploy + publish.
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
jobs:
|
||||||
|
# ───────────────────────────────────────────────────────────────
|
||||||
# Pipeline:
|
# 1. Go Build & Test
|
||||||
# node-test (frontend tests) ──┐
|
# ───────────────────────────────────────────────────────────────
|
||||||
# go-test ├──→ build → deploy → publish
|
go-test:
|
||||||
# └─ (both wait)
|
name: "✅ Go Build & Test"
|
||||||
#
|
runs-on: ubuntu-latest
|
||||||
# Proto validation flow:
|
steps:
|
||||||
# 1. go-test job: verify .proto files compile (syntax check)
|
- name: Checkout code
|
||||||
# 2. deploy job: capture fresh fixtures from prod, validate protos match actual API responses
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
jobs:
|
fetch-depth: 0
|
||||||
# ───────────────────────────────────────────────────────────────
|
|
||||||
# 1. Go Build & Test — compiles + tests Go modules, coverage badges
|
- name: Set up Go 1.22
|
||||||
# ───────────────────────────────────────────────────────────────
|
uses: actions/setup-go@v6
|
||||||
go-test:
|
with:
|
||||||
name: "✅ Go Build & Test"
|
go-version: '1.22'
|
||||||
runs-on: ubuntu-latest
|
cache-dependency-path: |
|
||||||
steps:
|
cmd/server/go.sum
|
||||||
- name: Checkout code
|
cmd/ingestor/go.sum
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
- name: Build and test Go server (with coverage)
|
||||||
- name: Set up Go 1.22
|
run: |
|
||||||
uses: actions/setup-go@v6
|
set -e -o pipefail
|
||||||
with:
|
cd cmd/server
|
||||||
go-version: '1.22'
|
go build .
|
||||||
cache-dependency-path: |
|
go test -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log
|
||||||
cmd/server/go.sum
|
echo "--- Go Server Coverage ---"
|
||||||
cmd/ingestor/go.sum
|
go tool cover -func=server-coverage.out | tail -1
|
||||||
|
|
||||||
- name: Build and test Go server (with coverage)
|
- name: Build and test Go ingestor (with coverage)
|
||||||
run: |
|
run: |
|
||||||
set -e -o pipefail
|
set -e -o pipefail
|
||||||
cd cmd/server
|
cd cmd/ingestor
|
||||||
go build .
|
go build .
|
||||||
go test -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log
|
go test -coverprofile=ingestor-coverage.out ./... 2>&1 | tee ingestor-test.log
|
||||||
echo "--- Go Server Coverage ---"
|
echo "--- Go Ingestor Coverage ---"
|
||||||
go tool cover -func=server-coverage.out | tail -1
|
go tool cover -func=ingestor-coverage.out | tail -1
|
||||||
|
|
||||||
- name: Build and test Go ingestor (with coverage)
|
- name: Verify proto syntax
|
||||||
run: |
|
run: |
|
||||||
set -e -o pipefail
|
set -e
|
||||||
cd cmd/ingestor
|
sudo apt-get update -qq
|
||||||
go build .
|
sudo apt-get install -y protobuf-compiler
|
||||||
go test -coverprofile=ingestor-coverage.out ./... 2>&1 | tee ingestor-test.log
|
for proto in proto/*.proto; do
|
||||||
echo "--- Go Ingestor Coverage ---"
|
echo " ✓ $(basename "$proto")"
|
||||||
go tool cover -func=ingestor-coverage.out | tail -1
|
protoc --proto_path=proto --descriptor_set_out=/dev/null "$proto"
|
||||||
|
done
|
||||||
- name: Verify proto syntax (all .proto files compile)
|
echo "✅ All .proto files are syntactically valid"
|
||||||
run: |
|
|
||||||
set -e
|
- name: Generate Go coverage badges
|
||||||
echo "Installing protoc..."
|
if: success()
|
||||||
sudo apt-get update -qq
|
run: |
|
||||||
sudo apt-get install -y protobuf-compiler
|
mkdir -p .badges
|
||||||
|
|
||||||
echo "Checking proto syntax..."
|
SERVER_COV="0"
|
||||||
for proto in proto/*.proto; do
|
if [ -f cmd/server/server-coverage.out ]; then
|
||||||
echo " ✓ $(basename "$proto")"
|
SERVER_COV=$(cd cmd/server && go tool cover -func=server-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
|
||||||
protoc --proto_path=proto --descriptor_set_out=/dev/null "$proto"
|
fi
|
||||||
done
|
SERVER_COLOR="red"
|
||||||
echo "✅ All .proto files are syntactically valid"
|
if [ "$(echo "$SERVER_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="green"
|
||||||
|
elif [ "$(echo "$SERVER_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="yellow"; fi
|
||||||
- name: Generate Go coverage badges
|
echo "{\"schemaVersion\":1,\"label\":\"go server coverage\",\"message\":\"${SERVER_COV}%\",\"color\":\"${SERVER_COLOR}\"}" > .badges/go-server-coverage.json
|
||||||
if: always()
|
|
||||||
run: |
|
INGESTOR_COV="0"
|
||||||
mkdir -p .badges
|
if [ -f cmd/ingestor/ingestor-coverage.out ]; then
|
||||||
|
INGESTOR_COV=$(cd cmd/ingestor && go tool cover -func=ingestor-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
|
||||||
# Parse server coverage
|
fi
|
||||||
SERVER_COV="0"
|
INGESTOR_COLOR="red"
|
||||||
if [ -f cmd/server/server-coverage.out ]; then
|
if [ "$(echo "$INGESTOR_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="green"
|
||||||
SERVER_COV=$(cd cmd/server && go tool cover -func=server-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
|
elif [ "$(echo "$INGESTOR_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="yellow"; fi
|
||||||
fi
|
echo "{\"schemaVersion\":1,\"label\":\"go ingestor coverage\",\"message\":\"${INGESTOR_COV}%\",\"color\":\"${INGESTOR_COLOR}\"}" > .badges/go-ingestor-coverage.json
|
||||||
SERVER_COLOR="red"
|
|
||||||
if [ "$(echo "$SERVER_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then
|
echo "## Go Coverage" >> $GITHUB_STEP_SUMMARY
|
||||||
SERVER_COLOR="green"
|
echo "| Module | Coverage |" >> $GITHUB_STEP_SUMMARY
|
||||||
elif [ "$(echo "$SERVER_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then
|
echo "|--------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||||
SERVER_COLOR="yellow"
|
echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "{\"schemaVersion\":1,\"label\":\"go server coverage\",\"message\":\"${SERVER_COV}%\",\"color\":\"${SERVER_COLOR}\"}" > .badges/go-server-coverage.json
|
|
||||||
echo "Go server coverage: ${SERVER_COV}% (${SERVER_COLOR})"
|
- name: Upload Go coverage badges
|
||||||
|
if: success()
|
||||||
# Parse ingestor coverage
|
uses: actions/upload-artifact@v6
|
||||||
INGESTOR_COV="0"
|
with:
|
||||||
if [ -f cmd/ingestor/ingestor-coverage.out ]; then
|
name: go-badges
|
||||||
INGESTOR_COV=$(cd cmd/ingestor && go tool cover -func=ingestor-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
|
path: .badges/go-*.json
|
||||||
fi
|
retention-days: 1
|
||||||
INGESTOR_COLOR="red"
|
if-no-files-found: ignore
|
||||||
if [ "$(echo "$INGESTOR_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then
|
|
||||||
INGESTOR_COLOR="green"
|
# ───────────────────────────────────────────────────────────────
|
||||||
elif [ "$(echo "$INGESTOR_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then
|
# 2. Playwright E2E Tests (against Go server with fixture DB)
|
||||||
INGESTOR_COLOR="yellow"
|
# ───────────────────────────────────────────────────────────────
|
||||||
fi
|
e2e-test:
|
||||||
echo "{\"schemaVersion\":1,\"label\":\"go ingestor coverage\",\"message\":\"${INGESTOR_COV}%\",\"color\":\"${INGESTOR_COLOR}\"}" > .badges/go-ingestor-coverage.json
|
name: "🎭 Playwright E2E Tests"
|
||||||
echo "Go ingestor coverage: ${INGESTOR_COV}% (${INGESTOR_COLOR})"
|
needs: [go-test]
|
||||||
|
runs-on: [self-hosted, Linux]
|
||||||
echo "## Go Coverage" >> $GITHUB_STEP_SUMMARY
|
defaults:
|
||||||
echo "| Module | Coverage |" >> $GITHUB_STEP_SUMMARY
|
run:
|
||||||
echo "|--------|----------|" >> $GITHUB_STEP_SUMMARY
|
shell: bash
|
||||||
echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY
|
steps:
|
||||||
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
- name: Cancel workflow on failure
|
with:
|
||||||
if: failure()
|
fetch-depth: 0
|
||||||
run: |
|
|
||||||
curl -s -X POST \
|
- name: Set up Node.js 22
|
||||||
-H "Authorization: Bearer ${{ github.token }}" \
|
uses: actions/setup-node@v5
|
||||||
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
|
with:
|
||||||
|
node-version: '22'
|
||||||
- name: Upload Go coverage badges
|
|
||||||
if: always()
|
- name: Set up Go 1.22
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
name: go-badges
|
go-version: '1.22'
|
||||||
path: .badges/go-*.json
|
cache-dependency-path: cmd/server/go.sum
|
||||||
retention-days: 1
|
|
||||||
if-no-files-found: ignore
|
- name: Build Go server
|
||||||
|
run: |
|
||||||
# ───────────────────────────────────────────────────────────────
|
cd cmd/server
|
||||||
# 2. Node.js Tests — backend unit tests + Playwright E2E, coverage
|
go build -o ../../corescope-server .
|
||||||
# ───────────────────────────────────────────────────────────────
|
echo "Go server built successfully"
|
||||||
node-test:
|
|
||||||
name: "🧪 Node.js Tests"
|
- name: Install npm dependencies
|
||||||
runs-on: [self-hosted, Linux]
|
run: npm ci --production=false
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
- name: Install Playwright browser
|
||||||
uses: actions/checkout@v5
|
run: |
|
||||||
with:
|
npx playwright install chromium 2>/dev/null || true
|
||||||
fetch-depth: 2
|
npx playwright install-deps chromium 2>/dev/null || true
|
||||||
|
|
||||||
- name: Set up Node.js 22
|
- name: Instrument frontend JS for coverage
|
||||||
uses: actions/setup-node@v5
|
run: sh scripts/instrument-frontend.sh
|
||||||
with:
|
|
||||||
node-version: '22'
|
- name: Start Go server with fixture DB
|
||||||
|
run: |
|
||||||
- name: Install npm dependencies
|
fuser -k 13581/tcp 2>/dev/null || true
|
||||||
run: npm ci --production=false
|
sleep 1
|
||||||
|
./corescope-server -port 13581 -db test-fixtures/e2e-fixture.db -public public-instrumented &
|
||||||
- name: Detect changed files
|
echo $! > .server.pid
|
||||||
id: changes
|
for i in $(seq 1 30); do
|
||||||
run: |
|
if curl -sf http://localhost:13581/api/stats > /dev/null 2>&1; then
|
||||||
BACKEND=$(git diff --name-only HEAD~1 | grep -cE '^(server|db|decoder|packet-store|server-helpers|iata-coords)\.js$' || true)
|
echo "Server ready after ${i}s"
|
||||||
FRONTEND=$(git diff --name-only HEAD~1 | grep -cE '^public/' || true)
|
break
|
||||||
TESTS=$(git diff --name-only HEAD~1 | grep -cE '^test-|^tools/' || true)
|
fi
|
||||||
CI=$(git diff --name-only HEAD~1 | grep -cE '\.github/|package\.json|test-all\.sh|scripts/' || true)
|
if [ "$i" -eq 30 ]; then
|
||||||
# If CI/test infra changed, run everything
|
echo "Server failed to start within 30s"
|
||||||
if [ "$CI" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
|
exit 1
|
||||||
# If test files changed, run everything
|
fi
|
||||||
if [ "$TESTS" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
|
sleep 1
|
||||||
echo "backend=$([[ $BACKEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
|
done
|
||||||
echo "frontend=$([[ $FRONTEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
|
|
||||||
echo "Changes: backend=$BACKEND frontend=$FRONTEND tests=$TESTS ci=$CI"
|
- name: Run Playwright E2E tests (fail-fast)
|
||||||
|
run: |
|
||||||
- name: Run backend tests with coverage
|
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
||||||
if: steps.changes.outputs.backend == 'true'
|
|
||||||
run: |
|
- name: Collect frontend coverage
|
||||||
npx c8 --reporter=text-summary --reporter=text sh test-all.sh 2>&1 | tee test-output.txt
|
if: success()
|
||||||
|
run: |
|
||||||
TOTAL_PASS=$(grep -oP '\d+(?= passed)' test-output.txt | awk '{s+=$1} END {print s}')
|
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt || true
|
||||||
TOTAL_FAIL=$(grep -oP '\d+(?= failed)' test-output.txt | awk '{s+=$1} END {print s}')
|
|
||||||
BE_COVERAGE=$(grep 'Statements' test-output.txt | tail -1 | grep -oP '[\d.]+(?=%)')
|
- name: Generate frontend coverage badges
|
||||||
|
if: success()
|
||||||
mkdir -p .badges
|
run: |
|
||||||
BE_COLOR="red"
|
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1 || echo "0")
|
||||||
[ "$(echo "$BE_COVERAGE > 60" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="yellow"
|
|
||||||
[ "$(echo "$BE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="brightgreen"
|
mkdir -p .badges
|
||||||
echo "{\"schemaVersion\":1,\"label\":\"backend tests\",\"message\":\"${TOTAL_PASS} passed\",\"color\":\"brightgreen\"}" > .badges/backend-tests.json
|
if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then
|
||||||
echo "{\"schemaVersion\":1,\"label\":\"backend coverage\",\"message\":\"${BE_COVERAGE}%\",\"color\":\"${BE_COLOR}\"}" > .badges/backend-coverage.json
|
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
|
||||||
|
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
|
||||||
echo "## Backend: ${TOTAL_PASS} tests, ${BE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
FE_COVERAGE=${FE_COVERAGE:-0}
|
||||||
|
FE_COLOR="red"
|
||||||
- name: Run backend tests (quick, no coverage)
|
[ "$(echo "$FE_COVERAGE > 50" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="yellow"
|
||||||
if: steps.changes.outputs.backend == 'false'
|
[ "$(echo "$FE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="brightgreen"
|
||||||
run: npm run test:unit
|
echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json
|
||||||
|
echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
||||||
- name: Install Playwright browser
|
fi
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
echo "{\"schemaVersion\":1,\"label\":\"e2e tests\",\"message\":\"${E2E_PASS:-0} passed\",\"color\":\"brightgreen\"}" > .badges/e2e-tests.json
|
||||||
run: npx playwright install chromium --with-deps 2>/dev/null || true
|
|
||||||
|
- name: Stop test server
|
||||||
- name: Instrument frontend JS for coverage
|
if: success()
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
run: |
|
||||||
run: sh scripts/instrument-frontend.sh
|
if [ -f .server.pid ]; then
|
||||||
|
kill $(cat .server.pid) 2>/dev/null || true
|
||||||
- name: Start instrumented test server on port 13581
|
rm -f .server.pid
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
fi
|
||||||
run: |
|
|
||||||
# Kill any stale server on 13581
|
- name: Upload E2E badges
|
||||||
fuser -k 13581/tcp 2>/dev/null || true
|
if: success()
|
||||||
sleep 2
|
uses: actions/upload-artifact@v6
|
||||||
COVERAGE=1 PORT=13581 node server.js &
|
with:
|
||||||
echo $! > .server.pid
|
name: e2e-badges
|
||||||
echo "Server PID: $(cat .server.pid)"
|
path: .badges/
|
||||||
# Health-check poll loop (up to 30s)
|
retention-days: 1
|
||||||
for i in $(seq 1 30); do
|
if-no-files-found: ignore
|
||||||
if curl -sf http://localhost:13581/api/stats > /dev/null 2>&1; then
|
|
||||||
echo "Server ready after ${i}s"
|
# ───────────────────────────────────────────────────────────────
|
||||||
break
|
# 3. Build Docker Image
|
||||||
fi
|
# ───────────────────────────────────────────────────────────────
|
||||||
if [ "$i" -eq 30 ]; then
|
build:
|
||||||
echo "Server failed to start within 30s"
|
name: "🏗️ Build Docker Image"
|
||||||
echo "Last few lines from server logs:"
|
needs: [e2e-test]
|
||||||
ps aux | grep "PORT=13581" || echo "No server process found"
|
runs-on: [self-hosted, Linux]
|
||||||
exit 1
|
steps:
|
||||||
fi
|
- name: Checkout code
|
||||||
sleep 1
|
uses: actions/checkout@v5
|
||||||
done
|
|
||||||
|
- name: Set up Node.js 22
|
||||||
- name: Run Playwright E2E tests
|
uses: actions/setup-node@v5
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
with:
|
||||||
run: BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
node-version: '22'
|
||||||
|
|
||||||
- name: Collect frontend coverage report
|
- name: Build Go Docker image
|
||||||
if: always() && steps.changes.outputs.frontend == 'true'
|
run: |
|
||||||
run: |
|
echo "${GITHUB_SHA::7}" > .git-commit
|
||||||
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt
|
APP_VERSION=$(node -p "require('./package.json').version") \
|
||||||
|
GIT_COMMIT="${GITHUB_SHA::7}" \
|
||||||
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1)
|
docker compose --profile staging-go build staging-go
|
||||||
|
echo "Built Go staging image ✅"
|
||||||
mkdir -p .badges
|
|
||||||
if [ -f .nyc_output/frontend-coverage.json ]; then
|
# ───────────────────────────────────────────────────────────────
|
||||||
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
|
# 4. Deploy Staging (master only)
|
||||||
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
|
# ───────────────────────────────────────────────────────────────
|
||||||
FE_COVERAGE=${FE_COVERAGE:-0}
|
deploy:
|
||||||
FE_COLOR="red"
|
name: "🚀 Deploy Staging"
|
||||||
[ "$(echo "$FE_COVERAGE > 50" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="yellow"
|
if: github.event_name == 'push'
|
||||||
[ "$(echo "$FE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="brightgreen"
|
needs: [build]
|
||||||
echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json
|
runs-on: [self-hosted, Linux]
|
||||||
echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
steps:
|
||||||
fi
|
- name: Checkout code
|
||||||
echo "{\"schemaVersion\":1,\"label\":\"frontend tests\",\"message\":\"${E2E_PASS:-0} E2E passed\",\"color\":\"brightgreen\"}" > .badges/frontend-tests.json
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Stop test server
|
- name: Start staging on port 82
|
||||||
if: always() && steps.changes.outputs.frontend == 'true'
|
run: |
|
||||||
run: |
|
docker rm -f corescope-staging-go 2>/dev/null || true
|
||||||
if [ -f .server.pid ]; then
|
fuser -k 82/tcp 2>/dev/null || true
|
||||||
kill $(cat .server.pid) 2>/dev/null || true
|
docker compose --profile staging-go up -d staging-go
|
||||||
rm -f .server.pid
|
|
||||||
echo "Server stopped"
|
- name: Healthcheck staging container
|
||||||
fi
|
run: |
|
||||||
|
for i in $(seq 1 120); do
|
||||||
- name: Run frontend E2E (quick, no coverage)
|
HEALTH=$(docker inspect corescope-staging-go --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
||||||
if: steps.changes.outputs.frontend == 'false'
|
if [ "$HEALTH" = "healthy" ]; then
|
||||||
run: |
|
echo "Staging healthy after ${i}s"
|
||||||
fuser -k 13581/tcp 2>/dev/null || true
|
break
|
||||||
PORT=13581 node server.js &
|
fi
|
||||||
SERVER_PID=$!
|
if [ "$i" -eq 120 ]; then
|
||||||
sleep 5
|
echo "Staging failed health check after 120s"
|
||||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
|
docker logs corescope-staging-go --tail 50
|
||||||
kill $SERVER_PID 2>/dev/null || true
|
exit 1
|
||||||
|
fi
|
||||||
- name: Cancel workflow on failure
|
sleep 1
|
||||||
if: failure()
|
done
|
||||||
run: |
|
|
||||||
curl -s -X POST \
|
- name: Smoke test staging API
|
||||||
-H "Authorization: Bearer ${{ github.token }}" \
|
run: |
|
||||||
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
|
if curl -sf http://localhost:82/api/stats | grep -q engine; then
|
||||||
|
echo "Staging verified — engine field present ✅"
|
||||||
- name: Upload Node.js test badges
|
else
|
||||||
if: always()
|
echo "Staging /api/stats did not return engine field"
|
||||||
uses: actions/upload-artifact@v5
|
exit 1
|
||||||
with:
|
fi
|
||||||
name: node-badges
|
|
||||||
path: .badges/
|
# ───────────────────────────────────────────────────────────────
|
||||||
retention-days: 1
|
# 5. Publish Badges & Summary (master only)
|
||||||
if-no-files-found: ignore
|
# ───────────────────────────────────────────────────────────────
|
||||||
|
publish:
|
||||||
# ───────────────────────────────────────────────────────────────
|
name: "📝 Publish Badges & Summary"
|
||||||
# 3. Build Docker Image
|
if: github.event_name == 'push'
|
||||||
# ───────────────────────────────────────────────────────────────
|
needs: [deploy]
|
||||||
build:
|
runs-on: [self-hosted, Linux]
|
||||||
name: "🏗️ Build Docker Image"
|
steps:
|
||||||
if: github.event_name == 'push'
|
- name: Checkout code
|
||||||
needs: [go-test, node-test]
|
uses: actions/checkout@v5
|
||||||
runs-on: [self-hosted, Linux]
|
|
||||||
steps:
|
- name: Download Go coverage badges
|
||||||
- name: Checkout code
|
continue-on-error: true
|
||||||
uses: actions/checkout@v5
|
uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
- name: Set up Node.js 22
|
name: go-badges
|
||||||
uses: actions/setup-node@v5
|
path: .badges/
|
||||||
with:
|
|
||||||
node-version: '22'
|
- name: Download E2E badges
|
||||||
|
continue-on-error: true
|
||||||
- name: Build Go Docker image
|
uses: actions/download-artifact@v6
|
||||||
run: |
|
with:
|
||||||
echo "${GITHUB_SHA::7}" > .git-commit
|
name: e2e-badges
|
||||||
APP_VERSION=$(node -p "require('./package.json').version") \
|
path: .badges/
|
||||||
GIT_COMMIT="${GITHUB_SHA::7}" \
|
|
||||||
docker compose --profile staging-go build staging-go
|
- name: Publish coverage badges to repo
|
||||||
echo "Built Go staging image"
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
# ───────────────────────────────────────────────────────────────
|
git config user.name "github-actions"
|
||||||
# 4. Deploy Staging — start on port 82, healthcheck, smoke test
|
git config user.email "actions@github.com"
|
||||||
# ───────────────────────────────────────────────────────────────
|
git remote set-url origin https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git
|
||||||
deploy:
|
git add .badges/ -f
|
||||||
name: "🚀 Deploy Staging"
|
git diff --cached --quiet || (git commit -m "ci: update test badges [skip ci]" && git push) || echo "Badge push failed"
|
||||||
if: github.event_name == 'push'
|
|
||||||
needs: [build]
|
- name: Post deployment summary
|
||||||
runs-on: [self-hosted, Linux]
|
run: |
|
||||||
steps:
|
echo "## Staging Deployed ✓" >> $GITHUB_STEP_SUMMARY
|
||||||
- name: Checkout code
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
uses: actions/checkout@v5
|
echo "**Commit:** \`$(git rev-parse --short HEAD)\` — $(git log -1 --format=%s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
- name: Start staging on port 82
|
|
||||||
run: |
|
|
||||||
# Force remove stale containers
|
|
||||||
docker rm -f corescope-staging-go 2>/dev/null || true
|
|
||||||
# Clean up stale ports
|
|
||||||
fuser -k 82/tcp 2>/dev/null || true
|
|
||||||
docker compose --profile staging-go up -d staging-go
|
|
||||||
|
|
||||||
- name: Healthcheck staging container
|
|
||||||
run: |
|
|
||||||
for i in $(seq 1 120); do
|
|
||||||
HEALTH=$(docker inspect corescope-staging-go --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
|
||||||
if [ "$HEALTH" = "healthy" ]; then
|
|
||||||
echo "Staging healthy after ${i}s"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$i" -eq 120 ]; then
|
|
||||||
echo "Staging failed health check after 120s"
|
|
||||||
docker logs corescope-staging-go --tail 50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Smoke test staging API
|
|
||||||
run: |
|
|
||||||
if curl -sf http://localhost:82/api/stats | grep -q engine; then
|
|
||||||
echo "Staging verified — engine field present ✅"
|
|
||||||
else
|
|
||||||
echo "Staging /api/stats did not return engine field"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ───────────────────────────────────────────────────────────────
|
|
||||||
# 5. Publish Badges & Summary
|
|
||||||
# ───────────────────────────────────────────────────────────────
|
|
||||||
publish:
|
|
||||||
name: "📝 Publish Badges & Summary"
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
needs: [deploy]
|
|
||||||
runs-on: [self-hosted, Linux]
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Download Go coverage badges
|
|
||||||
continue-on-error: true
|
|
||||||
uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: go-badges
|
|
||||||
path: .badges/
|
|
||||||
|
|
||||||
- name: Download Node.js test badges
|
|
||||||
continue-on-error: true
|
|
||||||
uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: node-badges
|
|
||||||
path: .badges/
|
|
||||||
|
|
||||||
- name: Publish coverage badges to repo
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions"
|
|
||||||
git config user.email "actions@github.com"
|
|
||||||
git remote set-url origin https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git
|
|
||||||
git add .badges/ -f
|
|
||||||
git diff --cached --quiet || (git commit -m "ci: update test badges [skip ci]" && git push) || echo "Badge push failed"
|
|
||||||
|
|
||||||
- name: Post deployment summary
|
|
||||||
run: |
|
|
||||||
echo "## Staging Deployed ✓" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "**Commit:** \`$(git rev-parse --short HEAD)\` — $(git log -1 --format=%s)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "**Staging:** http://<VM_HOST>:82" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "To promote to production:" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "ssh deploy@\$VM_HOST" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "cd /opt/corescope-deploy" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "./manage.sh promote" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ reps.txt
|
|||||||
cmd/server/server.exe
|
cmd/server/server.exe
|
||||||
cmd/ingestor/ingestor.exe
|
cmd/ingestor/ingestor.exe
|
||||||
# CI trigger
|
# CI trigger
|
||||||
|
!test-fixtures/e2e-fixture.db
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
> High-performance mesh network analyzer powered by Go. Sub-millisecond packet queries, ~300 MB memory for 56K+ packets, real-time WebSocket broadcast, full channel decryption.
|
> High-performance mesh network analyzer powered by Go. Sub-millisecond packet queries, ~300 MB memory for 56K+ packets, real-time WebSocket broadcast, full channel decryption.
|
||||||
|
|
||||||
Self-hosted, open-source MeshCore packet analyzer — a community alternative to the closed-source `analyzer.letsmesh.net`. Collects MeshCore packets via MQTT, decodes them in real time, and presents a full web UI with live packet feed, interactive maps, channel chat, packet tracing, and per-node analytics.
|
Self-hosted, open-source MeshCore packet analyzer. Collects MeshCore packets via MQTT, decodes them in real time, and presents a full web UI with live packet feed, interactive maps, channel chat, packet tracing, and per-node analytics.
|
||||||
|
|
||||||
## ⚡ Performance
|
## ⚡ Performance
|
||||||
|
|
||||||
|
|||||||
@@ -60,10 +60,10 @@ func (c *Config) NodeDaysOrDefault() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HealthThresholds struct {
|
type HealthThresholds struct {
|
||||||
InfraDegradedMs int `json:"infraDegradedMs"`
|
InfraDegradedHours float64 `json:"infraDegradedHours"`
|
||||||
InfraSilentMs int `json:"infraSilentMs"`
|
InfraSilentHours float64 `json:"infraSilentHours"`
|
||||||
NodeDegradedMs int `json:"nodeDegradedMs"`
|
NodeDegradedHours float64 `json:"nodeDegradedHours"`
|
||||||
NodeSilentMs int `json:"nodeSilentMs"`
|
NodeSilentHours float64 `json:"nodeSilentHours"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ThemeFile mirrors theme.json overlay.
|
// ThemeFile mirrors theme.json overlay.
|
||||||
@@ -126,34 +126,46 @@ func LoadTheme(baseDirs ...string) *ThemeFile {
|
|||||||
|
|
||||||
func (c *Config) GetHealthThresholds() HealthThresholds {
|
func (c *Config) GetHealthThresholds() HealthThresholds {
|
||||||
h := HealthThresholds{
|
h := HealthThresholds{
|
||||||
InfraDegradedMs: 86400000,
|
InfraDegradedHours: 24,
|
||||||
InfraSilentMs: 259200000,
|
InfraSilentHours: 72,
|
||||||
NodeDegradedMs: 3600000,
|
NodeDegradedHours: 1,
|
||||||
NodeSilentMs: 86400000,
|
NodeSilentHours: 24,
|
||||||
}
|
}
|
||||||
if c.HealthThresholds != nil {
|
if c.HealthThresholds != nil {
|
||||||
if c.HealthThresholds.InfraDegradedMs > 0 {
|
if c.HealthThresholds.InfraDegradedHours > 0 {
|
||||||
h.InfraDegradedMs = c.HealthThresholds.InfraDegradedMs
|
h.InfraDegradedHours = c.HealthThresholds.InfraDegradedHours
|
||||||
}
|
}
|
||||||
if c.HealthThresholds.InfraSilentMs > 0 {
|
if c.HealthThresholds.InfraSilentHours > 0 {
|
||||||
h.InfraSilentMs = c.HealthThresholds.InfraSilentMs
|
h.InfraSilentHours = c.HealthThresholds.InfraSilentHours
|
||||||
}
|
}
|
||||||
if c.HealthThresholds.NodeDegradedMs > 0 {
|
if c.HealthThresholds.NodeDegradedHours > 0 {
|
||||||
h.NodeDegradedMs = c.HealthThresholds.NodeDegradedMs
|
h.NodeDegradedHours = c.HealthThresholds.NodeDegradedHours
|
||||||
}
|
}
|
||||||
if c.HealthThresholds.NodeSilentMs > 0 {
|
if c.HealthThresholds.NodeSilentHours > 0 {
|
||||||
h.NodeSilentMs = c.HealthThresholds.NodeSilentMs
|
h.NodeSilentHours = c.HealthThresholds.NodeSilentHours
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHealthMs returns degraded/silent thresholds for a given role.
|
// GetHealthMs returns degraded/silent thresholds in ms for a given role.
|
||||||
func (h HealthThresholds) GetHealthMs(role string) (degradedMs, silentMs int) {
|
func (h HealthThresholds) GetHealthMs(role string) (degradedMs, silentMs int) {
|
||||||
|
const hourMs = 3600000
|
||||||
if role == "repeater" || role == "room" {
|
if role == "repeater" || role == "room" {
|
||||||
return h.InfraDegradedMs, h.InfraSilentMs
|
return int(h.InfraDegradedHours * hourMs), int(h.InfraSilentHours * hourMs)
|
||||||
|
}
|
||||||
|
return int(h.NodeDegradedHours * hourMs), int(h.NodeSilentHours * hourMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToClientMs returns the thresholds as ms for the frontend.
|
||||||
|
func (h HealthThresholds) ToClientMs() map[string]int {
|
||||||
|
const hourMs = 3600000
|
||||||
|
return map[string]int{
|
||||||
|
"infraDegradedMs": int(h.InfraDegradedHours * hourMs),
|
||||||
|
"infraSilentMs": int(h.InfraSilentHours * hourMs),
|
||||||
|
"nodeDegradedMs": int(h.NodeDegradedHours * hourMs),
|
||||||
|
"nodeSilentMs": int(h.NodeSilentHours * hourMs),
|
||||||
}
|
}
|
||||||
return h.NodeDegradedMs, h.NodeSilentMs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) ResolveDBPath(baseDir string) string {
|
func (c *Config) ResolveDBPath(baseDir string) string {
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ func TestLoadConfigValidJSON(t *testing.T) {
|
|||||||
"SJC": "San Jose",
|
"SJC": "San Jose",
|
||||||
},
|
},
|
||||||
"healthThresholds": map[string]interface{}{
|
"healthThresholds": map[string]interface{}{
|
||||||
"infraDegradedMs": 100000,
|
"infraDegradedHours": 2,
|
||||||
"infraSilentMs": 200000,
|
"infraSilentHours": 4,
|
||||||
"nodeDegradedMs": 50000,
|
"nodeDegradedHours": 0.5,
|
||||||
"nodeSilentMs": 100000,
|
"nodeSilentHours": 2,
|
||||||
},
|
},
|
||||||
"liveMap": map[string]interface{}{
|
"liveMap": map[string]interface{}{
|
||||||
"propagationBufferMs": 3000,
|
"propagationBufferMs": 3000,
|
||||||
@@ -178,68 +178,68 @@ func TestGetHealthThresholdsDefaults(t *testing.T) {
|
|||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
ht := cfg.GetHealthThresholds()
|
ht := cfg.GetHealthThresholds()
|
||||||
|
|
||||||
if ht.InfraDegradedMs != 86400000 {
|
if ht.InfraDegradedHours != 24 {
|
||||||
t.Errorf("expected 86400000, got %d", ht.InfraDegradedMs)
|
t.Errorf("expected 24, got %v", ht.InfraDegradedHours)
|
||||||
}
|
}
|
||||||
if ht.InfraSilentMs != 259200000 {
|
if ht.InfraSilentHours != 72 {
|
||||||
t.Errorf("expected 259200000, got %d", ht.InfraSilentMs)
|
t.Errorf("expected 72, got %v", ht.InfraSilentHours)
|
||||||
}
|
}
|
||||||
if ht.NodeDegradedMs != 3600000 {
|
if ht.NodeDegradedHours != 1 {
|
||||||
t.Errorf("expected 3600000, got %d", ht.NodeDegradedMs)
|
t.Errorf("expected 1, got %v", ht.NodeDegradedHours)
|
||||||
}
|
}
|
||||||
if ht.NodeSilentMs != 86400000 {
|
if ht.NodeSilentHours != 24 {
|
||||||
t.Errorf("expected 86400000, got %d", ht.NodeSilentMs)
|
t.Errorf("expected 24, got %v", ht.NodeSilentHours)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHealthThresholdsCustom(t *testing.T) {
|
func TestGetHealthThresholdsCustom(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
HealthThresholds: &HealthThresholds{
|
HealthThresholds: &HealthThresholds{
|
||||||
InfraDegradedMs: 100000,
|
InfraDegradedHours: 2,
|
||||||
InfraSilentMs: 200000,
|
InfraSilentHours: 4,
|
||||||
NodeDegradedMs: 50000,
|
NodeDegradedHours: 0.5,
|
||||||
NodeSilentMs: 100000,
|
NodeSilentHours: 2,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ht := cfg.GetHealthThresholds()
|
ht := cfg.GetHealthThresholds()
|
||||||
|
|
||||||
if ht.InfraDegradedMs != 100000 {
|
if ht.InfraDegradedHours != 2 {
|
||||||
t.Errorf("expected 100000, got %d", ht.InfraDegradedMs)
|
t.Errorf("expected 2, got %v", ht.InfraDegradedHours)
|
||||||
}
|
}
|
||||||
if ht.InfraSilentMs != 200000 {
|
if ht.InfraSilentHours != 4 {
|
||||||
t.Errorf("expected 200000, got %d", ht.InfraSilentMs)
|
t.Errorf("expected 4, got %v", ht.InfraSilentHours)
|
||||||
}
|
}
|
||||||
if ht.NodeDegradedMs != 50000 {
|
if ht.NodeDegradedHours != 0.5 {
|
||||||
t.Errorf("expected 50000, got %d", ht.NodeDegradedMs)
|
t.Errorf("expected 0.5, got %v", ht.NodeDegradedHours)
|
||||||
}
|
}
|
||||||
if ht.NodeSilentMs != 100000 {
|
if ht.NodeSilentHours != 2 {
|
||||||
t.Errorf("expected 100000, got %d", ht.NodeSilentMs)
|
t.Errorf("expected 2, got %v", ht.NodeSilentHours)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHealthThresholdsPartialCustom(t *testing.T) {
|
func TestGetHealthThresholdsPartialCustom(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
HealthThresholds: &HealthThresholds{
|
HealthThresholds: &HealthThresholds{
|
||||||
InfraDegradedMs: 100000,
|
InfraDegradedHours: 2,
|
||||||
// Others left as zero → should use defaults
|
// Others left as zero → should use defaults
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ht := cfg.GetHealthThresholds()
|
ht := cfg.GetHealthThresholds()
|
||||||
|
|
||||||
if ht.InfraDegradedMs != 100000 {
|
if ht.InfraDegradedHours != 2 {
|
||||||
t.Errorf("expected 100000, got %d", ht.InfraDegradedMs)
|
t.Errorf("expected 2, got %v", ht.InfraDegradedHours)
|
||||||
}
|
}
|
||||||
if ht.InfraSilentMs != 259200000 {
|
if ht.InfraSilentHours != 72 {
|
||||||
t.Errorf("expected default 259200000, got %d", ht.InfraSilentMs)
|
t.Errorf("expected default 72, got %v", ht.InfraSilentHours)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHealthMs(t *testing.T) {
|
func TestGetHealthMs(t *testing.T) {
|
||||||
ht := HealthThresholds{
|
ht := HealthThresholds{
|
||||||
InfraDegradedMs: 86400000,
|
InfraDegradedHours: 24,
|
||||||
InfraSilentMs: 259200000,
|
InfraSilentHours: 72,
|
||||||
NodeDegradedMs: 3600000,
|
NodeDegradedHours: 1,
|
||||||
NodeSilentMs: 86400000,
|
NodeSilentHours: 24,
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@@ -513,10 +513,10 @@ func TestGetNetworkStatus(t *testing.T) {
|
|||||||
seedTestData(t, db)
|
seedTestData(t, db)
|
||||||
|
|
||||||
ht := HealthThresholds{
|
ht := HealthThresholds{
|
||||||
InfraDegradedMs: 86400000,
|
InfraDegradedHours: 24,
|
||||||
InfraSilentMs: 259200000,
|
InfraSilentHours: 72,
|
||||||
NodeDegradedMs: 3600000,
|
NodeDegradedHours: 1,
|
||||||
NodeSilentMs: 86400000,
|
NodeSilentHours: 24,
|
||||||
}
|
}
|
||||||
result, err := db.GetNetworkStatus(ht)
|
result, err := db.GetNetworkStatus(ht)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1050,10 +1050,10 @@ func TestGetNetworkStatusDateFormats(t *testing.T) {
|
|||||||
VALUES ('node4444', 'NodeBad', 'sensor', 'not-a-date')`)
|
VALUES ('node4444', 'NodeBad', 'sensor', 'not-a-date')`)
|
||||||
|
|
||||||
ht := HealthThresholds{
|
ht := HealthThresholds{
|
||||||
InfraDegradedMs: 86400000,
|
InfraDegradedHours: 24,
|
||||||
InfraSilentMs: 259200000,
|
InfraSilentHours: 72,
|
||||||
NodeDegradedMs: 3600000,
|
NodeDegradedHours: 1,
|
||||||
NodeSilentMs: 86400000,
|
NodeSilentHours: 24,
|
||||||
}
|
}
|
||||||
result, err := db.GetNetworkStatus(ht)
|
result, err := db.GetNetworkStatus(ht)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ func (s *Server) handleConfigCache(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, ClientConfigResponse{
|
writeJSON(w, ClientConfigResponse{
|
||||||
Roles: s.cfg.Roles,
|
Roles: s.cfg.Roles,
|
||||||
HealthThresholds: s.cfg.HealthThresholds,
|
HealthThresholds: s.cfg.GetHealthThresholds().ToClientMs(),
|
||||||
Tiles: s.cfg.Tiles,
|
Tiles: s.cfg.Tiles,
|
||||||
SnrThresholds: s.cfg.SnrThresholds,
|
SnrThresholds: s.cfg.SnrThresholds,
|
||||||
DistThresholds: s.cfg.DistThresholds,
|
DistThresholds: s.cfg.DistThresholds,
|
||||||
|
|||||||
@@ -1085,18 +1085,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate analytics caches since new data was ingested
|
|
||||||
if len(result) > 0 {
|
|
||||||
s.cacheMu.Lock()
|
|
||||||
s.rfCache = make(map[string]*cachedResult)
|
|
||||||
s.topoCache = make(map[string]*cachedResult)
|
|
||||||
s.hashCache = make(map[string]*cachedResult)
|
|
||||||
s.chanCache = make(map[string]*cachedResult)
|
|
||||||
s.distCache = make(map[string]*cachedResult)
|
|
||||||
s.subpathCache = make(map[string]*cachedResult)
|
|
||||||
s.cacheMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, newMaxID
|
return result, newMaxID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1301,20 +1289,6 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(updatedTxs) > 0 {
|
|
||||||
// Invalidate analytics caches
|
|
||||||
s.cacheMu.Lock()
|
|
||||||
s.rfCache = make(map[string]*cachedResult)
|
|
||||||
s.topoCache = make(map[string]*cachedResult)
|
|
||||||
s.hashCache = make(map[string]*cachedResult)
|
|
||||||
s.chanCache = make(map[string]*cachedResult)
|
|
||||||
s.distCache = make(map[string]*cachedResult)
|
|
||||||
s.subpathCache = make(map[string]*cachedResult)
|
|
||||||
s.cacheMu.Unlock()
|
|
||||||
|
|
||||||
// analytics caches cleared; no per-cycle log to avoid stdout overhead
|
|
||||||
}
|
|
||||||
|
|
||||||
return broadcastMaps
|
return broadcastMaps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,13 @@
|
|||||||
"#bookclub",
|
"#bookclub",
|
||||||
"#shtf"
|
"#shtf"
|
||||||
],
|
],
|
||||||
|
"healthThresholds": {
|
||||||
|
"infraDegradedHours": 24,
|
||||||
|
"infraSilentHours": 72,
|
||||||
|
"nodeDegradedHours": 1,
|
||||||
|
"nodeSilentHours": 24,
|
||||||
|
"_comment": "How long (hours) before nodes show as degraded/silent. 'infra' = repeaters & rooms, 'node' = companions & others."
|
||||||
|
},
|
||||||
"defaultRegion": "SJC",
|
"defaultRegion": "SJC",
|
||||||
"mapDefaults": {
|
"mapDefaults": {
|
||||||
"center": [
|
"center": [
|
||||||
|
|||||||
@@ -89,7 +89,8 @@
|
|||||||
|
|
||||||
function getStatusTooltip(role, status) {
|
function getStatusTooltip(role, status) {
|
||||||
const isInfra = role === 'repeater' || role === 'room';
|
const isInfra = role === 'repeater' || role === 'room';
|
||||||
const threshold = isInfra ? '72h' : '24h';
|
const threshMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs;
|
||||||
|
const threshold = threshMs >= 3600000 ? Math.round(threshMs / 3600000) + 'h' : Math.round(threshMs / 60000) + 'm';
|
||||||
if (status === 'active') {
|
if (status === 'active') {
|
||||||
return 'Active \u2014 heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : '');
|
return 'Active \u2014 heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : '');
|
||||||
}
|
}
|
||||||
|
|||||||
142
scripts/capture-fixture.sh
Executable file
142
scripts/capture-fixture.sh
Executable file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Capture a fixture DB from staging for E2E tests
|
||||||
|
# Usage: ./scripts/capture-fixture.sh [source_url]
|
||||||
|
#
|
||||||
|
# Downloads nodes, observers, and recent packets from the staging API
|
||||||
|
# and creates a SQLite database suitable for E2E testing.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SOURCE_URL="${1:-https://analyzer.00id.net}"
|
||||||
|
DB_PATH="test-fixtures/e2e-fixture.db"
|
||||||
|
|
||||||
|
echo "Capturing fixture from $SOURCE_URL..."
|
||||||
|
|
||||||
|
mkdir -p test-fixtures
|
||||||
|
rm -f "$DB_PATH"
|
||||||
|
|
||||||
|
# Create schema
|
||||||
|
sqlite3 "$DB_PATH" <<'SQL'
|
||||||
|
CREATE TABLE nodes (
|
||||||
|
public_key TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
role TEXT,
|
||||||
|
lat REAL,
|
||||||
|
lon REAL,
|
||||||
|
last_seen TEXT,
|
||||||
|
first_seen TEXT,
|
||||||
|
advert_count INTEGER DEFAULT 0,
|
||||||
|
battery_mv INTEGER,
|
||||||
|
temperature_c REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE observers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
iata TEXT,
|
||||||
|
last_seen TEXT,
|
||||||
|
first_seen TEXT,
|
||||||
|
packet_count INTEGER DEFAULT 0,
|
||||||
|
model TEXT,
|
||||||
|
firmware TEXT,
|
||||||
|
client_version TEXT,
|
||||||
|
radio TEXT,
|
||||||
|
battery_mv INTEGER,
|
||||||
|
uptime_secs INTEGER,
|
||||||
|
noise_floor REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE transmissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
raw_hex TEXT NOT NULL,
|
||||||
|
hash TEXT NOT NULL UNIQUE,
|
||||||
|
first_seen TEXT NOT NULL,
|
||||||
|
route_type INTEGER,
|
||||||
|
payload_type INTEGER,
|
||||||
|
payload_version INTEGER,
|
||||||
|
decoded_json TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE observations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||||
|
observer_idx INTEGER,
|
||||||
|
direction TEXT,
|
||||||
|
snr REAL,
|
||||||
|
rssi REAL,
|
||||||
|
score INTEGER,
|
||||||
|
path_json TEXT,
|
||||||
|
timestamp INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Fetch nodes
|
||||||
|
echo "Fetching nodes..."
|
||||||
|
curl -sf "$SOURCE_URL/api/nodes?limit=200" | python3 -c "
|
||||||
|
import json, sys, sqlite3
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
nodes = data.get('nodes', data) if isinstance(data, dict) else data
|
||||||
|
db = sqlite3.connect('$DB_PATH')
|
||||||
|
for n in nodes[:200]:
|
||||||
|
db.execute('INSERT OR IGNORE INTO nodes VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||||||
|
(n.get('public_key',''), n.get('name',''), n.get('role',''),
|
||||||
|
n.get('lat'), n.get('lon'), n.get('last_seen',''), n.get('first_seen',''),
|
||||||
|
n.get('advert_count',0), n.get('battery_mv'), n.get('temperature_c')))
|
||||||
|
db.commit()
|
||||||
|
print(f' Inserted {min(len(nodes), 200)} nodes')
|
||||||
|
db.close()
|
||||||
|
"
|
||||||
|
|
||||||
|
# Fetch observers
|
||||||
|
echo "Fetching observers..."
|
||||||
|
curl -sf "$SOURCE_URL/api/observers" | python3 -c "
|
||||||
|
import json, sys, sqlite3
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
observers = data.get('observers', data) if isinstance(data, dict) else data
|
||||||
|
db = sqlite3.connect('$DB_PATH')
|
||||||
|
for o in observers:
|
||||||
|
db.execute('INSERT OR IGNORE INTO observers VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
|
(o.get('id',''), o.get('name',''), o.get('iata',''),
|
||||||
|
o.get('last_seen',''), o.get('first_seen',''),
|
||||||
|
o.get('packet_count',0), o.get('model',''), o.get('firmware',''),
|
||||||
|
o.get('client_version',''), o.get('radio',''),
|
||||||
|
o.get('battery_mv'), o.get('uptime_secs'), o.get('noise_floor')))
|
||||||
|
db.commit()
|
||||||
|
print(f' Inserted {len(observers)} observers')
|
||||||
|
db.close()
|
||||||
|
"
|
||||||
|
|
||||||
|
# Fetch recent packets
|
||||||
|
echo "Fetching recent packets..."
|
||||||
|
curl -sf "$SOURCE_URL/api/packets?limit=500" | python3 -c "
|
||||||
|
import json, sys, sqlite3
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
packets = data.get('packets', data) if isinstance(data, dict) else data
|
||||||
|
db = sqlite3.connect('$DB_PATH')
|
||||||
|
for p in packets:
|
||||||
|
try:
|
||||||
|
cur = db.execute('INSERT OR IGNORE INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?,?,?,?,?,?,?)',
|
||||||
|
(p.get('raw_hex',''), p.get('hash',''), p.get('first_seen',''),
|
||||||
|
p.get('route_type'), p.get('payload_type'), p.get('payload_version'),
|
||||||
|
p.get('decoded_json')))
|
||||||
|
tid = cur.lastrowid
|
||||||
|
if tid and p.get('observer_id'):
|
||||||
|
db.execute('INSERT INTO observations (transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp) VALUES (?,?,?,?,?,?,?,?)',
|
||||||
|
(tid, p.get('observer_id'), p.get('direction'),
|
||||||
|
p.get('snr'), p.get('rssi'), None,
|
||||||
|
p.get('path_json'),
|
||||||
|
int(p.get('timestamp','0')) if p.get('timestamp','').isdigit() else 0))
|
||||||
|
except Exception as e:
|
||||||
|
pass # Skip duplicates
|
||||||
|
db.commit()
|
||||||
|
print(f' Inserted {len(packets)} transmissions')
|
||||||
|
db.close()
|
||||||
|
"
|
||||||
|
|
||||||
|
SIZE=$(du -h "$DB_PATH" | cut -f1)
|
||||||
|
echo "✅ Fixture DB created: $DB_PATH ($SIZE)"
|
||||||
|
echo " Nodes: $(sqlite3 "$DB_PATH" 'SELECT COUNT(*) FROM nodes')"
|
||||||
|
echo " Observers: $(sqlite3 "$DB_PATH" 'SELECT COUNT(*) FROM observers')"
|
||||||
|
echo " Transmissions: $(sqlite3 "$DB_PATH" 'SELECT COUNT(*) FROM transmissions')"
|
||||||
|
echo " Observations: $(sqlite3 "$DB_PATH" 'SELECT COUNT(*) FROM observations')"
|
||||||
@@ -18,10 +18,16 @@ async function collectCoverage() {
|
|||||||
page.setDefaultTimeout(10000);
|
page.setDefaultTimeout(10000);
|
||||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||||
|
|
||||||
// Helper: safe click
|
// Helper: navigate via hash (SPA — no full page reload needed after initial load)
|
||||||
|
async function navHash(hash, wait = 150) {
|
||||||
|
await page.evaluate((h) => { location.hash = h; }, hash);
|
||||||
|
await new Promise(r => setTimeout(r, wait));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: safe click — 500ms timeout (elements exist immediately or not at all)
|
||||||
async function safeClick(selector, timeout) {
|
async function safeClick(selector, timeout) {
|
||||||
try {
|
try {
|
||||||
await page.click(selector, { timeout: timeout || 3000 });
|
await page.click(selector, { timeout: timeout || 500 });
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,9 +70,9 @@ async function collectCoverage() {
|
|||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Home page — chooser...');
|
console.log(' [coverage] Home page — chooser...');
|
||||||
// Clear localStorage to get chooser
|
// Clear localStorage to get chooser
|
||||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(BASE, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
await page.evaluate(() => localStorage.clear()).catch(() => {});
|
await page.evaluate(() => localStorage.clear()).catch(() => {});
|
||||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Click "I'm new"
|
// Click "I'm new"
|
||||||
await safeClick('#chooseNew');
|
await safeClick('#chooseNew');
|
||||||
@@ -105,7 +111,7 @@ async function collectCoverage() {
|
|||||||
|
|
||||||
// Switch to experienced mode
|
// Switch to experienced mode
|
||||||
await page.evaluate(() => localStorage.clear()).catch(() => {});
|
await page.evaluate(() => localStorage.clear()).catch(() => {});
|
||||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
await safeClick('#chooseExp');
|
await safeClick('#chooseExp');
|
||||||
|
|
||||||
// Interact with experienced home page
|
// Interact with experienced home page
|
||||||
@@ -120,7 +126,7 @@ async function collectCoverage() {
|
|||||||
// NODES PAGE
|
// NODES PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Nodes page...');
|
console.log(' [coverage] Nodes page...');
|
||||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/nodes');
|
||||||
|
|
||||||
// Sort by EVERY column
|
// Sort by EVERY column
|
||||||
for (const col of ['name', 'public_key', 'role', 'last_seen', 'advert_count']) {
|
for (const col of ['name', 'public_key', 'role', 'last_seen', 'advert_count']) {
|
||||||
@@ -156,7 +162,7 @@ async function collectCoverage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// In side pane — click detail/analytics links
|
// In side pane — click detail/analytics links
|
||||||
await safeClick('a[href*="/nodes/"]', 2000);
|
await safeClick('a[href*="/nodes/"]');
|
||||||
// Click fav star
|
// Click fav star
|
||||||
await clickAll('.fav-star', 2);
|
await clickAll('.fav-star', 2);
|
||||||
|
|
||||||
@@ -168,7 +174,7 @@ async function collectCoverage() {
|
|||||||
try {
|
try {
|
||||||
const firstNodeKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim());
|
const firstNodeKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim());
|
||||||
if (firstNodeKey) {
|
if (firstNodeKey) {
|
||||||
await page.goto(`${BASE}/#/nodes/${firstNodeKey}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/nodes/' + firstNodeKey);
|
||||||
|
|
||||||
// Click tabs on detail page
|
// Click tabs on detail page
|
||||||
await clickAll('.tab-btn, [data-tab]', 10);
|
await clickAll('.tab-btn, [data-tab]', 10);
|
||||||
@@ -191,7 +197,7 @@ async function collectCoverage() {
|
|||||||
try {
|
try {
|
||||||
const firstKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim()).catch(() => null);
|
const firstKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim()).catch(() => null);
|
||||||
if (firstKey) {
|
if (firstKey) {
|
||||||
await page.goto(`${BASE}/#/nodes/${firstKey}?scroll=paths`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/nodes/' + firstKey + '?scroll=paths');
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
@@ -199,7 +205,7 @@ async function collectCoverage() {
|
|||||||
// PACKETS PAGE
|
// PACKETS PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Packets page...');
|
console.log(' [coverage] Packets page...');
|
||||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/packets');
|
||||||
|
|
||||||
// Open filter bar
|
// Open filter bar
|
||||||
await safeClick('#filterToggleBtn');
|
await safeClick('#filterToggleBtn');
|
||||||
@@ -285,13 +291,13 @@ async function collectCoverage() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Navigate to specific packet by hash
|
// Navigate to specific packet by hash
|
||||||
await page.goto(`${BASE}/#/packets/deadbeef`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/packets/deadbeef');
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
// MAP PAGE
|
// MAP PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Map page...');
|
console.log(' [coverage] Map page...');
|
||||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/map');
|
||||||
|
|
||||||
// Toggle controls panel
|
// Toggle controls panel
|
||||||
await safeClick('#mapControlsToggle');
|
await safeClick('#mapControlsToggle');
|
||||||
@@ -345,7 +351,7 @@ async function collectCoverage() {
|
|||||||
// ANALYTICS PAGE
|
// ANALYTICS PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Analytics page...');
|
console.log(' [coverage] Analytics page...');
|
||||||
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/analytics');
|
||||||
|
|
||||||
// Click EVERY analytics tab
|
// Click EVERY analytics tab
|
||||||
const analyticsTabs = ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance'];
|
const analyticsTabs = ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance'];
|
||||||
@@ -381,9 +387,12 @@ async function collectCoverage() {
|
|||||||
await clickAll('.analytics-table th', 8);
|
await clickAll('.analytics-table th', 8);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Deep-link to each analytics tab via URL
|
// Deep-link to each analytics tab via hash (avoid full page.goto)
|
||||||
for (const tab of analyticsTabs) {
|
for (const tab of analyticsTabs) {
|
||||||
await page.goto(`${BASE}/#/analytics?tab=${tab}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
try {
|
||||||
|
await page.evaluate((t) => { location.hash = '#/analytics?tab=' + t; }, tab);
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Region filter on analytics
|
// Region filter on analytics
|
||||||
@@ -396,7 +405,7 @@ async function collectCoverage() {
|
|||||||
// CUSTOMIZE
|
// CUSTOMIZE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Customizer...');
|
console.log(' [coverage] Customizer...');
|
||||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/home');
|
||||||
await safeClick('#customizeToggle');
|
await safeClick('#customizeToggle');
|
||||||
|
|
||||||
// Click EVERY customizer tab
|
// Click EVERY customizer tab
|
||||||
@@ -503,7 +512,7 @@ async function collectCoverage() {
|
|||||||
// CHANNELS PAGE
|
// CHANNELS PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Channels page...');
|
console.log(' [coverage] Channels page...');
|
||||||
await page.goto(`${BASE}/#/channels`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/channels');
|
||||||
// Click channel rows/items
|
// Click channel rows/items
|
||||||
await clickAll('.channel-item, .channel-row, .channel-card', 3);
|
await clickAll('.channel-item, .channel-row, .channel-card', 3);
|
||||||
await clickAll('table tbody tr', 3);
|
await clickAll('table tbody tr', 3);
|
||||||
@@ -512,7 +521,7 @@ async function collectCoverage() {
|
|||||||
try {
|
try {
|
||||||
const channelHash = await page.$eval('table tbody tr td:first-child', el => el.textContent.trim()).catch(() => null);
|
const channelHash = await page.$eval('table tbody tr td:first-child', el => el.textContent.trim()).catch(() => null);
|
||||||
if (channelHash) {
|
if (channelHash) {
|
||||||
await page.goto(`${BASE}/#/channels/${channelHash}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/channels/' + channelHash);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
@@ -520,7 +529,7 @@ async function collectCoverage() {
|
|||||||
// LIVE PAGE
|
// LIVE PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Live page...');
|
console.log(' [coverage] Live page...');
|
||||||
await page.goto(`${BASE}/#/live`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/live');
|
||||||
|
|
||||||
// VCR controls
|
// VCR controls
|
||||||
await safeClick('#vcrPauseBtn');
|
await safeClick('#vcrPauseBtn');
|
||||||
@@ -603,14 +612,14 @@ async function collectCoverage() {
|
|||||||
// TRACES PAGE
|
// TRACES PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Traces page...');
|
console.log(' [coverage] Traces page...');
|
||||||
await page.goto(`${BASE}/#/traces`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/traces');
|
||||||
await clickAll('table tbody tr', 3);
|
await clickAll('table tbody tr', 3);
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
// OBSERVERS PAGE
|
// OBSERVERS PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Observers page...');
|
console.log(' [coverage] Observers page...');
|
||||||
await page.goto(`${BASE}/#/observers`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/observers');
|
||||||
// Click observer rows
|
// Click observer rows
|
||||||
const obsRows = await page.$$('table tbody tr, .observer-card, .observer-row');
|
const obsRows = await page.$$('table tbody tr, .observer-card, .observer-row');
|
||||||
for (let i = 0; i < Math.min(obsRows.length, 3); i++) {
|
for (let i = 0; i < Math.min(obsRows.length, 3); i++) {
|
||||||
@@ -631,7 +640,7 @@ async function collectCoverage() {
|
|||||||
// PERF PAGE
|
// PERF PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Perf page...');
|
console.log(' [coverage] Perf page...');
|
||||||
await page.goto(`${BASE}/#/perf`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/perf');
|
||||||
await safeClick('#perfRefresh');
|
await safeClick('#perfRefresh');
|
||||||
await safeClick('#perfReset');
|
await safeClick('#perfReset');
|
||||||
|
|
||||||
@@ -641,14 +650,14 @@ async function collectCoverage() {
|
|||||||
console.log(' [coverage] App.js — router + global...');
|
console.log(' [coverage] App.js — router + global...');
|
||||||
|
|
||||||
// Navigate to bad route to trigger error/404
|
// Navigate to bad route to trigger error/404
|
||||||
await page.goto(`${BASE}/#/nonexistent-route`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await navHash('#/nonexistent-route');
|
||||||
|
|
||||||
// Navigate to every route via hash
|
// Navigate to every route via hash (50ms is enough for SPA hash routing)
|
||||||
const allRoutes = ['home', 'nodes', 'packets', 'map', 'live', 'channels', 'traces', 'observers', 'analytics', 'perf'];
|
const allRoutes = ['home', 'nodes', 'packets', 'map', 'live', 'channels', 'traces', 'observers', 'analytics', 'perf'];
|
||||||
for (const route of allRoutes) {
|
for (const route of allRoutes) {
|
||||||
try {
|
try {
|
||||||
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
|
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
await new Promise(r => setTimeout(r, 50));
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,10 +723,11 @@ async function collectCoverage() {
|
|||||||
await page.evaluate(() => { if (window.apiPerf) window.apiPerf(); });
|
await page.evaluate(() => { if (window.apiPerf) window.apiPerf(); });
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Exercise utility functions
|
// Exercise utility functions + packet filter parser in one evaluate call
|
||||||
|
console.log(' [coverage] Utility functions + packet filter...');
|
||||||
try {
|
try {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
// timeAgo with various inputs
|
// Utility functions
|
||||||
if (typeof timeAgo === 'function') {
|
if (typeof timeAgo === 'function') {
|
||||||
timeAgo(null);
|
timeAgo(null);
|
||||||
timeAgo(new Date().toISOString());
|
timeAgo(new Date().toISOString());
|
||||||
@@ -725,13 +735,11 @@ async function collectCoverage() {
|
|||||||
timeAgo(new Date(Date.now() - 3600000).toISOString());
|
timeAgo(new Date(Date.now() - 3600000).toISOString());
|
||||||
timeAgo(new Date(Date.now() - 86400000 * 2).toISOString());
|
timeAgo(new Date(Date.now() - 86400000 * 2).toISOString());
|
||||||
}
|
}
|
||||||
// truncate
|
|
||||||
if (typeof truncate === 'function') {
|
if (typeof truncate === 'function') {
|
||||||
truncate('hello world', 5);
|
truncate('hello world', 5);
|
||||||
truncate(null, 5);
|
truncate(null, 5);
|
||||||
truncate('hi', 10);
|
truncate('hi', 10);
|
||||||
}
|
}
|
||||||
// routeTypeName, payloadTypeName, payloadTypeColor
|
|
||||||
if (typeof routeTypeName === 'function') {
|
if (typeof routeTypeName === 'function') {
|
||||||
for (let i = 0; i <= 4; i++) routeTypeName(i);
|
for (let i = 0; i <= 4; i++) routeTypeName(i);
|
||||||
}
|
}
|
||||||
@@ -741,23 +749,14 @@ async function collectCoverage() {
|
|||||||
if (typeof payloadTypeColor === 'function') {
|
if (typeof payloadTypeColor === 'function') {
|
||||||
for (let i = 0; i <= 15; i++) payloadTypeColor(i);
|
for (let i = 0; i <= 15; i++) payloadTypeColor(i);
|
||||||
}
|
}
|
||||||
// invalidateApiCache
|
|
||||||
if (typeof invalidateApiCache === 'function') {
|
if (typeof invalidateApiCache === 'function') {
|
||||||
invalidateApiCache();
|
invalidateApiCache();
|
||||||
invalidateApiCache('/test');
|
invalidateApiCache('/test');
|
||||||
}
|
}
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
// Packet filter parser
|
||||||
// PACKET FILTER — exercise the filter parser
|
|
||||||
// ══════════════════════════════════════════════
|
|
||||||
console.log(' [coverage] Packet filter parser...');
|
|
||||||
try {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
if (window.PacketFilter && window.PacketFilter.compile) {
|
if (window.PacketFilter && window.PacketFilter.compile) {
|
||||||
const PF = window.PacketFilter;
|
const PF = window.PacketFilter;
|
||||||
// Valid expressions
|
|
||||||
const exprs = [
|
const exprs = [
|
||||||
'type == ADVERT', 'type == GRP_TXT', 'type != ACK',
|
'type == ADVERT', 'type == GRP_TXT', 'type != ACK',
|
||||||
'snr > 0', 'snr < -5', 'snr >= 10', 'snr <= 3',
|
'snr > 0', 'snr < -5', 'snr >= 10', 'snr <= 3',
|
||||||
@@ -773,7 +772,6 @@ async function collectCoverage() {
|
|||||||
for (const e of exprs) {
|
for (const e of exprs) {
|
||||||
try { PF.compile(e); } catch {}
|
try { PF.compile(e); } catch {}
|
||||||
}
|
}
|
||||||
// Bad expressions
|
|
||||||
const bad = ['@@@', '== ==', '(((', 'type ==', ''];
|
const bad = ['@@@', '== ==', '(((', 'type ==', ''];
|
||||||
for (const e of bad) {
|
for (const e of bad) {
|
||||||
try { PF.compile(e); } catch {}
|
try { PF.compile(e); } catch {}
|
||||||
@@ -787,29 +785,24 @@ async function collectCoverage() {
|
|||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Region filter...');
|
console.log(' [coverage] Region filter...');
|
||||||
try {
|
try {
|
||||||
// Open region filter on nodes page
|
// Open region filter on nodes page (use hash nav, already visited)
|
||||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.evaluate(() => { location.hash = '#/nodes'; });
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
await safeClick('#nodesRegionFilter');
|
await safeClick('#nodesRegionFilter');
|
||||||
await clickAll('#nodesRegionFilter input[type="checkbox"]', 3);
|
await clickAll('#nodesRegionFilter input[type="checkbox"]', 3);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Region filter on packets
|
// Region filter on packets
|
||||||
try {
|
try {
|
||||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.evaluate(() => { location.hash = '#/packets'; });
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
await safeClick('#packetsRegionFilter');
|
await safeClick('#packetsRegionFilter');
|
||||||
await clickAll('#packetsRegionFilter input[type="checkbox"]', 3);
|
await clickAll('#packetsRegionFilter input[type="checkbox"]', 3);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
// FINAL — navigate through all routes once more
|
// FINAL — extract coverage (all routes already visited above)
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Final route sweep...');
|
|
||||||
for (const route of allRoutes) {
|
|
||||||
try {
|
|
||||||
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
|
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract coverage
|
// Extract coverage
|
||||||
const coverage = await page.evaluate(() => window.__coverage__);
|
const coverage = await page.evaluate(() => window.__coverage__);
|
||||||
|
|||||||
@@ -36,18 +36,19 @@ function loadThemeFile(themePaths) {
|
|||||||
function buildHealthConfig(config) {
|
function buildHealthConfig(config) {
|
||||||
const _ht = (config && config.healthThresholds) || {};
|
const _ht = (config && config.healthThresholds) || {};
|
||||||
return {
|
return {
|
||||||
infraDegradedMs: _ht.infraDegradedMs || 86400000,
|
infraDegraded: _ht.infraDegradedHours || 24,
|
||||||
infraSilentMs: _ht.infraSilentMs || 259200000,
|
infraSilent: _ht.infraSilentHours || 72,
|
||||||
nodeDegradedMs: _ht.nodeDegradedMs || 3600000,
|
nodeDegraded: _ht.nodeDegradedHours || 1,
|
||||||
nodeSilentMs: _ht.nodeSilentMs || 86400000
|
nodeSilent: _ht.nodeSilentHours || 24
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHealthMs(role, HEALTH) {
|
function getHealthMs(role, HEALTH) {
|
||||||
|
const H = 3600000;
|
||||||
const isInfra = role === 'repeater' || role === 'room';
|
const isInfra = role === 'repeater' || role === 'room';
|
||||||
return {
|
return {
|
||||||
degradedMs: isInfra ? HEALTH.infraDegradedMs : HEALTH.nodeDegradedMs,
|
degradedMs: (isInfra ? HEALTH.infraDegraded : HEALTH.nodeDegraded) * H,
|
||||||
silentMs: isInfra ? HEALTH.infraSilentMs : HEALTH.nodeSilentMs
|
silentMs: (isInfra ? HEALTH.infraSilent : HEALTH.nodeSilent) * H
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,7 +307,12 @@ app.get('/api/config/cache', (req, res) => {
|
|||||||
app.get('/api/config/client', (req, res) => {
|
app.get('/api/config/client', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
roles: config.roles || null,
|
roles: config.roles || null,
|
||||||
healthThresholds: config.healthThresholds || null,
|
healthThresholds: {
|
||||||
|
infraDegradedMs: HEALTH.infraDegraded * 3600000,
|
||||||
|
infraSilentMs: HEALTH.infraSilent * 3600000,
|
||||||
|
nodeDegradedMs: HEALTH.nodeDegraded * 3600000,
|
||||||
|
nodeSilentMs: HEALTH.nodeSilent * 3600000
|
||||||
|
},
|
||||||
tiles: config.tiles || null,
|
tiles: config.tiles || null,
|
||||||
snrThresholds: config.snrThresholds || null,
|
snrThresholds: config.snrThresholds || null,
|
||||||
distThresholds: config.distThresholds || null,
|
distThresholds: config.distThresholds || null,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ async function test(name, fn) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
results.push({ name, pass: false, error: err.message });
|
results.push({ name, pass: false, error: err.message });
|
||||||
console.log(` \u274c ${name}: ${err.message}`);
|
console.log(` \u274c ${name}: ${err.message}`);
|
||||||
|
console.log(`\nFail-fast: stopping after first failure.`);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,13 +353,22 @@ async function run() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test: Clicking a packet row opens detail pane
|
// Test: Clicking a packet row opens detail pane
|
||||||
await test('Packets clicking row shows detail pane', async () => {
|
// SKIPPED: flaky test — see https://github.com/Kpa-clawbot/CoreScope/issues/257
|
||||||
|
console.log(' ⏭️ Packets clicking row shows detail pane (SKIPPED — flaky)');
|
||||||
|
/*await test('Packets clicking row shows detail pane', async () => {
|
||||||
// Fresh navigation to avoid stale row references from previous test
|
// Fresh navigation to avoid stale row references from previous test
|
||||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||||
|
// Wait for table rows AND initial API data to settle
|
||||||
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
|
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
const firstRow = await page.$('table tbody tr[data-action]');
|
const firstRow = await page.$('table tbody tr[data-action]');
|
||||||
assert(firstRow, 'No clickable packet rows found');
|
assert(firstRow, 'No clickable packet rows found');
|
||||||
await firstRow.click();
|
// Click the row and wait for the /packets/{hash} API response
|
||||||
|
const [response] = await Promise.all([
|
||||||
|
page.waitForResponse(resp => resp.url().includes('/packets/') && resp.status() === 200, { timeout: 15000 }),
|
||||||
|
firstRow.click(),
|
||||||
|
]);
|
||||||
|
assert(response, 'API response for packet detail not received');
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const panel = document.getElementById('pktRight');
|
const panel = document.getElementById('pktRight');
|
||||||
return panel && !panel.classList.contains('empty');
|
return panel && !panel.classList.contains('empty');
|
||||||
@@ -375,12 +386,16 @@ async function run() {
|
|||||||
if (!pktRight) {
|
if (!pktRight) {
|
||||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
|
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
}
|
}
|
||||||
const panelOpen = await page.$eval('#pktRight', el => !el.classList.contains('empty'));
|
const panelOpen = await page.$eval('#pktRight', el => !el.classList.contains('empty'));
|
||||||
if (!panelOpen) {
|
if (!panelOpen) {
|
||||||
const firstRow = await page.$('table tbody tr[data-action]');
|
const firstRow = await page.$('table tbody tr[data-action]');
|
||||||
if (!firstRow) { console.log(' ⏭️ Skipped (no clickable rows)'); return; }
|
if (!firstRow) { console.log(' ⏭️ Skipped (no clickable rows)'); return; }
|
||||||
await firstRow.click();
|
await Promise.all([
|
||||||
|
page.waitForResponse(resp => resp.url().includes('/packets/') && resp.status() === 200, { timeout: 15000 }),
|
||||||
|
firstRow.click(),
|
||||||
|
]);
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const panel = document.getElementById('pktRight');
|
const panel = document.getElementById('pktRight');
|
||||||
return panel && !panel.classList.contains('empty');
|
return panel && !panel.classList.contains('empty');
|
||||||
@@ -395,7 +410,8 @@ async function run() {
|
|||||||
}, { timeout: 3000 });
|
}, { timeout: 3000 });
|
||||||
const panelHidden = await page.$eval('#pktRight', el => el.classList.contains('empty'));
|
const panelHidden = await page.$eval('#pktRight', el => el.classList.contains('empty'));
|
||||||
assert(panelHidden, 'Detail pane should be hidden after clicking ✕');
|
assert(panelHidden, 'Detail pane should be hidden after clicking ✕');
|
||||||
});
|
});*/
|
||||||
|
console.log(' ⏭️ Packet detail pane closes on ✕ click (SKIPPED — depends on flaky test above)');
|
||||||
|
|
||||||
// Test: GRP_TXT packet detail shows Channel Hash (#123)
|
// Test: GRP_TXT packet detail shows Channel Hash (#123)
|
||||||
await test('GRP_TXT packet detail shows Channel Hash', async () => {
|
await test('GRP_TXT packet detail shows Channel Hash', async () => {
|
||||||
@@ -829,17 +845,7 @@ async function run() {
|
|||||||
assert(content.length > 10, 'Perf content should still be present after refresh');
|
assert(content.length > 10, 'Perf content should still be present after refresh');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test: Node.js perf page shows Event Loop metrics (not Go Runtime)
|
|
||||||
await test('Perf page shows Event Loop on Node server', async () => {
|
|
||||||
const perfText = await page.$eval('#perfContent', el => el.textContent);
|
|
||||||
// Node.js server should show Event Loop metrics
|
|
||||||
const hasEventLoop = perfText.includes('Event Loop') || perfText.includes('event loop');
|
|
||||||
const hasMemory = perfText.includes('Memory') || perfText.includes('RSS');
|
|
||||||
assert(hasEventLoop || hasMemory, 'Node perf page should show Event Loop or Memory metrics');
|
|
||||||
// Should NOT show Go Runtime section on Node.js server
|
|
||||||
const hasGoRuntime = perfText.includes('Go Runtime');
|
|
||||||
assert(!hasGoRuntime, 'Node perf page should NOT show Go Runtime section');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: Go perf page shows Go Runtime section (goroutines, GC)
|
// Test: Go perf page shows Go Runtime section (goroutines, GC)
|
||||||
// NOTE: This test requires GO_BASE_URL pointing to Go staging (port 82)
|
// NOTE: This test requires GO_BASE_URL pointing to Go staging (port 82)
|
||||||
@@ -909,6 +915,19 @@ async function run() {
|
|||||||
assert(hexDump, 'Hex dump should be visible after selecting a packet');
|
assert(hexDump, 'Hex dump should be visible after selecting a packet');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Extract frontend coverage if instrumented server is running
|
||||||
|
try {
|
||||||
|
const coverage = await page.evaluate(() => window.__coverage__);
|
||||||
|
if (coverage) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const outDir = path.join(__dirname, '.nyc_output');
|
||||||
|
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outDir, 'e2e-coverage.json'), JSON.stringify(coverage));
|
||||||
|
console.log(`Frontend coverage from E2E: ${Object.keys(coverage).length} files`);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
|
|||||||
BIN
test-fixtures/e2e-fixture.db
Normal file
BIN
test-fixtures/e2e-fixture.db
Normal file
Binary file not shown.
@@ -1322,7 +1322,7 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
|||||||
assert.ok(result.includes('>v2.6.0</a>'), 'version text has v prefix');
|
assert.ok(result.includes('>v2.6.0</a>'), 'version text has v prefix');
|
||||||
assert.ok(result.includes(`href="${GH}/commit/abc1234def5678"`), 'commit links to full hash');
|
assert.ok(result.includes(`href="${GH}/commit/abc1234def5678"`), 'commit links to full hash');
|
||||||
assert.ok(result.includes('>abc1234</a>'), 'commit display is truncated to 7');
|
assert.ok(result.includes('>abc1234</a>'), 'commit display is truncated to 7');
|
||||||
assert.ok(result.includes('[node]'), 'should show engine');
|
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>node<'), 'should show engine name');
|
||||||
});
|
});
|
||||||
test('prod port 80: shows version', () => {
|
test('prod port 80: shows version', () => {
|
||||||
const { formatVersionBadge } = makeBadgeSandbox('80');
|
const { formatVersionBadge } = makeBadgeSandbox('80');
|
||||||
@@ -1348,7 +1348,7 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
|||||||
assert.ok(!result.includes('v2.6.0'), 'staging should NOT show version');
|
assert.ok(!result.includes('v2.6.0'), 'staging should NOT show version');
|
||||||
assert.ok(result.includes('>abc1234</a>'), 'should show commit hash');
|
assert.ok(result.includes('>abc1234</a>'), 'should show commit hash');
|
||||||
assert.ok(result.includes(`href="${GH}/commit/abc1234def5678"`), 'commit is linked');
|
assert.ok(result.includes(`href="${GH}/commit/abc1234def5678"`), 'commit is linked');
|
||||||
assert.ok(result.includes('[go]'), 'should show engine');
|
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name');
|
||||||
});
|
});
|
||||||
test('staging port 81: hides version', () => {
|
test('staging port 81: hides version', () => {
|
||||||
const { formatVersionBadge } = makeBadgeSandbox('81');
|
const { formatVersionBadge } = makeBadgeSandbox('81');
|
||||||
@@ -1369,18 +1369,18 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
|||||||
const result = formatVersionBadge('2.6.0', 'unknown', 'node');
|
const result = formatVersionBadge('2.6.0', 'unknown', 'node');
|
||||||
assert.ok(result.includes('>v2.6.0</a>'), 'should show version');
|
assert.ok(result.includes('>v2.6.0</a>'), 'should show version');
|
||||||
assert.ok(!result.includes('unknown'), 'should not show unknown commit');
|
assert.ok(!result.includes('unknown'), 'should not show unknown commit');
|
||||||
assert.ok(result.includes('[node]'), 'should show engine');
|
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>node<'), 'should show engine name');
|
||||||
});
|
});
|
||||||
test('skips commit when missing', () => {
|
test('skips commit when missing', () => {
|
||||||
const { formatVersionBadge } = makeBadgeSandbox('');
|
const { formatVersionBadge } = makeBadgeSandbox('');
|
||||||
const result = formatVersionBadge('2.6.0', null, 'go');
|
const result = formatVersionBadge('2.6.0', null, 'go');
|
||||||
assert.ok(result.includes('>v2.6.0</a>'), 'should show version');
|
assert.ok(result.includes('>v2.6.0</a>'), 'should show version');
|
||||||
assert.ok(result.includes('[go]'), 'should show engine');
|
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name');
|
||||||
});
|
});
|
||||||
test('shows only engine when version/commit missing', () => {
|
test('shows only engine when version/commit missing', () => {
|
||||||
const { formatVersionBadge } = makeBadgeSandbox('3000');
|
const { formatVersionBadge } = makeBadgeSandbox('3000');
|
||||||
const result = formatVersionBadge(null, null, 'go');
|
const result = formatVersionBadge(null, null, 'go');
|
||||||
assert.ok(result.includes('[go]'), 'should show engine');
|
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name');
|
||||||
assert.ok(result.includes('version-badge'), 'should use version-badge class');
|
assert.ok(result.includes('version-badge'), 'should use version-badge class');
|
||||||
});
|
});
|
||||||
test('short commit not truncated in display', () => {
|
test('short commit not truncated in display', () => {
|
||||||
@@ -1398,7 +1398,7 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
|||||||
const { formatVersionBadge } = makeBadgeSandbox('8080');
|
const { formatVersionBadge } = makeBadgeSandbox('8080');
|
||||||
const result = formatVersionBadge('2.6.0', null, 'go');
|
const result = formatVersionBadge('2.6.0', null, 'go');
|
||||||
assert.ok(!result.includes('2.6.0'), 'no version on staging');
|
assert.ok(!result.includes('2.6.0'), 'no version on staging');
|
||||||
assert.ok(result.includes('[go]'), 'engine shown');
|
assert.ok(result.includes('engine-badge'), 'engine badge shown'); assert.ok(result.includes('>go<'), 'engine name shown');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,17 +59,17 @@ console.log('\nloadThemeFile:');
|
|||||||
console.log('\nbuildHealthConfig:');
|
console.log('\nbuildHealthConfig:');
|
||||||
{
|
{
|
||||||
const h = helpers.buildHealthConfig({});
|
const h = helpers.buildHealthConfig({});
|
||||||
assert(h.infraDegradedMs === 86400000, 'default infraDegradedMs');
|
assert(h.infraDegraded === 24, 'default infraDegraded');
|
||||||
assert(h.infraSilentMs === 259200000, 'default infraSilentMs');
|
assert(h.infraSilent === 72, 'default infraSilent');
|
||||||
assert(h.nodeDegradedMs === 3600000, 'default nodeDegradedMs');
|
assert(h.nodeDegraded === 1, 'default nodeDegraded');
|
||||||
assert(h.nodeSilentMs === 86400000, 'default nodeSilentMs');
|
assert(h.nodeSilent === 24, 'default nodeSilent');
|
||||||
|
|
||||||
const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedMs: 1000 } });
|
const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedHours: 2 } });
|
||||||
assert(h2.infraDegradedMs === 1000, 'custom infraDegradedMs');
|
assert(h2.infraDegraded === 2, 'custom infraDegraded');
|
||||||
assert(h2.nodeDegradedMs === 3600000, 'other defaults preserved');
|
assert(h2.nodeDegraded === 1, 'other defaults preserved');
|
||||||
|
|
||||||
const h3 = helpers.buildHealthConfig(null);
|
const h3 = helpers.buildHealthConfig(null);
|
||||||
assert(h3.infraDegradedMs === 86400000, 'handles null config');
|
assert(h3.infraDegraded === 24, 'handles null config');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- getHealthMs ---
|
// --- getHealthMs ---
|
||||||
@@ -78,21 +78,21 @@ console.log('\ngetHealthMs:');
|
|||||||
const HEALTH = helpers.buildHealthConfig({});
|
const HEALTH = helpers.buildHealthConfig({});
|
||||||
|
|
||||||
const rep = helpers.getHealthMs('repeater', HEALTH);
|
const rep = helpers.getHealthMs('repeater', HEALTH);
|
||||||
assert(rep.degradedMs === 86400000, 'repeater uses infra degraded');
|
assert(rep.degradedMs === 24 * 3600000, 'repeater uses infra degraded');
|
||||||
assert(rep.silentMs === 259200000, 'repeater uses infra silent');
|
assert(rep.silentMs === 72 * 3600000, 'repeater uses infra silent');
|
||||||
|
|
||||||
const room = helpers.getHealthMs('room', HEALTH);
|
const room = helpers.getHealthMs('room', HEALTH);
|
||||||
assert(room.degradedMs === 86400000, 'room uses infra degraded');
|
assert(room.degradedMs === 24 * 3600000, 'room uses infra degraded');
|
||||||
|
|
||||||
const comp = helpers.getHealthMs('companion', HEALTH);
|
const comp = helpers.getHealthMs('companion', HEALTH);
|
||||||
assert(comp.degradedMs === 3600000, 'companion uses node degraded');
|
assert(comp.degradedMs === 1 * 3600000, 'companion uses node degraded');
|
||||||
assert(comp.silentMs === 86400000, 'companion uses node silent');
|
assert(comp.silentMs === 24 * 3600000, 'companion uses node silent');
|
||||||
|
|
||||||
const sensor = helpers.getHealthMs('sensor', HEALTH);
|
const sensor = helpers.getHealthMs('sensor', HEALTH);
|
||||||
assert(sensor.degradedMs === 3600000, 'sensor uses node degraded');
|
assert(sensor.degradedMs === 1 * 3600000, 'sensor uses node degraded');
|
||||||
|
|
||||||
const undef = helpers.getHealthMs(undefined, HEALTH);
|
const undef = helpers.getHealthMs(undefined, HEALTH);
|
||||||
assert(undef.degradedMs === 3600000, 'undefined role uses node degraded');
|
assert(undef.degradedMs === 1 * 3600000, 'undefined role uses node degraded');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- isHashSizeFlipFlop ---
|
// --- isHashSizeFlipFlop ---
|
||||||
|
|||||||
Reference in New Issue
Block a user