Skip to content

E2E & live smoke

Three Playwright suites and four gated shell scripts, plus a wrapper that runs two of them as a signed-in person. None of them runs as part of npm test, and one of the three suites runs in CI.

Suite Where Specs Tests In CI? Last measured
Frontend Playwright packages/frontend/e2e/ 15 47 No 9 Oct β€” 42 passed, 5 skipped, 2.6m
Public-site Playwright packages/public-site/e2e/ 1 6 No 9 Oct β€” 6/6 serially, 6.4s; 2 timeouts in the parallel run
PA-demo Playwright packages/pa-demo/e2e/ 1 11 Yes β€” azure-pa-demo-acc.yml 9 Oct β€” 11 passed, 8.7s
Live smoke scripts scripts/*.sh 4 scripts and a wrapper β€” No never run for these pages

64 end-to-end tests, all three suites green β€” the frontend with five tests skipping themselves by design, and the public site green serially. Measured on 9 October 2026 for v2026.10.1 (main at ebec288), from the working checkout on acc at 0ea4985, a tree identical to ebec288, against the full local stack the developer already had running; nothing was started or stopped for the run.

Re-run on 9 October β€” four new specs, nineteen more tests

v2026.10.1 added four frontend specs and 19 tests, and the runner collected 47 tests across fifteen specs β€” 42 passed and five skipped, each skip one the spec declares:

  • landing-single-board (13 tests, 9 passed, 4 skipped) β€” the organisation landing pages at /amsterdam and /heusden: one exact "Inloggen", the caseworker login in the tenant's colours, the DigiD link pre-filling the tenant's test citizen, an old /?tenant= link redirected, and an unknown or Flevoland path sent to the grid at /. Its four link preview tests skip on a local target β€” the per-tenant pages exist only in a deployed build, and the dev server serves the root index.html for every path.
  • citizen-services (3, passed) β€” the cards each test citizen sees: Amsterdam exactly Zorgtoeslag, Heusden Zorgtoeslag and Heusdenpas, Flevoland Zorgtoeslag, Vergunningen and Subsidies β€” and no Mijn toestemming button.
  • landing-access-denied (2, passed) β€” a board the role does not open ends in the no-access dialog on the landing page, and the boards with an Entra role offer the Flevoland account.
  • heusdenpas-journey (1, passed, 11.8s) β€” Gemeente Heusden's Heusdenpas end to end: test-citizen-heusden applies, and test-caseworker-heusden checks completeness, reviews the outcome of the untenanted SVB, SZW and Heusdenpas decisions, and informs the applicant, who then sees the decided application once in Mijn aanvragen.
  • rip-r21-journey skipped itself again, as on 3 October: the local stack signs with the real ValidSign (VALIDSIGN_STUB_MODE=false).

One existing spec changed: caseworker-journey.spec.ts now fills the Kapvergunning forms by their Dutch labels β€” Stamdiameter (cm), Aanvraag indienen, Beoordeling indienen, Bekendmaking bevestigen β€” because linked-data-explorer's fixtures translated them (linked-data-explorer#254 item 1); it passed. The required-processes helper behind globalSetup now requires nine process definitions, the two Heusdenpas ones under tenant heusden, and three more decisions β€” see Deploying the E2E fixtures.

The public-site suite went red once and green serially again, as on 3 October: four passed and the same two search-journey tests timed out in its default parallel run, and it passed 6/6 in 6.4s with --workers=1; see Public-site Playwright suite. pa-demo passed 11/11 in 8.7s.

The inventory is seventeen specs: fifteen in packages/frontend/e2e/, plus packages/pa-demo/e2e/plato-demo.spec.ts and packages/public-site/e2e/publiek.spec.ts. No playwright.config.ts changed, and the workflow wiring is unchanged: the pa-demo spec runs in azure-pa-demo-acc.yml and only there, and no workflow runs the other two.

The fifteen: caseworker-journey, citizen-services, heusdenpas-journey, infra-board-journey, landing-access-denied, landing-single-board, login-redirect, pa-live-authoring, pa-mock-journey, protected-route, rip-r21-journey, smoke, tenant-isolation, thuisbatterij-journey, zorgtoeslag-journey.

History: re-run on 3 October β€” the frontend count is a total again

Every pass from 12 to 30 September left the Playwright suites alone, and the 27-test frontend figure they repeated was measured when there were ten specs, so it could not include thuisbatterij-journey.spec.ts. On 3 October the runner collected 28 tests across the eleven specs β€” 27 passed and one skipped:

  • thuisbatterij-journey ran for the first time on these pages: 1 test, passed, 8.4s.
  • rip-r21-journey skipped itself, by one of its two runtime guards: the local stack signs with the real ValidSign (VALIDSIGN_STUB_MODE=false), and the journey will not request a binding signature, so it refuses before sending a package and creates nothing. See below.

The public-site suite went red once and green serially, and is recorded that way. Run with its config's default workers β€” six on that machine β€” it returned 4 passed, 2 failed: both search-journey tests (publiek.spec.ts:6 and :27) timed out after 10s waiting for the search-result filter checkbox to appear. Re-run with --workers=1 against the same running backend, it passed 6/6 in 7.8s. 6/6 serially; 2 timeouts in the parallel run β€” not a defect until it fails on its own; see Public-site Playwright suite.

The inventory was thirteen specs then, unchanged since v2026.09.11: eleven in packages/frontend/e2e/, plus packages/pa-demo/e2e/plato-demo.spec.ts and packages/public-site/e2e/publiek.spec.ts. v2026.10.0 changed no spec, no playwright.config.ts and no helper: git diff ae06c9e 0625d48 -- 'packages/*/e2e' is empty.

History: v2026.09.14 changed three specs and the login helper, none in its test count β€” read from the source at ae06c9e:

  • e2e/helpers/auth.ts matches the medewerker login button exactly (9f54e82). v2026.09.13 added "Inloggen met uw Flevoland-account" to the landing page, and loginAsMedewerker looked its button up by the name "Inloggen", which Playwright matches as a substring β€” so it resolved to both buttons and strict mode refused to click. The commit records that 26 of 28 E2E tests against ACC failed at that one line; that run is the commit's account, not a measurement these pages made. The helper now passes exact: true, because the plain "Inloggen" in the top bar is the Keycloak login it fills in, while the new button goes to Entra ID.
  • caseworker-journey.spec.ts accepts the Dutch Kapvergunning task names (10e83ce), and zorgtoeslag-journey.spec.ts and tenant-isolation.spec.ts the Dutch Zorgtoeslag ones (1f0e52b) β€” the same pattern thuisbatterij-journey adopted in v2026.09.12; see below.

The three playwright.config.ts files are unchanged between 86af73e and ebec288, and e2e/global-setup.ts changed only in v2026.09.13, when its fix messages began naming npm run e2e:deploy-fixtures; what it requires grew in v2026.10.1, through helpers/required-processes.ts β€” see What each suite needs running.

What each suite needs running

The webServer block is declared per config, not per spec, so it applies to every spec under that directory. This is the table to check before running anything locally β€” it is what separates a suite you can start cold from one that will fail its preconditions.

Re-read at 86af73e on 24 September 2026; the configs are unchanged at 2443adc (v2026.09.12), 963fe24 (v2026.09.13), ae06c9e (v2026.09.15), 0625d48 (v2026.10.0) and ebec288 (v2026.10.1).

Config Declares webServer? Has a globalSetup? What must already be up
packages/frontend/e2e/playwright.config.ts No β€” none at all Yes β€” ./global-setup.ts, plus a globalTeardown Four services: the frontend on :5173, the backend on :3002, Keycloak on :8080, and the sibling Linked Data Explorer backend on :3001 β€” plus docker compose up -d behind them, and a deployed process/decision bundle
packages/pa-demo/e2e/playwright.config.ts Yes, conditionally β€” npm run dev on :5176 No Nothing. No backend, database or Keycloak; plato issues no network requests at all
packages/public-site/e2e/playwright.config.ts Yes, conditionally β€” npm run dev on :5175 No The backend, on whatever VITE_API_URL points at. The config starts the site but not the API, and these specs hit real search results

Four things follow from that table:

  • The frontend suite is the one that needs a human, and its globalSetup now checks considerably more than three URLs. In order, it refuses to run against production without CONFIRM_PROD=1; launches and closes a Chromium to prove the browser binary is actually on the machine; probes the frontend, backend /v1/health, Keycloak and β€” locally, or whenever LDE_URL is set β€” the LDE backend /v1/health; then calls verifyRequiredProcesses() and, separately, verifyRequiredDecisions() against the engine. Each failure throws naming what is missing and how to fix it, rather than surfacing later as a confusing connection error. It starts nothing itself, which is exactly why it is not in CI.
  • The decision check is a separate gate for a reason. Decisions deploy without an Organization so a tenant-scoped process can reach them, so a missing or tenant-pinned DMN does not show up as a missing process β€” it surfaces mid-journey as a 500 on process start, or on a citizen's screen as "probeer het opnieuw", neither of which mentions a decision.
  • "Starts its own server" is not the same as "self-contained." public-site declares a webServer and still needs the backend; pa-demo declares one and needs nothing. Only pa-demo is genuinely cold-startable, which is why it is the suite that runs in CI.
  • Both conditional configs skip the server entirely when E2E_BASE_URL is set, pointing baseURL at a deployed site instead β€” the post-deploy verification path against ACC. reuseExistingServer is on outside CI, so a dev server you already have running is attached to rather than duplicated. The frontend config has its own equivalent in e2e/helpers/target.ts, which reads FRONTEND_URL, BACKEND_URL, KEYCLOAK_URL, LDE_URL and OPERATON_URL and falls back to localhost for each.

workers: 1 on the frontend suite is deliberate, not a leftover

The config pins a single worker and says why: every Operaton-touching spec shares the same stateful local engine, and two files creating identically-named tasks for the same caseworker β€” tenant-isolation.spec.ts and zorgtoeslag-journey.spec.ts, both "Case review: provisional entitlement decision" β€” raced when run in different workers. A .first() task-list match grabbed the other file's task mid-flight, producing a real Operaton save conflict ("Opslaan mislukt"), not merely a bad selector. One worker serializes everything, trading suite speed for correctness.

This is the one place in the repository where serial execution is the right answer, and it is worth contrasting with the unit suites, where it is not β€” see Overview. The difference is that here the shared state is real and external; there it is the machine's own CPU.

Count these with the runner, never with grep

A static count of test( calls across the eleven frontend specs gives 24 at 86af73e, and still 24 at 2443adc, ae06c9e and 0625d48; across the fifteen at ebec288 it gives 34. The runner reported 27 across ten of them on 30 August, 28 across eleven on 3 October, and 47 across fifteen on 9 October. login-redirect.spec.ts alone declares one test( and runs five, because the cases are parameterised; landing-single-board.spec.ts declares six and runs thirteen, from loops over two tenants, two paths and two redirect targets; and citizen-services.spec.ts declares one and runs three. rip-r21-journey.spec.ts contains two test.skip(true, reason) calls inside test bodies, runtime skips that a naive grep reads as skipped declarations, and one of which fired on 3 and 9 October; landing-single-board's test.skip(isLocalTarget, …) skips a whole block, four tests, on a local target. None of that is visible from the source text.

This is why thuisbatterij-journey.spec.ts was listed without a test count until it was run, rather than with the 1 its source shows. The runner agreed on 3 October, but only the run could say so.

The public-site suite needs the backend, and says nothing useful without it

Run with no backend on :3002, three of its six fail on timeouts β€” the two search-journey tests and the detail-page axe scan, all of which need search results. That is an unmet dependency, not a regression: the same specs at the same commit pass 6/6 once the backend is up. Re-running serially reproduces the same three, so it is not contention either.

3 and 9 October were the other case, and the check told them apart. With the backend up, the default parallel run failed two both times β€” the two search-journey tests, waiting 10s for the filter checkbox β€” while the detail-page axe scan, which also needs search results, passed. Re-run serially it was 6/6, in 7.8s and 6.4s. A missing backend fails the same three serially and in parallel; this failed two in parallel only. That is consistent with contention, not with an absent dependency, and a parallel-only failure is not a defect until it fails on its own β€” recorded as 6/6 serially; 2 timeouts in the parallel run.

The PA-demo suite is covered on its own page β€” see PA-demo suite. It is in CI because it needs no backend, database or Keycloak: Playwright starts its own dev server and that is the whole environment. The other two need a running stack, which is the whole of why they are not there yet β€” though the gap is narrower than it looks: the public-site suite needs one service, not five, and starts its own dev server already.

Deploying the E2E fixtures

Since v2026.10.1 the gate also needs the Heusden bundle, which this command does not deploy

e2e/helpers/required-processes.ts now lists nine process definitions β€” HeusdenpasAanvraagProcess and HeusdenpasBeoordelingSubProcess under tenant heusden beside the seven before β€” and three more untenanted decisions, SVB_LeeftijdsInformatie, SZW_BijstandsnormInformatie and RONL_HeusdenpasEindresultaat. Its own comment says why they are different: there is no E2E fixture variant of that bundle. They are deployed from linked-data-explorer's examples/organizations/heusden/swimlanes-for-rba/, under the same keys everywhere, with Organization heusden for the two processes and none for the three decisions. npm run e2e:deploy-fixtures reads only e2e-fixtures/manifest.json, so on a fresh engine globalSetup will refuse to start until the Heusden bundle has been deployed by hand β€” see Local development. The 9 October run passed those gates on a stack where it already was.

New in v2026.09.13, and read at 963fe24 rather than run: the repository root gained a script for the one precondition the table above describes but cannot help you meet β€” the process and decision bundle that the frontend suite's globalSetup refuses to start without.

npm run e2e:deploy-fixtures    # repo root

It is a shim. The deployer lives in linked-data-explorer

scripts/deploy-e2e-fixtures.mjs in this repository deploys nothing. It resolves a linked-data-explorer checkout β€” LDE_PATH, defaulting to a sibling directory β€” and, if <LDE>/scripts/deploy-e2e-fixtures.mjs is not there, exits 1 saying so and naming the two ways to fix it. Otherwise it runs that script with cwd set to the LDE checkout and passes arguments and the exit code straight through.

The deployer moved because two copies had already drifted. Both read ronl:documentRef out of the BPMN; when that attribute became a comma-separated list, the copy here silently stopped matching any template and the whole bundle would have failed to deploy. The fixtures, the manifest and the deploy route all live in linked-data-explorer, so the script does too β€” and the command name stays here, because this repository's E2E gate is what needs the bundle and what prints the command when it is missing.

What the real deployer does, and what it needs:

Talks to the linked-data-explorer backend at LDE_URL, default http://localhost:3001 β€” never straight to Operaton, so the bundle is also recorded in LDE's own store and the boardOwner tag is derived by the same code a Modeler deploy uses
Refuses any target that is not on this machine. It asks GET /v1/dmns/process/deploy-target first and rejects a host that is not localhost, 127.0.0.1 or ::1 β€” a fixture bundle on a shared tier is drift, not a deployment
Reads linked-data-explorer/e2e-fixtures/manifest.json, plus the external zorgtoeslag_resultaat rules set from TTL_EDITOR_REPO, default ../ttl-editor β€” the manifest names that decision but deliberately does not ship it
Step 1 every file under sharedDecisions.files through POST /v1/dmns/deploy, without a tenant id, which is the point: each businessRuleTask resolves its decision with decisionRefTenantId="${null}"
Step 2 one POST /v1/dmns/process/deploy per top-level manifest entry β€” sub-processes, forms and documents in the same request, organization set to the tenant directory it lives in β€” one request per entry, as one Modeler action is
Exit 0 when everything deployed, 1 on the first failure. Every run deploys a new version of everything, since LDE disables Operaton's duplicate filtering; the gate reads the latest version, so a run always leaves the engine matching the fixtures on disk

The gate prints this command, which is why the shim had to stay

packages/frontend/e2e/global-setup.ts checks four things, in order, and throws on the first that fails with a message naming the fix:

  1. Chromium actually launches. It launches a browser and closes it rather than comparing version strings β€” Playwright keeps its browsers outside node_modules, so npm ci installs a new Playwright without fetching the build it needs, and every spec would otherwise die in browserType.launch with the one explanatory line buried in the first of N identical failures. The fix it names is npx playwright install chromium.
  2. Four services answer: the frontend, the backend's /v1/health, Keycloak, and β€” locally, or whenever LDE_URL is set β€” the LDE backend's /v1/health. Three seconds each locally, fifteen against a remote tier.
  3. Every process in the manifest is deployed under its tenant (verifyRequiredProcesses()).
  4. Every decision those processes call is deployed without an Organization (verifyRequiredDecisions()), so a tenant-scoped process can reach it.

Failures 3 and 4 both print npm run e2e:deploy-fixtures as the fix β€” which is precisely why the shim stays in this repository after the deployer left it. A gate that names a command has to name one that exists.

Checks 3 and 4 are separate on purpose. A missing or tenant-pinned DMN does not show up as a missing process; it surfaces mid-journey as a 500 on process start, or on a citizen's screen as "probeer het opnieuw", neither of which mentions a decision.

What a full local E2E run actually needs

Five things, and none of them is started by Playwright for this suite:

  • docker compose up -d at this repository's root β€” Keycloak, Postgres, Redis;
  • npm run dev at this repository's root β€” frontend on :5173 and backend on :3002;
  • npm run dev:backend in the linked-data-explorer checkout β€” the LDE backend on :3001, which is also what the fixture deploy goes through;
  • a ttl-editor checkout beside them, for the one external decision;
  • npx playwright install chromium, then npm run e2e:deploy-fixtures.

Since v2026.10.1, also the Heusden bundle β€” see the warning at the top of this section. Only then does npm run test:e2e --workspace=@ronl/frontend get past its own preconditions. That list is the whole reason this suite is not in CI, and the reason its figures went unmeasured from 30 August until a pass on 3 October found the stack already up.


Frontend Playwright suite

packages/frontend/e2e/, its own playwright.config.ts (npm run test:e2e --workspace=@ronl/frontend). Chromium only, workers: 1.

The single worker is deliberate: two specs race to claim an identically-named task for the same caseworker against the shared local Operaton engine, so the suite trades parallelism for correctness.

Measured 9 October 2026: 47 tests across 15 specs β€” 42 passed, 5 skipped, 2.6m, one worker, against the developer's already-running local stack. Nothing failed and nothing was retried. The previous run, on 3 October 2026, was 28 tests across the 11 specs there were then: 27 passed, 1 skipped, 2.8m; before that, on 30 August 2026 against acc at 15dfbf9, 27 tests across 10 specs, 27 passed, 1.9m.

Spec Tests 9 October Covers
landing-single-board.spec.ts 13 9 passed, 4 skipped β€” the link-preview tests, on a local target New in v2026.10.1. The organisation landing pages /amsterdam and /heusden: the single-board layout, the caseworker login in the tenant's colours, the DigiD link with the tenant's test citizen, the per-tenant link preview, and the redirects from /?tenant= and to the grid
infra-board-journey.spec.ts 7 7 passed The Infra-board, including a full sweep asserting no failed request and no console error
pa-mock-journey.spec.ts 5 5 passed PA cockpit mock mode against the real store
login-redirect.spec.ts 5 5 passed Role-based landing, parameterised per role
citizen-services.spec.ts 3 3 passed New in v2026.10.1. The service cards each organisation's test citizen sees, parameterised per citizen (#344)
protected-route.spec.ts 3 3 passed Route guards
pa-live-authoring.spec.ts 2 2 passed Authoring against the live backend and a real database
landing-access-denied.spec.ts 2 2 passed New in v2026.10.1. The no-access dialog on the landing page, and the Flevoland-account button on the boards with an Entra role
rip-r21-journey.spec.ts 1 skipped β€” real ValidSign on the stack The R2.1 phase, all twelve tasks, ending in the signing panel
caseworker-journey.spec.ts 1 passed, 15.0s The caseworker journey end to end β€” since v2026.10.1 through the Dutch Kapvergunning forms
heusdenpas-journey.spec.ts 1 passed, 11.8s New in v2026.10.1. A fourth deep two-persona journey: a Heusden citizen applies for a Heusdenpas, three untenanted decisions grant it, and the Heusden caseworker decides and informs
thuisbatterij-journey.spec.ts 1 passed, 7.7s A citizen applies for a Thuisbatterij subsidy, the six-decision RechtEnHoogteSubsidieThuisbatterij DRD evaluates, and the caseworker reviews the resulting task
tenant-isolation.spec.ts 1 passed, 7.5s Tenant scoping
zorgtoeslag-journey.spec.ts 1 passed, 6.2s The zorgtoeslag journey
smoke.spec.ts 1 passed Boot and render

What the Heusdenpas journey guards beyond its own service

Read from the spec at ebec288. Every step of it submits a form with a field that is required but hidden β€” the partner's date of birth for a single applicant, the missing details when the application is complete, the motivation when the caseworker accepts the outcome β€” so the journey stalls on a submit that never goes through if form-js ever validates a hidden field. Its applicant is single, 21 or over, without children and on a Baanbrekers benefit, so the rules grant the pass automatically (linked-data-explorer's verified case 6). It is also the one journey that ends back on the citizen's side, checking that the decided application appears once in Mijn aanvragen, not once per sub-process.

The thuisbatterij journey accepts both task names β€” edited in v2026.09.12

0e71fd3, "match the Thuisbatterij tasks by their Dutch names as well", widened the two task matchers the caseworker half of the journey depends on, because the Thuisbatterij process definitions β€” which come from linked-data-explorer β€” rename those tasks in the swimlane redesign. Each regex now accepts the old English name or the new Dutch one:

Task Before the redesign After it
The review Case review: recht en hoogte subsidie… Beoordeling behandelaar: recht en hoogte subsidie…
The follow-up notify task Phase 6: Notify applicant of decision Fase 6: Aanvrager informeren over besluit

Accepting both rather than switching to the new names is deliberate, and the commit says why: the journey should pass on engines still running the old definitions and on those running the new ones. The test count is unchanged β€” one test(). Read from the spec at 2443adc; the spec was first run by these pages on 3 October 2026, and passed.

Three more specs accept the Dutch names, and the login helper matches exactly β€” v2026.09.14

linked-data-explorer redrew the Kapvergunning and Zorgtoeslag processes in swimlanes with Dutch task names, and three specs followed the Thuisbatterij one, each regex accepting the old English name or the new Dutch one so the journeys pass against either deployment:

Spec Commit Review task, after the redesign Notify task, after it
caseworker-journey.spec.ts 10e83ce Beoordeling behandelaar: besluit kapvergunning Fase 6: Aanvrager informeren over besluit
zorgtoeslag-journey.spec.ts 1f0e52b Beoordeling behandelaar: besluit voorlopige aanspraak Fase 6: Aanvrager informeren over besluit
tenant-isolation.spec.ts 1f0e52b Beoordeling behandelaar: besluit voorlopige aanspraak Fase 6: Aanvrager informeren over besluit

In tenant-isolation.spec.ts the change matters more than a selector usually does: REVIEW_TASK_NAME became a regex so that the check that a Flevoland caseworker cannot see the task stays meaningful after the rename, instead of passing on a name that no longer exists. A negative assertion against a stale name is green for the wrong reason.

Separately, e2e/helpers/auth.ts now finds the medewerker login button with { name: 'Inloggen', exact: true } (9f54e82), because the landing page's new "Inloggen met uw Flevoland-account" button made the substring match ambiguous and Playwright's strict mode refused to click either. Every spec that signs a medewerker in goes through that helper. Test counts are unchanged in all four files; read from the source at ae06c9e, and all four passed when run on 3 October.

What the thuisbatterij journey is actually guarding

Read from the spec at 86af73e, not run; the passage below is unchanged at 2443adc. Its processes deploy under tenant-id flevoland while the DMNs they call deploy without a tenant, so every business-rule task carries camunda:decisionRefTenantId="${null}" to reach them. Drop that attribute and the engine refuses to instantiate at all β€” "no decision definition deployed with key 'AwbCompletenessCheck' and tenant-id 'flevoland'" β€” which reaches a user as a 500 from POST /v1/process/:key/start and an unexplained "aanvraag kon niet worden ingediend". That is an ACC outage that already happened once; this spec exists to catch the next one before a deploy repeats it, which is also why globalSetup gained its separate decision check.

rip-r21-journey.spec.ts now starts R2.1 with a project identity β€” issue #165 is closed

Fixed in aadedfc on 20 September 2026 and closed as completed the same day. Established by reading the spec and the component at 86af73e on 24 September, not by running either.

Through v2026.09.9 the spec clicked the R2.1 start button without filling the two fields v2026.09.8 had made required, so it waited ninety seconds for a control that could never become enabled. The spec had not been updated alongside its component; the component was right.

What it does now (rip-r21-journey.spec.ts:617-630):

const startForm = page.locator('.pb-new-project');
const startButton = page.locator('.pb-new-project + button');
await expect(startForm).toBeVisible();
await expect(startButton).toHaveText(/R2\.1 starten/);

await startForm.getByLabel('Projectnummer').fill(PROJECT_NUMBER);
await startForm.getByLabel('Projectnaam').fill(PROJECT_NAME);
await expect(startButton).toBeEnabled();

Three things in that block are worth copying rather than merely reading:

  • Both fields are filled from module-scope fixtures β€” E2E-26014 and "E2E β€” R2.1 journey (test, safe to delete)" β€” deliberately unmistakable rather than realistic, so a project this spec leaves behind is obvious in the board.
  • toBeEnabled() comes before the click. That turns the gate into an assertion instead of an implicit wait, so the next regression here names the gate rather than timing out on it.
  • The locator is pinned structurally to the block the step means, and what it found is asserted before it is clicked. Two buttons in this tab read "R2.1 starten" β€” this one and the bulk "start the selected projects" β€” in different branches of PhaseDetail's ternary. They cannot render together today, so a bare getByRole resolves; pinning keeps that true if the branches ever converge.

A further assertion switches to the WIP tab and checks the started instance carries the number and name it was started with. Without it the spec would pass just as happily against a build that dropped both values on the floor.

The component confirms it, read at the same commit: PhaseDetail.tsx renders both inputs as required inside <div className="pb-new-project">, and the sibling button is

disabled={submitting || !newProjectReady}
const newProjectReady = newProjectNumber.trim() !== '' && newProjectName.trim() !== '';

Spec and component now agree.

The R2.1 journey is the one to watch after a signing change. Because the approval task carries ronl:signatureRef, the board renders the signing panel where a form used to be β€” and the journey previously drove every task by filling a form, so it failed on the last one reporting that a form never rendered. A true statement about a task that no longer has one. Issue #165 was the same failure mode one step earlier in the journey: the UI grew a precondition and the spec did not hear about it. Twice in two releases, in one spec, is the pattern to take from it β€” this journey drives more UI surface than any other here, so it is the first to notice when that surface moves.

It also carries two test.skip(true, reason) calls inside test bodies, which skip the run when preconditions are not met and log the reason first. There was one at v2026.09.9; the second arrived in 097ff84, "skip rather than fail where a tier signs for real" β€” a tier with real signing configured cannot complete the journey's approval task, and skipping with a reason is the honest outcome there rather than a red run. Neither skipped in the 30 August measurement, which predates both. On 3 October the second one fired, on a local stack configured with VALIDSIGN_STUB_MODE=false, and logged:

[rip-r21-journey] SKIPPED β€” local dev stack signs with the real ValidSign
(VALIDSIGN_STUB_MODE=false), and this journey will not request a binding
signature. Nothing was created β€” the refusal happens before
POST /task/:id/package. Run it against a target where VALIDSIGN_STUB_MODE=true.

That is the outcome the commit designed for, and it is why the 3 October run read 27 passed, 1 skipped rather than 28 passed. On 9 October it fired again, with the same message. It also means the R2.1 work β€” and v2026.10.0's signing changes, the panel now shared with the caseworker inbox and signing state kept per task, and v2026.10.1's signer recorded as the eDOCS author β€” was not driven in a browser by either pass. A run against a target in stub mode is what would.

Coverage per board

The 47 tests do not spread evenly, and seven of the fifteen specs belong to no board. This table is the one to check before claiming a board has or lacks end-to-end coverage β€” the per-board pages defer to it.

Board Specs Tests Which
Infra-board 2 8 infra-board-journey (7, the shell), rip-r21-journey (1, the work β€” skipped on 3 and 9 October, see above)
PA cockpit 2 7 pa-mock-journey (5), pa-live-authoring (2)
Caseworker 4 4 caseworker-journey (1), zorgtoeslag-journey (1), thuisbatterij-journey (1), heusdenpas-journey (1, new in v2026.10.1) β€” each ending in a caseworker's task; the first three match the Dutch task names as well as the English ones since v2026.09.14
Woo-dashboard 0 0 β€”
Citizen portal and landing pages 3 18 landing-single-board (13, four of them skipped on a local target), citizen-services (3), landing-access-denied (2) β€” all new in v2026.10.1
No single board 4 10 login-redirect (5), protected-route (3), tenant-isolation (1), smoke (1)

The last two rows are the reason a naive per-board sum of the boards alone does not reach 47. The citizen's dashboard and the organisation landing pages are not boards and have no board page of their own β€” their unit tests are counted on Overview among the frontend files that belong to no board β€” and authentication redirects, route guards, tenant scoping and the boot smoke test cut across every board and belong to none.

The Caseworker row is the board row that moved: heusdenpas-journey.spec.ts is a fourth deep journey ending in a caseworker's decision, and the first for an organisation other than Flevoland and Dienst Toeslagen.

No spec drives Besluitvorming, the caseworker section v2026.10.0 added; it is covered by unit tests only β€” see Caseworker.

Re-derive this table from the spec directory, not from the release being synced

The Infra-board specs landed on 24 August 2026 and this documentation continued to record the board as having no end-to-end coverage at all through two subsequent syncs. Nothing in a changelog-driven pass pointed at them, because neither release that added them was the one being documented. A per-board E2E claim is only as current as the last time somebody listed packages/frontend/e2e/.

What it needs running

docker compose up -d at the repo root (Keycloak + Postgres + Redis), the backend and frontend dev servers, and a sibling linked-data-explorer repo's backend on :3001 β€” the last is required for the Procesbibliotheek journey.

e2e/global-setup.ts, re-read at 86af73e, probes four URLs before any test runs β€” http://localhost:5173, http://localhost:3002/v1/health, http://localhost:8080 for Keycloak, and http://localhost:3001/v1/health β€” and throws with each missing one named:

E2E preconditions not met β€” target: local dev stack.
- Frontend not reachable at http://localhost:5173
- Backend not reachable at http://localhost:3002/v1/health
- Keycloak not reachable at http://localhost:8080
- LDE backend not reachable at http://localhost:3001/v1/health

The LDE probe runs locally, or whenever LDE_URL is set explicitly β€” a shared tier has no LDE backend under a predictable name, so it is skipped there rather than failed. Against a remote target the per-probe timeout rises from 3s to 15s, because a cold-started App Service is slower to answer than loopback.

Two further gates run after the probes, and each throws with its own instructions: verifyRequiredProcesses() checks the fixture bundle is deployed under the right tenant, and verifyRequiredDecisions() checks the DMN definitions are deployed without an Organization. A decision listed under an Organization is the failure mode here, not an absent one.

Before any of that, globalSetup launches and closes a Chromium. Playwright keeps its browsers outside node_modules, so npm ci installs a new Playwright without fetching the build it needs; every spec would otherwise die in browserType.launch and the one line explaining it would be buried in the first of N identical failures. The launch costs about a second and is the thing that actually has to work, which is why it is preferred to comparing versions or guessing at paths.

It also refuses to run against production unless CONFIRM_PROD=1 is set. These journeys are not read-only β€” they start real process instances and complete real tasks, which against production is real case data in the audit log.

It does not start anything itself, and playwright.config.ts declares no webServer, which is why this suite is not wired into CI: there is no human to start the stack on a runner. The PA-demo suite has no such dependency and does run in CI β€” the difference is the stack, not the tooling. See Overview β†’ Roadmap for what closing that would take.

The probe deliberately builds its own AbortController rather than using AbortSignal.timeout(): the latter did not always clean up its internal timer before the fetch settled, crashing Node on Windows with a libuv UV_HANDLE_CLOSING assertion during process exit. That crash used to be listed as a blocker for putting this suite in CI and no longer is.

Getting JSON output

The config declares list and html reporters, not json. To capture machine -readable results:

PLAYWRIGHT_JSON_OUTPUT_NAME=../../playwright-report/frontend-e2e.json \
  npm run test:e2e --workspace=@ronl/frontend -- --reporter=list,json

Three things will bite otherwise. PLAYWRIGHT_JSON_OUTPUT_NAME resolves against the cwd, which under --workspace is packages/frontend, hence the ../../. Passing --reporter replaces the configured list, so the auto-opening HTML report is lost unless you add it back. And do not redirect stdout to a file: globalTeardown prompts interactively there (Clean up Operaton history…? [y/N]), so the prompt would be swallowed into the file and the terminal would appear to hang.

The HTML report also embeds the same result data, which is where the 20 August figures came from when no JSON reporter was configured.


Public-site Playwright suite

Covered on Public site suite β€” six tests including three axe-core accessibility scans, and the one suite here that starts its own dev server.

Re-run on 9 October 2026, against the developer's already-running backend β€” and, as on 3 October, the one suite in the pass that went red before it went green, the same two tests in the same way:

Run Workers Result Time
9 Oct, default (fullyParallel: true, no workers set) 6 4 passed, 2 failed β€” publiek.spec.ts:6 search β†’ filter β†’ detail β†’ back preserves the filtered URL and :27 a deep link with filters pre-applied renders those filters checked, each a TimeoutError after 10s waiting for getByRole('checkbox', { name: /Regel/ }) 16.8s
9 Oct, --workers=1 1 6 passed 6.4s
3 Oct, default 6 4 passed, 2 failed β€” the same two, the same wait 17.2s
3 Oct, --workers=1 1 6 passed 7.8s

Recorded as 6/6 serially, with 2 timeouts in the parallel run β€” not as a defect. Both failures are the same wait in the two tests that need a filtered result list; the detail-page axe scan, which also needs search results, passed in the same parallel run, and the serial run passed all six against the same backend. That is consistent with contention, not with a missing backend (which fails three, serially too), and nothing failed on its own. It is worth watching rather than dismissing β€” two passes a week apart now agree on exactly which wait gives way: if this suite is wired into CI β€” see Overview β†’ Roadmap β€” that 10-second wait under parallel workers is the first thing the run will test. The spec is unchanged since 2443adc and still in no workflow. See Public site suite.


Live smoke suite (shell scripts, cross-app)

Four gated shell scripts under scripts/, deliberately kept out of npm test β€” they hit real running services over the network, mutate real data in three cases (one of them only data it created itself), and need real credentials for some tiers β€” and, new in v2026.10.1, a fifth that runs two of them as a signed-in person.

These were not run for this page. They are described from their configuration and source only β€” test-m2m-routes.sh, test-smoke-live.sh and test-edocs-person.sh read at ebec288.

Script Covers Mutates?
test-smoke-live.sh Cross-app health: Operaton, Keycloak, LDE, TriplyDB, CPRMV, media store, eDOCS reach/status, MCP layer. Since v2026.10.1 Tier 2b also asks /v1/edocs/status as the seeded caseworker, who has no Entra identity and must be reported as such (skipped in stub mode), and an optional Tier 2c runs when PERSON_TOKEN holds a Flevoland-signed-in person's Keycloak token, passing only if eDOCS knows that person No
test-edocs-live.sh eDOCS through /v1/edocs and through the MCP route: workspaces listed read-only, standalone documents uploaded and read back β€” see eDOCS β€” Live Testing Yes β€” every run leaves its uploaded documents behind; nothing is deleted
test-doccle-live.sh Doccle sender API β€” see Doccle β€” Live Testing Yes β€” not yet live-tested, still DOCCLE_STUB_MODE=true in every run so far
test-m2m-routes.sh The active /v1/m2m operations: the reads, decision.evaluate, and β€” since v2026.09.14 (#214) β€” a write lifecycle of start, claim, complete and delete. Since v2026.10.0 it also asserts that POST /v1/m2m/process/history filters β€” a request filtered on one processDefinitionKey must return at least one instance and none of another key, since a dropped filter answers 200 with the whole history (#263); since v2026.10.1 that the GET spelling answers 404, removed after one deprecated release β€” against an older backend that check fails, which says which build you are talking to (in v2026.10.0 it asserted Deprecation: @1790985600 instead); and that start and complete refuse a caller-supplied municipality with 400 RESERVED_VARIABLE (#261), the refused completion leaving the task open for the real one. Its tenant-isolation check reads the problem's code. Local by default, reading the seeded client secret from the realm file; TARGET=acc needs an explicit CLIENT_SECRET, and on ACC the M2M surface now uses ACC's main engine (#262) Yes, but only its own: the lifecycle starts two instances and removes both, and never writes to an instance it did not create β€” if the reserved-variable guard on start ever failed, the stray instance is cancelled at once

| test-edocs-person.sh | New in v2026.10.1. Not a suite of its own: it reads a Flevoland-signed-in person's Keycloak token from the clipboard (or an exported PERSON_TOKEN), checks it, prints only its claims, and hands it to test-edocs-live.sh (live, sections 1b and 1c) or test-smoke-live.sh (smoke, Tier 2c); diag asks Keycloak's broker endpoint for the person's stored Entra token β€” status and key names only β€” and shows what the backend makes of the person. Targets local and acc, and prod for smoke. See eDOCS β€” Live Testing | Only through live, as test-edocs-live.sh does |

bash scripts/test-smoke-live.sh                                     # local, full run
CLIENT_SECRET=<secret> TARGET=acc bash scripts/test-smoke-live.sh   # against ACC
bash scripts/test-edocs-live.sh                                     # eDOCS, mutating
bash scripts/test-edocs-person.sh live|smoke|diag [local|acc]       # as a signed-in person
CLIENT_SECRET=<secret> bash scripts/test-doccle-live.sh              # Doccle, mutating
bash scripts/test-m2m-routes.sh                                     # M2M routes, local
TARGET=acc CLIENT_SECRET=<secret> bash scripts/test-m2m-routes.sh   # M2M routes vs ACC

Exit 0 when nothing failed, 1 on any real failure β€” a dependency that is intentionally off (stub mode, no CLIENT_SECRET) skips with a ~ note, never a red fail. curl http://localhost:3002/v1/health | jq ., or the ACC equivalent, is the fastest single check of a running instance's dependency status, independent of the smoke scripts.


Rate limiting will masquerade as an outage

The backend rate-limits per IP. A short authoring journey measures ~21 requests to /v1/pa/*, so two specs back to back can exhaust a low budget, and the UI renders the resulting 429 as "Kon dossiers niet laden" β€” indistinguishable from a backend that is down.

e2e/helpers/rate-limit.ts records the first 429 of a run and fails the test with a message naming the throttle. It deliberately does not retry or wait it out. The shipped default was raised to 1000/min in config.ts; note the limiter keys on IP, so TRUST_PROXY decides whether that budget is per user or per deployment.

The full account of how that was diagnosed β€” including two confident wrong answers before anyone looked at the response codes β€” is on Writing tests.