e2e: fail the gate when any job it guards fails

The gate enforced only health. explorer, apps and branded each printed a
warning and fell through to an unconditional 'gate passed', so run 62877
went green with three red jobs. One rule now covers every guarded job:
green or the gate is red.

The apps matrix named itself App E2E (${{ matrix.app }}), which
stringifies the leg mapping to 'Object' — five identically named jobs,
none identifying which leg failed. Use matrix.app.name.

Explorer E2E was one of the hidden failures: it asserted SPC serves
stats, but no SPC chain exists and the explorer config disables the slug,
so api-explore-spc answers a plain-text 404. Four specs each carried
their own copy of the chain list; EXPLORERS in env.ts is now the one
list, and SPC is absent from it with the reason recorded.

httpGetJson fed error-page bodies to JSON.parse, reporting a complaint
about byte 4 of '404 page not found' instead of the endpoint and status.
It now reports both.
This commit is contained in:
Antje Worring
2026-08-28 12:39:52 -07:00
committed by Hanzo AI
parent 5d1b40c72b
commit da50d29940
6 changed files with 64 additions and 57 deletions
+18 -19
View File
@@ -109,7 +109,7 @@ jobs:
# ── App Tests (Exchange, Bridge, Staking, Cloud, Wallet) ─────────
apps:
name: App E2E (${{ matrix.app }})
name: App E2E (${{ matrix.app.name }})
runs-on: lux-build-amd64
timeout-minutes: 15
needs: health
@@ -203,23 +203,22 @@ jobs:
needs: [health, explorer, apps, branded]
if: always()
steps:
# One rule for every job the gate guards: green or the gate is red.
# A per-job exemption is indistinguishable from no gate at all — it is
# how three red jobs shipped a green run. `apps` is a matrix; with
# fail-fast disabled its aggregate result is failure if any leg failed.
- name: Check results
env:
RESULTS: >-
health=${{ needs.health.result }}
explorer=${{ needs.explorer.result }}
apps=${{ needs.apps.result }}
branded=${{ needs.branded.result }}
run: |
echo "Health: ${{ needs.health.result }}"
echo "Explorer: ${{ needs.explorer.result }}"
echo "Apps: ${{ needs.apps.result }}"
echo "Branded: ${{ needs.branded.result }}"
if [ "${{ needs.health.result }}" != "success" ]; then
echo "FATAL: Health tests failed"
exit 1
fi
if [ "${{ needs.explorer.result }}" = "failure" ]; then
echo "WARNING: Explorer tests failed"
fi
if [ "${{ needs.apps.result }}" = "failure" ]; then
echo "WARNING: App tests had failures"
fi
if [ "${{ needs.branded.result }}" = "failure" ]; then
echo "WARNING: Branded matrix found an un-flagged regression — see the branded-matrix artifact"
fi
echo "E2E gate passed (health OK)"
red=
for r in $RESULTS; do
echo "$r"
case $r in *=success) ;; *) red=1 ;; esac
done
[ -z "$red" ] || { echo "E2E gate failed"; exit 1; }
echo "E2E gate passed"
+6 -21
View File
@@ -1,19 +1,12 @@
import { test, expect } from '@playwright/test'
import { getUrls, httpStatus, httpGetJson, snap, expectBlocksSection } from './helpers'
import { EXPLORERS } from './env'
const urls = getUrls()
// ── Explorer API — All 5 chains ─────────────────────────────────────
// ── Explorer API — every indexed chain ──────────────────────────────
test.describe('Explorer API — Blockscout Stats', () => {
const explorers = [
{ name: 'Lux C-Chain', api: urls.apiExploreLux, ui: urls.exploreLux },
{ name: 'Zoo', api: urls.apiExploreZoo, ui: urls.exploreZoo },
{ name: 'Hanzo', api: urls.apiExploreHanzo, ui: urls.exploreHanzo },
{ name: 'SPC', api: urls.apiExploreSpc, ui: urls.exploreSpc },
{ name: 'Pars', api: urls.apiExplorePars, ui: urls.explorePars },
]
for (const { name, api } of explorers) {
for (const { name, api } of EXPLORERS) {
test(`${name} — stats endpoint returns blocks`, async ({ request }) => {
const data = await httpGetJson(request, `${api}/stats`) as Record<string, unknown>
const blocks = Number(data.total_blocks || 0)
@@ -22,7 +15,7 @@ test.describe('Explorer API — Blockscout Stats', () => {
})
}
for (const { name, api } of explorers) {
for (const { name, api } of EXPLORERS) {
test(`${name} — search API accessible`, async ({ request }) => {
const status = await httpStatus(request, `${api}/search?q=0x0000000000000000000000000000000000000000`)
expect([200, 404]).toContain(status)
@@ -30,17 +23,9 @@ test.describe('Explorer API — Blockscout Stats', () => {
}
})
// ── Explorer Frontend — All 5 chains ────────────────────────────────
// ── Explorer Frontend — every indexed chain ─────────────────────────
test.describe('Explorer Frontend — Page Load', () => {
const frontends = [
{ name: 'Lux C-Chain', url: urls.exploreLux },
{ name: 'Zoo', url: urls.exploreZoo },
{ name: 'Hanzo', url: urls.exploreHanzo },
{ name: 'SPC', url: urls.exploreSpc },
{ name: 'Pars', url: urls.explorePars },
]
for (const { name, url } of frontends) {
for (const { name, ui: url } of EXPLORERS) {
test(`${name} explorer loads (${url})`, async ({ page }) => {
const res = await page.goto(url, { timeout: 60_000, waitUntil: 'domcontentloaded' })
expect(res?.status()).toBe(200)
+3 -9
View File
@@ -6,6 +6,7 @@ import {
httpStatus, httpGetJson,
snap,
} from './helpers'
import { EXPLORERS } from './env'
const urls = getUrls()
@@ -48,15 +49,8 @@ test.describe('Stats — Validator Count Consistency', () => {
expect(validators.length).toBeGreaterThanOrEqual(5)
})
test('all 5 explorer instances report blocks', async ({ request }) => {
const explorers = [
{ name: 'Lux', api: urls.apiExploreLux },
{ name: 'Zoo', api: urls.apiExploreZoo },
{ name: 'Hanzo', api: urls.apiExploreHanzo },
{ name: 'SPC', api: urls.apiExploreSpc },
{ name: 'Pars', api: urls.apiExplorePars },
]
for (const { name, api } of explorers) {
test('every indexed explorer reports blocks', async ({ request }) => {
for (const { name, api } of EXPLORERS) {
const data = await httpGetJson(request, `${api}/stats`) as Record<string, unknown>
const blocks = Number(data.total_blocks || 0)
console.log(` ${name}: ${blocks} blocks`)
+2 -7
View File
@@ -4,6 +4,7 @@ import {
rpcCall, ethBlockNumber, ethChainId,
httpStatus, httpGetJson,
} from './helpers'
import { EXPLORERS } from './env'
const urls = getUrls()
const chainIds = getChainIds()
@@ -171,13 +172,7 @@ test.describe('App Chains — Pars', () => {
// ── Cross-Chain Block Production ──────────────────────────────────
test.describe('App Chains — Cross-Chain Block Comparison', () => {
test('all app chain explorers report blocks', async ({ request }) => {
const explorers = [
{ name: 'Zoo', api: urls.apiExploreZoo },
{ name: 'Hanzo', api: urls.apiExploreHanzo },
{ name: 'SPC', api: urls.apiExploreSpc },
{ name: 'Pars', api: urls.apiExplorePars },
]
for (const { name, api } of explorers) {
for (const { name, api } of EXPLORERS.filter((e) => e.org !== 'lux')) {
try {
const data = await httpGetJson(request, `${api}/stats`) as Record<string, unknown>
const blocks = Number(data.total_blocks || 0)
+20
View File
@@ -388,6 +388,26 @@ export const env: Env = {
isLocal: ENV_NAME === 'local',
}
// ── Indexed chains ─────────────────────────────────────────────────────────
// Which chains have a live indexer, asked and answered ONCE. Four specs used
// to carry their own copy of this list, so one wrong entry was four red tests.
//
// SPC is deliberately absent. There is no SPC chain to index anywhere, and the
// explorer says so itself: k8s/lux-mainnet/explorer.yaml sets `enabled: false`
// on slug `spc`, so the supervisor never mounts /v1/indexer/spc and
// api-explore-spc.lux.network answers `404 page not found` in text/plain where
// a stats document belongs. Restore the entry the day an SPC L1 exists and the
// indexer is enabled — the chain stays in REGISTRY either way, because it is a
// declared network; this list is about what is running, not what is planned.
export interface Explorer { org: Org; name: string; api: string; ui: string }
export const EXPLORERS: Explorer[] = [
{ org: 'lux', name: 'Lux C-Chain', api: env.urls.apiExploreLux, ui: env.urls.exploreLux },
{ org: 'zoo', name: 'Zoo', api: env.urls.apiExploreZoo, ui: env.urls.exploreZoo },
{ org: 'hanzo', name: 'Hanzo', api: env.urls.apiExploreHanzo, ui: env.urls.exploreHanzo },
{ org: 'pars', name: 'Pars', api: env.urls.apiExplorePars, ui: env.urls.explorePars },
]
// The one way to resolve any org's C-Chain RPC. Omit `network` for the active
// env (local-aware); pass one explicitly to reach that network's PUBLIC
// endpoint — which is what a cross-network spec wants even from a local run.
+15 -1
View File
@@ -270,6 +270,13 @@ export async function httpStatus(
}
}
// Reads JSON, and when there is no JSON to read, SAYS SO. This used to be a
// bare `res.json()`, which handed the body of any error page straight to
// JSON.parse. A plain-text `404 page not found` then failed as `Unexpected
// non-whitespace character after JSON at position 4` — a complaint about the
// fifth byte of the phrase, naming neither the URL nor the status. Every
// caller wants the same two facts on failure: which endpoint, and what it
// actually served.
export async function httpGetJson(
request: APIRequestContext,
url: string,
@@ -280,7 +287,14 @@ export async function httpGetJson(
timeout,
headers: { Accept: 'application/json' },
})
return res.json()
const body = await res.text()
const how = `GET ${url} → ${res.status()} ${res.headers()['content-type'] || 'no content-type'}`
if (!res.ok()) throw new Error(`${how}: ${body.slice(0, 200)}`)
try {
return JSON.parse(body)
} catch {
throw new Error(`${how}, body is not JSON: ${body.slice(0, 200)}`)
}
}
// ---------------------------------------------------------------------------