{"id":"69e3f6e1b77beae64a7c1065","title":"2026 04 18","path":"how-to/committee/release-notes/2026-04-18","contentMarkdown":"# 18-Apr-2026 — Ramblers-upload-worker → Integration-worker [#114](https://github.com/nbarrett/ngx-ramblers/issues/114)\n\n## [build 614](https://github.com/nbarrett/ngx-ramblers/actions/runs/24613943307) — [commit 40e8d62](https://github.com/nbarrett/ngx-ramblers/commit/40e8d62202cac68a47709e707cdb38a165d2ca9d)\n\n_____\n\nRename the Ramblers upload worker to a generic integration worker, cut every\nin-process browser session out of the website, and replace the buffered\nSerenity StageCrewMember step-delivery path with a Playwright-native reporter\nthat posts each `test.step` as it ends. Clean up env-var wiring, settings\nsurface, and scripts along the way.\n\n## Rename: ramblers-upload-worker → integration-worker\n\nThe worker now handles Ramblers walks upload, Flickr album scraping,\narbitrary HTML fetches and full static-site migration - \"upload worker\"\nno longer describes it. Rename every piece of the worker plumbing;\nkeep walks-upload-specific files (dispatcher, queue, session registry,\naudit notifier, execution model) named `ramblers-upload-*` because they\ndescribe one *type* of integration job.\n\n- Fly app: `ngx-ramblers-upload-worker` → `ngx-ramblers-integration-worker`\n- Fly config: `fly.worker.toml` → `fly.integration-worker.toml`\n- Manual deploy workflow: `build-and-deploy-ramblers-upload-worker.yml` →\n  `build-and-deploy-integration-worker.yml`\n- Docker image tags: `ngx-ramblers-worker:N` →\n  `ngx-ramblers-integration-worker:N`; push tag\n  `ngx-ramblers:worker-N` → `ngx-ramblers:integration-worker-N`;\n  local tag `ngx-worker:local` → `ngx-integration-worker:local`;\n  local container `ngx-worker-local` → `ngx-integration-worker-local`\n- Env vars: `RAMBLERS_UPLOAD_WORKER_*` → `INTEGRATION_WORKER_*`\n- Types: `RamblersUploadWorker*` → `IntegrationWorker*`\n- Files: 12 files in `server/lib/ramblers/`\n  (`integration-worker-{client,server,routes,runner,callback-client,\nexecution-state,crypto,crypto.spec}`) plus\n  `server/deploy/deploy-integration-worker.ts` and\n  `projects/.../integration-worker.model.ts`\n- Route prefix: `/api/ramblers-upload-worker/*` →\n  `/api/integration-worker/*`\n\n## Sync browser ops moved to the worker\n\n- `POST /api/integration-worker/browser/html-fetch`\n- launches Chromium,\n  navigates, returns `{ html, finalUrl, baseHref }`. Used by\n  `html-from-url`, `fetchHtmlFromUrl` and `search-venue-website` on\n  the website.\n- `POST /api/integration-worker/browser/flickr-user-albums`\n- scrapes a\n  Flickr albums page (TrustArc consent handled via Playwright\n  `frameLocator`) and extracts `FlickrScrapedUserAlbumsData`.\n- New `integration-worker-browser-client.ts` on the website: signs with\n  `signRamblersUploadBody`, POSTs to the worker, falls back to plain\n  `fetch` if the worker errors out.\n- `flickr-provider.scrapeUserAlbumsPage` becomes a thin wrapper calling\n  `scrapeFlickrUserAlbumsViaIntegrationWorker`; the ~180-line in-process\n  page scrape + cookie-consent iframe dance moved to the worker.\n- `migration-routes.ts` drops `launchPlainBrowser()` entirely. The\n  `USE_SERENITY_FOR_MIGRATION` feature flag and its env entry go with it.\n\n## Full site migration moved to the worker (async job + callbacks)\n\n- `POST /api/integration-worker/migration/jobs` accepts\n  `{ jobId, siteConfig, persistData, uploadTos3, callback }`, runs\n  `migrateStaticSite()` on the worker, streams progress back via signed\n  callbacks. Refuses a second job while one is running.\n- New model types:\n  `IntegrationWorkerMigrationJobRequest`,\n  `IntegrationWorkerMigrationProgressCallback`,\n  `IntegrationWorkerMigrationResultCallback`.\n- New backend callback endpoints at\n  `/api/integration-worker/migration/progress` and `/result`. Each\n  verifies the signature, looks up the active migration session, and\n  forwards `{ level, message }` or the final `{ status, result,\nerrorMessage }` to the client websocket.\n- New `migration-session-registry.ts`\n- in-memory map of\n  `jobId → { ws, siteIdentifier, siteName, historyId, startedAt }`.\n- New `migration-notifier.ts`\n- websocket send + audit-log /\n  history-status persistence, split from the old ws handler so the\n  callback route and initial handler share it.\n- `site-migration-ws-handler.ts` no longer imports\n  `migrate-static-site-engine` or calls `migrateStaticSite` directly.\n  It resolves the site config, creates the `migrationHistory` doc,\n  generates a `jobId` via `crypto.randomUUID()`, registers a migration\n  session, and submits the job to the worker via\n  `submitMigrationJobToIntegrationWorker`.\n\n## Real-time step streaming: fix and DRY up the delivery path\n\nThe original `DomainEventPublisher` StageCrewMember (48aca026) worked\nunder WebdriverIO because WDIO fires `InteractionFinished` /\n`TaskFinished` synchronously as each action executes. Under\n`@serenity-js/playwright-test` those domain events are buffered by\n`PlaywrightEventBuffer` and only announced to the Stage at `onTestEnd`\n- so our crew member only ever saw them in one end-of-run batch, which\n  is why the audit view only populated when the browser closed.\n\nReplace the whole per-step delivery path with a Playwright-native\n`Reporter` (bypasses Serenity's buffer):\n\n- `server/lib/serenity-js/reporters/realtime-step-reporter.ts` -\n  listens to `Reporter.onStepEnd`, filters `category === \"test.step\"`,\n  and POSTs each step to `/api/integration-worker/progress` with the\n  existing `test-step` envelope (`eventData.timestamp/details.name/\noutcome.code`). Registered in `playwright.config.ts` alongside the\n  Serenity reporter.\n\nBackend handler, session registry and audit notifier tightened so the\nreal-time stream behaves correctly:\n\n- `/progress` handler now responds `200 OK` immediately, then processes\n  the audit asynchronously. Eliminates the 1-3 s per-request latency\n  that was queueing test-step POSTs and bunching them at end-of-run.\n- `/progress` for `test-step` now unwraps the\n  `{ eventData, finished }` envelope before handing the inner event to\n  `parseTestStepEvent`, so the parser sees real `timestamp` /\n  `details.name` / `outcome.code` again (the old path was passing the\n  envelope, so every event came out with a NaN timestamp and got\n  clustered into one write-time window).\n- `sendAudit` is now serialised per-session via a promise chain. Each\n  background callback runs strictly after the previous one, so\n  `session.record` increments monotonically instead of racing (no more\n  runs of `rec=8, rec=8, rec=8`).\n- `updateRamblersUploadSession` mutates the session object in place\n  (`Object.assign`) instead of replacing it in the map, so all captured\n  `session` references stay in sync with the incremented record.\n\n## DRY: drop the old WDIO-era event transport entirely\n\nWith `RealtimeStepReporter` as the single per-step path, the old\nWebSocket-based transport and its wiring are dead weight:\n\n- Deleted:\n- `server/lib/serenity-js/screenplay/crew/DomainEventPublisher.ts`\n- `server/lib/ramblers/process-test-step-event.ts`\n- `server/lib/ramblers/integration-worker-websocket-server.ts`\n- `server/lib/websockets/websocket-client.ts`\n- Removed `EventType.TEST_STEP_REPORTER` from the shared\n  `websocket.model.ts`.\n- Dropped the `TEST_STEP_REPORTER` handler from the backend\n  `websocket-server.ts` and the `createIntegrationWorkerWebSocketServer`\n  call from the worker server.\n\n## Environment enum: move to shared model, prune, enforce\n\n- `Environment` enum moved to\n  `projects/ngx-ramblers/src/app/models/environment.model.ts` so the\n  Angular app can use it too; deleted the server shim at\n  `server/lib/env-config/environment-model.ts`; all 28 server importers\n  rewritten to the shared path.\n- Added `ADMIN_MONGODB_URI` and `PLAYWRIGHT_HEADLESS` to the enum.\n- Every literal `process.env.X` / `env.X` / `secrets.X` access in the\n  server now goes through `Environment.X` - deploy script, CLI\n  commands, worker routes.\n- Frontend `environment-settings.ts` now uses `[Environment.X]`\n  computed keys instead of string literals.\n\n## Local CLI ergonomics\n\n- New `--no-headless` flag on `ngx-cli local dev`. Runs the worker\n  headed (visible browser) and implies `--no-docker-worker` (Docker\n  always headless). Existing `--no-docker-worker` still works for\n  fast-feedback headless iteration without an image rebuild.\n- `docker-worker` and `headless` threaded through `LocalRunConfig`.\n- Force-kills stale processes on backend / frontend / worker ports\n  before each start (`SIGTERM` with 4 s grace, then `SIGKILL`);\n  tears down any stale worker container.\n\n## Walk export UI\n\n- `autoTitle` added to the `<app-page>` wrapper so the tab view has an\n  H1 above the tabs, via the same mechanism used by Bookings / Member\n  Bulk Load / Environment Setup.\n- Audit sort state persisted in the URL as `?sort=<field>&sort-order=\n<asc|desc>`:\n- New `StoredValue` entries `AUDIT_MESSAGE`, `AUDIT_TIME`,\n  `DURATION`, `STATUS` (kebab-case values in the URL).\n- Reuse existing `StoredValue.SORT` + `SORT_ORDER` as the query\n  keys and `SortDirection.ASC/DESC` from `sort.model.ts` for the\n  values (no duplicate `\"asc\"`/`\"desc\"` constants).\n- `sortAuditsBy(field: StoredValue)` now flips order on the same\n  column and resets to `DESC` on a new column, then persists via\n  `updateUrl()`.\n- `AUDIT_SORT_FIELD_MAPPING` maps the URL/`StoredValue` kebab-case\n  to the internal camelCase field the `sortBy` helper reads.\n- Duration column:\n- `durationMs` is now computed on every audit unconditionally\n  (the `showDetail` gate was stopping the field from being added\n  when the checkbox was off, which was why the Duration header sort\n  did nothing).\n- `dateUtils.formatDuration` no longer short-circuits to \"0 secs\"\n  when `fromTime === 0` - that falsy-zero guard was wrong for\n  millisecond-delta inputs and was making every row render \"0 secs\"\n  regardless of the real duration.\n- `applyFilter` handles the oldest row (no previous) cleanly\n  instead of computing `auditTime - 0`.\n\n## Scripts cleanup\n\n`server/package.json`:\n- Dropped `test: \"failsafe serenity report\"`\n- duplicated `serenity`.\n- Dropped `lint` / `lintfix` / `lintversion` and the `tslint` dep +\n  `server/tslint.json`. tslint has been unmaintained since 2019 and\n  none of the hooks or CI workflows invoke them.\n\n`server/test-setenv.sh`: dropped stale `CHROME_BIN` /\n`CHROMEDRIVER_PATH` exports.\n\n`server/.env`: local key names renamed to `INTEGRATION_WORKER_*`;\nstale Chrome paths removed. (Local values only; remote Fly secrets\nre-imported under the new names separately.)\n\n## Dead code removal\n\n- `server/lib/migration/migrate-static-site.ts` (standalone NWK\n  script, unreferenced).\n- `server/lib/migration/serenity-migration-engine.ts`,\n  `serenity-migration-utils.ts`, `serenity-utils.ts` and the\n  `screenplay/` scaffolding (parallel implementation, not imported\n  by any live route).\n- `server/lib/migration/puppeteer-utils.ts` replaced by\n  `server/lib/migration/browser-utils.ts` (only compiled into the\n  worker image - never called from the website live path).\n\n## Fly secrets\n\nStaged but not deployed on the two apps available to the current\ntoken:\n- `ngx-ramblers-upload-worker`: new `INTEGRATION_WORKER_SHARED_SECRET`\n  / `INTEGRATION_WORKER_ENCRYPTION_KEY` staged with matching digests\n  to the old names.\n- `ngx-ramblers`: new `INTEGRATION_WORKER_SHARED_SECRET` /\n  `INTEGRATION_WORKER_ENCRYPTION_KEY` / `INTEGRATION_WORKER_URL` /\n  `INTEGRATION_WORKER_CALLBACK_BASE_URL` staged.\n\nStaging central DB `config.environments` for `staging`, `ekwg` and\n`ekwg-dev-2-staging` renamed the per-env secret keys from\n`RAMBLERS_UPLOAD_WORKER_*` to `INTEGRATION_WORKER_*` so future\ndeploys push the new names consistently.\n\nOld secrets remain Deployed on both apps until the next deploy\nactivates the new staged names.\n\n### **worker**: switch upload worker to Serenity/JS Playwright stack and cut the website off WebdriverIO/Puppeteer ([#114](https://github.com/nbarrett/ngx-ramblers/issues/114))\n\nMigrate the Ramblers upload worker off the WebdriverIO + Chrome/Chromedriver\nruntime onto the officially supported Serenity/JS Playwright image, harden the\nend-to-end upload path, and remove every WebdriverIO/Puppeteer usage from the\ncodebase so the website image no longer ships a browser stack.\n\n## Worker image\n\n- `Dockerfile` `worker` stage now builds from\n  `ghcr.io/serenity-js/playwright:v1.58.1-noble` (Node 24 pinned via `n`,\n  npm 11, OpenJDK, Playwright browsers pre-installed on Ubuntu 24.04).\n  Chrome/Chromedriver/`@puppeteer/browsers` installs are removed - the\n  Playwright image already ships the browsers the worker needs.\n- `npm run serenity-bdd-update` is pre-run so runtime doesn't re-download\n  the Serenity-BDD JAR.\n- `.dockerignore` excludes `server/node_modules`, `cli-output/`, `target/`,\n  `ts-gen/` and `dist/` so worker images stay lean.\n\n## Website image\n\n- Renamed `--target origin` to `--target website` to reflect its role as\n  the pure web tier. Build arg `CHROME_VERSION`, OpenJDK 21 install and\n  `serenity-bdd-update` are removed from this stage - the website no\n  longer runs Serenity or any browser.\n- CI workflow now builds with `--target website` and no longer passes\n  `--build-arg CHROME_VERSION`.\n- Dockerfile header comment updated to state the website stage ships\n  neither a browser nor a JDK.\n\n## Test runner migration (WebdriverIO → Playwright)\n\n- `server/package.json` `serenity-run` now invokes\n  `playwright test -c ./playwright.config.ts`. Adds `@playwright/test`,\n  `playwright`, `@serenity-js/playwright` and `@serenity-js/playwright-test`.\n- New `server/playwright.config.ts` wires the Serenity crew, BDD reporter,\n  `ArtifactArchiver` and `Photographer`, using `SerenityFixtures` /\n  `SerenityWorkerFixtures`. Honours `PLAYWRIGHT_HEADLESS`,\n  `RAMBLERS_FEATURE` and `BASE_URL`.\n- Feature specs (`contacts`, `process-command-args`, `walks-and-events-\nmanager`, `walks-programme`, `walks-upload`) migrated off mocha /\n  `actorCalled` statics to the `@serenity-js/playwright-test` `actorCalled`\n  fixture; `beforeEach` engagement is replaced with per-scenario\n  `actorCalled(...)`.\n- `SaveBrowserSource` task replaces the WDIO `@wdio/globals` call with\n  `BrowseTheWeb.currentPage().executeScript(...)` and now writes to\n  `target/browser-source/` with a fallback page on capture failure.\n- New `SetFileInput` task drives file uploads via Playwright's native\n  `setInputFiles`, replacing `Enter.theValue(...)` and fixing the\n  \"Input of type file cannot be filled\" failure on the Ramblers upload\n  modal.\n- Login wait restored to `Wait.upTo(DEFAULT_WAIT_TIMEOUT).until(...)`\n  sourcing the 15 s budget from a single shared constant in\n  `server/lib/serenity-js/config/serenity-timeouts.ts` (also consumed\n  by `playwright.config.ts`). Fixes the 5 s `TimeoutExpiredError` on\n  the post-login Create-menu dropdown check.\n\n## Bulk-action selector hardening\n\n- `WalksPageElements` tightens the `Delete` / `Unpublish` / `Publish` /\n  `Uncancel` selectors to\n  `#vbo-action-form-wrapper input[data-vbo='vbo-action'][value='...']`,\n  eliminating ambiguous matches when the VBO action bar renders twice.\n- Adds a `loggedInWalksManagerPage` element (body CSS class check) used\n  inside `AuthErrorCookieBannerOrCreateMenuDropdown` so post-login\n  detection succeeds even before the Create menu renders.\n\n## Live audit streaming (in progress)\n\n- Server spawns the Serenity subprocess with\n  `RAMBLERS_UPLOAD_WORKER_CALLBACK_BASE_URL`,\n  `_CALLBACK_PROGRESS_PATH`, `_CALLBACK_RESULT_PATH`,\n  `_CALLBACK_SECRET` and `_JOB_ID` in the environment (new\n  `Environment` enum entries).\n- `DomainEventPublisher` posts `TEST_STEP` events directly to the worker\n  callback endpoint when that env is present, with diagnostic logging on\n  the transport branch actually taken, instead of going through the\n  Serenity subprocess websocket (which intermittently dropped with\n  close code 1006 and caused audit rows to bunch at end-of-run).\n- `processTestStepEvent` now writes step events straight into the active\n  local session so the Walk upload audit tab updates while the browser\n  is still running.\n- Frontend `WebSocketClientService` wraps `onopen` / `onmessage` /\n  `onclose` / `onerror` in `NgZone.run(...)` so progress events trigger\n  change detection. `walk-export.ts` calls\n  `ChangeDetectorRef.detectChanges()` on updates, broadcasts\n  `NamedEventType.REFRESH` on completion, and runs a 2 s audit refresh\n  loop while the export is in progress.\n\n## Audit parser & timing clean-up\n\n- `ramblers-audit-parser` strips ANSI escape sequences via module-level\n  `RegExp`s built from `String.fromCharCode(27)` (no inline control\n  characters, passes `no-control-regex` without a disable) and filters\n  separator / noise lines such as `Execution Summary`, `Scenarios:`,\n  `Real time:`, `Total time:`, `1 passed`,\n  `Succeeded with exit code 0 as all scripts passed`, `> playwright`,\n  `@playwright/test`, `@serenity-js/playwright` / `-test`,\n  `Running 1 test using 1 worker`, the `mkdir -p target/site/archive`\n  line and all-`=` separator lines.\n- `walk-export.ts` `timing()` no longer has a special SUMMARY case that\n  returned the whole-session duration; summary and step rows now both\n  show true per-step durations derived from `durationMs` or the\n  adjacent-row delta.\n- `ramblers-upload-walks.ts` progress line reports\n  `PLAYWRIGHT_HEADLESS` and `BASE_URL` instead of the removed\n  `CHROME_BIN` / `CHROMEDRIVER_PATH`.\n\n## Report storage (zipped artefacts)\n\n- `ramblers-upload-worker-runner.ts` uploads the Serenity report as a\n  single `<keyPrefix>.zip` via `adm-zip`, emitting lifecycle progress\n  events for archive start, completion (with file count, byte size and\n  elapsed ms) and failure.\n- `recordReportLocation` stores the summary message as\n  `s3://<bucket>/<keyPrefix>.zip`.\n- New `GET /api/aws/report/:bucket/*` route in `aws-routes.ts` streams\n  assets from the archive: `aws-controllers.ts` lazy-extracts each\n  report under `target/report-cache/<sha1>/`, guards against directory\n  traversal, dedupes concurrent extractions via an in-memory promise\n  cache, and falls back to a chunked `streamToBuffer` for SDK response\n  bodies that aren't already `Buffer`s.\n- `walk-export.ts` `openReport()` uses the new route with\n  `bucket` + `keyPrefix` + `/_/` + `index.html` as the split marker;\n  sessions without a stored bucket are no-ops.\n\n## WebdriverIO / Puppeteer removal across the codebase\n\n- `@serenity-js/webdriverio`, `webdriverio`, `puppeteer` and every\n  `@wdio/*` runner/CLI package removed from `server/package.json`; the\n  lockfile shrunk by ~4 200 lines.\n- Deleted `server/wdio.conf.ts`, `server/lib/serenity-js/features/config/\nactors.ts`, `server/lib/migration/puppeteer-utils.ts` and the parallel\n  `serenity-migration-engine.ts` / `serenity-migration-utils.ts` /\n  `serenity-utils.ts` stack plus their `screenplay/` scaffolding and the\n  standalone `migrate-static-site.ts` script (none were imported by any\n  live route).\n- New `server/lib/migration/browser-utils.ts` provides a Playwright\n  `chromium.launch(...)` replacement for the old puppeteer helper.\n- `migrate-static-site-engine.ts` now imports `Browser`/`Page` from\n  `playwright`, swaps `networkidle2` for `networkidle`, and restructures\n  every `page.evaluate(fn, arg1, arg2, ...)` call for Playwright's\n  single-argument contract (object-payload pattern).\n- `migration-routes.ts` collapses the dual WebdriverIO / Puppeteer\n  branches, drops the `USE_SERENITY_FOR_MIGRATION` feature flag and its\n  env entry, and uses Playwright-only paths.\n- `flickr-provider.ts` is on Playwright: `browser.url`/`waitUntil`/\n  `execute`/`getPageSource`/`$` become `page.goto`/`waitForFunction`/\n  `evaluate`/`content`/`locator`, and cookie-consent iframe handling is\n  reimplemented with `page.frameLocator(...)` rather than\n  `switchToFrame`.\n- `Environment` enum drops `CHROME_BIN` and `CHROMEDRIVER_PATH`; the\n  `ramblers-audit-parser` noise list replaces the webdriverio marker\n  with `@serenity-js/playwright`.\n\n## Local dev ergonomics\n\n- `bin/ngx-cli local dev` force-kills stale processes holding the\n  backend / frontend / worker ports (`SIGTERM` with a 4 s grace, then\n  `SIGKILL`), and tears down any old worker container before each run.\n- Docker worker rebuild trigger now watches\n  `server/playwright.config.ts`, `server/lib/ramblers`,\n  `server/lib/serenity-js` and `server/lib/env-config` recursively, via\n  a new `newestMtimeMs` helper, so nested source edits trigger a\n  rebuild.\n- When `PLAYWRIGHT_HEADLESS=false` is set, the in-process worker path\n  is used (headed debugging) instead of the Docker worker, and\n  `npm exec playwright install chromium` is ensured with output\n  streamed to both stdout and the worker log.\n- Backend env injects\n  `RAMBLERS_UPLOAD_WORKER_CALLBACK_BASE_URL=http://host.docker.internal:<port>`\n  when the Docker worker is used.\n- Worker Docker build logs are streamed to the terminal as well as the\n  worker log file.\n\n## CI\n\n- `build-push-and-deploy-ngx-ramblers-docker-image.yml` builds a\n  separate worker image tagged `worker-${run_number}` whenever\n  worker-relevant files changed, deploys it with\n  `--image-tag worker-${run_number}`, and now builds the website image\n  with `--target website` (no Chrome build arg).\n- Manual `build-and-deploy-ramblers-upload-worker.yml` points at the\n  `worker` build stage.\n- `server/deploy/detect-worker-changes.ts` treats\n  `server/playwright.config.ts` as worker-relevant and drops\n  `server/wdio.conf.ts` from its watch list.\n","contentHtml":"<h1>18-Apr-2026 — Ramblers-upload-worker → Integration-worker <a href=\"https://github.com/nbarrett/ngx-ramblers/issues/114\">#114</a></h1>\n<h2><a href=\"https://github.com/nbarrett/ngx-ramblers/actions/runs/24613943307\">build 614</a> — <a href=\"https://github.com/nbarrett/ngx-ramblers/commit/40e8d62202cac68a47709e707cdb38a165d2ca9d\">commit 40e8d62</a></h2>\n<hr>\n<p>Rename the Ramblers upload worker to a generic integration worker, cut every\nin-process browser session out of the website, and replace the buffered\nSerenity StageCrewMember step-delivery path with a Playwright-native reporter\nthat posts each <code>test.step</code> as it ends. Clean up env-var wiring, settings\nsurface, and scripts along the way.</p>\n<h2>Rename: ramblers-upload-worker → integration-worker</h2>\n<p>The worker now handles Ramblers walks upload, Flickr album scraping,\narbitrary HTML fetches and full static-site migration - &quot;upload worker&quot;\nno longer describes it. Rename every piece of the worker plumbing;\nkeep walks-upload-specific files (dispatcher, queue, session registry,\naudit notifier, execution model) named <code>ramblers-upload-*</code> because they\ndescribe one <em>type</em> of integration job.</p>\n<ul>\n<li>Fly app: <code>ngx-ramblers-upload-worker</code> → <code>ngx-ramblers-integration-worker</code></li>\n<li>Fly config: <code>fly.worker.toml</code> → <code>fly.integration-worker.toml</code></li>\n<li>Manual deploy workflow: <code>build-and-deploy-ramblers-upload-worker.yml</code> →\n<code>build-and-deploy-integration-worker.yml</code></li>\n<li>Docker image tags: <code>ngx-ramblers-worker:N</code> →\n<code>ngx-ramblers-integration-worker:N</code>; push tag\n<code>ngx-ramblers:worker-N</code> → <code>ngx-ramblers:integration-worker-N</code>;\nlocal tag <code>ngx-worker:local</code> → <code>ngx-integration-worker:local</code>;\nlocal container <code>ngx-worker-local</code> → <code>ngx-integration-worker-local</code></li>\n<li>Env vars: <code>RAMBLERS_UPLOAD_WORKER_*</code> → <code>INTEGRATION_WORKER_*</code></li>\n<li>Types: <code>RamblersUploadWorker*</code> → <code>IntegrationWorker*</code></li>\n<li>Files: 12 files in <code>server/lib/ramblers/</code>\n(<code>integration-worker-{client,server,routes,runner,callback-client, execution-state,crypto,crypto.spec}</code>) plus\n<code>server/deploy/deploy-integration-worker.ts</code> and\n<code>projects/.../integration-worker.model.ts</code></li>\n<li>Route prefix: <code>/api/ramblers-upload-worker/*</code> →\n<code>/api/integration-worker/*</code></li>\n</ul>\n<h2>Sync browser ops moved to the worker</h2>\n<ul>\n<li><code>POST /api/integration-worker/browser/html-fetch</code></li>\n<li>launches Chromium,\nnavigates, returns <code>{ html, finalUrl, baseHref }</code>. Used by\n<code>html-from-url</code>, <code>fetchHtmlFromUrl</code> and <code>search-venue-website</code> on\nthe website.</li>\n<li><code>POST /api/integration-worker/browser/flickr-user-albums</code></li>\n<li>scrapes a\nFlickr albums page (TrustArc consent handled via Playwright\n<code>frameLocator</code>) and extracts <code>FlickrScrapedUserAlbumsData</code>.</li>\n<li>New <code>integration-worker-browser-client.ts</code> on the website: signs with\n<code>signRamblersUploadBody</code>, POSTs to the worker, falls back to plain\n<code>fetch</code> if the worker errors out.</li>\n<li><code>flickr-provider.scrapeUserAlbumsPage</code> becomes a thin wrapper calling\n<code>scrapeFlickrUserAlbumsViaIntegrationWorker</code>; the ~180-line in-process\npage scrape + cookie-consent iframe dance moved to the worker.</li>\n<li><code>migration-routes.ts</code> drops <code>launchPlainBrowser()</code> entirely. The\n<code>USE_SERENITY_FOR_MIGRATION</code> feature flag and its env entry go with it.</li>\n</ul>\n<h2>Full site migration moved to the worker (async job + callbacks)</h2>\n<ul>\n<li><code>POST /api/integration-worker/migration/jobs</code> accepts\n<code>{ jobId, siteConfig, persistData, uploadTos3, callback }</code>, runs\n<code>migrateStaticSite()</code> on the worker, streams progress back via signed\ncallbacks. Refuses a second job while one is running.</li>\n<li>New model types:\n<code>IntegrationWorkerMigrationJobRequest</code>,\n<code>IntegrationWorkerMigrationProgressCallback</code>,\n<code>IntegrationWorkerMigrationResultCallback</code>.</li>\n<li>New backend callback endpoints at\n<code>/api/integration-worker/migration/progress</code> and <code>/result</code>. Each\nverifies the signature, looks up the active migration session, and\nforwards <code>{ level, message }</code> or the final <code>{ status, result, errorMessage }</code> to the client websocket.</li>\n<li>New <code>migration-session-registry.ts</code></li>\n<li>in-memory map of\n<code>jobId → { ws, siteIdentifier, siteName, historyId, startedAt }</code>.</li>\n<li>New <code>migration-notifier.ts</code></li>\n<li>websocket send + audit-log /\nhistory-status persistence, split from the old ws handler so the\ncallback route and initial handler share it.</li>\n<li><code>site-migration-ws-handler.ts</code> no longer imports\n<code>migrate-static-site-engine</code> or calls <code>migrateStaticSite</code> directly.\nIt resolves the site config, creates the <code>migrationHistory</code> doc,\ngenerates a <code>jobId</code> via <code>crypto.randomUUID()</code>, registers a migration\nsession, and submits the job to the worker via\n<code>submitMigrationJobToIntegrationWorker</code>.</li>\n</ul>\n<h2>Real-time step streaming: fix and DRY up the delivery path</h2>\n<p>The original <code>DomainEventPublisher</code> StageCrewMember (48aca026) worked\nunder WebdriverIO because WDIO fires <code>InteractionFinished</code> /\n<code>TaskFinished</code> synchronously as each action executes. Under\n<code>@serenity-js/playwright-test</code> those domain events are buffered by\n<code>PlaywrightEventBuffer</code> and only announced to the Stage at <code>onTestEnd</code></p>\n<ul>\n<li>so our crew member only ever saw them in one end-of-run batch, which\nis why the audit view only populated when the browser closed.</li>\n</ul>\n<p>Replace the whole per-step delivery path with a Playwright-native\n<code>Reporter</code> (bypasses Serenity&#39;s buffer):</p>\n<ul>\n<li><code>server/lib/serenity-js/reporters/realtime-step-reporter.ts</code> -\nlistens to <code>Reporter.onStepEnd</code>, filters <code>category === &quot;test.step&quot;</code>,\nand POSTs each step to <code>/api/integration-worker/progress</code> with the\nexisting <code>test-step</code> envelope (<code>eventData.timestamp/details.name/ outcome.code</code>). Registered in <code>playwright.config.ts</code> alongside the\nSerenity reporter.</li>\n</ul>\n<p>Backend handler, session registry and audit notifier tightened so the\nreal-time stream behaves correctly:</p>\n<ul>\n<li><code>/progress</code> handler now responds <code>200 OK</code> immediately, then processes\nthe audit asynchronously. Eliminates the 1-3 s per-request latency\nthat was queueing test-step POSTs and bunching them at end-of-run.</li>\n<li><code>/progress</code> for <code>test-step</code> now unwraps the\n<code>{ eventData, finished }</code> envelope before handing the inner event to\n<code>parseTestStepEvent</code>, so the parser sees real <code>timestamp</code> /\n<code>details.name</code> / <code>outcome.code</code> again (the old path was passing the\nenvelope, so every event came out with a NaN timestamp and got\nclustered into one write-time window).</li>\n<li><code>sendAudit</code> is now serialised per-session via a promise chain. Each\nbackground callback runs strictly after the previous one, so\n<code>session.record</code> increments monotonically instead of racing (no more\nruns of <code>rec=8, rec=8, rec=8</code>).</li>\n<li><code>updateRamblersUploadSession</code> mutates the session object in place\n(<code>Object.assign</code>) instead of replacing it in the map, so all captured\n<code>session</code> references stay in sync with the incremented record.</li>\n</ul>\n<h2>DRY: drop the old WDIO-era event transport entirely</h2>\n<p>With <code>RealtimeStepReporter</code> as the single per-step path, the old\nWebSocket-based transport and its wiring are dead weight:</p>\n<ul>\n<li>Deleted:</li>\n<li><code>server/lib/serenity-js/screenplay/crew/DomainEventPublisher.ts</code></li>\n<li><code>server/lib/ramblers/process-test-step-event.ts</code></li>\n<li><code>server/lib/ramblers/integration-worker-websocket-server.ts</code></li>\n<li><code>server/lib/websockets/websocket-client.ts</code></li>\n<li>Removed <code>EventType.TEST_STEP_REPORTER</code> from the shared\n<code>websocket.model.ts</code>.</li>\n<li>Dropped the <code>TEST_STEP_REPORTER</code> handler from the backend\n<code>websocket-server.ts</code> and the <code>createIntegrationWorkerWebSocketServer</code>\ncall from the worker server.</li>\n</ul>\n<h2>Environment enum: move to shared model, prune, enforce</h2>\n<ul>\n<li><code>Environment</code> enum moved to\n<code>projects/ngx-ramblers/src/app/models/environment.model.ts</code> so the\nAngular app can use it too; deleted the server shim at\n<code>server/lib/env-config/environment-model.ts</code>; all 28 server importers\nrewritten to the shared path.</li>\n<li>Added <code>ADMIN_MONGODB_URI</code> and <code>PLAYWRIGHT_HEADLESS</code> to the enum.</li>\n<li>Every literal <code>process.env.X</code> / <code>env.X</code> / <code>secrets.X</code> access in the\nserver now goes through <code>Environment.X</code> - deploy script, CLI\ncommands, worker routes.</li>\n<li>Frontend <code>environment-settings.ts</code> now uses <code>[Environment.X]</code>\ncomputed keys instead of string literals.</li>\n</ul>\n<h2>Local CLI ergonomics</h2>\n<ul>\n<li>New <code>--no-headless</code> flag on <code>ngx-cli local dev</code>. Runs the worker\nheaded (visible browser) and implies <code>--no-docker-worker</code> (Docker\nalways headless). Existing <code>--no-docker-worker</code> still works for\nfast-feedback headless iteration without an image rebuild.</li>\n<li><code>docker-worker</code> and <code>headless</code> threaded through <code>LocalRunConfig</code>.</li>\n<li>Force-kills stale processes on backend / frontend / worker ports\nbefore each start (<code>SIGTERM</code> with 4 s grace, then <code>SIGKILL</code>);\ntears down any stale worker container.</li>\n</ul>\n<h2>Walk export UI</h2>\n<ul>\n<li><code>autoTitle</code> added to the <code>&lt;app-page&gt;</code> wrapper so the tab view has an\nH1 above the tabs, via the same mechanism used by Bookings / Member\nBulk Load / Environment Setup.</li>\n<li>Audit sort state persisted in the URL as `?sort=<field>&amp;sort-order=</li>\n</ul>\n<p>&lt;asc|desc&gt;`:</p>\n<ul>\n<li>New <code>StoredValue</code> entries <code>AUDIT_MESSAGE</code>, <code>AUDIT_TIME</code>,\n<code>DURATION</code>, <code>STATUS</code> (kebab-case values in the URL).</li>\n<li>Reuse existing <code>StoredValue.SORT</code> + <code>SORT_ORDER</code> as the query\nkeys and <code>SortDirection.ASC/DESC</code> from <code>sort.model.ts</code> for the\nvalues (no duplicate <code>&quot;asc&quot;</code>/<code>&quot;desc&quot;</code> constants).</li>\n<li><code>sortAuditsBy(field: StoredValue)</code> now flips order on the same\ncolumn and resets to <code>DESC</code> on a new column, then persists via\n<code>updateUrl()</code>.</li>\n<li><code>AUDIT_SORT_FIELD_MAPPING</code> maps the URL/<code>StoredValue</code> kebab-case\nto the internal camelCase field the <code>sortBy</code> helper reads.</li>\n<li>Duration column:</li>\n<li><code>durationMs</code> is now computed on every audit unconditionally\n(the <code>showDetail</code> gate was stopping the field from being added\nwhen the checkbox was off, which was why the Duration header sort\ndid nothing).</li>\n<li><code>dateUtils.formatDuration</code> no longer short-circuits to &quot;0 secs&quot;\nwhen <code>fromTime === 0</code> - that falsy-zero guard was wrong for\nmillisecond-delta inputs and was making every row render &quot;0 secs&quot;\nregardless of the real duration.</li>\n<li><code>applyFilter</code> handles the oldest row (no previous) cleanly\ninstead of computing <code>auditTime - 0</code>.</li>\n</ul>\n<h2>Scripts cleanup</h2>\n<p><code>server/package.json</code>:</p>\n<ul>\n<li>Dropped <code>test: &quot;failsafe serenity report&quot;</code></li>\n<li>duplicated <code>serenity</code>.</li>\n<li>Dropped <code>lint</code> / <code>lintfix</code> / <code>lintversion</code> and the <code>tslint</code> dep +\n<code>server/tslint.json</code>. tslint has been unmaintained since 2019 and\nnone of the hooks or CI workflows invoke them.</li>\n</ul>\n<p><code>server/test-setenv.sh</code>: dropped stale <code>CHROME_BIN</code> /\n<code>CHROMEDRIVER_PATH</code> exports.</p>\n<p><code>server/.env</code>: local key names renamed to <code>INTEGRATION_WORKER_*</code>;\nstale Chrome paths removed. (Local values only; remote Fly secrets\nre-imported under the new names separately.)</p>\n<h2>Dead code removal</h2>\n<ul>\n<li><code>server/lib/migration/migrate-static-site.ts</code> (standalone NWK\nscript, unreferenced).</li>\n<li><code>server/lib/migration/serenity-migration-engine.ts</code>,\n<code>serenity-migration-utils.ts</code>, <code>serenity-utils.ts</code> and the\n<code>screenplay/</code> scaffolding (parallel implementation, not imported\nby any live route).</li>\n<li><code>server/lib/migration/puppeteer-utils.ts</code> replaced by\n<code>server/lib/migration/browser-utils.ts</code> (only compiled into the\nworker image - never called from the website live path).</li>\n</ul>\n<h2>Fly secrets</h2>\n<p>Staged but not deployed on the two apps available to the current\ntoken:</p>\n<ul>\n<li><code>ngx-ramblers-upload-worker</code>: new <code>INTEGRATION_WORKER_SHARED_SECRET</code>\n/ <code>INTEGRATION_WORKER_ENCRYPTION_KEY</code> staged with matching digests\nto the old names.</li>\n<li><code>ngx-ramblers</code>: new <code>INTEGRATION_WORKER_SHARED_SECRET</code> /\n<code>INTEGRATION_WORKER_ENCRYPTION_KEY</code> / <code>INTEGRATION_WORKER_URL</code> /\n<code>INTEGRATION_WORKER_CALLBACK_BASE_URL</code> staged.</li>\n</ul>\n<p>Staging central DB <code>config.environments</code> for <code>staging</code>, <code>ekwg</code> and\n<code>ekwg-dev-2-staging</code> renamed the per-env secret keys from\n<code>RAMBLERS_UPLOAD_WORKER_*</code> to <code>INTEGRATION_WORKER_*</code> so future\ndeploys push the new names consistently.</p>\n<p>Old secrets remain Deployed on both apps until the next deploy\nactivates the new staged names.</p>\n<h3><strong>worker</strong>: switch upload worker to Serenity/JS Playwright stack and cut the website off WebdriverIO/Puppeteer (<a href=\"https://github.com/nbarrett/ngx-ramblers/issues/114\">#114</a>)</h3>\n<p>Migrate the Ramblers upload worker off the WebdriverIO + Chrome/Chromedriver\nruntime onto the officially supported Serenity/JS Playwright image, harden the\nend-to-end upload path, and remove every WebdriverIO/Puppeteer usage from the\ncodebase so the website image no longer ships a browser stack.</p>\n<h2>Worker image</h2>\n<ul>\n<li><code>Dockerfile</code> <code>worker</code> stage now builds from\n<code>ghcr.io/serenity-js/playwright:v1.58.1-noble</code> (Node 24 pinned via <code>n</code>,\nnpm 11, OpenJDK, Playwright browsers pre-installed on Ubuntu 24.04).\nChrome/Chromedriver/<code>@puppeteer/browsers</code> installs are removed - the\nPlaywright image already ships the browsers the worker needs.</li>\n<li><code>npm run serenity-bdd-update</code> is pre-run so runtime doesn&#39;t re-download\nthe Serenity-BDD JAR.</li>\n<li><code>.dockerignore</code> excludes <code>server/node_modules</code>, <code>cli-output/</code>, <code>target/</code>,\n<code>ts-gen/</code> and <code>dist/</code> so worker images stay lean.</li>\n</ul>\n<h2>Website image</h2>\n<ul>\n<li>Renamed <code>--target origin</code> to <code>--target website</code> to reflect its role as\nthe pure web tier. Build arg <code>CHROME_VERSION</code>, OpenJDK 21 install and\n<code>serenity-bdd-update</code> are removed from this stage - the website no\nlonger runs Serenity or any browser.</li>\n<li>CI workflow now builds with <code>--target website</code> and no longer passes\n<code>--build-arg CHROME_VERSION</code>.</li>\n<li>Dockerfile header comment updated to state the website stage ships\nneither a browser nor a JDK.</li>\n</ul>\n<h2>Test runner migration (WebdriverIO → Playwright)</h2>\n<ul>\n<li><code>server/package.json</code> <code>serenity-run</code> now invokes\n<code>playwright test -c ./playwright.config.ts</code>. Adds <code>@playwright/test</code>,\n<code>playwright</code>, <code>@serenity-js/playwright</code> and <code>@serenity-js/playwright-test</code>.</li>\n<li>New <code>server/playwright.config.ts</code> wires the Serenity crew, BDD reporter,\n<code>ArtifactArchiver</code> and <code>Photographer</code>, using <code>SerenityFixtures</code> /\n<code>SerenityWorkerFixtures</code>. Honours <code>PLAYWRIGHT_HEADLESS</code>,\n<code>RAMBLERS_FEATURE</code> and <code>BASE_URL</code>.</li>\n<li>Feature specs (<code>contacts</code>, <code>process-command-args</code>, <code>walks-and-events- manager</code>, <code>walks-programme</code>, <code>walks-upload</code>) migrated off mocha /\n<code>actorCalled</code> statics to the <code>@serenity-js/playwright-test</code> <code>actorCalled</code>\nfixture; <code>beforeEach</code> engagement is replaced with per-scenario\n<code>actorCalled(...)</code>.</li>\n<li><code>SaveBrowserSource</code> task replaces the WDIO <code>@wdio/globals</code> call with\n<code>BrowseTheWeb.currentPage().executeScript(...)</code> and now writes to\n<code>target/browser-source/</code> with a fallback page on capture failure.</li>\n<li>New <code>SetFileInput</code> task drives file uploads via Playwright&#39;s native\n<code>setInputFiles</code>, replacing <code>Enter.theValue(...)</code> and fixing the\n&quot;Input of type file cannot be filled&quot; failure on the Ramblers upload\nmodal.</li>\n<li>Login wait restored to <code>Wait.upTo(DEFAULT_WAIT_TIMEOUT).until(...)</code>\nsourcing the 15 s budget from a single shared constant in\n<code>server/lib/serenity-js/config/serenity-timeouts.ts</code> (also consumed\nby <code>playwright.config.ts</code>). Fixes the 5 s <code>TimeoutExpiredError</code> on\nthe post-login Create-menu dropdown check.</li>\n</ul>\n<h2>Bulk-action selector hardening</h2>\n<ul>\n<li><code>WalksPageElements</code> tightens the <code>Delete</code> / <code>Unpublish</code> / <code>Publish</code> /\n<code>Uncancel</code> selectors to\n<code>#vbo-action-form-wrapper input[data-vbo=&#39;vbo-action&#39;][value=&#39;...&#39;]</code>,\neliminating ambiguous matches when the VBO action bar renders twice.</li>\n<li>Adds a <code>loggedInWalksManagerPage</code> element (body CSS class check) used\ninside <code>AuthErrorCookieBannerOrCreateMenuDropdown</code> so post-login\ndetection succeeds even before the Create menu renders.</li>\n</ul>\n<h2>Live audit streaming (in progress)</h2>\n<ul>\n<li>Server spawns the Serenity subprocess with\n<code>RAMBLERS_UPLOAD_WORKER_CALLBACK_BASE_URL</code>,\n<code>_CALLBACK_PROGRESS_PATH</code>, <code>_CALLBACK_RESULT_PATH</code>,\n<code>_CALLBACK_SECRET</code> and <code>_JOB_ID</code> in the environment (new\n<code>Environment</code> enum entries).</li>\n<li><code>DomainEventPublisher</code> posts <code>TEST_STEP</code> events directly to the worker\ncallback endpoint when that env is present, with diagnostic logging on\nthe transport branch actually taken, instead of going through the\nSerenity subprocess websocket (which intermittently dropped with\nclose code 1006 and caused audit rows to bunch at end-of-run).</li>\n<li><code>processTestStepEvent</code> now writes step events straight into the active\nlocal session so the Walk upload audit tab updates while the browser\nis still running.</li>\n<li>Frontend <code>WebSocketClientService</code> wraps <code>onopen</code> / <code>onmessage</code> /\n<code>onclose</code> / <code>onerror</code> in <code>NgZone.run(...)</code> so progress events trigger\nchange detection. <code>walk-export.ts</code> calls\n<code>ChangeDetectorRef.detectChanges()</code> on updates, broadcasts\n<code>NamedEventType.REFRESH</code> on completion, and runs a 2 s audit refresh\nloop while the export is in progress.</li>\n</ul>\n<h2>Audit parser &amp; timing clean-up</h2>\n<ul>\n<li><code>ramblers-audit-parser</code> strips ANSI escape sequences via module-level\n<code>RegExp</code>s built from <code>String.fromCharCode(27)</code> (no inline control\ncharacters, passes <code>no-control-regex</code> without a disable) and filters\nseparator / noise lines such as <code>Execution Summary</code>, <code>Scenarios:</code>,\n<code>Real time:</code>, <code>Total time:</code>, <code>1 passed</code>,\n<code>Succeeded with exit code 0 as all scripts passed</code>, <code>&gt; playwright</code>,\n<code>@playwright/test</code>, <code>@serenity-js/playwright</code> / <code>-test</code>,\n<code>Running 1 test using 1 worker</code>, the <code>mkdir -p target/site/archive</code>\nline and all-<code>=</code> separator lines.</li>\n<li><code>walk-export.ts</code> <code>timing()</code> no longer has a special SUMMARY case that\nreturned the whole-session duration; summary and step rows now both\nshow true per-step durations derived from <code>durationMs</code> or the\nadjacent-row delta.</li>\n<li><code>ramblers-upload-walks.ts</code> progress line reports\n<code>PLAYWRIGHT_HEADLESS</code> and <code>BASE_URL</code> instead of the removed\n<code>CHROME_BIN</code> / <code>CHROMEDRIVER_PATH</code>.</li>\n</ul>\n<h2>Report storage (zipped artefacts)</h2>\n<ul>\n<li><code>ramblers-upload-worker-runner.ts</code> uploads the Serenity report as a\nsingle <code>&lt;keyPrefix&gt;.zip</code> via <code>adm-zip</code>, emitting lifecycle progress\nevents for archive start, completion (with file count, byte size and\nelapsed ms) and failure.</li>\n<li><code>recordReportLocation</code> stores the summary message as\n<code>s3://&lt;bucket&gt;/&lt;keyPrefix&gt;.zip</code>.</li>\n<li>New <code>GET /api/aws/report/:bucket/*</code> route in <code>aws-routes.ts</code> streams\nassets from the archive: <code>aws-controllers.ts</code> lazy-extracts each\nreport under <code>target/report-cache/&lt;sha1&gt;/</code>, guards against directory\ntraversal, dedupes concurrent extractions via an in-memory promise\ncache, and falls back to a chunked <code>streamToBuffer</code> for SDK response\nbodies that aren&#39;t already <code>Buffer</code>s.</li>\n<li><code>walk-export.ts</code> <code>openReport()</code> uses the new route with\n<code>bucket</code> + <code>keyPrefix</code> + <code>/_/</code> + <code>index.html</code> as the split marker;\nsessions without a stored bucket are no-ops.</li>\n</ul>\n<h2>WebdriverIO / Puppeteer removal across the codebase</h2>\n<ul>\n<li><code>@serenity-js/webdriverio</code>, <code>webdriverio</code>, <code>puppeteer</code> and every\n<code>@wdio/*</code> runner/CLI package removed from <code>server/package.json</code>; the\nlockfile shrunk by ~4 200 lines.</li>\n<li>Deleted <code>server/wdio.conf.ts</code>, <code>server/lib/serenity-js/features/config/ actors.ts</code>, <code>server/lib/migration/puppeteer-utils.ts</code> and the parallel\n<code>serenity-migration-engine.ts</code> / <code>serenity-migration-utils.ts</code> /\n<code>serenity-utils.ts</code> stack plus their <code>screenplay/</code> scaffolding and the\nstandalone <code>migrate-static-site.ts</code> script (none were imported by any\nlive route).</li>\n<li>New <code>server/lib/migration/browser-utils.ts</code> provides a Playwright\n<code>chromium.launch(...)</code> replacement for the old puppeteer helper.</li>\n<li><code>migrate-static-site-engine.ts</code> now imports <code>Browser</code>/<code>Page</code> from\n<code>playwright</code>, swaps <code>networkidle2</code> for <code>networkidle</code>, and restructures\nevery <code>page.evaluate(fn, arg1, arg2, ...)</code> call for Playwright&#39;s\nsingle-argument contract (object-payload pattern).</li>\n<li><code>migration-routes.ts</code> collapses the dual WebdriverIO / Puppeteer\nbranches, drops the <code>USE_SERENITY_FOR_MIGRATION</code> feature flag and its\nenv entry, and uses Playwright-only paths.</li>\n<li><code>flickr-provider.ts</code> is on Playwright: <code>browser.url</code>/<code>waitUntil</code>/\n<code>execute</code>/<code>getPageSource</code>/<code>$</code> become <code>page.goto</code>/<code>waitForFunction</code>/\n<code>evaluate</code>/<code>content</code>/<code>locator</code>, and cookie-consent iframe handling is\nreimplemented with <code>page.frameLocator(...)</code> rather than\n<code>switchToFrame</code>.</li>\n<li><code>Environment</code> enum drops <code>CHROME_BIN</code> and <code>CHROMEDRIVER_PATH</code>; the\n<code>ramblers-audit-parser</code> noise list replaces the webdriverio marker\nwith <code>@serenity-js/playwright</code>.</li>\n</ul>\n<h2>Local dev ergonomics</h2>\n<ul>\n<li><code>bin/ngx-cli local dev</code> force-kills stale processes holding the\nbackend / frontend / worker ports (<code>SIGTERM</code> with a 4 s grace, then\n<code>SIGKILL</code>), and tears down any old worker container before each run.</li>\n<li>Docker worker rebuild trigger now watches\n<code>server/playwright.config.ts</code>, <code>server/lib/ramblers</code>,\n<code>server/lib/serenity-js</code> and <code>server/lib/env-config</code> recursively, via\na new <code>newestMtimeMs</code> helper, so nested source edits trigger a\nrebuild.</li>\n<li>When <code>PLAYWRIGHT_HEADLESS=false</code> is set, the in-process worker path\nis used (headed debugging) instead of the Docker worker, and\n<code>npm exec playwright install chromium</code> is ensured with output\nstreamed to both stdout and the worker log.</li>\n<li>Backend env injects\n<code>RAMBLERS_UPLOAD_WORKER_CALLBACK_BASE_URL=http://host.docker.internal:&lt;port&gt;</code>\nwhen the Docker worker is used.</li>\n<li>Worker Docker build logs are streamed to the terminal as well as the\nworker log file.</li>\n</ul>\n<h2>CI</h2>\n<ul>\n<li><code>build-push-and-deploy-ngx-ramblers-docker-image.yml</code> builds a\nseparate worker image tagged <code>worker-${run_number}</code> whenever\nworker-relevant files changed, deploys it with\n<code>--image-tag worker-${run_number}</code>, and now builds the website image\nwith <code>--target website</code> (no Chrome build arg).</li>\n<li>Manual <code>build-and-deploy-ramblers-upload-worker.yml</code> points at the\n<code>worker</code> build stage.</li>\n<li><code>server/deploy/detect-worker-changes.ts</code> treats\n<code>server/playwright.config.ts</code> as worker-relevant and drops\n<code>server/wdio.conf.ts</code> from its watch list.</li>\n</ul>\n"}