You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/08/06 01:36:44 UTC
(skywalking-horizon-ui) branch main updated: ci(e2e): a BanyanDB-cluster deployment case, and the widgets nothing asserted (#112)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new 14628d0 ci(e2e): a BanyanDB-cluster deployment case, and the widgets nothing asserted (#112)
14628d0 is described below
commit 14628d0c1da65cba9cdbd8c24781acf9fdd63f71
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Wed Aug 5 18:36:38 2026 -0700
ci(e2e): a BanyanDB-cluster deployment case, and the widgets nothing asserted (#112)
Adds the sixth deployment scenario and closes the widget-coverage gaps an
audit of the suite turned up.
`deployment` stands up BanyanDB as a real CLUSTER — its own helm chart, two
liaisons, two data nodes, FODC enabled — with an OTel collector scraping the
FODC proxy into OAP's banyandb rules. Nothing else in the suite could produce
it: a standalone node has no roles to draw and no FODC to report them. It is
the first case anywhere to render a `table` widget, and the only one that can
reach the Deployment tab's edge sparklines, which need two roles to draw
between.
Three things about that pipeline are load-bearing and fail in ways that point
elsewhere, so each is documented where it is configured: the scrape job must
be named `banyandb-monitoring`; the scrape interval must be finer than the
rules' PT15S rate windows, or every rate reads 0, no samples are produced and
the SERVICE is never created; and the FODC proxy — not the per-node endpoints
— is the target, because only the proxy stamps the pod / container / role
labels the tab groups by.
Widget coverage: trace detail was five renderers behind one click with only
"the pane appeared" asserted — the duration scatter, waterfall, d3 tree,
statistics roll-up and span modal are now each asserted. Overview dashboards
had no spec at all, so one page covers KpiTileWidget, MetricCompositeWidget,
AlarmsWidget and SectionBreak. Plus the alarms timeline, the Explore
browser-error popout, and both trace deep links opened COLD in a fresh page —
a different path from clicking a row, and the one a shared link takes.
Fixes an overview-editor bug found while testing: an unsaved widget followed
the operator to another dashboard, where it read as — and would have been
saved to — the wrong template. The editor's dirty guard protects against a
background refetch clobbering edits; it must not suppress the reseed when the
operator picks a different dashboard.
The helm chart pin moves past 3aee62a (`helm --wait` deadlock against OAP's
init job) and every image a kind case runs is side-loaded, including the
`-slim` and FODC tags the cluster chart pulls.
---
.github/workflows/e2e.yaml | 30 +++-
.gitignore | 4 +-
CLAUDE.md | 12 ++
.../overview-templates/OverviewTemplatesAdmin.vue | 13 +-
docs/changelog/1.0.0.md | 2 +
test/e2e/CLAUDE.md | 26 ++-
test/e2e/README.md | 4 +-
test/e2e/cases/admin/e2e.yaml | 2 +-
test/e2e/cases/browser/e2e.yaml | 2 +-
test/e2e/cases/core/e2e.yaml | 27 ++-
test/e2e/cases/core/expected/has-traces.yml | 18 ++
test/e2e/cases/deployment/app.yaml | 108 ++++++++++++
test/e2e/cases/deployment/e2e.yaml | 192 +++++++++++++++++++++
test/e2e/cases/deployment/expected/has-cluster.yml | 18 ++
.../cases/deployment/expected/has-edge-series.yml | 18 ++
test/e2e/cases/deployment/expected/has-nodes.yml | 18 ++
test/e2e/cases/deployment/expected/passed.yml | 18 ++
test/e2e/cases/deployment/horizon.yaml | 95 ++++++++++
test/e2e/cases/deployment/kind.yaml | 35 ++++
test/e2e/cases/deployment/otel-collector.yaml | 105 +++++++++++
test/e2e/cases/es/e2e.yaml | 4 +-
test/e2e/cases/istio/e2e.yaml | 8 +-
test/e2e/cases/istio/expected/has-mesh-calls.yml | 18 ++
test/e2e/cases/istio/verify-mesh.yaml | 19 +-
test/e2e/cases/istio/verify-profiling.yaml | 4 +-
test/e2e/playwright/playwright.config.ts | 8 +
.../playwright/specs/admin/overview-draft.spec.ts | 82 +++++++++
.../specs/browser/inspect-popout.spec.ts | 59 +++++++
.../playwright/specs/deployment/banyandb.spec.ts | 136 +++++++++++++++
test/e2e/playwright/specs/fixture.ts | 15 ++
.../playwright/specs/istio-profiling/ebpf.spec.ts | 2 +-
test/e2e/playwright/specs/istio/mesh.spec.ts | 2 +-
.../playwright/specs/istio/zipkin-traces.spec.ts | 65 +++++++
test/e2e/playwright/specs/ui/entities.spec.ts | 9 +
test/e2e/playwright/specs/ui/overview.spec.ts | 51 ++++++
.../playwright/specs/ui/template-fidelity.spec.ts | 6 +-
.../e2e/playwright/specs/ui/trace-deeplink.spec.ts | 77 +++++++++
test/e2e/playwright/specs/ui/trace-detail.spec.ts | 104 +++++++++++
test/e2e/script/env | 21 ++-
test/e2e/script/prepare/build-images.sh | 17 ++
test/e2e/script/prepare/deployment-up.sh | 118 +++++++++++++
test/e2e/script/prepare/horizon-get.sh | 2 +-
test/e2e/script/prepare/istio-up.sh | 17 +-
test/e2e/script/prepare/playwright.sh | 4 +-
test/e2e/script/prepare/rover-processes.sh | 2 +-
45 files changed, 1546 insertions(+), 51 deletions(-)
diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml
index d3c8f06..b6d00ca 100644
--- a/.github/workflows/e2e.yaml
+++ b/.github/workflows/e2e.yaml
@@ -15,7 +15,7 @@
# limitations under the License.
#
-# The gate `tsc` and unit tests cannot be: Horizon against a REAL OAP, fed by
+# The check `tsc` and unit tests cannot be: Horizon against a REAL OAP, fed by
# real telemetry, verified on BOTH halves of the product — the BFF's HTTP
# surface and the rendered UI in headless Chromium. A route that returns
# correct JSON into a view that throws on mount passes every API assertion and
@@ -48,13 +48,20 @@
# tests: layer template headers and metrics, layer dashboard
# layout and widget spans, the line / top / record widget forms,
# service picker, traces, logs, topology, instance topology,
-# alarms, trace profiling (task create -> list), shell and login
+# alarms and their timeline, trace profiling (task create ->
+# list), the trace DETAIL — duration scatter, waterfall, tree,
+# statistics and the span modal — the services overview
+# dashboard, shell and login
# components: LayerWidgetTile, TimeChart, TopList, RecordList,
# TraceListPanel, LayerTracesView, TracePopout, LogStreamPanel,
# LogDetailPopout, LayerServiceMapView, TopologyDetailPanels,
# LayerInstanceTopologyView, LayerShell, LayerServiceSelector,
# LayerDashboardsView, LayerInstancePicker, LayerEndpointPicker,
-# AlarmsView, LayerTraceProfilingView, AppShell
+# AlarmsView, AlarmsTimeline, LayerTraceProfilingView,
+# TraceDistribution, TraceDetailCard, TraceWaterfallView,
+# TraceTreeView, TraceStatsView, SpanDetailModal,
+# OverviewDashboardView, KpiTileWidget, MetricCompositeWidget,
+# AlarmsWidget, SectionBreak, AppShell
#
# es the same, storing to ElasticSearch instead ACTIVE
# tests: the trace page on a non-BanyanDB backend — query-API
@@ -76,6 +83,16 @@
# components: DslCatalogView, DebugHistoryView, LiveDebuggerView,
# OverviewTemplatesAdmin, LayerDashboardsAdmin — mount + list
#
+# deployment BanyanDB as a CLUSTER on kind, via its helm chart ACTIVE
+# with FODC, plus an OTel collector
+# tests: the BANYANDB layer — the cluster resolving from the
+# collector's `cluster` label, the `table` widget form (declared
+# by no template any other case reaches), and the Deployment tab
+# drawing the cluster grouped by node role
+# components: TableWidget, LayerDeploymentView (its edge panel
+# and metric rows; the edge Sparkline itself is NOT asserted —
+# the series is still sparse on a minutes-old cluster)
+#
# istio kind + Istio + rover, in place of compose ACTIVE
# tests: the MESH layer — service list, mesh topology, and the
# mesh_dp instance dashboard, which is the first bundled template
@@ -85,7 +102,7 @@
# is asserted and no task is run, so the profiling components
# stay untested for now
# components: LayerWidgetTile's card branch,
-# LayerZipkinTracesView, TraceListPanel,
+# LayerZipkinTracesView, ZipkinTraceDetailCard, TraceListPanel,
# LayerEBPFProfilingView / LayerNetworkProfilingView — mount only
#
# ── RUNNING `istio` WITHOUT PROFILING ────────────────────────────────────
@@ -164,6 +181,9 @@ jobs:
# mesh install plus ALS analysis before the first assertion.
- name: istio (kind + Istio + rover)
config: test/e2e/cases/istio/e2e.yaml
+ # kind as well, but no eBPF — this one runs anywhere kind runs.
+ - name: deployment (BanyanDB cluster + FODC)
+ config: test/e2e/cases/deployment/e2e.yaml
steps:
- uses: actions/checkout@v6
with:
@@ -225,7 +245,7 @@ jobs:
e2e-logs
retention-days: 7
- # Rolls the matrix up into ONE status so branch protection can gate on it:
+ # Rolls the matrix up into ONE status so branch protection can check on it:
# `.asf.yaml` lists contexts by name, and a matrix produces one context per
# case whose names change whenever a case is added or renamed.
#
diff --git a/.gitignore b/.gitignore
index 39a96af..1ef38a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,4 +55,6 @@ horizon-wire.jsonl
# Release
scripts/.release-work
-scripts/.finalize-work
\ No newline at end of file
+scripts/.finalize-work
+# e2e failure diagnostics — collected on a red run, never committed.
+e2e-logs/
diff --git a/CLAUDE.md b/CLAUDE.md
index 517b7bd..2809520 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -127,6 +127,18 @@ Keep the changelog current as part of the change, not as an afterthought. Writte
- **The `CHANGELOG.md` at the root of a source tarball is made during packaging.** ASF convention puts one there for release reviewers, so `scripts/release.sh` copies `docs/changelog/<version>.md` to the tarball root as it assembles it. It is a packaging artifact — do not re-create it in git.
- **One line per paragraph and per list item — do not hard-wrap prose.** A GitHub **Release** body (rendered by the same engine as issue / PR / discussion comments) turns every single newline into a literal `<br>` (GFM hard line breaks), so a paragraph hard-wrapped at ~80 cols renders as a ragged column of short lines with a sea of right-hand whitespace. The repo file view collapses those same newlines to spaces, so the damage is invisible there — you only see it on the release page. Kee [...]
+## Naming and wording
+
+Code and prose are read by people who already know this ecosystem. **Use the word they already have for the thing.** Kubernetes, Istio, Envoy, Prometheus and OAP have settled vocabularies; borrowing from them makes a comment searchable, translatable and unambiguous, while a coined term forces every reader to learn a private language first.
+
+- **Reach for the upstream noun before inventing one.** Kubernetes says *condition*, *readiness probe*, *precondition*, *reconcile*; Prometheus says *scrape*, *target*, *series*, *label*; Envoy says *listener*, *cluster*, *filter*; OAP says *entity*, *scope*, *layer*, *metric*. If a reader could grep the term in upstream docs and land somewhere useful, it is the right term.
+- **Prefer the plain description over the metaphor.** "Check the roster is populated first" beats "gate on the roster". Metaphors read as precise to the person who picked them and as vague to everyone else — and they translate badly, which matters in a project shipping seven locales.
+- **A term must survive being read literally.** Someone will take it at face value: `gate` implies something that opens and closes, `smoke` implies a scope nobody defined, `spine` implies a structure that does not exist in the code. If the literal reading misleads, the word is wrong.
+- **Name the thing by what it does, not by how it feels.** `wait-for-banyandb`, `readiness check`, `retry budget`, `fail-fast marker` — each says what happens. Judge a name by whether a reviewer who has never seen the file can predict the behaviour from it.
+- **One name per concept, everywhere.** The same idea in a comment, a doc, a test name and a log line should use the same word. Two names for one thing reads as two things.
+
+This applies to prose as much as identifiers: file comments, `docs/`, changelog entries, commit messages, test names and CI output are all read by people deciding whether to trust the code.
+
## Common AI failure modes to avoid
1. **Skipping the read.** If you change a route, composable, store, or template without reading it end-to-end first, you will break something subtle. Read first, every time.
diff --git a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
index 1115b52..64c5436 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
@@ -556,11 +556,21 @@ const noPublishedVersion = computed<boolean>(
// `sourcesReady` so the editor doesn't visibly flip from bundled to
// remote on first mount. Once sources are ready the watcher runs
// every time the operator picks a different overview.
+//
+// The dirty guard applies ONLY to a re-fetch of the SAME overview — it is
+// there so a background detail refetch cannot clobber in-progress edits. It
+// must NOT survive a switch to a different overview: skipping the seed there
+// leaves the previous overview's draft in the editor, where it then reads as
+// (and would be saved as) the newly-selected one. Tracking the last-seeded id
+// is what separates the two triggers.
+const lastSeededId = ref<string>('');
watch(
[selectedId, () => detailQuery.data.value],
() => {
if (!sourcesReady.value) return;
- if (isDirty.value) return;
+ const switched = selectedId.value !== lastSeededId.value;
+ if (isDirty.value && !switched) return;
+ lastSeededId.value = selectedId.value;
seedEditor();
},
{ immediate: true },
@@ -570,6 +580,7 @@ watch(
// run the seed for the currently-selected overview once.
watch(sourcesReady, (ready, wasReady) => {
if (ready && !wasReady && selectedId.value && !isDirty.value) {
+ lastSeededId.value = selectedId.value;
seedEditor();
}
});
diff --git a/docs/changelog/1.0.0.md b/docs/changelog/1.0.0.md
index bf446f4..20b9b76 100644
--- a/docs/changelog/1.0.0.md
+++ b/docs/changelog/1.0.0.md
@@ -63,6 +63,8 @@
### Traces & logs
- **Custom time ranges on the Traces and Logs tabs now return results when your browser and the OAP server sit in different timezones.** A custom range (and the metric→trace drill's centered window) was sent as a browser-local wall-clock string that the server re-read in its own timezone — so on a UTC-container deployment the window shifted by your UTC offset and came back empty, while the rolling presets kept working. Both paths now send absolute timestamps and the server applies the OA [...]
+- **An unsaved overview widget no longer follows you to another dashboard.** Adding a widget in **Overview templates** and then switching to a different dashboard — without saving — left the half-finished widget in the editor, now attributed to the dashboard you had just opened; saving or pushing from there wrote it to the wrong template. The editor now reloads from the dashboard you selected, while still protecting edits in progress from a background refresh.
+
- **The layer Traces tab now reaches Zipkin on a Zipkin-tracing layer.** On layers whose template sources traces from Zipkin (mesh and Kubernetes), running a trace query from the layer's own Traces tab sent the Zipkin request to the GraphQL query port instead of the Zipkin query port, so the tab came back empty no matter what was stored — while the same traces were visible from the dedicated Zipkin trace view, which built its own connection correctly. Both paths now share one place that [...]
- **Opening a Zipkin trace's spans is now instant — no second query to OAP.** On a Zipkin-tracing layer (mesh / Kubernetes), clicking a trace in the results list rendered its span waterfall by re-fetching the trace by id — even though Zipkin's list response already contained every span. The list now carries those spans through, so the waterfall opens immediately from what's already on screen, one round-trip lighter. Opening a trace by a pasted id or a shared deep link still fetches it directly.
diff --git a/test/e2e/CLAUDE.md b/test/e2e/CLAUDE.md
index 101d9ad..3f3df2d 100644
--- a/test/e2e/CLAUDE.md
+++ b/test/e2e/CLAUDE.md
@@ -9,7 +9,7 @@ One CI job per case, all in parallel, from the matrix in `.github/workflows/e2e.
```
build images ──▶ infra-e2e run ──▶ setup ──▶ trigger ──▶ verify ──▶ cleanup
│ │ │
- │ │ ├─ gates (wire, cheap, §3.3)
+ │ │ ├─ readiness (wire, cheap, §3.3)
│ │ └─ browser (playwright.sh <project>)
│ └─ continuous traffic, one per case
└─ compose up + tools + data seeding
@@ -17,11 +17,13 @@ build images ──▶ infra-e2e run ──▶ setup ──▶ trigger ──▶
**Images first.** `make -C test/e2e images` builds the two things no registry carries: Horizon from *this* commit, and the demo services (upstream's service jar on upstream's agent image). Compose sets `pull_policy: never` for both, so a missing build fails loudly instead of silently testing someone else's image.
+**Rebuild it after touching app code.** The image is not rebuilt for you, and a stale one fails in the most misleading way available: the suite runs the PREVIOUS build, so a fix looks unfixed and its new spec looks wrong. If a case exercises a change under `apps/`, run `make images` before the case — the failure otherwise reads as a bad selector.
+
**Setup** announces the case's scope first — read out of the case table in `.github/workflows/e2e.yaml` and printed before anything boots, so a red run says what this case was responsible for before it says what broke. Then it brings up the stack — `cases/<name>/docker-compose.yml` extending `script/docker-compose/base-compose.yml` — and runs the case's remaining steps: install `yq` and `swctl`, pre-pull the browser image, and seed any data the single `trigger` cannot produce.
**Trigger** is one continuous HTTP loop, and a case gets exactly one. Anything else a fixture needs (log traffic, browser errors) is seeded in a setup step instead.
-**Verify** runs cases in order, and the order is load-bearing: readiness gates first so the wait lands on one cheap query, then the browser projects. A gate that fails tells you the fixture is not ready; a browser case that fails tells you the product is wrong.
+**Verify** runs cases in order, and the order is load-bearing: readiness checks first so the wait lands on one cheap query, then the browser projects. A readiness check that fails tells you the fixture is not ready; a browser case that fails tells you the product is wrong.
**Cleanup** is `on: always`.
@@ -31,7 +33,7 @@ build images ──▶ infra-e2e run ──▶ setup ──▶ trigger ──▶
One project per case, matching `specs/<project>/`. `auth` is the exception: it is nobody's project, it is a *dependency* of every other, signing in once and sharing the session.
-A project shared between cases couples them — a spec added for one becomes a requirement for all of them, including cases that do not run the gate it depends on. Prefer a project per case over a shared one.
+A project shared between cases couples them — a spec added for one becomes a requirement for all of them, including cases that never run the readiness check it depends on. Prefer a project per case over a shared one.
### On failure
@@ -120,11 +122,25 @@ The wire list is meant to stay **short and justified**, not to grow by habit. Tw
What each case actually covers is not recorded here — it lives in the case table in `.github/workflows/e2e.yaml`, which every case echoes to the console before it boots. A second copy in this file would be a list to maintain rather than a rule to follow, and it would go stale first.
-### 3.3 Readiness gates are not tests
+### 3.3 Readiness checks are not tests
Several verify cases exist purely to **wait**: metrics have persisted, the service relation has aggregated, alarms have fired. They query the wire because that is the cheapest way to ask, and they run before the browser cases so the wait lands on one query instead of a whole browser project retrying.
-They are synchronisation, not verification — and they should carry a comment saying what they are waiting for and why that wait is real. A gate that asserts a *feature* has quietly become a BFF test; move it.
+They are synchronisation, not verification — and they should carry a comment saying what they are waiting for and why that wait is real. A readiness check that asserts a *feature* has quietly become a BFF test; move it.
+
+### 3.4 A readiness check must prove the data the BROWSER will render
+
+`playwright.sh` records a project's first failure and refuses to re-run it, so a case that starts one persistence cycle early is red for good. That makes the readiness check ahead of a browser project decisive, and it has to ask for **exactly** what the page will draw. Three ways to get that subtly wrong, each of which has produced a readiness check that passed beside a red spec here:
+
+- **Checking the container, not the content.** A topology check that counts NODES is satisfied while the graph still has no edges; a deployment check that finds an EDGE is satisfied while every metric on it is null. Check for the thing the assertion needs.
+- **Accepting any value rather than a rendered one.** A metric series arrives as one entry per bucket, mostly `null` until data lands — a non-empty array is not data. Count NON-NULL points, and count them on the metric the page actually draws: accepting any key let the condition be met by `bytes` while the `write` and `query` rows the panel renders were still empty.
+- **Querying a different entity from the one on screen.** Metrics are scope-bound. A relation metric cannot be probed with a service name, and a labeled metric returns all-nulls until a label is selected — a probe missing either looks exactly like "no data yet".
+
+- **Sending a parameter the route ignores.** `/api/layer/:key/deployment` honours only the topbar triplet (`step` + `startMs` + `endMs`) and silently falls back to its own default otherwise, so a check written with `windowMinutes` describes a window the browser never asks for — and tuning that number changes nothing at all. Copy the request the page makes, not an equivalent-looking one.
+
+The rule of thumb: write the readiness check by asking *what would the spec have to see?*, then query for that, through Horizon when the entity is awkward to express (relations, labeled series) since the page's own endpoint already resolves it.
+
+And when a check keeps passing while the page stays empty, the answer may be that the fixture cannot yet produce what the assertion wants. Weakening the assertion to match what the fixture reliably proves — and saying so where the assertion lives — is honest; leaving a test that only passes when data happens to land is not.
## 4. When something fails, read the logs
diff --git a/test/e2e/README.md b/test/e2e/README.md
index c135733..2166bdc 100644
--- a/test/e2e/README.md
+++ b/test/e2e/README.md
@@ -2,7 +2,7 @@
Runs Horizon against a **real OAP** on **BanyanDB**, fed by an instrumented demo app, and verifies both halves of the product: the BFF's HTTP surface and the rendered UI in a headless browser.
-This is the gate that unit tests and `tsc` cannot be. A green type-check proves the code compiles; it does not prove a widget resolves its MQE against live data, that the trace list renders what the BFF returned, or that a view mounts at all.
+This is the check that unit tests and `tsc` cannot be. A green type-check proves the code compiles; it does not prove a widget resolves its MQE against live data, that the trace list renders what the BFF returned, or that a view mounts at all.
Driven by [skywalking-infra-e2e](https://github.com/apache/skywalking-infra-e2e) — the same harness every other e2e in the SkyWalking project uses.
@@ -106,7 +106,7 @@ Cases needing **rover / eBPF** cannot run on macOS: eBPF needs a Linux kernel, a
If the case needs UI coverage, add specs under `playwright/specs/` and call `script/prepare/playwright.sh <project>` from a verify case.
-**A Playwright project shared between cases couples them: a spec added for one becomes a requirement for every case that runs it,** including cases that never run the readiness gate it depends on. So each case has its OWN project matching `specs/<project>/` — `es` runs only the pre-v2 trace spec, `browser` only the Browser Errors spec, `istio` the mesh specs. `auth` is the exception: it is nobody's project and everybody's dependency, signing in once and sharing the session.
+**A Playwright project shared between cases couples them: a spec added for one becomes a requirement for every case that runs it,** including cases that never run the readiness check it depends on. So each case has its OWN project matching `specs/<project>/` — `es` runs only the pre-v2 trace spec, `browser` only the Browser Errors spec, `istio` the mesh specs. `auth` is the exception: it is nobody's project and everybody's dependency, signing in once and sharing the session.
## Writing a spec that will not flake
diff --git a/test/e2e/cases/admin/e2e.yaml b/test/e2e/cases/admin/e2e.yaml
index 97f6b23..8d5134a 100644
--- a/test/e2e/cases/admin/e2e.yaml
+++ b/test/e2e/cases/admin/e2e.yaml
@@ -89,7 +89,7 @@ verify:
# 5. A DEBUG SESSION needs the rule LIVE, not merely pushed. OAP answers
# rule_not_found until the apply reaches APPLIED, which is why this
- # follows the phase gate above rather than the push.
+ # follows the phase check above rather than the push.
- query: |
bash test/e2e/script/prepare/dsl-lifecycle.sh \
"http://${horizon_host}:${horizon_8081}" debug-start \
diff --git a/test/e2e/cases/browser/e2e.yaml b/test/e2e/cases/browser/e2e.yaml
index 0e135da..7eaef57 100644
--- a/test/e2e/cases/browser/e2e.yaml
+++ b/test/e2e/cases/browser/e2e.yaml
@@ -56,7 +56,7 @@ verify:
| yq -P '{"reachable": .reachable, "hasServices": (.services | length > 0)}'
expected: expected/browser-roster.yml
- # 2. GATE: the seeded RECORDS are queryable, not merely the service.
+ # 2. READINESS: the seeded RECORDS are queryable, not merely the service.
# The roster above appears as soon as perfData registers the service,
# which is a different write from the error logs the tab reads — so
# the roster can be green while the list is still empty. That matters
diff --git a/test/e2e/cases/core/e2e.yaml b/test/e2e/cases/core/e2e.yaml
index 2e67844..a27d1c2 100644
--- a/test/e2e/cases/core/e2e.yaml
+++ b/test/e2e/cases/core/e2e.yaml
@@ -65,13 +65,13 @@ verify:
# minute after the agents register. This retry budget is that window; it is
# why the specs themselves need no long polls.
retry:
- # Sized for the slowest gate, which is alarms: OAP's bundled rules need a
+ # Sized for the slowest check, which is alarms: OAP's bundled rules need a
# couple of minutes of traffic before they trip. The budget only costs
- # time when a case actually retries, so the earlier gates are unaffected.
+ # time when a case actually retries, so the earlier checks are unaffected.
count: 60
interval: 5s
cases:
- # 1. GATE + triage split, not a test: asks OAP directly what it knows. If
+ # 1. CHECK FIRST + triage split, not a test: asks OAP directly what it knows. If
# this fails the demo app or the agent is broken, and no amount of
# reading Horizon will show it — the distinction that costs the most
# time when a browser suite goes red.
@@ -88,11 +88,11 @@ verify:
| yq -P '{"reachable": .reachable, "demoServices": [.services[] | select(.name == "e2e-service-provider" or .name == "e2e-service-consumer")] | length}'
expected: expected/horizon-roster.yml
- # 3. Metrics have actually landed — the gate the browser cases depend on.
+ # 3. Metrics have actually landed — the check the browser cases depend on.
# Services register within seconds, but OAP persists metrics on a ~25s
# cycle and aggregates into minute buckets, so for a while after the
# roster is populated every metric widget is still legitimately empty.
- # Without this gate that wait falls to the `ui` case, which absorbs it
+ # Without this check that wait falls to the `ui` case, which absorbs it
# by RETRYING — re-running the whole browser project each time, and
# turning a timing question into what looks like a flaky UI.
# service_cpm is what the layer dashboard's traffic widgets read.
@@ -103,7 +103,7 @@ verify:
# 4. The consumer -> provider EDGE exists, not merely both services.
# service_cpm above is satisfied by the log burst alone, which hits the
- # provider directly — so the metrics gate can open while the topology
+ # provider directly — so the metrics check can open while the topology
# still has no relation in it. Relation metrics aggregate on their own
# cycle from the trigger traffic, and the topology assertions need that
# to have landed. Missing this is what turned CI red while the same
@@ -114,8 +114,21 @@ verify:
| yq -P '{"demoNodes": [.nodes[] | select(.name == "e2e-service-provider" or .name == "e2e-service-consumer")] | length, "hasCalls": (.calls | length > 0)}'
expected: expected/has-topology.yml
+ # 4b. READINESS: TRACES are queryable. service_cpm above proves metrics
+ # persisted, which is a different write — a segment can lag the
+ # metric it produced. Five renderers hang off a trace (scatter,
+ # waterfall, tree, statistics, span modal) plus the cold deep link,
+ # and playwright.sh will not re-run the project after a first
+ # failure, so the wait belongs here rather than inside the browser.
+ - query: |
+ bash test/e2e/script/prepare/horizon-get.sh \
+ "http://${horizon_host}:${horizon_8081}" /api/layer/general/traces \
+ '{"windowMinutes":30,"pageSize":10,"source":"native"}' \
+ | yq -P '{"hasTraces": ((.native.traces // []) | length > 0)}'
+ expected: expected/has-traces.yml
+
# 5. Logs reached OAP and survived the LAL pipeline. Separate from the
- # metrics gate because a log can fail for its own reasons — a missing
+ # metrics check because a log can fail for its own reasons — a missing
# LAL rule drops every record while traces and metrics stay perfect.
- query: |
swctl --display json --base-url=http://${oap_host}:${oap_12800}/graphql \
diff --git a/test/e2e/cases/core/expected/has-traces.yml b/test/e2e/cases/core/expected/has-traces.yml
new file mode 100644
index 0000000..ce000f8
--- /dev/null
+++ b/test/e2e/cases/core/expected/has-traces.yml
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+hasTraces: true
diff --git a/test/e2e/cases/deployment/app.yaml b/test/e2e/cases/deployment/app.yaml
new file mode 100644
index 0000000..cf26982
--- /dev/null
+++ b/test/e2e/cases/deployment/app.yaml
@@ -0,0 +1,108 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Write load for the cluster, deliberately minimal.
+#
+# This case is about BanyanDB's own deployment, not about the app: BanyanDB
+# reports nothing interesting while idle, so one instrumented service plus a
+# caller is enough to keep OAP writing. The compose cases run the same image.
+
+apiVersion: v1
+kind: Service
+metadata:
+ name: provider
+ namespace: skywalking
+spec:
+ selector:
+ app: provider
+ ports:
+ - port: 9090
+ targetPort: 9090
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: provider
+ namespace: skywalking
+ labels:
+ app: provider
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: provider
+ template:
+ metadata:
+ labels:
+ app: provider
+ spec:
+ containers:
+ - name: provider
+ image: __IMAGE__
+ imagePullPolicy: Never
+ ports:
+ - containerPort: 9090
+ env:
+ - name: SW_AGENT_COLLECTOR_BACKEND_SERVICES
+ value: skywalking-oap.skywalking:11800
+ - name: SW_AGENT_NAME
+ value: e2e-service-provider
+ - name: SW_AGENT_INSTANCE_NAME
+ value: provider1
+ - name: SW_LOGGING_OUTPUT
+ value: CONSOLE
+ - name: SW_METER_ACTIVE
+ value: 'false'
+ readinessProbe:
+ tcpSocket:
+ port: 9090
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ failureThreshold: 60
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: trafficgenerator
+ namespace: skywalking
+ labels:
+ app: trafficgenerator
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: trafficgenerator
+ template:
+ metadata:
+ labels:
+ app: trafficgenerator
+ spec:
+ containers:
+ - name: trafficgenerator
+ image: __TRAFFIC_IMAGE__
+ command: ["/bin/sh", "-c", "--"]
+ args:
+ - |
+ while true; do
+ curl -s -o /dev/null -X POST http://provider.skywalking:9090/users \
+ -H 'Content-Type: application/json' \
+ -d '{"id":"123","name":"skywalking"}'
+ sleep 2
+ done
+ resources:
+ requests:
+ cpu: 0.1
diff --git a/test/e2e/cases/deployment/e2e.yaml b/test/e2e/cases/deployment/e2e.yaml
new file mode 100644
index 0000000..bfb4f1a
--- /dev/null
+++ b/test/e2e/cases/deployment/e2e.yaml
@@ -0,0 +1,192 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Deployment case — BanyanDB as a CLUSTER, deployed by its own helm chart with
+# FODC, so the Deployment tab has a real multi-role topology to draw.
+#
+# Nothing in the compose cases can produce this: a standalone BanyanDB has no
+# roles and no FODC agent, and the BANYANDB layer's metrics only exist because
+# an OTel collector scrapes the FODC proxy and pushes OTLP to OAP.
+#
+# Run it: make -C test/e2e run CASE=deployment
+#
+# No rover, no eBPF — this one runs anywhere kind runs, macOS included.
+
+setup:
+ env: kind
+ file: kind.yaml
+ timeout: 40m
+ init-system-environment: ../../script/env
+ kind:
+ import-images:
+ # Every image the cluster runs: the node has no guaranteed outbound
+ # access (see the proxy note in test/e2e/CLAUDE.md).
+ - ${HORIZON_E2E_IMAGE}
+ - horizon-e2e-demo-provider:${SW_E2E_SERVICE_COMMIT}
+ - ghcr.io/apache/skywalking/oap:${SW_OAP_COMMIT}
+ # CLUSTER mode runs the `-slim` image — the chart appends the suffix
+ # itself for liaison and data (cluster_*_statefulset.yaml), while
+ # standalone uses the plain tag. Side-load both: the plain one still
+ # backs anything non-clustered, and a missing side-load here is an
+ # ImagePullBackOff on a node with no outbound access.
+ - ghcr.io/apache/skywalking-banyandb:${SW_BANYANDB_COMMIT}
+ - ghcr.io/apache/skywalking-banyandb:${SW_BANYANDB_COMMIT}-slim
+ # FODC is two more images, both tagged with the SAME BanyanDB commit:
+ # an AGENT sidecar in every liaison/data pod (which is why those pods
+ # report 1/2 when it is missing) and the PROXY that aggregates them.
+ # They are the whole point of this case — the scrape target and the
+ # per-pod identity labels come from them.
+ - ghcr.io/apache/skywalking-banyandb-fodc-agent:${SW_BANYANDB_COMMIT}
+ - ghcr.io/apache/skywalking-banyandb-fodc-proxy:${SW_BANYANDB_COMMIT}
+ - ${SW_OTEL_COLLECTOR_IMAGE}
+ - ${TRAFFIC_GEN_IMAGE}
+ - curlimages/curl:latest
+ expose-ports:
+ - namespace: skywalking
+ resource: service/skywalking-oap
+ port: 12800
+ - namespace: skywalking
+ resource: service/horizon
+ port: 8081
+ steps:
+ # Scope first, so a red run shows what this case covers before it shows
+ # what broke. Read out of the case table in .github/workflows/e2e.yaml.
+ - name: coverage
+ command: bash test/e2e/script/prepare/echo-coverage.sh deployment
+ - name: set PATH
+ command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH
+ - name: install yq
+ command: bash test/e2e/script/prepare/install.sh yq
+ - name: install swctl
+ command: bash test/e2e/script/prepare/install.sh swctl
+ - name: install kubectl
+ command: bash test/e2e/script/prepare/install.sh kubectl
+ - name: install helm
+ command: bash test/e2e/script/prepare/install.sh helm
+ - name: prepare browser image
+ command: bash test/e2e/script/prepare/install.sh playwright
+
+ - name: install skywalking and banyandb
+ command: bash test/e2e/script/prepare/deployment-up.sh skywalking
+ wait:
+ - namespace: skywalking
+ resource: deployments/skywalking-oap
+ for: condition=available
+ - name: deploy the otel collector
+ command: bash test/e2e/script/prepare/deployment-up.sh collector
+ wait:
+ - namespace: skywalking
+ resource: deployments/otel-collector
+ for: condition=available
+ - name: deploy horizon
+ command: bash test/e2e/script/prepare/deployment-up.sh horizon
+ wait:
+ - namespace: skywalking
+ resource: deployments/horizon
+ for: condition=available
+ # Traffic runs in-cluster; kind has no load balancer for a host-side
+ # trigger to call. This is why the case has no `trigger` block.
+ - name: deploy the demo app and its traffic
+ command: bash test/e2e/script/prepare/deployment-up.sh app
+ wait:
+ - namespace: skywalking
+ resource: deployments/provider
+ for: condition=available
+
+verify:
+ # The scrape interval, OAP's OTel analysis and its persistence cycle stack
+ # up here — BanyanDB's own metrics are further from the wire than an
+ # agent's.
+ retry:
+ count: 60
+ interval: 10s
+ cases:
+ # 1. READINESS, not a test: has the collector -> OAP path produced the BANYANDB
+ # service at all? It is named after the `cluster` label the collector
+ # injects — unique to this fixture — so a miss here means the scrape,
+ # the job name or the scrape interval is wrong rather than anything
+ # about Horizon. `service ls` takes no --layer flag; matching the name
+ # is both sufficient and one less thing to get wrong.
+ - query: |
+ swctl --display json --base-url=http://${service_skywalking_oap_host}:${service_skywalking_oap_12800}/graphql \
+ service ls \
+ | yq -P '{"hasCluster": [.[] | select(.name == "e2e-banyandb")] | length}'
+ expected: expected/has-cluster.yml
+
+ # 2. READINESS: the per-NODE metrics exist, which is a different aggregation
+ # from the service above — the Deployment tab groups instances by the
+ # `node_role` / `node_type` attributes that ride on these samples, so
+ # the service can resolve while the tab still has nothing to draw.
+ #
+ # The id is looked up rather than derived: the route refuses a name
+ # without its serviceId (`incomplete_service`), because the UI always
+ # sends the pair the roster returned.
+ #
+ # The window is sent as the PAGE sends it — `step` + `startMs` +
+ # `endMs`, the topbar triplet, spanning the 1h default the interactive
+ # Deployment tab inherits. This route ignores `windowMinutes`
+ # altogether (it honours only the triplet and otherwise falls back to
+ # its own default), so a check written with `windowMinutes` silently
+ # describes a window the browser never asks for.
+ - query: |
+ bash test/e2e/script/prepare/horizon-get.sh \
+ "http://${service_horizon_host}:${service_horizon_8081}" \
+ "/api/layer/banyandb/deployment?service=e2e-banyandb&step=MINUTE&startMs=$(python3 -c 'import time;print(int(time.time()*1000)-3600000)')&endMs=$(python3 -c 'import time;print(int(time.time()*1000))')&serviceId=$( \
+ swctl --display json --base-url=http://${service_skywalking_oap_host}:${service_skywalking_oap_12800}/graphql \
+ service ls | yq e '.[] | select(.name == "e2e-banyandb") | .id' - \
+ )" \
+ | yq -P '{"hasNodes": ((.nodes // []) | length > 0)}'
+ expected: expected/has-nodes.yml
+
+ # 3. READINESS: the graph has an EDGE, not just nodes. The edge sparklines
+ # read `meter_banyandb_instance_relation_*`, which OAP binds to
+ # serviceInstanceRelation — source AND destination instance both
+ # required — so it is built on its own rule and only once traffic has
+ # flowed between the roles. Nodes can be complete with no edge at all,
+ # and the browser test has nothing to click then.
+ #
+ # Asked through Horizon rather than swctl for exactly that reason: a
+ # relation metric cannot be probed with a service name alone, and the
+ # deployment endpoint already resolves both ends.
+ #
+ # Three things have to line up, and each was learned by watching this
+ # check open on data the page could not draw:
+ # - the VALUES, not the edge: an edge exists from the topology alone;
+ # - NON-NULL values, not a non-empty array: a series is one entry per
+ # bucket and mostly null until data lands, and Sparkline renders no
+ # `.sparkline` element at all for an all-null series;
+ # - the metrics the panel actually DRAWS. `write` / `query` /`fwd` are
+ # the primary rows for the liaison->data and liaison->liaison pairs.
+ # Accepting any key let the check open on `bytes` or `err` while every
+ # rendered row was still empty.
+ - query: |
+ bash test/e2e/script/prepare/horizon-get.sh \
+ "http://${service_horizon_host}:${service_horizon_8081}" \
+ "/api/layer/banyandb/deployment?service=e2e-banyandb&step=MINUTE&startMs=$(python3 -c 'import time;print(int(time.time()*1000)-3600000)')&endMs=$(python3 -c 'import time;print(int(time.time()*1000))')&serviceId=$( \
+ swctl --display json --base-url=http://${service_skywalking_oap_host}:${service_skywalking_oap_12800}/graphql \
+ service ls | yq e '.[] | select(.name == "e2e-banyandb") | .id' - \
+ )" \
+ | yq -P '{"hasEdgeSeries": ([.calls[]? | (.clientMetricSeries // {}) | to_entries[]? | select(.key == "write" or .key == "query" or .key == "fwd") | .value // [] | map(select(. != null)) | select(length > 0)] | length > 0)}'
+ expected: expected/has-edge-series.yml
+
+ # 4. The browser half — the actual test.
+ - query: bash test/e2e/script/prepare/playwright.sh deployment
+ expected: expected/passed.yml
+
+cleanup:
+ on: always
diff --git a/test/e2e/cases/deployment/expected/has-cluster.yml b/test/e2e/cases/deployment/expected/has-cluster.yml
new file mode 100644
index 0000000..d75df6f
--- /dev/null
+++ b/test/e2e/cases/deployment/expected/has-cluster.yml
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+hasCluster: 1
diff --git a/test/e2e/cases/deployment/expected/has-edge-series.yml b/test/e2e/cases/deployment/expected/has-edge-series.yml
new file mode 100644
index 0000000..9c1a0dc
--- /dev/null
+++ b/test/e2e/cases/deployment/expected/has-edge-series.yml
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+hasEdgeSeries: true
diff --git a/test/e2e/cases/deployment/expected/has-nodes.yml b/test/e2e/cases/deployment/expected/has-nodes.yml
new file mode 100644
index 0000000..ab35c78
--- /dev/null
+++ b/test/e2e/cases/deployment/expected/has-nodes.yml
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+hasNodes: true
diff --git a/test/e2e/cases/deployment/expected/passed.yml b/test/e2e/cases/deployment/expected/passed.yml
new file mode 100644
index 0000000..d23d763
--- /dev/null
+++ b/test/e2e/cases/deployment/expected/passed.yml
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+passed: true
diff --git a/test/e2e/cases/deployment/horizon.yaml b/test/e2e/cases/deployment/horizon.yaml
new file mode 100644
index 0000000..2746233
--- /dev/null
+++ b/test/e2e/cases/deployment/horizon.yaml
@@ -0,0 +1,95 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Horizon inside the cluster, as the compose cases run it beside OAP.
+#
+# Identical in shape to the istio case's copy — kept per case rather than
+# shared because the two differ in which OAP endpoints they need, and a
+# shared template with per-case holes punched in it reads worse than two
+# short manifests.
+#
+# __PLACEHOLDERS__ are substituted by script/prepare/deployment-up.sh from
+# script/env, so the image tag and the e2e account have ONE definition shared
+# with every other case.
+#
+# imagePullPolicy: Never — the image is built from this checkout and side-
+# loaded into the node. A pull would silently test someone else's build, the
+# same guarantee `pull_policy: never` gives the compose cases.
+
+apiVersion: v1
+kind: Service
+metadata:
+ name: horizon
+ namespace: istio-system
+ labels:
+ app: horizon
+spec:
+ type: NodePort
+ selector:
+ app: horizon
+ ports:
+ - name: http
+ port: 8081
+ targetPort: 8081
+ # Fixed rather than allocated: the browser container builds its URL from
+ # the same pin in script/env, so both ends agree without discovery.
+ nodePort: __NODE_PORT__
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: horizon
+ namespace: istio-system
+ labels:
+ app: horizon
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: horizon
+ template:
+ metadata:
+ labels:
+ app: horizon
+ spec:
+ containers:
+ - name: horizon
+ image: __IMAGE__
+ imagePullPolicy: Never
+ ports:
+ - containerPort: 8081
+ env:
+ - name: HORIZON_OAP_QUERY_URL
+ value: http://skywalking-oap.istio-system:12800
+ - name: HORIZON_OAP_ADMIN_URL
+ value: http://skywalking-oap.istio-system:17128
+ # Stated explicitly because it is the contract under test: Horizon
+ # seeds the bundle into OAP's ui_template store at boot and then
+ # renders only what OAP holds.
+ - name: HORIZON_TEMPLATES_MODE
+ value: live
+ - name: HORIZON_AUTH_BACKEND
+ value: local
+ - name: HORIZON_AUTH_LOCAL_USERS
+ value: '__AUTH_USERS__'
+ readinessProbe:
+ httpGet:
+ path: /api/health
+ port: 8081
+ initialDelaySeconds: 5
+ periodSeconds: 5
+ failureThreshold: 60
diff --git a/test/e2e/cases/deployment/kind.yaml b/test/e2e/cases/deployment/kind.yaml
new file mode 100644
index 0000000..b4da7d5
--- /dev/null
+++ b/test/e2e/cases/deployment/kind.yaml
@@ -0,0 +1,35 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Single-node cluster for the deployment scenario.
+#
+# The node container joins docker's `kind` network, which is how the browser
+# container reaches Horizon: it attaches to the same network and calls the
+# NodePort on the node by container name. No host port mapping is involved, so
+# a laptop run and a CI run address the fixture identically.
+#
+# The node image is written out in full rather than read from script/env: kind
+# config files are handed to kind verbatim, with none of the ${VAR} expansion
+# infra-e2e does elsewhere, so a reference here would be pulled literally.
+# It carries its own digest, so the k8s version and the image serving it
+# cannot drift apart.
+
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+nodes:
+ - role: control-plane
+ image: kindest/node:v1.28.15@sha256:a7c05c7ae043a0b8c818f5a06188bc2c4098f6cb59ca7d1856df00375d839251
diff --git a/test/e2e/cases/deployment/otel-collector.yaml b/test/e2e/cases/deployment/otel-collector.yaml
new file mode 100644
index 0000000..a77f422
--- /dev/null
+++ b/test/e2e/cases/deployment/otel-collector.yaml
@@ -0,0 +1,105 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# The collector that turns BanyanDB's self-observability into the metrics the
+# BANYANDB layer renders. Without it the layer exists and every widget is
+# empty — nothing else in the suite feeds OAP's OTel receiver.
+#
+# The shape is dictated by OAP's `otel-rules/banyandb/*` rules, not chosen:
+#
+# - The FODC PROXY is the single scrape target. Each BanyanDB pod runs a
+# FODC agent sidecar that polls that pod's own metrics ports and the proxy
+# aggregates them, so scraping the proxy sees the whole cluster with the
+# per-pod labels (pod_name, container_name, node_role, node_type) already
+# attached. Scraping the per-node :2121 endpoints instead would lose those
+# identity labels and produce instances the Deployment tab cannot group.
+#
+# - The job MUST be named `banyandb-monitoring`. The rules select on
+# `tags.job_name == 'banyandb-monitoring'`; any other name and OAP accepts
+# every sample and builds nothing from them.
+#
+# - `cluster` is the ONE label SkyWalking has to add, and it becomes the
+# BANYANDB service name. Everything else rides on the samples.
+#
+# __IMAGE__ and __CLUSTER__ are substituted from script/env.
+
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: otel-collector
+ namespace: skywalking
+data:
+ config.yaml: |
+ receivers:
+ prometheus:
+ config:
+ scrape_configs:
+ - job_name: banyandb-monitoring
+ metrics_path: /metrics
+ # MUST be finer than the rate windows in OAP's banyandb rules,
+ # which compute `.rate('PT15S')`. At a coarser interval every
+ # rate evaluates to 0, no samples are produced, and since the
+ # BANYANDB service is created BY those samples, the layer stays
+ # empty — indistinguishable from a scrape that never worked.
+ scrape_interval: 10s
+ static_configs:
+ - targets: ['skywalking-banyandb-fodc-proxy-http:17913']
+ labels:
+ cluster: __CLUSTER__
+ processors:
+ batch:
+ exporters:
+ otlp:
+ endpoint: skywalking-oap.skywalking:11800
+ tls:
+ insecure: true
+ service:
+ pipelines:
+ metrics:
+ receivers: [prometheus]
+ processors: [batch]
+ exporters: [otlp]
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: otel-collector
+ namespace: skywalking
+ labels:
+ app: otel-collector
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: otel-collector
+ template:
+ metadata:
+ labels:
+ app: otel-collector
+ spec:
+ containers:
+ - name: otel-collector
+ image: __IMAGE__
+ imagePullPolicy: IfNotPresent
+ args: ['--config=/conf/config.yaml']
+ volumeMounts:
+ - name: conf
+ mountPath: /conf
+ volumes:
+ - name: conf
+ configMap:
+ name: otel-collector
diff --git a/test/e2e/cases/es/e2e.yaml b/test/e2e/cases/es/e2e.yaml
index e2fd938..d6eacf5 100644
--- a/test/e2e/cases/es/e2e.yaml
+++ b/test/e2e/cases/es/e2e.yaml
@@ -58,7 +58,7 @@ verify:
count: 40
interval: 5s
cases:
- # A readiness gate, not a test (CLAUDE.md §3.3): OAP persists on a ~25s
+ # A readiness check, not a test (CLAUDE.md §3.3): OAP persists on a ~25s
# cycle, so without this the wait lands on the browser case, which absorbs
# it by retrying the whole project.
- query: |
@@ -73,7 +73,7 @@ verify:
#
# Deliberately the ONLY verification: re-running the shell and layer
# journeys on a second backend re-proves what storage does not affect.
- # GATE: traces are queryable. service_cpm above proves metrics persisted,
+ # READINESS: traces are queryable. service_cpm above proves metrics persisted,
# which is a DIFFERENT write — on ElasticSearch a trace segment can lag
# the metric it produced. The es project asserts only on traces, and
# playwright.sh will not re-run it after a first failure, so the wait has
diff --git a/test/e2e/cases/istio/e2e.yaml b/test/e2e/cases/istio/e2e.yaml
index ff6cae1..5449fcd 100644
--- a/test/e2e/cases/istio/e2e.yaml
+++ b/test/e2e/cases/istio/e2e.yaml
@@ -64,8 +64,10 @@ setup:
- ${HORIZON_E2E_IMAGE}
- ghcr.io/apache/skywalking/oap:${SW_OAP_COMMIT}
- ghcr.io/apache/skywalking-banyandb:${SW_BANYANDB_COMMIT}
- # The chart's wait-for-banyandb init container. Untagged upstream, and
- # its pull policy is IfNotPresent, so side-loading satisfies it.
+ # The chart's wait-for-banyandb init container asks for an UNTAGGED
+ # `image: curlimages/curl` with no value to override it, so the
+ # side-load must name `:latest`. The CONTENT is pinned instead —
+ # `make images` re-tags SW_CURL_IMAGE as this name.
- curlimages/curl:latest
- ${SW_ROVER_IMAGE}
- docker.io/istio/pilot:${ISTIO_VERSION}
@@ -80,7 +82,7 @@ setup:
- docker.io/istio/examples-bookinfo-reviews-v3:${BOOKINFO_VERSION}
- ${TRAFFIC_GEN_IMAGE}
expose-ports:
- # For the readiness gates only — the browser reaches Horizon over the
+ # For the readiness checks only — the browser reaches Horizon over the
# node's NodePort instead, so the browser half needs no host port.
- namespace: istio-system
resource: service/skywalking-oap
diff --git a/test/e2e/cases/istio/expected/has-mesh-calls.yml b/test/e2e/cases/istio/expected/has-mesh-calls.yml
new file mode 100644
index 0000000..67605a1
--- /dev/null
+++ b/test/e2e/cases/istio/expected/has-mesh-calls.yml
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+hasCalls: true
diff --git a/test/e2e/cases/istio/verify-mesh.yaml b/test/e2e/cases/istio/verify-mesh.yaml
index bfa35e8..1b9b225 100644
--- a/test/e2e/cases/istio/verify-mesh.yaml
+++ b/test/e2e/cases/istio/verify-mesh.yaml
@@ -18,7 +18,7 @@
# Shared by both istio variants: everything the mesh produces without rover.
cases:
- # 1. GATE, not a test: does OAP know the mesh at all? bookinfo's productpage
+ # 1. READINESS, not a test: does OAP know the mesh at all? bookinfo's productpage
# only exists once Envoy's access logs have been analysed into entities,
# so this failing means the mesh or ALS is broken, not Horizon.
- query: |
@@ -26,7 +26,7 @@ cases:
| yq -P '{"meshServices": [.[] | select(.name == "productpage" or .name == "reviews")] | length}'
expected: expected/mesh-services.yml
- # 2. GATE: the mesh_dp card widgets read Envoy's own stats, reported through
+ # 2. READINESS: the mesh_dp card widgets read Envoy's own stats, reported through
# the metrics service — a DIFFERENT pipeline from the access logs above,
# so the topology can be complete while every card is still empty.
#
@@ -43,7 +43,7 @@ cases:
)
expected: expected/metrics-has-value.yml
- # 3. GATE: Horizon can see the mesh roster too, absorbing the catalog TTL
+ # 3. READINESS: Horizon can see the mesh roster too, absorbing the catalog TTL
# before the browser project pays for it.
- query: |
bash test/e2e/script/prepare/horizon-get.sh \
@@ -51,7 +51,18 @@ cases:
| yq -P '{"reachable": .reachable, "meshServices": [.services[] | select(.name == "productpage")] | length}'
expected: expected/horizon-mesh-roster.yml
- # 4. GATE: Zipkin traces are queryable. Everything above is derived from
+ # 3b. READINESS: the mesh topology has EDGES. The service roster above proves
+ # ALS produced entities; the call graph is a different aggregation and
+ # lands later, so the spec's `.sm-edge` assertion can fail against a
+ # mesh that is merely still assembling.
+ - query: |
+ bash test/e2e/script/prepare/horizon-get.sh \
+ "http://${service_horizon_host}:${service_horizon_8081}" \
+ "/api/layer/mesh/topology?windowMinutes=30" \
+ | yq -P '{"hasCalls": ((.calls // []) | length > 0)}'
+ expected: expected/has-mesh-calls.yml
+
+ # 4. READINESS: Zipkin traces are queryable. Everything above is derived from
# ALS, which is a different pipeline from Envoy's tracing — the mesh can
# be fully mapped while no span has been reported yet. The istio project
# asserts on the Zipkin tab and will not be re-run after a first failure.
diff --git a/test/e2e/cases/istio/verify-profiling.yaml b/test/e2e/cases/istio/verify-profiling.yaml
index cb97f0d..be69ca6 100644
--- a/test/e2e/cases/istio/verify-profiling.yaml
+++ b/test/e2e/cases/istio/verify-profiling.yaml
@@ -25,7 +25,7 @@
# nothing, so nothing is skipped where it can actually run.
cases:
- # GATE: rover reported processes for the service.
+ # READINESS: rover reported processes for the service.
#
# The profiling SPECS below assert only that their pages mount, so strictly
# they do not need this. It stays because it is the one check that proves
@@ -34,7 +34,7 @@ cases:
# to be "kind + Istio + rover".
- query: |
if ! bash test/e2e/script/prepare/profiling-enabled.sh; then
- echo "SKIPPED: rover process gate" >&2
+ echo "SKIPPED: rover process check" >&2
echo "hasProcesses: true"
else
bash test/e2e/script/prepare/rover-processes.sh \
diff --git a/test/e2e/playwright/playwright.config.ts b/test/e2e/playwright/playwright.config.ts
index 2985897..ba5567c 100644
--- a/test/e2e/playwright/playwright.config.ts
+++ b/test/e2e/playwright/playwright.config.ts
@@ -130,6 +130,14 @@ export default defineConfig({
dependencies: ['auth'],
use: { ...devices['Desktop Chrome'], storageState: AUTH_STATE },
},
+ {
+ // BanyanDB's own deployment — its own project because the BANYANDB
+ // layer only exists where a FODC-reporting cluster is running.
+ name: 'deployment',
+ testMatch: '**/specs/deployment/*.spec.ts',
+ dependencies: ['auth'],
+ use: { ...devices['Desktop Chrome'], storageState: AUTH_STATE },
+ },
{
name: 'ui',
testMatch: '**/specs/ui/*.spec.ts',
diff --git a/test/e2e/playwright/specs/admin/overview-draft.spec.ts b/test/e2e/playwright/specs/admin/overview-draft.spec.ts
new file mode 100644
index 0000000..f40407d
--- /dev/null
+++ b/test/e2e/playwright/specs/admin/overview-draft.spec.ts
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { test, expect } from '../support/diagnostics.js';
+
+// An unsaved widget must not follow the operator to another dashboard.
+//
+// The editor's seed watch fires for two different reasons — a background
+// detail re-fetch, and the operator picking a different dashboard — and its
+// dirty guard exists for the first: a refetch must not clobber edits in
+// progress. Applied to the second it leaves the PREVIOUS dashboard's draft in
+// the editor, where it reads as (and would be saved as) the newly selected
+// one.
+//
+// This is the `admin` case rather than `core` because it edits, and it is a
+// browser test rather than a wire one because the leak lives entirely in
+// editor state — nothing reaches OAP until a push, so no request could show
+// it.
+
+test('an unsaved widget does not follow a dashboard switch', async ({ page, pageErrors }) => {
+ await page.goto('/admin/overview-templates');
+ await expect(page.locator('.ot')).toBeVisible({ timeout: 45_000 });
+
+ const widgets = page.locator('.ot__pv-grid .ot__cw');
+ const picker = page.locator('.tp button.tp-btn').first();
+
+ async function pick(index: number): Promise<string> {
+ await picker.click();
+ const rows = page.locator('.tp-pop .tp-row');
+ await expect(rows.first()).toBeVisible({ timeout: 45_000 });
+ const row = rows.nth(index);
+ await expect(row, 'this fixture needs two dashboards to switch between').toBeVisible();
+ const label = (await row.locator('.tp-name').innerText()).trim();
+ await row.click();
+ await expect(picker).toContainText(label);
+ return label;
+ }
+
+ // Record the TARGET's own shape first, so the assertion after the switch is
+ // an equality against a known dashboard rather than "not the leaked
+ // number" — a blank editor or the wrong dashboard would satisfy that.
+ const target = await pick(1);
+ await expect(widgets.first()).toBeVisible({ timeout: 45_000 });
+ const targetCount = await widgets.count();
+
+ const origin = await pick(0);
+ await expect(widgets.first()).toBeVisible({ timeout: 45_000 });
+ const originCount = await widgets.count();
+
+ // Add a widget and leave it UNSAVED — no "Save to browser", no push.
+ await page.locator('button.ot__add-trigger').click();
+ await expect(page.locator('.ot__composer')).toBeVisible();
+ await page.locator('.ot__composer button.ot__btn--primary').click();
+ await expect.poll(async () => widgets.count(), { timeout: 45_000 }).toBe(originCount + 1);
+
+ // Switch to the other dashboard. The editor must reseed from IT.
+ await pick(1);
+
+ await expect(picker, `the picker should now read ${target}, not ${origin}`).toContainText(target);
+ await expect(widgets.first(), 'the editor went blank instead of reseeding').toBeVisible({
+ timeout: 45_000,
+ });
+ await expect
+ .poll(async () => widgets.count(), { timeout: 45_000 })
+ .toBe(targetCount);
+
+ expect(pageErrors, 'an uncaught error during mount blanks the page').toEqual([]);
+});
diff --git a/test/e2e/playwright/specs/browser/inspect-popout.spec.ts b/test/e2e/playwright/specs/browser/inspect-popout.spec.ts
new file mode 100644
index 0000000..d773977
--- /dev/null
+++ b/test/e2e/playwright/specs/browser/inspect-popout.spec.ts
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { test, expect } from '../support/diagnostics.js';
+
+// Log inspect, and the browser-error popout that only it renders.
+//
+// The Browser Errors TAB has its own inline expander; this popout is a
+// different component reached from a different page, so the tab passing says
+// nothing about it. Both read the same seeded errors, which is why this case
+// is where it belongs.
+//
+// It also gives the Explore/inspect page its first coverage of any kind.
+
+test('log inspect opens a browser error in its popout', async ({ page, pageErrors }) => {
+ await page.goto('/operate/log-inspect');
+
+ // Three sources share this page; Browser swaps the entity picker to a
+ // browser-service one, so the source has to be chosen before anything can
+ // resolve.
+ await page.getByRole('button', { name: 'Browser', exact: true }).click();
+
+ const run = page.getByRole('button', { name: 'Run query' });
+ await expect(run).toBeEnabled({ timeout: 60_000 });
+ await run.click();
+
+ // Explore renders browser errors through its OWN list component, not the
+ // layer tab's log stream — `.be-row`, not `.lg-row`. Same records, a
+ // different renderer, which is the reason this page needs its own spec.
+ const rows = page.locator('.be-stream .be-row');
+ await expect(rows.first()).toBeVisible({ timeout: 60_000 });
+ await rows.first().click();
+
+ // The popout carries the whole record — the stack is what an operator came
+ // for, and it is the part the row itself never shows.
+ const popout = page.locator('.be');
+ await expect(popout).toBeVisible({ timeout: 45_000 });
+ await expect(popout.locator('.be-head')).toBeVisible();
+ // The SEEDED stack. `.be-pre` falls back to "(no stack)" when the record
+ // carries none, so any-text would pass on an error whose stack was lost in
+ // transit — which is the half of the record this popout exists to show.
+ await expect(popout.locator('.be-pre')).toContainText('TypeError');
+
+ expect(pageErrors, 'an uncaught error during mount blanks the page').toEqual([]);
+});
diff --git a/test/e2e/playwright/specs/deployment/banyandb.spec.ts b/test/e2e/playwright/specs/deployment/banyandb.spec.ts
new file mode 100644
index 0000000..7b9064c
--- /dev/null
+++ b/test/e2e/playwright/specs/deployment/banyandb.spec.ts
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { test, expect } from '../support/diagnostics.js';
+import { BANYANDB_LAYER, BANYANDB_CLUSTER, BANYANDB_ROLES } from '../fixture.js';
+
+// The BANYANDB layer, fed by BanyanDB's own self-observability: a FODC agent
+// beside every node, a proxy aggregating them, an OTel collector scraping the
+// proxy, and OAP's banyandb rules turning that into metrics. Nothing shorter
+// produces it — which is why this deployment exists.
+//
+// It is also the only case that renders the `table` widget form.
+
+test('the banyandb layer resolves the cluster', async ({ page, pageErrors }) => {
+ await page.goto(`/layer/${BANYANDB_LAYER}/service`);
+
+ // The service name IS the `cluster` label the collector injects, so this
+ // failing means the scrape reached OAP under a different identity — not
+ // that the page is broken.
+ const selected = page.locator('.service-row .svc-name');
+ await expect(selected).toBeVisible({ timeout: 60_000 });
+ await expect(selected).toHaveText(BANYANDB_CLUSTER);
+
+ expect(pageErrors, 'an uncaught error during mount blanks the page').toEqual([]);
+});
+
+test('the service dashboard renders the table widget form', async ({ page, pageErrors }) => {
+ await page.goto(`/layer/${BANYANDB_LAYER}/service`);
+ await expect(page.locator('.widget').first()).toBeVisible({ timeout: 60_000 });
+
+ // `table` is declared by no template any other case reaches, so this is the
+ // first time TableWidget renders in the suite at all. The type class says
+ // the renderer dispatched; the table element says it actually drew.
+ const tile = page
+ .locator('.widget')
+ .filter({ has: page.locator('.w-head-title').getByText('Containers by Role', { exact: true }) })
+ .first();
+ await expect(tile, 'banyandb.service declares a table widget; it did not render').toBeVisible({
+ timeout: 60_000,
+ });
+ await expect(tile.locator('.w-body')).toHaveClass(/\btype-table\b/);
+
+ // BODY rows, not the table element: TableWidget renders its `<thead>`
+ // regardless of the row set, so `.tw__table` alone would pass on an empty
+ // table — and the checks above cover the deployment nodes and edge series,
+ // not this widget's own `meter_banyandb_reporting_instances`. Naming a role
+ // ties the assertion to real cluster data rather than to any row at all.
+ const rows = tile.locator('.tw__table tbody tr');
+ await expect(rows.first()).toBeVisible({ timeout: 60_000 });
+ await expect(tile.locator('.tw__table tbody')).toContainText(/liaison|data/);
+
+ expect(pageErrors).toEqual([]);
+});
+
+test('the deployment tab draws the cluster by role', async ({ page, pageErrors }) => {
+ await page.goto(`/layer/${BANYANDB_LAYER}/deployment`);
+
+ // The tab groups instances by the `node_role` / `node_type` attributes that
+ // ride on the FODC samples. A cluster install has more than one role, which
+ // is the whole reason this case runs BanyanDB clustered rather than
+ // standalone — a single box would prove nothing about the grouping.
+ const roles = page.locator('.lg-role-name');
+ await expect(roles.first()).toBeVisible({ timeout: 60_000 });
+ for (const role of BANYANDB_ROLES) {
+ await expect(
+ roles.filter({ hasText: role }).first(),
+ `the ${role} role is missing from the deployment graph`,
+ ).toBeVisible({ timeout: 60_000 });
+ }
+
+ // Nodes, not just the legend: the graph is what an operator reads.
+ await expect(page.locator('.node-label').first()).toBeVisible({ timeout: 60_000 });
+
+ expect(pageErrors).toEqual([]);
+});
+
+test('clicking a deployment edge opens its client/server sparklines', async ({
+ page,
+ pageErrors,
+}) => {
+ await page.goto(`/layer/${BANYANDB_LAYER}/deployment`);
+ await expect(page.locator('.lg-role-name').first()).toBeVisible({ timeout: 60_000 });
+
+ // The edge is the whole reason this case runs BanyanDB clustered: liaison
+ // and data are separate roles, so `roleToRole` has a pair to draw between.
+ const edges = page.locator('g.sit-edge');
+ await expect(edges.first(), 'no edge in the deployment graph — single-role cluster?').toBeVisible({
+ timeout: 60_000,
+ });
+
+ // EVERY edge, not the first one: which edge carries traffic depends on
+ // where writes landed, and it differs between runs.
+ //
+ // `dispatchEvent` rather than `click`: the edge is an SVG `<g>` whose
+ // visible geometry is a thin stroke, so Playwright's centre-point
+ // actionability check lands on whatever is behind it and waits forever.
+ // The handler is bound to the `<g>` itself, so dispatching reaches it.
+ const count = await edges.count();
+ let rows = 0;
+ for (let i = 0; i < count && rows === 0; i += 1) {
+ await edges.nth(i).dispatchEvent('click');
+ await expect(page.locator('.ip-edge-rows').first()).toBeVisible({ timeout: 60_000 });
+ rows = await page.locator('.ip-edge-rows .ip-edge-row').count();
+ }
+
+ // The ROWS are what this proves: one per metric id the template declares
+ // for the pair, pairing `lineClient` (measured at the caller) with
+ // `lineServer` (measured at the callee). That is the edge configuration
+ // reaching the panel.
+ //
+ // The Sparkline itself is deliberately NOT asserted. It renders no
+ // `.sparkline` element for an all-null series, and on a cluster minutes old
+ // the relation metrics are still sparse — the readiness check ahead of this
+ // sees values through the same endpoint the page uses, yet the rendered
+ // rows are frequently still `—`. Requiring a drawn chart here made the case
+ // fail on a healthy fixture, which is exactly what §7 warns against. What
+ // is missing is a fixture that GUARANTEES sustained liaison<->data traffic,
+ // not a stricter assertion.
+ expect(rows, `no edge of ${count} rendered its metric rows`).toBeGreaterThan(0);
+
+ expect(pageErrors, 'an uncaught error during mount blanks the page').toEqual([]);
+});
diff --git a/test/e2e/playwright/specs/fixture.ts b/test/e2e/playwright/specs/fixture.ts
index 2595ea6..f375284 100644
--- a/test/e2e/playwright/specs/fixture.ts
+++ b/test/e2e/playwright/specs/fixture.ts
@@ -86,3 +86,18 @@ export const MESH_PEERS = ['productpage', 'reviews', 'details'];
export const MESH_DP_LAYER = 'mesh_dp';
/** MESH_DP names services `<workload>.<namespace>`; bookinfo runs in default. */
export const MESH_DP_SUFFIX = '\\.default';
+
+/**
+ * The deployment fixture: BanyanDB running as a CLUSTER, reporting its own
+ * observability through FODC.
+ *
+ * The service name is the `cluster` label the OTel collector injects — OAP's
+ * banyandb rules build the BANYANDB service from it, so it is fixture
+ * configuration rather than anything BanyanDB chooses.
+ *
+ * The roles come from the `node_role` attribute on those samples. A clustered
+ * install has more than one; a standalone node would have nothing to group.
+ */
+export const BANYANDB_LAYER = 'banyandb';
+export const BANYANDB_CLUSTER = 'e2e-banyandb';
+export const BANYANDB_ROLES = ['liaison', 'data'];
diff --git a/test/e2e/playwright/specs/istio-profiling/ebpf.spec.ts b/test/e2e/playwright/specs/istio-profiling/ebpf.spec.ts
index 631ecb6..fc64047 100644
--- a/test/e2e/playwright/specs/istio-profiling/ebpf.spec.ts
+++ b/test/e2e/playwright/specs/istio-profiling/ebpf.spec.ts
@@ -31,7 +31,7 @@ import { MESH_LAYER, MESH_PEERS } from '../fixture.js';
// buy is that the routes resolve and the views do not throw on mount against
// a deployment where rover is running.
//
-// The readiness gate beside them is what proves rover reported anything at
+// The readiness check beside them is what proves rover reported anything at
// all; it asks OAP directly.
test('the eBPF profiling page mounts and resolves a service', async ({ page, pageErrors }) => {
diff --git a/test/e2e/playwright/specs/istio/mesh.spec.ts b/test/e2e/playwright/specs/istio/mesh.spec.ts
index c1db05e..c1d6de1 100644
--- a/test/e2e/playwright/specs/istio/mesh.spec.ts
+++ b/test/e2e/playwright/specs/istio/mesh.spec.ts
@@ -70,7 +70,7 @@ test('the mesh topology draws the bookinfo call graph', async ({ page, pageError
test('the mesh_dp instance dashboard renders its card widgets', async ({ page, pageErrors }) => {
// Envoy's own stats, reported through the metrics service rather than ALS —
// a different pipeline from the topology above, which is why the readiness
- // gate for this one is separate.
+ // check for this one is separate.
//
// MESH_DP, not MESH: the same workload appears in both, named
// `productpage.default` here and `productpage` there. Envoy's stats hang
diff --git a/test/e2e/playwright/specs/istio/zipkin-traces.spec.ts b/test/e2e/playwright/specs/istio/zipkin-traces.spec.ts
index ce03ab9..b4f830d 100644
--- a/test/e2e/playwright/specs/istio/zipkin-traces.spec.ts
+++ b/test/e2e/playwright/specs/istio/zipkin-traces.spec.ts
@@ -47,3 +47,68 @@ test('the Zipkin trace tab lists traces from the Zipkin receiver', async ({ page
expect(pageErrors, 'an uncaught error during mount blanks the page').toEqual([]);
});
+
+test('a zipkin trace opens its own detail card', async ({ page, pageErrors }) => {
+ await page.goto('/layer/mesh/zipkin-trace');
+ await page.locator('button.ztr-run-btn').click();
+
+ const rows = page.locator('.tr-rowlist .tr-row-card');
+ await expect(rows.first()).toBeVisible({ timeout: 45_000 });
+ await rows.first().click();
+
+ // A DIFFERENT detail renderer from the native one: Zipkin spans carry their
+ // own shape (annotations rather than SkyWalking's span kinds), so the two
+ // cards share a look and almost no code. The native card being right says
+ // nothing about this one.
+ const detail = page.locator('.ztr-detail');
+ await expect(detail).toBeVisible({ timeout: 45_000 });
+ // Span rows, not the header: `.ztr-tid` renders from the row that was
+ // clicked, so it is populated even when the detail fetch came back empty.
+ await expect(detail.locator('.ztr-waterfall .tp-row').first()).toBeVisible({
+ timeout: 45_000,
+ });
+
+ expect(pageErrors, 'an uncaught error during mount blanks the page').toEqual([]);
+});
+
+test('a shared zipkin trace link opens the trace in a new page', async ({
+ page,
+ context,
+ pageErrors,
+}) => {
+ await page.goto('/layer/mesh/zipkin-trace');
+ await page.locator('button.ztr-run-btn').click();
+ const rows = page.locator('.tr-rowlist .tr-row-card');
+ await expect(rows.first()).toBeVisible({ timeout: 45_000 });
+
+ // The id is read off the row rather than the URL: a row click on this tab
+ // commits to LOCAL state and shows the detail inline, so — unlike the
+ // native tab — it does not put anything in the query string. `?traceId=`
+ // is the paste-an-id / share-a-link path, which is exactly why it needs
+ // its own assertion.
+ await rows.first().click();
+ const detail = page.locator('.ztr-detail');
+ await expect(detail).toBeVisible({ timeout: 45_000 });
+ const traceId = (await detail.locator('.ztr-tid').innerText()).trim();
+ expect(traceId, 'the detail should name the trace it opened').toBeTruthy();
+
+ const shared = await context.newPage();
+ try {
+ await shared.goto(`/layer/mesh/zipkin-trace?traceId=${traceId}`);
+
+ // A SEPARATE global popout from the native one — AppShell mounts both,
+ // and `useTracePopout` deliberately declines Zipkin-sourced routes so the
+ // two never fight over the same `?traceId=`. Asserting the native
+ // backdrop here would fail against a perfectly working link.
+ await expect(shared.locator('.zk-popout')).toBeVisible({ timeout: 45_000 });
+ // The id echoes back from the URL, so it proves routing, not fetching.
+ // The spans are what the link had to go and get.
+ await expect(shared.locator('.zk-waterfall .tp-row').first()).toBeVisible({
+ timeout: 45_000,
+ });
+ } finally {
+ await shared.close();
+ }
+
+ expect(pageErrors, 'an uncaught error during mount blanks the page').toEqual([]);
+});
diff --git a/test/e2e/playwright/specs/ui/entities.spec.ts b/test/e2e/playwright/specs/ui/entities.spec.ts
index 6cc8e3c..8e79e57 100644
--- a/test/e2e/playwright/specs/ui/entities.spec.ts
+++ b/test/e2e/playwright/specs/ui/entities.spec.ts
@@ -71,5 +71,14 @@ test('the alarms page lists what OAP is firing', async ({ page, pageErrors }) =>
// renders without it is unactionable.
await expect(rows.first().locator('.ax__row-entity-name')).not.toBeEmpty();
+ // The timeline above the list is its own ECharts renderer over the same
+ // alarms. It can draw nothing while the rows below are perfect, and an
+ // operator reads it first — it is how a burst is told from a trickle.
+ // Asserting the CANVAS rather than the host: `.alarms-timeline` is an
+ // unconditional container and is present even if the chart never mounted.
+ await expect(page.locator('.alarms-timeline canvas').first()).toBeVisible({
+ timeout: 45_000,
+ });
+
expect(pageErrors).toEqual([]);
});
diff --git a/test/e2e/playwright/specs/ui/overview.spec.ts b/test/e2e/playwright/specs/ui/overview.spec.ts
new file mode 100644
index 0000000..c0f4619
--- /dev/null
+++ b/test/e2e/playwright/specs/ui/overview.spec.ts
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { test, expect } from '../support/diagnostics.js';
+
+// Overview dashboards, which are a SEPARATE renderer from the layer
+// dashboards — different widget vocabulary, different template shape. Nothing
+// asserted them, so four widget components shipped untested.
+//
+// The bundled `services` overview is the one this fixture can render: it is
+// public and lists GENERAL among its layers, which is what the demo app
+// reports into. It declares five KPI tiles, three section breaks, an alarms
+// widget, a metric-composite and a topology — so one page covers the lot.
+
+const OVERVIEW = '/overview/services';
+
+test('the services overview renders its widget vocabulary', async ({ page, pageErrors }) => {
+ await page.goto(OVERVIEW);
+
+ // KPI tiles are the overview's headline numbers, and the template declares
+ // five. Asserting more than one distinguishes "the grid rendered" from "one
+ // tile rendered and the rest fell through".
+ const tiles = page.locator('.sw-card.tile');
+ await expect(tiles.first()).toBeVisible({ timeout: 45_000 });
+ await expect.poll(async () => tiles.count(), { timeout: 45_000 }).toBeGreaterThan(1);
+ await expect(tiles.first().locator('.count')).toBeVisible();
+
+ // Section breaks are layout, not data — they render regardless of what OAP
+ // answers, so their absence means the template never reached the renderer.
+ await expect(page.locator('.section-break').first()).toBeVisible();
+
+ // The composite tile and the alarms tile are each their own renderer.
+ await expect(page.locator('.sw-card.mc').first()).toBeVisible({ timeout: 45_000 });
+ await expect(page.locator('.sw-card.alarms-widget').first()).toBeVisible({ timeout: 45_000 });
+
+ expect(pageErrors, 'an uncaught error during mount blanks the page').toEqual([]);
+});
diff --git a/test/e2e/playwright/specs/ui/template-fidelity.spec.ts b/test/e2e/playwright/specs/ui/template-fidelity.spec.ts
index a8267eb..88d517a 100644
--- a/test/e2e/playwright/specs/ui/template-fidelity.spec.ts
+++ b/test/e2e/playwright/specs/ui/template-fidelity.spec.ts
@@ -59,13 +59,13 @@ function widgetsOf(layer: string, scope: string): Widget[] {
/**
* Widgets that must render for THIS fixture's entities.
*
- * `visibleWhen` is evaluated BFF-side per entity: a gated widget is dropped
+ * `visibleWhen` is evaluated BFF-side per entity: a required widget is dropped
* from the response entirely when the entity lacks the metric. general's
- * endpoint scope carries "MQ Avg Consuming Latency", gated on
+ * endpoint scope carries "MQ Avg Consuming Latency", required on
* `endpoint_mq_consume_latency` existing — the demo app serves HTTP, so it is
* correctly absent. Asserting it would fail on a healthy build.
*
- * Gated widgets are therefore excluded from the count and the per-widget
+ * Conditional widgets are therefore excluded from the count and the per-widget
* checks. Everything ungated must appear.
*/
function unconditional(widgets: Widget[]): Widget[] {
diff --git a/test/e2e/playwright/specs/ui/trace-deeplink.spec.ts b/test/e2e/playwright/specs/ui/trace-deeplink.spec.ts
new file mode 100644
index 0000000..8d9f573
--- /dev/null
+++ b/test/e2e/playwright/specs/ui/trace-deeplink.spec.ts
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { test, expect } from '../support/diagnostics.js';
+import { LAYER } from '../fixture.js';
+
+// The shareable trace link, opened COLD in a fresh page.
+//
+// This is a different code path from clicking a row, and the difference is
+// the point: clicking has the trace already in memory from the list, while a
+// pasted link has nothing but an id in the URL and must fetch the trace on
+// mount. A build where the popout only ever renders from list state passes
+// every click-driven assertion and dead-ends on every shared link.
+//
+// `?traceId=` is consumed during setup by a watch with `{ immediate: true }`,
+// which is the shape that produces a silent TDZ ReferenceError and a blank
+// page when a ref is declared below its consumer — so this also guards the
+// parameter-on-mount flow the project's failure-modes list calls out.
+
+test('a shared native trace link opens the trace in a new page', async ({
+ page,
+ context,
+ pageErrors,
+}) => {
+ await page.goto(`/layer/${LAYER}/trace`);
+ await page.locator('button.tr-run-btn').click();
+ const rows = page.locator('.tr-rowlist .tr-row-card');
+ await expect(rows.first()).toBeVisible({ timeout: 45_000 });
+
+ // Read the id off the DETAIL, not the URL. A row click on this tab commits
+ // to local state and shows the inline split — it deliberately does not
+ // navigate, so there is no query string to copy. The header's "⧉ url"
+ // button is what builds the shareable link, and `?traceId=` is that link's
+ // shape.
+ await rows.first().click();
+ const detail = page.locator('.tr-detail');
+ await expect(detail).toBeVisible({ timeout: 45_000 });
+ const traceId = (
+ await detail.locator('.trace-id-text, .trace-id-select').first().innerText()
+ ).trim();
+ expect(traceId, 'the detail header should name the trace it opened').toBeTruthy();
+
+ // A genuinely fresh page — same session, no list, no prior state.
+ const shared = await context.newPage();
+ try {
+ await shared.goto(`/layer/${LAYER}/trace?traceId=${traceId}`);
+
+ // The global popout, mounted in AppShell and opened purely by the URL.
+ await expect(shared.locator('.tp-backdrop')).toBeVisible({ timeout: 45_000 });
+
+ // SPAN ROWS are the assertion. The card carries the trace id and its
+ // static labels whether or not the detail request returned anything, so
+ // asserting text on the card passes against an empty fetch — which is
+ // exactly the failure a cold deep link is here to catch.
+ await expect(shared.locator('.tp-waterfall .tp-row').first()).toBeVisible({
+ timeout: 45_000,
+ });
+ } finally {
+ await shared.close();
+ }
+
+ expect(pageErrors, 'an uncaught error during mount blanks the page').toEqual([]);
+});
diff --git a/test/e2e/playwright/specs/ui/trace-detail.spec.ts b/test/e2e/playwright/specs/ui/trace-detail.spec.ts
new file mode 100644
index 0000000..b877cc1
--- /dev/null
+++ b/test/e2e/playwright/specs/ui/trace-detail.spec.ts
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Page } from '@playwright/test';
+import { test, expect } from '../support/diagnostics.js';
+import { LAYER } from '../fixture.js';
+
+// What a trace opens INTO. Five components hang off one click and none of
+// them were asserted: the popouts spec proved the detail pane appears, which
+// says nothing about whether the waterfall drew a single bar.
+//
+// Each view is a distinct renderer over the same spans — a waterfall, a d3
+// tree, a per-span-name roll-up — so one being right implies nothing about
+// the others. The span modal is a sixth, reached from inside the waterfall.
+
+async function openFirstTrace(page: Page) {
+ await page.goto(`/layer/${LAYER}/trace`);
+ await page.locator('button.tr-run-btn').click();
+
+ const rows = page.locator('.tr-rowlist .tr-row-card');
+ await expect(rows.first()).toBeVisible({ timeout: 45_000 });
+ await rows.first().click();
+
+ const detail = page.locator('.tr-detail');
+ await expect(detail).toBeVisible({ timeout: 45_000 });
+ return detail;
+}
+
+test('the duration scatter renders over the trace results', async ({ page, pageErrors }) => {
+ await page.goto(`/layer/${LAYER}/trace`);
+ await page.locator('button.tr-run-btn').click();
+ await expect(page.locator('.tr-rowlist .tr-row-card').first()).toBeVisible({ timeout: 45_000 });
+
+ // The scatter is drawn from the SAME result set as the list, but by its own
+ // SVG renderer — an empty plot beside a populated list is a real defect and
+ // looks like nothing at all on screen.
+ const scatter = page.locator('.scatter-wrap');
+ await expect(scatter).toBeVisible({ timeout: 45_000 });
+ await expect(scatter.locator('.scatter-dot').first()).toBeVisible({ timeout: 45_000 });
+
+ expect(pageErrors, 'an uncaught error during mount blanks the page').toEqual([]);
+});
+
+test('a trace opens to a waterfall of its spans', async ({ page, pageErrors }) => {
+ const detail = await openFirstTrace(page);
+
+ // Default view. Rows carry a service stripe, which is what makes a
+ // multi-service trace readable — and is drawn per span, so it also proves
+ // spans arrived rather than an empty shell.
+ await expect(detail.locator('.tr-default-list')).toBeVisible({ timeout: 45_000 });
+ await expect(detail.locator('.tr-default-row').first()).toBeVisible();
+ await expect(detail.locator('.svc-stripe').first()).toBeVisible();
+
+ expect(pageErrors).toEqual([]);
+});
+
+test('the tree and statistics views render the same trace', async ({ page, pageErrors }) => {
+ const detail = await openFirstTrace(page);
+
+ // d3 owns this canvas, and a d3 view that throws leaves the previous one on
+ // screen — so assert the canvas itself, not merely that the button toggled.
+ await detail.getByRole('button', { name: 'Tree' }).click();
+ await expect(detail.locator('.tr-tree-canvas')).toBeVisible({ timeout: 45_000 });
+
+ // The roll-up is a table keyed by span name, a different shape again.
+ await detail.getByRole('button', { name: 'Statistics' }).click();
+ await expect(detail.locator('.tr-table')).toBeVisible({ timeout: 45_000 });
+
+ expect(pageErrors).toEqual([]);
+});
+
+test('a span opens its detail modal', async ({ page, pageErrors }) => {
+ const detail = await openFirstTrace(page);
+
+ await expect(detail.locator('.tr-default-row').first()).toBeVisible({ timeout: 45_000 });
+ await detail.locator('.tr-default-row').first().click();
+
+ // The modal is where tags, logs and timings live — the deepest point of the
+ // trace drill-down, and the last thing in the chain nothing asserted.
+ const modal = page.locator('.span-modal');
+ await expect(modal).toBeVisible({ timeout: 45_000 });
+ await expect(modal.locator('.span-modal-head')).toBeVisible();
+
+ // Dismissing returns to the waterfall rather than unmounting the detail.
+ await page.locator('.span-modal-backdrop').click({ position: { x: 5, y: 5 } });
+ await expect(modal).toHaveCount(0);
+ await expect(detail.locator('.tr-default-list')).toBeVisible();
+
+ expect(pageErrors).toEqual([]);
+});
diff --git a/test/e2e/script/env b/test/e2e/script/env
index 70561e9..53b3197 100644
--- a/test/e2e/script/env
+++ b/test/e2e/script/env
@@ -111,9 +111,28 @@ ISTIO_VERSION=1.24.3
# manifest the case applies, not guessed from ISTIO_VERSION.
BOOKINFO_VERSION=1.20.2
TRAFFIC_GEN_IMAGE=alpine/curl:8.9.1
+# The helm chart's wait-for-banyandb init container references `curlimages/curl`
+# UNTAGGED — it resolves to `:latest` and the chart exposes no override. The tag
+# a kind case side-loads therefore has to be `:latest` too. build-images.sh pins
+# the CONTENT instead: it pulls this version and re-tags it as `:latest` locally,
+# so a run is reproducible even though the reference cannot be pinned.
+SW_CURL_IMAGE=curlimages/curl:8.11.1
# skywalking-helm publishes an OCI chart per commit, not per release.
-SW_HELM_CHART_COMMIT=da0e267f877b9b8e5f7728ae4ea7dc7723a2a073
+#
+# Must be at or after 3aee62a, which moved OAP's init job out of a post-install
+# hook. Before it, `helm --wait` deadlocks against that job — the release fails
+# with "Job/skywalking-oap-init not ready ... Job in progress" whenever the job
+# is slower than the timeout, which reads like a broken fixture rather than a
+# known chart bug.
+SW_HELM_CHART_COMMIT=3aee62ac0b0a8c9626abbd386e9728c859cd21be
# rover ships no helm chart; the istio case applies its DaemonSet directly.
SW_ROVER_IMAGE=ghcr.io/apache/skywalking-rover/skywalking-rover:79292fe07f17f98f486e0c4471213e1961fb2d1d
+# The collector that scrapes BanyanDB's FODC proxy and pushes OTLP to OAP —
+# the only path by which the BANYANDB layer's metrics exist.
+SW_OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib:0.115.1
+# Becomes the BANYANDB service name: OAP's banyandb rules build the service
+# from the `cluster` label the collector adds.
+BANYANDB_E2E_CLUSTER=e2e-banyandb
+
# Reached from the browser container over the kind node's published NodePort.
HORIZON_E2E_NODE_PORT=30081
diff --git a/test/e2e/script/prepare/build-images.sh b/test/e2e/script/prepare/build-images.sh
index f13d3e1..60bd035 100755
--- a/test/e2e/script/prepare/build-images.sh
+++ b/test/e2e/script/prepare/build-images.sh
@@ -65,4 +65,21 @@ else
docker build -t "${HORIZON_E2E_IMAGE}" "${root}"
fi
+# The chart's init container asks for `curlimages/curl` with no tag, which
+# docker resolves to `:latest`. A kind case must side-load exactly that
+# reference, so it cannot be pinned in the import list — pin the CONTENT here
+# instead: pull a fixed version and re-tag it as the name the pod will request.
+# Skipped silently when the pull fails and something is already tagged locally,
+# so an offline run keeps working.
+CURL_IMAGE=$(val SW_CURL_IMAGE)
+if docker pull -q "${CURL_IMAGE}" > /dev/null 2>&1; then
+ docker tag "${CURL_IMAGE}" curlimages/curl:latest
+ echo "▸ pinned curlimages/curl:latest to ${CURL_IMAGE}"
+elif docker image inspect curlimages/curl:latest > /dev/null 2>&1; then
+ echo "▸ curlimages/curl:latest already present (pull failed, using local)"
+else
+ echo "ERROR: cannot resolve ${CURL_IMAGE} and no local curlimages/curl:latest" >&2
+ exit 1
+fi
+
echo "images ready"
diff --git a/test/e2e/script/prepare/deployment-up.sh b/test/e2e/script/prepare/deployment-up.sh
new file mode 100644
index 0000000..e9ce0cc
--- /dev/null
+++ b/test/e2e/script/prepare/deployment-up.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# deployment-up.sh <skywalking|collector|horizon|app>
+#
+# One stage of the deployment fixture per invocation, so infra-e2e reports
+# which stage failed rather than one opaque "setup failed".
+
+set -eu
+
+STAGE="${1:?usage: deployment-up.sh <skywalking|collector|horizon|app>}"
+
+here=$(cd "$(dirname "$0")/../.." && pwd) # test/e2e
+
+# A value already exported in the caller's shell wins over script/env.
+val() {
+ eval "local override=\${$1:-}"
+ if [ -n "${override}" ]; then
+ echo "${override}"
+ return
+ fi
+ grep -E "^$1=" "${here}/script/env" | head -1 | cut -d= -f2-
+}
+
+NS=skywalking
+
+case "${STAGE}" in
+ skywalking)
+ chart=$(val SW_HELM_CHART_COMMIT)
+ oap_commit=$(val SW_OAP_COMMIT)
+ banyandb_commit=$(val SW_BANYANDB_COMMIT)
+
+ # BanyanDB in CLUSTER mode with FODC, which is the whole point of this
+ # case: a standalone node has no roles to draw and no FODC to report
+ # them, so the Deployment tab would render a single box and prove
+ # nothing. Two liaisons and two data nodes is the smallest shape that
+ # still has a role split for the tab to group by.
+ #
+ # One release rather than two: the chart wires OAP's storage at the
+ # subchart's `-grpc` service, so installing BanyanDB separately would
+ # mean re-deriving that address by hand.
+ helm -n "${NS}" install skywalking \
+ oci://ghcr.io/apache/skywalking-helm/skywalking-helm \
+ --version "0.0.0-${chart}" \
+ --create-namespace \
+ --set fullnameOverride=skywalking \
+ --set oap.replicas=1 \
+ --set oap.image.repository=ghcr.io/apache/skywalking/oap \
+ --set "oap.image.tag=${oap_commit}" \
+ --set oap.storageType=banyandb \
+ --set oap.env.SW_HEALTH_CHECKER=default \
+ --set elasticsearch.enabled=false \
+ --set banyandb.enabled=true \
+ --set banyandb.fullnameOverride=skywalking-banyandb \
+ --set banyandb.image.repository=ghcr.io/apache/skywalking-banyandb \
+ --set "banyandb.image.tag=${banyandb_commit}" \
+ --set banyandb.standalone.enabled=false \
+ --set banyandb.cluster.enabled=true \
+ --set banyandb.cluster.fodc.enabled=true \
+ --set banyandb.cluster.liaison.replicas=2 \
+ --set banyandb.cluster.data.nodeTemplate.replicas=2 \
+ --set ui.enabled=false \
+ --timeout 20m
+ ;;
+
+ collector)
+ image=$(val SW_OTEL_COLLECTOR_IMAGE)
+ cluster=$(val BANYANDB_E2E_CLUSTER)
+ sed -e "s|__IMAGE__|${image}|" \
+ -e "s|__CLUSTER__|${cluster}|" \
+ "${here}/cases/deployment/otel-collector.yaml" | kubectl apply -f -
+ ;;
+
+ horizon)
+ image=$(val HORIZON_E2E_IMAGE)
+ node_port=$(val HORIZON_E2E_NODE_PORT)
+ user=$(val HORIZON_E2E_USER)
+ hash=$(val HORIZON_E2E_PASSWORD_HASH)
+ users="[{\"username\":\"${user}\",\"passwordHash\":\"${hash}\",\"roles\":[\"admin\"]}]"
+
+ # `|` as the delimiter: the argon2 hash contains `/`, `+` and `=`, but
+ # never a pipe.
+ sed -e "s|__IMAGE__|${image}|" \
+ -e "s|__NODE_PORT__|${node_port}|" \
+ -e "s|__AUTH_USERS__|${users}|" \
+ -e "s|istio-system|${NS}|g" \
+ "${here}/cases/deployment/horizon.yaml" | kubectl apply -f -
+ ;;
+
+ app)
+ # A load source, not a subject: the BANYANDB layer's metrics come from
+ # BanyanDB itself, and BanyanDB only has anything to report while OAP is
+ # writing to it. The demo provider gives it a steady stream to store.
+ image=$(val SW_E2E_SERVICE_COMMIT)
+ sed -e "s|__IMAGE__|horizon-e2e-demo-provider:${image}|" \
+ -e "s|__TRAFFIC_IMAGE__|$(val TRAFFIC_GEN_IMAGE)|" \
+ "${here}/cases/deployment/app.yaml" | kubectl apply -f -
+ ;;
+
+ *)
+ echo "unknown stage: ${STAGE}" >&2
+ exit 1
+ ;;
+esac
diff --git a/test/e2e/script/prepare/horizon-get.sh b/test/e2e/script/prepare/horizon-get.sh
index 4f8b5b6..f1d5578 100755
--- a/test/e2e/script/prepare/horizon-get.sh
+++ b/test/e2e/script/prepare/horizon-get.sh
@@ -23,7 +23,7 @@
#
# With a third argument it POSTs that JSON instead of GETting — the list
# routes (traces, logs, browser errors) take their filters in a body, and a
-# readiness gate has to ask the same question the page will.
+# readiness check has to ask the same question the page will.
set -eu
diff --git a/test/e2e/script/prepare/istio-up.sh b/test/e2e/script/prepare/istio-up.sh
index de9d0f8..94846f2 100644
--- a/test/e2e/script/prepare/istio-up.sh
+++ b/test/e2e/script/prepare/istio-up.sh
@@ -86,11 +86,16 @@ case "${STAGE}" in
# BanyanDB, as principle 1 requires: this scenario is here for the mesh,
# not for a second storage backend.
#
- # SW_RECEIVER_ZIPKIN / SW_QUERY_ZIPKIN: the MESH layer template sources
- # its traces from Zipkin, not from SkyWalking's own trace query, so the
- # mesh Traces tab is dark without these. Istio is configured below to
- # report spans to the receiver, which makes this the one place the Zipkin
- # path is exercised with real mesh traffic.
+ # The zipkin PORTS are what turn the receiver and query on: the chart
+ # emits SW_RECEIVER_ZIPKIN / SW_QUERY_ZIPKIN itself whenever they are
+ # declared, so setting those env vars here as well makes the Deployment
+ # carry duplicate env entries and helm refuses the whole release.
+ #
+ # They matter because the MESH layer template sources its traces from
+ # Zipkin rather than SkyWalking's own trace query — the mesh Traces tab is
+ # dark without them, and Istio is configured below to report spans to the
+ # receiver, which makes this the one place the Zipkin path runs on real
+ # mesh traffic.
#
# SW_QUERY_GRAPHQL_ENABLE_ON_DEMAND_POD_LOG: pod logs are read from the
# Kubernetes API on demand rather than from storage, and OAP ships that
@@ -116,8 +121,6 @@ case "${STAGE}" in
--set 'oap.env.K8S_SERVICE_NAME_RULE=${service.metadata.name}' \
--set oap.env.SW_HEALTH_CHECKER=default \
--set oap.env.SW_QUERY_GRAPHQL_ENABLE_ON_DEMAND_POD_LOG=true \
- --set oap.env.SW_RECEIVER_ZIPKIN=default \
- --set oap.env.SW_QUERY_ZIPKIN=default \
--set oap.ports.zipkin-receiver=9411 \
--set oap.ports.zipkin-query=9412 \
--set elasticsearch.enabled=false \
diff --git a/test/e2e/script/prepare/playwright.sh b/test/e2e/script/prepare/playwright.sh
index 0d2e4bf..1ea02f4 100755
--- a/test/e2e/script/prepare/playwright.sh
+++ b/test/e2e/script/prepare/playwright.sh
@@ -52,7 +52,7 @@ image=$(grep -E '^SW_PLAYWRIGHT_IMAGE=' "${here}/script/env" | head -1 | cut -d=
# a quarter of an hour, and overwrites the artifacts each round, so what
# survives is the LAST failure rather than the first.
#
-# The budget exists for the data-readiness gates ahead of this case, which do
+# The budget exists for the data-readiness checks ahead of this case, which do
# need it. A browser suite does not: if the assertions failed once against a
# fixture already proven ready, running them again changes nothing. Record the
# first failure and short-circuit every later attempt, which also preserves
@@ -73,7 +73,7 @@ fi
# kind — Horizon runs inside the cluster, so there is no such container.
# The browser instead joins the network the kind NODE is on and
# calls the node's NodePort. Deliberately not the host port-
-# forward infra-e2e sets up for the gates: that binds to the
+# forward infra-e2e sets up for the checks: that binds to the
# host's loopback, which a container only shares on Linux, and
# the without-profiling variant exists precisely so a developer
# on macOS can run this.
diff --git a/test/e2e/script/prepare/rover-processes.sh b/test/e2e/script/prepare/rover-processes.sh
index 31e913c..a6411d9 100644
--- a/test/e2e/script/prepare/rover-processes.sh
+++ b/test/e2e/script/prepare/rover-processes.sh
@@ -44,7 +44,7 @@ for instance in ${instances}; do
fi
done
-# The instance names are on stderr so a red gate says WHICH instances were
+# The instance names are on stderr so a red check says WHICH instances were
# asked — "rover reported nothing" and "the service has no instances at all"
# are different failures with the same output.
echo "instances checked: ${instances:-<none>}" >&2