diff --git a/.github/workflows/patchright_release.yml b/.github/workflows/patchright_release.yml index 83e782b8..75216c56 100644 --- a/.github/workflows/patchright_release.yml +++ b/.github/workflows/patchright_release.yml @@ -172,6 +172,8 @@ jobs: let pageTestsStepLink = 'Unavailable'; let libraryTestsStepLink = 'Unavailable'; + let pageTestsStepUrl = null; + let libraryTestsStepUrl = null; try { const jobsResponse = await github.rest.actions.listJobsForWorkflowRun({ owner: context.repo.owner, @@ -189,15 +191,62 @@ jobs: const pageStep = (testsJob.steps || []).find((step) => step.name === 'Run Page Tests'); const libraryStep = (testsJob.steps || []).find((step) => step.name === 'Run Library Tests'); - if (pageStep?.number) - pageTestsStepLink = `[Run Page Tests](${testsJob.html_url}#step:${pageStep.number}:1)`; - if (libraryStep?.number) - libraryTestsStepLink = `[Run Library Tests](${testsJob.html_url}#step:${libraryStep.number}:1)`; + if (pageStep?.number) { + pageTestsStepUrl = `${testsJob.html_url}#step:${pageStep.number}:1`; + pageTestsStepLink = `[Run Page Tests](${pageTestsStepUrl})`; + } + if (libraryStep?.number) { + libraryTestsStepUrl = `${testsJob.html_url}#step:${libraryStep.number}:1`; + libraryTestsStepLink = `[Run Library Tests](${libraryTestsStepUrl})`; + } } } catch (error) { // Keep defaults when job/step links are unavailable. } + let reportObj = null; + try { + reportObj = JSON.parse(reportJsonText); + } catch { + // leave null; summary will reflect that the report was unreadable + } + + let reportArtifact = null; + try { + const artifactsResponse = await github.rest.actions.listWorkflowRunArtifacts({ + owner, + repo, + run_id: context.runId, + }); + reportArtifact = artifactsResponse.data.artifacts.find((a) => a.name === 'report.json') || null; + } catch (error) { + reportArtifact = { error: String(error.message) }; + } + + const summaryPayload = { + old_version: oldVersion, + new_version: rawNewVersion, + summary: reportObj?.summary || { total: 0, affected: 0, unaffected: 0 }, + workflow_run_url: runUrl, + commit: commitSha, + test_links: { + page_tests: pageTestsStepUrl, + library_tests: libraryTestsStepUrl, + }, + affected_symbols: (reportObj?.affected || []).map((row) => ({ + symbol: row.symbol, + kind: row.kind, + change_type: row.changeType, + playwright_file: row.playwrightFile, + patch_file: row.patchFile, + })), + report_artifact: reportArtifact && reportArtifact.id ? { + artifact_id: reportArtifact.id, + name: reportArtifact.name, + archive_download_url: reportArtifact.archive_download_url, + } : (reportArtifact && reportArtifact.error ? { error: reportArtifact.error } : null), + }; + const issueBody = [ `## Release Failed: Playwright ${oldVersion} โ†’ ${rawNewVersion}`, '', @@ -215,11 +264,14 @@ jobs: '', affectedTable, '', - 'Full `check_patch_impact` report (`report.json`):', + '### Machine-readable summary', + '', '```json', - reportJsonText, + JSON.stringify(summaryPayload, null, 2), '```', '', + 'The full `report.json` artifact is linked in `report_artifact.archive_download_url` above.', + '', 'cc @Vinyzu', ].join('\n'); diff --git a/.github/workflows/patchright_tests.yml b/.github/workflows/patchright_tests.yml index 814e8b53..ba02f709 100644 --- a/.github/workflows/patchright_tests.yml +++ b/.github/workflows/patchright_tests.yml @@ -80,10 +80,15 @@ jobs: cd playwright npx playwright install-deps - - name: Install Chromium Browser + - name: Install Chromium and Chrome Browsers run: | cd playwright - npx playwright install chromium + npx playwright install chromium chrome + + - name: Run Detection Smoke Test + id: test_detection + continue-on-error: ${{ github.event_name == 'workflow_call' }} + run: xvfb-run -a node utils/detection_smoke_test.mjs - name: Modify Tests for Patchright run: npx tsx utils/modify_tests.ts @@ -106,7 +111,7 @@ jobs: id: set_test_outcome if: always() run: | - if [ "${{ steps.test_page.outcome }}" = "failure" ] || [ "${{ steps.test_library.outcome }}" = "failure" ]; then + if [ "${{ steps.test_detection.outcome }}" = "failure" ] || [ "${{ steps.test_page.outcome }}" = "failure" ] || [ "${{ steps.test_library.outcome }}" = "failure" ]; then echo "test_outcome=failure" >> "$GITHUB_OUTPUT" else echo "test_outcome=success" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index b371768a..31e9bba6 100644 --- a/.gitignore +++ b/.gitignore @@ -161,8 +161,12 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ .DS_Store +.vscode/ # Node.js node_modules/ package-lock.json playwright/ + +# MCP +.playwright-mcp/ diff --git a/README.md b/README.md index 8852706d..82f5ba3b 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@

Sponsors

-Want to Sponsor this Project? [Contact Me](https://discordapp.com/users/935224495126487150)! +Want to Sponsor this Project? Contact [@vinyzu](https://discordapp.com/users/935224495126487150) on Discord!
@@ -91,6 +91,52 @@ Built for developers who need **reliable**, anti-detection proxy infrastructure. Start fast. Scale globally. Stay stable with [RapidProxy](https://www.rapidproxy.io/?ref=Vinyzu). +--- + +[NodeMaven Banner](https://go.nodemaven.com/VinyzuGH) +
+#### [NodeMaven](https://go.nodemaven.com/VinyzuGH): Your reliable proxy provider with High Quality IPs.
Superb solution for automation, web scraping, SEO research, and social media management. + +Why [NodeMaven](https://go.nodemaven.com/VinyzuGH)? + +โœ… 99.9% Uptime +
+๐ŸŽฏ Sticky sessions up to 7 days +
+๐Ÿ” IP Filtering: Proxies are guaranteed to have fraud score <97% +
+๐Ÿ”ฐ No KYC required +
+๐Ÿ’ธ Cashback on Traffic - Burn GB and earn up to 10% back + +**Special codes** for Patchright users: +
+๐Ÿ”ฅ **PATCHRIGHT35** - **35% off** to Mobile and Residential Proxies +
+๐Ÿ”ฅ **PATCHRIGHT40** - **40% off** to ISP (Static) Proxies + +Choose quality and stability with [NodeMaven](https://go.nodemaven.com/VinyzuGH). + +--- + +[ColaProxy Banner](https://colaproxy.com/?utm_source=viny&utm_medium=viny&ref=viny) + +#### [**ColaProxy**](https://colaproxy.com/?utm_source=viny&utm_medium=viny&ref=viny) : Low-Cost Residential Proxies for Scalable Automation, Scraping, and Global Data Access + +Why choose [**ColaProxy**](https://colaproxy.com/?utm_source=viny&utm_medium=viny&ref=viny)? + +* Dynamic Residential Proxies from as low as **$0.3/GB** +* Massive residential IP pool across 195+ countries +* 99.9% uptime with stable and fast connections +* HTTP / HTTPS / SOCKS5 protocol support +* Rotating & sticky sessions for flexible automation +* Precise country-level geo-targeting +* 24/7 expert support + +Need reliable proxies for Patchright automation? [ColaProxy](https://colaproxy.com/?utm_source=viny&utm_medium=viny&ref=viny) offers cost-effective residential, ISP, and mobile proxy solutions for scraping, testing, account management, SEO monitoring, and global data collection. Use coupon code **COLA20** to enjoy 20% off your order. + +Affordable, stable, and built for developers. Start scaling your Patchright projects with [**ColaProxy**](https://colaproxy.com/?utm_source=viny&utm_medium=viny&ref=viny). +
--- diff --git a/driver_patches/XPathSelectorEnginePatch.ts b/driver_patches/XPathSelectorEnginePatch.ts index 79f206a7..a63b7fef 100644 --- a/driver_patches/XPathSelectorEnginePatch.ts +++ b/driver_patches/XPathSelectorEnginePatch.ts @@ -14,9 +14,7 @@ export function patchXPathSelectorEngine(project: Project) { .getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression); // -- evaluateExpression Method -- - const queryAllMethod = assertDefined( - xPathEngineLiteral.getProperty("queryAll") - ); + const queryAllMethod = assertDefined(xPathEngineLiteral.getProperty("queryAll")); queryAllMethod.asKindOrThrow(SyntaxKind.MethodDeclaration).insertStatements(0, ` if (root.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { const result: Element[] = []; @@ -54,4 +52,4 @@ export function patchXPathSelectorEngine(project: Project) { return result; } `); -} \ No newline at end of file +} diff --git a/driver_patches/browserContextDispatcherPatch.ts b/driver_patches/browserContextDispatcherPatch.ts index f2786e6e..275637d2 100644 --- a/driver_patches/browserContextDispatcherPatch.ts +++ b/driver_patches/browserContextDispatcherPatch.ts @@ -6,7 +6,9 @@ import { assertDefined } from "./utils.ts"; // ---------------------------------------------- export function patchBrowserContextDispatcher(project: Project) { // Add source file to the project - const contextDispatcherSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts"); + const contextDispatcherSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts", + ); // ------- BrowserContextDispatcher Class ------- const contextDispatcherClass = contextDispatcherSourceFile.getClassOrThrow("BrowserContextDispatcher"); @@ -18,7 +20,7 @@ export function patchBrowserContextDispatcher(project: Project) { contextDispatcherConstructor .getBodyOrThrow() .getDescendantsOfKind(SyntaxKind.ExpressionStatement) - .find((stmt) => stmt.getText().startsWith("this._dialogHandler =")), + .find(stmt => stmt.getText().startsWith("this._dialogHandler =")), ); dialogHandlerAssignment.replaceWithText(` this._dialogHandler = dialog => { diff --git a/driver_patches/browserContextPatch.ts b/driver_patches/browserContextPatch.ts index 0313b9e6..11408dd3 100644 --- a/driver_patches/browserContextPatch.ts +++ b/driver_patches/browserContextPatch.ts @@ -12,28 +12,29 @@ export function patchBrowserContext(project: Project) { const browserContextClass = browserContextSourceFile.getClassOrThrow("BrowserContext"); // -- _initialize Method -- - const initializeMethod = browserContextClass.getMethodOrThrow("_initialize"); + const initializeMethod = browserContextClass.getMethodOrThrow("initialize"); // Getting the service worker registration call const initializeMethodCall = assertDefined( initializeMethod - .getDescendantsOfKind(SyntaxKind.CallExpression) - .find((call) => - call.getExpression().getText().includes("addInitScript") && - call.getArguments().some((arg) => - arg.getText().includes("navigator.serviceWorker.register") - ) - ) + .getDescendantsOfKind(SyntaxKind.CallExpression) + .find( + call => + call.getExpression().getText().includes("addInitScript") && + call.getArguments().some(arg => arg.getText().includes("navigator.serviceWorker.register")), + ), ); const initScriptArgument = assertDefined( - initializeMethodCall.getArguments()[1] ?? initializeMethodCall.getArguments()[0] + initializeMethodCall.getArguments()[1] ?? initializeMethodCall.getArguments()[0], ); // Replace the service worker registration call with a custom one, which is less obvious - initScriptArgument.replaceWithText("`if (navigator.serviceWorker) navigator.serviceWorker.register = async () => { };`"); + initScriptArgument.replaceWithText( + "`if (navigator.serviceWorker) navigator.serviceWorker.register = async () => { };`", + ); // -- exposeBinding Method -- const exposeBindingMethod = browserContextClass.getMethodOrThrow("exposeBinding"); // Remove old loop and logic for localFrames and isolated world creation - exposeBindingMethod.getStatements().forEach((statement) => { + exposeBindingMethod.getStatements().forEach(statement => { const text = statement.getText(); // Check if the statement matches the patterns if (text.includes("this.doAddInitScript(binding.initScript)")) diff --git a/driver_patches/buildPatch.ts b/driver_patches/buildPatch.ts new file mode 100644 index 00000000..7cb370a7 --- /dev/null +++ b/driver_patches/buildPatch.ts @@ -0,0 +1,21 @@ +import { type Project, SyntaxKind } from "ts-morph"; + +// -------------------- +// utils/build/build.js +// -------------------- +export function patchBuild(project: Project) { + const buildSourceFile = project.addSourceFileAtPath("utils/build/build.js"); + const bundleAssertion = buildSourceFile.getFunctionOrThrow("assertCoreBundleHasNoNodeModules"); + const markerSearch = bundleAssertion + .getDescendantsOfKind(SyntaxKind.VariableDeclaration) + .find(declaration => declaration.getName() === "idx") + ?.getInitializerOrThrow(); + if (!markerSearch) throw new Error("Could not find the coreBundle node_modules marker check"); + markerSearch.replaceWithText("lines[i].search(/(?:require|import)\\s*\\(\\s*['\"][^'\"]*node_modules\\//)"); + buildSourceFile.replaceWithText( + buildSourceFile + .getFullText() + .replace("coreBundle.js contains 'node_modules/' references", "coreBundle.js contains runtime node_modules paths") + .replace("coreBundle.js: no node_modules/ references", "coreBundle.js: no runtime node_modules paths"), + ); +} diff --git a/driver_patches/chromiumPatch.ts b/driver_patches/chromiumPatch.ts index 15bd8ed5..cc6baba8 100644 --- a/driver_patches/chromiumPatch.ts +++ b/driver_patches/chromiumPatch.ts @@ -13,7 +13,7 @@ export function patchChromium(project: Project) { // -- _innerDefaultArgs Method -- const innerDefaultArgsMethod = chromiumClass.getMethodOrThrow("_innerDefaultArgs"); // Get all the if statements in the method and modify to always use the --headless=new flag - innerDefaultArgsMethod.getDescendantsOfKind(SyntaxKind.IfStatement).forEach((ifStatement) => { + innerDefaultArgsMethod.getDescendantsOfKind(SyntaxKind.IfStatement).forEach(ifStatement => { if (ifStatement.getExpression().getText().includes("process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW")) ifStatement.replaceWithText("chromeArguments.push('--headless=new');"); }); @@ -21,8 +21,13 @@ export function patchChromium(project: Project) { // Remove --enable-unsafe-swiftshader switches from innerDefaultArgs innerDefaultArgsMethod .getDescendantsOfKind(SyntaxKind.ExpressionStatement) - .filter((statement) => { - return statement.getText().includes("chromeArguments.push('--enable-unsafe-swiftshader')") || statement.getText().includes('chromeArguments.push("--enable-unsafe-swiftshader")'); + .filter(statement => { + return ( + statement.getText().includes("chromeArguments.push('--enable-unsafe-swiftshader')") || + statement.getText().includes('chromeArguments.push("--enable-unsafe-swiftshader")') + ); }) - .forEach((statement) => { statement.remove(); }); + .forEach(statement => { + statement.remove(); + }); } diff --git a/driver_patches/chromiumSwitchesPatch.ts b/driver_patches/chromiumSwitchesPatch.ts index 14d9ba19..1102f98f 100644 --- a/driver_patches/chromiumSwitchesPatch.ts +++ b/driver_patches/chromiumSwitchesPatch.ts @@ -5,7 +5,9 @@ import { type Project, SyntaxKind } from "ts-morph"; // ----------------------------------- export function patchChromiumSwitches(project: Project) { // Add source file to the project - const chromiumSwitchesSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/chromium/chromiumSwitches.ts"); + const chromiumSwitchesSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/chromium/chromiumSwitches.ts", + ); // -- chromiumSwitches Array Variable -- const chromiumSwitchesArrow = chromiumSwitchesSourceFile @@ -30,13 +32,15 @@ export function patchChromiumSwitches(project: Project) { "'--metrics-recording-only'", "'--unsafely-disable-devtools-self-xss-warnings'", "'--disable-back-forward-cache'", - "'--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,LensOverlay,PlzDedicatedWorker'" + "'--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,LensOverlay,PlzDedicatedWorker'", ]; chromiumSwitchesArray .getElements() - .filter((element) => switchesToDisable.includes(element.getText())) - .forEach((element) => { chromiumSwitchesArray.removeElement(element); }); - - // Add custom switches to the array + .filter(element => switchesToDisable.includes(element.getText())) + .forEach(element => { + chromiumSwitchesArray.removeElement(element); + }); + + // Add custom switches to the array chromiumSwitchesArray.addElement(`'--disable-blink-features=AutomationControlled'`); -} \ No newline at end of file +} diff --git a/driver_patches/cliAliasPatch.ts b/driver_patches/cliAliasPatch.ts index c2278d9e..23591a05 100644 --- a/driver_patches/cliAliasPatch.ts +++ b/driver_patches/cliAliasPatch.ts @@ -2,8 +2,7 @@ import type { Project, SourceFile } from "ts-morph"; function applyReplacements(sourceFile: SourceFile, replacements: Array<[string, string]>) { let text = sourceFile.getText(); - for (const [searchValue, replaceValue] of replacements) - text = text.split(searchValue).join(replaceValue); + for (const [searchValue, replaceValue] of replacements) text = text.split(searchValue).join(replaceValue); sourceFile.replaceWithText(text); } @@ -17,20 +16,50 @@ export function patchCliAlias(project: Project) { const cliProgramSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/cli/program.ts"); applyReplacements(cliProgramSourceFile, [ ["Playwright version:", "Patchright version:"], - ["ensure browsers necessary for this version of Playwright are installed", "ensure browsers necessary for this version of Patchright are installed"], - ["prints list of browsers from all playwright installations", "prints list of browsers from all patchright installations"], - ["WARNING: It looks like you are running 'npx playwright install' without first", "WARNING: It looks like you are running 'npx patchright install' without first"], + [ + "ensure browsers necessary for this version of Playwright are installed", + "ensure browsers necessary for this version of Patchright are installed", + ], + [ + "prints list of browsers from all playwright installations", + "prints list of browsers from all patchright installations", + ], + [ + "WARNING: It looks like you are running 'npx playwright install' without first", + "WARNING: It looks like you are running 'npx patchright install' without first", + ], ["then run Playwright's install command:", "then run Patchright's install command:"], [" npx playwright install", " npx patchright install"], - ["If your project does not yet depend on Playwright, first install the", "If your project does not yet depend on Patchright, first install the"], - ["then run Playwright's install command to download the browsers:", "then run Patchright's install command to download the browsers:"], + [ + "If your project does not yet depend on Playwright, first install the", + "If your project does not yet depend on Patchright, first install the", + ], + [ + "then run Playwright's install command to download the browsers:", + "then run Patchright's install command to download the browsers:", + ], [" npm install @playwright/test", " npm install -D patchright"], ["Playwright Host validation warning", "Patchright Host validation warning"], - ["Removes browsers used by this installation of Playwright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels.", "Removes browsers used by this installation of Patchright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels."], - ["Removes all browsers used by any Playwright installation from the system.", "Removes all browsers used by any Patchright installation from the system."], - ["Successfully uninstalled Playwright browsers for the current Playwright installation.", "Successfully uninstalled Patchright browsers for the current Patchright installation."], - ["used by other Playwright installations.\\nTo uninstall Playwright browsers for all installations, re-run with --all flag.", "used by other Patchright installations.\\nTo uninstall Patchright browsers for all installations, re-run with --all flag."], - ["timeout for Playwright actions in milliseconds, no timeout by default", "timeout for Patchright actions in milliseconds, no timeout by default"], + [ + "Removes browsers used by this installation of Playwright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels.", + "Removes browsers used by this installation of Patchright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels.", + ], + [ + "Removes all browsers used by any Playwright installation from the system.", + "Removes all browsers used by any Patchright installation from the system.", + ], + [ + "Successfully uninstalled Playwright browsers for the current Playwright installation.", + "Successfully uninstalled Patchright browsers for the current Patchright installation.", + ], + [ + "used by other Playwright installations.\\nTo uninstall Playwright browsers for all installations, re-run with --all flag.", + "used by other Patchright installations.\\nTo uninstall Patchright browsers for all installations, re-run with --all flag.", + ], + [ + "timeout for Playwright actions in milliseconds, no timeout by default", + "timeout for Patchright actions in milliseconds, no timeout by default", + ], ["return `playwright`;", "return `patchright`;"], ["return `${packageManagerCommand} playwright`;", "return `${packageManagerCommand} patchright`;"], ]); @@ -38,7 +67,9 @@ export function patchCliAlias(project: Project) { // -------------------------- // cli/programWithTestStub.ts // -------------------------- - const cliProgramWithTestStubSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/cli/programWithTestStub.ts"); + const cliProgramWithTestStubSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/cli/programWithTestStub.ts", + ); applyReplacements(cliProgramWithTestStubSourceFile, [ ["yarn playwright", "yarn patchright"], ["pnpm exec playwright", "pnpm exec patchright"], @@ -52,22 +83,38 @@ export function patchCliAlias(project: Project) { // sserver/android/android.ts // -------------------------- const androidServerSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/android/android.ts"); - applyReplacements(androidServerSourceFile, [ - ["playwright install android", "patchright install android"], - ]); + applyReplacements(androidServerSourceFile, [["playwright install android", "patchright install android"]]); // ------------------------ // server/registry/index.ts // ------------------------ const serverRegistrySourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/registry/index.ts"); applyReplacements(serverRegistrySourceFile, [ - ["Looks like ${sdkLanguage === 'javascript' ? 'Playwright Test or ' : ''}Playwright was just updated to ${preferredDockerVersion.driverVersion}.", "Looks like ${sdkLanguage === 'javascript' ? 'Patchright Test or ' : ''}Patchright was just updated to ${preferredDockerVersion.driverVersion}."], - ["Looks like ${sdkLanguage === 'javascript' ? 'Playwright Test or ' : ''}Playwright was just installed or updated.", "Looks like ${sdkLanguage === 'javascript' ? 'Patchright Test or ' : ''}Patchright was just installed or updated."], + [ + "Looks like ${sdkLanguage === 'javascript' ? 'Playwright Test or ' : ''}Playwright was just updated to ${preferredDockerVersion.driverVersion}.", + "Looks like ${sdkLanguage === 'javascript' ? 'Patchright Test or ' : ''}Patchright was just updated to ${preferredDockerVersion.driverVersion}.", + ], + [ + "Looks like ${sdkLanguage === 'javascript' ? 'Playwright Test or ' : ''}Playwright was just installed or updated.", + "Looks like ${sdkLanguage === 'javascript' ? 'Patchright Test or ' : ''}Patchright was just installed or updated.", + ], ["<3 Playwright Team", "<3 Patchright Team"], - ["ERROR: Playwright does not support installing ${executable.name}", "ERROR: Patchright does not support installing ${executable.name}"], - ["wait a few minutes if other Playwright is installing browsers in parallel", "wait a few minutes if other Patchright is installing browsers in parallel"], - ["ERROR: Playwright does not support ${descriptor.name} on ${hostPlatform}", "ERROR: Patchright does not support ${descriptor.name} on ${hostPlatform}"], - ["BEWARE: your OS is not officially supported by Playwright; downloading fallback build for ${hostPlatform}.", "BEWARE: your OS is not officially supported by Patchright; downloading fallback build for ${hostPlatform}."], + [ + "ERROR: Playwright does not support installing ${executable.name}", + "ERROR: Patchright does not support installing ${executable.name}", + ], + [ + "wait a few minutes if other Playwright is installing browsers in parallel", + "wait a few minutes if other Patchright is installing browsers in parallel", + ], + [ + "ERROR: Playwright does not support ${descriptor.name} on ${hostPlatform}", + "ERROR: Patchright does not support ${descriptor.name} on ${hostPlatform}", + ], + [ + "BEWARE: your OS is not officially supported by Playwright; downloading fallback build for ${hostPlatform}.", + "BEWARE: your OS is not officially supported by Patchright; downloading fallback build for ${hostPlatform}.", + ], ["::warning title=Playwright::${message}", "::warning title=Patchright::${message}"], ["return `playwright ${parameters}`;", "return `patchright ${parameters}`;"], ]); diff --git a/driver_patches/clockPatch.ts b/driver_patches/clockPatch.ts index 1ba3c27d..46252db7 100644 --- a/driver_patches/clockPatch.ts +++ b/driver_patches/clockPatch.ts @@ -18,7 +18,10 @@ export function patchClock(project: Project) { .getBodyOrThrow() .asKindOrThrow(SyntaxKind.Block) .getStatements() - .find((statement) => statement.getKind() === SyntaxKind.IfStatement && statement.getText().includes("this._initScripts.length")) + .find( + statement => + statement.getKind() === SyntaxKind.IfStatement && statement.getText().includes("this._initScripts.length"), + ), ); installIfNeededGuard.replaceWithText(` if (this._initScripts.length) { @@ -48,4 +51,4 @@ export function patchClock(project: Project) { } catch {} })); `); -} \ No newline at end of file +} diff --git a/driver_patches/crBrowserPatch.ts b/driver_patches/crBrowserPatch.ts index f3811d6f..e7a94c19 100644 --- a/driver_patches/crBrowserPatch.ts +++ b/driver_patches/crBrowserPatch.ts @@ -39,4 +39,4 @@ export function patchCRBrowser(project: Project) { for (const page of this.pages()) await (page.delegate as CRPage).removeExposedBindings(); `); -} \ No newline at end of file +} diff --git a/driver_patches/crCoveragePatch.ts b/driver_patches/crCoveragePatch.ts index db757d42..498c9438 100644 --- a/driver_patches/crCoveragePatch.ts +++ b/driver_patches/crCoveragePatch.ts @@ -5,7 +5,9 @@ import { type Project, SyntaxKind } from "ts-morph"; // ----------------------------- export function patchCRCoverage(project: Project) { // Add source file to the project - const crCoverageSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/chromium/crCoverage.ts"); + const crCoverageSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/chromium/crCoverage.ts", + ); // ------- JSCoverage Class ------- // ------- CSSCoverage Class ------- @@ -15,28 +17,35 @@ export function patchCRCoverage(project: Project) { // -- start Method -- const startMethod = coverageClass.getMethodOrThrow("start"); // Inject a 'Page.frameNavigated' listener after the existing 'Runtime.executionContextsCleared' listener - const executionContextsCleared = "eventsHelper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)),"; - const frameNavigated = "eventsHelper.addEventListener(this._client, 'Page.frameNavigated', this._onFrameNavigated.bind(this)),"; + const executionContextsCleared = + "eventsHelper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)),"; + const frameNavigated = + "eventsHelper.addEventListener(this._client, 'Page.frameNavigated', this._onFrameNavigated.bind(this)),"; startMethod .getBodyOrThrow() .asKindOrThrow(SyntaxKind.Block) .getStatements() - .forEach((statement) => { + .forEach(statement => { const text = statement.getText(); if (text.includes(executionContextsCleared) && !text.includes(frameNavigated)) - statement.replaceWithText(text.replace(executionContextsCleared, ` + statement.replaceWithText( + text.replace( + executionContextsCleared, + ` ${executionContextsCleared} ${frameNavigated} - `)); + `, + ), + ); }); // -- _onFrameNavigated Method -- if (!coverageClass.getMethod("_onFrameNavigated")) { - coverageClass.addMethod({ - name: "_onFrameNavigated", - parameters: [{ name: "event", type: "Protocol.Page.frameNavigatedPayload" }], + coverageClass.addMethod({ + name: "_onFrameNavigated", + parameters: [{ name: "event", type: "Protocol.Page.frameNavigatedPayload" }], statements: ["if (event.frame.parentId) return;", "this._onExecutionContextsCleared();"], - }); - } + }); + } } } diff --git a/driver_patches/crDevToolsPatch.ts b/driver_patches/crDevToolsPatch.ts index 3c01b69c..06b99ce4 100644 --- a/driver_patches/crDevToolsPatch.ts +++ b/driver_patches/crDevToolsPatch.ts @@ -6,7 +6,9 @@ import { assertDefined } from "./utils.ts"; // ----------------------------- export function patchCRDevTools(project: Project) { // Add source file to the project - const crDevToolsSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/chromium/crDevTools.ts"); + const crDevToolsSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/chromium/crDevTools.ts", + ); // ------- CRDevTools Class ------- const crDevToolsClass = crDevToolsSourceFile.getClassOrThrow("CRDevTools"); @@ -16,13 +18,15 @@ export function patchCRDevTools(project: Project) { // Find the specific `Promise.all` call const promiseAllCalls = installMethod .getDescendantsOfKind(SyntaxKind.CallExpression) - .filter((call) => call.getExpression().getText() === "Promise.all"); + .filter(call => call.getExpression().getText() === "Promise.all"); // Removing Runtime.enable from the Promise.all call - promiseAllCalls.forEach((call) => { + promiseAllCalls.forEach(call => { const arrayLiteral = assertDefined(call.getFirstDescendantByKind(SyntaxKind.ArrayLiteralExpression)); arrayLiteral .getElements() - .filter((element) => element.getText().includes("session.send('Runtime.enable'")) - .forEach((element) => { arrayLiteral.removeElement(element); }); + .filter(element => element.getText().includes("session.send('Runtime.enable'")) + .forEach(element => { + arrayLiteral.removeElement(element); + }); }); -} \ No newline at end of file +} diff --git a/driver_patches/crNetworkManagerPatch.ts b/driver_patches/crNetworkManagerPatch.ts index 6efe56ff..1595a11a 100644 --- a/driver_patches/crNetworkManagerPatch.ts +++ b/driver_patches/crNetworkManagerPatch.ts @@ -6,7 +6,9 @@ import { assertDefined } from "./utils.ts"; // ----------------------------------- export function patchCRNetworkManager(project: Project) { // Add source file to the project - const crNetworkManagerSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/chromium/crNetworkManager.ts"); + const crNetworkManagerSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/chromium/crNetworkManager.ts", + ); // Add the custom import and comment at the start of the file crNetworkManagerSourceFile.addImportDeclaration({ moduleSpecifier: "crypto", @@ -24,36 +26,45 @@ export function patchCRNetworkManager(project: Project) { // Clean up tracked network IDs when sessions are removed to avoid unbounded growth. const removeSessionBody = removeSessionMethod.getBodyOrThrow().asKindOrThrow(SyntaxKind.Block); const deleteSessionStatement = assertDefined( - removeSessionBody - .getStatements() - .find(s => s.getText().includes("this._sessions.delete(session)")) + removeSessionBody.getStatements().find(s => s.getText().includes("this._sessions.delete(session)")), + ); + removeSessionBody.insertStatements( + deleteSessionStatement.getChildIndex() + 1, + "if (!this._sessions.size) this._alreadyTrackedNetworkIds.clear();", ); - removeSessionBody.insertStatements(deleteSessionStatement.getChildIndex() + 1, "if (!this._sessions.size) this._alreadyTrackedNetworkIds.clear();") // -- _onRequest Method -- const onRequestMethod = crNetworkManagerClass.getMethodOrThrow("_onRequest"); // Find the route assignment, whether it is still pristine or already expanded. const routeAssignment = assertDefined( - onRequestMethod - .getDescendantsOfKind(SyntaxKind.BinaryExpression) - .find(expr => { - if (expr.getLeft().getText() !== "route") - return false; - return expr.getRight().getText().startsWith("new RouteImpl(requestPausedSessionInfo!.session, requestPausedEvent.requestId"); - }) + onRequestMethod.getDescendantsOfKind(SyntaxKind.BinaryExpression).find(expr => { + if (expr.getLeft().getText() !== "route") return false; + return expr + .getRight() + .getText() + .startsWith("new RouteImpl(requestPausedSessionInfo!.session, requestPausedEvent.requestId"); + }), ); // Adding new parameter to the RouteImpl call - routeAssignment.getRight().replaceWithText( - "new RouteImpl(requestPausedSessionInfo!.session, requestPausedEvent.requestId, this._page, requestPausedEvent.networkId ?? requestPausedEvent.requestId, this)", - ); + routeAssignment + .getRight() + .replaceWithText( + "new RouteImpl(requestPausedSessionInfo!.session, requestPausedEvent.requestId, this._page, requestPausedEvent.networkId ?? requestPausedEvent.requestId, this)", + ); // -- _updateProtocolRequestInterceptionForSession Method -- - const updateProtocolRequestInterceptionForSessionMethod = crNetworkManagerClass.getMethodOrThrow("_updateProtocolRequestInterceptionForSession"); + const updateProtocolRequestInterceptionForSessionMethod = crNetworkManagerClass.getMethodOrThrow( + "_updateProtocolRequestInterceptionForSession", + ); // Replace cache disabled logic: keep cache enabled unless user has route interceptors updateProtocolRequestInterceptionForSessionMethod .getStatements() - .filter((statement) => statement.getText().includes("const cachePromise = info.session.send('Network.setCacheDisabled', { cacheDisabled: enabled });")) - .forEach((statement) => { + .filter(statement => + statement + .getText() + .includes("const cachePromise = info.session.send('Network.setCacheDisabled', { cacheDisabled: enabled });"), + ) + .forEach(statement => { statement.replaceWithText(` const hasHarRecorders = !!this._page?.browserContext?._harRecorders?.size; const userInterception = this._page ? this._page.needsRequestInterception() : false; @@ -64,15 +75,11 @@ export function patchCRNetworkManager(project: Project) { // -- setRequestInterception Method -- // Always update cache state so user-added routes trigger cache bypass const setRequestInterceptionMethod = crNetworkManagerClass.getMethodOrThrow("setRequestInterception"); - setRequestInterceptionMethod - .getBodyOrThrow() - .asKindOrThrow(SyntaxKind.Block) - .addStatements(` + setRequestInterceptionMethod.getBodyOrThrow().asKindOrThrow(SyntaxKind.Block).addStatements(` if (this._page) await this._forEachSession(info => info.session.send('Network.setCacheDisabled', { cacheDisabled: this._page.needsRequestInterception() })); `); - // -- _onRequest Method -- const onRequestHandlerMethod = crNetworkManagerClass.getMethodOrThrow("_onRequest"); const onRequestHandlerMethodBody = onRequestHandlerMethod.getBodyOrThrow().asKindOrThrow(SyntaxKind.Block); @@ -86,31 +93,34 @@ export function patchCRNetworkManager(project: Project) { // Move `const isInterceptedOptionsPreflight` up before frame resolution so it's defined before first use const preflightStatementNode = assertDefined( - onRequestHandlerMethodBody - .getStatements() - .find(s => s.getText().includes('const isInterceptedOptionsPreflight')) + onRequestHandlerMethodBody.getStatements().find(s => s.getText().includes("const isInterceptedOptionsPreflight")), ); const preflightStatementText = preflightStatementNode.getText(); preflightStatementNode.remove(); // Insert before the `let frame` statement so it's defined early enough const frameStatement = assertDefined( - onRequestHandlerMethodBody - .getStatements() - .find(s => s.getText().startsWith('let frame')) + onRequestHandlerMethodBody.getStatements().find(s => s.getText().startsWith("let frame")), ); const frameIndex = frameStatement.getChildIndex(); onRequestHandlerMethodBody.insertStatements(frameIndex, preflightStatementText); // OPTIONS preflight bypass: when Patchright's always-on interception catches OPTIONS but no user routes exist - onRequestHandlerMethodBody.insertStatements(frameIndex + 1, ` + onRequestHandlerMethodBody.insertStatements( + frameIndex + 1, + ` if (isInterceptedOptionsPreflight && !(this._page || this._serviceWorker).needsRequestInterception()) { requestPausedSessionInfo!.session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent!.requestId }); return; } - `); + `, + ); // Guard null page delegate when resolving synthetic main-frame id. - onRequestHandlerMethodBody.getStatements().forEach((statement) => { - if (statement.getText().includes("if (!frame && this._page && requestWillBeSentEvent.frameId === (this._page?.delegate)._targetId)")) + onRequestHandlerMethodBody.getStatements().forEach(statement => { + if ( + statement + .getText() + .includes("if (!frame && this._page && requestWillBeSentEvent.frameId === (this._page?.delegate)._targetId)") + ) statement.replaceWithText(` const pageDelegate = this._page?.delegate; if (!frame && pageDelegate && requestWillBeSentEvent.frameId === pageDelegate._targetId) { @@ -118,6 +128,18 @@ export function patchCRNetworkManager(project: Project) { } `); }); + const provisionalHeadersStatement = assertDefined( + onRequestHandlerMethod + .getDescendantsOfKind(SyntaxKind.IfStatement) + .find( + statement => + statement.getExpression().getText() === "route" && + statement.getText().includes("request.request.setRawRequestHeaders"), + ), + ); + provisionalHeadersStatement + .getExpression() + .replaceWithText("route && (this._page?.needsRequestInterception() || !!this._serviceWorker)"); // -- _onRequestPaused Method -- const onRequestPausedMethod = crNetworkManagerClass.getMethodOrThrow("_onRequestPaused"); @@ -126,37 +148,50 @@ export function patchCRNetworkManager(project: Project) { .asKindOrThrow(SyntaxKind.Block) .insertStatements(0, "if (this._alreadyTrackedNetworkIds.has(event.networkId)) return;"); + // Cached requests do not produce Fetch.requestPaused, so finish pairing them from the Network event. + const onRequestServedFromCacheMethod = crNetworkManagerClass.getMethodOrThrow("_onRequestServedFromCache"); + onRequestServedFromCacheMethod.getBodyOrThrow().asKindOrThrow(SyntaxKind.Block).addStatements(` + const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get(event.requestId); + if (requestWillBeSentEvent) { + this._onRequest(requestWillBeSentEvent.sessionInfo, requestWillBeSentEvent.event, undefined, undefined); + this._requestIdToRequestWillBeSentEvent.delete(event.requestId); + } + `); // ------- RouteImpl Class ------- const routeImplClass = crNetworkManagerSourceFile.getClassOrThrow("RouteImpl"); + const fulfilledProperty = routeImplClass.getPropertyOrThrow("_fulfilled"); + routeImplClass.insertProperty(routeImplClass.getMembers().indexOf(fulfilledProperty) + 1, { + name: "_fulfilledSetCookieHeaders", + type: "types.HeadersArray", + initializer: "[]", + }); // -- RouteImpl Constructor -- const routeImplConstructor = assertDefined( - routeImplClass - .getConstructors() - .find((ctor) => { - const params = ctor.getParameters(); - return params[0]?.getName() === "session" && params[1]?.getName() === "interceptionId"; - }) + routeImplClass.getConstructors().find(ctor => { + const params = ctor.getParameters(); + return params[0]?.getName() === "session" && params[1]?.getName() === "interceptionId"; + }), ); // Get current parameters and add the new `page`, `networkId` and `sessionManager` parameter const routeImplConstructorParameters = routeImplConstructor.getParameters(); routeImplConstructor.insertParameters(routeImplConstructorParameters.length, [ - { name: 'page', type: 'Page | null' }, - { name: 'networkId', type: 'string' }, - { name: 'sessionManager', type: 'CRNetworkManager' }, + { name: "page", type: "Page | null" }, + { name: "networkId", type: "string" }, + { name: "sessionManager", type: "CRNetworkManager" }, ]); // Modify the constructor's body to include `this._page = page;` and other properties const routeImplConstructorBody = routeImplConstructor.getBodyOrThrow().asKindOrThrow(SyntaxKind.Block); routeImplConstructorBody.insertStatements(0, [ - 'this._page = void 0;', - 'this._networkId = void 0;', - 'this._sessionManager = void 0;', + "this._page = void 0;", + "this._networkId = void 0;", + "this._sessionManager = void 0;", ]); routeImplConstructorBody.addStatements([ - 'this._page = page;', - 'this._networkId = networkId;', - 'this._sessionManager = sessionManager;', + "this._page = page;", + "this._networkId = networkId;", + "this._sessionManager = sessionManager;", "eventsHelper.addEventListener(this._session, 'Fetch.requestPaused', async e => await this._networkRequestIntercepted(e));", ]); @@ -165,9 +200,9 @@ export function patchCRNetworkManager(project: Project) { name: "_fixCSP", isAsync: false, parameters: [ - { name: "csp", type: "string | null" }, + { name: "csp", type: "string | null" }, { name: "scriptNonce", type: "string | null" }, - ] + ], }); const fixCSPMethod = routeImplClass.getMethodOrThrow("_fixCSP"); fixCSPMethod.setBodyText(` @@ -181,6 +216,7 @@ export function patchCRNetworkManager(project: Project) { const fixedDirectives = []; let hasScriptSrc = false; + let hasDefaultSrc = false; const addIfMissing = (values: string[], ...items: string[]) => { for (const item of items) @@ -191,7 +227,7 @@ export function patchCRNetworkManager(project: Project) { for (let directive of directives) { // Improved directive parsing to handle more edge cases - const directiveMatch = directive.match(/^([a-zA-Z-]+)\\s+(.*)$/); + const directiveMatch = directive.match(/^([a-zA-Z-]+)(?:\\s+(.*))?$/); if (!directiveMatch) { fixedDirectives.push(directive); continue; @@ -249,14 +285,20 @@ export function patchCRNetworkManager(project: Project) { fixedDirectives.push(\`frame-ancestors \${frameAncestorValues}\`); break; + case 'default-src': + hasDefaultSrc = true; + fixedDirectives.push(directive); + break; + default: // Keep other directives as-is fixedDirectives.push(directive); } } - // Add script-src if it doesn't exist (for our injected scripts) - if (!hasScriptSrc) { + // Add script-src for our injected scripts only when it's missing AND a + // default-src exists to override. + if (!hasScriptSrc && hasDefaultSrc) { fixedDirectives.push( scriptNonce ? \`script-src 'self' 'unsafe-eval' 'nonce-\${scriptNonce}' *\` @@ -272,9 +314,9 @@ export function patchCRNetworkManager(project: Project) { name: "_injectIntoHead", isAsync: false, parameters: [ - { name: "body", type: "string" }, + { name: "body", type: "string" }, { name: "injectionHTML", type: "string" }, - ] + ], }); const injectIntoHeadMethod = routeImplClass.getMethodOrThrow("_injectIntoHead"); injectIntoHeadMethod.setBodyText(` @@ -441,6 +483,7 @@ export function patchCRNetworkManager(project: Project) { this._fulfilled = true; const body = response.isBase64 ? response.body : Buffer.from(response.body).toString("base64"); const responseHeaders = splitSetCookieHeader(response.headers); + this._fulfilledSetCookieHeaders = responseHeaders.filter(header => header.name.toLowerCase() === 'set-cookie'); await catchDisallowedErrors(async () => { await this._session.send("Fetch.fulfillRequest", { requestId: response.interceptionId ? response.interceptionId : this._interceptionId, @@ -452,6 +495,42 @@ export function patchCRNetworkManager(project: Project) { }); `); + // Chromium omits Set-Cookie from Network response events for fulfilled requests. + const createResponseMethod = crNetworkManagerClass.getMethodOrThrow("_createResponse"); + const responseDeclaration = assertDefined( + createResponseMethod + .getDescendantsOfKind(SyntaxKind.VariableDeclaration) + .find( + declaration => + declaration.getName() === "response" && + declaration.getInitializer()?.getText().startsWith("new network.Response("), + ), + ); + const responseConstructor = responseDeclaration.getInitializerOrThrow().asKindOrThrow(SyntaxKind.NewExpression); + responseConstructor.getArguments()[3].replaceWithText("responseHeaders"); + const responseStatement = responseDeclaration.getVariableStatementOrThrow(); + const createResponseBody = createResponseMethod.getBodyOrThrow().asKindOrThrow(SyntaxKind.Block); + const responseStatementIndex = createResponseBody.getStatements().indexOf(responseStatement); + createResponseBody.insertStatements( + responseStatementIndex, + ` + const responseHeaders = headersObjectToArray(responsePayload.headers); + const fulfilledSetCookieHeaders = request._originalRequestRoute?._fulfilledSetCookieHeaders ?? []; + if (fulfilledSetCookieHeaders.length && !responseHeaders.some(header => header.name.toLowerCase() === 'set-cookie')) + responseHeaders.push(...fulfilledSetCookieHeaders); + `, + ); + const updatedResponseStatementIndex = createResponseBody + .getStatements() + .findIndex(statement => statement.getText().startsWith("const response = new network.Response(")); + createResponseBody.insertStatements( + updatedResponseStatementIndex + 1, + ` + if (fulfilledSetCookieHeaders.length) + response.setRawResponseHeaders(responseHeaders); + `, + ); + // -- continue Method -- const continueMethod = routeImplClass.getMethodOrThrow("continue"); continueMethod.setBodyText(` ; @@ -484,9 +563,7 @@ export function patchCRNetworkManager(project: Project) { routeImplClass.addMethod({ name: "_networkRequestIntercepted", isAsync: true, - parameters: [ - { name: "event", type: "Protocol.Fetch.requestPausedPayload" }, - ] + parameters: [{ name: "event", type: "Protocol.Fetch.requestPausedPayload" }], }); const networkRequestInterceptedMethod = routeImplClass.getMethodOrThrow("_networkRequestIntercepted"); networkRequestInterceptedMethod.setBodyText(` diff --git a/driver_patches/crPagePatch.ts b/driver_patches/crPagePatch.ts index 69386358..3d885af9 100644 --- a/driver_patches/crPagePatch.ts +++ b/driver_patches/crPagePatch.ts @@ -13,25 +13,25 @@ export function patchCRPage(project: Project) { defaultImport: "crypto", }); - // ------- CRPage Class ------- const crPageClass = crPageSourceFile.getClassOrThrow("CRPage"); // -- CRPage Constructor -- const crPageConstructor = assertDefined( - crPageClass - .getConstructors() - .find((ctor) => { - const params = ctor.getParameters(); - return params[0]?.getName() === "client" && params[1]?.getName() === "targetId" && params[2]?.getName() === "browserContext" && params[3]?.getName() === "opener"; - }) + crPageClass.getConstructors().find(ctor => { + const params = ctor.getParameters(); + return ( + params[0]?.getName() === "client" && + params[1]?.getName() === "targetId" && + params[2]?.getName() === "browserContext" && + params[3]?.getName() === "opener" + ); + }), ); // Swap legacy updateRequestInterception for direct network manager interception + unique script tag. const updateRequestInterceptionStatement = assertDefined( - crPageConstructor - .getStatements() - .find((statement) => statement.getText() === "this.updateRequestInterception();") + crPageConstructor.getStatements().find(statement => statement.getText() === "this.updateRequestInterception();"), ); updateRequestInterceptionStatement.replaceWithText(` this._networkManager.setRequestInterception(true); @@ -42,9 +42,7 @@ export function patchCRPage(project: Project) { crPageClass.addMethod({ name: "exposeBinding", isAsync: true, - parameters: [ - { name: "binding", type: "PageBinding" }, - ], + parameters: [{ name: "binding", type: "PageBinding" }], }); const crExposeBindingMethod = crPageClass.getMethodOrThrow("exposeBinding"); // Initialize binding across all frame sessions and evaluate the binding source in all page frames @@ -76,8 +74,7 @@ export function patchCRPage(project: Project) { const sessionForFrameMethod = crPageClass.getMethodOrThrow("_sessionForFrame"); // Replace the error message for detached frames with a more concise version const methodText = sessionForFrameMethod.getText(); - sessionForFrameMethod.replaceWithText(methodText.replace('Frame has been detached.', 'Frame was detached')); - + sessionForFrameMethod.replaceWithText(methodText.replace("Frame has been detached.", "Frame was detached")); // ------- FrameSession Class ------- const frameSessionClass = crPageSourceFile.getClassOrThrow("FrameSession"); @@ -105,20 +102,25 @@ export function patchCRPage(project: Project) { // -- _initialize Method -- const initializeFrameSessionMethod = frameSessionClass.getMethodOrThrow("_initialize"); - const initializeFrameSessionMethodBody = initializeFrameSessionMethod.getBodyOrThrow().asKindOrThrow(SyntaxKind.Block); + const initializeFrameSessionMethodBody = initializeFrameSessionMethod + .getBodyOrThrow() + .asKindOrThrow(SyntaxKind.Block); initializeFrameSessionMethod.insertStatements(0, `const pageEnablePromise = this._client.send('Page.enable');`); // Buffer dialog events for main frames so that dialogs on newly opened popups are never missed const addBrowserListenersStatement = assertDefined( initializeFrameSessionMethod .getStatements() - .find((statement) => statement.getText().includes("this._addBrowserListeners()")) + .find(statement => statement.getText().includes("this._addBrowserListeners()")), ); - initializeFrameSessionMethodBody.insertStatements(addBrowserListenersStatement.getChildIndex(), ` + initializeFrameSessionMethodBody.insertStatements( + addBrowserListenersStatement.getChildIndex(), + ` let bufferedDialogEvents: any[] | undefined = this._isMainFrame() ? [] : undefined; if (bufferedDialogEvents) this._eventListeners.push(eventsHelper.addEventListener(this._client, 'Page.javascriptDialogOpening', (event: any) => bufferedDialogEvents ? bufferedDialogEvents.push(event) : undefined)); - `); + `, + ); const promisesDeclaration = initializeFrameSessionMethod.getVariableDeclarationOrThrow("promises"); // Find the initializer array @@ -126,22 +128,27 @@ export function patchCRPage(project: Project) { // Find the relevant element inside the array that we need to update promisesInitializer .getElements() - .filter((element) => - element.getText().includes("this._client.send('Runtime.enable'") || - element.getText().includes("this._client.send('Runtime.addBinding', { name: PageBinding.kPlaywrightBinding })") + .filter( + element => + element.getText().includes("this._client.send('Runtime.enable'") || + element.getText().includes("this._client.send('Runtime.addBinding', { name: PageBinding.kPlaywrightBinding })"), ) - .forEach((element) => { promisesInitializer.removeElement(element); }); + .forEach(element => { + promisesInitializer.removeElement(element); + }); // Replace Page.enable send in promises with the early-started promise promisesInitializer .getElements() - .filter((element) => element.getText().trim() === "this._client.send('Page.enable')") - .forEach((element) => { element.replaceWithText("pageEnablePromise"); }); - + .filter(element => element.getText().trim() === "this._client.send('Page.enable')") + .forEach(element => { + element.replaceWithText("pageEnablePromise"); + }); + // Find the relevant element inside the array that we need to update const pageGetFrameTreeElement = assertDefined( promisesInitializer .getElements() - .find((element) => element.getText().startsWith("this._client.send('Page.getFrameTree'")) + .find(element => element.getText().startsWith("this._client.send('Page.getFrameTree'")), ); const pageGetFrameTreeThenBlock = pageGetFrameTreeElement .asKindOrThrow(SyntaxKind.CallExpression) @@ -153,16 +160,14 @@ export function patchCRPage(project: Project) { pageGetFrameTreeThenBlock .getStatements() .find( - (statement) => - statement.isKind(SyntaxKind.IfStatement) && - statement.getText().includes("this._addRendererListeners()"), - ) + statement => + statement.isKind(SyntaxKind.IfStatement) && statement.getText().includes("this._addRendererListeners()"), + ), ); addRendererListenersIfStatement .asKindOrThrow(SyntaxKind.IfStatement) .getThenStatement() - .asKindOrThrow(SyntaxKind.Block) - .addStatements(` + .asKindOrThrow(SyntaxKind.Block).addStatements(` // Replay any dialog events that arrived before _addRendererListeners const pendingDialogEvents = bufferedDialogEvents || []; bufferedDialogEvents = undefined; @@ -173,44 +178,38 @@ export function patchCRPage(project: Project) { pageGetFrameTreeThenBlock .getStatements() .filter( - (statement) => + statement => statement.getText().includes("const localFrames = this._isMainFrame() ? this._page.frames()") || statement.getText().includes("this._client._sendMayFail('Page.createIsolatedWorld', {"), ) - .forEach((statement) => { statement.remove() }); + .forEach(statement => { + statement.remove(); + }); // Find the non-initial navigation branch and ensure our localFrames setup is present. const lifecycleEventIfStatement = assertDefined( initializeFrameSessionMethodBody .getDescendantsOfKind(SyntaxKind.IfStatement) - .find((statement) => statement.getText().includes("this._firstNonInitialNavigationCommittedFulfill()")) - ); - const lifecycleEventElseBlock = assertDefined( - lifecycleEventIfStatement - .getElseStatement() + .find(statement => statement.getText().includes("this._firstNonInitialNavigationCommittedFulfill()")), ); - lifecycleEventElseBlock - .asKindOrThrow(SyntaxKind.Block) - .insertStatements(0, ` + const lifecycleEventElseBlock = assertDefined(lifecycleEventIfStatement.getElseStatement()); + lifecycleEventElseBlock.asKindOrThrow(SyntaxKind.Block).insertStatements(0, ` const localFrames = this._isMainFrame() ? this._page.frames() : [this._page.frameManager.frame(this._targetId)!]; for (const frame of localFrames) { this._page.frameManager.frame(frame._id)._context("utility").catch(() => {}); for (const binding of this._crPage._browserContext._pageBindings.values()) frame.evaluateExpression(binding.source).catch(e => {}); - for (const source of this._crPage._browserContext.initScripts) - frame.evaluateExpression(source.source).catch(e => {}); - for (const source of this._crPage._page.initScripts) - frame.evaluateExpression(source.source).catch(e => {}); } `); - + // Allow focus control on pages https://github.com/Kaliiiiiiiiii-Vinyzu/patchright/issues/137#event-20580557051 const focusEmulationIfStatement = assertDefined( initializeFrameSessionMethodBody .getDescendantsOfKind(SyntaxKind.IfStatement) - .find((statement) => - statement.getText().startsWith("if (this._isMainFrame()") && - statement.getText().includes("Emulation.setFocusEmulationEnabled") - ) + .find( + statement => + statement.getText().startsWith("if (this._isMainFrame()") && + statement.getText().includes("Emulation.setFocusEmulationEnabled"), + ), ); focusEmulationIfStatement.replaceWithText(` if (this._isMainFrame() && !this._crPage._browserContext._options.focusControl) @@ -219,8 +218,8 @@ export function patchCRPage(project: Project) { // Find and patch the initScript Evaluation Loop to inject pageBindings alongside initScripts for both main and utility contexts initializeFrameSessionMethodBody .getDescendantsOfKind(SyntaxKind.ForOfStatement) - .filter((statement) => statement.getText().includes("this._crPage._page.allInitScripts()")) - .forEach((statement) => { + .filter(statement => statement.getText().includes("this._crPage._page.allInitScripts()")) + .forEach(statement => { if (statement.getText().includes("frame.evaluateExpression(initScript.source)")) statement.replaceWithText(` for (const binding of this._crPage._browserContext._pageBindings.values()) frame.evaluateExpression(binding.source).catch(e => {}); @@ -236,7 +235,9 @@ export function patchCRPage(project: Project) { // Find the statement `promises.push(this._client.send('Runtime.runIfWaitingForDebugger'))` const promisePushStatements = initializeFrameSessionMethodBody .getStatements() - .filter((statement) => statement.getText().includes("promises.push(this._client.send('Runtime.runIfWaitingForDebugger'))")); + .filter(statement => + statement.getText().includes("promises.push(this._client.send('Runtime.runIfWaitingForDebugger'))"), + ); // Ensure the right statements were found if (promisePushStatements.length === 1) { // Replace the first `promises.push` statement with the new conditional code @@ -284,7 +285,7 @@ export function patchCRPage(project: Project) { ); await Promise.all(evaluationPromises); `); - + // -- _removeExposedBindings Method -- frameSessionClass.addMethod({ name: "_removeExposedBindings", @@ -324,6 +325,21 @@ export function patchCRPage(project: Project) { try { await this._page.frameManager.frame(this._targetId)._context("utility") } catch { }; `); + // -- _eventBelongsToStaleFrame Method -- + const eventBelongsToStaleFrameMethod = frameSessionClass.getMethodOrThrow("_eventBelongsToStaleFrame"); + eventBelongsToStaleFrameMethod.setBodyText(` + const frame = this._page.frameManager.frame(frameId); + if (!frame) + return true; + let session: FrameSession; + try { + session = this._crPage._sessionForFrame(frame); + } catch { + return true; + } + return session && session !== this && !session._swappedIn; + `); + // -- _onFrameNavigated Method --ยด const onFrameNavigatedMethod = frameSessionClass.getMethodOrThrow("_onFrameNavigated"); onFrameNavigatedMethod.setIsAsync(true); @@ -355,18 +371,22 @@ export function patchCRPage(project: Project) { for (const name of this._exposedBindingNames) this._client._sendMayFail('Runtime.addBinding', { name: name, executionContextId: contextPayload.id }); `); - onExecutionContextCreatedMethod.insertStatements(2, ` + onExecutionContextCreatedMethod.insertStatements( + 2, + ` if (contextPayload.auxData?.type === "worker") throw new Error("ExecutionContext is worker"); - `); + `, + ); // Replace the legacy worldName branching logic with a direct assignment from contextPayload.name. onExecutionContextCreatedMethod .getStatements() - .filter((statement) => - statement.getText().includes("let worldName: types.World") || - statement.getText().includes("if (contextPayload.auxData && !!contextPayload.auxData.isDefault)") || - statement.getText().includes("worldName = 'main'") || - statement.getText().includes("else if (contextPayload.name === UTILITY_WORLD_NAME)") || - statement.getText().includes("worldName = 'utility'") + .filter( + statement => + statement.getText().includes("let worldName: types.World|null") || + statement.getText().includes("if (contextPayload.auxData && !!contextPayload.auxData.isDefault)") || + statement.getText().includes("worldName = 'main'") || + statement.getText().includes("else if (contextPayload.name === this._crPage.utilityWorldName)") || + statement.getText().includes("worldName = 'utility'"), ) .forEach((statement, index) => { if (index === 0) statement.replaceWithText("let worldName = contextPayload.name;"); @@ -376,11 +396,11 @@ export function patchCRPage(project: Project) { const contextCreatedIfStatement = assertDefined( onExecutionContextCreatedMethod .getDescendantsOfKind(SyntaxKind.IfStatement) - .find((stmt) => stmt.getText().includes("if (worldName)") && stmt.getText().includes("_contextCreated")) + .find(stmt => stmt.getText().includes("if (worldName)") && stmt.getText().includes("contextCreated")), ); contextCreatedIfStatement.replaceWithText(` if (worldName && (worldName === 'main' || worldName === 'utility')) - frame._contextCreated(worldName, context); + frame.contextCreated(worldName, context); `); // Execute all exposed binding scripts in the created execution context onExecutionContextCreatedMethod.addStatements(` @@ -400,27 +420,30 @@ export function patchCRPage(project: Project) { const sessionOnceCall = assertDefined( onAttachedToTargetMethod .getDescendantsOfKind(SyntaxKind.ExpressionStatement) - .find((statement) => statement.getText().includes("session.once('Runtime.executionContextCreated'")) + .find(statement => statement.getText().includes("session.once('Runtime.executionContextCreated'")), ); - sessionOnceCall - .getParentIfKindOrThrow(SyntaxKind.Block) - .insertStatements(sessionOnceCall.getChildIndex() + 1, ` + sessionOnceCall.getParentIfKindOrThrow(SyntaxKind.Block).insertStatements( + sessionOnceCall.getChildIndex() + 1, + ` var globalThis = await session._sendMayFail('Runtime.evaluate', { expression: "globalThis", serializationOptions: { serialization: "idOnly" } }); - if (globalThis && globalThis.result) { + if (globalThis && globalThis.result && globalThis.result.objectId) { var globalThisObjId = globalThis.result.objectId; var executionContextId = parseInt(globalThisObjId.split('.')[1], 10); - worker.createExecutionContext(new CRExecutionContext(session, { id: executionContextId })); + if (!isNaN(executionContextId)) { + worker.createExecutionContext(new CRExecutionContext(session, { id: executionContextId })); + } } - `); - + `, + ); + // Remove `Runtime.enable` calls const runtimeStatementToRemove = assertDefined( onAttachedToTargetMethod - .getStatements() - .find((statement) => statement.getText().includes("session._sendMayFail('Runtime.enable');")) + .getStatements() + .find(statement => statement.getText().includes("session._sendMayFail('Runtime.enable');")), ); runtimeStatementToRemove.remove(); @@ -430,27 +453,51 @@ export function patchCRPage(project: Project) { const onBindingCalledIfStatement = assertDefined( onBindingCalledMethod .getDescendantsOfKind(SyntaxKind.IfStatement) - .find((statement) => - statement.getExpression().getText() === "context" && + .find( + statement => + statement.getExpression().getText() === "context" && statement.getText().includes("await this._page.onBindingCalled(event.payload, context)"), - ) + ), ); onBindingCalledIfStatement.replaceWithText(` - if (context) await this._page.onBindingCalled(event.payload, context); - else await this._page._onBindingCalled(event.payload, (await this._page.mainFrame()._mainContext())) // This might be a bit sketchy but it works for now + if (context) { + await this._page.onBindingCalled(event.payload, context); + } else { + // Ensure execution contexts for all frames are resolved/created + await Promise.all(this._page.frames().map(frame => frame.mainContext().catch(() => null))); + const fallbackContext = this._contextIdToContext.get(event.executionContextId); + if (fallbackContext) { + await this._page.onBindingCalled(event.payload, fallbackContext); + } else { + // Last resort fallback + const mainCtx = await this._page.mainFrame().mainContext().catch(() => null); + if (mainCtx) { + await this._page.onBindingCalled(event.payload, mainCtx); + } + } + } `); // -- _evaluateOnNewDocument Method -- - frameSessionClass - .getMethodOrThrow("_evaluateOnNewDocument") - .setBodyText(`this._evaluateOnNewDocumentScripts.push(initScript); `); + frameSessionClass.getMethodOrThrow("_evaluateOnNewDocument").setBodyText(` + this._evaluateOnNewDocumentScripts.push(initScript); + const worldName = world === 'utility' ? this._crPage.utilityWorldName : undefined; + const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript.source, worldName, runImmediately }); + this._initScriptIds.set(initScript, identifier); + `); // -- _removeEvaluatesOnNewDocument Method -- - frameSessionClass - .getMethodOrThrow("_removeEvaluatesOnNewDocument") - .setBodyText(` + frameSessionClass.getMethodOrThrow("_removeEvaluatesOnNewDocument").setBodyText(` const toRemove = new Set(initScripts); this._evaluateOnNewDocumentScripts = this._evaluateOnNewDocumentScripts.filter(script => !toRemove.has(script)); + const ids: string[] = []; + for (const script of initScripts) { + const id = this._initScriptIds.get(script); + if (id) + ids.push(id); + this._initScriptIds.delete(script); + } + await Promise.all(ids.map(identifier => this._client.send('Page.removeScriptToEvaluateOnNewDocument', { identifier }).catch(() => {}))); `); // -- _adoptBackendNodeId Method -- @@ -460,15 +507,15 @@ export function patchCRPage(project: Project) { adoptBackendNodeIdMethod .getVariableStatements() .find( - (statement) => + statement => statement.getText().includes("const result = await this._client._sendMayFail('DOM.resolveNode'") && statement.getText().includes("executionContextId: (to.delegate as CRExecutionContext)._contextId"), - ) + ), ); const executionContextIdAssignment = assertDefined( resolveNodeResultStatement .getDescendantsOfKind(SyntaxKind.PropertyAssignment) - .find((assignment) => assignment.getName() === "executionContextId") + .find(assignment => assignment.getName() === "executionContextId"), ); executionContextIdAssignment.setInitializer("to.delegate._contextId"); -} \ No newline at end of file +} diff --git a/driver_patches/crServiceWorkerPatch.ts b/driver_patches/crServiceWorkerPatch.ts index 7b624a5e..a5174955 100644 --- a/driver_patches/crServiceWorkerPatch.ts +++ b/driver_patches/crServiceWorkerPatch.ts @@ -6,39 +6,47 @@ import { assertDefined } from "./utils.ts"; // ---------------------------------- export function patchCRServiceWorker(project: Project) { // Add source file to the project. - const crServiceWorkerSourceFile = project.addSourceFileAtPath('packages/playwright-core/src/server/chromium/crServiceWorker.ts'); + const crServiceWorkerSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/chromium/crServiceWorker.ts", + ); // ------- CRServiceWorker Class ------- const crServiceWorkerClass = crServiceWorkerSourceFile.getClassOrThrow("CRServiceWorker"); // -- CRServiceWorker Constructor -- const crServiceWorkerConstructorDeclaration = assertDefined( - crServiceWorkerClass - .getConstructors() - .find((ctor) => { - const params = ctor.getParameters(); - return params[0]?.getName() === "browserContext" && params[1]?.getName() === "session" && params[2]?.getName() === "url"; - }) + crServiceWorkerClass.getConstructors().find(ctor => { + const params = ctor.getParameters(); + return ( + params[0]?.getName() === "browserContext" && + params[1]?.getName() === "session" && + params[2]?.getName() === "url" + ); + }), ); - const crServiceWorkerConstructorBody = crServiceWorkerConstructorDeclaration.getBodyOrThrow().asKindOrThrow(SyntaxKind.Block); - + const crServiceWorkerConstructorBody = crServiceWorkerConstructorDeclaration + .getBodyOrThrow() + .asKindOrThrow(SyntaxKind.Block); + // Find the Runtime.enable statement to remove - assertDefined( - crServiceWorkerConstructorBody - .getStatements() - .find((s) => s.getText().includes("session.send") && s.getText().includes("Runtime.enable")) - ).remove(); + crServiceWorkerConstructorBody + .getStatements() + .find(s => s.getText().includes("session.send") && s.getText().includes("Runtime.enable")) + ?.remove(); + if (crServiceWorkerConstructorBody.getText().includes('expression: "globalThis"')) return; crServiceWorkerConstructorBody.addStatements(` session._sendMayFail("Runtime.evaluate", { expression: "globalThis", serializationOptions: { serialization: "idOnly" } }).then(globalThis => { - if (globalThis && globalThis.result) { + if (globalThis && globalThis.result && globalThis.result.objectId) { var globalThisObjId = globalThis.result.objectId; var executionContextId = parseInt(globalThisObjId.split(".")[1], 10); - this.createExecutionContext(new CRExecutionContext(session, { id: executionContextId })); + if (!isNaN(executionContextId)) { + this.createExecutionContext(new CRExecutionContext(session, { id: executionContextId })); + } } }); `); -} \ No newline at end of file +} diff --git a/driver_patches/credentialsPatch.ts b/driver_patches/credentialsPatch.ts new file mode 100644 index 00000000..7b1a1229 --- /dev/null +++ b/driver_patches/credentialsPatch.ts @@ -0,0 +1,19 @@ +import type { Project } from "ts-morph"; + +// --------------------- +// server/credentials.ts +// --------------------- +export function patchCredentials(project: Project) { + // Add source file to the project + const credentialsSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/credentials.ts"); + + // ------- Credentials Class ------- + const credentialsClass = credentialsSourceFile.getClassOrThrow("Credentials"); + + // -- install Method -- + const installMethod = credentialsClass.getMethodOrThrow("install"); + const scriptDeclaration = installMethod.getVariableDeclarationOrThrow("script"); + scriptDeclaration.setInitializer( + "`(() => {\n const module = {};\n ${rawWebAuthnSource.source}\n const installWebAuthn = () => {\n if (!globalThis.__pwWebAuthnBinding) {\n setTimeout(installWebAuthn, 0);\n return;\n }\n module.exports.inject()(globalThis);\n };\n installWebAuthn();\n })();`", + ); +} diff --git a/driver_patches/expectUtilsPatch.ts b/driver_patches/expectUtilsPatch.ts deleted file mode 100644 index 85e4fda7..00000000 --- a/driver_patches/expectUtilsPatch.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type Project, SyntaxKind } from 'ts-morph'; -import { assertDefined } from './utils.ts'; - -// --------------------------- -// server/utils/expectUtils.ts -// --------------------------- -export function patchExpectUtils(project: Project) { - // Add source file to the project - const expectUtilsSourceFile = project.addSourceFileAtPath('packages/playwright-core/src/server/utils/expectUtils.ts'); - - // ------- formatMatcherMessage Function ------- - const formatMatcherMessageFunction = expectUtilsSourceFile.getFunctionOrThrow('formatMatcherMessage'); - const formatMatcherMessageAlignDecl = assertDefined( - formatMatcherMessageFunction - .getDescendantsOfKind(SyntaxKind.VariableDeclaration) - .find(d => d.getName() === 'align') - ); - formatMatcherMessageAlignDecl.setInitializer( - "!details.errorMessage && details.printedExpected?.startsWith('Expected:') && (!details.printedReceived || details.printedReceived.startsWith('Received:'))" - ); -} diff --git a/driver_patches/frameDispatcherPatch.ts b/driver_patches/frameDispatcherPatch.ts index ed3621b0..0f33321d 100644 --- a/driver_patches/frameDispatcherPatch.ts +++ b/driver_patches/frameDispatcherPatch.ts @@ -5,7 +5,9 @@ import { type Project, SyntaxKind } from "ts-morph"; // ------------------------------------- export function patchFrameDispatcher(project: Project) { // Add source file to the project - const frameDispatcherSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/dispatchers/frameDispatcher.ts"); + const frameDispatcherSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/dispatchers/frameDispatcher.ts", + ); // ------- frameDispatcher Class ------- const frameDispatcherClass = frameDispatcherSourceFile.getClassOrThrow("FrameDispatcher"); @@ -15,11 +17,12 @@ export function patchFrameDispatcher(project: Project) { frameEvaluateExpressionMethod.setBodyText(` return { value: serializeResult( - await progress.race( - this._frame.evaluateExpression( - params.expression, - { isFunction: params.isFunction, world: params.isolatedContext ? 'utility': 'main' }, - parseArgument(params.arg) + await progress.race( + this._frame.evaluateExpression( + progress, + params.expression, + { isFunction: params.isFunction, world: params.isolatedContext ? 'utility': 'main' }, + parseArgument(params.arg) ) ) ) @@ -32,11 +35,12 @@ export function patchFrameDispatcher(project: Project) { return { handle: ElementHandleDispatcher.fromJSOrElementHandle( this, - await progress.race( - this._frame.evaluateExpressionHandle( - params.expression, - { isFunction: params.isFunction, world: params.isolatedContext ? 'utility': 'main' }, - parseArgument(params.arg) + await progress.race( + this._frame.evaluateExpressionHandle( + progress, + params.expression, + { isFunction: params.isFunction, world: params.isolatedContext ? 'utility': 'main' }, + parseArgument(params.arg) ) ) ) @@ -47,10 +51,11 @@ export function patchFrameDispatcher(project: Project) { const frameEvalOnSelectorAllExpressionMethod = frameDispatcherClass.getMethodOrThrow("evalOnSelectorAll"); frameEvalOnSelectorAllExpressionMethod.setBodyText(` return { - value: serializeResult( - await this._frame.evalOnSelectorAll( - params.selector, - params.expression, + value: serializeResult( + await this._frame.evalOnSelectorAll( + progress, + params.selector, + params.expression, params.isFunction, parseArgument(params.arg), null, @@ -59,4 +64,4 @@ export function patchFrameDispatcher(project: Project) { ) }; `); -} \ No newline at end of file +} diff --git a/driver_patches/frameSelectorsPatch.ts b/driver_patches/frameSelectorsPatch.ts index 0486c174..c27ab4a1 100644 --- a/driver_patches/frameSelectorsPatch.ts +++ b/driver_patches/frameSelectorsPatch.ts @@ -19,8 +19,7 @@ export function patchFrameSelectors(project: Project) { }); frameSelectorsSourceFile.addImportDeclaration({ moduleSpecifier: "./progress", - namedImports: ["Progress"], - isTypeOnly: true, + namedImports: ["Progress", "nullProgress"], }); frameSelectorsSourceFile.addImportDeclaration({ moduleSpecifier: "./chromium/protocol", @@ -33,97 +32,126 @@ export function patchFrameSelectors(project: Project) { // -- queryArrayInMainWorld Method -- const queryArrayInMainWorldMethod = frameSelectorsClass.getMethodOrThrow("queryArrayInMainWorld"); - queryArrayInMainWorldMethod.addParameter({ - name: "isolatedContext", - type: "boolean", - hasQuestionToken: true, - }); + if (!queryArrayInMainWorldMethod.getParameter("isolatedContext")) + queryArrayInMainWorldMethod.addParameter({ + name: "isolatedContext", + type: "boolean", + hasQuestionToken: true, + }); // Update mainWorld property based on isolatedContext parameter const resolveInjectedCall = queryArrayInMainWorldMethod .getDescendantsOfKind(SyntaxKind.CallExpression) - .find(callExpr => - callExpr.getExpression().getText() === "this.resolveInjectedForSelector" && - callExpr.getArguments()[1]?.getKind() === SyntaxKind.ObjectLiteralExpression + .find( + callExpr => + callExpr.getExpression().getText() === "this.resolveInjectedForSelector" && + callExpr.getArguments()[1]?.getKind() === SyntaxKind.ObjectLiteralExpression, ); const mainWorldProp = assertDefined( assertDefined(resolveInjectedCall) .getArguments()[1] .asKindOrThrow(SyntaxKind.ObjectLiteralExpression) - .getProperty("mainWorld") + .getProperty("mainWorld"), ); - if (mainWorldProp.getText() === "mainWorld: true") - mainWorldProp.replaceWithText("mainWorld: !isolatedContext"); + if (mainWorldProp.getText() === "mainWorld: true") mainWorldProp.replaceWithText("mainWorld: !isolatedContext"); // -- resolveFrameForSelector Method -- const resolveFrameForSelectorMethod = frameSelectorsClass.getMethodOrThrow("resolveFrameForSelector"); // Change 'element' variable declaration from const to let to allow reassignment. - assertDefined( - resolveFrameForSelectorMethod - .getDescendantsOfKind(SyntaxKind.VariableStatement) - .find(s => s.getText().includes("const element = handle.asElement()")) - ).setDeclarationKind(VariableDeclarationKind.Let); + resolveFrameForSelectorMethod + .getDescendantsOfKind(SyntaxKind.VariableStatement) + .find(s => s.getText().includes("const element = handle.asElement()")) + ?.setDeclarationKind(VariableDeclarationKind.Let); // Handle the case when element is not found - fetch it from the document using the parsed selector const resolveFrameForSelectorIfStatement = resolveFrameForSelectorMethod .getDescendantsOfKind(SyntaxKind.IfStatement) .find(statement => statement.getExpression().getText() === "!element"); - assertDefined(resolveFrameForSelectorIfStatement).replaceWithText(` - if (!element) { - try { - var client = frame._page.delegate._sessionForFrame(frame)._client; - } catch (e) { - var client = frame._page.delegate._mainFrameSession._client; - } - var mainContext = await frame._context("main"); - const documentNode = await client.send("Runtime.evaluate", { - expression: "document", - serializationOptions: { serialization: "idOnly" }, - contextId: mainContext.delegate._contextId - }); - const documentScope = new ElementHandle(mainContext, documentNode.result.objectId); + if (resolveFrameForSelectorIfStatement) + resolveFrameForSelectorIfStatement.replaceWithText(` + if (!element) { + if ((options as any).state === "hidden" || (options as any).state === "detached") + return null; + try { + var client = frame._page.delegate._sessionForFrame(frame)._client; + } catch (e) { + var client = frame._page.delegate._mainFrameSession._client; + } + var mainContext = await frame._context("main"); + const documentNode = await client.send("Runtime.evaluate", { + expression: "document", + serializationOptions: { serialization: "idOnly" }, + contextId: mainContext.delegate._contextId + }); + const documentScope = new ElementHandle(mainContext, documentNode.result.objectId); var check = await this._customFindFramesByParsed(injectedScript, client, mainContext, documentScope, undefined, info.parsed); - if (check.length === 0) return null; - element = check[0]; - } - `); - - // -- resolveInjectedForSelector Method -- - const resolveInjectedForSelectorMethod = frameSelectorsClass.getMethodOrThrow("resolveInjectedForSelector"); - // Find the statement where 'injected' is assigned from 'context.injectedScript' and add a null check - const contextStatement = assertDefined(resolveInjectedForSelectorMethod - .getStatements() - .find(stmt => { - const varStmt = stmt.asKind(SyntaxKind.VariableStatement); - if (!varStmt) - return false; - const decl = assertDefined(varStmt.getDeclarations()[0]); - const callExpr = decl - .getInitializerIfKind(SyntaxKind.AwaitExpression) - ?.getExpressionIfKind(SyntaxKind.CallExpression); - if (!callExpr) - return false; + if (check.length === 0) return null; + element = check[0]; + } + `); + if (!resolveFrameForSelectorMethod.getText().includes("const isConnected = await element.evaluateInUtility")) { + const maybeFrameStatement = assertDefined( + resolveFrameForSelectorMethod + .getDescendantsOfKind(SyntaxKind.VariableStatement) + .find(statement => + statement.getText().includes("const maybeFrame = await frame._page.delegate.getContentFrame(element)"), + ), + ); + const parentBlock = maybeFrameStatement.getParentIfKindOrThrow(SyntaxKind.Block); + parentBlock.insertStatements( + maybeFrameStatement.getChildIndex(), + ` + const isConnected = await element.evaluateInUtility(([injected, node]) => node.isConnected, {}).catch(() => false); + if (!isConnected) { + element.dispose(); + return null; + } + `, + ); + } - return decl.getName() === "context" && callExpr.getExpression().getText().includes("._context"); - })); - resolveInjectedForSelectorMethod.insertStatements(contextStatement.getChildIndex() + 1, `if (!context) throw new Error("Frame was detached");`); + // -- resolveInjectedForSelector Method -- + const resolveInjectedForSelectorMethod = frameSelectorsClass.getMethodOrThrow("resolveInjectedForSelector"); + // Find the statement where 'injected' is assigned from 'context.injectedScript' and add a null check + const contextStatement = assertDefined( + resolveInjectedForSelectorMethod.getStatements().find(stmt => { + const varStmt = stmt.asKind(SyntaxKind.VariableStatement); + if (!varStmt) return false; + const decl = assertDefined(varStmt.getDeclarations()[0]); + const callExpr = decl + .getInitializerIfKind(SyntaxKind.AwaitExpression) + ?.getExpressionIfKind(SyntaxKind.CallExpression); + if (!callExpr) return false; + const expressionText = callExpr.getExpression().getText(); + return ( + decl.getName() === "context" && (expressionText.includes("._context") || expressionText.includes(".context")) + ); + }), + ); + if (!resolveInjectedForSelectorMethod.getText().includes('if (!context) throw new Error("Frame was detached");')) + resolveInjectedForSelectorMethod.insertStatements( + contextStatement.getChildIndex() + 1, + `if (!context) throw new Error("Frame was detached");`, + ); // -- _customFindFramesByParsed Method -- progress - frameSelectorsClass.addMethod({ - name: "_customFindFramesByParsed", - isAsync: true, - parameters: [ - { name: "resolved", type: "JSHandle" }, - { name: "client", type: "CRSession" }, - { name: "context", type: "FrameExecutionContext" }, - { name: "documentScope", type: "ElementHandle" }, - { name: "progress", type: "Progress | undefined" }, - { name: "parsed", type: "ParsedSelector" }, - ], - }); + if (!frameSelectorsClass.getMethod("_customFindFramesByParsed")) + frameSelectorsClass.addMethod({ + name: "_customFindFramesByParsed", + isAsync: true, + parameters: [ + { name: "resolved", type: "JSHandle" }, + { name: "client", type: "CRSession" }, + { name: "context", type: "FrameExecutionContext" }, + { name: "documentScope", type: "ElementHandle" }, + { name: "progress", type: "Progress | undefined" }, + { name: "parsed", type: "ParsedSelector" }, + ], + }); const customFindFramesByParsedSelectorsMethod = frameSelectorsClass.getMethodOrThrow("_customFindFramesByParsed"); customFindFramesByParsedSelectorsMethod.setBodyText(` var parsedEdits = { ...parsed }; const callId = progress?.metadata.id; + progress = progress || nullProgress; // Note: We start scoping at document level var currentScopingElements = [documentScope]; @@ -191,7 +219,7 @@ export function patchFrameSelectors(project: Project) { // Elements Queryed in the "current round" const queryGroups: { handles: any; parentNode: any }[] = []; for (var shadowRoot of shadowRoots) { - const shadowHandles = await shadowRoot.evaluateHandleInUtility( + const shadowHandles = await (shadowRoot as any)._evaluateHandleInUtility( ([injected, node, { parsed, callId }]) => { const elements = injected.querySelectorAll(parsed, node); if (callId) @@ -206,7 +234,7 @@ export function patchFrameSelectors(project: Project) { } // Document Root Elements (not in CSR) - const rootHandles = await scope.evaluateHandleInUtility( + const rootHandles = await (scope as any)._evaluateHandleInUtility( ([injected, node, { parsed, callId }]) => { const elements = injected.querySelectorAll(parsed, node); if (callId) @@ -221,15 +249,16 @@ export function patchFrameSelectors(project: Project) { // Querying and Sorting the elements by their backendNodeId for (const { handles, parentNode } of queryGroups) { - const handlesAmount = await (await handles.getProperty("length")).jsonValue(); + const handlesAmount = await (await handles.getProperty(progress, "length")).jsonValue(progress); for (var i = 0; i < handlesAmount; i++) { + let element; if (parentNode instanceof ElementHandle) { - var element = await parentNode.evaluateHandleInUtility( + element = await (parentNode as any)._evaluateHandleInUtility( ([injected, node, { i, handles: elems }]) => elems[i], { i, handles } ); } else { - var element = await parentNode.evaluateHandle( + element = await parentNode.evaluateHandle( (injected, { i, handles: elems }) => elems[i], { i, handles } ); @@ -312,4 +341,4 @@ export function patchFrameSelectors(project: Project) { } return null; `); -} \ No newline at end of file +} diff --git a/driver_patches/framesPatch.ts b/driver_patches/framesPatch.ts index e86322b9..8cde628a 100644 --- a/driver_patches/framesPatch.ts +++ b/driver_patches/framesPatch.ts @@ -7,26 +7,29 @@ import { assertDefined } from "./utils.ts"; export function patchFrames(project: Project) { // Add source file to the project const framesSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/frames.ts"); + framesSourceFile.getImportDeclarationOrThrow("./errors").addNamedImport("TargetClosedError"); // Add the custom import and comment at the start of the file framesSourceFile.addImportDeclarations([ - { moduleSpecifier: './chromium/crExecutionContext', namedImports: ['CRExecutionContext'] }, - { moduleSpecifier: './dom', namedImports: ['FrameExecutionContext'] }, - { moduleSpecifier: './chromium/crConnection', namedImports: ['CRSession'], isTypeOnly: true }, - { moduleSpecifier: 'crypto', defaultImport: 'crypto' }, + { moduleSpecifier: "./chromium/crExecutionContext", namedImports: ["CRExecutionContext"] }, + { moduleSpecifier: "./dom", namedImports: ["FrameExecutionContext"] }, + { moduleSpecifier: "./chromium/crConnection", namedImports: ["CRSession"], isTypeOnly: true }, + { moduleSpecifier: "crypto", defaultImport: "crypto" }, ]); // ------- FrameManager Class ------- const frameManagerClass = framesSourceFile.getClassOrThrow("FrameManager"); // -- frameCommittedNewDocumentNavigation Method -- - const frameCommittedNewDocumentNavigationMethod = frameManagerClass.getMethodOrThrow("frameCommittedNewDocumentNavigation"); + const frameCommittedNewDocumentNavigationMethod = frameManagerClass.getMethodOrThrow( + "frameCommittedNewDocumentNavigation", + ); const clearLifecycleStatementIndex = frameCommittedNewDocumentNavigationMethod .getDescendantsOfKind(SyntaxKind.ExpressionStatement) .findIndex(stmt => stmt.getText().trim() === "frame._onClearLifecycle();"); frameCommittedNewDocumentNavigationMethod.insertStatements(clearLifecycleStatementIndex - 2, [ "frame._iframeWorld = undefined;", "frame._mainWorld = undefined;", - "frame._isolatedWorld = undefined;" + "frame._isolatedWorld = undefined;", ]); // ------- Frame Class ------- @@ -34,8 +37,8 @@ export function patchFrames(project: Project) { // Add Properties to the Frame Class frameClass.addProperties([ { name: "_isolatedWorld", type: "dom.FrameExecutionContext" }, - { name: "_mainWorld", type: "dom.FrameExecutionContext" }, - { name: "_iframeWorld", type: "dom.FrameExecutionContext" }, + { name: "_mainWorld", type: "dom.FrameExecutionContext" }, + { name: "_iframeWorld", type: "dom.FrameExecutionContext" }, ]); // -- evalOnSelector Method -- @@ -44,7 +47,7 @@ export function patchFrames(project: Project) { const handle = await this.selectors.query(selector, { strict }, scope); if (!handle) throw new Error('Failed to find element matching selector "' + selector + '"'); - const result = await handle.evaluateExpression(expression, { isFunction }, arg, true); + const result = await handle.internalEvaluateExpression(expression, { isFunction }, arg); handle.dispose(); return result; `); @@ -52,9 +55,9 @@ export function patchFrames(project: Project) { // -- evalOnSelectorAll Method -- const evalOnSelectorAllMethod = frameClass.getMethodOrThrow("evalOnSelectorAll"); evalOnSelectorAllMethod.addParameter({ - name: "isolatedContext", - type: "boolean", - hasQuestionToken: true, + name: "isolatedContext", + type: "boolean", + hasQuestionToken: true, }); evalOnSelectorAllMethod.setBodyText(` const maxAttempts = 3; @@ -62,7 +65,7 @@ export function patchFrames(project: Project) { try { isolatedContext = this.selectors._parseSelector(selector, { strict: false }).world !== "main" && isolatedContext; const arrayHandle = await this.selectors.queryArrayInMainWorld(selector, scope, isolatedContext); - const result = await arrayHandle.evaluateExpression(expression, { isFunction }, arg, isolatedContext); + const result = await arrayHandle.internalEvaluateExpression(expression, { isFunction }, arg); arrayHandle.dispose(); return result; } catch (e) { @@ -104,6 +107,19 @@ export function patchFrames(project: Project) { const callback = (injectedScript, element, data) => { injectedScript.dispatchEvent(element, data.type, data.eventInit); }; + if (eventInitHandles.length > 0 && selector !== ":scope") { + dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { strict: options.strict, performActionPreChecks: false }, async (progress, handle) => { + await handle.dispatchEvent(progress, type, eventInit); + return 'done' as const; + })); + return; + } + if (eventInitHandles.some(handle => handle._context?.frame !== this)) + throw new js.JavaScriptErrorInEvaluate("JSHandles can be evaluated only in the context they were created!"); + if (eventInitHandles.length === 0) { + await this._callOnElementOnceMatches(progress, selector, callback, { type, eventInit }, { ...options }, scope); + return; + } try { await this._callOnElementOnceMatches(progress, selector, callback, { type, eventInit }, { mainWorld: true, ...options }, scope); } catch (e) { @@ -118,26 +134,32 @@ export function patchFrames(project: Project) { // -- querySelectorAll Method -- const querySelectorAllMethod = frameClass.getMethodOrThrow("querySelectorAll"); querySelectorAllMethod.setBodyText(` - const metadata = { internal: false, log: [], method: "querySelectorAll" }; - const progress = { - log: message => metadata.log.push(message), - metadata, - race: (promise) => Promise.race(Array.isArray(promise) ? promise : [promise]) + const continuePolling = Symbol("continuePolling"); + let result; + try { + result = await this._retryWithoutProgress(progress, selector, {strict: null, performActionPreChecks: false}, async (result) => { + if (!result || !result[0]) return []; + return Array.isArray(result[1]) ? result[1] : []; + }, 'returnAll', continuePolling); + } catch (e: any) { + if ((e instanceof ReferenceError || e?.name === 'ReferenceError' || e?.message?.includes("element is not defined")) && e.message.includes("element is not defined")) + result = continuePolling; + else + throw e; } - return await this._retryWithoutProgress(progress, selector, {strict: null, performActionPreChecks: false}, async (result) => { - if (!result || !result[0]) return []; - return Array.isArray(result[1]) ? result[1] : []; - }, 'returnAll', null); + return result === continuePolling ? await progress.race(this.selectors.queryAll(selector)) : result; `); // -- querySelector Method -- const querySelectorMethod = frameClass.getMethodOrThrow("querySelector"); querySelectorMethod.setBodyText(` - return this.querySelectorAll(selector, options).then((handles) => { + this.apiLog(\` finding element using the selector "\${selector}"\`); + return this.querySelectorAll(progress, selector).then((handles) => { if (handles.length === 0) return null; - if (handles.length > 1 && options?.strict) - throw new Error(\`Strict mode: expected one element matching selector "\${selector}", found \${handles.length}\`); + const strict = options?.strict ?? this._page.browserContext._options.strictSelectors; + if (handles.length > 1 && strict) + throw new Error(\`strict mode violation: expected one element matching selector "\${selector}", found \${handles.length}\`); return handles[0]; }); `); @@ -146,9 +168,7 @@ export function patchFrames(project: Project) { frameClass.addMethod({ name: "_getFrameMainFrameContextId", isAsync: true, - parameters: [ - { name: "client", type: "CRSession" }, - ], + parameters: [{ name: "client", type: "CRSession" }], returnType: "Promise", }); const getFrameMainFrameContextIdMethod = frameClass.getMethodOrThrow("_getFrameMainFrameContextId"); @@ -166,16 +186,18 @@ export function patchFrames(project: Project) { if (!resolvedNode?.object?.objectId) return 0; - return parseInt(resolvedNode.object.objectId.split(".")[1], 10); + const executionContextId = parseInt(resolvedNode.object.objectId.split(".")[1], 10); + return isNaN(executionContextId) ? 0 : executionContextId; } catch (e) {} return 0; `); // -- _context Method -- - const contextMethod = frameClass.getMethodOrThrow("_context"); + const contextMethod = frameClass.getMethodOrThrow("context"); + contextMethod.rename("_context"); contextMethod.setIsAsync(true); contextMethod.setBodyText(` - if (this.isDetached()) + if (this._isDetached()) throw new Error('Frame was detached'); let client; @@ -210,12 +232,14 @@ export function patchFrames(project: Project) { expression: "globalThis", serializationOptions: { serialization: "idOnly" }, }); - if (!globalThis) { - if (this.isDetached()) throw new Error('Frame was detached'); + if (!globalThis || !globalThis?.result?.objectId) { + if (this._isDetached()) throw new Error('Frame was detached'); return; } const executionContextId = parseInt(globalThis.result.objectId.split('.')[1], 10); - this._mainWorld = registerContext(executionContextId, world); + if (!isNaN(executionContextId)) { + this._mainWorld = registerContext(executionContextId, world); + } } } @@ -223,10 +247,10 @@ export function patchFrames(project: Project) { const result = await client._sendMayFail('Page.createIsolatedWorld', { frameId: this._id, grantUniveralAccess: true, worldName: world, }); - if (!result) { - if (this.isDetached()) throw new Error("Frame was detached"); - return; - } + if (!result) { + if (this._isDetached()) throw new Error("Frame was detached"); + return; + } this._isolatedWorld = registerContext(result.executionContextId, "utility"); } @@ -236,25 +260,31 @@ export function patchFrames(project: Project) { return this._iframeWorld; return this._mainWorld; `); + frameClass.insertMethod(contextMethod.getChildIndex() + 1, { + name: "context", + parameters: [{ name: "world", type: "types.World" }], + returnType: "Promise", + statements: "return this._context(world);", + }); // -- _setContext Method -- const setContentMethod = frameClass.getMethodOrThrow("setContent"); setContentMethod.setBodyText(` - await this.raceNavigationAction(progress, async () => { - const waitUntil = options.waitUntil === void 0 ? "load" : options.waitUntil; - progress.log(\`setting frame content, waiting until "\${waitUntil}"\`); - const lifecyclePromise = new Promise((resolve, reject) => { - this._onClearLifecycle(); - this.waitForLoadState(progress, waitUntil).then(resolve).catch(reject); - }); - const setContentPromise = this._page.delegate._sessionForFrame(this)._client.send("Page.setDocumentContent", { - frameId: this._id, - html - }); - await Promise.all([setContentPromise, lifecyclePromise]); - - return null; - }); + await this.raceNavigationAction(progress, async () => { + const waitUntil = options.waitUntil === void 0 ? "load" : options.waitUntil; + progress.log(\`setting frame content, waiting until "\${waitUntil}"\`); + const lifecyclePromise = new Promise((resolve, reject) => { + this._onClearLifecycle(); + this.waitForLoadState(progress, waitUntil).then(resolve).catch(reject); + }); + const setContentPromise = this._page.delegate._sessionForFrame(this)._client.send("Page.setDocumentContent", { + frameId: this._id, + html + }); + await Promise.all([setContentPromise, lifecyclePromise]); + + return null; + }); `); // -- _retryWithProgressIfNotConnected Method -- @@ -268,20 +298,127 @@ export function patchFrames(project: Project) { const retryParamNames = retryWithProgressIfNotConnectedMethod.getParameters().map(p => p.getName()); if (retryParamNames.includes("options") && !retryParamNames.includes("strict")) { retryWithProgressIfNotConnectedMethod.setBodyText(` - if (!(options as any)?.__patchrightSkipRetryLogWaiting) - progress.log("waiting for " + this._asLocator(selector)); - return this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async continuePolling => { - return this._retryWithoutProgress(progress, selector, options as any, action as any, returnAction, continuePolling); + progress.log(\`waiting for \${this._asLocator(selector)}\`); + const noAutoWaiting = (options as any).__testHookNoAutoWaiting ?? options.noAutoWaiting; + const performActionPreChecks = (options.performActionPreChecks ?? !options.force) && !noAutoWaiting; + return this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async (progress, continuePolling) => { + if (performActionPreChecks) + await this._page.performActionPreChecks(progress); + + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, { strict: options.strict })); + if (!resolved) { + if (noAutoWaiting) + throw new dom.NonRecoverableDOMError('Element(s) not found'); + return continuePolling; + } + const result = await progress.race(resolved.injected.evaluateHandle((injected, { info, callId }) => { + const elements = injected.querySelectorAll(info.parsed, document); + if (callId) + injected.markTargetElements(new Set(elements), callId); + const element = elements[0] as Element | undefined; + let log = ''; + if (elements.length > 1) { + if (info.strict) + throw injected.strictModeViolationError(info.parsed, elements); + log = \` locator resolved to \${elements.length} elements. Proceeding with the first one: \${injected.previewNode(elements[0])}\`; + } else if (element) { + log = \` locator resolved to \${injected.previewNode(element)}\`; + } + injected.checkDeprecatedSelectorUsage(info.parsed, elements); + return { log, success: !!element, element }; + }, { info: resolved.info, callId: progress.metadata.id })); + const { log, success } = await progress.race(result.evaluate(r => ({ log: r.log, success: r.success }))); + if (log) + progress.log(log); + if (!success) { + result.dispose(); + // Fallback to custom _retryWithoutProgress + const actionWithProgress = async (res) => action.length >= 2 ? action(progress, res as any) : (action as any)(res); + const retryRes = await this._retryWithoutProgress(progress, selector, { ...options, performActionPreChecks: false } as any, actionWithProgress as any, returnAction, continuePolling); + if (retryRes !== continuePolling) + return retryRes; + + if (noAutoWaiting) + throw new dom.NonRecoverableDOMError('Element(s) not found'); + return continuePolling; + } + const element = await progress.race(result.evaluateHandle(r => r.element)) as dom.ElementHandle; + result.dispose(); + try { + const result = await action(progress, element); + if (result === 'error:notconnected') { + if (noAutoWaiting) + throw new dom.NonRecoverableDOMError('Element is not attached to the DOM'); + progress.log('element was detached from the DOM, retrying'); + return continuePolling; + } + return result; + } finally { + element?.dispose(); + } }); `); } else if (retryParamNames.includes("strict") && retryParamNames.includes("performActionPreChecks")) { retryWithProgressIfNotConnectedMethod.setBodyText(` + progress.log(\`waiting for \${this._asLocator(selector)}\`); const normalizedOptions: any = { strict, performActionPreChecks }; - - if (!(normalizedOptions as any)?.__patchrightSkipRetryLogWaiting) - progress.log("waiting for " + this._asLocator(selector)); - return this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async continuePolling => { - return this._retryWithoutProgress(progress, selector, normalizedOptions, action as any, returnAction, continuePolling); + const noAutoWaiting = (normalizedOptions as any).__testHookNoAutoWaiting ?? normalizedOptions.noAutoWaiting; + const performChecks = (normalizedOptions.performActionPreChecks ?? !normalizedOptions.force) && !noAutoWaiting; + return this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async (progress, continuePolling) => { + if (performChecks) + await this._page.performActionPreChecks(progress); + + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, { strict })); + if (!resolved) { + if (noAutoWaiting) + throw new dom.NonRecoverableDOMError('Element(s) not found'); + return continuePolling; + } + const result = await progress.race(resolved.injected.evaluateHandle((injected, { info, callId }) => { + const elements = injected.querySelectorAll(info.parsed, document); + if (callId) + injected.markTargetElements(new Set(elements), callId); + const element = elements[0] as Element | undefined; + let log = ''; + if (elements.length > 1) { + if (info.strict) + throw injected.strictModeViolationError(info.parsed, elements); + log = \` locator resolved to \${elements.length} elements. Proceeding with the first one: \${injected.previewNode(elements[0])}\`; + } else if (element) { + log = \` locator resolved to \${injected.previewNode(element)}\`; + } + injected.checkDeprecatedSelectorUsage(info.parsed, elements); + return { log, success: !!element, element }; + }, { info: resolved.info, callId: progress.metadata.id })); + const { log, success } = await progress.race(result.evaluate(r => ({ log: r.log, success: r.success }))); + if (log) + progress.log(log); + if (!success) { + result.dispose(); + // Fallback to custom _retryWithoutProgress + const actionWithProgress = async (res) => action.length >= 2 ? action(progress, res as any) : (action as any)(res); + const retryRes = await this._retryWithoutProgress(progress, selector, { ...normalizedOptions, performActionPreChecks: false }, actionWithProgress as any, returnAction, continuePolling); + if (retryRes !== continuePolling) + return retryRes; + + if (noAutoWaiting) + throw new dom.NonRecoverableDOMError('Element(s) not found'); + return continuePolling; + } + const element = await progress.race(result.evaluateHandle(r => r.element)) as dom.ElementHandle; + result.dispose(); + try { + const result = await action(progress, element); + if (result === 'error:notconnected') { + if (noAutoWaiting) + throw new dom.NonRecoverableDOMError('Element is not attached to the DOM'); + progress.log('element was detached from the DOM, retrying'); + return continuePolling; + } + return result; + } finally { + element?.dispose(); + } }); `); } else { @@ -295,8 +432,14 @@ export function patchFrames(project: Project) { parameters: [ { name: "progress", type: "Progress" }, { name: "selector", type: "string" }, - { name: "options", type: "{ performActionPreChecks: boolean; strict?: boolean | null; state?: 'attached' | 'detached' | 'visible' | 'hidden'; noAutoWaiting?: boolean; __testHookNoAutoWaiting?: boolean; __patchrightWaitForSelector?: boolean; __patchrightInitialScope?: dom.ElementHandle; __patchrightSkipRetryLogWaiting?: boolean }" }, - { name: "action", type: "(result: dom.ElementHandle | [dom.ElementHandle, dom.ElementHandle[]] | null) => Promise" }, + { + name: "options", + type: "{ performActionPreChecks: boolean; strict?: boolean | null; state?: 'attached' | 'detached' | 'visible' | 'hidden'; noAutoWaiting?: boolean; __testHookNoAutoWaiting?: boolean; __patchrightWaitForSelector?: boolean; __patchrightInitialScope?: dom.ElementHandle; __patchrightSkipRetryLogWaiting?: boolean }", + }, + { + name: "action", + type: "(result: dom.ElementHandle | [dom.ElementHandle, dom.ElementHandle[]] | null) => Promise", + }, { name: "returnAction", type: "'returnOnNotResolved' | 'returnAll' | undefined" }, { name: "continuePolling", type: "symbol" }, ], @@ -320,8 +463,8 @@ export function patchFrames(project: Project) { return continuePolling; } - const utilityContext = await resolved.frame._utilityContext(); - const mainContext = await resolved.frame._mainContext(); + const utilityContext = await resolved.frame.utilityContext(); + const mainContext = await resolved.frame.mainContext(); let client; try { client = this._page.delegate._sessionForFrame(resolved.frame)._client; @@ -453,82 +596,132 @@ export function patchFrames(project: Project) { throw new Error('options.visibility is not supported, did you mean options.state?'); if ((options as any).waitFor && (options as any).waitFor !== 'visible') throw new Error('options.waitFor is not supported, did you mean options.state?'); - const { state = 'visible' } = options; if (!['attached', 'detached', 'visible', 'hidden'].includes(state)) throw new Error(\`state: expected one of (attached|detached|visible|hidden)\`); - if (performActionPreChecksAndLog) progress.log(\`waiting for \${this._asLocator(selector)}\${state === 'attached' ? '' : ' to be ' + state}\`); - - const promise = this._retryWithProgressIfNotConnected(progress, selector, { ...options, performActionPreChecks: true, __patchrightWaitForSelector: true, __patchrightInitialScope: scope }, async handle => { - if (scope) { - const scopeIsConnected = await scope.evaluateInUtility(([injected, node]) => node.isConnected, {}).catch(() => false); - if (scopeIsConnected !== true) { + const promise = this.retryWithProgressAndBackoff(progress, async (progress, continuePolling) => { + if (performActionPreChecksAndLog) + await this._page.performActionPreChecks(progress); + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, options, scope)); + if (!resolved) { + if (state === 'hidden' || state === 'detached') + return null; + return continuePolling; + } + if ((state === 'hidden' || state === 'detached') && resolved.frame._isDetached()) + return null; + let result; + try { + result = await progress.race(resolved.injected.evaluateHandle((injected, { info, root, state }) => { + if (root && !root.isConnected) { if (state === 'hidden' || state === 'detached') - return null; - throw new dom.NonRecoverableDOMError('Element is not attached to the DOM'); + return { log: '', element: undefined, visible: false, attached: false }; + throw injected.createStacklessError('Element is not attached to the DOM'); + } + const elements = injected.querySelectorAll(info.parsed, root || document); + const element = elements[0]; + const visible = element ? injected.utils.isElementVisible(element) : false; + let log = ''; + if (elements.length > 1) { + if (info.strict) + throw injected.strictModeViolationError(info.parsed, elements); + log = " locator resolved to " + elements.length + " elements. Proceeding with the first one: " + injected.previewNode(elements[0]); + } else if (element) { + log = " locator resolved to " + (visible ? 'visible' : 'hidden') + " " + injected.previewNode(element); } + injected.checkDeprecatedSelectorUsage(info.parsed, elements); + return { log, element, visible, attached: !!element }; + }, { info: resolved.info, root: resolved.frame === this ? scope : undefined, state })); + } catch (e) { + if ((state === 'hidden' || state === 'detached') && resolved.frame._isDetached()) + return null; + throw e; } - - const attached = !!handle; - var visible = false; - - if (attached) { - if (handle.parentNode instanceof dom.ElementHandle) { - visible = await handle.parentNode.evaluateInUtility(([injected, node, { handle }]) => { - return handle ? injected.utils.isElementVisible(handle) : false; - }, { handle }); - } else { - visible = await handle.parentNode.evaluate((injected, { handle }) => { - return handle ? injected.utils.isElementVisible(handle) : false; - }, { handle }); + const { log, visible, attached } = await progress.race(result.evaluate(r => ({ log: r.log, visible: r.visible, attached: r.attached }))); + if (log) + progress.log(log); + const success = { attached, detached: !attached, visible, hidden: !visible }[state]; + if (!success) { + result.dispose(); + if ((state === 'attached' || state === 'visible') && !attached) { + const fallbackResult = await this._retryWithoutProgress(progress, selector, { ...options, state, performActionPreChecks: false, __patchrightWaitForSelector: true, __patchrightInitialScope: scope } as any, async (handle) => { + if (!handle) + return "internal:continuepolling"; + const visible = state === 'visible' ? await handle.evaluateInUtility(([injected, node]) => injected.utils.isElementVisible(node), {}).catch(() => false) : true; + if (!visible) + return "internal:continuepolling"; + if (options.omitReturnValue) + return null; + if ((options as any).__testHookBeforeAdoptNode) + await progress.race((options as any).__testHookBeforeAdoptNode()); + try { + const mainContext = await progress.race(handle._frame.mainContext()); + return await progress.race(handle._adoptTo(mainContext)); + } catch (e) { + return "internal:continuepolling"; + } + }, 'returnOnNotResolved', continuePolling); + if (fallbackResult !== continuePolling) + return fallbackResult; } + return continuePolling; } - - const success = { - attached, - detached: !attached, - visible, - hidden: !visible - }[state]; - if (!success) return "internal:continuepolling"; - if (options.omitReturnValue) return null; - - const element = state === 'attached' || state === 'visible' ? handle : null; - if (!element) return null; - if (options.__testHookBeforeAdoptNode) await options.__testHookBeforeAdoptNode(); + if (options.omitReturnValue) { + result.dispose(); + return null; + } + const element = state === 'attached' || state === 'visible' ? await progress.race(result.evaluateHandle(r => r.element)) : null; + result.dispose(); + if (!element) + return null; + if ((options as any).__testHookBeforeAdoptNode) + await progress.race((options as any).__testHookBeforeAdoptNode()); try { - return element; + const mainContext = await progress.race(resolved.frame.mainContext()); + return await progress.race(element._adoptTo(mainContext)); } catch (e) { - return "internal:continuepolling"; + return continuePolling; } - }, "returnOnNotResolved"); - - const resultPromise = scope ? scope._context._raceAgainstContextDestroyed(promise) : promise; - return resultPromise.catch(e => { - if (this.isDetached() && (e as any)?.message?.includes('Execution context was destroyed')) - throw new Error('Frame was detached'); - throw e; }); + if (!scope) + return promise; + const onNavigation = new ManualPromise(); + const navigationListener = (event: NavigationEvent) => { + if (event.newDocument) + onNavigation.reject(new Error('Execution context was destroyed, most likely because of a navigation')); + }; + this.on(Frame.Events.InternalNavigation, navigationListener); + try { + return await scope._context.raceAgainstContextDestroyed(Promise.race([promise, onNavigation])); + } finally { + this.off(Frame.Events.InternalNavigation, navigationListener); + } `); // -- waitForFunctionExpression Method -- const waitForFunctionExpressionMethod = frameClass.getMethodOrThrow("waitForFunctionExpression"); // Race the inner evaluate against _detachedScope so frame detachment immediately cancels the operation - const matchingReturnStmts = waitForFunctionExpressionMethod.getDescendantsOfKind(SyntaxKind.ReturnStatement).filter(stmt => stmt.getText().includes('progress.race(handle.evaluateHandle(h => h.result))')); + const matchingReturnStmts = waitForFunctionExpressionMethod + .getDescendantsOfKind(SyntaxKind.ReturnStatement) + .filter(stmt => stmt.getText().includes("progress.race(handle.evaluateHandle(h => h.result))")); // Take the last (innermost) match to avoid replacing the outer // `return this.retryWithProgressAndTimeouts(...)` statement whose // getText() also contains the substring. const targetReturnStmt = matchingReturnStmts[matchingReturnStmts.length - 1]; if (targetReturnStmt) { - targetReturnStmt.replaceWithText('return await progress.race(this._detachedScope.race(handle.evaluateHandle(h => h.result)));'); + targetReturnStmt.replaceWithText( + "return await progress.race(this._detachedScope.race(handle.evaluateHandle(h => h.result)));", + ); } else { // Upstream may already include _detachedScope wrapping; assert expected shape exists. assertDefined( waitForFunctionExpressionMethod .getDescendantsOfKind(SyntaxKind.ReturnStatement) - .find(stmt => stmt.getText().includes('progress.race(this._detachedScope.race(handle.evaluateHandle(h => h.result)))')) + .find(stmt => + stmt.getText().includes("progress.race(this._detachedScope.race(handle.evaluateHandle(h => h.result)))"), + ), ); } @@ -536,53 +729,46 @@ export function patchFrames(project: Project) { const isVisibleInternalMethod = frameClass.getMethodOrThrow("isVisibleInternal"); isVisibleInternalMethod.setBodyText(` try { - const metadata = { internal: false, log: [], method: "isVisible" }; - const progress = { - log: message => metadata.log.push(message), - metadata, - race: (promise) => Promise.race(Array.isArray(promise) ? promise : [promise]) - } - progress.log("waiting for " + this._asLocator(selector)); - if (selector === ":scope") { - const scopeParentNode = scope.parentNode || scope; + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, options, scope)); + if (!resolved) + return false; + const atomicResult = await progress.race(resolved.injected.evaluate((injected, { info, root }) => { + const element = injected.querySelector(info.parsed, root || document, info.strict); + return element ? { found: true, visible: injected.elementState(element, 'visible').matches } : { found: false, visible: false }; + }, { info: resolved.info, root: resolved.frame === this ? scope : undefined })); + if (atomicResult.found) + return atomicResult.visible; + + progress.log(\`waiting for \${this._asLocator(selector)}\`); + if (selector === ':scope') { + const scopeParentNode = (scope as any).parentNode || scope; if (scopeParentNode instanceof dom.ElementHandle) { - return await scopeParentNode.evaluateInUtility(([injected, node, { scope: handle2 }]) => { - const state = handle2 ? injected.elementState(handle2, "visible") : { - matches: false, - received: "error:notconnected" - }; + return await scopeParentNode.evaluateInUtility(([injected, node, { scope: handle }]) => { + const state = handle ? injected.elementState(handle, "visible") : { matches: false, received: "error:notconnected" }; return state.matches; }, { scope }); } else { - return await scopeParentNode.evaluate((injected, node, { scope: handle2 }) => { - const state = handle2 ? injected.elementState(handle2, "visible") : { - matches: false, - received: "error:notconnected" - }; + return await scopeParentNode.evaluate((injected, node, { scope: handle }) => { + const state = handle ? injected.elementState(handle, "visible") : { matches: false, received: "error:notconnected" }; return state.matches; }, { scope }); } } else { - return await this._retryWithoutProgress(progress, selector, { ...options, performActionPreChecks: false}, async (handle) => { - if (!handle) return false; + return await this._retryWithoutProgress(progress, selector, { ...options, performActionPreChecks: false }, async (handle) => { + if (!handle) + return false; if (handle.parentNode instanceof dom.ElementHandle) { return await handle.parentNode.evaluateInUtility(([injected, node, { handle: handle2 }]) => { - const state = handle2 ? injected.elementState(handle2, "visible") : { - matches: false, - received: "error:notconnected" - }; + const state = handle2 ? injected.elementState(handle2, "visible") : { matches: false, received: "error:notconnected" }; return state.matches; }, { handle }); } else { return await handle.parentNode.evaluate((injected, { handle: handle2 }) => { - const state = handle2 ? injected.elementState(handle2, "visible") : { - matches: false, - received: "error:notconnected" - }; + const state = handle2 ? injected.elementState(handle2, "visible") : { matches: false, received: "error:notconnected" }; return state.matches; }, { handle }); } - }, "returnOnNotResolved", null); + }, 'returnOnNotResolved', null as any) as boolean; } } catch (e) { if (this.isNonRetriableError(e)) throw e; @@ -614,15 +800,27 @@ export function patchFrames(project: Project) { // -- evaluateExpression Method -- const evaluateExpressionMethod = frameClass.getMethodOrThrow("evaluateExpression"); evaluateExpressionMethod.setBodyText(` - const context = await this._detachedScope.race(this._context(options.world ?? "main")); - return await this._detachedScope.race(context.evaluateExpression(expression, options, arg)); + try { + const context = await this._detachedScope.race(this._context(options.world ?? "main")); + return await this._detachedScope.race(context.evaluateExpression(expression, options, arg)); + } catch (e) { + if (e instanceof Error && (this._page.isClosedOrClosingOrCrashed() || this._page.browserContext.isClosingOrClosed() || (this._page.browserContext as any)._browser._startedClosing)) + throw new TargetClosedError(this._page.closeReason()); + throw e; + } `); // -- evaluateExpressionHandle Method -- const evaluateExpressionHandleMethod = frameClass.getMethodOrThrow("evaluateExpressionHandle"); evaluateExpressionHandleMethod.setBodyText(` - const context = await this._detachedScope.race(this._context(options.world ?? "utility")); - return await this._detachedScope.race(context.evaluateExpressionHandle(expression, options, arg)); + try { + const context = await this._detachedScope.race(this._context(options.world ?? "utility")); + return await this._detachedScope.race(context.evaluateExpressionHandle(expression, options, arg)); + } catch (e) { + if (e instanceof Error && (this._page.isClosedOrClosingOrCrashed() || this._page.browserContext.isClosingOrClosed() || (this._page.browserContext as any)._browser._startedClosing)) + throw new TargetClosedError(this._page.closeReason()); + throw e; + } `); // -- nonStallingEvaluateInExistingContext Method -- @@ -640,108 +838,56 @@ export function patchFrames(project: Project) { // -- queryCount Method -- const queryCountMethod = frameClass.getMethodOrThrow("queryCount"); queryCountMethod.setBodyText(` - const metadata = { internal: false, log: [], method: "queryCount" }; - const progress = { - log: message => metadata.log.push(message), - metadata, - race: (promise) => Promise.race(Array.isArray(promise) ? promise : [promise]) - } - return await this._retryWithoutProgress(progress, selector, {strict: null, performActionPreChecks: false }, async (result) => { + const continuePolling = Symbol("continuePolling"); + const result = await this._retryWithoutProgress(progress, selector, {strict: null, performActionPreChecks: false }, async (result) => { if (!result || !result[0]) return 0; return Array.isArray(result[1]) ? result[1].length : 0; - }, 'returnAll', null); + }, 'returnAll', continuePolling); + return result === continuePolling ? await this.selectors.queryCount(selector, options) : result; `); // -- _expectInternal Method -- const expectInternalMethod = frameClass.getMethodOrThrow("_expectInternal"); expectInternalMethod.setBodyText(` + const progressLog = (text: string) => progress.log(text); + const callId = progress.metadata.id; // The first expect check, a.k.a. one-shot, always finishes - even when progress is aborted. - const race = (p) => noAbort ? p : progress.race(p); - const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); - var log, matches, received, missingReceived; - if (selector) { - var frame, info; - try { - var { frame, info } = await race(this.selectors.resolveFrameForSelector(selector, { strict: true })); - } catch (e) { } - const action = async result => { - if (!result) { - if (options.expectedNumber === 0) - return { matches: true }; - if (options.isNot && options.expectedNumber) - return { matches: false, received: 0 }; - // expect(locator).toBeHidden() passes when there is no element. - if (!options.isNot && options.expression === 'to.be.hidden') - return { matches: true }; - // expect(locator).not.toBeVisible() passes when there is no element. - if (options.isNot && options.expression === 'to.be.visible') - return { matches: false }; - // expect(locator).toBeAttached({ attached: false }) passes when there is no element. - if (!options.isNot && options.expression === 'to.be.detached') - return { matches: true }; - // expect(locator).not.toBeAttached() passes when there is no element. - if (options.isNot && options.expression === 'to.be.attached') - return { matches: false }; - // expect(locator).not.toBeInViewport() passes when there is no element. - if (options.isNot && options.expression === 'to.be.in.viewport') - return { matches: false }; - // expect(locator).toHaveText([]) pass when there is no element. - if (options.expression === "to.have.text.array") { - if (options.expectedText.length === 0) - return { matches: true, received: [] }; - if (options.isNot && options.expectedText.length !== 0) - return { matches: false, received: [] }; - } - // When none of the above applies, expect does not match. - return { matches: options.isNot, missingReceived: true }; - } - - const handle = result[0]; - const handles = result[1]; - - if (handle.parentNode instanceof dom.ElementHandle) { - return await handle.parentNode.evaluateInUtility(async ([injected, node, { handle, options, handles }]) => { - return await injected.expect(handle, options, handles); - }, { handle, options, handles }); - } else { - return await handle.parentNode.evaluate(async (injected, { handle, options, handles }) => { - return await injected.expect(handle, options, handles); - }, { handle, options, handles }); - } - } - - if (noAbort) { - var { log, matches, received, missingReceived } = await this._retryWithoutProgress(progress, selector, {strict: !isArray, performActionPreChecks: false}, action, 'returnAll', null); - } else { - var { log, matches, received, missingReceived } = await race(this._retryWithProgressIfNotConnected(progress, selector, { strict: !isArray, performActionPreChecks: false, __patchrightSkipRetryLogWaiting: true } as any, action, 'returnAll')); - } - } else { - const world = options.expression === 'to.have.property' ? 'main' : 'utility'; - const context = await race(this._context(world)); - const injected = await race(context.injectedScript()); - var { matches, received, missingReceived } = await race(injected.evaluate(async (injected, { options, callId }) => { - return { ...await injected.expect(undefined, options, []) }; - }, { options, callId: progress.metadata.id })); - } - + if (noAbort) + progress = nullProgress; + const selectorInFrame = selector ? await progress.race(this.selectors.resolveFrameForSelector(selector, { strict: true })) : undefined; + + const { frame, info } = selectorInFrame || { frame: this, info: undefined }; + const world = options.expression === 'to.have.property' ? 'main' : (info?.world ?? 'utility'); + const context = await progress.race(frame.context(world)); + const injected = await progress.race(context.injectedScript()); + + const { log, matches, received, missingReceived } = await progress.race(injected.evaluate(async (injected, { info, options, callId }) => { + const elements = info ? injected.querySelectorAll(info.parsed, document) : []; + if (callId) + injected.markTargetElements(new Set(elements), callId); + const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); + let log = ''; + if (isArray) + log = " locator resolved to " + elements.length + " element" + (elements.length === 1 ? "" : "s"); + else if (elements.length > 1) + throw injected.strictModeViolationError(info!.parsed, elements); + else if (elements.length) + log = " locator resolved to " + injected.previewNode(elements[0]); + if (info) + injected.checkDeprecatedSelectorUsage(info.parsed, elements); + return { log, ...await injected.expect(elements[0], options, elements) }; + }, { info, options, callId })); if (log) - progress.log(log); + progressLog(log); // Note: missingReceived avoids \`unexpected value "undefined"\` when element was not found. if (matches === options.isNot) { - if (missingReceived) { - lastIntermediateResult.errorMessage = 'Error: element(s) not found'; - } else { - lastIntermediateResult.errorMessage = undefined; - lastIntermediateResult.received = received; - } + lastIntermediateResult.errorMessage = missingReceived ? 'element(s) not found' : undefined; + lastIntermediateResult.received = received; lastIntermediateResult.isSet = true; - if (!missingReceived) { - const rendered = renderUnexpectedValue(options.expression, received); - if (rendered !== undefined) - progress.log(' unexpected value "' + rendered + '"'); - } + if (!missingReceived && !Array.isArray(received?.value)) + progressLog(' unexpected value "' + renderUnexpectedValue(options.expression, received?.value) + '"'); } return { matches, received }; `); @@ -751,22 +897,144 @@ export function patchFrames(project: Project) { callOnElementOnceMatchesMethod.setBodyText(` const callbackText = body.toString(); progress.log("waiting for " + this._asLocator(selector)); + const eventInit = (taskData as any)?.eventInit; + const eventInitContainsHandle = (value: unknown): boolean => { + if (!value || typeof value !== "object") + return false; + if (value instanceof js.JSHandle) + return true; + if (Array.isArray(value)) + return value.some(eventInitContainsHandle); + return Object.values(value).some(eventInitContainsHandle); + }; + const firstEventInitHandle = (value: unknown): js.JSHandle | null => { + if (!value || typeof value !== "object") + return null; + if (value instanceof js.JSHandle) + return value; + if (Array.isArray(value)) { + for (const item of value) { + const handle = firstEventInitHandle(item); + if (handle) + return handle; + } + return null; + } + for (const propertyValue of Object.values(value)) { + const handle = firstEventInitHandle(propertyValue); + if (handle) + return handle; + } + return null; + }; + if (selector === ":scope" && scope instanceof dom.ElementHandle) { + const taskScope = firstEventInitHandle(eventInit); + if (taskScope) { + const taskScopeContext = taskScope._context; + const promise = (async () => { + const adoptedScope = scope._context === taskScopeContext ? scope : await this._page.delegate.adoptElementHandle(scope, taskScopeContext); + try { + return await taskScopeContext.evaluate(([injected, node, { callbackText: callbackText2, taskData: taskData2 }]) => { + const callback = injected.eval(callbackText2); + return callback(injected, node, taskData2); + }, [ + await taskScopeContext.injectedScript(), + adoptedScope, + { callbackText, taskData }, + ]); + } finally { + if (adoptedScope !== scope) + adoptedScope.dispose(); + } + })(); + return taskScopeContext.raceAgainstContextDestroyed(promise); + } + } + if (!options?.mainWorld && !eventInitContainsHandle(eventInit)) { + const promise = this.retryWithProgressAndBackoff(progress, async (progress, continuePolling) => { + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, options, scope)); + if (!resolved) + return continuePolling; + const { log, success, value } = await progress.race(resolved.injected.evaluate((injected, { info, callbackText, taskData, callId, root }) => { + const callback = injected.eval(callbackText); + const element = injected.querySelector(info.parsed, root || document, info.strict); + if (!element) + return { success: false }; + const log = " locator resolved to " + injected.previewNode(element); + if (callId) + injected.markTargetElements(new Set([element]), callId); + return { log, success: true, value: callback(injected, element, taskData) }; + }, { info: resolved.info, callbackText, taskData, callId: progress.metadata.id, root: resolved.frame === this ? scope : undefined })); + if (log) + progress.log(log); + if (!success) { + const fallbackResult = await this._retryWithoutProgress(progress, selector, { ...options, performActionPreChecks: false, __patchrightInitialScope: scope } as any, async (handle) => { + if (!handle) + return "internal:continuepolling"; + if (handle.parentNode instanceof dom.ElementHandle) { + return await handle.parentNode.evaluateInUtility(([injected, node, { callbackText: callbackText2, handle: handle2, taskData: taskData2 }]) => { + const callback = injected.eval(callbackText2); + return callback(injected, handle2, taskData2); + }, { + callbackText, + handle, + taskData + }); + } + return await handle.parentNode.evaluate((injected, { callbackText: callbackText2, handle: handle2, taskData: taskData2 }) => { + const callback = injected.eval(callbackText2); + return callback(injected, handle2, taskData2); + }, { + callbackText, + handle, + taskData + }); + }, 'returnOnNotResolved', continuePolling); + return fallbackResult === "internal:continuepolling" ? continuePolling : fallbackResult; + } + return value; + }); + return scope ? scope._context.raceAgainstContextDestroyed(promise) : promise; + } + if (options?.mainWorld && eventInitContainsHandle(eventInit)) { + const promise = this.retryWithProgressAndBackoff(progress, async (progress, continuePolling) => { + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, options, scope)); + if (!resolved) + return continuePolling; + const { log, success, value } = await progress.race(resolved.injected.evaluate((injected, { info, callbackText, taskData, callId, root }) => { + const callback = injected.eval(callbackText); + const element = injected.querySelector(info.parsed, root || document, info.strict); + if (!element) + return { success: false }; + const log = " locator resolved to " + injected.previewNode(element); + if (callId) + injected.markTargetElements(new Set([element]), callId); + return { log, success: true, value: callback(injected, element, taskData) }; + }, { info: resolved.info, callbackText, taskData, callId: progress.metadata.id, root: resolved.frame === this ? scope : undefined })); + if (log) + progress.log(log); + if (!success) + return continuePolling; + return value; + }); + return scope ? scope._context.raceAgainstContextDestroyed(promise) : promise; + } var promise; if (selector === ":scope") { const scopeParentNode = scope.parentNode || scope; if (scopeParentNode instanceof dom.ElementHandle) { if (options?.mainWorld) { promise = (async () => { - const mainContext = await this._mainContext(); + const mainContext = await this.mainContext(); const adoptedScope = await this._page.delegate.adoptElementHandle(scope, mainContext); try { - return await mainContext.evaluate(([injected, node, { callbackText: callbackText2, scope: handle2, taskData: taskData2 }]) => { + return await mainContext.evaluate(([injected, node, { callbackText: callbackText2, taskData: taskData2 }]) => { const callback = injected.eval(callbackText2); - return callback(injected, handle2, taskData2); + return callback(injected, node, taskData2); }, [ await mainContext.injectedScript(), adoptedScope, - { callbackText, scope: adoptedScope, taskData }, + { callbackText, taskData }, ]); } finally { adoptedScope.dispose(); @@ -775,7 +1043,7 @@ export function patchFrames(project: Project) { } else { promise = scopeParentNode.evaluateInUtility(([injected, node, { callbackText: callbackText2, scope: handle2, taskData: taskData2 }]) => { const callback = injected.eval(callbackText2); - return callback(injected, handle2, taskData2); + return callback(injected, node, taskData2); }, { callbackText, scope, @@ -794,19 +1062,19 @@ export function patchFrames(project: Project) { } } else { - promise = this._retryWithProgressIfNotConnected(progress, selector, { ...options, performActionPreChecks: false }, async (handle) => { + promise = this._retryWithProgressIfNotConnected(progress, selector, { ...options, performActionPreChecks: false }, async (progress, handle) => { if (handle.parentNode instanceof dom.ElementHandle) { if (options?.mainWorld) { - const mainContext = await handle._frame._mainContext(); + const mainContext = await handle._frame.mainContext(); const adoptedHandle = await this._page.delegate.adoptElementHandle(handle, mainContext); try { - return await mainContext.evaluate(([injected, node, { callbackText: callbackText2, handle: handle2, taskData: taskData2 }]) => { + return await mainContext.evaluate(([injected, node, { callbackText: callbackText2, taskData: taskData2 }]) => { const callback = injected.eval(callbackText2); - return callback(injected, handle2, taskData2); + return callback(injected, node, taskData2); }, [ await mainContext.injectedScript(), adoptedHandle, - { callbackText, handle: adoptedHandle, taskData }, + { callbackText, taskData }, ]); } finally { adoptedHandle.dispose(); @@ -818,13 +1086,13 @@ export function patchFrames(project: Project) { if (taskScope) { const taskScopeContext = taskScope._context; const adoptedHandle = await handle._adoptTo(taskScopeContext); - return await taskScopeContext.evaluate(([injected, node, { callbackText: callbackText2, adoptedHandle: handle2, taskData: taskData2 }]) => { + return await taskScopeContext.evaluate(([injected, node, { callbackText: callbackText2, taskData: taskData2 }]) => { const callback = injected.eval(callbackText2); - return callback(injected, handle2, taskData2); + return callback(injected, node, taskData2); }, [ await taskScopeContext.injectedScript(), adoptedHandle, - { callbackText, adoptedHandle, taskData }, + { callbackText, taskData }, ]); } @@ -848,7 +1116,7 @@ export function patchFrames(project: Project) { } }) } - return scope ? scope._context._raceAgainstContextDestroyed(promise) : promise; + return scope ? scope._context.raceAgainstContextDestroyed(promise) : promise; `); // -- _customFindElementsByParsed Method -- @@ -856,7 +1124,10 @@ export function patchFrames(project: Project) { name: "_customFindElementsByParsed", isAsync: true, parameters: [ - { name: "resolved", type: "{ injected: js.JSHandle, info: { parsed: ParsedSelector, strict: boolean }, frame: Frame, scope?: dom.ElementHandle }" }, + { + name: "resolved", + type: "{ injected: js.JSHandle, info: { parsed: ParsedSelector, strict: boolean }, frame: Frame, scope?: dom.ElementHandle }", + }, { name: "client", type: "CRSession" }, { name: "context", type: "dom.FrameExecutionContext" }, { name: "documentScope", type: "dom.ElementHandle" }, @@ -867,6 +1138,7 @@ export function patchFrames(project: Project) { const customFindElementsByParsedMethod = frameClass.getMethodOrThrow("_customFindElementsByParsed"); customFindElementsByParsedMethod.setBodyText(` var parsedEdits = { ...parsed }; + progress = progress || nullProgress; // Note: We start scoping at document level var currentScopingElements = [documentScope]; @@ -934,7 +1206,7 @@ export function patchFrames(project: Project) { // Elements Queryed in the "current round" const queryGroups: { handles: any; parentNode: any }[] = []; for (var shadowRoot of shadowRoots) { - const shadowHandles = await shadowRoot.evaluateHandleInUtility( + const shadowHandles = await (shadowRoot as any)._evaluateHandleInUtility( ([injected, node, { parsed, callId }]) => { const elements = injected.querySelectorAll(parsed, node); if (callId) @@ -949,7 +1221,7 @@ export function patchFrames(project: Project) { } // Document Root Elements (not in CSR) - const rootHandles = await scope.evaluateHandleInUtility( + const rootHandles = await (scope as any)._evaluateHandleInUtility( ([injected, node, { parsed, callId }]) => { const elements = injected.querySelectorAll(parsed, node); if (callId) @@ -964,15 +1236,16 @@ export function patchFrames(project: Project) { // Querying and Sorting the elements by their backendNodeId for (const { handles, parentNode } of queryGroups) { - const handlesAmount = await (await handles.getProperty("length")).jsonValue(); + const handlesAmount = await (await handles.getProperty(progress, "length")).jsonValue(progress); for (var i = 0; i < handlesAmount; i++) { + let element; if (parentNode instanceof dom.ElementHandle) { - var element = await parentNode.evaluateHandleInUtility( + element = await (parentNode as any)._evaluateHandleInUtility( ([injected, node, { i, handles: elems }]) => elems[i], { i, handles } ); } else { - var element = await parentNode.evaluateHandle( + element = await parentNode.evaluateHandle( (injected, { i, handles: elems }) => elems[i], { i, handles } ); @@ -1010,4 +1283,4 @@ export function patchFrames(project: Project) { return currentScopingElements; `); -} \ No newline at end of file +} diff --git a/driver_patches/index.ts b/driver_patches/index.ts index 583f3a45..d1e4e6d9 100644 --- a/driver_patches/index.ts +++ b/driver_patches/index.ts @@ -1,6 +1,6 @@ export * from "./browserContextDispatcherPatch.ts"; -export * from "./networkDispatchersPatch.ts"; export * from "./browserContextPatch.ts"; +export * from "./buildPatch.ts"; export * from "./chromiumPatch.ts"; export * from "./chromiumSwitchesPatch.ts"; export * from "./cliAliasPatch.ts"; @@ -8,16 +8,18 @@ export * from "./clockPatch.ts"; export * from "./crBrowserPatch.ts"; export * from "./crCoveragePatch.ts"; export * from "./crDevToolsPatch.ts"; +export * from "./credentialsPatch.ts"; export * from "./crNetworkManagerPatch.ts"; export * from "./crPagePatch.ts"; export * from "./crServiceWorkerPatch.ts"; -export * from "./expectUtilsPatch.ts"; export * from "./frameDispatcherPatch.ts"; export * from "./frameSelectorsPatch.ts"; export * from "./framesPatch.ts"; export * from "./javascriptPatch.ts"; export * from "./jsHandleDispatcherPatch.ts"; export * from "./launchAppPatch.ts"; +export * from "./networkDispatchersPatch.ts"; +export * from "./networkPatch.ts"; export * from "./pageBindingPatch.ts"; export * from "./pageDispatcherPatch.ts"; export * from "./pagePatch.ts"; @@ -27,4 +29,4 @@ export * from "./snapshotterInjectedPatch.ts"; export * from "./snapshotterPatch.ts"; export * from "./tracingPatch.ts"; export * from "./utilityScriptSerializersPatch.ts"; -export * from "./XPathSelectorEnginePatch.ts"; \ No newline at end of file +export * from "./XPathSelectorEnginePatch.ts"; diff --git a/driver_patches/javascriptPatch.ts b/driver_patches/javascriptPatch.ts index 4c9b7cd6..73694aaf 100644 --- a/driver_patches/javascriptPatch.ts +++ b/driver_patches/javascriptPatch.ts @@ -6,11 +6,6 @@ import type { Project } from "ts-morph"; export function patchJavascript(project: Project) { // Add source file to the project const javascriptSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/javascript.ts"); - javascriptSourceFile.addImportDeclaration({ - moduleSpecifier: "./dom", - namespaceImport: "domValue", - }); - // -------JSHandle Class ------- const jsHandleClass = javascriptSourceFile.getClassOrThrow("JSHandle"); @@ -22,23 +17,13 @@ export function patchJavascript(project: Project) { hasQuestionToken: true, }); jsHandleEvaluateExpressionMethod.replaceWithText( - jsHandleEvaluateExpressionMethod.getText().replace(/this\._context/g, "context") + jsHandleEvaluateExpressionMethod + .getText() + .replace( + /this\.internalEvaluateExpression\(expression, options, arg\)/g, + "this.internalEvaluateExpression(expression, options, arg)", + ), ); - // Initialize context with frame-specific context if needed - jsHandleEvaluateExpressionMethod.insertStatements(0, ` - let context = this._context; - if (context instanceof domValue.FrameExecutionContext) { - const frame = context.frame; - if (frame) { - if (isolatedContext === true) - context = await frame._utilityContext(); - else if (isolatedContext === false) - context = await frame._mainContext(); - } - } - if (context !== this._context && context.adoptIfNeeded(this) === null) - context = this._context; - `); // -- evaluateExpressionHandle Method -- const jsHandleEvaluateExpressionHandleMethod = jsHandleClass.getMethodOrThrow("evaluateExpressionHandle"); @@ -48,21 +33,11 @@ export function patchJavascript(project: Project) { hasQuestionToken: true, }); jsHandleEvaluateExpressionHandleMethod.replaceWithText( - jsHandleEvaluateExpressionHandleMethod.getText().replace(/this\._context/g, "context") + jsHandleEvaluateExpressionHandleMethod + .getText() + .replace( + /this\._evaluateExpressionHandle\(expression, options, arg\)/g, + "this._evaluateExpressionHandle(expression, options, arg)", + ), ); - // Initialize context with frame-specific context if needed - jsHandleEvaluateExpressionHandleMethod.insertStatements(0, ` - let context = this._context; - if (context instanceof domValue.FrameExecutionContext) { - const frame = context.frame; - if (frame) { - if (isolatedContext === true) - context = await frame._utilityContext(); - else if (isolatedContext === false) - context = await frame._mainContext(); - } - } - if (context !== this._context && context.adoptIfNeeded(this) === null) - context = this._context; - `); -} \ No newline at end of file +} diff --git a/driver_patches/jsHandleDispatcherPatch.ts b/driver_patches/jsHandleDispatcherPatch.ts index 2968e69f..340de11d 100644 --- a/driver_patches/jsHandleDispatcherPatch.ts +++ b/driver_patches/jsHandleDispatcherPatch.ts @@ -6,7 +6,9 @@ import { assertDefined } from "./utils.ts"; // ---------------------------------------- export function patchJSHandleDispatcher(project: Project) { // Add source file to the project - const jsHandleDispatcherSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/dispatchers/jsHandleDispatcher.ts"); + const jsHandleDispatcherSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/dispatchers/jsHandleDispatcher.ts", + ); // ------- workerDispatcher Class ------- const jsHandleDispatcherClass = jsHandleDispatcherSourceFile.getClassOrThrow("JSHandleDispatcher"); @@ -19,8 +21,8 @@ export function patchJSHandleDispatcher(project: Project) { const jsHandleDispatcherEvaluateExpressionCall = assertDefined( jsHandleDispatcherEvaluateMethod .getDescendantsOfKind(SyntaxKind.CallExpression) - .find(call => call.getExpression().getText().includes("this._object.evaluateExpression")) + .find(call => call.getExpression().getText().includes("this._object.evaluateExpression")), ); jsHandleDispatcherEvaluateExpressionCall.addArgument("params.isolatedContext"); } -} \ No newline at end of file +} diff --git a/driver_patches/launchAppPatch.ts b/driver_patches/launchAppPatch.ts index 61906933..d14874e9 100644 --- a/driver_patches/launchAppPatch.ts +++ b/driver_patches/launchAppPatch.ts @@ -6,7 +6,7 @@ import type { Project } from "ts-morph"; export function patchLaunchApp(project: Project) { // Add source file to the project const launchAppSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/launchApp.ts"); - + // ------- syncLocalStorageWithSettings Function ------- const syncLocalStorageWithSettings = launchAppSourceFile.getFunctionOrThrow("syncLocalStorageWithSettings"); // Add a type check before calling _saveSerializedSettings to prevent runtime errors when the function is not defined @@ -15,6 +15,6 @@ export function patchLaunchApp(project: Project) { `(window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage }));`, `if (typeof (window as any)._saveSerializedSettings === 'function') (window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage }));`, - ) + ), ); } diff --git a/driver_patches/networkDispatchersPatch.ts b/driver_patches/networkDispatchersPatch.ts index f2048bd4..34057ad1 100644 --- a/driver_patches/networkDispatchersPatch.ts +++ b/driver_patches/networkDispatchersPatch.ts @@ -6,7 +6,9 @@ import { assertDefined } from "./utils.ts"; // ---------------------------------------- export function patchNetworkDispatchers(project: Project) { // Add source file to the project - const networkDispatchersSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/dispatchers/networkDispatchers.ts"); + const networkDispatchersSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/dispatchers/networkDispatchers.ts", + ); // ------- RouteDispatcher Class ------- const routeDispatcherClass = networkDispatchersSourceFile.getClassOrThrow("RouteDispatcher"); @@ -14,10 +16,9 @@ export function patchNetworkDispatchers(project: Project) { // -- continue Method -- const continueMethod = routeDispatcherClass.getMethodOrThrow("continue"); const continueCall = assertDefined( - continueMethod.getFirstDescendant(node => - node.isKind(SyntaxKind.CallExpression) && - node.getExpression().getText() === "this._object.continue" - ) + continueMethod.getFirstDescendant( + node => node.isKind(SyntaxKind.CallExpression) && node.getExpression().getText() === "this._object.continue", + ), ).asKindOrThrow(SyntaxKind.CallExpression); const continueOptions = continueCall.getArguments()[0].asKindOrThrow(SyntaxKind.ObjectLiteralExpression); if (!continueOptions.getProperty("patchrightInitScript")) { diff --git a/driver_patches/networkPatch.ts b/driver_patches/networkPatch.ts new file mode 100644 index 00000000..c57d0517 --- /dev/null +++ b/driver_patches/networkPatch.ts @@ -0,0 +1,33 @@ +import { type Project, Scope } from "ts-morph"; + +// ----------------- +// server/network.ts +// ----------------- +export function patchNetwork(project: Project) { + // Add source file to the project + const networkSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/network.ts"); + + // ------- Request Class ------- + const requestClass = networkSourceFile.getClassOrThrow("Request"); + + // -- _rawRequestHeaders Property -- + const rawHeadersPromise = requestClass.getPropertyOrThrow("_rawRequestHeadersPromise"); + requestClass.insertProperty(requestClass.getMembers().indexOf(rawHeadersPromise), { + name: "_rawRequestHeaders", + type: "HeadersArray | undefined", + scope: Scope.Private, + }); + + // -- setRawRequestHeaders Method -- + requestClass.getMethodOrThrow("setRawRequestHeaders").setBodyText(` + const rawHeaders = headers || this._headers; + this._rawRequestHeaders = rawHeaders; + if (!this._rawRequestHeadersPromise.isDone()) + this._rawRequestHeadersPromise.resolve(rawHeaders); + `); + + // -- internalRawRequestHeaders Method -- + requestClass.getMethodOrThrow("internalRawRequestHeaders").setBodyText(` + return this._overrides?.headers || this._rawRequestHeaders || this._rawRequestHeadersPromise; + `); +} diff --git a/driver_patches/pageBindingPatch.ts b/driver_patches/pageBindingPatch.ts index 74f31fc5..6112a58b 100644 --- a/driver_patches/pageBindingPatch.ts +++ b/driver_patches/pageBindingPatch.ts @@ -97,5 +97,7 @@ export function patchPageBinding(project: Project) { } `; - project.createSourceFile("packages/playwright-core/src/server/pageBinding.ts", pageBindingSourceContent, { overwrite: true }); -} \ No newline at end of file + project.createSourceFile("packages/playwright-core/src/server/pageBinding.ts", pageBindingSourceContent, { + overwrite: true, + }); +} diff --git a/driver_patches/pageDispatcherPatch.ts b/driver_patches/pageDispatcherPatch.ts index 2255988f..2ccb2423 100644 --- a/driver_patches/pageDispatcherPatch.ts +++ b/driver_patches/pageDispatcherPatch.ts @@ -5,7 +5,9 @@ import { type Project, SyntaxKind } from "ts-morph"; // ------------------------------------ export function patchPageDispatcher(project: Project) { // Add source file to the project - const pageDispatcherSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/dispatchers/pageDispatcher.ts"); + const pageDispatcherSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/dispatchers/pageDispatcher.ts", + ); // ------- workerDispatcher Class ------- const workerDispatcherClass = pageDispatcherSourceFile.getClassOrThrow("WorkerDispatcher"); @@ -17,9 +19,9 @@ export function patchPageDispatcher(project: Project) { const workerDispatcherEvaluateCall = workerDispatcherEvaluateMethod .getFirstDescendantByKindOrThrow(SyntaxKind.ReturnStatement) .getFirstDescendantByKindOrThrow(SyntaxKind.CallExpression) - .getFirstDescendantByKindOrThrow(SyntaxKind.CallExpression) + .getFirstDescendantByKindOrThrow(SyntaxKind.CallExpression); // Forward the isolatedContext param from the dispatcher to the underlying evaluateExpression call. if (workerDispatcherEvaluateCall.getExpression().getText().includes("this._object.evaluateExpression")) workerDispatcherEvaluateCall.addArgument("params.isolatedContext"); } -} \ No newline at end of file +} diff --git a/driver_patches/pagePatch.ts b/driver_patches/pagePatch.ts index 5c7c7ecc..fa07a990 100644 --- a/driver_patches/pagePatch.ts +++ b/driver_patches/pagePatch.ts @@ -7,6 +7,9 @@ import { assertDefined } from "./utils.ts"; export function patchPage(project: Project) { // Add source file to the project const pageSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/page.ts"); + const selectorParserImport = pageSourceFile.getImportDeclarationOrThrow("@isomorphic/selectorParser"); + if (!selectorParserImport.getNamedImports().some(namedImport => namedImport.getName() === "splitSelectorByFrame")) + selectorParserImport.addNamedImport("splitSelectorByFrame"); pageSourceFile.addImportDeclaration({ moduleSpecifier: "./dom", namespaceImport: "domValue", @@ -27,7 +30,7 @@ export function patchPage(project: Project) { throw new Error(\`Function "\${name}" has been already registered\`); if (this.browserContext._pageBindings.has(name)) throw new Error(\`Function "\${name}" has been already registered in the browser context\`); - const binding = new PageBinding(this, name, playwrightBinding, needsHandle); + const binding = new PageBinding(this, name, playwrightBinding, false); this._pageBindings.set(name, binding); await this.delegate.exposeBinding(binding); return binding; @@ -45,7 +48,6 @@ export function patchPage(project: Project) { return [...this.browserContext._pageBindings.values(), ...this._pageBindings.values()]; `); - // ------- PageBinding Class ------- const pageBindingClass = pageSourceFile.getClassOrThrow("PageBinding"); // Content modified from https://raw.githubusercontent.com/microsoft/playwright/471930b1ceae03c9e66e0eb80c1364a1a788e7db/packages/playwright-core/src/server/page.ts @@ -85,8 +87,8 @@ export function patchPage(project: Project) { debugLogger.log('error', deliveryError); return; } - const mainContext = await frame._mainContext().catch(() => null); - const utilityContext = await frame._utilityContext().catch(() => null); + const mainContext = await frame.mainContext().catch(() => null); + const utilityContext = await frame.utilityContext().catch(() => null); for (const ctx of [mainContext, utilityContext]) { if (!ctx || ctx === context) continue; @@ -131,12 +133,10 @@ export function patchPage(project: Project) { const initScriptClass = pageSourceFile.getClassOrThrow("InitScript"); // -- InitScript Constructor -- const initScriptConstructorAssignment = assertDefined( - initScriptClass.getConstructors()[0] + initScriptClass + .getConstructors()[0] .getStatements() - .find(s => - s.getKind() === SyntaxKind.ExpressionStatement && - s.getText().includes("this.source = `(() => {") - ) + .find(s => s.getKind() === SyntaxKind.ExpressionStatement && s.getText().includes("this.source = `(() => {")), ); initScriptConstructorAssignment.replaceWithText("this.source = `(() => { ${source} })();`;"); @@ -152,7 +152,7 @@ export function patchPage(project: Project) { hasQuestionToken: true, }); workerEvaluateMethod.replaceWithText( - workerEvaluateMethod.getText().replace(/await this\._executionContextPromise/g, "context") + workerEvaluateMethod.getText().replace(/await this\._executionContextPromise/g, "context"), ); // Insert the new line of code after the responseAwaitStatement workerEvaluateMethod.insertStatements(0, ` @@ -160,10 +160,26 @@ export function patchPage(project: Project) { if (context instanceof domValue.FrameExecutionContext) { const frame = context.frame; if (frame) { - if (isolatedContext) context = await frame._utilityContext(); - else if (!isolatedContext) context = await frame._mainContext(); + if (isolatedContext) context = await frame.utilityContext(); + else if (!isolatedContext) context = await frame.mainContext(); } } `); } -} \ No newline at end of file + + const pagePerformLocatorHandlersCheckpointMethod = pageClass.getMethodOrThrow("_performLocatorHandlersCheckpoint"); + const waitForHiddenStatement = pagePerformLocatorHandlersCheckpointMethod + .getDescendantsOfKind(SyntaxKind.ExpressionStatement) + .find( + statement => + statement.getText() === + "await this.mainFrame().waitForSelector(progress, handler.selector, false, { state: 'hidden' });", + ); + if (waitForHiddenStatement) + waitForHiddenStatement.replaceWithText(` + const frameChunks = splitSelectorByFrame(handler.selector); + if (frameChunks.length > 1 && !await this.mainFrame().isVisibleInternal(progress, stringifySelector(frameChunks[0]), { strict: true })) + return; + await this.mainFrame().waitForSelector(progress, handler.selector, false, { state: 'hidden' }); + `); +} diff --git a/driver_patches/recorderPatch.ts b/driver_patches/recorderPatch.ts index b088b9ee..60911c4a 100644 --- a/driver_patches/recorderPatch.ts +++ b/driver_patches/recorderPatch.ts @@ -15,11 +15,12 @@ export function patchRecorder(project: Project) { // Add try-catch block around the existing React.useEffect body const useEffectCall = assertDefined( - recorderFn.getDescendantsOfKind(SyntaxKind.CallExpression).find(call => call.getExpression().getText() === "React.useEffect") + recorderFn + .getDescendantsOfKind(SyntaxKind.CallExpression) + .find(call => call.getExpression().getText() === "React.useEffect"), ); useEffectCall .getArguments()[0] .asKindOrThrow(SyntaxKind.ArrowFunction) - .setBodyText(`try { window.dispatch({ event: 'setAutoExpect', params: { autoExpect } }); } catch {} `); - + .setBodyText(`try { window.dispatch({ event: 'setAutoExpect', params: { autoExpect } }); } catch {}`); } diff --git a/driver_patches/screenshotterPatch.ts b/driver_patches/screenshotterPatch.ts index dcdfc68b..0323f120 100644 --- a/driver_patches/screenshotterPatch.ts +++ b/driver_patches/screenshotterPatch.ts @@ -17,14 +17,18 @@ export function patchScreenshotter(project: Project) { const safeEvalStatement = assertDefined( prepareMethod .getDescendantsOfKind(SyntaxKind.ExpressionStatement) - .find((s) => s.getText().includes("safeNonStallingEvaluateInAllFrames") && s.getText().includes("inPagePrepareForScreenshots")) + .find( + s => + s.getText().includes("safeNonStallingEvaluateInAllFrames") && + s.getText().includes("inPagePrepareForScreenshots"), + ), ); - safeEvalStatement - .getParentIfKindOrThrow(SyntaxKind.Block) - .insertStatements(safeEvalStatement.getChildIndex(), ` + safeEvalStatement.getParentIfKindOrThrow(SyntaxKind.Block).insertStatements( + safeEvalStatement.getChildIndex(), + ` await Promise.all(this._page.frames().map(async (f: any) => { - try { await f._utilityContext(); } catch {} + try { await f.utilityContext(); } catch {} })); - `); - + `, + ); } diff --git a/driver_patches/snapshotterInjectedPatch.ts b/driver_patches/snapshotterInjectedPatch.ts index d4876976..e3747752 100644 --- a/driver_patches/snapshotterInjectedPatch.ts +++ b/driver_patches/snapshotterInjectedPatch.ts @@ -6,7 +6,9 @@ import { assertDefined } from "./utils.ts"; // -------------------------------------------- export function patchSnapshotterInjected(project: Project) { // Add source file to the project - const snapshotterInjectedSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts"); + const snapshotterInjectedSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts", + ); // ------- frameSnapshotStreamer Function ------- const frameSnapshotStreamerFunction = snapshotterInjectedSourceFile.getFunctionOrThrow("frameSnapshotStreamer"); @@ -14,7 +16,7 @@ export function patchSnapshotterInjected(project: Project) { const streamerClass = assertDefined( frameSnapshotStreamerFunction .getDescendantsOfKind(SyntaxKind.ClassDeclaration) - .find(c => c.getName() === "Streamer") + .find(c => c.getName() === "Streamer"), ); // Remove CSS monkey-patches from constructor @@ -29,7 +31,9 @@ export function patchSnapshotterInjected(project: Project) { .getStatements() .filter(stmt => CTOR_STATEMENTS_TO_REMOVE.some(term => stmt.getText().includes(term))) .reverse() - .forEach(stmt => { stmt.remove() }); + .forEach(stmt => { + stmt.remove(); + }); // Remove properties and methods related to CSS interception streamerClass.getPropertyOrThrow("_staleStyleSheets").remove(); @@ -87,19 +91,15 @@ export function patchSnapshotterInjected(project: Project) { const captureMethod = streamerClass.getMethodOrThrow("captureSnapshot"); // iterate document.styleSheets instead of _modifiedStyleSheets const forOfStatement = assertDefined( - captureMethod - .getStatements() - .find(s => s.getText().includes("this._modifiedStyleSheets")) + captureMethod.getStatements().find(s => s.getText().includes("this._modifiedStyleSheets")), ); forOfStatement.replaceWithText(forOfStatement.getText().replace("this._modifiedStyleSheets", "document.styleSheets")); - // -- reset Method -- - const resetMethod = streamerClass.getMethodOrThrow("reset"); + // -- resetHistory Method -- + const resetMethod = streamerClass.getMethodOrThrow("resetHistory"); // remove _staleStyleSheets.clear() const staleStylesheetsClearStatement = assertDefined( - resetMethod - .getStatements() - .find(s => s.getText().includes("this._staleStyleSheets.clear();")) + resetMethod.getStatements().find(s => s.getText().includes("this._staleStyleSheets.clear();")), ); staleStylesheetsClearStatement.remove(); -} \ No newline at end of file +} diff --git a/driver_patches/snapshotterPatch.ts b/driver_patches/snapshotterPatch.ts index 85de87f0..61770481 100644 --- a/driver_patches/snapshotterPatch.ts +++ b/driver_patches/snapshotterPatch.ts @@ -6,22 +6,22 @@ import { assertDefined } from "./utils.ts"; // ------------------------------------ export function patchSnapshotter(project: Project) { // Add source file to the project - const snapshotterSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/trace/recorder/snapshotter.ts"); + const snapshotterSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/trace/recorder/snapshotter.ts", + ); // Remove unneeded imports const initScriptImport = assertDefined( snapshotterSourceFile .getImportDeclarations() - .find(imp => imp.getNamedImports().some(n => n.getName() === "InitScript")) + .find(imp => imp.getNamedImports().some(n => n.getName() === "InitScript")), ); initScriptImport.remove(); - + // ------- Snapshotter Class ------- const snapshotterClass = snapshotterSourceFile.getClassOrThrow("Snapshotter"); // Replace _initScript type, add _initScriptSource, remove InitScript import - snapshotterClass - .getPropertyOrThrow("_initScript") - .setType("boolean | undefined"); + snapshotterClass.getPropertyOrThrow("_initScript").setType("boolean | undefined"); snapshotterClass.addProperty({ name: "_initScriptSource", type: "string | undefined", @@ -31,9 +31,7 @@ export function patchSnapshotter(project: Project) { const resetMethod = snapshotterClass.getMethodOrThrow("reset"); // switch from 'main' to 'utility' world const mainWorldLiteral = assertDefined( - resetMethod - .getDescendantsOfKind(SyntaxKind.StringLiteral) - .find(s => s.getLiteralText() === "main") + resetMethod.getDescendantsOfKind(SyntaxKind.StringLiteral).find(s => s.getLiteralText() === "main"), ); mainWorldLiteral.replaceWithText("'utility'"); @@ -69,7 +67,7 @@ export function patchSnapshotter(project: Project) { const rawEvaluateCall = assertDefined( captureFrameMethod .getDescendantsOfKind(SyntaxKind.CallExpression) - .find(c => c.getText().includes("nonStallingRawEvaluateInExistingMainContext")) + .find(c => c.getText().includes("nonStallingRawEvaluateInExistingMainContext")), ); rawEvaluateCall.replaceWithText("frame.nonStallingEvaluateInExistingContext(expression, 'utility')"); @@ -77,7 +75,7 @@ export function patchSnapshotter(project: Project) { const onPageMethod = snapshotterClass.getMethodOrThrow("_onPage"); // re-inject streamer script on navigation onPageMethod.addStatements( - "this._eventListeners.push(eventsHelper.addEventListener(page, Page.Events.InternalFrameNavigatedToNewDocument, (frame: Frame) => this._onFrameNavigated(frame)));" + "this._eventListeners.push(eventsHelper.addEventListener(page, Page.Events.InternalFrameNavigatedToNewDocument, (frame: Frame) => this._onFrameNavigated(frame)));", ); // -- _onFrameNavigated Method -- @@ -100,9 +98,7 @@ export function patchSnapshotter(project: Project) { const annotateMethod = snapshotterClass.getMethodOrThrow("_annotateFrameHierarchy"); // use utility context instead of main const mainContextIdentifier = assertDefined( - annotateMethod - .getDescendantsOfKind(SyntaxKind.Identifier) - .find(id => id.getText() === "_mainContext") + annotateMethod.getDescendantsOfKind(SyntaxKind.Identifier).find(id => id.getText() === "mainContext"), ); - mainContextIdentifier.replaceWithText("_utilityContext"); -} \ No newline at end of file + mainContextIdentifier.replaceWithText("utilityContext"); +} diff --git a/driver_patches/tracingPatch.ts b/driver_patches/tracingPatch.ts index a984f092..02bf4842 100644 --- a/driver_patches/tracingPatch.ts +++ b/driver_patches/tracingPatch.ts @@ -5,13 +5,20 @@ import { type Project, SyntaxKind } from "ts-morph"; // -------------------------------- export function patchTracing(project: Project) { // Add source file to the project - const tracingSourceFile = project.addSourceFileAtPath("packages/playwright-core/src/server/trace/recorder/tracing.ts"); + const tracingSourceFile = project.addSourceFileAtPath( + "packages/playwright-core/src/server/trace/recorder/tracing.ts", + ); // ------- createBeforeActionTraceEvent Function ------- // ------- createInputActionTraceEvent Function ------- // ------- createActionLogTraceEvent Function ------- // ------- createAfterActionTraceEvent Function ------- - const eventFunctionNamesToPatch = ["createBeforeActionTraceEvent", "createInputActionTraceEvent", "createActionLogTraceEvent", "createAfterActionTraceEvent"]; + const eventFunctionNamesToPatch = [ + "createBeforeActionTraceEvent", + "createInputActionTraceEvent", + "createActionLogTraceEvent", + "createAfterActionTraceEvent", + ]; for (const eventFunctionName of eventFunctionNamesToPatch) { const eventFunction = tracingSourceFile.getFunctionOrThrow(eventFunctionName); // We want to ignore Patchright-Internal Route.continue Calls in the Tracing diff --git a/driver_patches/utilityScriptSerializersPatch.ts b/driver_patches/utilityScriptSerializersPatch.ts index e729dc95..98b7addb 100644 --- a/driver_patches/utilityScriptSerializersPatch.ts +++ b/driver_patches/utilityScriptSerializersPatch.ts @@ -301,6 +301,6 @@ export function patchUtilityScriptSerializers(project: Project) { project.createSourceFile( "packages/playwright-core/src/utils/isomorphic/oldUtilityScriptSerializers.ts", oldUtilityScriptSerializerSourceContent, - { overwrite: true } + { overwrite: true }, ); -} \ No newline at end of file +} diff --git a/driver_patches/utils.ts b/driver_patches/utils.ts index 2c37b926..8fcc6345 100644 --- a/driver_patches/utils.ts +++ b/driver_patches/utils.ts @@ -1,4 +1,4 @@ export function assertDefined(value: T | undefined | null, name?: string): T { if (value == null) throw new Error(`Required value${name ? ` "${name}"` : ""} is null or undefined`); return value; -} \ No newline at end of file +} diff --git a/package.json b/package.json index c6720b24..23ca44d7 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,11 @@ "patchright-nodejs:update": "git -C . submodule update --init --recursive --remote patchright-nodejs", "typecheck": "tsc --noEmit", "pretypecheck": "git -C . submodule update --init --recursive patchright-nodejs", - "format:check": "bash -c 'biome check --line-width=320 --formatter-enabled=false --diagnostic-level=error ${@:-patchright_driver_patch.ts driver_patches/}' --", + "format:check": "bash -c 'tsx utils/format-indentation.ts --check ${@:-patchright_driver_patch.ts driver_patches/} && git diff --check -- ${@:-patchright_driver_patch.ts driver_patches/} && biome check --line-width=120 --formatter-enabled=false --diagnostic-level=error ${@:-patchright_driver_patch.ts driver_patches/}' --", "format:fix": "tsx utils/format-indentation.ts", - "format": "bash -c 'npm run format:fix -- ${@} && npm run format:check -- ${@}' --" + "format": "bash -c 'npm run format:fix -- ${@} && npm run format:check -- ${@}' --", + "rebuild": "bash utils/rebuild_local_package.sh", + "test": "bash utils/test_local_package.sh" }, "devDependencies": { "@biomejs/biome": "^2.4.10", diff --git a/patchright.patch b/patchright.patch index ee11eb59..8dfde7a7 100644 --- a/patchright.patch +++ b/patchright.patch @@ -22,42 +22,488 @@ diff -ruN -x protocol.yml --minimal playwright/node_modules/playwright-core/src/ -/* eslint-disable no-console */ - import '../bootstrap'; - import { gracefullyProcessExitDoNotHang, getPackageManagerExecCommand } from '../utils'; - import { addTraceCommands } from '../tools/trace/traceCli'; -@@ -69,10 +51,10 @@ + import { program } from 'commander'; + import { gracefullyProcessExitDoNotHang } from '@utils/processLauncher'; +@@ -34,89 +16,123 @@ + import type { Command } from 'commander'; - program - .command('install [browser...]') -- .description('ensure browsers necessary for this version of Playwright are installed') + export function decorateProgram(program: Command) { +- + program +- .version('Version ' + (process.env.PW_CLI_DISPLAY_VERSION || packageJSON.version)) +- .name(buildBasePlaywrightCLICommand(process.env.PW_LANG_NAME)); ++ .version('Version ' + (process.env.PW_CLI_DISPLAY_VERSION || packageJSON.version)) ++ .name(buildBasePlaywrightCLICommand(process.env.PW_LANG_NAME)); + + program +- .command('mark-docker-image [dockerImageNameTemplate]', { hidden: true }) +- .description('mark docker image') +- .allowUnknownOption(true) +- .action(async function(dockerImageNameTemplate) { +- markDockerImage(dockerImageNameTemplate).catch(logErrorAndExit); +- }); ++ .command('mark-docker-image [dockerImageNameTemplate]', { hidden: true }) ++ .description('mark docker image') ++ .allowUnknownOption(true) ++ .action(async function (dockerImageNameTemplate) { ++ markDockerImage(dockerImageNameTemplate).catch(logErrorAndExit); ++ }); + + commandWithOpenOptions('open [url]', 'open page in browser specified via -b, --browser', []) +- .action(async function(url, options) { +- open(options, url).catch(logErrorAndExit); +- }) +- .addHelpText('afterAll', ` ++ .action(async function (url, options) { ++ open(options, url).catch(logErrorAndExit); ++ }) ++ .addHelpText( ++ 'afterAll', ++ ` + Examples: + + $ open +- $ open -b webkit https://example.com`); ++ $ open -b webkit https://example.com`, ++ ); + +- commandWithOpenOptions('codegen [url]', 'open page and generate code for user actions', +- [ +- ['-o, --output ', 'saves the generated script to a file'], +- ['--target ', `language to generate, one of javascript, playwright-test, python, python-async, python-pytest, csharp, csharp-mstest, csharp-nunit, csharp-xunit, java, java-junit`, codegenId()], +- ['--test-id-attribute ', 'use the specified attribute to generate data test ID selectors'], +- ]).action(async function(url, options) { +- await codegen(options, url); +- }).addHelpText('afterAll', ` ++ commandWithOpenOptions('codegen [url]', 'open page and generate code for user actions', [ ++ ['-o, --output ', 'saves the generated script to a file'], ++ [ ++ '--target ', ++ `language to generate, one of javascript, playwright-test, python, python-async, python-pytest, csharp, csharp-mstest, csharp-nunit, csharp-xunit, java, java-junit`, ++ codegenId(), ++ ], ++ ['--test-id-attribute ', 'use the specified attribute to generate data test ID selectors'], ++ ]) ++ .action(async function (url, options) { ++ await codegen(options, url); ++ }) ++ .addHelpText( ++ 'afterAll', ++ ` + Examples: + + $ codegen + $ codegen --target=python +- $ codegen -b webkit https://example.com`); ++ $ codegen -b webkit https://example.com`, ++ ); + + program +- .command('install [browser...]') +- .description('ensure browsers necessary for this version of Playwright are installed') +- .option('--with-deps', 'install system dependencies for browsers') +- .option('--dry-run', 'do not execute installation, only print information') +- .option('--list', 'prints list of browsers from all playwright installations') +- .option('--force', 'force reinstall of already installed browsers') +- .option('--only-shell', 'only install headless shell when installing chromium') +- .option('--no-shell', 'do not install chromium headless shell') +- .option('--no-progress', 'do not show download progress bars') +- .action(async function(args: string[], options: { withDeps?: boolean, force?: boolean, dryRun?: boolean, list?: boolean, shell?: boolean, noShell?: boolean, onlyShell?: boolean, progress?: boolean }) { +- try { +- await installBrowsers(args, options); +- } catch (e) { +- console.log(`Failed to install browsers\n${e}`); +- gracefullyProcessExitDoNotHang(1); +- } +- }).addHelpText('afterAll', ` ++ .command('install [browser...]') + .description('ensure browsers necessary for this version of Patchright are installed') - .option('--with-deps', 'install system dependencies for browsers') - .option('--dry-run', 'do not execute installation, only print information') -- .option('--list', 'prints list of browsers from all playwright installations') ++ .option('--with-deps', 'install system dependencies for browsers') ++ .option('--dry-run', 'do not execute installation, only print information') + .option('--list', 'prints list of browsers from all patchright installations') - .option('--force', 'force reinstall of already installed browsers') - .option('--only-shell', 'only install headless shell when installing chromium') - .option('--no-shell', 'do not install chromium headless shell') -@@ -95,8 +77,8 @@ - - program - .command('uninstall') -- .description('Removes browsers used by this installation of Playwright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels.') -- .option('--all', 'Removes all browsers used by any Playwright installation from the system.') -+ .description('Removes browsers used by this installation of Patchright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels.') ++ .option('--force', 'force reinstall of already installed browsers') ++ .option('--only-shell', 'only install headless shell when installing chromium') ++ .option('--no-shell', 'do not install chromium headless shell') ++ .option('--no-progress', 'do not show download progress bars') ++ .action(async function ( ++ args: string[], ++ options: { ++ withDeps?: boolean; ++ force?: boolean; ++ dryRun?: boolean; ++ list?: boolean; ++ shell?: boolean; ++ noShell?: boolean; ++ onlyShell?: boolean; ++ progress?: boolean; ++ }, ++ ) { ++ try { ++ await installBrowsers(args, options); ++ } catch (e) { ++ console.log(`Failed to install browsers\n${e}`); ++ gracefullyProcessExitDoNotHang(1); ++ } ++ }) ++ .addHelpText( ++ 'afterAll', ++ ` + + Examples: + - $ install + Install default browsers. + + - $ install chrome firefox +- Install custom browsers, supports chromium, firefox, webkit, chromium-headless-shell.`); ++ Install custom browsers, supports chromium, firefox, webkit, chromium-headless-shell.`, ++ ); + + program +- .command('uninstall') +- .description('Removes browsers used by this installation of Playwright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels.') +- .option('--all', 'Removes all browsers used by any Playwright installation from the system.') +- .action(async (options: { all?: boolean }) => { +- uninstallBrowsers(options).catch(logErrorAndExit); +- }); ++ .command('uninstall') ++ .description( ++ 'Removes browsers used by this installation of Patchright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels.', ++ ) + .option('--all', 'Removes all browsers used by any Patchright installation from the system.') - .action(async (options: { all?: boolean }) => { - const { uninstallBrowsers } = await import('./installActions'); - uninstallBrowsers(options).catch(logErrorAndExit); -@@ -284,7 +266,7 @@ - .option('--save-har-glob ', 'filter entries in the HAR by matching url against this glob pattern') - .option('--save-storage ', 'save context storage state at the end, for later use with --load-storage') - .option('--timezone