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. 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 (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.

The original design, as shown in the animated example above was introduced in 48aca026, was clean and idiomatic Serenity:
- A custom
StageCrewMembercalledDomainEventPublisher - On
notifyOf(event), filter forInteractionFinished/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 — 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:
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/browsersinto the worker image at build time, matching versions by hand, and shippingCHROME_BIN/CHROMEDRIVER_PATHthrough the runtime env. Every Chrome bump meant image rebuilds and config. - Official path exists: Serenity/JS publishes the
ghcr.io/serenity-js/playwrightimage 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/webdriveriofor@serenity-js/playwright+@serenity-js/playwright-test - replace
wdio.conf.tswithplaywright.config.ts - replace mocha's
describe/itwith the@serenity-js/playwright-testactorCalledfixture - 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:
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) made it click. The real flow is:
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, bootstrapped in the playwright config here:
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:
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:
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
recordcounter was read-then-written outside any mutex, so concurrentsendAuditcallbacks would all readrecord = N, each computeN + 1, and write duplicates. Fixed by serialisingsendAuditper-session via a promise chain. - Stale session reference:
updateRamblersUploadSessionwas{ ...current, ...update }into a new object and putting that in the Map, so capturedsessionrefs never sawrecordincrements. Fixed byObject.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 base with the Serenity-BDD JAR pre-fetched.
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. ItsDockerfilestage is now justnode:24.14.0+mongosh,gh,flyctl. OpenJDK 21 andserenity-bdd-updatecame 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 aTEST_STEP_REPORTERevent type inwebsocket.model.ts) was deleted outright onceRealtimeStepReporterbecame 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 on top of b0a6d384 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:
Document the buffering behaviour somewhere in the
@serenity-js/playwright-testdocs. I looked at the API overview and the handbook page and couldn't find it explicitly called out in either. The fact thatStageCrewMember.notifyOffires in a batch atonTestEndrather than live is a meaningful architectural constraint for anyone building a live event listener. The discussion that inspired our original approach (#2797) is WDIO-context; a note about how that pattern translates under Playwright would close the loop.Consider an eager-announce mode on the Playwright-test reporter. Something like:
["@serenity-js/playwright-test", { crew: [/* ... */], announceEventsEagerly: true // or: emitPerStep: true }]The reporter already has the data it needs in
onStepEnd(Serenity converts these intoInteractionFinished/TaskFinisheddomain events post-hoc). Announcing them to the Stage as each step ends, rather than collecting them intoPlaywrightEventBufferand flushing atonTestEnd, would let aStageCrewMemberlike the originalDomainEventPublishercontinue to serve the "live audit trail" use case without us needing to drop down to a Playwright-nativeReporter.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 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.