fix: replace playwright-core monkey patches with a public-API har engine - #64
Draft
vanilla-wave wants to merge 20 commits into
Draft
fix: replace playwright-core monkey patches with a public-API har engine#64vanilla-wave wants to merge 20 commits into
vanilla-wave wants to merge 20 commits into
Conversation
Contributor
|
🚀 Prerelease version published! Install this PR version: npm i --save-dev @gravity-ui/playwright-tools@2.0.2-beta.35bcd01782e61b2a5a292a30506e89dbd19b1dde.0 |
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? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Playwright bundled all of
lib/server/**intolib/coreBundle.jsstarting with playwright-core 1.60.0 (PRs microsoft/playwright#40057 + #40074; first bundled artifact1.60.0-alpha-2026-04-07, first stable 1.60.0).har/getPlaywrightCoreModule.tsresolves absolute file paths insideplaywright-coreandrequire()s them:lib/server/dispatchers/localUtilsDispatcher— patched forharOpen/harLookuplib/server/har/harRecorder— patched foronEntryFinished/flushBoth now throw
MODULE_NOT_FOUND, so the whole HAR feature is dead on Playwright >= 1.60, and./fixturesis affected transitively throughmockNetworkFixturesBuilder→harPatcher.@playwright/test@1.62has nolib/at all, so the second resolution branch is dead too.coreBundle'sservernamespace exports no HAR class, and1.63.0-alphais the same shape.Worse, the failure is silent:
patchInited = trueis the first line of each init function, beforegetPlaywrightCoreModule()throws. In every worker the first four tests die with four different errors and tests 5+ register no transforms and throw nothing — the recordedhttps://base.url.placeholderis never rewritten back, every lookup returnsnoentry, andnotFound: 'abort'aborts everything.Solution
Two tiers, selected by what the installed
playwright-coreships, not by version number:Playwright 1.23–1.59 — legacy tier. The historical
HarRecorder/LocalUtilsDispatcherpatches, now installed all at once by the firstadd*Transform/initDumpscall (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 firstadd*Transform/initDumpscall of a worker wraps the browser factories of every resolvableplaywright-core(chromium.launch(),connect(),connectOverCDP(),launchPersistentContext()) — the very object@playwright/testlaunches 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 notinstallHarEngine()ever saw the object:routeFromHAR({update: true})on a page or a context, including a bare call withoutinitDumps;recordHarofbrowser.newContext()/launchPersistentContext()/use: {contextOptions: {recordHar}};context.tracing.startHar()/stopHar()(1.60+), including a recording that is still running atcontext.close().Replay — Playwright's own HAR router replays the dump, so response timing is unchanged. The package wraps the client-side
LocalUtils.harOpen/harLookup/harClosecalls the router makes: without an open transform Playwright matches the requests itself; with one, the dump is opened in memory,addHarOpenTransformis applied there and the router's lookups are answered by the vendored matcher port, so the dump on disk is never rewritten.addHarLookupTransformwrapsharLookupon 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. Aftercontext.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()beforecontext.close(), a crash) is reported asrecord-not-exportedand 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+NOTICEshipped).Fail-closed. The old code threw
Can't find … in playwright-corewhen a patch could not be installed; the first cut of this PR silently ran plain Playwright instead. NowinstallHarEngine()throws when noplaywright-core/BrowserType.launch/routeFromHARcan be found, every downgrade goes through oneHAR engine degraded (<code>)funnel, andPLAYWRIGHT_TOOLS_HAR_STRICT=1turns each of them into an error — the HAR CI matrix runs with it. The transform registry lives onglobalThis, so two copies of the package in one worker share it, as they used to share the one patchedplaywright-core.Registration rules made explicit.
addHarOpenTransformis 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 lateradd*Transformcall with a different function is still ignored (first call wins, as before) but warns. The mock-network fixture registers its per-test transforms throughsetFixtureHarTransformsinstead of the latched calls.Not covered on 1.60+, documented in
har/README.md:reuse-browser/ UI mode (_newContextForReuse), alaunchServer()browser driven by a client in another language, theplaywright open --save-harCLI, and a context created before the first registration of the worker.Record-side notes that also applied before this change:
addHarRecorderTransform/addFlushTransformrun aftercontext.close()rather than during the test on 1.60+. SameEntryobjects, same order, same result on disk.content._file, so they are not reachable from these hooks. Rewriting a body per request is whataddHarLookupTransform'stransformResultis for.replaceBaseUrlInEntrydiscarded the result ofredirectURL.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:
addHarOpenTransformthrows when registered after a replay has started (it never applied in that case); an ignored repeat registration warns;installHarEnginethrows 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
routeFromHARat all, so the declared peer range^1.22is 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:
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 ofplaywright-core,recordHarredirection fornewContext/launchPersistentContext,startHar/stopHar/ close, native vs fallback replay, concurrentclose(), failure isolation and aggregation, the browser-sidecloseevent, 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 sharedglobalThisregistry, strict mode, theLocalUtilsseam, 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: barerouteFromHARin a fresh worker withoutinitDumps,recordHaronnewContextandlaunchPersistentContext,tracing.startHarstopped and unstopped,installHarEngine(context)+page.routeFromHAR,browser.close()beforecontext.close(), the forced userland fallback, plus the original round trips with assertions on which engine served the replay.CI
.github/workflows/har-matrix.ymlruns 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,latestnightly, plus a non-blockingnextcanary — all withPLAYWRIGHT_TOOLS_HAR_STRICT=1, so a silently downgraded engine fails the job.