{"id":"69e393dd4ce147c5bae9a232","title":"2026 04 18 Wdio To Playwright Serenity Migration","path":"how-to/technical-articles/2026-04-18-wdio-to-playwright-serenity-migration","contentMarkdown":"# 18-Apr-2026 — WDIO → Playwright: migrating Serenity/JS to the official image, and why real-time step streaming broke\n\n_____\n\nThis 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.\n\nThe 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.\n\n## The feature: walks upload with live progress in the admin UI\n\nOur 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.\n\n![](https://ngx-ramblers.org.uk/api/aws/s3/site-content/3cb5fcb8-116d-47b9-88bf-bbd56fcd9468.gif)\n\n\n\nThe 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:\n\n- A custom `StageCrewMember` called `DomainEventPublisher`\n- On `notifyOf(event)`, filter for `InteractionFinished` / `TaskFinished` / `TestSuiteFinished` / `SceneParametersDetected`\n- Serialise each event and publish it over a WebSocket to the backend, which forwards it to the UI over its own WebSocket\n\nThat 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.\n\nUnder WebdriverIO the pipeline is synchronous and live end to end:\n\n```mermaid\nflowchart LR\n    UI@{ icon: \"ngx:user\", label: \"Admin UI\", pos: \"b\", h: 48 }\n\n    subgraph subprocess[\"Serenity/JS subprocess\"]\n        ACTOR[\"Actor performs Click / Enter / Wait\"]\n        STAGE[\"Serenity Stage\"]\n        CREW[\"DomainEventPublisher\"]\n        WSCLIENT[\"WebSocket client\"]\n    end\n\n    subgraph backend[\"NGX Ramblers backend\"]\n        NODE@{ icon: \"logos:nodejs-icon\", label: \"Node spawn\", pos: \"b\", h: 48 }\n        WS@{ icon: \"ngx:ramblers\", label: \"WebSocket server\", pos: \"b\", h: 48 }\n    end\n\n    ACTOR --> STAGE\n    STAGE --> CREW\n    CREW --> WSCLIENT\n    WSCLIENT --> WS\n    NODE --> subprocess\n    WS --> UI\n\n    style subprocess fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style backend fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\nUnder 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.\n\nThis worked cleanly for about a year.\n\n\n\n## Why migrate to Playwright\n\nTwo mounting costs made WDIO feel like a tax:\n\n- **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.\n- **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.\n\nThe 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:\n\n- swap `@serenity-js/webdriverio` for `@serenity-js/playwright` + `@serenity-js/playwright-test`\n- replace `wdio.conf.ts` with `playwright.config.ts`\n- replace mocha's `describe`/`it` with the `@serenity-js/playwright-test` `actorCalled` fixture\n- delete Chrome/ChromeDriver install from the Dockerfile worker stage\n\nModerate, not a full rewrite. And that's how it played out — until the first end-to-end test.\n\n## The silent failure\n\nThe 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.\n\nWorse, 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`.\n\nUnder `@serenity-js/playwright-test` the same `DomainEventPublisher` saw events only via a buffered end-of-test flush:\n\n```mermaid\nflowchart TB\n    subgraph subprocess[\"Serenity/JS subprocess\"]\n        ACTOR[\"Actor performs Click / Enter / Wait\"]\n        PWORKER[\"Playwright test worker\"]\n        REPORTER[\"SerenityReporterForPlaywrightTest\"]\n        BUFFER[\"PlaywrightEventBuffer\"]\n    end\n\n    subgraph batch[\"End-of-test flush\"]\n        FLUSH[\"onTestEnd → flush all events\"]\n        BATCH[\"Events announced in one burst\"]\n        STAGE[\"Serenity Stage\"]\n        CREW[\"DomainEventPublisher\"]\n    end\n\n    BACKEND@{ icon: \"ngx:ramblers\", label: \"Backend /progress\", pos: \"b\", h: 48 }\n\n    ACTOR --> PWORKER\n    PWORKER --> REPORTER\n    REPORTER --> BUFFER\n    BUFFER --> FLUSH\n    FLUSH --> BATCH\n    BATCH --> STAGE\n    STAGE --> CREW\n    CREW --> BACKEND\n\n    style subprocess fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style batch fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\nThe `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.\n\n\n\n## The diagnosis\n\nReading `@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:\n\n```js\nonTestEnd(test, result) {\n    // ... retry / crash handling omitted ...\n    this.eventBuffer.appendSceneEvents(test, result);\n    if (pendingAfterAllHooks === 0) {\n        this.eventBuffer.appendSceneFinishedEvent(test, result);\n        const events = this.eventBuffer.flush(test, result);\n        this.serenity.announce(...events);\n    } else {\n        this.eventBuffer.deferAppendingSceneFinishedEvent(test, result);\n    }\n}\n```\n\n`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.\n\nThis 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.\n\nThe corollary: under `@serenity-js/playwright-test`, `StageCrewMember.notifyOf` is not a real-time hook. It is the end-of-test flush point.\n\n\n\n## The fix: a Playwright-native reporter\n\nBecause 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 ):\n\n```ts\nexport default class RealtimeStepReporter implements Reporter {\n\n  onStepEnd(_test: TestCase, _result: TestResult, step: TestStep): void {\n    if (!this.configured) return;\n    if (step.category !== \"test.step\") return;\n\n    const timestamp = new Date(step.startTime.getTime() + (step.duration || 0)).toISOString();\n    const payload = {\n      eventData: {\n        timestamp,\n        details: { name: step.title },\n        outcome: step.error\n          ? { code: FAILED_OUTCOME_CODE, error: { message: step.error.message, stack: step.error.stack } }\n          : { code: SUCCESS_OUTCOME_CODE }\n      },\n      finished: false\n    };\n    this.pendingPosts.push(this.post(payload));\n  }\n\n  async onEnd(): Promise<void> {\n    if (this.pendingPosts.length > 0) await Promise.allSettled(this.pendingPosts);\n  }\n}\n```\n\nRegistered alongside the Serenity reporter in `playwright.config.ts`:\n\n```ts\nreporter: [\n  [\"@serenity-js/playwright-test\", { crew: [/* photographer, BDD, artifact archiver */] }],\n  [\"./lib/serenity-js/reporters/realtime-step-reporter.ts\"],\n  [\"list\"]\n]\n```\n\nFiltering 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.\n\nWith a Playwright-native reporter added alongside the Serenity one, per-step events stream live while Serenity still handles the end-of-run artefacts:\n\n```mermaid\nflowchart LR\n    subgraph subprocess[\"Serenity/JS subprocess\"]\n        ACTOR[\"Actor performs Click / Enter / Wait\"]\n        PWORKER[\"Playwright test worker\"]\n        RTR[\"RealtimeStepReporter\"]\n        SRP[\"SerenityReporterForPlaywrightTest\"]\n    end\n\n    subgraph backend[\"NGX Ramblers backend\"]\n        PROGRESS@{ icon: \"ngx:ramblers\", label: \"Progress endpoint\", pos: \"b\", h: 48 }\n        WS@{ icon: \"ngx:ramblers\", label: \"WebSocket push\", pos: \"b\", h: 48 }\n    end\n\n    UI@{ icon: \"ngx:user\", label: \"Admin UI\", pos: \"b\", h: 48 }\n    S3@{ icon: \"ngx:aws\", label: \"Serenity report on S3\", pos: \"b\", h: 48 }\n\n    ACTOR --> PWORKER\n    PWORKER --> RTR\n    PWORKER --> SRP\n    RTR --> PROGRESS\n    PROGRESS --> WS\n    WS --> UI\n    SRP --> S3\n\n    style subprocess fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style backend fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\nEach 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.\n\n\n\n### Other backend bugs this uncovered\n\nMoving events to a faster pace also shook loose two backend issues that the old end-of-run batch had been hiding:\n\n- **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.\n- **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.\n\nBoth were latent before the migration; WDIO's per-step cadence wasn't concurrent enough to trip them.\n\n## The architecture change that came with it\n\nOnce 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.\n\nThat's now gone:\n\nThe 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.\n\n```mermaid\nflowchart TB\n    UI@{ icon: \"ngx:user\", label: \"Admin UI\", pos: \"b\", h: 48 }\n\n    subgraph website[\"Website image\"]\n        BACKEND@{ icon: \"logos:nodejs-icon\", label: \"Express + Mongoose\", pos: \"b\", h: 48 }\n        HANDLERS[\"Migration, HTML fetch, Flickr, venue scraper\"]\n    end\n\n    subgraph worker[\"Integration worker image\"]\n        JOBS[\"Async jobs endpoint\"]\n        BROWSER[\"Sync browser endpoint\"]\n        SUBPROCESS[\"Serenity/JS subprocess\"]\n    end\n\n    MONGO@{ icon: \"ngx:mongodb\", label: \"MongoDB Atlas\", pos: \"b\", h: 48 }\n    S3@{ icon: \"ngx:aws\", label: \"S3 reports + content\", pos: \"b\", h: 48 }\n\n    UI --> BACKEND\n    BACKEND --> MONGO\n    BACKEND --> HANDLERS\n    HANDLERS --> JOBS\n    HANDLERS --> BROWSER\n    JOBS --> SUBPROCESS\n    SUBPROCESS --> BACKEND\n    JOBS --> S3\n\n    style website fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style worker fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\n\n\n#### Details\n\n- The worker fly app  is named `ngx-ramblers-integration-worker`.\n- 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.\n- 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.\n- 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.\n\nEnd 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.\n\n\n\n## Observations — and a suggestion\n\nThe 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.\n\nTwo things that would have saved me time, and might be worth thinking about for Serenity/JS:\n\n1. **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.\n\n2. **Consider an eager-announce mode** on the Playwright-test reporter. Something like:\n\n   ```ts\n   [\"@serenity-js/playwright-test\", {\n     crew: [/* ... */],\n     announceEventsEagerly: true   // or: emitPerStep: true\n   }]\n   ```\n\n   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`.\n\n   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\".\n\nIf 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.\n\n\n\n## Closing thought\n\nThe 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.\n\nThanks 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.\n","contentHtml":"<h1>18-Apr-2026 — WDIO → Playwright: migrating Serenity/JS to the official image, and why real-time step streaming broke</h1>\n<hr>\n<p>This is a write-up of the WebdriverIO → Playwright migration completed on the NGX Ramblers project under <a href=\"https://github.com/nbarrett/ngx-ramblers/issues/114\">issue #114</a>. 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 <a href=\"https://github.com/nbarrett/ngx-ramblers/commit/48aca02617e120b33c1c8dac054b281f8a29be66\">commit <code>48aca026</code></a> (May 2025) — went silent under <code>@serenity-js/playwright-test</code> without any obvious error.</p>\n<p>The fix is pragmatic and works. The interesting bit is what was discovered about how <code>@serenity-js/playwright-test</code> surfaces domain events, and a small suggestion that a first-class real-time option would close the gap we ran into.</p>\n<h2>The feature: walks upload with live progress in the admin UI</h2>\n<p>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.</p>\n<p><img src=\"https://ngx-ramblers.org.uk/api/aws/s3/site-content/3cb5fcb8-116d-47b9-88bf-bbd56fcd9468.gif\" alt=\"\"></p>\n<p>The original design, as shown in the animated example above was introduced in <a href=\"https://github.com/nbarrett/ngx-ramblers/commit/48aca02617e120b33c1c8dac054b281f8a29be66\"><code>48aca026</code></a>, was clean and idiomatic Serenity:</p>\n<ul>\n<li>A custom <code>StageCrewMember</code> called <code>DomainEventPublisher</code></li>\n<li>On <code>notifyOf(event)</code>, filter for <code>InteractionFinished</code> / <code>TaskFinished</code> / <code>TestSuiteFinished</code> / <code>SceneParametersDetected</code></li>\n<li>Serialise each event and publish it over a WebSocket to the backend, which forwards it to the UI over its own WebSocket</li>\n</ul>\n<p>That architecture is exactly what Jan suggested in <a href=\"https://github.com/orgs/serenity-js/discussions/2797#discussioncomment-13148635\">Serenity/JS discussion #2797</a> — treating domain events as a first-class stream from the test runner to any interested listener.</p>\n<p>Under WebdriverIO the pipeline is synchronous and live end to end:</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    UI@{ icon: &quot;ngx:user&quot;, label: &quot;Admin UI&quot;, pos: &quot;b&quot;, h: 48 }\n\n    subgraph subprocess[&quot;Serenity/JS subprocess&quot;]\n        ACTOR[&quot;Actor performs Click / Enter / Wait&quot;]\n        STAGE[&quot;Serenity Stage&quot;]\n        CREW[&quot;DomainEventPublisher&quot;]\n        WSCLIENT[&quot;WebSocket client&quot;]\n    end\n\n    subgraph backend[&quot;NGX Ramblers backend&quot;]\n        NODE@{ icon: &quot;logos:nodejs-icon&quot;, label: &quot;Node spawn&quot;, pos: &quot;b&quot;, h: 48 }\n        WS@{ icon: &quot;ngx:ramblers&quot;, label: &quot;WebSocket server&quot;, pos: &quot;b&quot;, h: 48 }\n    end\n\n    ACTOR --&gt; STAGE\n    STAGE --&gt; CREW\n    CREW --&gt; WSCLIENT\n    WSCLIENT --&gt; WS\n    NODE --&gt; subprocess\n    WS --&gt; UI\n\n    style subprocess fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style backend fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<p>Under WebdriverIO, every activity an actor performs flows synchronously through the Stage. <code>InteractionFinished</code> 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.</p>\n<p>This worked cleanly for about a year.</p>\n<h2>Why migrate to Playwright</h2>\n<p>Two mounting costs made WDIO feel like a tax:</p>\n<ul>\n<li><strong>Browser lifecycle</strong>: we were installing Chrome and ChromeDriver via <code>@puppeteer/browsers</code> into the worker image at build time, matching versions by hand, and shipping <code>CHROME_BIN</code> / <code>CHROMEDRIVER_PATH</code> through the runtime env. Every Chrome bump meant image rebuilds and config.</li>\n<li><strong>Official path exists</strong>: Serenity/JS publishes the <a href=\"https://github.com/serenity-js/serenity-js/pkgs/container/playwright\"><code>ghcr.io/serenity-js/playwright</code></a> 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.</li>\n</ul>\n<p>The screenplay code was already framework-agnostic — tasks and questions are written against <code>@serenity-js/web</code> (<code>PageElement</code>, <code>Click</code>, <code>Enter</code>, <code>Navigate</code>). Only the ability layer and runner were WDIO-specific. The migration shape was therefore:</p>\n<ul>\n<li>swap <code>@serenity-js/webdriverio</code> for <code>@serenity-js/playwright</code> + <code>@serenity-js/playwright-test</code></li>\n<li>replace <code>wdio.conf.ts</code> with <code>playwright.config.ts</code></li>\n<li>replace mocha&#39;s <code>describe</code>/<code>it</code> with the <code>@serenity-js/playwright-test</code> <code>actorCalled</code> fixture</li>\n<li>delete Chrome/ChromeDriver install from the Dockerfile worker stage</li>\n</ul>\n<p>Moderate, not a full rewrite. And that&#39;s how it played out — until the first end-to-end test.</p>\n<h2>The silent failure</h2>\n<p>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.</p>\n<p>Worse, all the step audits ended up with identical timestamps (same wall-clock millisecond) because they were being written to MongoDB concurrently with <code>dateTimeNowAsValue()</code> as their <code>auditTime</code>.</p>\n<p>Under <code>@serenity-js/playwright-test</code> the same <code>DomainEventPublisher</code> saw events only via a buffered end-of-test flush:</p>\n<pre><code class=\"language-mermaid\">flowchart TB\n    subgraph subprocess[&quot;Serenity/JS subprocess&quot;]\n        ACTOR[&quot;Actor performs Click / Enter / Wait&quot;]\n        PWORKER[&quot;Playwright test worker&quot;]\n        REPORTER[&quot;SerenityReporterForPlaywrightTest&quot;]\n        BUFFER[&quot;PlaywrightEventBuffer&quot;]\n    end\n\n    subgraph batch[&quot;End-of-test flush&quot;]\n        FLUSH[&quot;onTestEnd → flush all events&quot;]\n        BATCH[&quot;Events announced in one burst&quot;]\n        STAGE[&quot;Serenity Stage&quot;]\n        CREW[&quot;DomainEventPublisher&quot;]\n    end\n\n    BACKEND@{ icon: &quot;ngx:ramblers&quot;, label: &quot;Backend /progress&quot;, pos: &quot;b&quot;, h: 48 }\n\n    ACTOR --&gt; PWORKER\n    PWORKER --&gt; REPORTER\n    REPORTER --&gt; BUFFER\n    BUFFER --&gt; FLUSH\n    FLUSH --&gt; BATCH\n    BATCH --&gt; STAGE\n    STAGE --&gt; CREW\n    CREW --&gt; BACKEND\n\n    style subprocess fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style batch fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<p>The <code>DomainEventPublisher</code> 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.</p>\n<h2>The diagnosis</h2>\n<p>Reading <code>@serenity-js/playwright-test</code>&#39;s reporter source (<a href=\"https://github.com/serenity-js/serenity-js/blob/main/packages/playwright-test/src/reporter/SerenityReporterForPlaywrightTest.ts\"><code>SerenityReporterForPlaywrightTest.js</code></a>) made it click. The real flow is:</p>\n<pre><code class=\"language-js\">onTestEnd(test, result) {\n    // ... retry / crash handling omitted ...\n    this.eventBuffer.appendSceneEvents(test, result);\n    if (pendingAfterAllHooks === 0) {\n        this.eventBuffer.appendSceneFinishedEvent(test, result);\n        const events = this.eventBuffer.flush(test, result);\n        this.serenity.announce(...events);\n    } else {\n        this.eventBuffer.deferAppendingSceneFinishedEvent(test, result);\n    }\n}\n</code></pre>\n<p><code>PlaywrightEventBuffer</code> accumulates an entire test&#39;s worth of domain events (<code>TaskStarts</code>, <code>TaskFinished</code>, <code>InteractionStarts</code>, <code>InteractionFinished</code>, <code>ActivityRelatedArtifactGenerated</code>, and so on) and then announces them to the Stage <strong>in a single batch at <code>onTestEnd</code></strong>. Inside that batch, the events carry their original timestamps — so the order and durations are accurate once they arrive — but they don&#39;t arrive until the test is over.</p>\n<p>This is architecturally sensible: Playwright&#39;s reporter API gives you <code>TestStep</code> info (<code>step.title</code>, <code>step.duration</code>, <code>step.error</code>) 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 <code>StageCrewMember</code> can see <code>InteractionFinished</code> <strong>as the interaction finishes</strong>. The Stage only gets notified after the test has exited.</p>\n<p>The corollary: under <code>@serenity-js/playwright-test</code>, <code>StageCrewMember.notifyOf</code> is not a real-time hook. It is the end-of-test flush point.</p>\n<h2>The fix: a Playwright-native reporter</h2>\n<p>Because Playwright&#39;s own reporter API <strong>does</strong> fire <code>onStepBegin</code>/<code>onStepEnd</code> synchronously as steps execute, the fix is to hook Playwright directly and bypass Serenity&#39;s buffer for this one concern.  <code>RealtimeStepReporter</code> below was implemented <a href=\"https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/serenity-js/reporters/realtime-step-reporter.ts\">here</a>,  bootstrapped in the playwright config <a href=\"https://ngx-ramblers.org.uk/%E2%81%A0https://github.com/nbarrett/ngx-ramblers/blob/main/server/playwright.config.ts\">here</a>:</p>\n<pre><code class=\"language-ts\">export default class RealtimeStepReporter implements Reporter {\n\n  onStepEnd(_test: TestCase, _result: TestResult, step: TestStep): void {\n    if (!this.configured) return;\n    if (step.category !== &quot;test.step&quot;) return;\n\n    const timestamp = new Date(step.startTime.getTime() + (step.duration || 0)).toISOString();\n    const payload = {\n      eventData: {\n        timestamp,\n        details: { name: step.title },\n        outcome: step.error\n          ? { code: FAILED_OUTCOME_CODE, error: { message: step.error.message, stack: step.error.stack } }\n          : { code: SUCCESS_OUTCOME_CODE }\n      },\n      finished: false\n    };\n    this.pendingPosts.push(this.post(payload));\n  }\n\n  async onEnd(): Promise&lt;void&gt; {\n    if (this.pendingPosts.length &gt; 0) await Promise.allSettled(this.pendingPosts);\n  }\n}\n</code></pre>\n<p>Registered alongside the Serenity reporter in <code>playwright.config.ts</code>:</p>\n<pre><code class=\"language-ts\">reporter: [\n  [&quot;@serenity-js/playwright-test&quot;, { crew: [/* photographer, BDD, artifact archiver */] }],\n  [&quot;./lib/serenity-js/reporters/realtime-step-reporter.ts&quot;],\n  [&quot;list&quot;]\n]\n</code></pre>\n<p>Filtering by <code>category === &quot;test.step&quot;</code> keeps fixture/hook steps out of the audit view; Serenity&#39;s <code>Activity.performAs</code> wraps each interaction in a <code>test.step()</code>, so the <code>step.title</code> is the activity description users already recognise from the old WDIO output.</p>\n<p>With a Playwright-native reporter added alongside the Serenity one, per-step events stream live while Serenity still handles the end-of-run artefacts:</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    subgraph subprocess[&quot;Serenity/JS subprocess&quot;]\n        ACTOR[&quot;Actor performs Click / Enter / Wait&quot;]\n        PWORKER[&quot;Playwright test worker&quot;]\n        RTR[&quot;RealtimeStepReporter&quot;]\n        SRP[&quot;SerenityReporterForPlaywrightTest&quot;]\n    end\n\n    subgraph backend[&quot;NGX Ramblers backend&quot;]\n        PROGRESS@{ icon: &quot;ngx:ramblers&quot;, label: &quot;Progress endpoint&quot;, pos: &quot;b&quot;, h: 48 }\n        WS@{ icon: &quot;ngx:ramblers&quot;, label: &quot;WebSocket push&quot;, pos: &quot;b&quot;, h: 48 }\n    end\n\n    UI@{ icon: &quot;ngx:user&quot;, label: &quot;Admin UI&quot;, pos: &quot;b&quot;, h: 48 }\n    S3@{ icon: &quot;ngx:aws&quot;, label: &quot;Serenity report on S3&quot;, pos: &quot;b&quot;, h: 48 }\n\n    ACTOR --&gt; PWORKER\n    PWORKER --&gt; RTR\n    PWORKER --&gt; SRP\n    RTR --&gt; PROGRESS\n    PROGRESS --&gt; WS\n    WS --&gt; UI\n    SRP --&gt; S3\n\n    style subprocess fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style backend fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<p>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&#39;t replaced Serenity; we&#39;ve supplemented it with one extra Reporter for the one concern Serenity can&#39;t serve under Playwright.</p>\n<h3>Other backend bugs this uncovered</h3>\n<p>Moving events to a faster pace also shook loose two backend issues that the old end-of-run batch had been hiding:</p>\n<ul>\n<li><strong>Concurrent record numbering</strong>: the per-session <code>record</code> counter was read-then-written outside any mutex, so concurrent <code>sendAudit</code> callbacks would all read <code>record = N</code>, each compute <code>N + 1</code>, and write duplicates. Fixed by serialising <code>sendAudit</code> per-session via a promise chain.</li>\n<li><strong>Stale session reference</strong>: <code>updateRamblersUploadSession</code> was <code>{ ...current, ...update }</code> into a new object and putting that in the Map, so captured <code>session</code> refs never saw <code>record</code> increments. Fixed by <code>Object.assign</code>-ing in place.</li>\n</ul>\n<p>Both were latent before the migration; WDIO&#39;s per-step cadence wasn&#39;t concurrent enough to trip them.</p>\n<h2>The architecture change that came with it</h2>\n<p>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.</p>\n<p>That&#39;s now gone:</p>\n<p>The website image is Node 24 only — no browser binaries, no Playwright, no OpenJDK. The integration worker image is the <a href=\"https://github.com/serenity-js/serenity-js/pkgs/container/playwright\"><code>ghcr.io/serenity-js/playwright</code></a> base with the Serenity-BDD JAR pre-fetched.</p>\n<pre><code class=\"language-mermaid\">flowchart TB\n    UI@{ icon: &quot;ngx:user&quot;, label: &quot;Admin UI&quot;, pos: &quot;b&quot;, h: 48 }\n\n    subgraph website[&quot;Website image&quot;]\n        BACKEND@{ icon: &quot;logos:nodejs-icon&quot;, label: &quot;Express + Mongoose&quot;, pos: &quot;b&quot;, h: 48 }\n        HANDLERS[&quot;Migration, HTML fetch, Flickr, venue scraper&quot;]\n    end\n\n    subgraph worker[&quot;Integration worker image&quot;]\n        JOBS[&quot;Async jobs endpoint&quot;]\n        BROWSER[&quot;Sync browser endpoint&quot;]\n        SUBPROCESS[&quot;Serenity/JS subprocess&quot;]\n    end\n\n    MONGO@{ icon: &quot;ngx:mongodb&quot;, label: &quot;MongoDB Atlas&quot;, pos: &quot;b&quot;, h: 48 }\n    S3@{ icon: &quot;ngx:aws&quot;, label: &quot;S3 reports + content&quot;, pos: &quot;b&quot;, h: 48 }\n\n    UI --&gt; BACKEND\n    BACKEND --&gt; MONGO\n    BACKEND --&gt; HANDLERS\n    HANDLERS --&gt; JOBS\n    HANDLERS --&gt; BROWSER\n    JOBS --&gt; SUBPROCESS\n    SUBPROCESS --&gt; BACKEND\n    JOBS --&gt; S3\n\n    style website fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style worker fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<h4>Details</h4>\n<ul>\n<li>The worker fly app  is named <code>ngx-ramblers-integration-worker</code>.</li>\n<li>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.</li>\n<li>The website image no longer installs Playwright browsers, Chrome, ChromeDriver, <code>@puppeteer/browsers</code>, or any WDIO packages. Its <code>Dockerfile</code> stage is now just <code>node:24.14.0</code> + <code>mongosh</code>, <code>gh</code>, <code>flyctl</code>. OpenJDK 21 and <code>serenity-bdd-update</code> came off too — the JDK-backed report generation happens only on the worker.</li>\n<li>The old transport (<code>DomainEventPublisher</code>, <code>process-test-step-event.ts</code>, the worker&#39;s own WebSocket server, and a <code>TEST_STEP_REPORTER</code> event type in <code>websocket.model.ts</code>) was deleted outright once <code>RealtimeStepReporter</code> became the single real-time path. No duplicate delivery mechanisms.</li>\n</ul>\n<p>End state: one browser image (the worker), one real-time step delivery path (Playwright&#39;s <code>onStepEnd</code> → signed POST → UI WebSocket), one audit persistence path (<code>sendAudit</code> serialised per session). All committed under <a href=\"https://github.com/nbarrett/ngx-ramblers/commit/58aa7fd13a3eafd6f8b1b5e19c571ea82954939a\"><code>58aa7fd1</code></a> on top of <a href=\"https://github.com/nbarrett/ngx-ramblers/commit/b0a6d384366287f67be5e4650508c64a2a21c7c6\"><code>b0a6d384</code></a> on issue #114.</p>\n<h2>Observations — and a suggestion</h2>\n<p>The migration itself was straightforward; the discovery in the middle was the interesting part. Knowing in advance that <code>@serenity-js/playwright-test</code> batches domain events at <code>onTestEnd</code> would have saved an afternoon of puzzling. The <code>DomainEventPublisher</code> 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.</p>\n<p>Two things that would have saved me time, and might be worth thinking about for Serenity/JS:</p>\n<ol>\n<li><p><strong>Document the buffering behaviour</strong> somewhere in the <code>@serenity-js/playwright-test</code> docs. I looked at the <a href=\"https://serenity-js.org/api/playwright-test/\">API overview</a> and the <a href=\"https://serenity-js.org/handbook/test-runners/playwright-test/\">handbook page</a> and couldn&#39;t find it explicitly called out in either. The fact that <code>StageCrewMember.notifyOf</code> fires in a batch at <code>onTestEnd</code> rather than live is a meaningful architectural constraint for anyone building a live event listener. The discussion that inspired our original approach (<a href=\"https://github.com/orgs/serenity-js/discussions/2797#discussioncomment-13148635\">#2797</a>) is WDIO-context; a note about how that pattern translates under Playwright would close the loop.</p>\n</li>\n<li><p><strong>Consider an eager-announce mode</strong> on the Playwright-test reporter. Something like:</p>\n<pre><code class=\"language-ts\">[&quot;@serenity-js/playwright-test&quot;, {\n  crew: [/* ... */],\n  announceEventsEagerly: true   // or: emitPerStep: true\n}]\n</code></pre>\n<p>The reporter already has the data it needs in <code>onStepEnd</code> (Serenity converts these into <code>InteractionFinished</code> / <code>TaskFinished</code> domain events post-hoc). Announcing them to the Stage as each step ends, rather than collecting them into <code>PlaywrightEventBuffer</code> and flushing at <code>onTestEnd</code>, would let a <code>StageCrewMember</code> like the original <code>DomainEventPublisher</code> continue to serve the &quot;live audit trail&quot; use case without us needing to drop down to a Playwright-native <code>Reporter</code>.</p>\n<p>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&#39;s perspective it would restore the elegance of &quot;domain events from Serenity, one mechanism, framework-agnostic&quot;.</p>\n</li>\n</ol>\n<p>If any of this is wrong — if there <strong>is</strong> a way to get real-time domain events out of <code>@serenity-js/playwright-test</code> that I missed — I&#39;d love to know. It&#39;s entirely possible I&#39;ve read past the right hook.</p>\n<h2>Closing thought</h2>\n<p>The official <a href=\"https://github.com/serenity-js/serenity-js/pkgs/container/playwright\">Serenity/JS Playwright image</a> 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&#39;s the sort of paper-over that would be nicer not to need.</p>\n<p>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.</p>\n"}