Compare commits

..

3 Commits

Author SHA1 Message Date
Kpa-clawbot
678c4ce8da ci: force bash shell for all workflow steps
Self-hosted runner defaults to PowerShell on Windows, causing bash
syntax (if/then/fi, curl line continuations) to fail with parse errors.
Setting defaults.run.shell=bash at workflow level fixes all steps.
2026-03-29 15:39:08 +00:00
Kpa-clawbot
752f25382a fix: update TestTransportCodes to match new byte order
The test data still used the old byte order (header, pathByte, transport_codes)
but the decoder now expects (header, transport_codes, pathByte). Reorder the
test hex string accordingly.
2026-03-29 15:37:31 +00:00
efiten
092d0809f0 fix: stop wiping analytics cache on every ingest cycle
The 15s TTL already handles freshness — clearing all cache maps on
every 1-second poll meant entries were never reused, giving 0% server
hit rate and forcing every analytics request back to SQLite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 07:48:38 -07:00
18 changed files with 594 additions and 662 deletions

View File

@@ -1,324 +1,411 @@
name: CI/CD Pipeline name: Deploy
on: on:
push: push:
branches: [master] branches: [master]
pull_request: paths-ignore:
branches: [master] - '**.md'
- 'LICENSE'
concurrency: - '.gitignore'
group: ci-${{ github.event.pull_request.number || github.ref }} - 'docs/**'
cancel-in-progress: true pull_request:
branches: [master]
defaults: paths-ignore:
run: - '**.md'
shell: bash - 'LICENSE'
- '.gitignore'
env: - 'docs/**'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
concurrency:
# Pipeline (sequential, fail-fast): group: deploy-${{ github.event.pull_request.number || github.ref }}
# go-test → e2e-test → build → deploy → publish cancel-in-progress: true
# PRs stop after build. Master continues to deploy + publish.
defaults:
jobs: run:
# ─────────────────────────────────────────────────────────────── shell: bash
# 1. Go Build & Test
# ─────────────────────────────────────────────────────────────── env:
go-test: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
name: "✅ Go Build & Test"
runs-on: ubuntu-latest # Pipeline:
steps: # node-test (frontend tests) ──┐
- name: Checkout code # go-test ├──→ build → deploy → publish
uses: actions/checkout@v5 # └─ (both wait)
with: #
fetch-depth: 0 # Proto validation flow:
# 1. go-test job: verify .proto files compile (syntax check)
- name: Set up Go 1.22 # 2. deploy job: capture fresh fixtures from prod, validate protos match actual API responses
uses: actions/setup-go@v6
with: jobs:
go-version: '1.22' # ───────────────────────────────────────────────────────────────
cache-dependency-path: | # 1. Go Build & Test — compiles + tests Go modules, coverage badges
cmd/server/go.sum # ───────────────────────────────────────────────────────────────
cmd/ingestor/go.sum go-test:
name: "✅ Go Build & Test"
- name: Build and test Go server (with coverage) runs-on: ubuntu-latest
run: | steps:
set -e -o pipefail - name: Checkout code
cd cmd/server uses: actions/checkout@v5
go build .
go test -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log - name: Set up Go 1.22
echo "--- Go Server Coverage ---" uses: actions/setup-go@v6
go tool cover -func=server-coverage.out | tail -1 with:
go-version: '1.22'
- name: Build and test Go ingestor (with coverage) cache-dependency-path: |
run: | cmd/server/go.sum
set -e -o pipefail cmd/ingestor/go.sum
cd cmd/ingestor
go build . - name: Build and test Go server (with coverage)
go test -coverprofile=ingestor-coverage.out ./... 2>&1 | tee ingestor-test.log run: |
echo "--- Go Ingestor Coverage ---" set -e -o pipefail
go tool cover -func=ingestor-coverage.out | tail -1 cd cmd/server
go build .
- name: Verify proto syntax go test -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log
run: | echo "--- Go Server Coverage ---"
set -e go tool cover -func=server-coverage.out | tail -1
sudo apt-get update -qq
sudo apt-get install -y protobuf-compiler - name: Build and test Go ingestor (with coverage)
for proto in proto/*.proto; do run: |
echo " ✓ $(basename "$proto")" set -e -o pipefail
protoc --proto_path=proto --descriptor_set_out=/dev/null "$proto" cd cmd/ingestor
done go build .
echo "✅ All .proto files are syntactically valid" go test -coverprofile=ingestor-coverage.out ./... 2>&1 | tee ingestor-test.log
echo "--- Go Ingestor Coverage ---"
- name: Generate Go coverage badges go tool cover -func=ingestor-coverage.out | tail -1
if: success()
run: | - name: Verify proto syntax (all .proto files compile)
mkdir -p .badges run: |
set -e
SERVER_COV="0" echo "Installing protoc..."
if [ -f cmd/server/server-coverage.out ]; then sudo apt-get update -qq
SERVER_COV=$(cd cmd/server && go tool cover -func=server-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)') sudo apt-get install -y protobuf-compiler
fi
SERVER_COLOR="red" echo "Checking proto syntax..."
if [ "$(echo "$SERVER_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="green" for proto in proto/*.proto; do
elif [ "$(echo "$SERVER_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="yellow"; fi echo " ✓ $(basename "$proto")"
echo "{\"schemaVersion\":1,\"label\":\"go server coverage\",\"message\":\"${SERVER_COV}%\",\"color\":\"${SERVER_COLOR}\"}" > .badges/go-server-coverage.json protoc --proto_path=proto --descriptor_set_out=/dev/null "$proto"
done
INGESTOR_COV="0" echo "✅ All .proto files are syntactically valid"
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.]+(?=%)') - name: Generate Go coverage badges
fi if: always()
INGESTOR_COLOR="red" run: |
if [ "$(echo "$INGESTOR_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="green" mkdir -p .badges
elif [ "$(echo "$INGESTOR_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="yellow"; fi
echo "{\"schemaVersion\":1,\"label\":\"go ingestor coverage\",\"message\":\"${INGESTOR_COV}%\",\"color\":\"${INGESTOR_COLOR}\"}" > .badges/go-ingestor-coverage.json # Parse server coverage
SERVER_COV="0"
echo "## Go Coverage" >> $GITHUB_STEP_SUMMARY if [ -f cmd/server/server-coverage.out ]; then
echo "| Module | Coverage |" >> $GITHUB_STEP_SUMMARY SERVER_COV=$(cd cmd/server && go tool cover -func=server-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
echo "|--------|----------|" >> $GITHUB_STEP_SUMMARY fi
echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY SERVER_COLOR="red"
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY if [ "$(echo "$SERVER_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then
SERVER_COLOR="green"
- name: Upload Go coverage badges elif [ "$(echo "$SERVER_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then
if: success() SERVER_COLOR="yellow"
uses: actions/upload-artifact@v6 fi
with: echo "{\"schemaVersion\":1,\"label\":\"go server coverage\",\"message\":\"${SERVER_COV}%\",\"color\":\"${SERVER_COLOR}\"}" > .badges/go-server-coverage.json
name: go-badges echo "Go server coverage: ${SERVER_COV}% (${SERVER_COLOR})"
path: .badges/go-*.json
retention-days: 1 # Parse ingestor coverage
if-no-files-found: ignore INGESTOR_COV="0"
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.]+(?=%)')
# 2. Playwright E2E Tests (against Go server with fixture DB) fi
# ─────────────────────────────────────────────────────────────── INGESTOR_COLOR="red"
e2e-test: if [ "$(echo "$INGESTOR_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then
name: "🎭 Playwright E2E Tests" INGESTOR_COLOR="green"
needs: [go-test] elif [ "$(echo "$INGESTOR_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then
runs-on: [self-hosted, Linux] INGESTOR_COLOR="yellow"
defaults: fi
run: echo "{\"schemaVersion\":1,\"label\":\"go ingestor coverage\",\"message\":\"${INGESTOR_COV}%\",\"color\":\"${INGESTOR_COLOR}\"}" > .badges/go-ingestor-coverage.json
shell: bash echo "Go ingestor coverage: ${INGESTOR_COV}% (${INGESTOR_COLOR})"
steps:
- name: Checkout code echo "## Go Coverage" >> $GITHUB_STEP_SUMMARY
uses: actions/checkout@v5 echo "| Module | Coverage |" >> $GITHUB_STEP_SUMMARY
with: echo "|--------|----------|" >> $GITHUB_STEP_SUMMARY
fetch-depth: 0 echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY
- name: Set up Node.js 22
uses: actions/setup-node@v5 - name: Cancel workflow on failure
with: if: failure()
node-version: '22' run: |
curl -s -X POST \
- name: Set up Go 1.22 -H "Authorization: Bearer ${{ github.token }}" \
uses: actions/setup-go@v6 "https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
with:
go-version: '1.22' - name: Upload Go coverage badges
cache-dependency-path: cmd/server/go.sum if: always()
uses: actions/upload-artifact@v5
- name: Build Go server with:
run: | name: go-badges
cd cmd/server path: .badges/go-*.json
go build -o ../../corescope-server . retention-days: 1
echo "Go server built successfully" if-no-files-found: ignore
- name: Install npm dependencies # ───────────────────────────────────────────────────────────────
run: npm ci --production=false # 2. Node.js Tests — backend unit tests + Playwright E2E, coverage
# ───────────────────────────────────────────────────────────────
- name: Install Playwright browser node-test:
run: | name: "🧪 Node.js Tests"
npx playwright install chromium 2>/dev/null || true runs-on: [self-hosted, Linux]
npx playwright install-deps chromium 2>/dev/null || true steps:
- name: Checkout code
- name: Instrument frontend JS for coverage uses: actions/checkout@v5
run: sh scripts/instrument-frontend.sh with:
fetch-depth: 2
- name: Start Go server with fixture DB
run: | - name: Set up Node.js 22
fuser -k 13581/tcp 2>/dev/null || true uses: actions/setup-node@v5
sleep 1 with:
./corescope-server -port 13581 -db test-fixtures/e2e-fixture.db -public public-instrumented & node-version: '22'
echo $! > .server.pid
for i in $(seq 1 30); do - name: Install npm dependencies
if curl -sf http://localhost:13581/api/stats > /dev/null 2>&1; then run: npm ci --production=false
echo "Server ready after ${i}s"
break - name: Detect changed files
fi id: changes
if [ "$i" -eq 30 ]; then run: |
echo "Server failed to start within 30s" BACKEND=$(git diff --name-only HEAD~1 | grep -cE '^(server|db|decoder|packet-store|server-helpers|iata-coords)\.js$' || true)
exit 1 FRONTEND=$(git diff --name-only HEAD~1 | grep -cE '^public/' || true)
fi TESTS=$(git diff --name-only HEAD~1 | grep -cE '^test-|^tools/' || true)
sleep 1 CI=$(git diff --name-only HEAD~1 | grep -cE '\.github/|package\.json|test-all\.sh|scripts/' || true)
done # If CI/test infra changed, run everything
if [ "$CI" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
- name: Run Playwright E2E tests (fail-fast) # If test files changed, run everything
run: | if [ "$TESTS" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt echo "backend=$([[ $BACKEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
echo "frontend=$([[ $FRONTEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
- name: Collect frontend coverage echo "Changes: backend=$BACKEND frontend=$FRONTEND tests=$TESTS ci=$CI"
if: success()
run: | - name: Run backend tests with coverage
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt || true if: steps.changes.outputs.backend == 'true'
run: |
- name: Generate frontend coverage badges 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}')
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1 || echo "0") 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.]+(?=%)')
mkdir -p .badges
if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then mkdir -p .badges
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt BE_COLOR="red"
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0") [ "$(echo "$BE_COVERAGE > 60" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="yellow"
FE_COVERAGE=${FE_COVERAGE:-0} [ "$(echo "$BE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="brightgreen"
FE_COLOR="red" echo "{\"schemaVersion\":1,\"label\":\"backend tests\",\"message\":\"${TOTAL_PASS} passed\",\"color\":\"brightgreen\"}" > .badges/backend-tests.json
[ "$(echo "$FE_COVERAGE > 50" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="yellow" echo "{\"schemaVersion\":1,\"label\":\"backend coverage\",\"message\":\"${BE_COVERAGE}%\",\"color\":\"${BE_COLOR}\"}" > .badges/backend-coverage.json
[ "$(echo "$FE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="brightgreen"
echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json echo "## Backend: ${TOTAL_PASS} tests, ${BE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
fi - name: Run backend tests (quick, no coverage)
echo "{\"schemaVersion\":1,\"label\":\"e2e tests\",\"message\":\"${E2E_PASS:-0} passed\",\"color\":\"brightgreen\"}" > .badges/e2e-tests.json if: steps.changes.outputs.backend == 'false'
run: npm run test:unit
- name: Stop test server
if: success() - name: Install Playwright browser
run: | if: steps.changes.outputs.frontend == 'true'
if [ -f .server.pid ]; then run: npx playwright install chromium --with-deps 2>/dev/null || true
kill $(cat .server.pid) 2>/dev/null || true
rm -f .server.pid - name: Instrument frontend JS for coverage
fi if: steps.changes.outputs.frontend == 'true'
run: sh scripts/instrument-frontend.sh
- name: Upload E2E badges
if: success() - name: Start instrumented test server on port 13581
uses: actions/upload-artifact@v6 if: steps.changes.outputs.frontend == 'true'
with: run: |
name: e2e-badges # Kill any stale server on 13581
path: .badges/ fuser -k 13581/tcp 2>/dev/null || true
retention-days: 1 sleep 2
if-no-files-found: ignore COVERAGE=1 PORT=13581 node server.js &
echo $! > .server.pid
# ─────────────────────────────────────────────────────────────── echo "Server PID: $(cat .server.pid)"
# 3. Build Docker Image # Health-check poll loop (up to 30s)
# ─────────────────────────────────────────────────────────────── for i in $(seq 1 30); do
build: if curl -sf http://localhost:13581/api/stats > /dev/null 2>&1; then
name: "🏗️ Build Docker Image" echo "Server ready after ${i}s"
needs: [e2e-test] break
runs-on: [self-hosted, Linux] fi
steps: if [ "$i" -eq 30 ]; then
- name: Checkout code echo "Server failed to start within 30s"
uses: actions/checkout@v5 echo "Last few lines from server logs:"
ps aux | grep "PORT=13581" || echo "No server process found"
- name: Set up Node.js 22 exit 1
uses: actions/setup-node@v5 fi
with: sleep 1
node-version: '22' done
- name: Build Go Docker image - name: Run Playwright E2E tests
run: | if: steps.changes.outputs.frontend == 'true'
echo "${GITHUB_SHA::7}" > .git-commit run: BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
APP_VERSION=$(node -p "require('./package.json').version") \
GIT_COMMIT="${GITHUB_SHA::7}" \ - name: Collect frontend coverage report
docker compose --profile staging-go build staging-go if: always() && steps.changes.outputs.frontend == 'true'
echo "Built Go staging image ✅" run: |
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt
# ───────────────────────────────────────────────────────────────
# 4. Deploy Staging (master only) E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1)
# ───────────────────────────────────────────────────────────────
deploy: mkdir -p .badges
name: "🚀 Deploy Staging" if [ -f .nyc_output/frontend-coverage.json ]; then
if: github.event_name == 'push' npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
needs: [build] FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
runs-on: [self-hosted, Linux] FE_COVERAGE=${FE_COVERAGE:-0}
steps: FE_COLOR="red"
- name: Checkout code [ "$(echo "$FE_COVERAGE > 50" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="yellow"
uses: actions/checkout@v5 [ "$(echo "$FE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="brightgreen"
echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json
- name: Start staging on port 82 echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
run: | fi
docker rm -f corescope-staging-go 2>/dev/null || true echo "{\"schemaVersion\":1,\"label\":\"frontend tests\",\"message\":\"${E2E_PASS:-0} E2E passed\",\"color\":\"brightgreen\"}" > .badges/frontend-tests.json
fuser -k 82/tcp 2>/dev/null || true
docker compose --profile staging-go up -d staging-go - name: Stop test server
if: always() && steps.changes.outputs.frontend == 'true'
- name: Healthcheck staging container run: |
run: | if [ -f .server.pid ]; then
for i in $(seq 1 120); do kill $(cat .server.pid) 2>/dev/null || true
HEALTH=$(docker inspect corescope-staging-go --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting") rm -f .server.pid
if [ "$HEALTH" = "healthy" ]; then echo "Server stopped"
echo "Staging healthy after ${i}s" fi
break
fi - name: Run frontend E2E (quick, no coverage)
if [ "$i" -eq 120 ]; then if: steps.changes.outputs.frontend == 'false'
echo "Staging failed health check after 120s" run: |
docker logs corescope-staging-go --tail 50 fuser -k 13581/tcp 2>/dev/null || true
exit 1 PORT=13581 node server.js &
fi SERVER_PID=$!
sleep 1 sleep 5
done BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
kill $SERVER_PID 2>/dev/null || true
- name: Smoke test staging API
run: | - name: Cancel workflow on failure
if curl -sf http://localhost:82/api/stats | grep -q engine; then if: failure()
echo "Staging verified — engine field present ✅" run: |
else curl -s -X POST \
echo "Staging /api/stats did not return engine field" -H "Authorization: Bearer ${{ github.token }}" \
exit 1 "https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
fi
- name: Upload Node.js test badges
# ─────────────────────────────────────────────────────────────── if: always()
# 5. Publish Badges & Summary (master only) uses: actions/upload-artifact@v5
# ─────────────────────────────────────────────────────────────── with:
publish: name: node-badges
name: "📝 Publish Badges & Summary" path: .badges/
if: github.event_name == 'push' retention-days: 1
needs: [deploy] if-no-files-found: ignore
runs-on: [self-hosted, Linux]
steps: # ───────────────────────────────────────────────────────────────
- name: Checkout code # 3. Build Docker Image
uses: actions/checkout@v5 # ───────────────────────────────────────────────────────────────
build:
- name: Download Go coverage badges name: "🏗️ Build Docker Image"
continue-on-error: true if: github.event_name == 'push'
uses: actions/download-artifact@v6 needs: [go-test, node-test]
with: runs-on: [self-hosted, Linux]
name: go-badges steps:
path: .badges/ - name: Checkout code
uses: actions/checkout@v5
- name: Download E2E badges
continue-on-error: true - name: Set up Node.js 22
uses: actions/download-artifact@v6 uses: actions/setup-node@v5
with: with:
name: e2e-badges node-version: '22'
path: .badges/
- name: Build Go Docker image
- name: Publish coverage badges to repo run: |
continue-on-error: true echo "${GITHUB_SHA::7}" > .git-commit
run: | APP_VERSION=$(node -p "require('./package.json').version") \
git config user.name "github-actions" GIT_COMMIT="${GITHUB_SHA::7}" \
git config user.email "actions@github.com" docker compose --profile staging-go build staging-go
git remote set-url origin https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git echo "Built Go staging image"
git add .badges/ -f
git diff --cached --quiet || (git commit -m "ci: update test badges [skip ci]" && git push) || echo "Badge push failed" # ───────────────────────────────────────────────────────────────
# 4. Deploy Staging — start on port 82, healthcheck, smoke test
- name: Post deployment summary # ───────────────────────────────────────────────────────────────
run: | deploy:
echo "## Staging Deployed ✓" >> $GITHUB_STEP_SUMMARY name: "🚀 Deploy Staging"
echo "" >> $GITHUB_STEP_SUMMARY if: github.event_name == 'push'
echo "**Commit:** \`$(git rev-parse --short HEAD)\` — $(git log -1 --format=%s)" >> $GITHUB_STEP_SUMMARY needs: [build]
runs-on: [self-hosted, Linux]
steps:
- name: Checkout code
uses: actions/checkout@v5
- 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
View File

@@ -28,4 +28,3 @@ 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

View File

@@ -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. 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 — 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.
## ⚡ Performance ## ⚡ Performance

View File

@@ -60,10 +60,10 @@ func (c *Config) NodeDaysOrDefault() int {
} }
type HealthThresholds struct { type HealthThresholds struct {
InfraDegradedHours float64 `json:"infraDegradedHours"` InfraDegradedMs int `json:"infraDegradedMs"`
InfraSilentHours float64 `json:"infraSilentHours"` InfraSilentMs int `json:"infraSilentMs"`
NodeDegradedHours float64 `json:"nodeDegradedHours"` NodeDegradedMs int `json:"nodeDegradedMs"`
NodeSilentHours float64 `json:"nodeSilentHours"` NodeSilentMs int `json:"nodeSilentMs"`
} }
// ThemeFile mirrors theme.json overlay. // ThemeFile mirrors theme.json overlay.
@@ -126,46 +126,34 @@ func LoadTheme(baseDirs ...string) *ThemeFile {
func (c *Config) GetHealthThresholds() HealthThresholds { func (c *Config) GetHealthThresholds() HealthThresholds {
h := HealthThresholds{ h := HealthThresholds{
InfraDegradedHours: 24, InfraDegradedMs: 86400000,
InfraSilentHours: 72, InfraSilentMs: 259200000,
NodeDegradedHours: 1, NodeDegradedMs: 3600000,
NodeSilentHours: 24, NodeSilentMs: 86400000,
} }
if c.HealthThresholds != nil { if c.HealthThresholds != nil {
if c.HealthThresholds.InfraDegradedHours > 0 { if c.HealthThresholds.InfraDegradedMs > 0 {
h.InfraDegradedHours = c.HealthThresholds.InfraDegradedHours h.InfraDegradedMs = c.HealthThresholds.InfraDegradedMs
} }
if c.HealthThresholds.InfraSilentHours > 0 { if c.HealthThresholds.InfraSilentMs > 0 {
h.InfraSilentHours = c.HealthThresholds.InfraSilentHours h.InfraSilentMs = c.HealthThresholds.InfraSilentMs
} }
if c.HealthThresholds.NodeDegradedHours > 0 { if c.HealthThresholds.NodeDegradedMs > 0 {
h.NodeDegradedHours = c.HealthThresholds.NodeDegradedHours h.NodeDegradedMs = c.HealthThresholds.NodeDegradedMs
} }
if c.HealthThresholds.NodeSilentHours > 0 { if c.HealthThresholds.NodeSilentMs > 0 {
h.NodeSilentHours = c.HealthThresholds.NodeSilentHours h.NodeSilentMs = c.HealthThresholds.NodeSilentMs
} }
} }
return h return h
} }
// GetHealthMs returns degraded/silent thresholds in ms for a given role. // GetHealthMs returns degraded/silent thresholds 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 int(h.InfraDegradedHours * hourMs), int(h.InfraSilentHours * hourMs) return h.InfraDegradedMs, h.InfraSilentMs
}
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 {

View File

@@ -23,10 +23,10 @@ func TestLoadConfigValidJSON(t *testing.T) {
"SJC": "San Jose", "SJC": "San Jose",
}, },
"healthThresholds": map[string]interface{}{ "healthThresholds": map[string]interface{}{
"infraDegradedHours": 2, "infraDegradedMs": 100000,
"infraSilentHours": 4, "infraSilentMs": 200000,
"nodeDegradedHours": 0.5, "nodeDegradedMs": 50000,
"nodeSilentHours": 2, "nodeSilentMs": 100000,
}, },
"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.InfraDegradedHours != 24 { if ht.InfraDegradedMs != 86400000 {
t.Errorf("expected 24, got %v", ht.InfraDegradedHours) t.Errorf("expected 86400000, got %d", ht.InfraDegradedMs)
} }
if ht.InfraSilentHours != 72 { if ht.InfraSilentMs != 259200000 {
t.Errorf("expected 72, got %v", ht.InfraSilentHours) t.Errorf("expected 259200000, got %d", ht.InfraSilentMs)
} }
if ht.NodeDegradedHours != 1 { if ht.NodeDegradedMs != 3600000 {
t.Errorf("expected 1, got %v", ht.NodeDegradedHours) t.Errorf("expected 3600000, got %d", ht.NodeDegradedMs)
} }
if ht.NodeSilentHours != 24 { if ht.NodeSilentMs != 86400000 {
t.Errorf("expected 24, got %v", ht.NodeSilentHours) t.Errorf("expected 86400000, got %d", ht.NodeSilentMs)
} }
} }
func TestGetHealthThresholdsCustom(t *testing.T) { func TestGetHealthThresholdsCustom(t *testing.T) {
cfg := &Config{ cfg := &Config{
HealthThresholds: &HealthThresholds{ HealthThresholds: &HealthThresholds{
InfraDegradedHours: 2, InfraDegradedMs: 100000,
InfraSilentHours: 4, InfraSilentMs: 200000,
NodeDegradedHours: 0.5, NodeDegradedMs: 50000,
NodeSilentHours: 2, NodeSilentMs: 100000,
}, },
} }
ht := cfg.GetHealthThresholds() ht := cfg.GetHealthThresholds()
if ht.InfraDegradedHours != 2 { if ht.InfraDegradedMs != 100000 {
t.Errorf("expected 2, got %v", ht.InfraDegradedHours) t.Errorf("expected 100000, got %d", ht.InfraDegradedMs)
} }
if ht.InfraSilentHours != 4 { if ht.InfraSilentMs != 200000 {
t.Errorf("expected 4, got %v", ht.InfraSilentHours) t.Errorf("expected 200000, got %d", ht.InfraSilentMs)
} }
if ht.NodeDegradedHours != 0.5 { if ht.NodeDegradedMs != 50000 {
t.Errorf("expected 0.5, got %v", ht.NodeDegradedHours) t.Errorf("expected 50000, got %d", ht.NodeDegradedMs)
} }
if ht.NodeSilentHours != 2 { if ht.NodeSilentMs != 100000 {
t.Errorf("expected 2, got %v", ht.NodeSilentHours) t.Errorf("expected 100000, got %d", ht.NodeSilentMs)
} }
} }
func TestGetHealthThresholdsPartialCustom(t *testing.T) { func TestGetHealthThresholdsPartialCustom(t *testing.T) {
cfg := &Config{ cfg := &Config{
HealthThresholds: &HealthThresholds{ HealthThresholds: &HealthThresholds{
InfraDegradedHours: 2, InfraDegradedMs: 100000,
// Others left as zero → should use defaults // Others left as zero → should use defaults
}, },
} }
ht := cfg.GetHealthThresholds() ht := cfg.GetHealthThresholds()
if ht.InfraDegradedHours != 2 { if ht.InfraDegradedMs != 100000 {
t.Errorf("expected 2, got %v", ht.InfraDegradedHours) t.Errorf("expected 100000, got %d", ht.InfraDegradedMs)
} }
if ht.InfraSilentHours != 72 { if ht.InfraSilentMs != 259200000 {
t.Errorf("expected default 72, got %v", ht.InfraSilentHours) t.Errorf("expected default 259200000, got %d", ht.InfraSilentMs)
} }
} }
func TestGetHealthMs(t *testing.T) { func TestGetHealthMs(t *testing.T) {
ht := HealthThresholds{ ht := HealthThresholds{
InfraDegradedHours: 24, InfraDegradedMs: 86400000,
InfraSilentHours: 72, InfraSilentMs: 259200000,
NodeDegradedHours: 1, NodeDegradedMs: 3600000,
NodeSilentHours: 24, NodeSilentMs: 86400000,
} }
tests := []struct { tests := []struct {

View File

@@ -513,10 +513,10 @@ func TestGetNetworkStatus(t *testing.T) {
seedTestData(t, db) seedTestData(t, db)
ht := HealthThresholds{ ht := HealthThresholds{
InfraDegradedHours: 24, InfraDegradedMs: 86400000,
InfraSilentHours: 72, InfraSilentMs: 259200000,
NodeDegradedHours: 1, NodeDegradedMs: 3600000,
NodeSilentHours: 24, NodeSilentMs: 86400000,
} }
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{
InfraDegradedHours: 24, InfraDegradedMs: 86400000,
InfraSilentHours: 72, InfraSilentMs: 259200000,
NodeDegradedHours: 1, NodeDegradedMs: 3600000,
NodeSilentHours: 24, NodeSilentMs: 86400000,
} }
result, err := db.GetNetworkStatus(ht) result, err := db.GetNetworkStatus(ht)
if err != nil { if err != nil {

View File

@@ -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.GetHealthThresholds().ToClientMs(), HealthThresholds: s.cfg.HealthThresholds,
Tiles: s.cfg.Tiles, Tiles: s.cfg.Tiles,
SnrThresholds: s.cfg.SnrThresholds, SnrThresholds: s.cfg.SnrThresholds,
DistThresholds: s.cfg.DistThresholds, DistThresholds: s.cfg.DistThresholds,

View File

@@ -1085,6 +1085,18 @@ 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
} }
@@ -1289,6 +1301,20 @@ 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
} }

View File

@@ -98,13 +98,6 @@
"#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": [

View File

@@ -89,8 +89,7 @@
function getStatusTooltip(role, status) { function getStatusTooltip(role, status) {
const isInfra = role === 'repeater' || role === 'room'; const isInfra = role === 'repeater' || role === 'room';
const threshMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs; const threshold = isInfra ? '72h' : '24h';
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.' : '');
} }

View File

@@ -1,142 +0,0 @@
#!/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')"

View File

@@ -18,16 +18,10 @@ 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: navigate via hash (SPA — no full page reload needed after initial load) // Helper: safe click
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 || 500 }); await page.click(selector, { timeout: timeout || 3000 });
} catch {} } catch {}
} }
@@ -70,9 +64,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: 'domcontentloaded', timeout: 10000 }).catch(() => {}); await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.evaluate(() => localStorage.clear()).catch(() => {}); await page.evaluate(() => localStorage.clear()).catch(() => {});
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {}); await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// Click "I'm new" // Click "I'm new"
await safeClick('#chooseNew'); await safeClick('#chooseNew');
@@ -111,7 +105,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: 'domcontentloaded', timeout: 10000 }).catch(() => {}); await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await safeClick('#chooseExp'); await safeClick('#chooseExp');
// Interact with experienced home page // Interact with experienced home page
@@ -126,7 +120,7 @@ async function collectCoverage() {
// NODES PAGE // NODES PAGE
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
console.log(' [coverage] Nodes page...'); console.log(' [coverage] Nodes page...');
await navHash('#/nodes'); await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// 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']) {
@@ -162,7 +156,7 @@ async function collectCoverage() {
} }
// In side pane — click detail/analytics links // In side pane — click detail/analytics links
await safeClick('a[href*="/nodes/"]'); await safeClick('a[href*="/nodes/"]', 2000);
// Click fav star // Click fav star
await clickAll('.fav-star', 2); await clickAll('.fav-star', 2);
@@ -174,7 +168,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 navHash('#/nodes/' + firstNodeKey); await page.goto(`${BASE}/#/nodes/${firstNodeKey}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// Click tabs on detail page // Click tabs on detail page
await clickAll('.tab-btn, [data-tab]', 10); await clickAll('.tab-btn, [data-tab]', 10);
@@ -197,7 +191,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 navHash('#/nodes/' + firstKey + '?scroll=paths'); await page.goto(`${BASE}/#/nodes/${firstKey}?scroll=paths`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
} }
} catch {} } catch {}
@@ -205,7 +199,7 @@ async function collectCoverage() {
// PACKETS PAGE // PACKETS PAGE
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
console.log(' [coverage] Packets page...'); console.log(' [coverage] Packets page...');
await navHash('#/packets'); await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// Open filter bar // Open filter bar
await safeClick('#filterToggleBtn'); await safeClick('#filterToggleBtn');
@@ -291,13 +285,13 @@ async function collectCoverage() {
} catch {} } catch {}
// Navigate to specific packet by hash // Navigate to specific packet by hash
await navHash('#/packets/deadbeef'); await page.goto(`${BASE}/#/packets/deadbeef`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// MAP PAGE // MAP PAGE
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
console.log(' [coverage] Map page...'); console.log(' [coverage] Map page...');
await navHash('#/map'); await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// Toggle controls panel // Toggle controls panel
await safeClick('#mapControlsToggle'); await safeClick('#mapControlsToggle');
@@ -351,7 +345,7 @@ async function collectCoverage() {
// ANALYTICS PAGE // ANALYTICS PAGE
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
console.log(' [coverage] Analytics page...'); console.log(' [coverage] Analytics page...');
await navHash('#/analytics'); await page.goto(`${BASE}/#/analytics`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// 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'];
@@ -387,12 +381,9 @@ async function collectCoverage() {
await clickAll('.analytics-table th', 8); await clickAll('.analytics-table th', 8);
} catch {} } catch {}
// Deep-link to each analytics tab via hash (avoid full page.goto) // Deep-link to each analytics tab via URL
for (const tab of analyticsTabs) { for (const tab of analyticsTabs) {
try { await page.goto(`${BASE}/#/analytics?tab=${tab}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
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
@@ -405,7 +396,7 @@ async function collectCoverage() {
// CUSTOMIZE // CUSTOMIZE
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
console.log(' [coverage] Customizer...'); console.log(' [coverage] Customizer...');
await navHash('#/home'); await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await safeClick('#customizeToggle'); await safeClick('#customizeToggle');
// Click EVERY customizer tab // Click EVERY customizer tab
@@ -512,7 +503,7 @@ async function collectCoverage() {
// CHANNELS PAGE // CHANNELS PAGE
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
console.log(' [coverage] Channels page...'); console.log(' [coverage] Channels page...');
await navHash('#/channels'); await page.goto(`${BASE}/#/channels`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// 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);
@@ -521,7 +512,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 navHash('#/channels/' + channelHash); await page.goto(`${BASE}/#/channels/${channelHash}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
} }
} catch {} } catch {}
@@ -529,7 +520,7 @@ async function collectCoverage() {
// LIVE PAGE // LIVE PAGE
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
console.log(' [coverage] Live page...'); console.log(' [coverage] Live page...');
await navHash('#/live'); await page.goto(`${BASE}/#/live`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// VCR controls // VCR controls
await safeClick('#vcrPauseBtn'); await safeClick('#vcrPauseBtn');
@@ -612,14 +603,14 @@ async function collectCoverage() {
// TRACES PAGE // TRACES PAGE
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
console.log(' [coverage] Traces page...'); console.log(' [coverage] Traces page...');
await navHash('#/traces'); await page.goto(`${BASE}/#/traces`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
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 navHash('#/observers'); await page.goto(`${BASE}/#/observers`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// 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++) {
@@ -640,7 +631,7 @@ async function collectCoverage() {
// PERF PAGE // PERF PAGE
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
console.log(' [coverage] Perf page...'); console.log(' [coverage] Perf page...');
await navHash('#/perf'); await page.goto(`${BASE}/#/perf`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await safeClick('#perfRefresh'); await safeClick('#perfRefresh');
await safeClick('#perfReset'); await safeClick('#perfReset');
@@ -650,14 +641,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 navHash('#/nonexistent-route'); await page.goto(`${BASE}/#/nonexistent-route`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// Navigate to every route via hash (50ms is enough for SPA hash routing) // Navigate to every route via hash
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 new Promise(r => setTimeout(r, 50)); await page.waitForLoadState('networkidle').catch(() => {});
} catch {} } catch {}
} }
@@ -723,11 +714,10 @@ async function collectCoverage() {
await page.evaluate(() => { if (window.apiPerf) window.apiPerf(); }); await page.evaluate(() => { if (window.apiPerf) window.apiPerf(); });
} catch {} } catch {}
// Exercise utility functions + packet filter parser in one evaluate call // Exercise utility functions
console.log(' [coverage] Utility functions + packet filter...');
try { try {
await page.evaluate(() => { await page.evaluate(() => {
// Utility functions // timeAgo with various inputs
if (typeof timeAgo === 'function') { if (typeof timeAgo === 'function') {
timeAgo(null); timeAgo(null);
timeAgo(new Date().toISOString()); timeAgo(new Date().toISOString());
@@ -735,11 +725,13 @@ 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);
} }
@@ -749,14 +741,23 @@ 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',
@@ -772,6 +773,7 @@ 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 {}
@@ -785,24 +787,29 @@ async function collectCoverage() {
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
console.log(' [coverage] Region filter...'); console.log(' [coverage] Region filter...');
try { try {
// Open region filter on nodes page (use hash nav, already visited) // Open region filter on nodes page
await page.evaluate(() => { location.hash = '#/nodes'; }); await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
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.evaluate(() => { location.hash = '#/packets'; }); await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
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 — extract coverage (all routes already visited above) // FINAL — navigate through all routes once more
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
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__);

View File

@@ -36,19 +36,18 @@ function loadThemeFile(themePaths) {
function buildHealthConfig(config) { function buildHealthConfig(config) {
const _ht = (config && config.healthThresholds) || {}; const _ht = (config && config.healthThresholds) || {};
return { return {
infraDegraded: _ht.infraDegradedHours || 24, infraDegradedMs: _ht.infraDegradedMs || 86400000,
infraSilent: _ht.infraSilentHours || 72, infraSilentMs: _ht.infraSilentMs || 259200000,
nodeDegraded: _ht.nodeDegradedHours || 1, nodeDegradedMs: _ht.nodeDegradedMs || 3600000,
nodeSilent: _ht.nodeSilentHours || 24 nodeSilentMs: _ht.nodeSilentMs || 86400000
}; };
} }
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.infraDegraded : HEALTH.nodeDegraded) * H, degradedMs: isInfra ? HEALTH.infraDegradedMs : HEALTH.nodeDegradedMs,
silentMs: (isInfra ? HEALTH.infraSilent : HEALTH.nodeSilent) * H silentMs: isInfra ? HEALTH.infraSilentMs : HEALTH.nodeSilentMs
}; };
} }

View File

@@ -307,12 +307,7 @@ 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: { healthThresholds: config.healthThresholds || null,
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,

View File

@@ -17,8 +17,6 @@ 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);
} }
} }
@@ -353,22 +351,13 @@ async function run() {
}); });
// Test: Clicking a packet row opens detail pane // Test: Clicking a packet row opens detail pane
// SKIPPED: flaky test — see https://github.com/Kpa-clawbot/CoreScope/issues/257 await test('Packets clicking row shows detail pane', async () => {
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');
// Click the row and wait for the /packets/{hash} API response await firstRow.click();
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');
@@ -386,16 +375,12 @@ 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 Promise.all([ await firstRow.click();
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');
@@ -410,8 +395,7 @@ 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 () => {
@@ -845,7 +829,17 @@ 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)
@@ -915,19 +909,6 @@ 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

Binary file not shown.

View File

@@ -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('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>node<'), 'should show engine name'); assert.ok(result.includes('[node]'), 'should show engine');
}); });
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('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name'); assert.ok(result.includes('[go]'), 'should show engine');
}); });
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('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>node<'), 'should show engine name'); assert.ok(result.includes('[node]'), 'should show engine');
}); });
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('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name'); assert.ok(result.includes('[go]'), 'should show engine');
}); });
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('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name'); assert.ok(result.includes('[go]'), 'should show engine');
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('engine-badge'), 'engine badge shown'); assert.ok(result.includes('>go<'), 'engine name shown'); assert.ok(result.includes('[go]'), 'engine shown');
}); });
} }

View File

@@ -59,17 +59,17 @@ console.log('\nloadThemeFile:');
console.log('\nbuildHealthConfig:'); console.log('\nbuildHealthConfig:');
{ {
const h = helpers.buildHealthConfig({}); const h = helpers.buildHealthConfig({});
assert(h.infraDegraded === 24, 'default infraDegraded'); assert(h.infraDegradedMs === 86400000, 'default infraDegradedMs');
assert(h.infraSilent === 72, 'default infraSilent'); assert(h.infraSilentMs === 259200000, 'default infraSilentMs');
assert(h.nodeDegraded === 1, 'default nodeDegraded'); assert(h.nodeDegradedMs === 3600000, 'default nodeDegradedMs');
assert(h.nodeSilent === 24, 'default nodeSilent'); assert(h.nodeSilentMs === 86400000, 'default nodeSilentMs');
const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedHours: 2 } }); const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedMs: 1000 } });
assert(h2.infraDegraded === 2, 'custom infraDegraded'); assert(h2.infraDegradedMs === 1000, 'custom infraDegradedMs');
assert(h2.nodeDegraded === 1, 'other defaults preserved'); assert(h2.nodeDegradedMs === 3600000, 'other defaults preserved');
const h3 = helpers.buildHealthConfig(null); const h3 = helpers.buildHealthConfig(null);
assert(h3.infraDegraded === 24, 'handles null config'); assert(h3.infraDegradedMs === 86400000, '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 === 24 * 3600000, 'repeater uses infra degraded'); assert(rep.degradedMs === 86400000, 'repeater uses infra degraded');
assert(rep.silentMs === 72 * 3600000, 'repeater uses infra silent'); assert(rep.silentMs === 259200000, 'repeater uses infra silent');
const room = helpers.getHealthMs('room', HEALTH); const room = helpers.getHealthMs('room', HEALTH);
assert(room.degradedMs === 24 * 3600000, 'room uses infra degraded'); assert(room.degradedMs === 86400000, 'room uses infra degraded');
const comp = helpers.getHealthMs('companion', HEALTH); const comp = helpers.getHealthMs('companion', HEALTH);
assert(comp.degradedMs === 1 * 3600000, 'companion uses node degraded'); assert(comp.degradedMs === 3600000, 'companion uses node degraded');
assert(comp.silentMs === 24 * 3600000, 'companion uses node silent'); assert(comp.silentMs === 86400000, 'companion uses node silent');
const sensor = helpers.getHealthMs('sensor', HEALTH); const sensor = helpers.getHealthMs('sensor', HEALTH);
assert(sensor.degradedMs === 1 * 3600000, 'sensor uses node degraded'); assert(sensor.degradedMs === 3600000, 'sensor uses node degraded');
const undef = helpers.getHealthMs(undefined, HEALTH); const undef = helpers.getHealthMs(undefined, HEALTH);
assert(undef.degradedMs === 1 * 3600000, 'undefined role uses node degraded'); assert(undef.degradedMs === 3600000, 'undefined role uses node degraded');
} }
// --- isHashSizeFlipFlop --- // --- isHashSizeFlipFlop ---