``` ├── .editorconfig ├── .gitattributes ├── .github/ ├── ISSUE_TEMPLATE/ ├── bug.yml ├── config.yml ├── documentation.yml ├── feature.yml ├── question.yml ├── regression.yml ├── actions/ ├── download-artifact/ ├── action.yml ├── enable-microphone-access/ ├── action.yml ├── run-test/ ├── action.yml ├── upload-blob-report/ ├── action.yml ├── dependabot.yml ├── workflows/ ├── cherry_pick_into_release_branch.yml ├── create_test_report.yml ├── infra.yml ├── merge.config.ts ├── pr_check_client_side_changes.yml ├── publish_canary.yml ├── publish_release_docker.yml ├── publish_release_driver.yml ├── publish_release_npm.yml ├── publish_release_traceviewer.yml ├── roll_browser_into_playwright.yml ├── roll_driver_nodejs.yml ├── tests_bidi.yml ├── tests_components.yml ├── tests_others.yml ├── tests_primary.yml ├── tests_secondary.yml ├── tests_video.yml ├── trigger_tests.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FILING_ISSUES.md ├── LICENSE ├── NOTICE ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── browser_patches/ ├── firefox/ ├── .gitignore ├── UPSTREAM_CONFIG.sh ├── juggler/ ├── Helper.js ├── JugglerFrameParent.jsm ├── NetworkObserver.js ├── SimpleChannel.js ├── TargetRegistry.js ├── components/ ├── Juggler.js ├── components.conf ├── moz.build ├── content/ ├── FrameTree.js ├── JugglerFrameChild.jsm ├── PageAgent.js ``` ## /.editorconfig ```editorconfig path="/.editorconfig" root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true ``` ## /.gitattributes ```gitattributes path="/.gitattributes" # text files must be lf for golden file tests to work * text=auto eol=lf # make project show as TS on GitHub *.js linguist-detectable=false ``` ## /.github/ISSUE_TEMPLATE/bug.yml ```yml path="/.github/ISSUE_TEMPLATE/bug.yml" name: Bug Report 🪲 description: Create a bug report to help us improve title: '[Bug]: ' body: - type: markdown attributes: value: | # Please follow these steps first: - type: markdown attributes: value: | ## Troubleshoot If Playwright is not behaving the way you expect, we'd ask you to look at the [documentation](https://playwright.dev/docs/intro) and search the issue tracker for evidence supporting your expectation. Please make reasonable efforts to troubleshoot and rule out issues with your code, the configuration, or any 3rd party libraries you might be using. Playwright offers [several debugging tools](https://playwright.dev/docs/debug) that you can use to troubleshoot your issues. - type: markdown attributes: value: | ## Ask for help through appropriate channels If you feel unsure about the cause of the problem, consider asking for help on for example [StackOverflow](https://stackoverflow.com/questions/ask) or our [Discord channel](https://aka.ms/playwright/discord) before posting a bug report. The issue tracker is not a help forum. - type: markdown attributes: value: | ## Make a minimal reproduction To file the report, you will need a GitHub repository with a minimal (but complete) example and simple/clear steps on how to reproduce the bug. The simpler you can make it, the more likely we are to successfully verify and fix the bug. You can create a new project with `npm init playwright@latest new-project` and then add the test code there. Please make sure you only include the code and the dependencies absolutely necessary for your repro. Due to the security considerations, we can only run the code we trust. Major web frameworks are Ok to use, but smaller convenience libraries are not. - type: markdown attributes: value: | > [!IMPORTANT] > Bug reports without a minimal reproduction will be rejected. --- - type: input id: version attributes: label: Version description: | The version of Playwright you are using. Is it the [latest](https://github.com/microsoft/playwright/releases)? Test and see if the bug has already been fixed. placeholder: ex. 1.41.1 validations: required: true - type: textarea id: reproduction attributes: label: Steps to reproduce description: Please link to a repository with a minimal reproduction and describe accurately how we can reproduce/verify the bug. placeholder: | Example steps (replace with your own): 1. Clone my repo at https://github.com//example 2. npm install 3. npm run test 4. You should see the error come up validations: required: true - type: textarea id: expected attributes: label: Expected behavior description: A description of what you expect to happen. placeholder: I expect to see X or Y validations: required: true - type: textarea id: what-happened attributes: label: Actual behavior description: | A clear and concise description of the unexpected behavior. Please include any relevant output here, especially any error messages. placeholder: A bug happened! validations: required: true - type: textarea id: context attributes: label: Additional context description: Anything else that might be relevant validations: required: false - type: textarea id: envinfo attributes: label: Environment description: | Please paste the output of running `npx envinfo --preset playwright`. This will be automatically formatted as a code block, so no need for backticks. placeholder: | System: OS: Linux 6.2 Ubuntu 22.04.3 LTS 22.04.3 LTS (Jammy Jellyfish) CPU: (8) arm64 Binaries: Node: 18.19.0 - ~/.nvm/versions/node/v18.19.0/bin/node npm: 10.2.3 - ~/.nvm/versions/node/v18.19.0/bin/npm npmPackages: @playwright/test: 1.41.1 => 1.41.1 render: shell validations: required: true ``` ## /.github/ISSUE_TEMPLATE/config.yml ```yml path="/.github/ISSUE_TEMPLATE/config.yml" blank_issues_enabled: false contact_links: - name: Join our Discord Server url: https://aka.ms/playwright/discord about: Ask questions and discuss with other community members ``` ## /.github/ISSUE_TEMPLATE/documentation.yml ```yml path="/.github/ISSUE_TEMPLATE/documentation.yml" name: Documentation 📖 description: Submit a request to add or update documentation title: '[Docs]: ' labels: ['Documentation :book:'] body: - type: markdown attributes: value: | ### Thank you for helping us improve our documentation! Please be sure you are looking at [the Next version of the documentation](https://playwright.dev/docs/next/intro) before opening an issue here. - type: textarea id: links attributes: label: Page(s) description: | Links to one or more documentation pages that should be modified. If you are reporting an issue with a specific section of a page, try to link directly to the nearest anchor. If you are suggesting that a new page be created, link to the parent of the proposed page. validations: required: true - type: textarea id: description attributes: label: Description description: | Describe the change you are requesting. If the issue pertains to a single function or matcher, be sure to specify the entire call signature. validations: required: true ``` ## /.github/ISSUE_TEMPLATE/feature.yml ```yml path="/.github/ISSUE_TEMPLATE/feature.yml" name: Feature Request 🚀 description: Submit a proposal for a new feature title: '[Feature]: ' body: - type: markdown attributes: value: | ### Thank you for taking the time to suggest a new feature! - type: textarea id: description attributes: label: '🚀 Feature Request' description: A clear and concise description of what the feature is. validations: required: true - type: textarea id: example attributes: label: Example description: Describe how this feature would be used. validations: required: false - type: textarea id: motivation attributes: label: Motivation description: | Outline your motivation for the proposal. How will it make Playwright better? validations: required: true ``` ## /.github/ISSUE_TEMPLATE/question.yml ```yml path="/.github/ISSUE_TEMPLATE/question.yml" name: 'Questions / Help 💬' description: If you have questions, please check StackOverflow or Discord title: '[Please read the message below]' labels: [':speech_balloon: Question'] body: - type: markdown attributes: value: | ## Questions and Help 💬 This issue tracker is reserved for bug reports and feature requests. For anything else, such as questions or getting help, please see: - [The Playwright documentation](https://playwright.dev) - [Our Discord server](https://aka.ms/playwright/discord) - type: checkboxes id: no-post attributes: label: | Please do not submit this issue. description: | > [!IMPORTANT] > This issue will be closed. options: - label: I understand that this issue will be closed required: true ``` ## /.github/ISSUE_TEMPLATE/regression.yml ```yml path="/.github/ISSUE_TEMPLATE/regression.yml" name: Report regression description: Functionality that used to work and does not any more title: "[Regression]: " body: - type: markdown attributes: value: | # Please follow these steps first: - type: markdown attributes: value: | ## Make a minimal reproduction To file the report, you will need a GitHub repository with a minimal (but complete) example and simple/clear steps on how to reproduce the regression. The simpler you can make it, the more likely we are to successfully verify and fix the regression. - type: markdown attributes: value: | > [!IMPORTANT] > Regression reports without a minimal reproduction will be rejected. --- - type: input id: goodVersion attributes: label: Last Good Version description: | Last version of Playwright where the feature was working. placeholder: ex. 1.40.1 validations: required: true - type: input id: badVersion attributes: label: First Bad Version description: | First version of Playwright where the feature was broken. Is it the [latest](https://github.com/microsoft/playwright/releases)? Test and see if the regression has already been fixed. placeholder: ex. 1.41.1 validations: required: true - type: textarea id: reproduction attributes: label: Steps to reproduce description: Please link to a repository with a minimal reproduction and describe accurately how we can reproduce/verify the bug. placeholder: | Example steps (replace with your own): 1. Clone my repo at https://github.com//example 2. npm install 3. npm run test 4. You should see the error come up validations: required: true - type: textarea id: expected attributes: label: Expected behavior description: A description of what you expect to happen. placeholder: I expect to see X or Y validations: required: true - type: textarea id: what-happened attributes: label: Actual behavior description: A clear and concise description of the unexpected behavior. placeholder: A bug happened! validations: required: true - type: textarea id: context attributes: label: Additional context description: Anything else that might be relevant validations: required: false - type: textarea id: envinfo attributes: label: Environment description: | Please paste the output of running `npx envinfo --preset playwright`. This will be automatically formatted as a code block, so no need for backticks. placeholder: | System: OS: Linux 6.2 Ubuntu 22.04.3 LTS 22.04.3 LTS (Jammy Jellyfish) CPU: (8) arm64 Binaries: Node: 18.19.0 - ~/.nvm/versions/node/v18.19.0/bin/node npm: 10.2.3 - ~/.nvm/versions/node/v18.19.0/bin/npm npmPackages: @playwright/test: 1.41.1 => 1.41.1 render: shell validations: required: true ``` ## /.github/actions/download-artifact/action.yml ```yml path="/.github/actions/download-artifact/action.yml" name: 'Download artifacts' description: 'Download artifacts from GitHub' inputs: namePrefix: description: 'Name prefix of the artifacts to download' required: true default: 'blob-report' path: description: 'Directory with downloaded artifacts' required: true default: '.' runs: using: "composite" steps: - name: Create temp downloads dir shell: bash run: mkdir -p '${{ inputs.path }}/artifacts' - name: Download artifacts uses: actions/github-script@v7 with: script: | console.log(`downloading artifacts for workflow_run: ${context.payload.workflow_run.id}`); console.log(`workflow_run: ${JSON.stringify(context.payload.workflow_run, null, 2)}`); const allArtifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { ...context.repo, run_id: context.payload.workflow_run.id }); console.log('total = ', allArtifacts.length); const artifacts = allArtifacts.filter(a => a.name.startsWith('${{ inputs.namePrefix }}')); const fs = require('fs'); for (const artifact of artifacts) { const result = await github.rest.actions.downloadArtifact({ ...context.repo, artifact_id: artifact.id, archive_format: 'zip' }); console.log(`Downloaded ${artifact.name}.zip (${result.data.byteLength} bytes)`); fs.writeFileSync(`${{ inputs.path }}/artifacts/${artifact.name}.zip`, Buffer.from(result.data)); } - name: Unzip artifacts shell: bash run: | unzip -n '${{ inputs.path }}/artifacts/*.zip' -d ${{ inputs.path }} rm -rf '${{ inputs.path }}/artifacts' ``` ## /.github/actions/enable-microphone-access/action.yml ```yml path="/.github/actions/enable-microphone-access/action.yml" name: Enable Microphone Access (macOS) description: 'Allow microphone access to all apps on macOS' runs: using: composite steps: # https://github.com/actions/runner-images/issues/9330 - name: Allow microphone access to all apps shell: bash run: | if [[ "$(uname)" != "Darwin" ]]; then echo "Not macOS, exiting" exit 0 fi echo "Allowing microphone access to all apps" version=$(sw_vers -productVersion | cut -d. -f1) if [[ "$version" == "14" || "$version" == "15" ]]; then sqlite3 $HOME/Library/Application\ Support/com.apple.TCC/TCC.db "INSERT OR IGNORE INTO access VALUES ('kTCCServiceMicrophone','/usr/local/opt/runner/provisioner/provisioner',1,2,4,1,NULL,NULL,0,'UNUSED',NULL,0,1687786159,NULL,NULL,'UNUSED',1687786159);" elif [[ "$version" == "12" || "$version" == "13" ]]; then sqlite3 $HOME/Library/Application\ Support/com.apple.TCC/TCC.db "INSERT OR REPLACE INTO access VALUES('kTCCServiceMicrophone','/usr/local/opt/runner/provisioner/provisioner',1,2,4,1,NULL,NULL,0,'UNUSED',NULL,0,1687786159);" else echo "Skipping unsupported macOS version $version" exit 0 fi echo "Successfully allowed microphone access" ``` ## /.github/actions/run-test/action.yml ```yml path="/.github/actions/run-test/action.yml" name: 'Run browser tests' description: 'Run browser tests' inputs: command: description: 'Command to run tests' required: true node-version: description: 'Node.js version to use' required: false default: '18' browsers-to-install: description: 'Browser to install. Default is all browsers.' required: false default: '' bot-name: description: 'Bot name' required: true shell: description: 'Shell to use' required: false default: 'bash' flakiness-client-id: description: 'Azure Flakiness Dashboard Client ID' required: false flakiness-tenant-id: description: 'Azure Flakiness Dashboard Tenant ID' required: false flakiness-subscription-id: description: 'Azure Flakiness Dashboard Subscription ID' required: false runs: using: composite steps: - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} - uses: ./.github/actions/enable-microphone-access - run: | echo "::group::npm ci" npm ci echo "::endgroup::" shell: bash - run: | echo "::group::npm run build" npm run build echo "::endgroup::" shell: bash - run: | echo "::group::npx playwright install --with-deps" npx playwright install --with-deps ${{ inputs.browsers-to-install }} echo "::endgroup::" shell: bash - name: Run tests if: inputs.shell == 'bash' run: | if [[ "$(uname)" == "Linux" ]]; then xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- ${{ inputs.command }} else ${{ inputs.command }} fi shell: bash env: PWTEST_BOT_NAME: ${{ inputs.bot-name }} - name: Run tests if: inputs.shell != 'bash' run: ${{ inputs.command }} shell: ${{ inputs.shell }} env: PWTEST_BOT_NAME: ${{ inputs.bot-name }} - name: Azure Login uses: azure/login@v2 if: ${{ !cancelled() && github.event_name == 'push' && github.repository == 'microsoft/playwright' }} with: client-id: ${{ inputs.flakiness-client-id }} tenant-id: ${{ inputs.flakiness-tenant-id }} subscription-id: ${{ inputs.flakiness-subscription-id }} - run: | echo "::group::./utils/upload_flakiness_dashboard.sh" ./utils/upload_flakiness_dashboard.sh ./test-results/report.json echo "::endgroup::" if: ${{ !cancelled() }} shell: bash - name: Upload blob report # We only merge reports for PRs as per .github/workflows/create_test_report.yml. if: ${{ !cancelled() && github.event_name == 'pull_request' }} uses: ./.github/actions/upload-blob-report with: report_dir: blob-report job_name: ${{ inputs.bot-name }} ``` ## /.github/actions/upload-blob-report/action.yml ```yml path="/.github/actions/upload-blob-report/action.yml" name: 'Upload blob report' description: 'Upload blob report to GitHub artifacts (for pull requests)' inputs: report_dir: description: 'Directory containing blob report' required: true default: 'test-results/blob-report' job_name: description: 'Unique job name' required: true default: '' runs: using: "composite" steps: - name: Integrity check shell: bash run: find "${{ inputs.report_dir }}" -name "*.zip" -exec unzip -t {} \; - name: Upload blob report to GitHub if: ${{ !cancelled() && github.event_name == 'pull_request' }} uses: actions/upload-artifact@v4 with: name: blob-report-${{ inputs.job_name }} path: ${{ inputs.report_dir }}/** retention-days: 7 ``` ## /.github/dependabot.yml ```yml path="/.github/dependabot.yml" version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" groups: actions: patterns: - "*" ``` ## /.github/workflows/cherry_pick_into_release_branch.yml ```yml path="/.github/workflows/cherry_pick_into_release_branch.yml" name: Cherry-pick into release branch on: workflow_dispatch: inputs: version: type: string description: Version number, e.g. 1.25 required: true commit_hashes: type: string description: Comma-separated list of commit hashes to cherry-pick required: true permissions: contents: write jobs: roll: runs-on: ubuntu-22.04 steps: - name: Validate input version number run: | VERSION="${{ github.event.inputs.version }}" if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+$ ]]; then echo "Version is not a two digit semver version" exit 1 fi - uses: actions/checkout@v4 with: ref: release-${{ github.event.inputs.version }} fetch-depth: 0 - name: Cherry-pick commits id: cherry-pick run: | git config --global user.name microsoft-playwright-automation[bot] git config --global user.email 203992400+microsoft-playwright-automation[bot]@users.noreply.github.com for COMMIT_HASH in $(echo "${{ github.event.inputs.commit_hashes }}" | tr "," "\n"); do git cherry-pick --no-commit "$COMMIT_HASH" COMMIT_MESSAGE="$(git show -s --format=%B $COMMIT_HASH | head -n 1)" COMMIT_MESSAGE=$(node -e ' const match = /^(.*) (\(#\d+\))$/.exec(process.argv[1]); if (!match) { console.log(process.argv[1]); process.exit(0); } console.log(`cherry-pick${match[2]}: ${match[1]}`); ' "$COMMIT_MESSAGE") git commit -m "$COMMIT_MESSAGE" done LAST_COMMIT_MESSAGE=$(git show -s --format=%B) echo "PR_TITLE=$LAST_COMMIT_MESSAGE" >> $GITHUB_OUTPUT - name: Prepare branch id: prepare-branch run: | BRANCH_NAME="cherry-pick-${{ github.event.inputs.version }}-$(date +%Y-%m-%d-%H-%M-%S)" echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT git checkout -b "$BRANCH_NAME" git push origin $BRANCH_NAME - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ vars.PLAYWRIGHT_APP_ID }} private-key: ${{ secrets.PLAYWRIGHT_PRIVATE_KEY }} - name: Create Pull Request uses: actions/github-script@v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | const readableCommitHashesList = '${{ github.event.inputs.commit_hashes }}'.split(',').map(hash => `- ${hash}`).join('\n'); const response = await github.rest.pulls.create({ owner: 'microsoft', repo: 'playwright', head: 'microsoft:${{ steps.prepare-branch.outputs.BRANCH_NAME }}', base: 'release-${{ github.event.inputs.version }}', title: '${{ steps.cherry-pick.outputs.PR_TITLE }}', body: `This PR cherry-picks the following commits:\n\n${readableCommitHashesList}`, }); await github.rest.issues.addLabels({ owner: 'microsoft', repo: 'playwright', issue_number: response.data.number, labels: ['CQ1'], }); ``` ## /.github/workflows/create_test_report.yml ```yml path="/.github/workflows/create_test_report.yml" name: Publish Test Results on: workflow_run: workflows: ["tests 1", "tests 2", "tests others"] types: - completed jobs: merge-reports: permissions: pull-requests: write checks: write id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed if: ${{ github.event.workflow_run.event == 'pull_request' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 - run: npm ci env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 - run: npm run build - name: Download blob report artifact uses: ./.github/actions/download-artifact with: namePrefix: 'blob-report' path: 'all-blob-reports' - name: Merge reports run: | npx playwright merge-reports --config .github/workflows/merge.config.ts ./all-blob-reports env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 HTML_REPORT_URL: 'https://mspwblobreport.z1.web.core.windows.net/run-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}-${{ github.sha }}/index.html' - name: Azure Login uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_BLOB_REPORTS_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_BLOB_REPORTS_TENANT_ID }} subscription-id: ${{ secrets.AZURE_BLOB_REPORTS_SUBSCRIPTION_ID }} - name: Upload HTML report to Azure run: | REPORT_DIR='run-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}-${{ github.sha }}' azcopy cp --recursive "./playwright-report/*" "https://mspwblobreport.blob.core.windows.net/\$web/$REPORT_DIR" echo "Report url: https://mspwblobreport.z1.web.core.windows.net/$REPORT_DIR/index.html" env: AZCOPY_AUTO_LOGIN_TYPE: AZCLI ``` ## /.github/workflows/infra.yml ```yml path="/.github/workflows/infra.yml" name: "infra" on: push: branches: - main - release-* pull_request: branches: - main - release-* env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 jobs: doc-and-lint: name: "docs & lint" runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 - run: npm ci - run: npm run build - run: npx playwright install --with-deps - run: npm run lint - name: Verify clean tree run: | if [[ -n $(git status -s) ]]; then echo "ERROR: tree is dirty after npm run build:" git diff exit 1 fi - name: Audit prod NPM dependencies run: node utils/check_audit.js continue-on-error: true lint-snippets: name: "Lint snippets" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 - uses: actions/setup-python@v5 with: python-version: '3.11' - uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '21' - run: npm ci - run: pip install -r utils/doclint/linting-code-snippets/python/requirements.txt - run: mvn package working-directory: utils/doclint/linting-code-snippets/java - run: node utils/doclint/linting-code-snippets/cli.js ``` ## /.github/workflows/merge.config.ts ```ts path="/.github/workflows/merge.config.ts" export default { testDir: '../../tests', reporter: [[require.resolve('../../packages/playwright-dashboard/lib/ghaMarkdownReporter')], ['html']] }; ``` ## /.github/workflows/pr_check_client_side_changes.yml ```yml path="/.github/workflows/pr_check_client_side_changes.yml" name: "Check client side changes" on: push: branches: - main paths: - 'docs/src/api/**/*' - 'packages/playwright-core/src/client/**/*' - 'packages/playwright-core/src/utils/isomorphic/**/*' - 'packages/playwright/src/matchers/matchers.ts' - 'packages/protocol/src/protocol.yml' jobs: check: name: Check runs-on: ubuntu-24.04 if: github.repository == 'microsoft/playwright' steps: - uses: actions/checkout@v4 - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ vars.PLAYWRIGHT_APP_ID }} private-key: ${{ secrets.PLAYWRIGHT_PRIVATE_KEY }} repositories: | playwright playwright-python playwright-java playwright-dotnet - name: Create GitHub issue uses: actions/github-script@v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | const currentPlaywrightVersion = require('./package.json').version.match(/\d+\.\d+/)[0]; const { data } = await github.rest.git.getCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: context.sha, }); const commitHeader = data.message.split('\n')[0]; const prMatch = commitHeader.match(/#(\d+)/); const formattedCommit = prMatch ? `https://github.com/microsoft/playwright/pull/${prMatch[1]}` : `https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${context.sha} (${commitHeader})`; const title = '[Ports]: Backport client side changes for ' + currentPlaywrightVersion; for (const repo of ['playwright-python', 'playwright-java', 'playwright-dotnet']) { const { data: issuesData } = await github.rest.search.issuesAndPullRequests({ q: `is:issue is:open repo:microsoft/${repo} in:title "${title}"` }) let issueNumber = null; let issueBody = ''; if (issuesData.total_count > 0) { issueNumber = issuesData.items[0].number issueBody = issuesData.items[0].body } else { const { data: issueCreateData } = await github.rest.issues.create({ owner: context.repo.owner, repo: repo, title, body: 'Please backport client side changes: \n', }); issueNumber = issueCreateData.number; issueBody = issueCreateData.body; } const newBody = issueBody.trimEnd() + ` - [ ] ${formattedCommit}`; const data = await github.rest.issues.update({ owner: context.repo.owner, repo: repo, issue_number: issueNumber, body: newBody }) } ``` ## /.github/workflows/publish_canary.yml ```yml path="/.github/workflows/publish_canary.yml" name: "publish canary" on: workflow_dispatch: schedule: - cron: "10 0 * * *" push: branches: - release-* env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 jobs: publish-canary: name: "publish canary NPM" runs-on: ubuntu-24.04 if: github.repository == 'microsoft/playwright' permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed environment: allow-publish-driver-to-cdn # This is required for OIDC login (azure/login) steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 registry-url: 'https://registry.npmjs.org' - run: npm ci - run: npm run build - name: "@next: publish with commit timestamp (triggered manually)" if: contains(github.ref, 'main') && github.event_name == 'workflow_dispatch' run: | node utils/build/update_canary_version.js --alpha --commit-timestamp utils/publish_all_packages.sh --alpha env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: "@next: publish with today's date (triggered automatically)" if: contains(github.ref, 'main') && github.event_name != 'workflow_dispatch' run: | node utils/build/update_canary_version.js --alpha --today-date utils/publish_all_packages.sh --alpha env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: "@beta: publish with commit timestamp (triggered automatically)" if: contains(github.ref, 'release') && github.event_name != 'workflow_dispatch' run: | node utils/build/update_canary_version.js --beta --commit-timestamp utils/publish_all_packages.sh --beta env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Azure Login uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_PW_CDN_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_PW_CDN_TENANT_ID }} subscription-id: ${{ secrets.AZURE_PW_CDN_SUBSCRIPTION_ID }} - name: build & publish driver env: AZ_UPLOAD_FOLDER: driver/next run: | utils/build/build-playwright-driver.sh utils/build/upload-playwright-driver.sh publish-trace-viewer: name: "publish Trace Viewer to trace.playwright.dev" runs-on: ubuntu-24.04 if: github.repository == 'microsoft/playwright' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ vars.PLAYWRIGHT_APP_ID }} private-key: ${{ secrets.PLAYWRIGHT_PRIVATE_KEY }} repositories: trace.playwright.dev - name: Deploy Canary run: bash utils/build/deploy-trace-viewer.sh --canary if: contains(github.ref, 'main') env: GH_SERVICE_ACCOUNT_TOKEN: ${{ steps.app-token.outputs.token }} - name: Deploy BETA run: bash utils/build/deploy-trace-viewer.sh --beta if: contains(github.ref, 'release') env: GH_SERVICE_ACCOUNT_TOKEN: ${{ steps.app-token.outputs.token }} ``` ## /.github/workflows/publish_release_docker.yml ```yml path="/.github/workflows/publish_release_docker.yml" name: "publish release - Docker" on: workflow_dispatch: release: types: [published] env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 jobs: publish-docker-release: name: "publish to DockerHub" runs-on: ubuntu-22.04 permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed if: github.repository == 'microsoft/playwright' environment: allow-publishing-docker-to-acr steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 registry-url: 'https://registry.npmjs.org' - name: Set up Docker QEMU for arm64 docker builds uses: docker/setup-qemu-action@v3 with: platforms: arm64 - run: npm ci - run: npm run build - name: Azure Login uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }} subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }} - name: Login to ACR via OIDC run: az acr login --name playwright - run: ./utils/docker/publish_docker.sh stable ``` ## /.github/workflows/publish_release_driver.yml ```yml path="/.github/workflows/publish_release_driver.yml" name: "publish release - driver" on: release: types: [published] env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 jobs: publish-driver-release: name: "publish playwright driver to CDN" runs-on: ubuntu-24.04 if: github.repository == 'microsoft/playwright' permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed environment: allow-publish-driver-to-cdn # This is required for OIDC login (azure/login) steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 registry-url: 'https://registry.npmjs.org' - run: npm ci - run: npm run build - run: utils/build/build-playwright-driver.sh - name: Azure Login uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_PW_CDN_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_PW_CDN_TENANT_ID }} subscription-id: ${{ secrets.AZURE_PW_CDN_SUBSCRIPTION_ID }} - run: utils/build/upload-playwright-driver.sh env: AZ_UPLOAD_FOLDER: driver ``` ## /.github/workflows/publish_release_npm.yml ```yml path="/.github/workflows/publish_release_npm.yml" name: "publish release - NPM" on: release: types: [published] env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 jobs: publish-npm-release: name: "publish to NPM" runs-on: ubuntu-24.04 if: github.repository == 'microsoft/playwright' permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 registry-url: 'https://registry.npmjs.org' - run: npm ci - run: npm run build - run: utils/publish_all_packages.sh --release-candidate if: ${{ github.event.release.prerelease }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: utils/publish_all_packages.sh --release if: ${{ !github.event.release.prerelease }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` ## /.github/workflows/publish_release_traceviewer.yml ```yml path="/.github/workflows/publish_release_traceviewer.yml" name: "publish release - TraceViewer" on: release: types: [published] jobs: publish-trace-viewer: name: "publish Trace Viewer to trace.playwright.dev" runs-on: ubuntu-24.04 if: github.repository == 'microsoft/playwright' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ vars.PLAYWRIGHT_APP_ID }} private-key: ${{ secrets.PLAYWRIGHT_PRIVATE_KEY }} repositories: trace.playwright.dev - name: Deploy Stable run: bash utils/build/deploy-trace-viewer.sh --stable env: GH_SERVICE_ACCOUNT_TOKEN: ${{ steps.app-token.outputs.token }} ``` ## /.github/workflows/roll_browser_into_playwright.yml ```yml path="/.github/workflows/roll_browser_into_playwright.yml" name: Roll Browser into Playwright on: repository_dispatch: types: [roll_into_pw] env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 BROWSER: ${{ github.event.client_payload.browser }} REVISION: ${{ github.event.client_payload.revision }} permissions: contents: write concurrency: group: 'roll-browser-into-playwright-${{ github.event.client_payload.browser }}-${{ github.event.client_payload.revision }}' jobs: roll: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 - run: npm ci - run: npm run build - name: Install dependencies run: npx playwright install-deps - name: Roll to new revision run: | ./utils/roll_browser.js $BROWSER $REVISION npm run build - name: Prepare branch id: prepare-branch run: | BRANCH_NAME="roll-into-pw-${BROWSER}/${REVISION}" echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT git fetch origin $BRANCH_NAME:$BRANCH_NAME || true if git show-ref --verify --quiet refs/heads/$BRANCH_NAME; then echo "exists=1" >> $GITHUB_OUTPUT echo "branch $BRANCH_NAME already exists, exiting" exit 0 fi echo "exists=0" >> $GITHUB_OUTPUT git config --global user.name microsoft-playwright-automation[bot] git config --global user.email 203992400+microsoft-playwright-automation[bot]@users.noreply.github.com git checkout -b "$BRANCH_NAME" git add . git commit -m "feat(${BROWSER}): roll to r${REVISION}" git push origin $BRANCH_NAME --force - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ vars.PLAYWRIGHT_APP_ID }} private-key: ${{ secrets.PLAYWRIGHT_PRIVATE_KEY }} - name: Create Pull Request uses: actions/github-script@v7 if: ${{ steps.prepare-branch.outputs.exists == '0' }} with: github-token: ${{ steps.app-token.outputs.token }} script: | const response = await github.rest.pulls.create({ owner: 'microsoft', repo: 'playwright', head: 'microsoft:${{ steps.prepare-branch.outputs.BRANCH_NAME }}', base: 'main', title: 'feat(${{ env.BROWSER }}): roll to r${{ env.REVISION }}', }); await github.rest.issues.addLabels({ owner: 'microsoft', repo: 'playwright', issue_number: response.data.number, labels: ['CQ1'], }); ``` ## /.github/workflows/roll_driver_nodejs.yml ```yml path="/.github/workflows/roll_driver_nodejs.yml" name: "PR: bump driver Node.js" on: workflow_dispatch: schedule: # At 10:00am UTC (3AM PST) every tuesday and thursday to roll to new Node.js driver - cron: "0 10 * * 2,4" jobs: trigger-nodejs-roll: name: Trigger Roll runs-on: ubuntu-22.04 if: github.repository == 'microsoft/playwright' permissions: contents: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 - run: node utils/build/update-playwright-driver-version.mjs - name: Prepare branch id: prepare-branch run: | if [[ "$(git status --porcelain)" == "" ]]; then echo "there are no changes"; exit 0; fi echo "HAS_CHANGES=1" >> $GITHUB_OUTPUT BRANCH_NAME="roll-driver-nodejs/$(date +%Y-%b-%d)" echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT git config --global user.name microsoft-playwright-automation[bot] git config --global user.email 203992400+microsoft-playwright-automation[bot]@users.noreply.github.com git checkout -b "$BRANCH_NAME" git add . git commit -m "chore(driver): roll driver to recent Node.js LTS version" git push origin $BRANCH_NAME - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ vars.PLAYWRIGHT_APP_ID }} private-key: ${{ secrets.PLAYWRIGHT_PRIVATE_KEY }} - name: Create Pull Request if: ${{ steps.prepare-branch.outputs.HAS_CHANGES == '1' }} uses: actions/github-script@v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | await github.rest.pulls.create({ owner: 'microsoft', repo: 'playwright', head: 'microsoft:${{ steps.prepare-branch.outputs.BRANCH_NAME }}', base: 'main', title: 'chore(driver): roll driver to recent Node.js LTS version', }); ``` ## /.github/workflows/tests_bidi.yml ```yml path="/.github/workflows/tests_bidi.yml" name: tests BiDi on: workflow_dispatch: inputs: ref: description: Playwright SHA / ref to test. Use 'refs/pull/PULL_REQUEST_ID/head' to test a PR. Defaults to the current branch. required: false default: '' pull_request: branches: - main paths: - .github/workflows/tests_bidi.yml - packages/playwright-core/src/server/bidi/** - tests/bidi/** schedule: # Run every day at midnight - cron: '0 0 * * *' env: FORCE_COLOR: 1 jobs: test_bidi: name: BiDi environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ubuntu-24.04 permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed strategy: fail-fast: false matrix: channel: [bidi-chromium, moz-firefox] steps: - uses: actions/checkout@v4 if: github.event_name != 'workflow_dispatch' - uses: actions/checkout@v4 if: github.event_name == 'workflow_dispatch' with: ref: ${{ github.event.inputs.ref }} - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npm run build - run: npx playwright install --with-deps chromium if: matrix.channel == 'bidi-chromium' - if: matrix.channel == 'moz-firefox' id: install_firefox run: | npx -y @puppeteer/browsers install firefox@nightly |\ awk 'END { $1=""; sub(/^ /,""); print "bidi_ffpath="$0 }' |\ tee -a $GITHUB_OUTPUT - name: Run tests run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}* env: PWTEST_USE_BIDI_EXPECTATIONS: '1' BIDI_FFPATH: ${{ steps.install_firefox.outputs.bidi_ffpath }} - name: Upload csv report to GitHub if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 with: name: csv-report-${{ matrix.channel }} path: test-results/report.csv retention-days: 7 - name: Azure Login if: ${{ !cancelled() && github.ref == 'refs/heads/main' }} uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_BLOB_REPORTS_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_BLOB_REPORTS_TENANT_ID }} subscription-id: ${{ secrets.AZURE_BLOB_REPORTS_SUBSCRIPTION_ID }} - name: Upload report.csv to Azure if: ${{ !cancelled() && github.ref == 'refs/heads/main' }} run: | REPORT_DIR='bidi-reports' azcopy cp "./test-results/report.csv" "https://mspwblobreport.blob.core.windows.net/\$web/$REPORT_DIR/${{ matrix.channel }}.csv" echo "Report url: https://mspwblobreport.z1.web.core.windows.net/$REPORT_DIR/${{ matrix.channel }}.csv" env: AZCOPY_AUTO_LOGIN_TYPE: AZCLI ``` ## /.github/workflows/tests_components.yml ```yml path="/.github/workflows/tests_components.yml" name: "components" on: push: branches: - main - release-* pull_request: paths-ignore: - 'browser_patches/**' - 'docs/**' branches: - main - release-* env: FORCE_COLOR: 1 ELECTRON_SKIP_BINARY_DOWNLOAD: 1 jobs: test_components: name: ${{ matrix.os }} - Node.js ${{ matrix.node-version }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] node-version: [18] include: - os: ubuntu-latest node-version: 20 - os: ubuntu-latest node-version: 22 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: npm ci - run: npm run build - run: npx playwright install --with-deps - run: npm run ct ``` ## /.github/workflows/tests_others.yml ```yml path="/.github/workflows/tests_others.yml" name: tests others on: push: branches: - main - release-* pull_request: paths-ignore: - 'browser_patches/**' - 'docs/**' types: [ labeled ] branches: - main - release-* env: FORCE_COLOR: 1 ELECTRON_SKIP_BINARY_DOWNLOAD: 1 jobs: test_stress: name: Stress - ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npm run build - run: npx playwright install --with-deps - run: npm run stest contexts -- --project=chromium if: ${{ !cancelled() }} - run: npm run stest browsers -- --project=chromium if: ${{ !cancelled() }} - run: npm run stest frames -- --project=chromium if: ${{ !cancelled() }} - run: npm run stest contexts -- --project=webkit if: ${{ !cancelled() }} - run: npm run stest browsers -- --project=webkit if: ${{ !cancelled() }} - run: npm run stest frames -- --project=webkit if: ${{ !cancelled() }} - run: npm run stest contexts -- --project=firefox if: ${{ !cancelled() }} - run: npm run stest browsers -- --project=firefox if: ${{ !cancelled() }} - run: npm run stest frames -- --project=firefox if: ${{ !cancelled() }} - run: npm run stest heap -- --project=chromium if: ${{ !cancelled() }} test_webview2: name: WebView2 environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: windows-2022 permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' - run: dotnet build working-directory: tests/webview2/webview2-app/ - name: Update to Evergreen WebView2 Runtime shell: pwsh run: | # See here: https://developer.microsoft.com/en-us/microsoft-edge/webview2/ Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' -OutFile 'setup.exe' Start-Process -FilePath setup.exe -Verb RunAs -Wait - uses: ./.github/actions/run-test with: node-version: 20 browsers-to-install: chromium command: npm run webview2test bot-name: "webview2-chromium-windows" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test_clock_frozen_time_linux: name: time library - ${{ matrix.clock }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed strategy: fail-fast: false matrix: clock: [frozen, realtime] runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: node-version: 20 browsers-to-install: chromium command: npm run test -- --project=chromium-* bot-name: "${{ matrix.clock }}-time-library-chromium-linux" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PW_CLOCK: ${{ matrix.clock }} test_clock_frozen_time_test_runner: name: time test runner - ${{ matrix.clock }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ubuntu-22.04 permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed strategy: fail-fast: false matrix: clock: [frozen, realtime] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: node-version: 20 command: npm run ttest bot-name: "${{ matrix.clock }}-time-runner-chromium-linux" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PW_CLOCK: ${{ matrix.clock }} test_electron: name: Electron - ${{ matrix.os }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: Setup Ubuntu Binary Installation # TODO: Remove when https://github.com/electron/electron/issues/42510 is fixed if: ${{ runner.os == 'Linux' }} run: | if grep -q "Ubuntu 24" /etc/os-release; then sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 fi shell: bash - uses: ./.github/actions/run-test with: browsers-to-install: chromium command: npm run etest bot-name: "electron-${{ matrix.os }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: ELECTRON_SKIP_BINARY_DOWNLOAD: ``` ## /.github/workflows/tests_primary.yml ```yml path="/.github/workflows/tests_primary.yml" name: "tests 1" on: push: branches: - main - release-* pull_request: paths-ignore: - 'browser_patches/**' - 'docs/**' branches: - main - release-* concurrency: # For pull requests, cancel all currently-running jobs for this workflow # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: # Force terminal colors. @see https://www.npmjs.com/package/colors FORCE_COLOR: 1 ELECTRON_SKIP_BINARY_DOWNLOAD: 1 DEBUG_GIT_COMMIT_INFO: 1 jobs: test_linux: name: ${{ matrix.os }} (${{ matrix.browser }} - Node.js ${{ matrix.node-version }}) environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: browser: [chromium, firefox, webkit] os: [ubuntu-22.04] node-version: [18] include: - os: ubuntu-22.04 node-version: 20 browser: chromium - os: ubuntu-22.04 node-version: 22 browser: chromium runs-on: ${{ matrix.os }} permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: node-version: ${{ matrix.node-version }} browsers-to-install: ${{ matrix.browser }} chromium command: npm run test -- --project=${{ matrix.browser }}-* bot-name: "${{ matrix.browser }}-${{ matrix.os }}-node${{ matrix.node-version }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test_linux_chromium_tot: name: ${{ matrix.os }} (chromium tip-of-tree) environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: os: [ubuntu-22.04] runs-on: ${{ matrix.os }} permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: chromium-tip-of-tree command: npm run test -- --project=chromium-* bot-name: "${{ matrix.os }}-chromium-tip-of-tree" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PWTEST_CHANNEL: chromium-tip-of-tree test_test_runner: name: Test Runner environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] node-version: [18] shardIndex: [1, 2] shardTotal: [2] include: - os: ubuntu-latest node-version: 20 shardIndex: 1 shardTotal: 2 - os: ubuntu-latest node-version: 20 shardIndex: 2 shardTotal: 2 - os: ubuntu-latest node-version: 22 shardIndex: 1 shardTotal: 2 - os: ubuntu-latest node-version: 22 shardIndex: 2 shardTotal: 2 runs-on: ${{ matrix.os }} permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: node-version: ${{matrix.node-version}} command: npm run ttest -- --shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} bot-name: "${{ matrix.os }}-node${{ matrix.node-version }}-${{ matrix.shardIndex }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PWTEST_CHANNEL: firefox-beta test_web_components: name: Web Components runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 - run: npm ci - run: npm run build - run: npx playwright install --with-deps - run: npm run test-html-reporter env: PWTEST_BOT_NAME: "web-components-html-reporter" - name: Upload blob report if: ${{ !cancelled() }} uses: ./.github/actions/upload-blob-report with: report_dir: packages/html-reporter/blob-report job_name: "web-components-html-reporter" - run: npm run test-web if: ${{ !cancelled() }} env: PWTEST_BOT_NAME: "web-components-web" - name: Upload blob report if: ${{ !cancelled() }} uses: ./.github/actions/upload-blob-report with: report_dir: packages/web/blob-report job_name: "web-components-web" test_vscode_extension: name: VSCode Extension runs-on: ubuntu-latest env: PWTEST_BOT_NAME: "vscode-extension" DEBUG_GIT_COMMIT_INFO: "" steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 - run: npm ci env: DEBUG: pw:install - run: npm run build - run: npx playwright install chromium - name: Checkout extension run: git clone https://github.com/microsoft/playwright-vscode.git - name: Print extension revision run: git rev-parse HEAD working-directory: ./playwright-vscode - name: Remove @playwright/test from extension dependencies run: node -e "const p = require('./package.json'); delete p.devDependencies['@playwright/test']; fs.writeFileSync('./package.json', JSON.stringify(p, null, 2));" working-directory: ./playwright-vscode - name: Build extension run: npm ci && npm run build working-directory: ./playwright-vscode - name: Run extension tests run: npm run test -- --workers=1 working-directory: ./playwright-vscode - name: Upload blob report if: ${{ !cancelled() }} uses: ./.github/actions/upload-blob-report with: report_dir: playwright-vscode/blob-report job_name: ${{ env.PWTEST_BOT_NAME }} test_package_installations: name: "Installation Test ${{ matrix.os }}" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: os: - ubuntu-latest - macos-latest - windows-latest runs-on: ${{ matrix.os }} timeout-minutes: 30 permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed steps: - uses: actions/checkout@v4 - run: npm install -g yarn@1 - run: npm install -g pnpm@8 - name: Setup Ubuntu Binary Installation # TODO: Remove when https://github.com/electron/electron/issues/42510 is fixed if: ${{ runner.os == 'Linux' }} run: | if grep -q "Ubuntu 24" /etc/os-release; then sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 fi shell: bash - uses: ./.github/actions/run-test with: command: npm run itest bot-name: "package-installations-${{ matrix.os }}" shell: ${{ matrix.os == 'windows-latest' && 'pwsh' || 'bash' }} flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} ``` ## /.github/workflows/tests_secondary.yml ```yml path="/.github/workflows/tests_secondary.yml" name: "tests 2" on: push: branches: - main - release-* pull_request: paths-ignore: - 'browser_patches/**' - 'docs/**' types: [ labeled ] branches: - main - release-* env: # Force terminal colors. @see https://www.npmjs.com/package/colors FORCE_COLOR: 1 ELECTRON_SKIP_BINARY_DOWNLOAD: 1 permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed jobs: test_linux: name: ${{ matrix.os }} (${{ matrix.browser }}) environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: browser: [chromium, firefox, webkit] os: [ubuntu-24.04] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: ${{ matrix.browser }} chromium command: npm run test -- --project=${{ matrix.browser }}-* bot-name: "${{ matrix.browser }}-${{ matrix.os }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test_mac: name: ${{ matrix.os }} (${{ matrix.browser }}) environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: # Intel: *-large # Arm64: *-xlarge os: [macos-13-large, macos-13-xlarge, macos-14-large, macos-14-xlarge] browser: [chromium, firefox, webkit] include: - os: macos-15-large browser: webkit - os: macos-15-xlarge browser: webkit runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: ${{ matrix.browser }} chromium command: npm run test -- --project=${{ matrix.browser }}-* bot-name: "${{ matrix.browser }}-${{ matrix.os }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test_win: name: "Windows" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: browser: [chromium, firefox, webkit] runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: ${{ matrix.browser }} chromium command: npm run test -- --project=${{ matrix.browser }}-* ${{ matrix.browser == 'firefox' && '--workers 1' || '' }} bot-name: "${{ matrix.browser }}-windows-latest" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test-package-installations-other-node-versions: name: "Installation Test ${{ matrix.os }} (${{ matrix.node_version }})" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - os: ubuntu-latest node_version: 20 - os: ubuntu-latest node_version: 22 timeout-minutes: 30 steps: - uses: actions/checkout@v4 - run: npm install -g yarn@1 - run: npm install -g pnpm@8 - name: Setup Ubuntu Binary Installation # TODO: Remove when https://github.com/electron/electron/issues/42510 is fixed if: ${{ runner.os == 'Linux' }} run: | if grep -q "Ubuntu 24" /etc/os-release; then sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 fi shell: bash - uses: ./.github/actions/run-test with: node-version: ${{ matrix.node_version }} command: npm run itest bot-name: "package-installations-${{ matrix.os }}-node${{ matrix.node_version }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} headed_tests: name: "headed ${{ matrix.browser }} (${{ matrix.os }})" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: browser: [chromium, firefox, webkit] os: [ubuntu-24.04, macos-14-xlarge, windows-latest] include: # We have different binaries per Ubuntu version for WebKit. - browser: webkit os: ubuntu-22.04 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: ${{ matrix.browser }} chromium command: npm run test -- --project=${{ matrix.browser }}-* --headed bot-name: "${{ matrix.browser }}-headed-${{ matrix.os }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} transport_linux: name: "Transport" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: mode: [driver, service] runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: chromium command: npm run ctest bot-name: "${{ matrix.mode }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PWTEST_MODE: ${{ matrix.mode }} tracing_linux: name: Tracing ${{ matrix.browser }} ${{ matrix.channel }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: include: - browser: chromium - browser: firefox - browser: webkit - browser: chromium channel: chromium-tip-of-tree runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: ${{ matrix.browser }} chromium ${{ matrix.channel }} command: npm run test -- --project=${{ matrix.browser }}-* bot-name: "tracing-${{ matrix.channel || matrix.browser }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PWTEST_TRACE: 1 PWTEST_CHANNEL: ${{ matrix.channel }} test_chromium_channels: name: Test ${{ matrix.channel }} on ${{ matrix.runs-on }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ${{ matrix.runs-on }} strategy: fail-fast: false matrix: channel: [chrome, chrome-beta, msedge, msedge-beta, msedge-dev] runs-on: [ubuntu-22.04, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: ${{ matrix.channel }} command: npm run ctest bot-name: ${{ matrix.channel }}-${{ matrix.runs-on }} flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PWTEST_CHANNEL: ${{ matrix.channel }} chromium_tot: name: Chromium tip-of-tree ${{ matrix.os }}${{ matrix.headed }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-22.04, macos-13, windows-latest] headed: ['--headed', ''] exclude: # Tested in tests_primary.yml already - os: ubuntu-22.04 headed: '' steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: chromium-tip-of-tree command: npm run ctest -- ${{ matrix.headed }} bot-name: "chromium-tip-of-tree-${{ matrix.os }}${{ matrix.headed }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PWTEST_CHANNEL: chromium-tip-of-tree chromium_tot_headless_shell: name: Chromium tip-of-tree headless-shell-${{ matrix.os }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-22.04] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: chromium-tip-of-tree-headless-shell command: npm run ctest bot-name: "chromium-tip-of-tree-headless-shell-${{ matrix.os }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PWTEST_CHANNEL: chromium-tip-of-tree-headless-shell firefox_beta: name: Firefox Beta ${{ matrix.os }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-22.04, windows-latest, macos-latest] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: firefox-beta chromium command: npm run ftest bot-name: "firefox-beta-${{ matrix.os }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PWTEST_CHANNEL: firefox-beta build-playwright-driver: name: "build-playwright-driver" runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 - run: npm ci - run: npm run build - run: utils/build/build-playwright-driver.sh test_channel_chromium: name: Test channel=chromium environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false matrix: runs-on: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: # TODO: this should pass --no-shell. # However, codegen tests do not inherit the channel and try to launch headless shell. browsers-to-install: chromium command: npm run ctest bot-name: "channel-chromium-${{ matrix.runs-on }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PWTEST_CHANNEL: chromium ``` ## /.github/workflows/tests_video.yml ```yml path="/.github/workflows/tests_video.yml" name: "tests Video" on: push: branches: - main - release-* env: # Force terminal colors. @see https://www.npmjs.com/package/colors FORCE_COLOR: 1 ELECTRON_SKIP_BINARY_DOWNLOAD: 1 jobs: video_linux: name: "Video Linux" environment: allow-uploading-flakiness-results strategy: fail-fast: false matrix: browser: [chromium, firefox, webkit] os: [ubuntu-22.04, ubuntu-24.04] permissions: id-token: write # This is required for OIDC login (azure/login) to succeed contents: read # This is required for actions/checkout to succeed runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test with: browsers-to-install: ${{ matrix.browser }} chromium command: npm run test -- --project=${{ matrix.browser }}-* bot-name: "${{ matrix.browser }}-${{ matrix.os }}" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PWTEST_VIDEO: 1 ``` ## /.github/workflows/trigger_tests.yml ```yml path="/.github/workflows/trigger_tests.yml" name: "Internal Tests" on: push: branches: - main - release-* jobs: trigger: name: "trigger" runs-on: ubuntu-24.04 steps: - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ vars.PLAYWRIGHT_APP_ID }} private-key: ${{ secrets.PLAYWRIGHT_PRIVATE_KEY }} repositories: playwright-browsers - run: | curl -X POST \ -H "Accept: application/vnd.github.v3+json" \ -H "Authorization: token ${GH_TOKEN}" \ --data "{\"event_type\": \"playwright_tests\", \"client_payload\": {\"ref\": \"${GITHUB_SHA}\"}}" \ https://api.github.com/repos/microsoft/playwright-browsers/dispatches env: GH_TOKEN: ${{ steps.app-token.outputs.token }} ``` ## /.gitignore ```gitignore path="/.gitignore" node_modules/ /test-results/ /tests/coverage-report .local-browsers/ /.dev_profile* .DS_Store *.swp *.pyc .vscode .mono .idea yarn.lock /packages/playwright-core/src/generated /packages/playwright-ct-core/src/generated packages/*/lib/ drivers/ .android-sdk/ .gradle/ nohup.out .trace .tmp allure* blob-report playwright-report test-results /demo/ /packages/*/LICENSE /packages/*/NOTICE /packages/playwright/README.md /packages/playwright-test/README.md /packages/playwright-core/api.json .env /tests/installation/output/ /tests/installation/.registry.json .cache/ .eslintcache playwright.env /firefox/ ``` ## /CODE_OF_CONDUCT.md # Microsoft Open Source Code of Conduct This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). Resources: - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns ## /CONTRIBUTING.md # Contributing ## Choose an issue Playwright **requires an issue** for every contribution, except for minor documentation updates. We strongly recommend to pick an issue labeled [open-to-a-pull-request](https://github.com/microsoft/playwright/issues?q=is%3Aissue%20state%3Aopen%20label%3Aopen-to-a-pull-request) for your first contribution to the project. If you are passionate about a bug/feature, but cannot find an issue describing it, **file an issue first**. This will facilitate the discussion, and you might get some early feedback from project maintainers before spending your time on creating a pull request. ## Make a change Make sure you're running Node.js 20 or later. ```bash node --version ``` Clone the repository. If you plan to send a pull request, it might be better to [fork the repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) first. ```bash git clone https://github.com/microsoft/playwright cd playwright ``` Install dependencies and run the build in watch mode. ```bash npm ci npm run watch npx playwright install ``` **Experimental dev mode with Hot Module Replacement for recorder/trace-viewer/UI Mode** ``` PW_HMR=1 npm run watch PW_HMR=1 npx playwright show-trace PW_HMR=1 npm run ctest -- --ui PW_HMR=1 npx playwright codegen PW_HMR=1 npx playwright show-report ``` Playwright is a multi-package repository that uses npm workspaces. For browser APIs, look at [`packages/playwright-core`](https://github.com/microsoft/playwright/blob/main/packages/playwright-core). For test runner, see [`packages/playwright`](https://github.com/microsoft/playwright/blob/main/packages/playwright). Note that some files are generated by the build, so the watch process might override your changes if done in the wrong file. For example, TypeScript types for the API are generated from the [`docs/src`](https://github.com/microsoft/playwright/blob/main/docs/src). Coding style is fully defined in [eslint.config.mjs](https://github.com/microsoft/playwright/blob/main/eslint.config.mjs). Before creating a pull request, or at any moment during development, run linter to check all kinds of things: ```bash npm run lint ``` Comments should have an explicit purpose and should improve readability rather than hinder it. If the code would not be understood without comments, consider re-writing the code to make it self-explanatory. ### Write documentation Every part of the public API should be documented in [`docs/src`](https://github.com/microsoft/playwright/blob/main/docs/src), in the same change that adds/changes the API. We use markdown files with custom structure to specify the API. Take a look around for an example. Various other files are generated from the API specification. If you are running `npm run watch`, these will be re-generated automatically. Larger changes will require updates to the documentation guides as well. This will be made clear during the code review. ## Add a test Playwright requires a test for almost any new or modified functionality. An exception would be a pure refactoring, but chances are you are doing more than that. There are multiple [test suites](https://github.com/microsoft/playwright/blob/main/tests) in Playwright that will be executed on the CI. The two most important that you need to run locally are: - Library tests cover APIs not related to the test runner. ```bash # fast path runs all tests in Chromium npm run ctest # slow path runs all tests in three browsers npm run test ``` - Test runner tests. ```bash npm run ttest ``` Since Playwright tests are using Playwright under the hood, everything from our documentation applies, for example [this guide on running and debugging tests](https://playwright.dev/docs/running-tests#running-tests). Note that tests should be *hermetic*, and not depend on external services. Tests should work on all three platforms: macOS, Linux and Windows. ## Write a commit message Commit messages should follow the [Semantic Commit Messages](https://www.conventionalcommits.org/en/v1.0.0/) format: ``` label(namespace): title description footer ``` 1. *label* is one of the following: - `fix` - bug fixes - `feat` - new features - `docs` - documentation-only changes - `test` - test-only changes - `devops` - changes to the CI or build - `chore` - everything that doesn't fall under previous categories 2. *namespace* is put in parentheses after label and is optional. Must be lowercase. 3. *title* is a brief summary of changes. 4. *description* is **optional**, new-line separated from title and is in present tense. 5. *footer* is **optional**, new-line separated from *description* and contains "fixes" / "references" attribution to GitHub issues. Example: ``` feat(trace viewer): network panel filtering This patch adds a filtering toolbar to the network panel. Fixes #123, references #234. ``` ## Send a pull request All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Make sure to keep your PR (diff) small and readable. If necessary, split your contribution into multiple PRs. Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests. After a successful code review, one of the maintainers will merge your pull request. Congratulations! ## More details **No new dependencies** There is a very high bar for new dependencies, including updating to a new version of an existing dependency. We recommend to explicitly discuss this in an issue and get a green light from a maintainer, before creating a pull request that updates dependencies. **Custom browser build** To run tests with custom browser executable, specify `CRPATH`, `WKPATH` or `FFPATH` env variable that points to browser executable: ```bash CRPATH= npm run ctest ``` You will also find `DEBUG=pw:browser` useful for debugging custom-builds. **Building documentation site** The [playwright.dev](https://playwright.dev/) documentation site lives in a separate repository, and documentation from [`docs/src`](https://github.com/microsoft/playwright/blob/main/docs/src) is frequently rolled there. Most of the time this should not concern you. However, if you are doing something unusual in the docs, you can build locally and test how your changes will look in practice: 1. Clone the [microsoft/playwright.dev](https://github.com/microsoft/playwright.dev) repo. 1. Follow [the playwright.dev README instructions to "roll docs"](https://github.com/microsoft/playwright.dev/#roll-docs) against your local `playwright` repo with your changes in progress. 1. Follow [the playwright.dev README instructions to "run dev server"](https://github.com/microsoft/playwright.dev/#run-dev-server) to view your changes. ## Contributor License Agreement This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. ### Code of Conduct This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. ## /FILING_ISSUES.md # How to File a Bug Report That Actually Gets Resolved Make sure you’re on the latest Playwright release before filing. Check existing GitHub issues to avoid duplicates. ## Use the Template Follow the **Bug Report** template. It guides you step-by-step: - Fill it out thoroughly. - Clearly list the steps needed to reproduce the bug. - Provide what you expected to see versus what happened in reality. - Include system info from `npx envinfo --preset playwright`. ## Keep Your Repro Minimal We can't parse your entire code base. Reduce it down to the absolute essentials: - Start a fresh project (`npm init playwright@latest new-project`). - Add only the code/DOM needed to show the problem. - Only use major frameworks if necessary (React, Angular, static HTTP server, etc.). - Avoid adding extra libraries unless absolutely necessary. Note that we won't install any suspect dependencies. ## Why This Matters - Most issues that lack a repro turn out to be misconfigurations or usage errors. - We can't fix problems if we can’t reproduce them ourselves. - We can’t debug entire private projects or handle sensitive credentials. - Each confirmed bug will have a test in our repo, so your repro must be as clean as possible. ## More Help - [Stack Overflow’s Minimal Reproducible Example Guide](https://stackoverflow.com/help/minimal-reproducible-example) - [Playwright Debugging Tools](https://playwright.dev/docs/debug) ## Bottom Line A well-isolated bug speeds up verification and resolution. Minimal, public repro or it’s unlikely we can assist. ## /LICENSE ``` path="/LICENSE" Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Portions Copyright (c) Microsoft Corporation. Portions Copyright 2017 Google Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` ## /NOTICE ``` path="/NOTICE" Playwright Copyright (c) Microsoft Corporation This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer), available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE). ``` ## /README.md # 🎭 Playwright [![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-136.0.7103.48-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-137.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.4-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) Playwright is a framework for Web Testing and Automation. It allows testing [Chromium](https://www.chromium.org/Home), [Firefox](https://www.mozilla.org/en-US/firefox/new/) and [WebKit](https://webkit.org/) with a single API. Playwright is built to enable cross-browser web automation that is **ever-green**, **capable**, **reliable** and **fast**. | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | | Chromium 136.0.7103.48 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.4 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 137.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | Headless execution is supported for all browsers on all platforms. Check out [system requirements](https://playwright.dev/docs/intro#system-requirements) for details. Looking for Playwright for [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), or [Java](https://playwright.dev/java/docs/intro)? ## Installation Playwright has its own test runner for end-to-end tests, we call it Playwright Test. ### Using init command The easiest way to get started with Playwright Test is to run the init command. ```Shell # Run from your project's root directory npm init playwright@latest # Or create a new project npm init playwright@latest new-project ``` This will create a configuration file, optionally add examples, a GitHub Action workflow and a first test example.spec.ts. You can now jump directly to writing assertions section. ### Manually Add dependency and install browsers. ```Shell npm i -D @playwright/test # install supported browsers npx playwright install ``` You can optionally install only selected browsers, see [install browsers](https://playwright.dev/docs/cli#install-browsers) for more details. Or you can install no browsers at all and use existing [browser channels](https://playwright.dev/docs/browsers). * [Getting started](https://playwright.dev/docs/intro) * [API reference](https://playwright.dev/docs/api/class-playwright) ## Capabilities ### Resilient • No flaky tests **Auto-wait**. Playwright waits for elements to be actionable prior to performing actions. It also has a rich set of introspection events. The combination of the two eliminates the need for artificial timeouts - a primary cause of flaky tests. **Web-first assertions**. Playwright assertions are created specifically for the dynamic web. Checks are automatically retried until the necessary conditions are met. **Tracing**. Configure test retry strategy, capture execution trace, videos and screenshots to eliminate flakes. ### No trade-offs • No limits Browsers run web content belonging to different origins in different processes. Playwright is aligned with the architecture of the modern browsers and runs tests out-of-process. This makes Playwright free of the typical in-process test runner limitations. **Multiple everything**. Test scenarios that span multiple tabs, multiple origins and multiple users. Create scenarios with different contexts for different users and run them against your server, all in one test. **Trusted events**. Hover elements, interact with dynamic controls and produce trusted events. Playwright uses real browser input pipeline indistinguishable from the real user. Test frames, pierce Shadow DOM. Playwright selectors pierce shadow DOM and allow entering frames seamlessly. ### Full isolation • Fast execution **Browser contexts**. Playwright creates a browser context for each test. Browser context is equivalent to a brand new browser profile. This delivers full test isolation with zero overhead. Creating a new browser context only takes a handful of milliseconds. **Log in once**. Save the authentication state of the context and reuse it in all the tests. This bypasses repetitive log-in operations in each test, yet delivers full isolation of independent tests. ### Powerful Tooling **[Codegen](https://playwright.dev/docs/codegen)**. Generate tests by recording your actions. Save them into any language. **[Playwright inspector](https://playwright.dev/docs/inspector)**. Inspect page, generate selectors, step through the test execution, see click points and explore execution logs. **[Trace Viewer](https://playwright.dev/docs/trace-viewer)**. Capture all the information to investigate the test failure. Playwright trace contains test execution screencast, live DOM snapshots, action explorer, test source and many more. Looking for Playwright for [TypeScript](https://playwright.dev/docs/intro), [JavaScript](https://playwright.dev/docs/intro), [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), or [Java](https://playwright.dev/java/docs/intro)? ## Examples To learn how to run these Playwright Test examples, check out our [getting started docs](https://playwright.dev/docs/intro). #### Page screenshot This code snippet navigates to Playwright homepage and saves a screenshot. ```TypeScript import { test } from '@playwright/test'; test('Page Screenshot', async ({ page }) => { await page.goto('https://playwright.dev/'); await page.screenshot({ path: `example.png` }); }); ``` #### Mobile and geolocation This snippet emulates Mobile Safari on a device at given geolocation, navigates to maps.google.com, performs the action and takes a screenshot. ```TypeScript import { test, devices } from '@playwright/test'; test.use({ ...devices['iPhone 13 Pro'], locale: 'en-US', geolocation: { longitude: 12.492507, latitude: 41.889938 }, permissions: ['geolocation'], }) test('Mobile and geolocation', async ({ page }) => { await page.goto('https://maps.google.com'); await page.getByText('Your location').click(); await page.waitForRequest(/.*preview\/pwa/); await page.screenshot({ path: 'colosseum-iphone.png' }); }); ``` #### Evaluate in browser context This code snippet navigates to example.com, and executes a script in the page context. ```TypeScript import { test } from '@playwright/test'; test('Evaluate in browser context', async ({ page }) => { await page.goto('https://www.example.com/'); const dimensions = await page.evaluate(() => { return { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, deviceScaleFactor: window.devicePixelRatio } }); console.log(dimensions); }); ``` #### Intercept network requests This code snippet sets up request routing for a page to log all network requests. ```TypeScript import { test } from '@playwright/test'; test('Intercept network requests', async ({ page }) => { // Log and continue all network requests await page.route('**', route => { console.log(route.request().url()); route.continue(); }); await page.goto('http://todomvc.com'); }); ``` ## Resources * [Documentation](https://playwright.dev) * [API reference](https://playwright.dev/docs/api/class-playwright/) * [Contribution guide](CONTRIBUTING.md) * [Changelog](https://github.com/microsoft/playwright/releases) ## /SECURITY.md ## Security Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin). If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. ## Reporting Security Issues **Please do not report security vulnerabilities through public GitHub issues.** Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) * Full paths of source file(s) related to the manifestation of the issue * The location of the affected source code (tag/branch/commit or direct URL) * Any special configuration required to reproduce the issue * Step-by-step instructions to reproduce the issue * Proof-of-concept or exploit code (if possible) * Impact of the issue, including how an attacker might exploit the issue This information will help us triage your report more quickly. If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. ## Preferred Languages We prefer all communications to be in English. ## Policy Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). ## /SUPPORT.md # Support ## How to file issues and get help This project uses GitHub issues to track bugs and feature requests. Please search the [existing issues][gh-issues] before filing new ones to avoid duplicates. For new issues, file your bug or feature request as a new issue using corresponding template. For help and questions about using this project, please see the [docs site for Playwright][docs]. Join our community [Discord Server][discord-server] to connect with other developers using Playwright and ask questions in our 'help-playwright' forum. ## Microsoft Support Policy Support for Playwright is limited to the resources listed above. [gh-issues]: https://github.com/microsoft/playwright/issues/ [docs]: https://playwright.dev/ [discord-server]: https://aka.ms/playwright/discord ## /browser_patches/firefox/.gitignore ```gitignore path="/browser_patches/firefox/.gitignore" /checkout ``` ## /browser_patches/firefox/UPSTREAM_CONFIG.sh ```sh path="/browser_patches/firefox/UPSTREAM_CONFIG.sh" REMOTE_URL="https://github.com/mozilla/gecko-dev" BASE_BRANCH="release" BASE_REVISION="5e1efb776a56e399f6810204a2eca13f18a3eba6" ``` ## /browser_patches/firefox/juggler/Helper.js ```js path="/browser_patches/firefox/juggler/Helper.js" /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); class Helper { decorateAsEventEmitter(objectToDecorate) { const { EventEmitter } = ChromeUtils.import('resource://gre/modules/EventEmitter.jsm'); const emitter = new EventEmitter(); objectToDecorate.on = emitter.on.bind(emitter); objectToDecorate.addEventListener = emitter.on.bind(emitter); objectToDecorate.off = emitter.off.bind(emitter); objectToDecorate.removeEventListener = emitter.off.bind(emitter); objectToDecorate.once = emitter.once.bind(emitter); objectToDecorate.emit = emitter.emit.bind(emitter); } collectAllBrowsingContexts(rootBrowsingContext, allBrowsingContexts = []) { allBrowsingContexts.push(rootBrowsingContext); for (const child of rootBrowsingContext.children) this.collectAllBrowsingContexts(child, allBrowsingContexts); return allBrowsingContexts; } awaitTopic(topic) { return new Promise(resolve => { const listener = () => { Services.obs.removeObserver(listener, topic); resolve(); } Services.obs.addObserver(listener, topic); }); } toProtocolNavigationId(loadIdentifier) { return `nav-${loadIdentifier}`; } addObserver(handler, topic) { Services.obs.addObserver(handler, topic); return () => Services.obs.removeObserver(handler, topic); } addMessageListener(receiver, eventName, handler) { receiver.addMessageListener(eventName, handler); return () => receiver.removeMessageListener(eventName, handler); } addEventListener(receiver, eventName, handler, options) { receiver.addEventListener(eventName, handler, options); return () => { try { receiver.removeEventListener(eventName, handler, options); } catch (e) { // This could fail when window has navigated cross-process // and we remove the listener from WindowProxy. // Nothing we can do here - so ignore the error. } }; } awaitEvent(receiver, eventName) { return new Promise(resolve => { receiver.addEventListener(eventName, function listener() { receiver.removeEventListener(eventName, listener); resolve(); }); }); } on(receiver, eventName, handler, options) { // The toolkit/modules/EventEmitter.jsm dispatches event name as a first argument. // Fire event listeners without it for convenience. const handlerWrapper = (_, ...args) => handler(...args); receiver.on(eventName, handlerWrapper, options); return () => receiver.off(eventName, handlerWrapper); } addProgressListener(progress, listener, flags) { progress.addProgressListener(listener, flags); return () => progress.removeProgressListener(listener); } removeListeners(listeners) { for (const tearDown of listeners) tearDown.call(null); listeners.splice(0, listeners.length); } generateId() { const string = uuidGen.generateUUID().toString(); return string.substring(1, string.length - 1); } getLoadContext(channel) { let loadContext = null; try { if (channel.notificationCallbacks) loadContext = channel.notificationCallbacks.getInterface(Ci.nsILoadContext); } catch (e) {} try { if (!loadContext && channel.loadGroup) loadContext = channel.loadGroup.notificationCallbacks.getInterface(Ci.nsILoadContext); } catch (e) { } return loadContext; } getNetworkErrorStatusText(status) { if (!status) return null; for (const key of Object.keys(Cr)) { if (Cr[key] === status) return key; } // Security module. The following is taken from // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/How_to_check_the_secruity_state_of_an_XMLHTTPRequest_over_SSL if ((status & 0xff0000) === 0x5a0000) { // NSS_SEC errors (happen below the base value because of negative vals) if ((status & 0xffff) < Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE)) { // The bases are actually negative, so in our positive numeric space, we // need to subtract the base off our value. const nssErr = Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (status & 0xffff); switch (nssErr) { case 11: return 'SEC_ERROR_EXPIRED_CERTIFICATE'; case 12: return 'SEC_ERROR_REVOKED_CERTIFICATE'; case 13: return 'SEC_ERROR_UNKNOWN_ISSUER'; case 20: return 'SEC_ERROR_UNTRUSTED_ISSUER'; case 21: return 'SEC_ERROR_UNTRUSTED_CERT'; case 36: return 'SEC_ERROR_CA_CERT_INVALID'; case 90: return 'SEC_ERROR_INADEQUATE_KEY_USAGE'; case 176: return 'SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED'; default: return 'SEC_ERROR_UNKNOWN'; } } const sslErr = Math.abs(Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (status & 0xffff); switch (sslErr) { case 3: return 'SSL_ERROR_NO_CERTIFICATE'; case 4: return 'SSL_ERROR_BAD_CERTIFICATE'; case 8: return 'SSL_ERROR_UNSUPPORTED_CERTIFICATE_TYPE'; case 9: return 'SSL_ERROR_UNSUPPORTED_VERSION'; case 12: return 'SSL_ERROR_BAD_CERT_DOMAIN'; default: return 'SSL_ERROR_UNKNOWN'; } } return ''; } browsingContextToFrameId(browsingContext) { if (!browsingContext) return undefined; if (!browsingContext.parent) return 'mainframe-' + browsingContext.browserId; return 'subframe-' + browsingContext.id; } } const helper = new Helper(); class EventWatcher { constructor(receiver, eventNames, pendingEventWatchers = new Set()) { this._pendingEventWatchers = pendingEventWatchers; this._pendingEventWatchers.add(this); this._events = []; this._pendingPromises = []; this._eventListeners = eventNames.map(eventName => helper.on(receiver, eventName, this._onEvent.bind(this, eventName)), ); } _onEvent(eventName, eventObject) { this._events.push({eventName, eventObject}); for (const promise of this._pendingPromises) promise.resolve(); this._pendingPromises = []; } async ensureEvent(aEventName, predicate) { if (typeof aEventName !== 'string') throw new Error('ERROR: ensureEvent expects a "string" as its first argument'); while (true) { const result = this.getEvent(aEventName, predicate); if (result) return result; await new Promise((resolve, reject) => this._pendingPromises.push({resolve, reject})); } } async ensureEvents(eventNames, predicate) { if (!Array.isArray(eventNames)) throw new Error('ERROR: ensureEvents expects an array of event names as its first argument'); return await Promise.all(eventNames.map(eventName => this.ensureEvent(eventName, predicate))); } async ensureEventsAndDispose(eventNames, predicate) { if (!Array.isArray(eventNames)) throw new Error('ERROR: ensureEventsAndDispose expects an array of event names as its first argument'); const result = await this.ensureEvents(eventNames, predicate); this.dispose(); return result; } getEvent(aEventName, predicate = (eventObject) => true) { return this._events.find(({eventName, eventObject}) => eventName === aEventName && predicate(eventObject))?.eventObject; } hasEvent(aEventName, predicate) { return !!this.getEvent(aEventName, predicate); } dispose() { this._pendingEventWatchers.delete(this); for (const promise of this._pendingPromises) promise.reject(new Error('EventWatcher is being disposed')); this._pendingPromises = []; helper.removeListeners(this._eventListeners); } } var EXPORTED_SYMBOLS = [ "Helper", "EventWatcher" ]; this.Helper = Helper; this.EventWatcher = EventWatcher; ``` ## /browser_patches/firefox/juggler/JugglerFrameParent.jsm ```jsm path="/browser_patches/firefox/juggler/JugglerFrameParent.jsm" "use strict"; const { TargetRegistry } = ChromeUtils.import('chrome://juggler/content/TargetRegistry.js'); const { Helper } = ChromeUtils.import('chrome://juggler/content/Helper.js'); const helper = new Helper(); var EXPORTED_SYMBOLS = ['JugglerFrameParent']; class JugglerFrameParent extends JSWindowActorParent { constructor() { super(); } receiveMessage() { } async actorCreated() { // Actors are registered per the WindowGlobalParent / WindowGlobalChild pair. We are only // interested in those WindowGlobalParent actors that are matching current browsingContext // window global. // See https://github.com/mozilla/gecko-dev/blob/cd2121e7d83af1b421c95e8c923db70e692dab5f/testing/mochitest/BrowserTestUtils/BrowserTestUtilsParent.sys.mjs#L15 if (!this.manager?.isCurrentGlobal) return; // Only interested in main frames for now. if (this.browsingContext.parent) return; this._target = TargetRegistry.instance()?.targetForBrowserId(this.browsingContext.browserId); if (!this._target) return; this.actorName = `browser::page[${this._target.id()}]/${this.browsingContext.browserId}/${this.browsingContext.id}/${this._target.nextActorSequenceNumber()}`; this._target.setActor(this); } didDestroy() { if (!this._target) return; this._target.removeActor(this); } } ``` ## /browser_patches/firefox/juggler/NetworkObserver.js ```js path="/browser_patches/firefox/juggler/NetworkObserver.js" /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); const { ChannelEventSinkFactory } = ChromeUtils.import("chrome://remote/content/cdp/observers/ChannelEventSink.jsm"); const Cc = Components.classes; const Ci = Components.interfaces; const Cu = Components.utils; const Cr = Components.results; const Cm = Components.manager; const CC = Components.Constructor; const helper = new Helper(); const UINT32_MAX = Math.pow(2, 32)-1; const BinaryInputStream = CC('@mozilla.org/binaryinputstream;1', 'nsIBinaryInputStream', 'setInputStream'); const BinaryOutputStream = CC('@mozilla.org/binaryoutputstream;1', 'nsIBinaryOutputStream', 'setOutputStream'); const StorageStream = CC('@mozilla.org/storagestream;1', 'nsIStorageStream', 'init'); // Cap response storage with 100Mb per tracked tab. const MAX_RESPONSE_STORAGE_SIZE = 100 * 1024 * 1024; const pageNetworkSymbol = Symbol('PageNetwork'); class PageNetwork { static forPageTarget(target) { if (!target) return undefined; let result = target[pageNetworkSymbol]; if (!result) { result = new PageNetwork(target); target[pageNetworkSymbol] = result; } return result; } constructor(target) { helper.decorateAsEventEmitter(this); this._target = target; this._extraHTTPHeaders = null; this._responseStorage = new ResponseStorage(MAX_RESPONSE_STORAGE_SIZE, MAX_RESPONSE_STORAGE_SIZE / 10); this._requestInterceptionEnabled = false; // This is requestId => NetworkRequest map, only contains requests that are // awaiting interception action (abort, resume, fulfill) over the protocol. this._interceptedRequests = new Map(); } setExtraHTTPHeaders(headers) { this._extraHTTPHeaders = headers; } combinedExtraHTTPHeaders() { return [ ...(this._target.browserContext().extraHTTPHeaders || []), ...(this._extraHTTPHeaders || []), ]; } enableRequestInterception() { this._requestInterceptionEnabled = true; } disableRequestInterception() { this._requestInterceptionEnabled = false; for (const intercepted of this._interceptedRequests.values()) intercepted.resume(); this._interceptedRequests.clear(); } resumeInterceptedRequest(requestId, url, method, headers, postData) { this._takeIntercepted(requestId).resume(url, method, headers, postData); } fulfillInterceptedRequest(requestId, status, statusText, headers, base64body) { this._takeIntercepted(requestId).fulfill(status, statusText, headers, base64body); } abortInterceptedRequest(requestId, errorCode) { this._takeIntercepted(requestId).abort(errorCode); } getResponseBody(requestId) { if (!this._responseStorage) throw new Error('Responses are not tracked for the given browser'); return this._responseStorage.getBase64EncodedResponse(requestId); } _takeIntercepted(requestId) { const intercepted = this._interceptedRequests.get(requestId); if (!intercepted) throw new Error(`Cannot find request "${requestId}"`); this._interceptedRequests.delete(requestId); return intercepted; } } class NetworkRequest { constructor(networkObserver, httpChannel, redirectedFrom) { this._networkObserver = networkObserver; this.httpChannel = httpChannel; const loadInfo = this.httpChannel.loadInfo; const browsingContext = loadInfo?.frameBrowsingContext || loadInfo?.workerAssociatedBrowsingContext || loadInfo?.browsingContext; this._frameId = helper.browsingContextToFrameId(browsingContext); this.requestId = httpChannel.channelId + ''; this.navigationId = httpChannel.isMainDocumentChannel && loadInfo ? helper.toProtocolNavigationId(loadInfo.jugglerLoadIdentifier) : undefined; this._redirectedIndex = 0; if (redirectedFrom) { this.redirectedFromId = redirectedFrom.requestId; this._redirectedIndex = redirectedFrom._redirectedIndex + 1; this.requestId = this.requestId + '-redirect' + this._redirectedIndex; this.navigationId = redirectedFrom.navigationId; // Finish previous request now. Since we inherit the listener, we could in theory // use onStopRequest, but that will only happen after the last redirect has finished. redirectedFrom._sendOnRequestFinished(); } // In case of proxy auth, we get two requests with the same channel: // - one is pre-auth // - second is with auth header. // // In this case, we create this NetworkRequest object with a `redirectedFrom` // object, and they both share the same httpChannel. // // Since we want to maintain _channelToRequest map without clashes, // we must call `_sendOnRequestFinished` **before** we update it with a new object // here. if (this._networkObserver._channelToRequest.has(this.httpChannel)) throw new Error(`Internal Error: invariant is broken for _channelToRequest map`); this._networkObserver._channelToRequest.set(this.httpChannel, this); if (redirectedFrom) { this._pageNetwork = redirectedFrom._pageNetwork; } else if (browsingContext) { const target = this._networkObserver._targetRegistry.targetForBrowserId(browsingContext.browserId); this._pageNetwork = PageNetwork.forPageTarget(target); } this._expectingInterception = false; this._expectingResumedRequest = undefined; // { method, headers, postData } this._overriddenHeadersForRedirect = redirectedFrom?._overriddenHeadersForRedirect; this._sentOnResponse = false; this._fulfilled = false; if (this._overriddenHeadersForRedirect) overrideRequestHeaders(httpChannel, this._overriddenHeadersForRedirect); else if (this._pageNetwork) appendExtraHTTPHeaders(httpChannel, this._pageNetwork.combinedExtraHTTPHeaders()); this._responseBodyChunks = []; httpChannel.QueryInterface(Ci.nsITraceableChannel); this._originalListener = httpChannel.setNewListener(this); if (redirectedFrom) { // Listener is inherited for regular redirects, so we'd like to avoid // calling into previous NetworkRequest. this._originalListener = redirectedFrom._originalListener; } this._previousCallbacks = httpChannel.notificationCallbacks; httpChannel.notificationCallbacks = this; this.QueryInterface = ChromeUtils.generateQI([ Ci.nsIAuthPrompt2, Ci.nsIAuthPromptProvider, Ci.nsIInterfaceRequestor, Ci.nsINetworkInterceptController, Ci.nsIStreamListener, ]); if (this.redirectedFromId) { // Redirects are not interceptable. this._sendOnRequest(false); } } // Public interception API. resume(url, method, headers, postData) { this._expectingResumedRequest = { method, headers, postData }; const newUri = url ? Services.io.newURI(url) : null; this._interceptedChannel.resetInterceptionWithURI(newUri); this._interceptedChannel = undefined; } // Public interception API. abort(errorCode) { const error = errorMap[errorCode] || Cr.NS_ERROR_FAILURE; this._interceptedChannel.cancelInterception(error); this._interceptedChannel = undefined; } // Public interception API. fulfill(status, statusText, headers, base64body) { this._fulfilled = true; this._interceptedChannel.synthesizeStatus(status, statusText); for (const header of headers) { this._interceptedChannel.synthesizeHeader(header.name, header.value); if (header.name.toLowerCase() === 'set-cookie') { Services.cookies.QueryInterface(Ci.nsICookieService); for (const cookieString of header.value.split('\n')) Services.cookies.setCookieStringFromHttp(this.httpChannel.URI, cookieString, this.httpChannel); } } const synthesized = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream); if (base64body) synthesized.setByteStringData(atob(base64body)); this._interceptedChannel.startSynthesizedResponse(synthesized, null, null, '', false); this._interceptedChannel.finishSynthesizedResponse(); this._interceptedChannel = undefined; } // Instrumentation called by NetworkObserver. _onInternalRedirect(newChannel) { // Intercepted requests produce "internal redirects" - this is both for our own // interception and service workers. // An internal redirect does not necessarily have the same channelId, // but inherits notificationCallbacks and the listener, // and should be used instead of an old channel. this._networkObserver._channelToRequest.delete(this.httpChannel); this.httpChannel = newChannel; this._networkObserver._channelToRequest.set(this.httpChannel, this); } // Instrumentation called by NetworkObserver. _onInternalRedirectReady() { // Resumed request is first internally redirected to a new request, // and then the new request is ready to be updated. if (!this._expectingResumedRequest) return; const { method, headers, postData } = this._expectingResumedRequest; this._overriddenHeadersForRedirect = headers; this._expectingResumedRequest = undefined; if (headers) overrideRequestHeaders(this.httpChannel, headers); else if (this._pageNetwork) appendExtraHTTPHeaders(this.httpChannel, this._pageNetwork.combinedExtraHTTPHeaders()); if (method) this.httpChannel.requestMethod = method; if (postData !== undefined) setPostData(this.httpChannel, postData, headers); } // nsIInterfaceRequestor getInterface(iid) { if (iid.equals(Ci.nsIAuthPrompt2) || iid.equals(Ci.nsIAuthPromptProvider) || iid.equals(Ci.nsINetworkInterceptController)) return this; if (iid.equals(Ci.nsIAuthPrompt)) // Block nsIAuthPrompt - we want nsIAuthPrompt2 to be used instead. throw Cr.NS_ERROR_NO_INTERFACE; if (this._previousCallbacks) return this._previousCallbacks.getInterface(iid); throw Cr.NS_ERROR_NO_INTERFACE; } // nsIAuthPromptProvider getAuthPrompt(aPromptReason, iid) { return this; } // nsIAuthPrompt2 asyncPromptAuth(aChannel, aCallback, aContext, level, authInfo) { let canceled = false; Promise.resolve().then(() => { if (canceled) return; const hasAuth = this.promptAuth(aChannel, level, authInfo); if (hasAuth) aCallback.onAuthAvailable(aContext, authInfo); else aCallback.onAuthCancelled(aContext, true); }); return { QueryInterface: ChromeUtils.generateQI([Ci.nsICancelable]), cancel: () => { aCallback.onAuthCancelled(aContext, false); canceled = true; } }; } // nsIAuthPrompt2 promptAuth(aChannel, level, authInfo) { if (authInfo.flags & Ci.nsIAuthInformation.PREVIOUS_FAILED) return false; const pageNetwork = this._pageNetwork; if (!pageNetwork) return false; let credentials = null; if (authInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY) { const proxy = this._networkObserver._targetRegistry.getProxyInfo(aChannel); credentials = proxy ? {username: proxy.username, password: proxy.password} : null; } else { credentials = pageNetwork._target.browserContext().httpCredentials; } if (!credentials) return false; const origin = aChannel.URI.scheme + '://' + aChannel.URI.hostPort; if (credentials.origin && origin.toLowerCase() !== credentials.origin.toLowerCase()) return false; authInfo.username = credentials.username; authInfo.password = credentials.password; // This will produce a new request with respective auth header set. // It will have the same id as ours. We expect it to arrive as new request and // will treat it as our own redirect. this._networkObserver._expectRedirect(this.httpChannel.channelId + '', this); return true; } // nsINetworkInterceptController shouldPrepareForIntercept(aURI, channel) { const interceptController = this._fallThroughInterceptController(); if (interceptController && interceptController.shouldPrepareForIntercept(aURI, channel)) { // We assume that interceptController is a service worker if there is one, // and yield interception to it. We are not going to intercept ourselves, // so we send onRequest now. this._sendOnRequest(false); return true; } if (channel !== this.httpChannel) { // Not our channel? Just in case this happens, don't do anything. return false; } // We do not want to intercept any redirects, because we are not able // to intercept subresource redirects, and it's unreliable for main requests. // We do not sendOnRequest here, because redirects do that in constructor. if (this.redirectedFromId) return false; const shouldIntercept = this._shouldIntercept(); if (!shouldIntercept) { // We are not intercepting - ready to issue onRequest. this._sendOnRequest(false); return false; } this._expectingInterception = true; return true; } // nsINetworkInterceptController channelIntercepted(intercepted) { if (!this._expectingInterception) { // We are not intercepting, fall-through. const interceptController = this._fallThroughInterceptController(); if (interceptController) interceptController.channelIntercepted(intercepted); return; } this._expectingInterception = false; this._interceptedChannel = intercepted.QueryInterface(Ci.nsIInterceptedChannel); const pageNetwork = this._pageNetwork; if (!pageNetwork) { // Just in case we disabled instrumentation while intercepting, resume and forget. this.resume(); return; } // Ok, so now we have intercepted the request, let's issue onRequest. // If interception has been disabled while we were intercepting, resume and forget. const interceptionEnabled = this._shouldIntercept(); this._sendOnRequest(!!interceptionEnabled); if (interceptionEnabled) pageNetwork._interceptedRequests.set(this.requestId, this); else this.resume(); } // nsIStreamListener onDataAvailable(aRequest, aInputStream, aOffset, aCount) { // Turns out webcompat shims might redirect to // SimpleChannel, so we get requests from a different channel. // See https://github.com/microsoft/playwright/issues/9418#issuecomment-944836244 if (aRequest !== this.httpChannel) return; // For requests with internal redirect (e.g. intercepted by Service Worker), // we do not get onResponse normally, but we do get nsIStreamListener notifications. this._sendOnResponse(false); const iStream = new BinaryInputStream(aInputStream); const sStream = new StorageStream(8192, aCount, null); const oStream = new BinaryOutputStream(sStream.getOutputStream(0)); // Copy received data as they come. const data = iStream.readBytes(aCount); this._responseBodyChunks.push(data); oStream.writeBytes(data, aCount); try { this._originalListener.onDataAvailable(aRequest, sStream.newInputStream(0), aOffset, aCount); } catch (e) { // Be ready to original listener exceptions. } } // nsIStreamListener onStartRequest(aRequest) { // Turns out webcompat shims might redirect to // SimpleChannel, so we get requests from a different channel. // See https://github.com/microsoft/playwright/issues/9418#issuecomment-944836244 if (aRequest !== this.httpChannel) return; try { this._originalListener.onStartRequest(aRequest); } catch (e) { // Be ready to original listener exceptions. } } // nsIStreamListener onStopRequest(aRequest, aStatusCode) { // Turns out webcompat shims might redirect to // SimpleChannel, so we get requests from a different channel. // See https://github.com/microsoft/playwright/issues/9418#issuecomment-944836244 if (aRequest !== this.httpChannel) return; try { this._originalListener.onStopRequest(aRequest, aStatusCode); } catch (e) { // Be ready to original listener exceptions. } if (aStatusCode === 0) { // For requests with internal redirect (e.g. intercepted by Service Worker), // we do not get onResponse normally, but we do get nsIRequestObserver notifications. this._sendOnResponse(false); const body = this._responseBodyChunks.join(''); const pageNetwork = this._pageNetwork; if (pageNetwork) pageNetwork._responseStorage.addResponseBody(this, body); this._sendOnRequestFinished(); } else { this._sendOnRequestFailed(aStatusCode); } delete this._responseBodyChunks; } _shouldIntercept() { const pageNetwork = this._pageNetwork; if (!pageNetwork) return false; if (pageNetwork._requestInterceptionEnabled) return true; const browserContext = pageNetwork._target.browserContext(); if (browserContext.requestInterceptionEnabled) return true; return false; } _fallThroughInterceptController() { try { return this._previousCallbacks?.getInterface(Ci.nsINetworkInterceptController); } catch (e) { return undefined; } } _sendOnRequest(isIntercepted) { // Note: we call _sendOnRequest either after we intercepted the request, // or at the first moment we know that we are not going to intercept. const pageNetwork = this._pageNetwork; if (!pageNetwork) return; const loadInfo = this.httpChannel.loadInfo; const causeType = loadInfo?.externalContentPolicyType || Ci.nsIContentPolicy.TYPE_OTHER; const internalCauseType = loadInfo?.internalContentPolicyType || Ci.nsIContentPolicy.TYPE_OTHER; pageNetwork.emit(PageNetwork.Events.Request, { url: this.httpChannel.URI.spec, frameId: this._frameId, isIntercepted, requestId: this.requestId, redirectedFrom: this.redirectedFromId, postData: readRequestPostData(this.httpChannel), headers: requestHeaders(this.httpChannel), method: this.httpChannel.requestMethod, navigationId: this.navigationId, cause: causeTypeToString(causeType), internalCause: causeTypeToString(internalCauseType), }, this._frameId); } _sendOnResponse(fromCache, opt_statusCode, opt_statusText) { if (this._sentOnResponse) { // We can come here twice because of internal redirects, e.g. service workers. return; } this._sentOnResponse = true; const pageNetwork = this._pageNetwork; if (!pageNetwork) return; this.httpChannel.QueryInterface(Ci.nsIHttpChannelInternal); this.httpChannel.QueryInterface(Ci.nsITimedChannel); const timing = { startTime: this.httpChannel.channelCreationTime, domainLookupStart: this.httpChannel.domainLookupStartTime, domainLookupEnd: this.httpChannel.domainLookupEndTime, connectStart: this.httpChannel.connectStartTime, secureConnectionStart: this.httpChannel.secureConnectionStartTime, connectEnd: this.httpChannel.connectEndTime, requestStart: this.httpChannel.requestStartTime, responseStart: this.httpChannel.responseStartTime, }; const { status, statusText, headers } = responseHead(this.httpChannel, opt_statusCode, opt_statusText); let remoteIPAddress = undefined; let remotePort = undefined; try { remoteIPAddress = this.httpChannel.remoteAddress; remotePort = this.httpChannel.remotePort; } catch (e) { // remoteAddress is not defined for cached requests. } const fromServiceWorker = this._networkObserver._channelIdsFulfilledByServiceWorker.has(this.requestId); this._networkObserver._channelIdsFulfilledByServiceWorker.delete(this.requestId); pageNetwork.emit(PageNetwork.Events.Response, { requestId: this.requestId, securityDetails: getSecurityDetails(this.httpChannel), fromCache, headers, remoteIPAddress, remotePort, status, statusText, timing, fromServiceWorker, }, this._frameId); } _sendOnRequestFailed(error) { const pageNetwork = this._pageNetwork; if (pageNetwork) { pageNetwork.emit(PageNetwork.Events.RequestFailed, { requestId: this.requestId, errorCode: helper.getNetworkErrorStatusText(error), }, this._frameId); } this._networkObserver._channelToRequest.delete(this.httpChannel); } _sendOnRequestFinished() { const pageNetwork = this._pageNetwork; // Undefined |responseEndTime| means there has been no response yet. // This happens when request interception API is used to redirect // the request to a different URL. // In this case, we should not emit "requestFinished" event. if (pageNetwork && this.httpChannel.responseEndTime !== undefined) { let protocolVersion = undefined; try { protocolVersion = this.httpChannel.protocolVersion; } catch (e) { // protocolVersion is unavailable in certain cases. }; pageNetwork.emit(PageNetwork.Events.RequestFinished, { requestId: this.requestId, responseEndTime: this.httpChannel.responseEndTime, transferSize: this.httpChannel.transferSize, encodedBodySize: this.httpChannel.encodedBodySize, protocolVersion, }, this._frameId); } this._networkObserver._channelToRequest.delete(this.httpChannel); } } class NetworkObserver { static instance() { return NetworkObserver._instance || null; } constructor(targetRegistry) { helper.decorateAsEventEmitter(this); NetworkObserver._instance = this; this._targetRegistry = targetRegistry; this._channelToRequest = new Map(); // http channel -> network request this._expectedRedirect = new Map(); // expected redirect channel id (string) -> network request this._channelIdsFulfilledByServiceWorker = new Set(); // http channel ids that were fulfilled by service worker const protocolProxyService = Cc['@mozilla.org/network/protocol-proxy-service;1'].getService(); this._channelProxyFilter = { QueryInterface: ChromeUtils.generateQI([Ci.nsIProtocolProxyChannelFilter]), applyFilter: (channel, defaultProxyInfo, proxyFilter) => { const proxy = this._targetRegistry.getProxyInfo(channel); if (!proxy) { proxyFilter.onProxyFilterResult(defaultProxyInfo); return; } if (this._targetRegistry.shouldBustHTTPAuthCacheForProxy(proxy)) Services.obs.notifyObservers(null, "net:clear-active-logins"); proxyFilter.onProxyFilterResult(protocolProxyService.newProxyInfo( proxy.type, proxy.host, proxy.port, '', /* aProxyAuthorizationHeader */ '', /* aConnectionIsolationKey */ Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST, /* aFlags */ UINT32_MAX, /* aFailoverTimeout */ null, /* failover proxy */ )); }, }; protocolProxyService.registerChannelFilter(this._channelProxyFilter, 0 /* position */); // Register self as ChannelEventSink to track redirects. ChannelEventSinkFactory.getService().registerCollector({ _onChannelRedirect: this._onRedirect.bind(this), }); this._eventListeners = [ helper.addObserver(this._onRequest.bind(this), 'http-on-modify-request'), helper.addObserver(this._onResponse.bind(this, false /* fromCache */), 'http-on-examine-response'), helper.addObserver(this._onResponse.bind(this, true /* fromCache */), 'http-on-examine-cached-response'), helper.addObserver(this._onResponse.bind(this, true /* fromCache */), 'http-on-examine-merged-response'), helper.addObserver(this._onServiceWorkerResponse.bind(this), 'service-worker-synthesized-response'), ]; } _expectRedirect(channelId, previous) { this._expectedRedirect.set(channelId, previous); } _onRedirect(oldChannel, newChannel, flags) { if (!(oldChannel instanceof Ci.nsIHttpChannel) || !(newChannel instanceof Ci.nsIHttpChannel)) return; const oldHttpChannel = oldChannel.QueryInterface(Ci.nsIHttpChannel); const newHttpChannel = newChannel.QueryInterface(Ci.nsIHttpChannel); const request = this._channelToRequest.get(oldHttpChannel); if (flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL) { if (request) request._onInternalRedirect(newHttpChannel); } else if (flags & Ci.nsIChannelEventSink.REDIRECT_STS_UPGRADE) { if (request) { // This is an internal HSTS upgrade. The original http request is canceled, and a new // equivalent https request is sent. We forge 307 redirect to follow Chromium here: // https://source.chromium.org/chromium/chromium/src/+/main:net/url_request/url_request_http_job.cc;l=211 request._sendOnResponse(false, 307, 'Temporary Redirect'); this._expectRedirect(newHttpChannel.channelId + '', request); } } else { if (request) this._expectRedirect(newHttpChannel.channelId + '', request); } } _onRequest(channel, topic) { if (!(channel instanceof Ci.nsIHttpChannel)) return; const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); const channelId = httpChannel.channelId + ''; const redirectedFrom = this._expectedRedirect.get(channelId); if (redirectedFrom) { this._expectedRedirect.delete(channelId); new NetworkRequest(this, httpChannel, redirectedFrom); } else { const redirectedRequest = this._channelToRequest.get(httpChannel); if (redirectedRequest) redirectedRequest._onInternalRedirectReady(); else new NetworkRequest(this, httpChannel); } } _onResponse(fromCache, httpChannel, topic) { const request = this._channelToRequest.get(httpChannel); if (request) request._sendOnResponse(fromCache); } _onServiceWorkerResponse(channel, topic) { if (!(channel instanceof Ci.nsIHttpChannel)) return; const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); const channelId = httpChannel.channelId + ''; this._channelIdsFulfilledByServiceWorker.add(channelId); } dispose() { this._activityDistributor.removeObserver(this); ChannelEventSinkFactory.unregister(); helper.removeListeners(this._eventListeners); } } const protocolVersionNames = { [Ci.nsITransportSecurityInfo.TLS_VERSION_1]: 'TLS 1', [Ci.nsITransportSecurityInfo.TLS_VERSION_1_1]: 'TLS 1.1', [Ci.nsITransportSecurityInfo.TLS_VERSION_1_2]: 'TLS 1.2', [Ci.nsITransportSecurityInfo.TLS_VERSION_1_3]: 'TLS 1.3', }; function getSecurityDetails(httpChannel) { const securityInfo = httpChannel.securityInfo; if (!securityInfo) return null; securityInfo.QueryInterface(Ci.nsITransportSecurityInfo); if (!securityInfo.serverCert) return null; return { protocol: protocolVersionNames[securityInfo.protocolVersion] || '', subjectName: securityInfo.serverCert.commonName, issuer: securityInfo.serverCert.issuerCommonName, // Convert to seconds. validFrom: securityInfo.serverCert.validity.notBefore / 1000 / 1000, validTo: securityInfo.serverCert.validity.notAfter / 1000 / 1000, }; } function readRequestPostData(httpChannel) { if (!(httpChannel instanceof Ci.nsIUploadChannel)) return undefined; let iStream = httpChannel.uploadStream; if (!iStream) return undefined; const isSeekableStream = iStream instanceof Ci.nsISeekableStream; const isTellableStream = iStream instanceof Ci.nsITellableStream; // For some reason, we cannot rewind back big streams, // so instead we should clone them. const isCloneable = iStream instanceof Ci.nsICloneableInputStream; if (isCloneable) iStream = iStream.clone(); let prevOffset; // Surprisingly, stream might implement `nsITellableStream` without // implementing the `tell` method. if (isSeekableStream && isTellableStream && iStream.tell) { prevOffset = iStream.tell(); iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); } // Read data from the stream. let result = undefined; try { const maxLen = iStream.available(); // Cap at 10Mb. if (maxLen <= 10 * 1024 * 1024) { const buffer = NetUtil.readInputStreamToString(iStream, maxLen); result = btoa(buffer); } } catch (err) { } // Seek locks the file, so seek to the beginning only if necko hasn't // read it yet, since necko doesn't seek to 0 before reading (at lest // not till 459384 is fixed). if (isSeekableStream && prevOffset == 0 && !isCloneable) iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); return result; } function requestHeaders(httpChannel) { const headers = []; httpChannel.visitRequestHeaders({ visitHeader: (name, value) => headers.push({name, value}), }); return headers; } function clearRequestHeaders(httpChannel) { for (const header of requestHeaders(httpChannel)) { // We cannot remove the "host" header. if (header.name.toLowerCase() === 'host') continue; httpChannel.setRequestHeader(header.name, '', false /* merge */); } } function overrideRequestHeaders(httpChannel, headers) { clearRequestHeaders(httpChannel); appendExtraHTTPHeaders(httpChannel, headers); } function causeTypeToString(causeType) { for (let key in Ci.nsIContentPolicy) { if (Ci.nsIContentPolicy[key] === causeType) return key; } return 'TYPE_OTHER'; } function appendExtraHTTPHeaders(httpChannel, headers) { if (!headers) return; for (const header of headers) httpChannel.setRequestHeader(header.name, header.value, false /* merge */); } class ResponseStorage { constructor(maxTotalSize, maxResponseSize) { this._totalSize = 0; this._maxResponseSize = maxResponseSize; this._maxTotalSize = maxTotalSize; this._responses = new Map(); } addResponseBody(request, body) { if (body.length > this._maxResponseSize) { this._responses.set(request.requestId, { evicted: true, body: '', }); return; } let encodings = []; // Note: fulfilled request comes with decoded body right away. if ((request.httpChannel instanceof Ci.nsIEncodedChannel) && request.httpChannel.contentEncodings && !request.httpChannel.applyConversion && !request._fulfilled) { const encodingHeader = request.httpChannel.getResponseHeader("Content-Encoding"); encodings = encodingHeader.split(/\s*\t*,\s*\t*/); } this._responses.set(request.requestId, {body, encodings}); this._totalSize += body.length; if (this._totalSize > this._maxTotalSize) { for (let [requestId, response] of this._responses) { this._totalSize -= response.body.length; response.body = ''; response.evicted = true; if (this._totalSize < this._maxTotalSize) break; } } } getBase64EncodedResponse(requestId) { const response = this._responses.get(requestId); if (!response) throw new Error(`Request "${requestId}" is not found`); if (response.evicted) return {base64body: '', evicted: true}; let result = response.body; if (response.encodings && response.encodings.length) { for (const encoding of response.encodings) result = convertString(result, encoding, 'uncompressed'); } return {base64body: btoa(result)}; } } function responseHead(httpChannel, opt_statusCode, opt_statusText) { const headers = []; let status = opt_statusCode || 0; let statusText = opt_statusText || ''; try { status = httpChannel.responseStatus; statusText = httpChannel.responseStatusText; httpChannel.visitResponseHeaders({ visitHeader: (name, value) => headers.push({name, value}), }); } catch (e) { // Response headers, status and/or statusText are not available // when redirect did not actually hit the network. } return { status, statusText, headers }; } function setPostData(httpChannel, postData, headers) { if (!(httpChannel instanceof Ci.nsIUploadChannel2)) return; const synthesized = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream); const body = atob(postData); synthesized.setByteStringData(body); const overriddenHeader = (lowerCaseName) => { if (headers) { for (const header of headers) { if (header.name.toLowerCase() === lowerCaseName) { return header.value; } } } return undefined; } // Clear content-length, so that upload stream resets it. httpChannel.setRequestHeader('content-length', '', false /* merge */); let contentType = overriddenHeader('content-type'); if (contentType === undefined) { try { contentType = httpChannel.getRequestHeader('content-type'); } catch (e) { if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) contentType = 'application/octet-stream'; else throw e; } } httpChannel.explicitSetUploadStream(synthesized, contentType, -1, httpChannel.requestMethod, false); } function convertString(s, source, dest) { const is = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( Ci.nsIStringInputStream ); is.setByteStringData(s); const listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance( Ci.nsIStreamLoader ); let result = []; listener.init({ onStreamComplete: function onStreamComplete( loader, context, status, length, data ) { const array = Array.from(data); const kChunk = 100000; for (let i = 0; i < length; i += kChunk) { const len = Math.min(kChunk, length - i); const chunk = String.fromCharCode.apply(this, array.slice(i, i + len)); result.push(chunk); } }, }); const converter = Cc["@mozilla.org/streamConverters;1"].getService( Ci.nsIStreamConverterService ).asyncConvertData( source, dest, listener, null ); converter.onStartRequest(null, null); converter.onDataAvailable(null, is, 0, s.length); converter.onStopRequest(null, null, null); return result.join(''); } const errorMap = { 'aborted': Cr.NS_ERROR_ABORT, 'accessdenied': Cr.NS_ERROR_PORT_ACCESS_NOT_ALLOWED, 'addressunreachable': Cr.NS_ERROR_UNKNOWN_HOST, 'blockedbyclient': Cr.NS_ERROR_FAILURE, 'blockedbyresponse': Cr.NS_ERROR_FAILURE, 'connectionaborted': Cr.NS_ERROR_NET_INTERRUPT, 'connectionclosed': Cr.NS_ERROR_FAILURE, 'connectionfailed': Cr.NS_ERROR_FAILURE, 'connectionrefused': Cr.NS_ERROR_CONNECTION_REFUSED, 'connectionreset': Cr.NS_ERROR_NET_RESET, 'internetdisconnected': Cr.NS_ERROR_OFFLINE, 'namenotresolved': Cr.NS_ERROR_UNKNOWN_HOST, 'timedout': Cr.NS_ERROR_NET_TIMEOUT, 'failed': Cr.NS_ERROR_FAILURE, }; PageNetwork.Events = { Request: Symbol('PageNetwork.Events.Request'), Response: Symbol('PageNetwork.Events.Response'), RequestFinished: Symbol('PageNetwork.Events.RequestFinished'), RequestFailed: Symbol('PageNetwork.Events.RequestFailed'), }; var EXPORTED_SYMBOLS = ['NetworkObserver', 'PageNetwork']; this.NetworkObserver = NetworkObserver; this.PageNetwork = PageNetwork; ``` ## /browser_patches/firefox/juggler/SimpleChannel.js ```js path="/browser_patches/firefox/juggler/SimpleChannel.js" /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; // Note: this file should be loadabale with eval() into worker environment. // Avoid Components.*, ChromeUtils and global const variables. const SIMPLE_CHANNEL_MESSAGE_NAME = 'juggler:simplechannel'; class SimpleChannel { constructor(name, uid) { this._name = name; this._messageId = 0; this._connectorId = 0; this._pendingMessages = new Map(); this._handlers = new Map(); this._bufferedIncomingMessages = []; this.transport = { sendMessage: null, dispose: () => {}, }; this._ready = false; this._paused = false; this._disposed = false; this._bufferedResponses = new Map(); // This is a "unique" identifier of this end of the channel. Two SimpleChannel instances // on the same end of the channel (e.g. two content processes) must not have the same id. // This way, the other end can distinguish between the old peer with a new transport and a new peer. this._uid = uid; this._connectedToUID = undefined; } bindToActor(actor) { this.resetTransport(); this._name = actor.actorName; const oldReceiveMessage = actor.receiveMessage; actor.receiveMessage = message => this._onMessage(message.data); this.setTransport({ sendMessage: obj => actor.sendAsyncMessage(SIMPLE_CHANNEL_MESSAGE_NAME, obj), dispose: () => actor.receiveMessage = oldReceiveMessage, }); } resetTransport() { this.transport.dispose(); this.transport = { sendMessage: null, dispose: () => {}, }; this._ready = false; } setTransport(transport) { this.transport = transport; // connection handshake: // 1. There are two channel ends in different processes. // 2. Both ends start in the `ready = false` state, meaning that they will // not send any messages over transport. // 3. Once channel end is created, it sends { ack: `READY` } message to the other end. // 4. Eventually, at least one of the ends receives { ack: `READY` } message and responds with // { ack: `READY_ACK` }. We assume at least one of the ends will receive { ack: "READY" } event from the other, since // channel ends have a "parent-child" relation, i.e. one end is always created before the other one. // 5. Once channel end receives either { ack: `READY` } or { ack: `READY_ACK` }, it transitions to `ready` state. this.transport.sendMessage({ ack: 'READY', uid: this._uid }); } pause() { this._paused = true; } resumeSoon() { if (!this._paused) return; this._paused = false; this._setTimeout(() => this._deliverBufferedIncomingMessages(), 0); } _setTimeout(cb, timeout) { // Lazy load on first call. this._setTimeout = ChromeUtils.import('resource://gre/modules/Timer.jsm').setTimeout; this._setTimeout(cb, timeout); } _markAsReady() { if (this._ready) return; this._ready = true; for (const { message } of this._pendingMessages.values()) this.transport.sendMessage(message); } dispose() { if (this._disposed) return; this._disposed = true; for (const {resolve, reject, methodName} of this._pendingMessages.values()) reject(new Error(`Failed "${methodName}": ${this._name} is disposed.`)); this._pendingMessages.clear(); this._handlers.clear(); this.transport.dispose(); } _rejectCallbacksFromConnector(connectorId) { for (const [messageId, callback] of this._pendingMessages) { if (callback.connectorId === connectorId) { callback.reject(new Error(`Failed "${callback.methodName}": connector for namespace "${callback.namespace}" in channel "${this._name}" is disposed.`)); this._pendingMessages.delete(messageId); } } } connect(namespace) { const connectorId = ++this._connectorId; return { send: (...args) => this._send(namespace, connectorId, ...args), emit: (...args) => void this._send(namespace, connectorId, ...args).catch(e => {}), dispose: () => this._rejectCallbacksFromConnector(connectorId), }; } register(namespace, handler) { if (this._handlers.has(namespace)) throw new Error('ERROR: double-register for namespace ' + namespace); this._handlers.set(namespace, handler); this._deliverBufferedIncomingMessages(); return () => this.unregister(namespace); } _deliverBufferedIncomingMessages() { const bufferedRequests = this._bufferedIncomingMessages; this._bufferedIncomingMessages = []; for (const data of bufferedRequests) { this._onMessage(data); } } unregister(namespace) { this._handlers.delete(namespace); } /** * @param {string} namespace * @param {number} connectorId * @param {string} methodName * @param {...*} params * @return {!Promise<*>} */ async _send(namespace, connectorId, methodName, ...params) { if (this._disposed) throw new Error(`ERROR: channel ${this._name} is already disposed! Cannot send "${methodName}" to "${namespace}"`); const id = ++this._messageId; const message = {requestId: id, methodName, params, namespace}; const promise = new Promise((resolve, reject) => { this._pendingMessages.set(id, {connectorId, resolve, reject, methodName, namespace, message}); }); if (this._ready) this.transport.sendMessage(message); return promise; } _onMessage(data) { if (data?.ack === 'READY') { // The "READY" and "READY_ACK" messages are a part of initialization sequence. // This sequence happens when: // 1. A new SimpleChannel instance is getting initialized on the other end. // In this case, it will have a different UID and we must clear // `this._bufferedResponses` since they are no longer relevant. // 2. A new transport is assigned to communicate between 2 SimpleChannel instances. // In this case, we MUST NOT clear `this._bufferedResponses` since they are used // to address the double-dispatch issue. if (this._connectedToUID !== data.uid) this._bufferedResponses.clear(); this._connectedToUID = data.uid; this.transport.sendMessage({ ack: 'READY_ACK', uid: this._uid }); this._markAsReady(); return; } if (data?.ack === 'READY_ACK') { if (this._connectedToUID !== data.uid) this._bufferedResponses.clear(); this._connectedToUID = data.uid; this._markAsReady(); return; } if (data?.ack === 'RESPONSE_ACK') { this._bufferedResponses.delete(data.responseId); return; } if (this._paused) this._bufferedIncomingMessages.push(data); else this._onMessageInternal(data); } async _onMessageInternal(data) { if (data.responseId) { this.transport.sendMessage({ ack: 'RESPONSE_ACK', responseId: data.responseId }); const message = this._pendingMessages.get(data.responseId); if (!message) { // During cross-process navigation, we might receive a response for // the message sent by another process. return; } this._pendingMessages.delete(data.responseId); if (data.error) message.reject(new Error(data.error)); else message.resolve(data.result); } else if (data.requestId) { // When the underlying transport gets replaced, some responses might // not get delivered. As a result, sender will repeat the same request once // a new transport gets set. // // If this request was already processed, we can fulfill it with the cached response // and fast-return. if (this._bufferedResponses.has(data.requestId)) { this.transport.sendMessage(this._bufferedResponses.get(data.requestId)); return; } const namespace = data.namespace; const handler = this._handlers.get(namespace); if (!handler) { this._bufferedIncomingMessages.push(data); return; } const method = handler[data.methodName]; if (!method) { this.transport.sendMessage({responseId: data.requestId, error: `error in channel "${this._name}": No method "${data.methodName}" in namespace "${namespace}"`}); return; } let response; const connectedToUID = this._connectedToUID; try { const result = await method.call(handler, ...data.params); response = {responseId: data.requestId, result}; } catch (error) { response = {responseId: data.requestId, error: `error in channel "${this._name}": exception while running method "${data.methodName}" in namespace "${namespace}": ${error.message} ${error.stack}`}; } // The connection might have changed during the ASYNCHRONOUS handler execution. // We only need to buffer & send response if we are connected to the same // end. if (connectedToUID === this._connectedToUID) { this._bufferedResponses.set(data.requestId, response); this.transport.sendMessage(response); } } else { dump(`WARNING: unknown message in channel "${this._name}": ${JSON.stringify(data)}\n`); } } } var EXPORTED_SYMBOLS = ['SimpleChannel']; this.SimpleChannel = SimpleChannel; ``` ## /browser_patches/firefox/juggler/TargetRegistry.js ```js path="/browser_patches/firefox/juggler/TargetRegistry.js" /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); const {SimpleChannel} = ChromeUtils.import('chrome://juggler/content/SimpleChannel.js'); const {Preferences} = ChromeUtils.import("resource://gre/modules/Preferences.jsm"); const {ContextualIdentityService} = ChromeUtils.import("resource://gre/modules/ContextualIdentityService.jsm"); const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); const {AppConstants} = ChromeUtils.import("resource://gre/modules/AppConstants.jsm"); const Cr = Components.results; const helper = new Helper(); const IDENTITY_NAME = 'JUGGLER '; const HUNDRED_YEARS = 60 * 60 * 24 * 365 * 100; const ALL_PERMISSIONS = [ 'geo', 'desktop-notification', ]; let globalTabAndWindowActivationChain = Promise.resolve(); // This is a workaround for https://github.com/microsoft/playwright/issues/34586 let globalNewPageChain = Promise.resolve(); class DownloadInterceptor { constructor(registry) { this._registry = registry this._handlerToUuid = new Map(); this._uuidToHandler = new Map(); } // // nsIDownloadInterceptor implementation. // interceptDownloadRequest(externalAppHandler, request, browsingContext, outFile) { if (!(request instanceof Ci.nsIChannel)) return false; const channel = request.QueryInterface(Ci.nsIChannel); let pageTarget = this._registry._browserIdToTarget.get(channel.loadInfo.browsingContext.top.browserId); if (!pageTarget) return false; const browserContext = pageTarget.browserContext(); const options = browserContext.downloadOptions; if (!options) return false; const uuid = helper.generateId(); let file = null; if (options.behavior === 'saveToDisk') { file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); file.initWithPath(options.downloadsDir); file.append(uuid); try { file.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); } catch (e) { dump(`WARNING: interceptDownloadRequest failed to create file: ${e}\n`); return false; } } outFile.value = file; this._handlerToUuid.set(externalAppHandler, uuid); this._uuidToHandler.set(uuid, externalAppHandler); const downloadInfo = { uuid, browserContextId: browserContext.browserContextId, pageTargetId: pageTarget.id(), frameId: helper.browsingContextToFrameId(channel.loadInfo.browsingContext), url: request.name, suggestedFileName: externalAppHandler.suggestedFileName, }; this._registry.emit(TargetRegistry.Events.DownloadCreated, downloadInfo); return true; } onDownloadComplete(externalAppHandler, canceled, errorName) { const uuid = this._handlerToUuid.get(externalAppHandler); if (!uuid) return; this._handlerToUuid.delete(externalAppHandler); this._uuidToHandler.delete(uuid); const downloadInfo = { uuid, error: errorName, }; if (canceled === 'NS_BINDING_ABORTED') { downloadInfo.canceled = true; } this._registry.emit(TargetRegistry.Events.DownloadFinished, downloadInfo); } async cancelDownload(uuid) { const externalAppHandler = this._uuidToHandler.get(uuid); if (!externalAppHandler) { return; } await externalAppHandler.cancel(Cr.NS_BINDING_ABORTED); } } const screencastService = Cc['@mozilla.org/juggler/screencast;1'].getService(Ci.nsIScreencastService); class TargetRegistry { static instance() { return TargetRegistry._instance || null; } constructor() { helper.decorateAsEventEmitter(this); TargetRegistry._instance = this; this._browserContextIdToBrowserContext = new Map(); this._userContextIdToBrowserContext = new Map(); this._browserToTarget = new Map(); this._browserIdToTarget = new Map(); this._proxiesWithClashingAuthCacheKeys = new Set(); this._browserProxy = null; // Cleanup containers from previous runs (if any) for (const identity of ContextualIdentityService.getPublicIdentities()) { if (identity.name && identity.name.startsWith(IDENTITY_NAME)) { ContextualIdentityService.remove(identity.userContextId); ContextualIdentityService.closeContainerTabs(identity.userContextId); } } this._defaultContext = new BrowserContext(this, undefined, undefined); Services.obs.addObserver({ observe: (subject, topic, data) => { const browser = subject.ownerElement; if (!browser) return; const target = this._browserToTarget.get(browser); if (!target) return; target.emit(PageTarget.Events.Crashed); target.dispose(); } }, 'oop-frameloader-crashed'); const onTabOpenListener = (appWindow, window, event) => { const tab = event.target; const userContextId = tab.userContextId; const browserContext = this._userContextIdToBrowserContext.get(userContextId); const hasExplicitSize = appWindow && (appWindow.chromeFlags & Ci.nsIWebBrowserChrome.JUGGLER_WINDOW_EXPLICIT_SIZE) !== 0; const openerContext = tab.linkedBrowser.browsingContext.opener; let openerTarget; if (openerContext) { // Popups usually have opener context. Get top context for the case when opener is // an iframe. openerTarget = this._browserIdToTarget.get(openerContext.top.browserId); } else if (tab.openerTab) { // Noopener popups from the same window have opener tab instead. openerTarget = this._browserToTarget.get(tab.openerTab.linkedBrowser); } if (!browserContext) throw new Error(`Internal error: cannot find context for userContextId=${userContextId}`); const target = new PageTarget(this, window, tab, browserContext, openerTarget); target.updateOverridesForBrowsingContext(tab.linkedBrowser.browsingContext); if (!hasExplicitSize) target.updateViewportSize(); if (browserContext.videoRecordingOptions) target._startVideoRecording(browserContext.videoRecordingOptions); }; const onTabCloseListener = event => { const tab = event.target; const linkedBrowser = tab.linkedBrowser; const target = this._browserToTarget.get(linkedBrowser); if (target) target.dispose(); }; const domWindowTabListeners = new Map(); const onOpenWindow = async (appWindow) => { let domWindow; if (appWindow instanceof Ci.nsIAppWindow) { domWindow = appWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowInternal || Ci.nsIDOMWindow); } else { domWindow = appWindow; appWindow = null; } if (!domWindow.isChromeWindow) return; // In persistent mode, window might be opened long ago and might be // already initialized. // // In this case, we want to keep this callback synchronous so that we will call // `onTabOpenListener` synchronously and before the sync IPc message `juggler:content-ready`. if (domWindow.document.readyState === 'uninitialized' || domWindow.document.readyState === 'loading') { // For non-initialized windows, DOMContentLoaded initializes gBrowser // and starts tab loading (see //browser/base/content/browser.js), so we // are guaranteed to call `onTabOpenListener` before the sync IPC message // `juggler:content-ready`. await helper.awaitEvent(domWindow, 'DOMContentLoaded'); } if (!domWindow.gBrowser) return; const tabContainer = domWindow.gBrowser.tabContainer; domWindowTabListeners.set(domWindow, [ helper.addEventListener(tabContainer, 'TabOpen', event => onTabOpenListener(appWindow, domWindow, event)), helper.addEventListener(tabContainer, 'TabClose', onTabCloseListener), ]); for (const tab of domWindow.gBrowser.tabs) onTabOpenListener(appWindow, domWindow, { target: tab }); }; const onCloseWindow = window => { const domWindow = window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowInternal || Ci.nsIDOMWindow); if (!domWindow.isChromeWindow) return; if (!domWindow.gBrowser) return; const listeners = domWindowTabListeners.get(domWindow) || []; domWindowTabListeners.delete(domWindow); helper.removeListeners(listeners); for (const tab of domWindow.gBrowser.tabs) onTabCloseListener({ target: tab }); }; const extHelperAppSvc = Cc["@mozilla.org/uriloader/external-helper-app-service;1"].getService(Ci.nsIExternalHelperAppService); this._downloadInterceptor = new DownloadInterceptor(this); extHelperAppSvc.setDownloadInterceptor(this._downloadInterceptor); Services.wm.addListener({ onOpenWindow, onCloseWindow }); for (const win of Services.wm.getEnumerator(null)) onOpenWindow(win); } // Firefox uses nsHttpAuthCache to cache authentication to the proxy. // If we're provided with a single proxy with a multiple different authentications, then // we should clear the nsHttpAuthCache on every request. shouldBustHTTPAuthCacheForProxy(proxy) { return this._proxiesWithClashingAuthCacheKeys.has(proxy); } _updateProxiesWithSameAuthCacheAndDifferentCredentials() { const proxyIdToCredentials = new Map(); const allProxies = [...this._browserContextIdToBrowserContext.values()].map(bc => bc._proxy).filter(Boolean); if (this._browserProxy) allProxies.push(this._browserProxy); const proxyAuthCacheKeyAndProxy = allProxies.map(proxy => [ JSON.stringify({ type: proxy.type, host: proxy.host, port: proxy.port, }), proxy, ]); this._proxiesWithClashingAuthCacheKeys.clear(); proxyAuthCacheKeyAndProxy.sort(([cacheKey1], [cacheKey2]) => cacheKey1 < cacheKey2 ? -1 : 1); for (let i = 0; i < proxyAuthCacheKeyAndProxy.length - 1; ++i) { const [cacheKey1, proxy1] = proxyAuthCacheKeyAndProxy[i]; const [cacheKey2, proxy2] = proxyAuthCacheKeyAndProxy[i + 1]; if (cacheKey1 !== cacheKey2) continue; if (proxy1.username === proxy2.username && proxy1.password === proxy2.password) continue; // `proxy1` and `proxy2` have the same caching key, but serve different credentials. // We have to bust HTTP Auth Cache everytime there's a request that will use either of the proxies. this._proxiesWithClashingAuthCacheKeys.add(proxy1); this._proxiesWithClashingAuthCacheKeys.add(proxy2); } } async cancelDownload(options) { this._downloadInterceptor.cancelDownload(options.uuid); } setBrowserProxy(proxy) { this._browserProxy = proxy; this._updateProxiesWithSameAuthCacheAndDifferentCredentials(); } getProxyInfo(channel) { const originAttributes = channel.loadInfo && channel.loadInfo.originAttributes; const browserContext = originAttributes ? this.browserContextForUserContextId(originAttributes.userContextId) : null; // Prefer context proxy and fallback to browser-level proxy. const proxyInfo = (browserContext && browserContext._proxy) || this._browserProxy; if (!proxyInfo || proxyInfo.bypass.some(domainSuffix => channel.URI.host.endsWith(domainSuffix))) return null; return proxyInfo; } defaultContext() { return this._defaultContext; } createBrowserContext(removeOnDetach) { return new BrowserContext(this, helper.generateId(), removeOnDetach); } browserContextForId(browserContextId) { return this._browserContextIdToBrowserContext.get(browserContextId); } browserContextForUserContextId(userContextId) { return this._userContextIdToBrowserContext.get(userContextId); } async newPage({browserContextId}) { const result = globalNewPageChain.then(async () => { const browserContext = this.browserContextForId(browserContextId); const features = "chrome,dialog=no,all"; // See _callWithURIToLoad in browser.js for the structure of window.arguments // window.arguments[1]: unused (bug 871161) // [2]: referrerInfo (nsIReferrerInfo) // [3]: postData (nsIInputStream) // [4]: allowThirdPartyFixup (bool) // [5]: userContextId (int) // [6]: originPrincipal (nsIPrincipal) // [7]: originStoragePrincipal (nsIPrincipal) // [8]: triggeringPrincipal (nsIPrincipal) // [9]: allowInheritPrincipal (bool) // [10]: csp (nsIContentSecurityPolicy) // [11]: nsOpenWindowInfo const args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); const urlSupports = Cc["@mozilla.org/supports-string;1"].createInstance( Ci.nsISupportsString ); urlSupports.data = 'about:blank'; args.appendElement(urlSupports); // 0 args.appendElement(undefined); // 1 args.appendElement(undefined); // 2 args.appendElement(undefined); // 3 args.appendElement(undefined); // 4 const userContextIdSupports = Cc[ "@mozilla.org/supports-PRUint32;1" ].createInstance(Ci.nsISupportsPRUint32); userContextIdSupports.data = browserContext.userContextId; args.appendElement(userContextIdSupports); // 5 args.appendElement(undefined); // 6 args.appendElement(undefined); // 7 args.appendElement(Services.scriptSecurityManager.getSystemPrincipal()); // 8 const window = Services.ww.openWindow(null, AppConstants.BROWSER_CHROME_URL, '_blank', features, args); await waitForWindowReady(window); if (window.gBrowser.browsers.length !== 1) throw new Error(`Unexpected number of tabs in the new window: ${window.gBrowser.browsers.length}`); const browser = window.gBrowser.browsers[0]; let target = this._browserToTarget.get(browser); while (!target) { await helper.awaitEvent(this, TargetRegistry.Events.TargetCreated); target = this._browserToTarget.get(browser); } browser.focus(); if (browserContext.crossProcessCookie.settings.timezoneId) { if (await target.hasFailedToOverrideTimezone()) throw new Error('Failed to override timezone'); } return target.id(); }); globalNewPageChain = result.catch(error => { /* swallow errors to keep chain running */ }); return result; } targets() { return Array.from(this._browserToTarget.values()); } targetForBrowser(browser) { return this._browserToTarget.get(browser); } targetForBrowserId(browserId) { return this._browserIdToTarget.get(browserId); } } class PageTarget { constructor(registry, win, tab, browserContext, opener) { helper.decorateAsEventEmitter(this); this._targetId = helper.generateId(); this._registry = registry; this._window = win; this._gBrowser = win.gBrowser; this._tab = tab; this._linkedBrowser = tab.linkedBrowser; this._browserContext = browserContext; this._viewportSize = undefined; this._zoom = 1; this._initialDPPX = this._linkedBrowser.browsingContext.overrideDPPX; this._url = 'about:blank'; this._openerId = opener ? opener.id() : undefined; this._actor = undefined; this._actorSequenceNumber = 0; this._channel = new SimpleChannel(`browser::page[${this._targetId}]`, 'target-' + this._targetId); this._videoRecordingInfo = undefined; this._screencastRecordingInfo = undefined; this._dialogs = new Map(); this.forcedColors = 'none'; this.disableCache = false; this.mediumOverride = ''; this.crossProcessCookie = { initScripts: [], bindings: [], interceptFileChooserDialog: false, }; const navigationListener = { QueryInterface: ChromeUtils.generateQI([Ci.nsIWebProgressListener, Ci.nsISupportsWeakReference]), onLocationChange: (aWebProgress, aRequest, aLocation) => this._onNavigated(aLocation), }; this._eventListeners = [ helper.addObserver(this._updateModalDialogs.bind(this), 'common-dialog-loaded'), helper.addProgressListener(tab.linkedBrowser, navigationListener, Ci.nsIWebProgress.NOTIFY_LOCATION), helper.addEventListener(this._linkedBrowser, 'DOMModalDialogClosed', event => this._updateModalDialogs()), helper.addEventListener(this._linkedBrowser, 'WillChangeBrowserRemoteness', event => this._willChangeBrowserRemoteness()), ]; this._disposed = false; browserContext.pages.add(this); this._registry._browserToTarget.set(this._linkedBrowser, this); this._registry._browserIdToTarget.set(this._linkedBrowser.browsingContext.browserId, this); this._registry.emit(TargetRegistry.Events.TargetCreated, this); } async activateAndRun(callback = () => {}, { muteNotificationsPopup = false } = {}) { const ownerWindow = this._tab.linkedBrowser.ownerGlobal; const tabBrowser = ownerWindow.gBrowser; // Serialize all tab-switching commands per tabbed browser // to disallow concurrent tab switching. const result = globalTabAndWindowActivationChain.then(async () => { this._window.focus(); if (tabBrowser.selectedTab !== this._tab) { const promise = helper.awaitEvent(ownerWindow, 'TabSwitchDone'); tabBrowser.selectedTab = this._tab; await promise; } const notificationsPopup = muteNotificationsPopup ? this._linkedBrowser?.ownerDocument.getElementById('notification-popup') : null; notificationsPopup?.style.setProperty('pointer-events', 'none'); try { await callback(); } finally { notificationsPopup?.style.removeProperty('pointer-events'); } }); globalTabAndWindowActivationChain = result.catch(error => { /* swallow errors to keep chain running */ }); return result; } frameIdToBrowsingContext(frameId) { return helper.collectAllBrowsingContexts(this._linkedBrowser.browsingContext).find(bc => helper.browsingContextToFrameId(bc) === frameId); } nextActorSequenceNumber() { return ++this._actorSequenceNumber; } setActor(actor) { this._actor = actor; this._channel.bindToActor(actor); } removeActor(actor) { // Note: the order between setActor and removeActor is non-deterministic. // Therefore we check that we are still bound to the actor that is being removed. if (this._actor !== actor) return; this._actor = undefined; this._channel.resetTransport(); } _willChangeBrowserRemoteness() { this.removeActor(this._actor); } dialog(dialogId) { return this._dialogs.get(dialogId); } dialogs() { return [...this._dialogs.values()]; } async windowReady() { await waitForWindowReady(this._window); } linkedBrowser() { return this._linkedBrowser; } browserContext() { return this._browserContext; } updateOverridesForBrowsingContext(browsingContext = undefined) { this.updateTouchOverride(browsingContext); this.updateUserAgent(browsingContext); this.updatePlatform(browsingContext); this.updateDPPXOverride(browsingContext); this.updateZoom(browsingContext); this.updateEmulatedMedia(browsingContext); this.updateColorSchemeOverride(browsingContext); this.updateReducedMotionOverride(browsingContext); this.updateContrastOverride(browsingContext); this.updateForcedColorsOverride(browsingContext); this.updateForceOffline(browsingContext); this.updateCacheDisabled(browsingContext); } updateForceOffline(browsingContext = undefined) { (browsingContext || this._linkedBrowser.browsingContext).forceOffline = this._browserContext.forceOffline; } setCacheDisabled(disabled) { this.disableCache = disabled; this.updateCacheDisabled(); } updateCacheDisabled(browsingContext = this._linkedBrowser.browsingContext) { const enableFlags = Ci.nsIRequest.LOAD_NORMAL; const disableFlags = Ci.nsIRequest.LOAD_BYPASS_CACHE | Ci.nsIRequest.INHIBIT_CACHING; browsingContext.defaultLoadFlags = (this._browserContext.disableCache || this.disableCache) ? disableFlags : enableFlags; } updateTouchOverride(browsingContext = undefined) { (browsingContext || this._linkedBrowser.browsingContext).touchEventsOverride = this._browserContext.touchOverride ? 'enabled' : 'none'; } updateUserAgent(browsingContext = undefined) { (browsingContext || this._linkedBrowser.browsingContext).customUserAgent = this._browserContext.defaultUserAgent; } updatePlatform(browsingContext = undefined) { (browsingContext || this._linkedBrowser.browsingContext).customPlatform = this._browserContext.defaultPlatform; } updateDPPXOverride(browsingContext = undefined) { browsingContext ||= this._linkedBrowser.browsingContext; const dppx = this._zoom * (this._browserContext.deviceScaleFactor || this._initialDPPX); browsingContext.overrideDPPX = dppx; } async updateZoom(browsingContext = undefined) { browsingContext ||= this._linkedBrowser.browsingContext; // Update dpr first, and then UI zoom. this.updateDPPXOverride(browsingContext); browsingContext.fullZoom = this._zoom; } _updateModalDialogs() { const prompts = new Set(this._linkedBrowser.tabDialogBox.getContentDialogManager().dialogs.map(dialog => dialog.frameContentWindow.Dialog)); for (const dialog of this._dialogs.values()) { if (!prompts.has(dialog.prompt())) { this._dialogs.delete(dialog.id()); this.emit(PageTarget.Events.DialogClosed, dialog); } else { prompts.delete(dialog.prompt()); } } for (const prompt of prompts) { const dialog = Dialog.createIfSupported(prompt); if (!dialog) continue; this._dialogs.set(dialog.id(), dialog); this.emit(PageTarget.Events.DialogOpened, dialog); } } async updateViewportSize() { await waitForWindowReady(this._window); this.updateDPPXOverride(); // Viewport size is defined by three arguments: // 1. default size. Could be explicit if set as part of `window.open` call, e.g. // `window.open(url, title, 'width=400,height=400')` // 2. page viewport size // 3. browserContext viewport size // // The "default size" (1) is only respected when the page is opened. // Otherwise, explicitly set page viewport prevales over browser context // default viewport. const viewportSize = this._viewportSize || this._browserContext.defaultViewportSize; if (viewportSize) { const {width, height} = viewportSize; this._linkedBrowser.style.setProperty('width', width + 'px'); this._linkedBrowser.style.setProperty('height', height + 'px'); this._linkedBrowser.style.setProperty('box-sizing', 'content-box'); this._linkedBrowser.closest('.browserStack').style.setProperty('overflow', 'auto'); this._linkedBrowser.closest('.browserStack').style.setProperty('contain', 'size'); this._linkedBrowser.closest('.browserStack').style.setProperty('scrollbar-width', 'none'); this._linkedBrowser.browsingContext.inRDMPane = true; const stackRect = this._linkedBrowser.closest('.browserStack').getBoundingClientRect(); const toolbarTop = stackRect.y; this._window.resizeBy(width - this._window.innerWidth, height + toolbarTop - this._window.innerHeight); await this._channel.connect('').send('awaitViewportDimensions', { width: width / this._zoom, height: height / this._zoom }); } else { this._linkedBrowser.style.removeProperty('width'); this._linkedBrowser.style.removeProperty('height'); this._linkedBrowser.style.removeProperty('box-sizing'); this._linkedBrowser.closest('.browserStack').style.removeProperty('overflow'); this._linkedBrowser.closest('.browserStack').style.removeProperty('contain'); this._linkedBrowser.closest('.browserStack').style.removeProperty('scrollbar-width'); this._linkedBrowser.browsingContext.inRDMPane = false; const actualSize = this._linkedBrowser.getBoundingClientRect(); await this._channel.connect('').send('awaitViewportDimensions', { width: actualSize.width / this._zoom, height: actualSize.height / this._zoom, }); } } setEmulatedMedia(mediumOverride) { this.mediumOverride = mediumOverride || ''; this.updateEmulatedMedia(); } updateEmulatedMedia(browsingContext = undefined) { (browsingContext || this._linkedBrowser.browsingContext).mediumOverride = this.mediumOverride; } setColorScheme(colorScheme) { this.colorScheme = fromProtocolColorScheme(colorScheme); this.updateColorSchemeOverride(); } updateColorSchemeOverride(browsingContext = undefined) { (browsingContext || this._linkedBrowser.browsingContext).prefersColorSchemeOverride = this.colorScheme || this._browserContext.colorScheme || 'none'; } setReducedMotion(reducedMotion) { this.reducedMotion = fromProtocolReducedMotion(reducedMotion); this.updateReducedMotionOverride(); } updateReducedMotionOverride(browsingContext = undefined) { (browsingContext || this._linkedBrowser.browsingContext).prefersReducedMotionOverride = this.reducedMotion || this._browserContext.reducedMotion || 'none'; } setContrast(contrast) { this.contrast = fromProtocolContrast(contrast); this.updateContrastOverride(); } updateContrastOverride(browsingContext = undefined) { (browsingContext || this._linkedBrowser.browsingContext).prefersContrastOverride = this.contrast || this._browserContext.contrast || 'none'; } setForcedColors(forcedColors) { this.forcedColors = fromProtocolForcedColors(forcedColors); this.updateForcedColorsOverride(); } updateForcedColorsOverride(browsingContext = undefined) { const isActive = this.forcedColors === 'active' || this._browserContext.forcedColors === 'active'; (browsingContext || this._linkedBrowser.browsingContext).forcedColorsOverride = isActive ? 'active' : 'none'; } async setInterceptFileChooserDialog(enabled) { this.crossProcessCookie.interceptFileChooserDialog = enabled; this._updateCrossProcessCookie(); await this._channel.connect('').send('setInterceptFileChooserDialog', enabled).catch(e => {}); } async setViewportSize(viewportSize) { this._viewportSize = viewportSize; await this.updateViewportSize(); } async setZoom(zoom) { // This is default range from the ZoomManager. if (zoom < 0.3 || zoom > 5) throw new Error('Invalid zoom value, must be between 0.3 and 5'); this._zoom = zoom; await this.updateZoom(); } close(runBeforeUnload = false) { this._gBrowser.removeTab(this._tab, { skipPermitUnload: !runBeforeUnload, }); } channel() { return this._channel; } id() { return this._targetId; } info() { return { targetId: this.id(), type: 'page', browserContextId: this._browserContext.browserContextId, openerId: this._openerId, }; } _onNavigated(aLocation) { this._url = aLocation.spec; this._browserContext.grantPermissionsToOrigin(this._url); } _updateCrossProcessCookie() { Services.ppmm.sharedData.set('juggler:page-cookie-' + this._linkedBrowser.browsingContext.browserId, this.crossProcessCookie); Services.ppmm.sharedData.flush(); } async ensurePermissions() { await this._channel.connect('').send('ensurePermissions', {}).catch(e => void e); } async setInitScripts(scripts) { this.crossProcessCookie.initScripts = scripts; this._updateCrossProcessCookie(); await this.pushInitScripts(); } async pushInitScripts() { await this._channel.connect('').send('setInitScripts', [...this._browserContext.crossProcessCookie.initScripts, ...this.crossProcessCookie.initScripts]).catch(e => void e); } async addBinding(worldName, name, script) { this.crossProcessCookie.bindings.push({ worldName, name, script }); this._updateCrossProcessCookie(); await this._channel.connect('').send('addBinding', { worldName, name, script }).catch(e => void e); } async applyContextSetting(name, value) { await this._channel.connect('').send('applyContextSetting', { name, value }).catch(e => void e); } async hasFailedToOverrideTimezone() { return await this._channel.connect('').send('hasFailedToOverrideTimezone').catch(e => true); } async _startVideoRecording({width, height, dir}) { // On Mac the window may not yet be visible when TargetCreated and its // NSWindow.windowNumber may be -1, so we wait until the window is known // to be initialized and visible. await this.windowReady(); const file = PathUtils.join(dir, helper.generateId() + '.webm'); if (width < 10 || width > 10000 || height < 10 || height > 10000) throw new Error("Invalid size"); const docShell = this._gBrowser.ownerGlobal.docShell; // Exclude address bar and navigation control from the video. const rect = this.linkedBrowser().getBoundingClientRect(); const devicePixelRatio = this._window.devicePixelRatio; let sessionId; const registry = this._registry; const screencastClient = { QueryInterface: ChromeUtils.generateQI([Ci.nsIScreencastServiceClient]), screencastFrame(data, deviceWidth, deviceHeight) { }, screencastStopped() { registry.emit(TargetRegistry.Events.ScreencastStopped, sessionId); }, }; const viewport = this._viewportSize || this._browserContext.defaultViewportSize || { width: 0, height: 0 }; sessionId = screencastService.startVideoRecording(screencastClient, docShell, true, file, width, height, 0, viewport.width, viewport.height, devicePixelRatio * rect.top); this._videoRecordingInfo = { sessionId, file }; this.emit(PageTarget.Events.ScreencastStarted); } _stopVideoRecording() { if (!this._videoRecordingInfo) throw new Error('No video recording in progress'); const videoRecordingInfo = this._videoRecordingInfo; this._videoRecordingInfo = undefined; screencastService.stopVideoRecording(videoRecordingInfo.sessionId); } videoRecordingInfo() { return this._videoRecordingInfo; } async startScreencast({ width, height, quality }) { // On Mac the window may not yet be visible when TargetCreated and its // NSWindow.windowNumber may be -1, so we wait until the window is known // to be initialized and visible. await this.windowReady(); if (width < 10 || width > 10000 || height < 10 || height > 10000) throw new Error("Invalid size"); const docShell = this._gBrowser.ownerGlobal.docShell; // Exclude address bar and navigation control from the video. const rect = this.linkedBrowser().getBoundingClientRect(); const devicePixelRatio = this._window.devicePixelRatio; const self = this; const screencastClient = { QueryInterface: ChromeUtils.generateQI([Ci.nsIScreencastServiceClient]), screencastFrame(data, deviceWidth, deviceHeight) { if (self._screencastRecordingInfo) self.emit(PageTarget.Events.ScreencastFrame, { data, deviceWidth, deviceHeight }); }, screencastStopped() { }, }; const viewport = this._viewportSize || this._browserContext.defaultViewportSize || { width: 0, height: 0 }; const screencastId = screencastService.startVideoRecording(screencastClient, docShell, false, '', width, height, quality || 90, viewport.width, viewport.height, devicePixelRatio * rect.top); this._screencastRecordingInfo = { screencastId }; return { screencastId }; } screencastFrameAck({ screencastId }) { if (!this._screencastRecordingInfo || this._screencastRecordingInfo.screencastId !== screencastId) return; screencastService.screencastFrameAck(screencastId); } stopScreencast() { if (!this._screencastRecordingInfo) throw new Error('No screencast in progress'); const { screencastId } = this._screencastRecordingInfo; this._screencastRecordingInfo = undefined; screencastService.stopVideoRecording(screencastId); } ensureContextMenuClosed() { // Close context menu, if any, since it might capture mouse events on Linux // and prevent browser shutdown on MacOS. const doc = this._linkedBrowser.ownerDocument; const contextMenu = doc.getElementById('contentAreaContextMenu'); if (contextMenu) contextMenu.hidePopup(); const autocompletePopup = doc.getElementById('PopupAutoComplete'); if (autocompletePopup) autocompletePopup.hidePopup(); const selectPopup = doc.getElementById('ContentSelectDropdown')?.menupopup; if (selectPopup) selectPopup.hidePopup() } dispose() { this.ensureContextMenuClosed(); this._disposed = true; if (this._videoRecordingInfo) this._stopVideoRecording(); if (this._screencastRecordingInfo) this.stopScreencast(); this._browserContext.pages.delete(this); this._registry._browserToTarget.delete(this._linkedBrowser); this._registry._browserIdToTarget.delete(this._linkedBrowser.browsingContext.browserId); try { helper.removeListeners(this._eventListeners); } catch (e) { // In some cases, removing listeners from this._linkedBrowser fails // because it is already half-destroyed. if (e) dump(e.message + '\n' + e.stack + '\n'); } this._registry.emit(TargetRegistry.Events.TargetDestroyed, this); } } PageTarget.Events = { ScreencastStarted: Symbol('PageTarget.ScreencastStarted'), ScreencastFrame: Symbol('PageTarget.ScreencastFrame'), Crashed: Symbol('PageTarget.Crashed'), DialogOpened: Symbol('PageTarget.DialogOpened'), DialogClosed: Symbol('PageTarget.DialogClosed'), }; function fromProtocolColorScheme(colorScheme) { if (colorScheme === 'light' || colorScheme === 'dark') return colorScheme; if (colorScheme === null || colorScheme === 'no-preference') return undefined; throw new Error('Unknown color scheme: ' + colorScheme); } function fromProtocolReducedMotion(reducedMotion) { if (reducedMotion === 'reduce' || reducedMotion === 'no-preference') return reducedMotion; if (reducedMotion === null) return undefined; throw new Error('Unknown reduced motion: ' + reducedMotion); } function fromProtocolContrast(contrast) { if (contrast === 'more' || contrast === 'less' || contrast === 'custom' || contrast === 'no-preference') return contrast; if (contrast === null) return undefined; throw new Error('Unknown contrast: ' + contrast); } function fromProtocolForcedColors(forcedColors) { if (forcedColors === 'active' || forcedColors === 'none') return forcedColors; if (!forcedColors) return 'none'; throw new Error('Unknown forced colors: ' + forcedColors); } class BrowserContext { constructor(registry, browserContextId, removeOnDetach) { this._registry = registry; this.browserContextId = browserContextId; // Default context has userContextId === 0, but we pass undefined to many APIs just in case. this.userContextId = 0; if (browserContextId !== undefined) { const identity = ContextualIdentityService.create(IDENTITY_NAME + browserContextId); this.userContextId = identity.userContextId; } this._principals = []; // Maps origins to the permission lists. this._permissions = new Map(); this._registry._browserContextIdToBrowserContext.set(this.browserContextId, this); this._registry._userContextIdToBrowserContext.set(this.userContextId, this); this._proxy = null; this.removeOnDetach = removeOnDetach; this.extraHTTPHeaders = undefined; this.httpCredentials = undefined; this.requestInterceptionEnabled = undefined; this.ignoreHTTPSErrors = undefined; this.downloadOptions = undefined; this.defaultViewportSize = undefined; this.deviceScaleFactor = undefined; this.defaultUserAgent = null; this.defaultPlatform = null; this.touchOverride = false; this.forceOffline = false; this.disableCache = false; this.colorScheme = 'none'; this.forcedColors = 'none'; this.reducedMotion = 'none'; this.contrast = 'none'; this.videoRecordingOptions = undefined; this.crossProcessCookie = { initScripts: [], bindings: [], settings: {}, }; this.pages = new Set(); } _updateCrossProcessCookie() { Services.ppmm.sharedData.set('juggler:context-cookie-' + this.userContextId, this.crossProcessCookie); Services.ppmm.sharedData.flush(); } setColorScheme(colorScheme) { this.colorScheme = fromProtocolColorScheme(colorScheme); for (const page of this.pages) page.updateColorSchemeOverride(); } setReducedMotion(reducedMotion) { this.reducedMotion = fromProtocolReducedMotion(reducedMotion); for (const page of this.pages) page.updateReducedMotionOverride(); } setContrast(contrast) { this.contrast = fromProtocolContrast(contrast); for (const page of this.pages) page.updateContrastOverride(); } setForcedColors(forcedColors) { this.forcedColors = fromProtocolForcedColors(forcedColors); for (const page of this.pages) page.updateForcedColorsOverride(); } async destroy() { if (this.userContextId !== 0) { ContextualIdentityService.remove(this.userContextId); for (const page of this.pages) page.close(); if (this.pages.size) { await new Promise(f => { const listener = helper.on(this._registry, TargetRegistry.Events.TargetDestroyed, () => { if (!this.pages.size) { helper.removeListeners([listener]); f(); } }); }); } } this._registry._browserContextIdToBrowserContext.delete(this.browserContextId); this._registry._userContextIdToBrowserContext.delete(this.userContextId); this._registry._updateProxiesWithSameAuthCacheAndDifferentCredentials(); } setProxy(proxy) { // Clear AuthCache. Services.obs.notifyObservers(null, "net:clear-active-logins"); this._proxy = proxy; this._registry._updateProxiesWithSameAuthCacheAndDifferentCredentials(); } setIgnoreHTTPSErrors(ignoreHTTPSErrors) { if (this.ignoreHTTPSErrors === ignoreHTTPSErrors) return; this.ignoreHTTPSErrors = ignoreHTTPSErrors; const certOverrideService = Cc[ "@mozilla.org/security/certoverride;1" ].getService(Ci.nsICertOverrideService); if (ignoreHTTPSErrors) { Preferences.set("network.stricttransportsecurity.preloadlist", false); Preferences.set("security.cert_pinning.enforcement_level", 0); certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData(true, this.userContextId); } else { certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData(false, this.userContextId); } } setDefaultUserAgent(userAgent) { this.defaultUserAgent = userAgent; for (const page of this.pages) page.updateUserAgent(); } setDefaultPlatform(platform) { this.defaultPlatform = platform; for (const page of this.pages) page.updatePlatform(); } setTouchOverride(touchOverride) { this.touchOverride = touchOverride; for (const page of this.pages) page.updateTouchOverride(); } setForceOffline(forceOffline) { this.forceOffline = forceOffline; for (const page of this.pages) page.updateForceOffline(); } setCacheDisabled(disabled) { this.disableCache = disabled; for (const page of this.pages) page.updateCacheDisabled(); } async setDefaultViewport(viewport) { this.defaultViewportSize = viewport ? viewport.viewportSize : undefined; this.deviceScaleFactor = viewport ? viewport.deviceScaleFactor : undefined; await Promise.all(Array.from(this.pages).map(page => page.updateViewportSize())); } async setInitScripts(scripts) { this.crossProcessCookie.initScripts = scripts; this._updateCrossProcessCookie(); await Promise.all(Array.from(this.pages).map(page => page.pushInitScripts())); } async addBinding(worldName, name, script) { this.crossProcessCookie.bindings.push({ worldName, name, script }); this._updateCrossProcessCookie(); await Promise.all(Array.from(this.pages).map(page => page.addBinding(worldName, name, script))); } async applySetting(name, value) { this.crossProcessCookie.settings[name] = value; this._updateCrossProcessCookie(); await Promise.all(Array.from(this.pages).map(page => page.applyContextSetting(name, value))); } async grantPermissions(origin, permissions) { this._permissions.set(origin, permissions); const promises = []; for (const page of this.pages) { if (origin === '*' || page._url.startsWith(origin)) { this.grantPermissionsToOrigin(page._url); promises.push(page.ensurePermissions()); } } await Promise.all(promises); } resetPermissions() { for (const principal of this._principals) { for (const permission of ALL_PERMISSIONS) Services.perms.removeFromPrincipal(principal, permission); } this._principals = []; this._permissions.clear(); } grantPermissionsToOrigin(url) { let origin = Array.from(this._permissions.keys()).find(key => url.startsWith(key)); if (!origin) origin = '*'; const permissions = this._permissions.get(origin); if (!permissions) return; const attrs = { userContextId: this.userContextId || undefined }; const principal = Services.scriptSecurityManager.createContentPrincipal(NetUtil.newURI(url), attrs); this._principals.push(principal); for (const permission of ALL_PERMISSIONS) { const action = permissions.includes(permission) ? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION; Services.perms.addFromPrincipal(principal, permission, action, Ci.nsIPermissionManager.EXPIRE_NEVER, 0 /* expireTime */); } } setCookies(cookies) { const protocolToSameSite = { [undefined]: Ci.nsICookie.SAMESITE_NONE, 'Lax': Ci.nsICookie.SAMESITE_LAX, 'Strict': Ci.nsICookie.SAMESITE_STRICT, }; for (const cookie of cookies) { const uri = cookie.url ? NetUtil.newURI(cookie.url) : null; let domain = cookie.domain; if (!domain) { if (!uri) throw new Error('At least one of the url and domain needs to be specified'); domain = uri.host; } let path = cookie.path; if (!path) path = uri ? dirPath(uri.filePath) : '/'; let secure = false; if (cookie.secure !== undefined) secure = cookie.secure; else if (uri && uri.scheme === 'https') secure = true; Services.cookies.add( domain, path, cookie.name, cookie.value, secure, cookie.httpOnly || false, cookie.expires === undefined || cookie.expires === -1 /* isSession */, cookie.expires === undefined ? Date.now() + HUNDRED_YEARS : cookie.expires, { userContextId: this.userContextId || undefined } /* originAttributes */, protocolToSameSite[cookie.sameSite], Ci.nsICookie.SCHEME_UNSET ); } } clearCookies() { Services.cookies.removeCookiesWithOriginAttributes(JSON.stringify({ userContextId: this.userContextId || undefined })); } getCookies() { const result = []; const sameSiteToProtocol = { [Ci.nsICookie.SAMESITE_NONE]: 'None', [Ci.nsICookie.SAMESITE_LAX]: 'Lax', [Ci.nsICookie.SAMESITE_STRICT]: 'Strict', }; for (let cookie of Services.cookies.cookies) { if (cookie.originAttributes.userContextId !== this.userContextId) continue; if (cookie.host === 'addons.mozilla.org') continue; result.push({ name: cookie.name, value: cookie.value, domain: cookie.host, path: cookie.path, expires: cookie.isSession ? -1 : cookie.expiry, size: cookie.name.length + cookie.value.length, httpOnly: cookie.isHttpOnly, secure: cookie.isSecure, session: cookie.isSession, sameSite: sameSiteToProtocol[cookie.sameSite], }); } return result; } async setVideoRecordingOptions(options) { this.videoRecordingOptions = options; const promises = []; for (const page of this.pages) { if (options) promises.push(page._startVideoRecording(options)); else if (page._videoRecordingInfo) promises.push(page._stopVideoRecording()); } await Promise.all(promises); } } class Dialog { static createIfSupported(prompt) { const type = prompt.args.promptType; switch (type) { case 'alert': case 'alertCheck': return new Dialog(prompt, 'alert'); case 'prompt': return new Dialog(prompt, 'prompt'); case 'confirm': case 'confirmCheck': return new Dialog(prompt, 'confirm'); case 'confirmEx': return new Dialog(prompt, 'beforeunload'); default: return null; }; } constructor(prompt, type) { this._id = helper.generateId(); this._type = type; this._prompt = prompt; } id() { return this._id; } message() { return this._prompt.ui.infoBody.textContent; } type() { return this._type; } prompt() { return this._prompt; } dismiss() { if (this._prompt.ui.button1) this._prompt.ui.button1.click(); else this._prompt.ui.button0.click(); } defaultValue() { return this._prompt.ui.loginTextbox.value; } accept(promptValue) { if (typeof promptValue === 'string' && this._type === 'prompt') this._prompt.ui.loginTextbox.value = promptValue; this._prompt.ui.button0.click(); } } function dirPath(path) { return path.substring(0, path.lastIndexOf('/') + 1); } async function waitForWindowReady(window) { if (window.delayedStartupPromise) { await window.delayedStartupPromise; } else { await new Promise((resolve => { Services.obs.addObserver(function observer(aSubject, aTopic) { if (window == aSubject) { Services.obs.removeObserver(observer, aTopic); resolve(); } }, "browser-delayed-startup-finished"); })); } if (window.document.readyState !== 'complete') await helper.awaitEvent(window, 'load'); } TargetRegistry.Events = { TargetCreated: Symbol('TargetRegistry.Events.TargetCreated'), TargetDestroyed: Symbol('TargetRegistry.Events.TargetDestroyed'), DownloadCreated: Symbol('TargetRegistry.Events.DownloadCreated'), DownloadFinished: Symbol('TargetRegistry.Events.DownloadFinished'), ScreencastStopped: Symbol('TargetRegistry.ScreencastStopped'), }; var EXPORTED_SYMBOLS = ['TargetRegistry', 'PageTarget']; this.TargetRegistry = TargetRegistry; this.PageTarget = PageTarget; ``` ## /browser_patches/firefox/juggler/components/Juggler.js ```js path="/browser_patches/firefox/juggler/components/Juggler.js" /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ var EXPORTED_SYMBOLS = ["Juggler", "JugglerFactory"]; const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); const {ComponentUtils} = ChromeUtils.import("resource://gre/modules/ComponentUtils.jsm"); const {Dispatcher} = ChromeUtils.import("chrome://juggler/content/protocol/Dispatcher.js"); const {BrowserHandler} = ChromeUtils.import("chrome://juggler/content/protocol/BrowserHandler.js"); const {NetworkObserver} = ChromeUtils.import("chrome://juggler/content/NetworkObserver.js"); const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); const {ActorManagerParent} = ChromeUtils.import('resource://gre/modules/ActorManagerParent.jsm'); const helper = new Helper(); const Cc = Components.classes; const Ci = Components.interfaces; // Register JSWindowActors that will be instantiated for each frame. ActorManagerParent.addJSWindowActors({ JugglerFrame: { parent: { moduleURI: 'chrome://juggler/content/JugglerFrameParent.jsm', }, child: { moduleURI: 'chrome://juggler/content/content/JugglerFrameChild.jsm', events: { // Normally, we instantiate an actor when a new window is created. DOMWindowCreated: {}, // However, for same-origin iframes, the navigation from about:blank // to the URL will share the same window, so we need to also create // an actor for a new document via DOMDocElementInserted. DOMDocElementInserted: {}, // Also, listening to DOMContentLoaded. DOMContentLoaded: {}, DOMWillOpenModalDialog: {}, DOMModalDialogClosed: {}, }, }, allFrames: true, }, }); let browserStartupFinishedCallback; let browserStartupFinishedPromise = new Promise(x => browserStartupFinishedCallback = x); class Juggler { get classDescription() { return "Sample command-line handler"; } get classID() { return Components.ID('{f7a74a33-e2ab-422d-b022-4fb213dd2639}'); } get contractID() { return "@mozilla.org/remote/juggler;1" } get QueryInterface() { return ChromeUtils.generateQI([ Ci.nsICommandLineHandler, Ci.nsIObserver ]); } get helpInfo() { return " --juggler Enable Juggler automation\n"; } handle(cmdLine) { // flag has to be consumed in nsICommandLineHandler:handle // to avoid issues on macos. See Marionette.jsm::handle() for more details. // TODO: remove after Bug 1724251 is fixed. cmdLine.handleFlag("juggler-pipe", false); } // This flow is taken from Remote agent and Marionette. // See https://github.com/mozilla/gecko-dev/blob/0c1b4921830e6af8bc951da01d7772de2fe60a08/remote/components/RemoteAgent.jsm#L302 async observe(subject, topic) { switch (topic) { case "profile-after-change": Services.obs.addObserver(this, "command-line-startup"); Services.obs.addObserver(this, "browser-idle-startup-tasks-finished"); break; case "command-line-startup": Services.obs.removeObserver(this, topic); const cmdLine = subject; const jugglerPipeFlag = cmdLine.handleFlag('juggler-pipe', false); if (!jugglerPipeFlag) return; this._silent = cmdLine.findFlag('silent', false) >= 0; if (this._silent) { Services.startup.enterLastWindowClosingSurvivalArea(); browserStartupFinishedCallback(); } Services.obs.addObserver(this, "final-ui-startup"); break; case "browser-idle-startup-tasks-finished": browserStartupFinishedCallback(); break; // Used to wait until the initial application window has been opened. case "final-ui-startup": Services.obs.removeObserver(this, topic); const targetRegistry = new TargetRegistry(); new NetworkObserver(targetRegistry); const loadStyleSheet = () => { if (Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless) { const styleSheetService = Cc["@mozilla.org/content/style-sheet-service;1"].getService(Components.interfaces.nsIStyleSheetService); const ioService = Cc["@mozilla.org/network/io-service;1"].getService(Components.interfaces.nsIIOService); const uri = ioService.newURI('chrome://juggler/content/content/hidden-scrollbars.css', null, null); styleSheetService.loadAndRegisterSheet(uri, styleSheetService.AGENT_SHEET); } }; // Force create hidden window here, otherwise its creation later closes the web socket! // Since https://phabricator.services.mozilla.com/D219834, hiddenDOMWindow is only available on MacOS. if (Services.appShell.hasHiddenWindow) { Services.appShell.hiddenDOMWindow; } let pipeStopped = false; let browserHandler; const pipe = Cc['@mozilla.org/juggler/remotedebuggingpipe;1'].getService(Ci.nsIRemoteDebuggingPipe); const connection = { QueryInterface: ChromeUtils.generateQI([Ci.nsIRemoteDebuggingPipeClient]), receiveMessage(message) { if (this.onmessage) this.onmessage({ data: message }); }, disconnected() { if (browserHandler) browserHandler['Browser.close'](); }, send(message) { if (pipeStopped) { // We are missing the response to Browser.close, // but everything works fine. Once we actually need it, // we have to stop the pipe after the response is sent. return; } pipe.sendMessage(message); }, }; pipe.init(connection); const dispatcher = new Dispatcher(connection); browserHandler = new BrowserHandler(dispatcher.rootSession(), dispatcher, targetRegistry, browserStartupFinishedPromise, () => { if (this._silent) Services.startup.exitLastWindowClosingSurvivalArea(); connection.onclose(); pipe.stop(); pipeStopped = true; }); dispatcher.rootSession().setHandler(browserHandler); loadStyleSheet(); dump(`\nJuggler listening to the pipe\n`); break; } } } const jugglerInstance = new Juggler(); // This is used by the XPCOM codepath which expects a constructor var JugglerFactory = function() { return jugglerInstance; }; ``` ## /browser_patches/firefox/juggler/components/components.conf ```conf path="/browser_patches/firefox/juggler/components/components.conf" # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. Classes = [ # Juggler { "cid": "{f7a74a33-e2ab-422d-b022-4fb213dd2639}", "contract_ids": ["@mozilla.org/remote/juggler;1"], "categories": { "command-line-handler": "m-remote", "profile-after-change": "Juggler", }, "jsm": "chrome://juggler/content/components/Juggler.js", "constructor": "JugglerFactory", }, ] ``` ## /browser_patches/firefox/juggler/components/moz.build ```build path="/browser_patches/firefox/juggler/components/moz.build" # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. XPCOM_MANIFESTS += ["components.conf"] ``` ## /browser_patches/firefox/juggler/content/FrameTree.js ```js path="/browser_patches/firefox/juggler/content/FrameTree.js" /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const Ci = Components.interfaces; const Cr = Components.results; const Cu = Components.utils; const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); const {SimpleChannel} = ChromeUtils.import('chrome://juggler/content/SimpleChannel.js'); const {Runtime} = ChromeUtils.import('chrome://juggler/content/content/Runtime.js'); const helper = new Helper(); class FrameTree { constructor(rootBrowsingContext) { helper.decorateAsEventEmitter(this); this._rootBrowsingContext = rootBrowsingContext; this._browsingContextGroup = rootBrowsingContext.group; if (!this._browsingContextGroup.__jugglerFrameTrees) this._browsingContextGroup.__jugglerFrameTrees = new Set(); this._browsingContextGroup.__jugglerFrameTrees.add(this); this._isolatedWorlds = new Map(); this._webSocketEventService = Cc[ "@mozilla.org/websocketevent/service;1" ].getService(Ci.nsIWebSocketEventService); this._runtime = new Runtime(false /* isWorker */); this._workers = new Map(); this._frameIdToFrame = new Map(); this._pageReady = false; this._javaScriptDisabled = false; for (const browsingContext of helper.collectAllBrowsingContexts(rootBrowsingContext)) this._createFrame(browsingContext); this._mainFrame = this.frameForBrowsingContext(rootBrowsingContext); const webProgress = rootBrowsingContext.docShell.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebProgress); this.QueryInterface = ChromeUtils.generateQI([ Ci.nsIWebProgressListener, Ci.nsIWebProgressListener2, Ci.nsISupportsWeakReference, ]); this._wdm = Cc["@mozilla.org/dom/workers/workerdebuggermanager;1"].createInstance(Ci.nsIWorkerDebuggerManager); this._wdmListener = { QueryInterface: ChromeUtils.generateQI([Ci.nsIWorkerDebuggerManagerListener]), onRegister: this._onWorkerCreated.bind(this), onUnregister: this._onWorkerDestroyed.bind(this), }; this._wdm.addListener(this._wdmListener); for (const workerDebugger of this._wdm.getWorkerDebuggerEnumerator()) this._onWorkerCreated(workerDebugger); const flags = Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT | Ci.nsIWebProgress.NOTIFY_LOCATION; this._eventListeners = [ helper.addObserver((docShell, topic, loadIdentifier) => { const frame = this.frameForDocShell(docShell); if (!frame) return; frame._pendingNavigationId = helper.toProtocolNavigationId(loadIdentifier); this.emit(FrameTree.Events.NavigationStarted, frame); }, 'juggler-navigation-started-renderer'), helper.addObserver(this._onDOMWindowCreated.bind(this), 'content-document-global-created'), helper.addObserver(this._onDOMWindowCreated.bind(this), 'juggler-dom-window-reused'), helper.addObserver((browsingContext, topic, why) => { this._onBrowsingContextAttached(browsingContext); }, 'browsing-context-attached'), helper.addObserver((browsingContext, topic, why) => { this._onBrowsingContextDetached(browsingContext); }, 'browsing-context-discarded'), helper.addObserver((subject, topic, eventInfo) => { const [type, jugglerEventId] = eventInfo.split(' '); this.emit(FrameTree.Events.InputEvent, { type, jugglerEventId: +(jugglerEventId ?? '0') }); }, 'juggler-mouse-event-hit-renderer'), helper.addProgressListener(webProgress, this, flags), ]; this._dragEventListeners = []; } workers() { return [...this._workers.values()]; } runtime() { return this._runtime; } setInitScripts(scripts) { for (const world of this._isolatedWorlds.values()) world._scriptsToEvaluateOnNewDocument = []; for (let { worldName, script } of scripts) { worldName = worldName || ''; const existing = this._isolatedWorlds.has(worldName); const world = this._ensureWorld(worldName); world._scriptsToEvaluateOnNewDocument.push(script); // FIXME: 'should inherit http credentials from browser context' fails without this if (worldName && !existing) { for (const frame of this.frames()) frame._createIsolatedContext(worldName); } } } _ensureWorld(worldName) { worldName = worldName || ''; let world = this._isolatedWorlds.get(worldName); if (!world) { world = new IsolatedWorld(worldName); this._isolatedWorlds.set(worldName, world); } return world; } _frameForWorker(workerDebugger) { if (workerDebugger.type !== Ci.nsIWorkerDebugger.TYPE_DEDICATED) return null; if (!workerDebugger.window) return null; return this.frameForDocShell(workerDebugger.window.docShell); } _onDOMWindowCreated(window) { const frame = this.frameForDocShell(window.docShell); if (!frame) return; frame._onGlobalObjectCleared(); } setJavaScriptDisabled(javaScriptDisabled) { this._javaScriptDisabled = javaScriptDisabled; for (const frame of this.frames()) frame._updateJavaScriptDisabled(); } _onWorkerCreated(workerDebugger) { // Note: we do not interoperate with firefox devtools. if (workerDebugger.isInitialized) return; const frame = this._frameForWorker(workerDebugger); if (!frame) return; const worker = new Worker(frame, workerDebugger); this._workers.set(workerDebugger, worker); this.emit(FrameTree.Events.WorkerCreated, worker); } _onWorkerDestroyed(workerDebugger) { const worker = this._workers.get(workerDebugger); if (!worker) return; worker.dispose(); this._workers.delete(workerDebugger); this.emit(FrameTree.Events.WorkerDestroyed, worker); } allFramesInBrowsingContextGroup(group) { const frames = []; for (const frameTree of (group.__jugglerFrameTrees || [])) { for (const frame of frameTree.frames()) { try { // Try accessing docShell and domWindow to filter out dead frames. // This might happen for print-preview frames, but maybe for something else as well. frame.docShell(); frame.domWindow(); frames.push(frame); } catch (e) { dump(`WARNING: unable to access docShell and domWindow of the frame[id=${frame.id()}]\n`); } } } return frames; } isPageReady() { return this._pageReady; } forcePageReady() { if (this._pageReady) return false; this._pageReady = true; this.emit(FrameTree.Events.PageReady); return true; } addBinding(worldName, name, script) { worldName = worldName || ''; const world = this._ensureWorld(worldName); world._bindings.set(name, script); for (const frame of this.frames()) frame._addBinding(worldName, name, script); } frameForBrowsingContext(browsingContext) { if (!browsingContext) return null; const frameId = helper.browsingContextToFrameId(browsingContext); return this._frameIdToFrame.get(frameId) ?? null; } frameForDocShell(docShell) { if (!docShell) return null; const frameId = helper.browsingContextToFrameId(docShell.browsingContext); return this._frameIdToFrame.get(frameId) ?? null; } frame(frameId) { return this._frameIdToFrame.get(frameId) || null; } frames() { let result = []; collect(this._mainFrame); return result; function collect(frame) { result.push(frame); for (const subframe of frame._children) collect(subframe); } } mainFrame() { return this._mainFrame; } dispose() { this._browsingContextGroup.__jugglerFrameTrees.delete(this); this._wdm.removeListener(this._wdmListener); this._runtime.dispose(); helper.removeListeners(this._eventListeners); helper.removeListeners(this._dragEventListeners); } onWindowEvent(event) { if (event.type !== 'DOMDocElementInserted' || !event.target.ownerGlobal) return; const docShell = event.target.ownerGlobal.docShell; const frame = this.frameForDocShell(docShell); if (!frame) { dump(`WARNING: ${event.type} for unknown frame ${helper.browsingContextToFrameId(docShell.browsingContext)}\n`); return; } if (frame._pendingNavigationId) { docShell.QueryInterface(Ci.nsIWebNavigation); this._frameNavigationCommitted(frame, docShell.currentURI.spec); } if (frame === this._mainFrame) { helper.removeListeners(this._dragEventListeners); const chromeEventHandler = docShell.chromeEventHandler; const options = { mozSystemGroup: true, capture: true, }; const emitInputEvent = (event) => this.emit(FrameTree.Events.InputEvent, { type: event.type, jugglerEventId: 0 }); // Drag events are dispatched from content process, so these we don't see in the // `juggler-mouse-event-hit-renderer` instrumentation. this._dragEventListeners = [ helper.addEventListener(chromeEventHandler, 'dragstart', emitInputEvent, options), helper.addEventListener(chromeEventHandler, 'dragover', emitInputEvent, options), ]; } } _frameNavigationCommitted(frame, url) { for (const subframe of frame._children) this._detachFrame(subframe); const navigationId = frame._pendingNavigationId; frame._pendingNavigationId = null; frame._lastCommittedNavigationId = navigationId; frame._url = url; this.emit(FrameTree.Events.NavigationCommitted, frame); if (frame === this._mainFrame) this.forcePageReady(); } onStateChange(progress, request, flag, status) { if (!(request instanceof Ci.nsIChannel)) return; const channel = request.QueryInterface(Ci.nsIChannel); const docShell = progress.DOMWindow.docShell; const frame = this.frameForDocShell(docShell); if (!frame) return; if (!channel.isDocument) { // Somehow, we can get worker requests here, // while we are only interested in frame documents. return; } const isStop = flag & Ci.nsIWebProgressListener.STATE_STOP; if (isStop && frame._pendingNavigationId && status) { // Navigation is aborted. const navigationId = frame._pendingNavigationId; frame._pendingNavigationId = null; // Always report download navigation as failure to match other browsers. const errorText = helper.getNetworkErrorStatusText(status); this.emit(FrameTree.Events.NavigationAborted, frame, navigationId, errorText); if (frame === this._mainFrame && status !== Cr.NS_BINDING_ABORTED) this.forcePageReady(); } } onLocationChange(progress, request, location, flags) { const docShell = progress.DOMWindow.docShell; const frame = this.frameForDocShell(docShell); const sameDocumentNavigation = !!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT); if (frame && sameDocumentNavigation) { frame._url = location.spec; this.emit(FrameTree.Events.SameDocumentNavigation, frame); } } _onBrowsingContextAttached(browsingContext) { // If this browsing context doesn't belong to our frame tree - do nothing. if (browsingContext.top !== this._rootBrowsingContext) return; this._createFrame(browsingContext); } _onBrowsingContextDetached(browsingContext) { const frame = this.frameForBrowsingContext(browsingContext); if (frame) this._detachFrame(frame); } _createFrame(browsingContext) { const parentFrame = this.frameForBrowsingContext(browsingContext.parent); if (!parentFrame && this._mainFrame) { dump(`WARNING: found docShell with the same root, but no parent!\n`); return; } const frame = new Frame(this, this._runtime, browsingContext, parentFrame); this._frameIdToFrame.set(frame.id(), frame); if (browsingContext.docShell?.domWindow && browsingContext.docShell?.domWindow.location) frame._url = browsingContext.docShell.domWindow.location.href; this.emit(FrameTree.Events.FrameAttached, frame); // Create execution context **after** reporting frame. // This is our protocol contract. if (frame.domWindow()) frame._onGlobalObjectCleared(); return frame; } _detachFrame(frame) { // Detach all children first for (const subframe of frame._children) this._detachFrame(subframe); if (frame === this._mainFrame) { // Do not detach main frame (happens during cross-process navigation), // as it confuses the client. return; } this._frameIdToFrame.delete(frame.id()); if (frame._parentFrame) frame._parentFrame._children.delete(frame); frame._parentFrame = null; frame.dispose(); this.emit(FrameTree.Events.FrameDetached, frame); } } FrameTree.Events = { FrameAttached: 'frameattached', FrameDetached: 'framedetached', WorkerCreated: 'workercreated', WorkerDestroyed: 'workerdestroyed', WebSocketCreated: 'websocketcreated', WebSocketOpened: 'websocketopened', WebSocketClosed: 'websocketclosed', WebSocketFrameReceived: 'websocketframereceived', WebSocketFrameSent: 'websocketframesent', NavigationStarted: 'navigationstarted', NavigationCommitted: 'navigationcommitted', NavigationAborted: 'navigationaborted', SameDocumentNavigation: 'samedocumentnavigation', PageReady: 'pageready', InputEvent: 'inputevent', }; class IsolatedWorld { constructor(name) { this._name = name; this._scriptsToEvaluateOnNewDocument = []; this._bindings = new Map(); } } class Frame { constructor(frameTree, runtime, browsingContext, parentFrame) { this._frameTree = frameTree; this._runtime = runtime; this._browsingContext = browsingContext; this._children = new Set(); this._frameId = helper.browsingContextToFrameId(browsingContext); this._parentFrame = null; this._url = ''; if (parentFrame) { this._parentFrame = parentFrame; parentFrame._children.add(this); } this._lastCommittedNavigationId = null; this._pendingNavigationId = null; this._textInputProcessor = null; this._worldNameToContext = new Map(); this._initialNavigationDone = false; this._webSocketListenerInnerWindowId = 0; // WebSocketListener calls frameReceived event before webSocketOpened. // To avoid this, serialize event reporting. this._webSocketInfos = new Map(); const dispatchWebSocketFrameReceived = (webSocketSerialID, frame) => this._frameTree.emit(FrameTree.Events.WebSocketFrameReceived, { frameId: this._frameId, wsid: webSocketSerialID + '', opcode: frame.opCode, data: frame.opCode !== 1 ? btoa(frame.payload) : frame.payload, }); this._webSocketListener = { QueryInterface: ChromeUtils.generateQI([Ci.nsIWebSocketEventListener, ]), webSocketCreated: (webSocketSerialID, uri, protocols) => { this._frameTree.emit(FrameTree.Events.WebSocketCreated, { frameId: this._frameId, wsid: webSocketSerialID + '', requestURL: uri, }); this._webSocketInfos.set(webSocketSerialID, { opened: false, pendingIncomingFrames: [], }); }, webSocketOpened: (webSocketSerialID, effectiveURI, protocols, extensions, httpChannelId) => { this._frameTree.emit(FrameTree.Events.WebSocketOpened, { frameId: this._frameId, requestId: httpChannelId + '', wsid: webSocketSerialID + '', effectiveURL: effectiveURI, }); const info = this._webSocketInfos.get(webSocketSerialID); info.opened = true; for (const frame of info.pendingIncomingFrames) dispatchWebSocketFrameReceived(webSocketSerialID, frame); }, webSocketMessageAvailable: (webSocketSerialID, data, messageType) => { // We don't use this event. }, webSocketClosed: (webSocketSerialID, wasClean, code, reason) => { this._webSocketInfos.delete(webSocketSerialID); let error = ''; if (!wasClean) { const keys = Object.keys(Ci.nsIWebSocketChannel); for (const key of keys) { if (Ci.nsIWebSocketChannel[key] === code) error = key; } } this._frameTree.emit(FrameTree.Events.WebSocketClosed, { frameId: this._frameId, wsid: webSocketSerialID + '', error, }); }, frameReceived: (webSocketSerialID, frame) => { // Report only text and binary frames. if (frame.opCode !== 1 && frame.opCode !== 2) return; const info = this._webSocketInfos.get(webSocketSerialID); if (info.opened) dispatchWebSocketFrameReceived(webSocketSerialID, frame); else info.pendingIncomingFrames.push(frame); }, frameSent: (webSocketSerialID, frame) => { // Report only text and binary frames. if (frame.opCode !== 1 && frame.opCode !== 2) return; this._frameTree.emit(FrameTree.Events.WebSocketFrameSent, { frameId: this._frameId, wsid: webSocketSerialID + '', opcode: frame.opCode, data: frame.opCode !== 1 ? btoa(frame.payload) : frame.payload, }); }, }; } _createIsolatedContext(name) { const principal = [this.domWindow()]; // extended principal const sandbox = Cu.Sandbox(principal, { sandboxPrototype: this.domWindow(), wantComponents: false, wantExportHelpers: false, wantXrays: true, }); const world = this._runtime.createExecutionContext(this.domWindow(), sandbox, { frameId: this.id(), name, }); this._worldNameToContext.set(name, world); return world; } unsafeObject(objectId) { for (const context of this._worldNameToContext.values()) { const result = context.unsafeObject(objectId); if (result) return result.object; } throw new Error('Cannot find object with id = ' + objectId); } dispose() { for (const context of this._worldNameToContext.values()) this._runtime.destroyExecutionContext(context); this._worldNameToContext.clear(); } _addBinding(worldName, name, script) { let executionContext = this._worldNameToContext.get(worldName); if (worldName && !executionContext) executionContext = this._createIsolatedContext(worldName); if (executionContext) executionContext.addBinding(name, script); } _onGlobalObjectCleared() { const webSocketService = this._frameTree._webSocketEventService; if (this._webSocketListenerInnerWindowId && webSocketService.hasListenerFor(this._webSocketListenerInnerWindowId)) webSocketService.removeListener(this._webSocketListenerInnerWindowId, this._webSocketListener); this._webSocketListenerInnerWindowId = this.domWindow().windowGlobalChild.innerWindowId; webSocketService.addListener(this._webSocketListenerInnerWindowId, this._webSocketListener); for (const context of this._worldNameToContext.values()) this._runtime.destroyExecutionContext(context); this._worldNameToContext.clear(); this._worldNameToContext.set('', this._runtime.createExecutionContext(this.domWindow(), this.domWindow(), { frameId: this._frameId, name: '', })); for (const [name, world] of this._frameTree._isolatedWorlds) { if (name) this._createIsolatedContext(name); const executionContext = this._worldNameToContext.get(name); // Add bindings before evaluating scripts. for (const [name, script] of world._bindings) executionContext.addBinding(name, script); for (const script of world._scriptsToEvaluateOnNewDocument) executionContext.evaluateScriptSafely(script); } const url = this.domWindow().location?.href; if (url === 'about:blank' && !this._url) { // Sometimes FrameTree is created too early, before the location has been set. this._url = url; this._frameTree.emit(FrameTree.Events.NavigationCommitted, this); } this._updateJavaScriptDisabled(); } _updateJavaScriptDisabled() { if (this._browsingContext.currentWindowContext) this._browsingContext.currentWindowContext.allowJavascript = !this._frameTree._javaScriptDisabled; } mainExecutionContext() { return this._worldNameToContext.get(''); } textInputProcessor() { if (!this._textInputProcessor) { this._textInputProcessor = Cc["@mozilla.org/text-input-processor;1"].createInstance(Ci.nsITextInputProcessor); } this._textInputProcessor.beginInputTransactionForTests(this.docShell().DOMWindow); return this._textInputProcessor; } pendingNavigationId() { return this._pendingNavigationId; } lastCommittedNavigationId() { return this._lastCommittedNavigationId; } docShell() { return this._browsingContext.docShell; } domWindow() { return this.docShell()?.domWindow; } name() { const frameElement = this.domWindow()?.frameElement; let name = ''; if (frameElement) name = frameElement.getAttribute('name') || frameElement.getAttribute('id') || ''; return name; } parentFrame() { return this._parentFrame; } id() { return this._frameId; } url() { return this._url; } } class Worker { constructor(frame, workerDebugger) { this._frame = frame; this._workerId = helper.generateId(); this._workerDebugger = workerDebugger; workerDebugger.initialize('chrome://juggler/content/content/WorkerMain.js'); this._channel = new SimpleChannel(`content::worker[${this._workerId}]`, 'worker-' + this._workerId); this._channel.setTransport({ sendMessage: obj => workerDebugger.postMessage(JSON.stringify(obj)), dispose: () => {}, }); this._workerDebuggerListener = { QueryInterface: ChromeUtils.generateQI([Ci.nsIWorkerDebuggerListener]), onMessage: msg => void this._channel._onMessage(JSON.parse(msg)), onClose: () => void this._channel.dispose(), onError: (filename, lineno, message) => { dump(`WARNING: Error in worker: ${message} @${filename}:${lineno}\n`); }, }; workerDebugger.addListener(this._workerDebuggerListener); } channel() { return this._channel; } frame() { return this._frame; } id() { return this._workerId; } url() { return this._workerDebugger.url; } dispose() { this._channel.dispose(); this._workerDebugger.removeListener(this._workerDebuggerListener); } } function channelId(channel) { if (channel instanceof Ci.nsIIdentChannel) { const identChannel = channel.QueryInterface(Ci.nsIIdentChannel); return String(identChannel.channelId); } return helper.generateId(); } var EXPORTED_SYMBOLS = ['FrameTree']; this.FrameTree = FrameTree; ``` ## /browser_patches/firefox/juggler/content/JugglerFrameChild.jsm ```jsm path="/browser_patches/firefox/juggler/content/JugglerFrameChild.jsm" "use strict"; const { Helper } = ChromeUtils.import('chrome://juggler/content/Helper.js'); const { initialize } = ChromeUtils.import('chrome://juggler/content/content/main.js'); const Ci = Components.interfaces; const helper = new Helper(); let sameProcessInstanceNumber = 0; const topBrowingContextToAgents = new Map(); class JugglerFrameChild extends JSWindowActorChild { constructor() { super(); this._eventListeners = []; } handleEvent(aEvent) { const agents = this._agents(); if (!agents) return; if (aEvent.type === 'DOMWillOpenModalDialog') { agents.channel.pause(); return; } if (aEvent.type === 'DOMModalDialogClosed') { agents.channel.resumeSoon(); return; } if (aEvent.target === this.document) { agents.pageAgent.onWindowEvent(aEvent); agents.frameTree.onWindowEvent(aEvent); } } _agents() { return topBrowingContextToAgents.get(this.browsingContext.top); } actorCreated() { this.actorName = `content::${this.browsingContext.browserId}/${this.browsingContext.id}/${++sameProcessInstanceNumber}`; this._eventListeners.push(helper.addEventListener(this.contentWindow, 'load', event => { this._agents()?.pageAgent.onWindowEvent(event); })); if (this.document.documentURI.startsWith('moz-extension://')) return; // Child frame events will be forwarded to related top-level agents. if (this.browsingContext.parent) return; let agents = topBrowingContextToAgents.get(this.browsingContext); if (!agents) { agents = initialize(this.browsingContext, this.docShell); topBrowingContextToAgents.set(this.browsingContext, agents); } agents.channel.bindToActor(this); agents.actor = this; } didDestroy() { helper.removeListeners(this._eventListeners); if (this.browsingContext.parent) return; const agents = topBrowingContextToAgents.get(this.browsingContext); // The agents are already re-bound to a new actor. if (agents?.actor !== this) return; topBrowingContextToAgents.delete(this.browsingContext); agents.channel.resetTransport(); agents.pageAgent.dispose(); agents.frameTree.dispose(); } receiveMessage() { } } var EXPORTED_SYMBOLS = ['JugglerFrameChild']; ``` The content has been capped at 50000 tokens, and files over NaN bytes have been omitted. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.