You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
graph-explorer currently validates behavior exclusively through Vitest unit tests (docs/agents/testing.md), which cover hooks, atoms, and query-template generation in isolation. There is no layer that exercises the composed application routing, Jotai state wiring, React Query cache behavior, and the DOM as a user actually experiences it.
This gap has concrete cost. The schema-sync empty-state flash investigated in #1556/#1557 was a timing defect in SchemaDiscoveryBoundary that unit tests could not have caught: the bug only manifests as a sequence of renders across a real fetch lifecycle, not as a single hook's output. A component-level or DOM-level integration test that asserts "the empty state is never painted while isFetching is true" would have caught the regression at PR time instead of requiring a multi-day trace investigation post-release.
The proposal: add a hermetic, deterministic integration/E2E layer using Playwright, with two tiers network-mocked integration tests (majority) and a small number of true end-to-end tests against an ephemeral, containerized Gremlin server for the highest-risk flows (schema discovery, connection setup, graph rendering).
Scope: packages/graph-explorer vs. packages/graph-explorer-proxy-server
This Playwright layer covers packages/graph-explorer it drives a real browser against the built frontend. packages/graph-explorer-proxy-server is not a gap this RFC needs to fill with a second browser-test framework: app.test.ts already runs a genuine request-level integration suite against the real createApp() Express instance via supertest routing for all five endpoints, CORS, IAM SigV4 signing, header validation, base-path preservation, allowed-origin enforcement, and fetch error handling. That is already integration testing in every sense that matters for that package; it just runs over in-process HTTP via Vitest instead of a browser, and doesn't need to be redone in Playwright.
What neither existing suite proves is the full real topology: browser → proxy-server (routing, IAM signing) → a real Gremlin/SPARQL server → back. The Tier 2 smoke tier is scoped to close exactly that gap see the webServer command below, which boots the proxy server rather than a bare frontend preview.
Architectural Goals
Why Playwright
Zero infrastructure overhead. Playwright ships its own browser binaries and a test runner; nothing beyond pnpm install and a one-time playwright install --with-deps is required. No Selenium grid, no separate driver management.
Deterministic by construction. Auto-waiting on actionability (visible, enabled, stable) eliminates the arbitrary sleep/waitFor patterns that make Cypress/Puppeteer suites flaky. This directly targets the failure class in Empty state message flashes briefly during schema sync in schema view #1557 asserting on transient render states requires a tool that can assert "never happened during this window," which Playwright's expect(locator).not.toBeVisible() combined with its trace viewer supports natively.
First-class network interception.page.route() gives full control over Gremlin/openCypher/SPARQL response timing without a real database for most tests this is what lets integration tests stay hermetic and fast.
Already the incumbent pattern in AWS frontend OSS. AWS Cloudscape Design System and AWS Amplify UI both use Playwright for their integration/E2E suites. Adopting it here keeps graph-explorer aligned with the tooling contributors moving between AWS-adjacent frontend repos already know, rather than introducing Cypress as a second, unrelated test runner alongside Vitest.
Single-vendor test story. Vitest (unit) and Playwright (integration/E2E) are both maintained with active TypeScript-first APIs and share enough conceptual surface (expect, describe/test) that contributors don't context-switch mental models, only DOM-vs-hook scope.
Alternatives considered and rejected:
Tool
Why not
Cypress
Weaker multi-tab/multi-origin support, no first-class network condition simulation for slow/flaky fetch sequencing, heavier CI runtime
Puppeteer + Testing Library
No batteries-included test runner, retry/wait logic, or trace viewer would require rebuilding what Playwright ships
WebdriverIO
Selenium-protocol overhead conflicts with the "zero infrastructure" goal
Why this pattern (mocked integration + minimal real-backend E2E)
Best-in-class practices converges on: most confidence should come from tests that render the real component tree with mocked I/O boundaries, not from either isolated unit tests or a large suite of slow, real-backend E2E tests.
Tier 1 Integration tests (majority): Full app or feature-shell rendered in a real browser, network calls intercepted via page.route() returning canned Gremlin/openCypher/SPARQL responses (reusing the existing graphsonHelpers.ts/ocHelpers.ts/sparqlHelpers.ts response builders from @/utils/testing no new fixture format). Fast, deterministic, no external dependency. This tier directly targets timing/sequencing bugs like Empty state message flashes briefly during schema sync in schema view #1557 by controlling response latency per-request.
Tier 2 E2E smoke tests (minimal, high-value): A handful of tests against real, ephemeral database backends, driving the browser through the actual proxy server rather than a bare frontend build validating the full topology (browser → proxy-server routing/IAM-signing → database) and the real serialization/wire-format contract end to end. graph-explorer already ships exactly the hermetic fixture needed for the Gremlin case: samples/air_routes/docker-compose.yaml's app service is the published public.ecr.aws/neptune/graph-explorer image, which runs the proxy server (USING_PROXY_SERVER=true) serving the UI against the database service (tinkerpop/gremlin-server:3.8, pre-seeded with the deterministic air-routes dataset) no AWS credentials, no Neptune cluster, no network dependency on any AWS account. This satisfies the "graph database may be needed" constraint without inventing new infrastructure, and it's the one place this RFC exercises graph-explorer-proxy-server's code path rather than relying solely on its existing supertest suite.
This two-tier split is why the constraint about needing a graph database resolves cleanly: only Tier 2 needs one, it's disposable per-run (docker compose up --wait / down -v), and every backend it uses is a public OSS image rather than an AWS-managed service.
Real-backend coverage across supported database flavors
docs/agents/product.md lists the databases graph-explorer targets: Amazon Neptune, Amazon Neptune Analytics, Apache TinkerPop Gremlin Server, and JanusGraph for property graphs, plus SPARQL 1.1 for RDF. Not all of those can be part of a hermetic Tier 2 the split:
In scope (OSS, runs locally, no AWS account):
Apache TinkerPop Gremlin Server already covered above.
Blazegraph and Apache Jena Fuseki (a generic SPARQL 1.1 store), run as a paired smoke test against the same spec. This directly targets a documented, real hazard: docs/agents/connectors.md states the query builder must never emit Blazegraph-only hint: triples (e.g. hint:joinOrder), because they silently return zero rows on other SPARQL 1.1 endpoints Graph Explorer supports. A single SPARQL backend can't prove that invariant only running the identical query against both a Blazegraph-flavored endpoint and a vanilla SPARQL 1.1 endpoint can. Exact image/tag selection (e.g. a maintained Blazegraph and Fuseki image) needs to be confirmed during the Phase 2 prototype spike below, not asserted here.
Named future work, not committed in this RFC:
JanusGraph. It speaks Gremlin and has a public image, but there's no documented, specific regression class distinct from the TinkerPop reference server that justifies a third real-backend container today. Add it if and when a JanusGraph-specific bug is actually found not preemptively.
openCypher against a real backend. Neptune's openCypher support is a proprietary Neptune extension; no non-Neptune OSS server is known to implement it. A hermetic Tier 2 openCypher smoke test isn't currently achievable without an AWS account, which would break this RFC's "zero infrastructure" goal. openCypher stays covered by Tier 1 (mocked) only, and this is a stated limitation, not an oversight.
Out of scope, by design: Amazon Neptune and Neptune Analytics themselves are managed AWS services with no local hermetic equivalent. Their proxy-specific behavior the service-type header default that distinguishes them, and IAM SigV4 signing is already covered by graph-explorer-proxy-server's existing supertest suite against mocked upstream responses. That is the correct layer for Neptune-specific behavior; Tier 2 should not try to re-prove it against a real Neptune cluster.
Proposed Directory Layout
Split across two locations not because of package politics, but because of a real tsconfig boundary: packages/graph-explorer/tsconfig.json defines the @/* path alias ("@/*": ["./src/*"]) scoped to that package only. Tier 1 needs that alias (it reuses @/utils/testing's response builders) and never touches the proxy server, so it belongs inside the package whose alias it depends on. Tier 2 needs neither alias it drives the composed app container black-box, over HTTP and is the one piece that genuinely crosses package boundaries, so it stays at the root alongside samples/ and .github/workflows/, which are cross-package for the same reason.
integration/ and smoke/ remain separate Playwright projects/configs (see below) so CI, and any contributor running locally, can select tier independently and, per the placement above, so integration never needs Docker to run at all.
Reference Implementation
e2e/playwright.shared.config.ts settings common to both tiers
packages/graph-explorer/e2e/playwright.config.ts Tier 1 (mocked, no Docker required)
import{defineConfig,devices}from"@playwright/test";import{sharedConfig}from"../../../e2e/playwright.shared.config";constPORT=4173;exportdefaultdefineConfig({
...sharedConfig,testDir: "./integration",fullyParallel: true,// No --max-failures here, deliberately: these tests are cheap (no real// network, no container boot), so a full run gives complete failure// visibility across every spec in one CI pass instead of one-fix-per-push.retries: process.env.CI ? 1 : 0,workers: process.env.CI ? 4 : undefined,use: {
...sharedConfig.use,baseURL: `http://localhost:${PORT}`,},webServer: {command: "pnpm preview --port "+PORT,url: `http://localhost:${PORT}`,reuseExistingServer: !process.env.CI,timeout: 60_000,},});
import{defineConfig,devices}from"@playwright/test";import{sharedConfig}from"./playwright.shared.config";exportdefaultdefineConfig({
...sharedConfig,fullyParallel: true,// No shared `webServer` block: each project below points at a container// the CI step (or the contributor, locally) already started via// `docker compose`. Playwright never manages these servers' lifecycle.projects: [{// Run locally with:// docker compose -f samples/air_routes/docker-compose.yaml up app database --waitname: "smoke-gremlin",testDir: "./smoke/gremlin",use: { ...devices["Desktop Chrome"],baseURL: "http://localhost:8080"},},{// Same spec file, run against the app pointed at Blazegraph.// Run locally with:// docker compose -f e2e/fixtures/rdf-backends/docker-compose.yaml up --waitname: "smoke-sparql-blazegraph",testDir: "./smoke/sparql",use: { ...devices["Desktop Chrome"],baseURL: "http://localhost:8081"},},{// Same spec file again, run against the app pointed at Fuseki this// pairing is what proves Blazegraph-only syntax doesn't leak.name: "smoke-sparql-fuseki",testDir: "./smoke/sparql",use: { ...devices["Desktop Chrome"],baseURL: "http://localhost:8082"},},],});
Network-mocked integration test regression coverage for #1557
// packages/graph-explorer/e2e/integration/schema-discovery/schema-sync-no-flash.spec.tsimport{test,expect}from"@playwright/test";import{mockSchemaSyncRequest}from"../../mocks/gremlinResponses";import{openSchemaExplorerWithConnection}from"../../fixtures/mockGraphExplorer";test("schema explorer never paints the empty-state while sync is in flight",async({
page,})=>{// Force a slow, controllable fetch so the intermediate render is observable.constschemaRequest=mockSchemaSyncRequest(page,{delayMs: 400,vertexLabels: ["airport","country","continent"],});awaitopenSchemaExplorerWithConnection(page);awaitpage.getByRole("button",{name: "Refresh schema"}).click();constemptyState=page.getByText("No Object Properties Available");constsyncingIndicator=page.getByText("Synchronizing schema");// The empty state must never be visible while data is fetching, at any// point in the render sequence not just at the start and the end.for(leti=0;i<5;i++){awaitexpect(emptyState).not.toBeVisible();awaitpage.waitForTimeout(50);}awaitschemaRequest.resolve();awaitexpect(syncingIndicator).not.toBeVisible();awaitexpect(page.getByText("airport")).toBeVisible();});
Note the deliberate reuse of createGraphSONVertexLabelsResponse from the existing @/utils/testing/graphsonHelpers the same response builders the unit suite already uses. No parallel fixture format is introduced.
Same app image the existing samples/air_routes/docker-compose.yaml uses, pointed at a different backend per instance no new deployment shape, just a second connection target. Both RDF stores need a fixed seed dataset loaded on container start (a handful of triples, matching the deterministic-dataset pattern air-routes already follows); the loading mechanism (init script vs. pre-baked image) is a detail to settle during the Phase 2 prototype, not this RFC.
Tier 2 smoke test against the real proxy server + Gremlin backend
// e2e/smoke/gremlin/connect-and-explore.spec.tsimport{test,expect}from"@playwright/test";test("connects through the proxy to a live Gremlin database and renders the graph",async({
page,})=>{// baseURL (see playwright.config.ts) points at the docker-compose `app`// service the real proxy server, not a bare frontend build so this// request exercises proxy-server routing and IAM-signing code, not just// the frontend.awaitpage.goto("/");awaitpage.getByRole("button",{name: "Add connection"}).click();awaitpage.getByLabel("Graph type").selectOption("gremlin");awaitpage.getByLabel("Connection URL").fill("http://localhost:8182");awaitpage.getByRole("button",{name: "Connect"}).click();awaitexpect(page.getByText("air-routes")).toBeVisible({timeout: 15_000});awaitpage.getByRole("button",{name: "Sync schema"}).click();awaitexpect(page.getByText("airport")).toBeVisible({timeout: 15_000});});
CI/CD & Automation Integration Plan
New workflow: .github/workflows/e2e.yml
name: E2E and Integration Testson:
pull_request:
push:
branches: [main]schedule:
- cron: "0 6 * * *"# nightly backstop full smoke run regardless of changed pathsworkflow_dispatch: {}jobs:
integration:
runs-on: ubuntu-latesttimeout-minutes: 15steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4with:
node-version: 24cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter graph-explorer exec playwright install --with-deps chromium# Config lives inside the package (see "Proposed Directory Layout") —# no --config flag needed, cwd is already packages/graph-explorer.# Deliberately no --max-failures: full-suite visibility beats saving# a few seconds on a tier this cheap.
- run: pnpm --filter graph-explorer exec playwright test --project=integration
- uses: actions/upload-artifact@v4if: failure()with:
name: playwright-integration-reportpath: packages/graph-explorer/e2e/playwright-report/retention-days: 7changes:
# Path filters on `on:` gate the whole workflow, not one job, so# detecting "which connector did this PR touch" needs its own step.# Split by connector so a SPARQL-only PR never pays for a Gremlin# container boot, and vice versa.runs-on: ubuntu-latestoutputs:
gremlin-changed: ${{ steps.filter.outputs.gremlin }}sparql-changed: ${{ steps.filter.outputs.sparql }}steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3id: filterwith:
filters: | gremlin: - 'packages/graph-explorer/src/connector/gremlin/**' - 'packages/graph-explorer/src/connector/openCypher/**' sparql: - 'packages/graph-explorer/src/connector/sparql/**'smoke-gremlin:
needs: changes# Real-backend tier runs on the PR path, but only when the change# actually touches Gremlin/openCypher query-serialization code the# class of defect this tier exists to catch. Every other PR skips it.# The nightly/manual run is a backstop for anything the path filter# misses (e.g. a Playwright fixture change with no connector diff).if: needs.changes.outputs.gremlin-changed == 'true' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'runs-on: ubuntu-latesttimeout-minutes: 15steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4with:
node-version: 24cache: pnpm
- run: pnpm install --frozen-lockfile# @playwright/test is a root devDependency for Tier 2 the config# lives at repo root, not inside any package (see directory layout).
- run: pnpm exec playwright install --with-deps chromium
- name: Start ephemeral proxy server + Gremlin backendrun: docker compose -f samples/air_routes/docker-compose.yaml up app database --wait# --max-failures=1: unlike `integration`, each spec here pays real# container-boot and network cost. Once one real-backend assertion# fails, the environment is presumptively suspect running the rest# buys little and burns runner minutes. Bail, fix, re-push.
- run: pnpm exec playwright test --config e2e/playwright.config.ts --project=smoke-gremlin --max-failures=1
- uses: actions/upload-artifact@v4if: failure()with:
name: playwright-smoke-gremlin-reportpath: e2e/playwright-report/retention-days: 7
- if: always()run: docker compose -f samples/air_routes/docker-compose.yaml down -vsmoke-sparql:
needs: changes# Same rationale as smoke-gremlin, gated on the SPARQL connector path# instead. Runs the identical spec against Blazegraph and Fuseki to# prove Blazegraph-only syntax never leaks to a vanilla SPARQL 1.1# endpoint (docs/agents/connectors.md, the `hint:` exclusion rule).if: needs.changes.outputs.sparql-changed == 'true' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'runs-on: ubuntu-latesttimeout-minutes: 15steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4with:
node-version: 24cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- name: Start ephemeral Blazegraph and Fuseki backendsrun: docker compose -f e2e/fixtures/rdf-backends/docker-compose.yaml up --wait# Both projects run in this one invocation, sharing Playwright's worker# pool genuinely concurrent, not sequential. --max-failures=1 stops# both on the first failure: since they run the identical spec, a# failure is either a shared assertion bug (no value in continuing) or# a single-backend regression (already pinpointed by that backend's# trace/video artifact; the other project's pass/fail is corroborating# signal, not the primary one).
- run: pnpm exec playwright test --config e2e/playwright.config.ts --project=smoke-sparql-blazegraph --project=smoke-sparql-fuseki --max-failures=1
- uses: actions/upload-artifact@v4if: failure()with:
name: playwright-smoke-sparql-reportpath: e2e/playwright-report/retention-days: 7
- if: always()run: docker compose -f e2e/fixtures/rdf-backends/docker-compose.yaml down -v
Design choices matching AWS OSS CI conventions already present in this repo (unit.yml, test_build_docker.yml):
Four independent jobs, not one a flaky container pull in either smoke job never blocks the fast, hermetic integration job from reporting, and a Gremlin-only or SPARQL-only PR never pays for the other backend's container.
docker compose ... down -v in an always() step guarantees no dangling container state between runs on shared runners.
Trace/video/screenshot artifacts upload only on: failure(), keeping the common case cheap and giving full observability (Playwright trace viewer) exactly when it's needed this is the "high observability on failure" requirement.
No AWS credentials, no Neptune endpoint, no account dependency anywhere in any job. Every external dependency is a public OSS image (tinkerpop/gremlin-server, Blazegraph, Fuseki) or mocked in-process.
Execution strategy: does one failure stop everything, and what runs in parallel?
Short answer: no single failure stops everything, at two levels, by design and each tier gets a different fail-fast policy on purpose.
Job-level (does Gremlin failing block SPARQL, or vice versa?).integration, smoke-gremlin, and smoke-sparql are three independent GitHub Actions jobs. smoke-gremlin and smoke-sparql both depend only on changes, not on each other, so GitHub Actions runs them concurrently on separate runners whenever both are triggered. A Gremlin regression failing smoke-gremlin has no effect on smoke-sparql's outcome they are separate failure domains, matching how a Gremlin-only PR shouldn't pay for the SPARQL container and vice versa (see the path-filter split above).
Test-level within a job (does one spec failing stop the rest?). This is where the tiers deliberately diverge:
integration (Tier 1)
smoke-gremlin / smoke-sparql-* (Tier 2)
Cost per spec
Cheap mocked network, no container boot
Expensive real container boot, real network round-trips
--max-failures
Not set runs to completion
1 stops on first failure
Why
Full failure visibility is worth more than the few seconds saved; a PR author should see every failing assertion in one CI pass, not fix one and wait for the next run to surface the next
Once a real-backend assertion fails, the environment is presumptively suspect; running the remaining specs against it buys little and burns runner minutes for low marginal information
Parallelism within smoke-sparql. Its single playwright test --project=smoke-sparql-blazegraph --project=smoke-sparql-fuseki invocation runs both projects concurrently across Playwright's shared worker pool on that one runner not sequentially so Blazegraph and Fuseki are exercised in parallel, at the cost of one shared --max-failures=1 across both (reasonable here specifically because both projects run the identical spec: a failure is either a shared assertion bug, where continuing adds nothing, or a single-backend regression, already pinpointed by that backend's own trace/video artifact).
Keeping it easy to run without it becoming slow. Three levers, already reflected above:
Path-gating means most PRs never run either smoke tier at all the majority-case cost stays at integration's runtime alone.
--max-failures=1 on Tier 2 bounds worst-case runner time to roughly one container boot plus one failing spec, not the full suite.
Locally, a contributor never needs Docker to be productive: pnpm --filter graph-explorer exec playwright test --project=integration (Tier 1, package-scoped, no external command) is the default "run the e2e tests" experience. Running a smoke tier is an explicit, documented opt-in the docker compose ... up --wait command called out in each project's comment above never something pnpm test triggers by default.
Decision: when do the smoke-* tiers run?
Two options were weighed:
Nightly / scheduled only
Path-scoped, on the pull request (selected)
Detection latency
A real wire-format regression can sit on main for up to 24h before anyone notices
Caught at the exact commit that introduced it
Fix ownership
The contributor who introduced it is out of the loop by the time it surfaces; a maintainer has to trace it back and file a revert/fix
Stays the contributor's own responsibility, as part of getting their PR merged
Infra-caused merge blocking
None
Bounded to PRs that touch the relevant connector path not every PR, only the ones where that specific real-backend check is relevant
Nightly-only was the initial default in this proposal, on the reasoning that it kept the non-hermetic dependencies (container pulls, server boots) off the required-check path entirely. On review, that reasoning underweighted a more important cost: the defect class these tiers exist to catch connector/query-serialization regressions is exactly the kind of bug a contributor's own PR is most likely to introduce, and deferring detection to nightly moves both discovery and the fix off the person who caused it. Path-scoped per-PR execution is the better default, and splitting the filter by connector (Gremlin/openCypher vs. SPARQL see the changes job) bounds each tier's infra-flake exposure to only the PRs where that specific check is relevant. The nightly/workflow_dispatch run stays as a backstop for anything either path filter misses.
Rollout sequencing
Land the Playwright scaffold and the integration project only, as a non-required check, alongside the single Empty state message flashes briefly during schema sync in schema view #1557 regression test this is the smallest slice that proves the tool choice and directly closes the gap that motivated this RFC.
Add 3-5 more integration specs for the highest-traffic flows (connection setup, node expansion, filtering) over subsequent PRs, each independently reviewable.
Promote integration to a required check once flake rate is proven at zero across ~2 weeks of PR traffic.
Add smoke-gremlin next, once integration is stable it carries the only real infrastructure dependency in that path and should not block the majority-case win. Gate it to PRs touching the Gremlin/openCypher connector paths (see "Decision" above), with a nightly/workflow_dispatch run as backstop.
Add smoke-sparql-blazegraph/smoke-sparql-fuseki after smoke-gremlin is proven stable same gating pattern, scoped to the SPARQL connector path. This is the tier that proves the documented Blazegraph-hint:-leak invariant against real servers; land it deliberately after Gremlin, not alongside it, so any new infra flakiness is diagnosed against one backend family at a time.
Once integration specs pass roughly five files, extract a page-object layer (e2e/pages/*.page.ts) per feature area to remove locator duplication across specs. Not worth introducing at prototype size one spec has nothing to deduplicate but plan for it before the suite grows past that point.
JanusGraph and a real-backend openCypher check remain named future work (see "Real-backend coverage across supported database flavors" above) revisit only if a concrete regression class specific to either is actually found.
Prototype step
Before committing to this RFC as written, land step 1 above as a standalone draft PR: the Playwright scaffold plus the single #1557 regression spec, run against CI as a non-required check for one week. This validates in practice that the mocked-network pattern reproduces the actual timing defect (not just a plausible-looking assertion), that CI runtime stays acceptable, and that the artifact-on-failure flow is actually useful for triage before any further specs or either smoke tier is built on top of it.
The prototype is done when, and only when:
It runs on a contributor's machine with no setup beyond pnpm install and one playwright install.
It passes reliably on GitHub-hosted runners, with no flakes across at least 10 consecutive CI runs.
No external infrastructure (AWS account, Neptune, live database) is required to run it.
A forced-failing run produces a usable trace/video/screenshot artifact without local reproduction.
Total runtime for the integration project stays under a few minutes.
Important
If you are interested in working on this issue, please leave a comment.
Tip
Please use a 👍 reaction to provide a +1/vote. This helps the community and maintainers prioritize this request.
Abstract / Problem Statement
graph-explorercurrently validates behavior exclusively through Vitest unit tests (docs/agents/testing.md), which cover hooks, atoms, and query-template generation in isolation. There is no layer that exercises the composed application routing, Jotai state wiring, React Query cache behavior, and the DOM as a user actually experiences it.This gap has concrete cost. The schema-sync empty-state flash investigated in #1556/#1557 was a timing defect in
SchemaDiscoveryBoundarythat unit tests could not have caught: the bug only manifests as a sequence of renders across a real fetch lifecycle, not as a single hook's output. A component-level or DOM-level integration test that asserts "the empty state is never painted whileisFetchingis true" would have caught the regression at PR time instead of requiring a multi-day trace investigation post-release.The proposal: add a hermetic, deterministic integration/E2E layer using Playwright, with two tiers network-mocked integration tests (majority) and a small number of true end-to-end tests against an ephemeral, containerized Gremlin server for the highest-risk flows (schema discovery, connection setup, graph rendering).
Scope:
packages/graph-explorervs.packages/graph-explorer-proxy-serverThis Playwright layer covers
packages/graph-explorerit drives a real browser against the built frontend.packages/graph-explorer-proxy-serveris not a gap this RFC needs to fill with a second browser-test framework:app.test.tsalready runs a genuine request-level integration suite against the realcreateApp()Express instance viasupertestrouting for all five endpoints, CORS, IAM SigV4 signing, header validation, base-path preservation, allowed-origin enforcement, and fetch error handling. That is already integration testing in every sense that matters for that package; it just runs over in-process HTTP via Vitest instead of a browser, and doesn't need to be redone in Playwright.What neither existing suite proves is the full real topology: browser → proxy-server (routing, IAM signing) → a real Gremlin/SPARQL server → back. The Tier 2 smoke tier is scoped to close exactly that gap see the
webServercommand below, which boots the proxy server rather than a bare frontend preview.Architectural Goals
Why Playwright
pnpm installand a one-timeplaywright install --with-depsis required. No Selenium grid, no separate driver management.sleep/waitForpatterns that make Cypress/Puppeteer suites flaky. This directly targets the failure class in Empty state message flashes briefly during schema sync in schema view #1557 asserting on transient render states requires a tool that can assert "never happened during this window," which Playwright'sexpect(locator).not.toBeVisible()combined with its trace viewer supports natively.page.route()gives full control over Gremlin/openCypher/SPARQL response timing without a real database for most tests this is what lets integration tests stay hermetic and fast.graph-exploreraligned with the tooling contributors moving between AWS-adjacent frontend repos already know, rather than introducing Cypress as a second, unrelated test runner alongside Vitest.expect,describe/test) that contributors don't context-switch mental models, only DOM-vs-hook scope.Alternatives considered and rejected:
Why this pattern (mocked integration + minimal real-backend E2E)
Best-in-class practices converges on: most confidence should come from tests that render the real component tree with mocked I/O boundaries, not from either isolated unit tests or a large suite of slow, real-backend E2E tests.
page.route()returning canned Gremlin/openCypher/SPARQL responses (reusing the existinggraphsonHelpers.ts/ocHelpers.ts/sparqlHelpers.tsresponse builders from@/utils/testingno new fixture format). Fast, deterministic, no external dependency. This tier directly targets timing/sequencing bugs like Empty state message flashes briefly during schema sync in schema view #1557 by controlling response latency per-request.graph-exploreralready ships exactly the hermetic fixture needed for the Gremlin case:samples/air_routes/docker-compose.yaml'sappservice is the publishedpublic.ecr.aws/neptune/graph-explorerimage, which runs the proxy server (USING_PROXY_SERVER=true) serving the UI against thedatabaseservice (tinkerpop/gremlin-server:3.8, pre-seeded with the deterministicair-routesdataset) no AWS credentials, no Neptune cluster, no network dependency on any AWS account. This satisfies the "graph database may be needed" constraint without inventing new infrastructure, and it's the one place this RFC exercisesgraph-explorer-proxy-server's code path rather than relying solely on its existingsupertestsuite.This two-tier split is why the constraint about needing a graph database resolves cleanly: only Tier 2 needs one, it's disposable per-run (
docker compose up --wait/down -v), and every backend it uses is a public OSS image rather than an AWS-managed service.Real-backend coverage across supported database flavors
docs/agents/product.mdlists the databasesgraph-explorertargets: Amazon Neptune, Amazon Neptune Analytics, Apache TinkerPop Gremlin Server, and JanusGraph for property graphs, plus SPARQL 1.1 for RDF. Not all of those can be part of a hermetic Tier 2 the split:docs/agents/connectors.mdstates the query builder must never emit Blazegraph-onlyhint:triples (e.g.hint:joinOrder), because they silently return zero rows on other SPARQL 1.1 endpoints Graph Explorer supports. A single SPARQL backend can't prove that invariant only running the identical query against both a Blazegraph-flavored endpoint and a vanilla SPARQL 1.1 endpoint can. Exact image/tag selection (e.g. a maintained Blazegraph and Fuseki image) needs to be confirmed during the Phase 2 prototype spike below, not asserted here.service-typeheader default that distinguishes them, and IAM SigV4 signing is already covered bygraph-explorer-proxy-server's existingsupertestsuite against mocked upstream responses. That is the correct layer for Neptune-specific behavior; Tier 2 should not try to re-prove it against a real Neptune cluster.Proposed Directory Layout
Split across two locations not because of package politics, but because of a real tsconfig boundary:
packages/graph-explorer/tsconfig.jsondefines the@/*path alias ("@/*": ["./src/*"]) scoped to that package only. Tier 1 needs that alias (it reuses@/utils/testing's response builders) and never touches the proxy server, so it belongs inside the package whose alias it depends on. Tier 2 needs neither alias it drives the composedappcontainer black-box, over HTTP and is the one piece that genuinely crosses package boundaries, so it stays at the root alongsidesamples/and.github/workflows/, which are cross-package for the same reason.integration/andsmoke/remain separate Playwright projects/configs (see below) so CI, and any contributor running locally, can select tier independently and, per the placement above, sointegrationnever needs Docker to run at all.Reference Implementation
e2e/playwright.shared.config.tssettings common to both tierspackages/graph-explorer/e2e/playwright.config.tsTier 1 (mocked, no Docker required)e2e/playwright.config.tsTier 2 (real backends, Docker required)Network-mocked integration test regression coverage for #1557
Note the deliberate reuse of
createGraphSONVertexLabelsResponsefrom the existing@/utils/testing/graphsonHelpersthe same response builders the unit suite already uses. No parallel fixture format is introduced.Tier 2 fixture
e2e/fixtures/rdf-backends/docker-compose.yamlSame
appimage the existingsamples/air_routes/docker-compose.yamluses, pointed at a different backend per instance no new deployment shape, just a second connection target. Both RDF stores need a fixed seed dataset loaded on container start (a handful of triples, matching the deterministic-dataset patternair-routesalready follows); the loading mechanism (init script vs. pre-baked image) is a detail to settle during the Phase 2 prototype, not this RFC.Tier 2 smoke test against the real proxy server + Gremlin backend
CI/CD & Automation Integration Plan
New workflow:
.github/workflows/e2e.ymlDesign choices matching AWS OSS CI conventions already present in this repo (
unit.yml,test_build_docker.yml):integrationjob from reporting, and a Gremlin-only or SPARQL-only PR never pays for the other backend's container.docker compose ... down -vin analways()step guarantees no dangling container state between runs on shared runners.on: failure(), keeping the common case cheap and giving full observability (Playwright trace viewer) exactly when it's needed this is the "high observability on failure" requirement.tinkerpop/gremlin-server, Blazegraph, Fuseki) or mocked in-process.Execution strategy: does one failure stop everything, and what runs in parallel?
Short answer: no single failure stops everything, at two levels, by design and each tier gets a different fail-fast policy on purpose.
Job-level (does Gremlin failing block SPARQL, or vice versa?).
integration,smoke-gremlin, andsmoke-sparqlare three independent GitHub Actions jobs.smoke-gremlinandsmoke-sparqlboth depend only onchanges, not on each other, so GitHub Actions runs them concurrently on separate runners whenever both are triggered. A Gremlin regression failingsmoke-gremlinhas no effect onsmoke-sparql's outcome they are separate failure domains, matching how a Gremlin-only PR shouldn't pay for the SPARQL container and vice versa (see the path-filter split above).Test-level within a job (does one spec failing stop the rest?). This is where the tiers deliberately diverge:
integration(Tier 1)smoke-gremlin/smoke-sparql-*(Tier 2)--max-failures1stops on first failureParallelism within
smoke-sparql. Its singleplaywright test --project=smoke-sparql-blazegraph --project=smoke-sparql-fusekiinvocation runs both projects concurrently across Playwright's shared worker pool on that one runner not sequentially so Blazegraph and Fuseki are exercised in parallel, at the cost of one shared--max-failures=1across both (reasonable here specifically because both projects run the identical spec: a failure is either a shared assertion bug, where continuing adds nothing, or a single-backend regression, already pinpointed by that backend's own trace/video artifact).Keeping it easy to run without it becoming slow. Three levers, already reflected above:
integration's runtime alone.--max-failures=1on Tier 2 bounds worst-case runner time to roughly one container boot plus one failing spec, not the full suite.pnpm --filter graph-explorer exec playwright test --project=integration(Tier 1, package-scoped, no external command) is the default "run the e2e tests" experience. Running a smoke tier is an explicit, documented opt-in thedocker compose ... up --waitcommand called out in each project's comment above never somethingpnpm testtriggers by default.Decision: when do the
smoke-*tiers run?Two options were weighed:
mainfor up to 24h before anyone noticesNightly-only was the initial default in this proposal, on the reasoning that it kept the non-hermetic dependencies (container pulls, server boots) off the required-check path entirely. On review, that reasoning underweighted a more important cost: the defect class these tiers exist to catch connector/query-serialization regressions is exactly the kind of bug a contributor's own PR is most likely to introduce, and deferring detection to nightly moves both discovery and the fix off the person who caused it. Path-scoped per-PR execution is the better default, and splitting the filter by connector (Gremlin/openCypher vs. SPARQL see the
changesjob) bounds each tier's infra-flake exposure to only the PRs where that specific check is relevant. The nightly/workflow_dispatchrun stays as a backstop for anything either path filter misses.Rollout sequencing
integrationproject only, as a non-required check, alongside the single Empty state message flashes briefly during schema sync in schema view #1557 regression test this is the smallest slice that proves the tool choice and directly closes the gap that motivated this RFC.integrationto a required check once flake rate is proven at zero across ~2 weeks of PR traffic.smoke-gremlinnext, onceintegrationis stable it carries the only real infrastructure dependency in that path and should not block the majority-case win. Gate it to PRs touching the Gremlin/openCypher connector paths (see "Decision" above), with a nightly/workflow_dispatchrun as backstop.smoke-sparql-blazegraph/smoke-sparql-fusekiaftersmoke-gremlinis proven stable same gating pattern, scoped to the SPARQL connector path. This is the tier that proves the documented Blazegraph-hint:-leak invariant against real servers; land it deliberately after Gremlin, not alongside it, so any new infra flakiness is diagnosed against one backend family at a time.e2e/pages/*.page.ts) per feature area to remove locator duplication across specs. Not worth introducing at prototype size one spec has nothing to deduplicate but plan for it before the suite grows past that point.Prototype step
Before committing to this RFC as written, land step 1 above as a standalone draft PR: the Playwright scaffold plus the single #1557 regression spec, run against CI as a non-required check for one week. This validates in practice that the mocked-network pattern reproduces the actual timing defect (not just a plausible-looking assertion), that CI runtime stays acceptable, and that the artifact-on-failure flow is actually useful for triage before any further specs or either smoke tier is built on top of it.
The prototype is done when, and only when:
pnpm installand oneplaywright install.integrationproject stays under a few minutes.Important
If you are interested in working on this issue, please leave a comment.
Tip
Please use a 👍 reaction to provide a +1/vote. This helps the community and maintainers prioritize this request.