# 18-Apr-2026 — WDIO → Playwright: migrating Serenity/JS to the official image, and why real-time step streaming broke

_____

This is a write-up of the WebdriverIO → Playwright migration completed on the NGX Ramblers project under [issue #114](https://github.com/nbarrett/ngx-ramblers/issues/114). Two specific things are worth sharing: why the move was an obvious win operationally, and why the real-time Serenity/JS domain-event publisher — which had worked cleanly under WDIO since [commit `48aca026`](https://github.com/nbarrett/ngx-ramblers/commit/48aca02617e120b33c1c8dac054b281f8a29be66) (May 2025) — went silent under `@serenity-js/playwright-test` without any obvious error.

The fix is pragmatic and works. The interesting bit is what was discovered about how `@serenity-js/playwright-test` surfaces domain events, and a small suggestion that a first-class real-time option would close the gap we ran into.

## The feature: walks upload with live progress in the admin UI

Our Ramblers walks upload flow runs a Serenity/JS scenario that logs into Ramblers Walks Manager, selects walks by date and title, deletes existing ones, uploads a CSV, publishes, and so on. The whole thing takes one-to-two minutes and happens off to the side of the admin UI. Users want to see progress as it happens, not a blank screen followed by a summary.

![](https://ngx-ramblers.org.uk/api/aws/s3/site-content/3cb5fcb8-116d-47b9-88bf-bbd56fcd9468.gif)



The original design, as shown in the animated example above was introduced in [`48aca026`](https://github.com/nbarrett/ngx-ramblers/commit/48aca02617e120b33c1c8dac054b281f8a29be66), was clean and idiomatic Serenity:

- A custom `StageCrewMember` called `DomainEventPublisher`
- On `notifyOf(event)`, filter for `InteractionFinished` / `TaskFinished` / `TestSuiteFinished` / `SceneParametersDetected`
- Serialise each event and publish it over a WebSocket to the backend, which forwards it to the UI over its own WebSocket

That architecture is exactly what Jan suggested in [Serenity/JS discussion #2797](https://github.com/orgs/serenity-js/discussions/2797#discussioncomment-13148635) — treating domain events as a first-class stream from the test runner to any interested listener.

Under WebdriverIO the pipeline is synchronous and live end to end:

```mermaid
flowchart LR
    UI@{ icon: "ngx:user", label: "Admin UI", pos: "b", h: 48 }

    subgraph subprocess["Serenity/JS subprocess"]
        ACTOR["Actor performs Click / Enter / Wait"]
        STAGE["Serenity Stage"]
        CREW["DomainEventPublisher"]
        WSCLIENT["WebSocket client"]
    end

    subgraph backend["NGX Ramblers backend"]
        NODE@{ icon: "logos:nodejs-icon", label: "Node spawn", pos: "b", h: 48 }
        WS@{ icon: "ngx:ramblers", label: "WebSocket server", pos: "b", h: 48 }
    end

    ACTOR --> STAGE
    STAGE --> CREW
    CREW --> WSCLIENT
    WSCLIENT --> WS
    NODE --> subprocess
    WS --> UI

    style subprocess fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style backend fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
```

Under WebdriverIO, every activity an actor performs flows synchronously through the Stage. `InteractionFinished` is announced the moment that interaction ends; the crew member receives it, serialises it, and hands it to the socket. The UI sees each step appear within a few hundred milliseconds of the action actually happening on the page.

This worked cleanly for about a year.



## Why migrate to Playwright

Two mounting costs made WDIO feel like a tax:

- **Browser lifecycle**: we were installing Chrome and ChromeDriver via `@puppeteer/browsers` into the worker image at build time, matching versions by hand, and shipping `CHROME_BIN` / `CHROMEDRIVER_PATH` through the runtime env. Every Chrome bump meant image rebuilds and config.
- **Official path exists**: Serenity/JS publishes the [`ghcr.io/serenity-js/playwright`](https://github.com/serenity-js/serenity-js/pkgs/container/playwright) image with Node 24, OpenJDK, and Playwright browsers pre-installed on Ubuntu 24.04. If we used it, none of the browser plumbing was ours to maintain.

The screenplay code was already framework-agnostic — tasks and questions are written against `@serenity-js/web` (`PageElement`, `Click`, `Enter`, `Navigate`). Only the ability layer and runner were WDIO-specific. The migration shape was therefore:

- swap `@serenity-js/webdriverio` for `@serenity-js/playwright` + `@serenity-js/playwright-test`
- replace `wdio.conf.ts` with `playwright.config.ts`
- replace mocha's `describe`/`it` with the `@serenity-js/playwright-test` `actorCalled` fixture
- delete Chrome/ChromeDriver install from the Dockerfile worker stage

Moderate, not a full rewrite. And that's how it played out — until the first end-to-end test.

## The silent failure

The migrated scenario worked. The walks uploaded. The report generated. But the audit view in the UI stayed empty until the browser closed at the end of the run, then every step appeared at once in a one-second burst.

Worse, all the step audits ended up with identical timestamps (same wall-clock millisecond) because they were being written to MongoDB concurrently with `dateTimeNowAsValue()` as their `auditTime`.

Under `@serenity-js/playwright-test` the same `DomainEventPublisher` saw events only via a buffered end-of-test flush:

```mermaid
flowchart TB
    subgraph subprocess["Serenity/JS subprocess"]
        ACTOR["Actor performs Click / Enter / Wait"]
        PWORKER["Playwright test worker"]
        REPORTER["SerenityReporterForPlaywrightTest"]
        BUFFER["PlaywrightEventBuffer"]
    end

    subgraph batch["End-of-test flush"]
        FLUSH["onTestEnd → flush all events"]
        BATCH["Events announced in one burst"]
        STAGE["Serenity Stage"]
        CREW["DomainEventPublisher"]
    end

    BACKEND@{ icon: "ngx:ramblers", label: "Backend /progress", pos: "b", h: 48 }

    ACTOR --> PWORKER
    PWORKER --> REPORTER
    REPORTER --> BUFFER
    BUFFER --> FLUSH
    FLUSH --> BATCH
    BATCH --> STAGE
    STAGE --> CREW
    CREW --> BACKEND

    style subprocess fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style batch fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
```

The `DomainEventPublisher` looked healthy from the outside: it was attached to the Stage, it was receiving events, it was serialising them, it was POSTing them. The POSTs just all happened in one clump at the end of the run.



## The diagnosis

Reading `@serenity-js/playwright-test`'s reporter source ([`SerenityReporterForPlaywrightTest.js`](https://github.com/serenity-js/serenity-js/blob/main/packages/playwright-test/src/reporter/SerenityReporterForPlaywrightTest.ts)) made it click. The real flow is:

```js
onTestEnd(test, result) {
    // ... retry / crash handling omitted ...
    this.eventBuffer.appendSceneEvents(test, result);
    if (pendingAfterAllHooks === 0) {
        this.eventBuffer.appendSceneFinishedEvent(test, result);
        const events = this.eventBuffer.flush(test, result);
        this.serenity.announce(...events);
    } else {
        this.eventBuffer.deferAppendingSceneFinishedEvent(test, result);
    }
}
```

`PlaywrightEventBuffer` accumulates an entire test's worth of domain events (`TaskStarts`, `TaskFinished`, `InteractionStarts`, `InteractionFinished`, `ActivityRelatedArtifactGenerated`, and so on) and then announces them to the Stage **in a single batch at `onTestEnd`**. Inside that batch, the events carry their original timestamps — so the order and durations are accurate once they arrive — but they don't arrive until the test is over.

This is architecturally sensible: Playwright's reporter API gives you `TestStep` info (`step.title`, `step.duration`, `step.error`) and Serenity/JS builds its domain-event stream from those. But the consequence for a real-time listener is the same: there is no point in the pipeline where a `StageCrewMember` can see `InteractionFinished` **as the interaction finishes**. The Stage only gets notified after the test has exited.

The corollary: under `@serenity-js/playwright-test`, `StageCrewMember.notifyOf` is not a real-time hook. It is the end-of-test flush point.



## The fix: a Playwright-native reporter

Because Playwright's own reporter API **does** fire `onStepBegin`/`onStepEnd` synchronously as steps execute, the fix is to hook Playwright directly and bypass Serenity's buffer for this one concern.  `RealtimeStepReporter` below was implemented [here](https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/serenity-js/reporters/realtime-step-reporter.ts),  bootstrapped in the playwright config [here](https://ngx-ramblers.org.uk/⁠https://github.com/nbarrett/ngx-ramblers/blob/main/server/playwright.config.ts ):

```ts
export default class RealtimeStepReporter implements Reporter {

  onStepEnd(_test: TestCase, _result: TestResult, step: TestStep): void {
    if (!this.configured) return;
    if (step.category !== "test.step") return;

    const timestamp = new Date(step.startTime.getTime() + (step.duration || 0)).toISOString();
    const payload = {
      eventData: {
        timestamp,
        details: { name: step.title },
        outcome: step.error
          ? { code: FAILED_OUTCOME_CODE, error: { message: step.error.message, stack: step.error.stack } }
          : { code: SUCCESS_OUTCOME_CODE }
      },
      finished: false
    };
    this.pendingPosts.push(this.post(payload));
  }

  async onEnd(): Promise<void> {
    if (this.pendingPosts.length > 0) await Promise.allSettled(this.pendingPosts);
  }
}
```

Registered alongside the Serenity reporter in `playwright.config.ts`:

```ts
reporter: [
  ["@serenity-js/playwright-test", { crew: [/* photographer, BDD, artifact archiver */] }],
  ["./lib/serenity-js/reporters/realtime-step-reporter.ts"],
  ["list"]
]
```

Filtering by `category === "test.step"` keeps fixture/hook steps out of the audit view; Serenity's `Activity.performAs` wraps each interaction in a `test.step()`, so the `step.title` is the activity description users already recognise from the old WDIO output.

With a Playwright-native reporter added alongside the Serenity one, per-step events stream live while Serenity still handles the end-of-run artefacts:

```mermaid
flowchart LR
    subgraph subprocess["Serenity/JS subprocess"]
        ACTOR["Actor performs Click / Enter / Wait"]
        PWORKER["Playwright test worker"]
        RTR["RealtimeStepReporter"]
        SRP["SerenityReporterForPlaywrightTest"]
    end

    subgraph backend["NGX Ramblers backend"]
        PROGRESS@{ icon: "ngx:ramblers", label: "Progress endpoint", pos: "b", h: 48 }
        WS@{ icon: "ngx:ramblers", label: "WebSocket push", pos: "b", h: 48 }
    end

    UI@{ icon: "ngx:user", label: "Admin UI", pos: "b", h: 48 }
    S3@{ icon: "ngx:aws", label: "Serenity report on S3", pos: "b", h: 48 }

    ACTOR --> PWORKER
    PWORKER --> RTR
    PWORKER --> SRP
    RTR --> PROGRESS
    PROGRESS --> WS
    WS --> UI
    SRP --> S3

    style subprocess fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style backend fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
```

Each step now arrives at the backend within a few hundred milliseconds of the action completing. The Serenity reporter is still attached to the same config, doing its usual end-of-run work — the BDD artefact, the Serenity-BDD report, the photographer, the artifact archiver. We haven't replaced Serenity; we've supplemented it with one extra Reporter for the one concern Serenity can't serve under Playwright.



### Other backend bugs this uncovered

Moving events to a faster pace also shook loose two backend issues that the old end-of-run batch had been hiding:

- **Concurrent record numbering**: the per-session `record` counter was read-then-written outside any mutex, so concurrent `sendAudit` callbacks would all read `record = N`, each compute `N + 1`, and write duplicates. Fixed by serialising `sendAudit` per-session via a promise chain.
- **Stale session reference**: `updateRamblersUploadSession` was `{ ...current, ...update }` into a new object and putting that in the Map, so captured `session` refs never saw `record` increments. Fixed by `Object.assign`-ing in place.

Both were latent before the migration; WDIO's per-step cadence wasn't concurrent enough to trip them.

## The architecture change that came with it

Once Playwright was the runtime, the natural follow-on was to get every browser session off the website process entirely. The website image had historically carried Playwright/WDIO dependencies because Flickr scraping, legacy-site migration, and URL-fetching for venue discovery all ran in-process.

That's now gone:

The website image is Node 24 only — no browser binaries, no Playwright, no OpenJDK. The integration worker image is the [`ghcr.io/serenity-js/playwright`](https://github.com/serenity-js/serenity-js/pkgs/container/playwright) base with the Serenity-BDD JAR pre-fetched.

```mermaid
flowchart TB
    UI@{ icon: "ngx:user", label: "Admin UI", pos: "b", h: 48 }

    subgraph website["Website image"]
        BACKEND@{ icon: "logos:nodejs-icon", label: "Express + Mongoose", pos: "b", h: 48 }
        HANDLERS["Migration, HTML fetch, Flickr, venue scraper"]
    end

    subgraph worker["Integration worker image"]
        JOBS["Async jobs endpoint"]
        BROWSER["Sync browser endpoint"]
        SUBPROCESS["Serenity/JS subprocess"]
    end

    MONGO@{ icon: "ngx:mongodb", label: "MongoDB Atlas", pos: "b", h: 48 }
    S3@{ icon: "ngx:aws", label: "S3 reports + content", pos: "b", h: 48 }

    UI --> BACKEND
    BACKEND --> MONGO
    BACKEND --> HANDLERS
    HANDLERS --> JOBS
    HANDLERS --> BROWSER
    JOBS --> SUBPROCESS
    SUBPROCESS --> BACKEND
    JOBS --> S3

    style website fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style worker fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
```



#### Details

- The worker fly app  is named `ngx-ramblers-integration-worker`.
- Synchronous browser operations (HTML fetch, Flickr albums) are signed HTTP request/response. Long-running flows (walks upload, static-site migration) are asynchronous jobs with signed progress/result callbacks.
- The website image no longer installs Playwright browsers, Chrome, ChromeDriver, `@puppeteer/browsers`, or any WDIO packages. Its `Dockerfile` stage is now just `node:24.14.0` + `mongosh`, `gh`, `flyctl`. OpenJDK 21 and `serenity-bdd-update` came off too — the JDK-backed report generation happens only on the worker.
- The old transport (`DomainEventPublisher`, `process-test-step-event.ts`, the worker's own WebSocket server, and a `TEST_STEP_REPORTER` event type in `websocket.model.ts`) was deleted outright once `RealtimeStepReporter` became the single real-time path. No duplicate delivery mechanisms.

End state: one browser image (the worker), one real-time step delivery path (Playwright's `onStepEnd` → signed POST → UI WebSocket), one audit persistence path (`sendAudit` serialised per session). All committed under [`58aa7fd1`](https://github.com/nbarrett/ngx-ramblers/commit/58aa7fd13a3eafd6f8b1b5e19c571ea82954939a) on top of [`b0a6d384`](https://github.com/nbarrett/ngx-ramblers/commit/b0a6d384366287f67be5e4650508c64a2a21c7c6) on issue #114.



## Observations — and a suggestion

The migration itself was straightforward; the discovery in the middle was the interesting part. Knowing in advance that `@serenity-js/playwright-test` batches domain events at `onTestEnd` would have saved an afternoon of puzzling. The `DomainEventPublisher` kept working well enough to look unbroken: it was being wired up correctly, it was receiving events, it was POSTing them — just all at the wrong moment.

Two things that would have saved me time, and might be worth thinking about for Serenity/JS:

1. **Document the buffering behaviour** somewhere in the `@serenity-js/playwright-test` docs. I looked at the [API overview](https://serenity-js.org/api/playwright-test/) and the [handbook page](https://serenity-js.org/handbook/test-runners/playwright-test/) and couldn't find it explicitly called out in either. The fact that `StageCrewMember.notifyOf` fires in a batch at `onTestEnd` rather than live is a meaningful architectural constraint for anyone building a live event listener. The discussion that inspired our original approach ([#2797](https://github.com/orgs/serenity-js/discussions/2797#discussioncomment-13148635)) is WDIO-context; a note about how that pattern translates under Playwright would close the loop.

2. **Consider an eager-announce mode** on the Playwright-test reporter. Something like:

   ```ts
   ["@serenity-js/playwright-test", {
     crew: [/* ... */],
     announceEventsEagerly: true   // or: emitPerStep: true
   }]
   ```

   The reporter already has the data it needs in `onStepEnd` (Serenity converts these into `InteractionFinished` / `TaskFinished` domain events post-hoc). Announcing them to the Stage as each step ends, rather than collecting them into `PlaywrightEventBuffer` and flushing at `onTestEnd`, would let a `StageCrewMember` like the original `DomainEventPublisher` continue to serve the "live audit trail" use case without us needing to drop down to a Playwright-native `Reporter`.

   There may well be reasons this would be harder than it looks — retry handling, after-all hook ordering, scene-finished deferral. But from a consumer's perspective it would restore the elegance of "domain events from Serenity, one mechanism, framework-agnostic".

If any of this is wrong — if there **is** a way to get real-time domain events out of `@serenity-js/playwright-test` that I missed — I'd love to know. It's entirely possible I've read past the right hook.



## Closing thought

The official [Serenity/JS Playwright image](https://github.com/serenity-js/serenity-js/pkgs/container/playwright) is genuinely a great piece of operational kit. Dropping Chrome, ChromeDriver, and a pile of WDIO runners from our worker build made the dev loop faster, the image smaller, and the failure modes fewer. The real-time streaming gap is a small thing against that, and easy enough to paper over with a Playwright-native reporter. But it's the sort of paper-over that would be nicer not to need.

Thanks to Jan and the Serenity/JS team for the image and the discussion thread that made the original design obvious in 2025. Happy to compare notes.
