Skip to content

fix: replace playwright-core monkey patches with a public-API har engine - #64

Draft
vanilla-wave wants to merge 20 commits into
mainfrom
feat/har-zero-internals-engine
Draft

fix: replace playwright-core monkey patches with a public-API har engine#64
vanilla-wave wants to merge 20 commits into
mainfrom
feat/har-zero-internals-engine

Conversation

@vanilla-wave

@vanilla-wave vanilla-wave commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Problem

Playwright bundled all of lib/server/** into lib/coreBundle.js starting with playwright-core 1.60.0 (PRs microsoft/playwright#40057 + #40074; first bundled artifact 1.60.0-alpha-2026-04-07, first stable 1.60.0).

har/getPlaywrightCoreModule.ts resolves absolute file paths inside playwright-core and require()s them:

  • lib/server/dispatchers/localUtilsDispatcher — patched for harOpen / harLookup
  • lib/server/har/harRecorder — patched for onEntryFinished / flush

Both now throw MODULE_NOT_FOUND, so the whole HAR feature is dead on Playwright >= 1.60, and ./fixtures is affected transitively through mockNetworkFixturesBuilderharPatcher. @playwright/test@1.62 has no lib/ at all, so the second resolution branch is dead too. coreBundle's server namespace exports no HAR class, and 1.63.0-alpha is the same shape.

Worse, the failure is silent: patchInited = true is the first line of each init function, before getPlaywrightCoreModule() throws. In every worker the first four tests die with four different errors and tests 5+ register no transforms and throw nothing — the recorded https://base.url.placeholder is never rewritten back, every lookup returns noentry, and notFound: 'abort' aborts everything.

Solution

Two tiers, selected by what the installed playwright-core ships, not by version number:

  • Playwright 1.23–1.59 — legacy tier. The historical HarRecorder / LocalUtilsDispatcher patches, now installed all at once by the first add*Transform / initDumps call (every patch is a pass-through for an unregistered hook). Released versions keep behaving exactly as before.

  • Playwright 1.60+ — public-API tier. No require() of internal paths. The first add*Transform / initDumps call of a worker wraps the browser factories of every resolvable playwright-core (chromium.launch(), connect(), connectOverCDP(), launchPersistentContext()) — the very object @playwright/test launches with — and everything created below them is wrapped as it appears: Browser.newContext, BrowserContext.routeFromHAR / newPage / close, Tracing.startHar / stopHar, Page.routeFromHAR. So every producer of a dump goes through the engine, whether or not installHarEngine() ever saw the object:

    • routeFromHAR({update: true}) on a page or a context, including a bare call without initDumps;
    • recordHar of browser.newContext() / launchPersistentContext() / use: {contextOptions: {recordHar}};
    • context.tracing.startHar() / stopHar() (1.60+), including a recording that is still running at context.close().

    Replay — Playwright's own HAR router replays the dump, so response timing is unchanged. The package wraps the client-side LocalUtils.harOpen / harLookup / harClose calls the router makes: without an open transform Playwright matches the requests itself; with one, the dump is opened in memory, addHarOpenTransform is applied there and the router's lookups are answered by the vendored matcher port, so the dump on disk is never rewritten. addHarLookupTransform wraps harLookup on both paths.

    Record — Playwright records into a private directory next to the dump (.har-recording-<pid>-<id>/), which also receives the body blobs of an unpacked dump. After context.close() / stopHar() the record-side transforms are applied to the finished recording, the dump and the blobs it still references move next to the committed dumps, and the directory is removed. If anything fails the dump is simply missing instead of being committed with unscrubbed headers, and a failure of one recording never leaves another one behind. A recording that Playwright never exported (browser.close() before context.close(), a crash) is reported as record-not-exported and removed; recordings abandoned by dead processes are swept on the next recording into that directory.

    Fallback — for a thin client, a userland replay on context.route() with a vendored port of Playwright's matcher (har/vendor/, Apache-2.0, LICENSE + NOTICE shipped).

Fail-closed. The old code threw Can't find … in playwright-core when a patch could not be installed; the first cut of this PR silently ran plain Playwright instead. Now installHarEngine() throws when no playwright-core / BrowserType.launch / routeFromHAR can be found, every downgrade goes through one HAR engine degraded (<code>) funnel, and PLAYWRIGHT_TOOLS_HAR_STRICT=1 turns each of them into an error — the HAR CI matrix runs with it. The transform registry lives on globalThis, so two copies of the package in one worker share it, as they used to share the one patched playwright-core.

Registration rules made explicit. addHarOpenTransform is consumed when the dump is opened; registering it after a replay has already started in the worker now throws instead of silently not applying. A later add*Transform call with a different function is still ignored (first call wins, as before) but warns. The mock-network fixture registers its per-test transforms through setFixtureHarTransforms instead of the latched calls.

Not covered on 1.60+, documented in har/README.md: reuse-browser / UI mode (_newContextForReuse), a launchServer() browser driven by a client in another language, the playwright open --save-har CLI, and a context created before the first registration of the worker.

Record-side notes that also applied before this change:

  • addHarRecorderTransform / addFlushTransform run after context.close() rather than during the test on 1.60+. Same Entry objects, same order, same result on disk.
  • Response bodies live in separate blobs referenced by content._file, so they are not reachable from these hooks. Rewriting a body per request is what addHarLookupTransform's transformResult is for.
  • replaceBaseUrlInEntry discarded the result of redirectURL.replace(...), so the live origin stayed in every recorded redirect. Fixed; the canaries assert the origin is absent from the whole dump.

Compatibility

All four documented hooks keep their names, signatures, argument shapes, return contracts and first-call-wins semantics. initDumps, clearHeaders, replaceBaseUrlInEntry, setExtraHash, the path builders and all exported types are untouched.

Behaviour changes: addHarOpenTransform throws when registered after a replay has started (it never applied in that case); an ignored repeat registration warns; installHarEngine throws when the engine cannot be installed. Additive: installHarEngine(target?), resetHarTransforms, setFixtureHarTransforms, PLAYWRIGHT_TOOLS_HAR_STRICT.

The oldest supported version is 1.23 — earlier ones have no routeFromHAR at all, so the declared peer range ^1.22 is wider than what actually works.

Verification

Record → post-process → replay round trip with all four hooks firing on 1.58.1 (legacy tier) and 1.62.1 (public-API tier), both also in strict mode.

Validated against a large real consumer (Yandex Tracker, ~2400 committed dumps) on Playwright 1.62.1 before the producer coverage landed:

  • a 483-test slice: 444 passed / 14 failed vs 438 / 20 on the trunk baseline — zero failures unique to this change
  • an independent adversarial check ran the missing control (this engine on Playwright 1.58.2) and found the engine to be a no-op at fixed Playwright version
  • the rewritten temporary dump is byte-equivalent to the old in-memory transform (JSON.stringify(oldHar) === JSON.stringify(newHar), all zip blobs identical)

An adversarial audit of the first cut (25 confirmed bypasses, reproduced by running code) drove the eager installation, the producer coverage, the private recording directory and the fail-closed diagnostics above.

Tests

har/ had no tests at all. Now 103 unit tests and 16 integration ones:

  • har/engine/__tests__/installHarEngine.test.ts — against a stand-in class hierarchy: the whole wrap chain from the browser factories, both live-object overloads, every copy of playwright-core, recordHar redirection for newContext / launchPersistentContext, startHar / stopHar / close, native vs fallback replay, concurrent close(), failure isolation and aggregation, the browser-side close event, stale-recording sweep, duplicate-copy and strict-mode diagnostics.
  • har/engine/__tests__/harPostProcessor.test.ts — transforms applied to the written dump, ordering of the per-entry and flush passes, blob moves for unpacked dumps, orphaned blobs dropped, never-exported recordings, failing transforms leaving neither recording, dump nor blobs behind.
  • har/engine/__tests__/transformRegistry.test.ts / diagnostics.test.ts / nativeHarReplay.test.ts / harReplayEngine.test.ts — latch, warnings, late-open throw, the shared globalThis registry, strict mode, the LocalUtils seam, the userland router.
  • har/vendor/__tests__/* — the matcher port (including 301/302/303/307 method rules), the ZIP reader/writer (Playwright-recorded fixture, ZIP64 archives, named errors instead of silent truncation), the serializer.
  • tests/har/ — canaries by the bytes on disk (no cookies, no live origin, marker on every response, file present) for every producer: bare routeFromHAR in a fresh worker without initDumps, recordHar on newContext and launchPersistentContext, tracing.startHar stopped and unstopped, installHarEngine(context) + page.routeFromHAR, browser.close() before context.close(), the forced userland fallback, plus the original round trips with assertions on which engine served the replay.

CI

.github/workflows/har-matrix.yml runs the integration suite against Playwright 1.49.1, 1.51.0, 1.58.1, 1.59.0, 1.60.0 and latest on pull requests touching the mechanism, latest nightly, plus a non-blocking next canary — all with PLAYWRIGHT_TOOLS_HAR_STRICT=1, so a silently downgraded engine fails the job.

@gravity-ui

gravity-ui Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

🚀 Prerelease version published!

Install this PR version:

npm i --save-dev @gravity-ui/playwright-tools@2.0.2-beta.35bcd01782e61b2a5a292a30506e89dbd19b1dde.0

@SwinX

SwinX commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

praise: really great implementation and its tests :) pwt-tools now does not rely on pwt implementation details which is great!

However changes surface is a bit scary. Instead of patching we're using new record mechanism + completely custom mechanism of replay, which is based on vendor port

Maybe it will be a good idea to discuss patch on call?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants