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:
+18
-19
@@ -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
@@ -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)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user