diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000000..672eff1778 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,16 @@ +# Content Scope Scripts - Cursor Rules + +## Documentation References + +When asked about Content Scope Scripts topics, refer to these documentation files: + +- **API Reference**: `injected/docs/api-reference.md` +- **Feature Development**: `injected/docs/features-guide.md` +- **Platform Integration and engine support**: `injected/docs/platform-integration.md` +- **Development Utilities**: `injected/docs/development-utilities.md` +- **Testing**: `injected/docs/testing-guide.md` +- **Favicon**: `injected/docs/favicon.md` +- **Message Bridge**: `injected/docs/message-bridge.md` +- **Test Pages**: `injected/docs/test-pages-guide.md` +- **Documentation Index**: `injected/docs/README.md` +- **High-level Overview**: `injected/README.md` \ No newline at end of file diff --git a/.github/scripts/diff-directories.js b/.github/scripts/diff-directories.js index 2f1219b170..8d1b5408ec 100644 --- a/.github/scripts/diff-directories.js +++ b/.github/scripts/diff-directories.js @@ -26,7 +26,7 @@ function upperCaseFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } -function displayDiffs(dir1Files, dir2Files, isOpen) { +function displayDiffs(dir1Files, dir2Files) { const rollupGrouping = {}; /** * Rolls up multiple files with the same diff into a single entry @@ -79,13 +79,13 @@ function displayDiffs(dir1Files, dir2Files, isOpen) { } } outString += '\n\n' + rollup.string; - return renderDetails(title, outString, isOpen); + return renderDetails(title, outString); }) .join('\n'); return outString; } -function renderDetails(section, text, isOpen) { +function renderDetails(section, text) { if (section === 'dist') { section = 'apple'; } @@ -113,12 +113,9 @@ function sortFiles(dirFiles, dirName) { } const buildDir = '/build'; -const sourcesOutput = '/Sources/ContentScopeScripts/'; sortFiles(readFilesRecursively(dir1 + buildDir), 'dir1'); sortFiles(readFilesRecursively(dir2 + buildDir), 'dir2'); -sortFiles(readFilesRecursively(dir1 + sourcesOutput), 'dir1'); -sortFiles(readFilesRecursively(dir2 + sourcesOutput), 'dir2'); // console.log(Object.keys(files)) -const fileOut = displayDiffs(sections.dir1, sections.dir2, true); +const fileOut = displayDiffs(sections.dir1, sections.dir2); console.log(fileOut); diff --git a/.github/workflows/asana.yml b/.github/workflows/asana.yml index d536f61d07..7acddeff06 100644 --- a/.github/workflows/asana.yml +++ b/.github/workflows/asana.yml @@ -14,10 +14,12 @@ jobs: sync: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: sammacbeth/action-asana-sync@v6 + - uses: actions/checkout@v5 + - uses: duckduckgo/action-asana-sync@v11 with: ASANA_ACCESS_TOKEN: ${{ secrets.ASANA_ACCESS_TOKEN }} ASANA_WORKSPACE_ID: ${{ secrets.ASANA_WORKSPACE_ID }} ASANA_PROJECT_ID: '1208598406046969' + GITHUB_PAT: ${{ secrets.GH_RO_PAT }} USER_MAP: ${{ vars.USER_MAP }} + ASSIGN_PR_AUTHOR: 'true' diff --git a/.github/workflows/auto-respond-pr.yml b/.github/workflows/auto-respond-pr.yml index 0f051727c3..da6cc069bd 100644 --- a/.github/workflows/auto-respond-pr.yml +++ b/.github/workflows/auto-respond-pr.yml @@ -11,14 +11,14 @@ jobs: steps: - name: Checkout base branch - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ github.event.pull_request.base.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} path: base - name: Checkout PR branch - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -49,7 +49,7 @@ jobs: node pr/.github/scripts/diff-directories.js base pr > diff.txt - name: Find Previous Comment - uses: peter-evans/find-comment@v3 + uses: peter-evans/find-comment@v4 id: find_comment with: issue-number: ${{ github.event.pull_request.number }} @@ -58,7 +58,7 @@ jobs: direction: last - name: Create Comment Body - uses: actions/github-script@v7 + uses: actions/github-script@v8 id: create_body with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -76,7 +76,7 @@ jobs: core.setOutput('pr_number', prNumber); - name: Create, or Update the Comment - uses: peter-evans/create-or-update-comment@v4 + uses: peter-evans/create-or-update-comment@v5 with: issue-number: ${{ github.event.pull_request.number }} comment-id: ${{ steps.find_comment.outputs.comment-id }} diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index c038cfd720..c5ab4cc835 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -13,12 +13,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - - name: Use Node.js 20 + - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 20.x + node-version-file: '.nvmrc' - uses: actions/cache@v4 with: path: ~/.npm @@ -41,14 +41,14 @@ jobs: git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" git checkout -b pr-releases/pr-${PR_NUMBER} - git add -f build Sources + git add -f build git commit -m "Add build folder for PR ${PR_NUMBER}" git push -u origin pr-releases/pr-${PR_NUMBER} --force echo "BRANCH_NAME=pr-releases/pr-${PR_NUMBER}" >> $GITHUB_ENV echo "COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_ENV - name: Find Previous Comment - uses: peter-evans/find-comment@v3 + uses: peter-evans/find-comment@v4 id: find_comment with: issue-number: ${{ github.event.pull_request.number }} @@ -57,7 +57,7 @@ jobs: direction: last - name: Create Comment Body - uses: actions/github-script@v7 + uses: actions/github-script@v8 id: create_body with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -68,6 +68,13 @@ jobs: const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`; const branchUrl = `${repoUrl}/tree/${branchName}`; const commitUrl = `${repoUrl}/commit/${commitHash}`; + + // Get the current date + const lastUpdatedDate = (new Date()).toLocaleString('en-US', { + dateStyle: 'long', + timeStyle: 'long' + }); + const commentBody = ` ### Temporary Branch Update @@ -75,15 +82,16 @@ jobs: - **Branch Name**: [${branchName}](${branchUrl}) - **Commit Hash**: [${commitHash}](${commitUrl}) + - **Last Updated**: ${lastUpdatedDate} - **Install Command**: \`npm i github:duckduckgo/content-scope-scripts#${commitHash}\` Please use the above install command to update to the latest version. - `; + `; core.setOutput('comment_body', commentBody); core.setOutput('pr_number', prNumber); - name: Create, or Update the Comment - uses: peter-evans/create-or-update-comment@v4 + uses: peter-evans/create-or-update-comment@v5 with: issue-number: ${{ github.event.pull_request.number }} comment-id: ${{ steps.find_comment.outputs.comment-id }} @@ -95,7 +103,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Delete release branch env: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 57309c991f..0ae409b0de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: '.nvmrc' cache: 'npm' - name: Fetch files and ensure branches exist @@ -49,9 +49,12 @@ jobs: cat ${{ github.workspace }}/CHANGELOG.txt echo "Current tag is: $(git rev-list --tags --max-count=1)" - - name: Checkout code from main into release branch + - name: Ensure clean release branch from main run: | - # Checkout the code of main onto releases + # Remove all tracked and untracked files except .git and .github + find . -mindepth 1 -maxdepth 1 ! -name '.git' ! -name '.github' ! -name 'CHANGELOG.txt' -exec rm -rf {} + + + # Copy files from main branch git checkout main -- . - name: Build release @@ -61,10 +64,10 @@ jobs: - name: Check in files run: | - git add -f build/ Sources/ + git add -f . ':!CHANGELOG.txt' ':!node_modules' - name: Commit build files - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: 'Release build ${{ github.event.inputs.version }} [ci release]' commit_options: '--allow-empty' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 56fa9795de..4a694fc836 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -51,4 +51,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000000..97a6701d3b --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,32 @@ +name: Dependabot auto-approve and auto-merge +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: github.event.pull_request.user.login == 'dependabot[bot]' + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + + - name: Auto-approve and enable auto-merge for npm patch updates (except ignored packages) + if: | + steps.metadata.outputs.package-ecosystem == 'npm' && + steps.metadata.outputs.update-type == 'version-update:semver-patch' && + !contains(steps.metadata.outputs.dependency-names, '@atlaskit/pragmatic-drag-and-drop') && + !contains(steps.metadata.outputs.dependency-names, 'preact') && + !contains(steps.metadata.outputs.dependency-names, '@preact/signals') && + !contains(steps.metadata.outputs.dependency-names, 'lottie-web') + run: | + gh pr review --approve "$PR_URL" + gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/snapshots-update.yml b/.github/workflows/snapshots-update.yml new file mode 100644 index 0000000000..651a9d9d3d --- /dev/null +++ b/.github/workflows/snapshots-update.yml @@ -0,0 +1,84 @@ +name: Update Snapshots on a PR +description: | + Runs previously failed snapshot tests, and commits the changes. + + Your PR will receive a commit with the changes so you can manually verify before merging. + +on: + workflow_dispatch: + inputs: + pr_number: + description: 'Pull Request Number (Warning: This action will push a commit to the referenced PR)' + required: true + type: number + +permissions: + pull-requests: write + contents: write + +jobs: + update-pr-with-snapshots: + name: Update PR With Snapshots + runs-on: macos-14 + steps: + - uses: actions/checkout@v5 + - name: Checkout PR ${{ github.event.inputs.pr_number }} + if: github.event_name == 'workflow_dispatch' + run: gh pr checkout ${{ github.event.inputs.pr_number }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + - uses: actions/cache@v4 + with: + path: | + ~/.npm + ~/.cache/ms-playwright + key: ${{ runner.os }}-node-playwright-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-playwright- + ${{ runner.os }}-node- + + - name: Install dependencies + run: npm ci + + - name: Build all + run: npm run build + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - name: Run Screenshot tests + id: screenshot_tests + run: npm run test-int-snapshots + + - if: ${{ steps.screenshot_tests.conclusion == 'success' }} + run: | + echo "nothing to update - tests all passed" + + - name: Re-Running Playwright to update snapshots + id: screenshot_tests_update + if: ${{ failure() && steps.screenshot_tests.conclusion == 'failure' }} + run: npm run test-int-snapshots-update + + - name: Commit the updated files to the PR branch + if: ${{ failure() && steps.screenshot_tests_update.conclusion == 'success' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Configure Git with a bot's username and email for committing changes + # This makes it easy to identify in the PR + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Stage all updated PNG files for commit + git add "*.png" + + # Commit the changes with a descriptive message + git commit -m "Updated snapshots via workflow" + + # Push the changes to the current branch in the PR + git push origin HEAD diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml new file mode 100644 index 0000000000..4eb880fec7 --- /dev/null +++ b/.github/workflows/snapshots.yml @@ -0,0 +1,67 @@ +name: Test Snapshots +description: | + Runs snapshot tests and uploads test reports as artifacts + + If this workflow fails, you can trigger `update-snapshots.yml` from the + GitHub UI. + +on: + push: + branches: + - main + pull_request: + merge_group: + +permissions: + contents: read + +jobs: + snapshots: + timeout-minutes: 5 + runs-on: macos-14 + steps: + - uses: actions/checkout@v5 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + - uses: actions/cache@v4 + with: + path: | + ~/.npm + ~/.cache/ms-playwright + key: ${{ runner.os }}-node-playwright-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-playwright- + ${{ runner.os }}-node- + + - run: npm ci + - run: npm run build + + - run: npm run lint + continue-on-error: true + + - run: npm run stylelint + continue-on-error: true + + - run: npm run test-unit + continue-on-error: true + + - name: 'Clean tree' + run: 'npm run test-clean-tree' + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - run: npm run test-int-snapshots + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-pages + path: | + special-pages/playwright-report/** + special-pages/test-results/** + injected/playwright-report/** + injected/test-results/** + retention-days: 5 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2311724457..be66c07686 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,7 @@ on: branches: - main pull_request: + merge_group: permissions: contents: read @@ -19,11 +20,11 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v4 - - name: Use Node.js 20 + - uses: actions/checkout@v5 + - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 20.x + node-version-file: '.nvmrc' - uses: actions/cache@v4 with: path: ~/.npm @@ -39,13 +40,13 @@ jobs: run: 'npm run test-clean-tree' integration: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - name: Use Node.js 20 + - uses: actions/checkout@v5 + - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 20.x + node-version-file: '.nvmrc' - uses: actions/cache@v4 with: path: ~/.npm @@ -64,14 +65,18 @@ jobs: key: docs-output-${{ github.run_id }} - name: Install Playwright Browsers run: npx playwright install --with-deps - - name: Install dependencies for CI integration tests - run: sudo apt-get install xvfb - run: npm run test-int-x - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report-pages - path: special-pages/test-results + path: special-pages/playwright-report + retention-days: 5 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-injected + path: injected/playwright-report retention-days: 5 - name: Build docs run: npm run docs @@ -84,11 +89,11 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v4 - - name: Use Node.js 20 + - uses: actions/checkout@v5 + - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 20.x + node-version-file: '.nvmrc' - name: Cache build outputs id: docs-output uses: actions/cache@v4 @@ -98,9 +103,26 @@ jobs: - name: Setup Github Pages uses: actions/configure-pages@v5 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: docs - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 + + # This job ensures all runtime dependencies for the injected/ subproject are correctly listed in 'dependencies' (not 'devDependencies') + # by running the build with only production dependencies installed in injected/. It will fail if any required dependency is missing. + production-deps: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + - name: Install production dependencies only (injected/) + run: npm ci --production + - name: Build with production dependencies (injected/) + run: cd injected && npm run build + - name: Simulate extension esbuild for GPC feature (production deps only) + run: npx esbuild injected/src/features/gpc.js --bundle --outfile=/tmp/gpc-bundle.js diff --git a/.gitignore b/.gitignore index f529ab8304..a87bc72bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ Sources/ContentScopeScripts/dist/ test-results !Sources/ContentScopeScripts/dist/pages/.gitignore +# Test output files (generated during tests) +injected/unit-test/fixtures/page-context/output/ + # Local Netlify folder .netlify # VS Code user config diff --git a/.nvmrc b/.nvmrc index 209e3ef4b6..2bd5a0a98a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +22 diff --git a/.prettierignore b/.prettierignore index 3ee510be90..d0de7f9120 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,10 +1,13 @@ build/**/* docs/**/* +!injected/docs/**/* injected/src/types special-pages/pages/**/types injected/integration-test/extension/contentScope.js **/*.json **/*.md +!injected/docs/**/*.md **/*.html +!injected/integration-test/test-pages/* **/*.har **/*.css diff --git a/.stylelintrc.json b/.stylelintrc.json index eeb5e24139..a825e898d0 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -4,8 +4,14 @@ "ignoreFiles": ["build/**/*.css", "Sources/**/*.css", "docs/**/*.css", "special-pages/pages/**/*/dist/*.css"], "rules": { "csstree/validator": { - "ignoreProperties": ["text-wrap"] + "ignoreProperties": ["text-wrap", "view-transition-name", "composes"] }, + "property-no-unknown": [ + true, + { + "ignoreProperties": ["composes"] + } + ], "alpha-value-notation": null, "at-rule-empty-line-before": null, "color-function-notation": null, diff --git a/.swift-version b/.swift-version deleted file mode 100644 index 9ad974f610..0000000000 --- a/.swift-version +++ /dev/null @@ -1 +0,0 @@ -5.5 diff --git a/CODEOWNERS b/CODEOWNERS index 23c6814744..447a7393a0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,5 +1,8 @@ * @duckduckgo/content-scope-scripts-owners +# Documentation - anyone can edit +injected/docs/ + # Feature owners injected/src/features/fingerprinting-* @duckduckgo/content-scope-scripts-owners @jonathanKingston @englehardt injected/src/canvas.js @duckduckgo/content-scope-scripts-owners @jonathanKingston @englehardt @@ -7,14 +10,22 @@ injected/src/element-hiding.js @duckduckgo/content-scope-scripts-owners @jonatha injected/src/features/click-to-load.js @duckduckgo/content-scope-scripts-owners @kzar @ladamski @franfaccin @jonathanKingston @shakyShane injected/src/features/click-to-load/ @duckduckgo/content-scope-scripts-owners @kzar @ladamski @franfaccin @jonathanKingston @shakyShane injected/src/locales/click-to-load/ @duckduckgo/content-scope-scripts-owners @kzar @ladamski @franfaccin @jonathanKingston @shakyShane -injected/src/features/broker-protection.js @duckduckgo/content-scope-scripts-owners @brianhall @shakyShane -injected/src/features/broker-protection/ @duckduckgo/content-scope-scripts-owners @brianhall @shakyShane +injected/src/features/autofill-import.js @duckduckgo/content-scope-scripts-owners @dbajpeyi +injected/src/features/page-context.js @duckduckgo/content-scope-scripts-owners @noisysocks + +# Broker protection +injected/src/features/broker-protection.js @duckduckgo/content-scope-scripts-owners @duckduckgo/injected-broker-protection +injected/src/features/broker-protection/ @duckduckgo/content-scope-scripts-owners @duckduckgo/injected-broker-protection +injected/integration-test/page-objects/broker-protection.js @duckduckgo/content-scope-scripts-owners @duckduckgo/injected-broker-protection +injected/integration-test/broker-protection-tests/ @duckduckgo/content-scope-scripts-owners @duckduckgo/injected-broker-protection +injected/integration-test/mocks/broker-protection/ @duckduckgo/content-scope-scripts-owners @duckduckgo/injected-broker-protection +injected/integration-test/test-pages/broker-protection/ @duckduckgo/content-scope-scripts-owners @duckduckgo/injected-broker-protection # Platform owners +injected/src/features.js @duckduckgo/content-scope-scripts-owners @duckduckgo/apple-devs @duckduckgo/android-devs @duckduckgo/team-windows-development @duckduckgo/extension-owners Sources/ @duckduckgo/content-scope-scripts-owners @duckduckgo/apple-devs injected/entry-points/android.js @duckduckgo/content-scope-scripts-owners @duckduckgo/android-devs -injected/entry-points/extension-mv3.js @duckduckgo/content-scope-scripts-owners @kzar @sammacbeth -injected/entry-points/chrome.js @duckduckgo/content-scope-scripts-owners @kzar @sammacbeth +injected/entry-points/extension-mv3.js @duckduckgo/content-scope-scripts-owners @duckduckgo/extension-owners injected/entry-points/windows.js @duckduckgo/content-scope-scripts-owners @duckduckgo/team-windows-development # Test owners diff --git a/CODING_STYLE.md b/CODING_STYLE.md new file mode 100644 index 0000000000..d97df18438 --- /dev/null +++ b/CODING_STYLE.md @@ -0,0 +1,246 @@ +# Coding Style Guide + +## Overview + +This document outlines the coding style and conventions used in the Content Scope Scripts project. + +## Code Formatting + +### Prettier + +We use [Prettier](https://prettier.io/) for automatic code formatting. This ensures consistent code style across the entire codebase. + +- **Configuration**: See [`.prettierrc`](.prettierrc) for current formatting settings +- **IDE Integration**: Enable Prettier in your IDE/editor for automatic formatting on save +- **CI/CD**: Code formatting is checked in our continuous integration pipeline + +### Running Prettier + +```bash +# Format all files +npm run lint-fix + +# Check formatting (without making changes) +npm run lint +``` + +## TypeScript via JSDoc + +We use JSDoc comments to provide TypeScript-like type safety without requiring a TypeScript compilation step. + +### JSDoc Resources + +- https://devhints.io/jsdoc +- https://docs.joshuatz.com/cheatsheets/js/jsdoc/ + +### Basic JSDoc Usage + +```javascript +/** + * @param {string} videoId - The video identifier + * @param {() => void} handler - Callback function to invoke + * @returns {boolean} Whether the operation was successful + */ +function processVideo(videoId, handler) { + // implementation +} +``` + +### Type Annotations + +```javascript +// Variable type annotation +/** @type {HTMLElement|null} */ +const element = document.getElementById('my-element'); + +// Function parameter and return types +/** + * @param {HTMLIFrameElement} iframe + * @returns {(() => void)|null} + */ +function setupIframe(iframe) { + // implementation +} +``` + +### Interface Definitions + +```javascript +/** + * @typedef {Object} VideoParams + * @property {string} id - Video ID + * @property {string} title - Video title + * @property {number} duration - Duration in seconds + */ + +/** + * @typedef {import("./iframe").IframeFeature} IframeFeature + */ +``` + +## Safety and Defensive Programming + +### Type Guards Over Type Casting + +When working with DOM elements or external environments (like iframes), prefer runtime type checks over type casting: + +```javascript +// ❌ Avoid type casting when safety is uncertain +/** @type {Element} */ +const element = someUnknownValue; + +// ✅ Use instanceof checks for runtime safety +if (!(target instanceof Element)) return; +const element = target; // TypeScript now knows this is an Element +``` + +### Null/Undefined Checks + +Always check for null/undefined when accessing properties that might not exist: + +```javascript +// ❌ Unsafe +const doc = iframe.contentDocument; +doc.addEventListener('click', handler); + +// ✅ Safe +const doc = iframe.contentDocument; +if (!doc) { + console.log('could not access contentDocument'); + return; +} +doc.addEventListener('click', handler); +``` + +## Best Practices + +1. **Use meaningful variable names** that describe their purpose +2. **Add JSDoc comments** for all public functions and complex logic +3. **Prefer explicit type checks** over type assertions in uncertain environments +4. **Handle edge cases** gracefully with proper error handling +5. **Keep functions small and focused** on a single responsibility +6. **Design richer return types** to avoid using exceptions as control flow +7. **Favor implements over extends** to avoid class inheritance (see Interface Implementation section) +8. **Remove 'index' files** if they only serve to enable re-exports - prefer explicit imports/exports +9. **Prefer function declarations** over arrow functions for module-level functions + +### Return Types and Error Handling + +Instead of using exceptions for control flow, design richer return types: + +```javascript +// ❌ Using exceptions for control flow +function parseVideoId(url) { + if (!url) throw new Error('URL is required'); + if (!isValidUrl(url)) throw new Error('Invalid URL'); + return extractId(url); +} + +// ✅ Using richer return types +/** + * @typedef {Object} ParseResult + * @property {boolean} success + * @property {string} [videoId] - Present when success is true + * @property {string} [error] - Present when success is false + */ + +/** + * @param {string} url + * @returns {ParseResult} + */ +function parseVideoId(url) { + if (!url) return { success: false, error: 'URL is required' }; + if (!isValidUrl(url)) return { success: false, error: 'Invalid URL' }; + return { success: true, videoId: extractId(url) }; +} +``` + +### Interface Implementation + +Favor `implements` over `extends` to avoid class inheritance. While this is awkward in JSDoc, it promotes composition over inheritance: + +```javascript +/** + * @typedef {Object} IframeFeature + * @property {function(HTMLIFrameElement): void} iframeDidLoad + */ + +/** + * @implements {IframeFeature} + */ +export class ReplaceWatchLinks { + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad(iframe) { + // implementation + } +} +``` + +### Import/Export Patterns + +Avoid index files that only serve re-exports. Be explicit about imports: + +```javascript +// ❌ Avoid index.js files with only re-exports +// index.js +export { FeatureA } from './feature-a.js'; +export { FeatureB } from './feature-b.js'; + +// ✅ Import directly from source files +import { FeatureA } from './features/feature-a.js'; +import { FeatureB } from './features/feature-b.js'; +``` + +### Function Declarations + +Prefer function declarations over arrow functions for module-level functions: + +```javascript +// ❌ Arrow functions at module level +const processVideo = (videoId) => { + // implementation +}; + +const validateUrl = (url) => { + // implementation +}; + +// ✅ Function declarations at module level +function processVideo(videoId) { + // implementation +} + +function validateUrl(url) { + // implementation +} +``` + +**Why function declarations are preferred:** +- Hoisted, so order doesn't matter +- Cleaner syntax for longer functions +- Better stack traces in debugging +- More conventional for module-level exports + +## IDE Configuration + +### VS Code + +Recommended extensions: +- Prettier - Code formatter +- TypeScript and JavaScript Language Features (built-in) +- ESLint + + +## Linting + +We use ESLint for code quality checks. See [`eslint.config.js`](eslint.config.js) for the current linting configuration. + +Run linting with: + +```bash +npm run lint +``` + +Follow the linting rules and fix any issues before committing code. \ No newline at end of file diff --git a/Package.swift b/Package.swift deleted file mode 100644 index 5c7881c216..0000000000 --- a/Package.swift +++ /dev/null @@ -1,29 +0,0 @@ -// swift-tools-version:5.5 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "ContentScopeScripts", - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "ContentScopeScripts", - targets: ["ContentScopeScripts"]), - ], - dependencies: [ - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "ContentScopeScripts", - dependencies: [], - resources: [ - .process("dist/contentScope.js"), - .process("dist/contentScopeIsolated.js"), - .copy("dist/pages"), - ] - ), - ] -) diff --git a/Sources/ContentScopeScripts/ContentScopeScripts.swift b/Sources/ContentScopeScripts/ContentScopeScripts.swift deleted file mode 100644 index c220f4f98d..0000000000 --- a/Sources/ContentScopeScripts/ContentScopeScripts.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -public struct ContentScopeScripts { - public static var Bundle: Bundle = .module -} diff --git a/Sources/ContentScopeScripts/dist/pages/.gitignore b/Sources/ContentScopeScripts/dist/pages/.gitignore deleted file mode 100644 index 72e8ffc0db..0000000000 --- a/Sources/ContentScopeScripts/dist/pages/.gitignore +++ /dev/null @@ -1 +0,0 @@ -* diff --git a/eslint.config.js b/eslint.config.js index 6497113bd9..9d794adf0d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,7 +11,7 @@ export default tseslint.config( '**/build/', '**/docs/', 'injected/lib', - 'Sources/ContentScopeScripts/dist/', + 'injected/playwright-report/', 'injected/integration-test/extension/contentScope.js', 'injected/integration-test/test-pages/duckplayer/scripts/dist', 'special-pages/pages/**/public', diff --git a/injected/README.md b/injected/README.md index 1f04842ed7..f99f0245fb 100644 --- a/injected/README.md +++ b/injected/README.md @@ -1,174 +1,103 @@ -# Content scope scripts - -Content Scope Scripts handles injecting in DOM modifications in a browser context; it's a cross platform solution that requires some minimal platform hooks. - -## Content Scope Features API - -Each platform calls into the API exposed by content-scope-features.js where the relevant JavaScript file is included from features/. This file loads the relevant platform enabled features. The platform itself should adhere to the features lifecycle when implementing. - -The exposed API is a global called contentScopeFeatures and has three methods: -- load - - Calls the load method on all the features -- init - - Calls the init method on all the features - - This should be passed the arguments object which has the following keys: - - 'platform' which is an object with: - - 'name' which is a string of 'android', 'ios', 'macos' or 'extension' - - 'debug' true if debugging should be enabled - - 'globalPrivacyControlValue' false if the user has disabled GPC. - - 'sessionKey' a unique session based key. - - 'cookie' TODO - - 'site' which is an object with: - - 'isBroken' true if remote config has an exception. - - 'allowlisted' true if the user has disabled protections. - - 'domain' the hostname of the site in the URL bar - - 'enabledFeatures' this is an array of features/ to enable -- update - - Calls the update method on all the features - -## Features - -These files stored in the features directory must include an init function and optionally update and load explained in the features lifecycle. - -## Features Lifecycle - -There are three stages that the content scope code is hooked into the platform: -- load - - This should be reserved for work that should happen that could cause a delay in loading the feature. - - Given the current limitations of how we inject our code we don't have the Privacy Remote Configuration exceptions so authors should be wary of actually loading anything that would modify the page (and potentially breaking it). - - This limitation may be re-addressed in manifest v3 - - One exception here is the first party cookie protections that are triggered on init to prevent race conditions. -- init - - This is the main place that features are actually loaded into the extension. -- update - - This allows the feature to be sent updates from the browser. - - If this is triggered before init, these updates will be queued and triggered straight after. - -### Platform specific integration details - -The [injected/entry-points/](https://github.com/duckduckgo/content-scope-scripts/tree/main/injected/entry-points) directory handles platform specific differences and is glue code into calling the contentScopeFeatures API. - -- In Firefox the code is loaded as a standard extension content script. -- For Apple, Windows and Android the code is a UserScript that has some string replacements for properties and loads in as the page scope. - - Note: currently we don't implement the update calls as it's only required by cookie protections which we don't implement. -- All other browsers the code is stringified, base64 encoded and injected in as a self deleting ` + + + + +``` + +### Example Configuration + +```json +{ + "readme": "This config tests conditional matching of experiments", + "version": 1, + "features": { + "apiManipulation": { + "state": "enabled", + "settings": { + "apiChanges": { + "Navigator.prototype.hardwareConcurrency": { + "type": "descriptor", + "getterValue": { + "type": "number", + "value": 222 + } + } + }, + "conditionalChanges": [ + { + "condition": { + "urlPattern": "/test/*" + }, + "patchSettings": [ + { + "op": "replace", + "path": "/apiChanges/Navigator.prototype.hardwareConcurrency/getterValue/value", + "value": 333 + } + ] + } + ] + } + } + } +} +``` + +**Tip**: The `apiManipulation` feature is particularly useful for testing config conditions because it modifies browser APIs in predictable ways. You can use it to validate that conditional logic, URL patterns, and other config conditions are being applied correctly by checking if the expected API values are returned. + +## Platform Integration + +### Cross-Platform Testing + +The test pages are designed to work across multiple platforms: + +- **Browser Extensions**: Tests run in extension context with injected content scripts +- **Android**: Tests run in WebView with platform-specific messaging +- **Apple**: Tests run in WKWebView with WebKit-specific implementations +- **Windows**: Tests run with Windows-specific global polyfills + +### Platform-Specific Handling + +The test framework automatically handles platform differences through the `ResultsCollector` class, which applies appropriate setup and polyfills for each platform during test execution. + +## Running Tests + +### Local Development + +1. **Start the test server**: + + ```bash + npm run serve + ``` + +2. **Access test pages**: + - Navigate to `http://localhost:3220/` for the main index + - Browse to specific test categories and pages + +### CI Integration + +Tests are ran in CI environments: + +```javascript +// Example CI test +test('Test infra', async ({ page }, testInfo) => { + await testPage( + page, + testInfo, + '/infra/pages/conditional-matching.html', + './integration-test/test-pages/infra/config/conditional-matching.json', + ); +}); +``` + +See [pages.spec.js](../integration-test/pages.spec.js) for complete CI test examples. + +## Testing Best Practices + +When writing integration tests, follow these important guidelines: + +### 1. Avoid Custom State in Spec Files + +It's unadvisable to add custom state for tests directly in `.spec.js` files as it makes validation difficult and reduces test reliability. If custom state is absolutely required, ensure this is clearly explained in the corresponding test HTML file with detailed comments about what state is being set and why it's necessary. + +### 2. Platform Configuration + +The `Platform` parameter can be passed to test functions to simulate different platform environments. This is demonstrated in the version tests in [pages.spec.js](../integration-test/pages.spec.js): + +- `minSupportedVersion (string)`: Uses `{ version: '1.5.0' }` +- `minSupportedVersion (int)`: Uses `{ version: 99 }` +- `maxSupportedVersion (string)`: Uses `{ version: '1.5.0' }` +- `maxSupportedVersion (int)`: Uses `{ version: 99 }` + +This is needed when testing features that have platform-specific behavior or version requirements. The platform object allows testing how features behave under different version constraints without modifying the core test infrastructure. + +### 3. Config-Driven Testing + +Where possible, prefer purely config-driven testing to validate features. This approach: + +- Makes tests more maintainable and readable +- Reduces coupling between test logic and implementation details +- Allows for easier test data management and updates +- Provides better separation of concerns between test setup and validation + +For detailed testing guidelines and examples, see the [IMPORTANT TESTING GUIDELINES section](../integration-test/pages.spec.js#L7) in the pages.spec.js file. + +## Interactive and Automation Modes + +### Interactive Mode + +- When the test page is loaded **without** `?automation=true` in the URL, a **"Run Tests" button** appears at the top of the page. +- This allows a human tester or a platform test harness to decide when to start the tests, rather than running them immediately on page load. +- Clicking the button will execute all defined tests and display the results. + +### Automation Mode + +- When the test page is loaded **with** `?automation=true` in the URL, tests will run automatically as soon as the Content Scope Scripts are initialized. +- This is used for CI and automated testing environments. +- Tests wait for Content Scope Scripts initialization +- Results are collected programmatically +- Standardized result format for validation + +## Result Reporting and Visual Validation + +- After the tests finish, the results are assigned to `window.results` as a standardized object. +- The results are also rendered as an HTML table on the page, with pass/fail indicators for each test case. +- This visual output can be used by visual testing tools (such as Maestro) to validate that the test run is complete and that all tests have passed. +- The test suite status is also displayed at the top of the page, showing "pass" or "fail" for the overall run. + +## Result Format + +Tests return results in a standardized format: + +```javascript +{ + "Test Name": [ + { + "name": "Specific test case", + "result": "actual value", + "expected": "expected value" + } + ] +} +``` + +Results are displayed in HTML tables with pass/fail indicators and can be collected programmatically for CI validation. + +## Best Practices + +### Creating Index Pages + +When creating a new feature directory in the test pages system, it's best practice to include an `index.html` file that serves as a navigation hub for that feature's tests. This provides several benefits: + +1. **Easy Navigation**: Developers can quickly browse available tests without digging through subdirectories +2. **Clear Organization**: Each feature directory has a consistent entry point +3. **Documentation**: The index page can include descriptions of what the feature tests cover +4. **Maintainability**: Makes it easier for new team members to understand the test structure + +#### Index Page Template + +```html + + + + + + Feature Name + + +

[Home]

+ +

Feature Name

+ + + +``` + +#### Key Elements + +- **Navigation Link**: Always include a link back to the parent index page +- **Feature Title**: Clear heading indicating what feature is being tested +- **Test Links**: Organized list of all test pages in the `pages/` subdirectory +- **Consistent Structure**: Follow the same pattern as existing feature directories + +### Writing Test Pages + +1. **Use descriptive test names** that clearly indicate what is being tested +2. **Include multiple test cases** to cover different scenarios and edge cases +3. **Use realistic test data** that mimics real-world usage +4. **Include proper cleanup** for tests that modify state + +### Writing Configurations + +1. **Document the purpose** in the `readme` field +2. **Use semantic feature names** that match the codebase +3. **Include platform-specific configurations** when needed +4. **Keep configurations focused** on specific test scenarios + +### Platform Compatibility + +1. **Test across all platforms** to ensure consistency +2. **Handle platform-specific APIs** appropriately +3. **Use platform-agnostic test logic** when possible +4. **Validate messaging interfaces** work correctly +5. **Test fallback behaviors** for unsupported features + +## Integration with Privacy Test Pages + +The test pages are hosted at [https://privacy-test-pages.site/](https://privacy-test-pages.site/) and used by DuckDuckGo clients, platform teams, CI systems, and external developers to ensure consistent functionality across all platforms. diff --git a/injected/docs/testing-guide.md b/injected/docs/testing-guide.md new file mode 100644 index 0000000000..688df48c2c --- /dev/null +++ b/injected/docs/testing-guide.md @@ -0,0 +1,70 @@ +# Testing Guide + +## Overview + +Depending on what you are changing, you may need to run the build processes locally, or individual tests. The following all run within GitHub Actions when you create a pull request, but you can run them locally as well. + +## Quick Test Command + +If you want to get a good feeling for whether a PR or CI run will pass/fail, you can run the `test` command which chains most of the following together: + +```shell +# run this if you want some confidence that your PR will pass +npm test +``` + +## Individual Test Commands + +### ESLint + +See root-level package for lint commands + +### TypeScript + +See root-level package for TypeScript commands + +### Unit Tests (Jasmine) + +Everything for unit-testing is located in the `unit-test` folder. Jasmine configuration is in `unit-test/jasmine.json`. + +```shell +npm run test-unit +``` + +### Feature Integration Tests (Playwright) + +Everything within `integration-test` is integration tests controlled by Playwright. + +```shell +npm run test-int +``` + +**Important**: When writing integration tests, follow the [testing best practices](../docs/test-pages-guide.md#testing-best-practices) outlined in the Test Pages Guide. These guidelines cover avoiding custom state in spec files, using platform configuration, and preferring config-driven testing approaches. + +**Preferred Testing Approach**: The [Test Pages Guide](../docs/test-pages-guide.md) describes the most preferred type of testing for the `/injected` directory. Test pages are the preferred approach where possible because they are **sharable with platforms** - the same test pages can be used by Android, Apple, Windows, and browser extension teams, ensuring consistent functionality validation across all platforms. + +### Feature Build Process + +To produce all artefacts that are used by platforms, just run the `npm run build` command. This will create platform specific code within the `build` folder (that is not checked in). + +```shell +npm run build +``` + +## Test Builds for Ship Review + +Test builds are created with a GitHub workflow. The assets for Content Scope Scripts will be created on demand if they are absent (which they will be, if you're pointing to a branch of CSS). + +1. Commit any changes to CSS and push a branch to the remote +2. Make sure you commit the submodule reference update in the Windows PR +3. Continue with "Build an installer for ship review / test" + +## Debugging + +### Adding Breakpoints + +If you drop a `debugger;` line in the scripts and open DevTools window, the DevTools will breakpoint and navigate to that exact line in code when the debug point has been hit. + +### Verifying CSS is Loaded + +Open DevTools, go to the Console tab and enter `navigator.duckduckgo`. If it's defined, then Content Scope Scripts is running. diff --git a/injected/entry-points/android-adsjs.js b/injected/entry-points/android-adsjs.js new file mode 100644 index 0000000000..9a94a44578 --- /dev/null +++ b/injected/entry-points/android-adsjs.js @@ -0,0 +1,87 @@ +/** + * @module Android AdsJS integration + */ +import { load, init, updateFeatureArgs } from '../src/content-scope-features.js'; +import { processConfig, isBeingFramed } from './../src/utils'; +import { AndroidAdsjsMessagingConfig, MessagingContext, Messaging } from '../../messaging/index.js'; + +/** + * Send initial ping once per frame to establish communication with the platform. + * This replaces the per-feature ping that was previously sent in AndroidAdsjsMessagingTransport. + * When response is received, updates all loaded feature configurations. + * + * @param {AndroidAdsjsMessagingConfig} messagingConfig + * @param {object} processedConfig - The base configuration + */ +async function sendInitialPingAndUpdate(messagingConfig, processedConfig) { + // Only send ping in top context, not in frames + if (isBeingFramed()) { + return; + } + + try { + // Create messaging context for the initial ping + const messagingContext = new MessagingContext({ + context: 'contentScopeScripts', + env: processedConfig.debug ? 'development' : 'production', + featureName: 'messaging', + }); + + // Create messaging instance - handles all the subscription/error boilerplate + const messaging = new Messaging(messagingContext, messagingConfig); + + if (processedConfig.debug) { + console.log('AndroidAdsjs: Sending initial ping...'); + } + + // Send the ping request + const response = await messaging.request('initialPing', {}); + + // Update all loaded features with merged configuration + if (response && typeof response === 'object') { + const updatedConfig = { ...processedConfig, ...response }; + + await updateFeatureArgs(updatedConfig); + } + } catch (error) { + if (processedConfig.debug) { + console.error('AndroidAdsjs: Initial ping failed:', error); + } + } +} + +function initCode() { + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + const config = $CONTENT_SCOPE$; + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + const userUnprotectedDomains = $USER_UNPROTECTED_DOMAINS$; + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + const userPreferences = $USER_PREFERENCES$; + + const processedConfig = processConfig(config, userUnprotectedDomains, userPreferences); + + const configConstruct = processedConfig; + const objectName = configConstruct.objectName || 'contentScopeAdsjs'; + + processedConfig.messagingConfig = new AndroidAdsjsMessagingConfig({ + objectName, + target: globalThis, + debug: processedConfig.debug, + }); + + // Send initial ping asynchronously to update feature configurations when response arrives + sendInitialPingAndUpdate(processedConfig.messagingConfig, processedConfig); + + // Load and init features immediately with base configuration + load({ + platform: processedConfig.platform, + site: processedConfig.site, + bundledConfig: processedConfig.bundledConfig, + messagingConfig: processedConfig.messagingConfig, + messageSecret: processedConfig.messageSecret, + }); + + init(processedConfig); +} + +initCode(); diff --git a/injected/entry-points/android.js b/injected/entry-points/android.js index f0103d52f7..262f37ab1b 100644 --- a/injected/entry-points/android.js +++ b/injected/entry-points/android.js @@ -2,8 +2,7 @@ * @module Android integration */ import { load, init } from '../src/content-scope-features.js'; -import { processConfig, isGloballyDisabled } from './../src/utils'; -import { isTrackerOrigin } from '../src/trackers'; +import { processConfig } from './../src/utils'; import { AndroidMessagingConfig } from '../../messaging/index.js'; function initCode() { @@ -15,9 +14,6 @@ function initCode() { const userPreferences = $USER_PREFERENCES$; const processedConfig = processConfig(config, userUnprotectedDomains, userPreferences); - if (isGloballyDisabled(processedConfig)) { - return; - } const configConstruct = processedConfig; const messageCallback = configConstruct.messageCallback; @@ -33,11 +29,10 @@ function initCode() { load({ platform: processedConfig.platform, - trackerLookup: processedConfig.trackerLookup, - documentOriginIsTracker: isTrackerOrigin(processedConfig.trackerLookup), site: processedConfig.site, bundledConfig: processedConfig.bundledConfig, messagingConfig: processedConfig.messagingConfig, + messageSecret: processedConfig.messageSecret, }); init(processedConfig); diff --git a/injected/entry-points/apple.js b/injected/entry-points/apple.js index 5caeffc283..6ab6848fea 100644 --- a/injected/entry-points/apple.js +++ b/injected/entry-points/apple.js @@ -2,9 +2,8 @@ * @module Apple integration */ import { load, init } from '../src/content-scope-features.js'; -import { processConfig, isGloballyDisabled, platformSpecificFeatures } from './../src/utils'; -import { isTrackerOrigin } from '../src/trackers'; -import { WebkitMessagingConfig, TestTransportConfig } from '../../messaging/index.js'; +import { processConfig, platformSpecificFeatures } from './../src/utils'; +import { WebkitMessagingConfig } from '../../messaging/index.js'; function initCode() { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f @@ -16,39 +15,24 @@ function initCode() { const processedConfig = processConfig(config, userUnprotectedDomains, userPreferences, platformSpecificFeatures); - if (isGloballyDisabled(processedConfig)) { - return; - } - + const handlerNames = []; if (import.meta.injectName === 'apple-isolated') { - processedConfig.messagingConfig = new WebkitMessagingConfig({ - webkitMessageHandlerNames: ['contentScopeScriptsIsolated'], - secret: '', - hasModernWebkitAPI: true, - }); + handlerNames.push('contentScopeScriptsIsolated'); } else { - processedConfig.messagingConfig = new TestTransportConfig({ - notify() { - // noop - }, - request: async () => { - // noop - }, - subscribe() { - return () => { - // noop - }; - }, - }); + handlerNames.push('contentScopeScripts'); } + processedConfig.messagingConfig = new WebkitMessagingConfig({ + webkitMessageHandlerNames: handlerNames, + secret: '', + hasModernWebkitAPI: true, + }); load({ platform: processedConfig.platform, - trackerLookup: processedConfig.trackerLookup, - documentOriginIsTracker: isTrackerOrigin(processedConfig.trackerLookup), site: processedConfig.site, bundledConfig: processedConfig.bundledConfig, messagingConfig: processedConfig.messagingConfig, + messageSecret: processedConfig.messageSecret, }); init(processedConfig); diff --git a/injected/entry-points/chrome.js b/injected/entry-points/chrome.js deleted file mode 100644 index ebae03cd2a..0000000000 --- a/injected/entry-points/chrome.js +++ /dev/null @@ -1,170 +0,0 @@ -/** - * @module Chrome integration - */ -import { isTrackerOrigin } from '../src/trackers'; -import { computeLimitedSiteObject } from '../src/utils'; - -/** - * Inject all the overwrites into the page. - */ - -const allowedMessages = [ - 'getClickToLoadState', - 'getYouTubeVideoDetails', - 'openShareFeedbackPage', - 'addDebugFlag', - 'setYoutubePreviewsEnabled', - 'unblockClickToLoadContent', - 'updateYouTubeCTLAddedFlag', - 'updateFacebookCTLBreakageFlags', -]; -const messageSecret = randomString(); - -function inject(code) { - const elem = document.head || document.documentElement; - // Inject into main page - try { - const e = document.createElement('script'); - e.textContent = `(() => { - ${code} - })();`; - elem.appendChild(e); - e.remove(); - } catch (e) {} -} - -function randomString() { - const num = crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32; - return num.toString().replace('0.', ''); -} - -function init() { - const trackerLookup = import.meta.trackerLookup; - const documentOriginIsTracker = isTrackerOrigin(trackerLookup); - // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f - const bundledConfig = $BUNDLED_CONFIG$; - const randomMethodName = '_d' + randomString(); - const randomPassword = '_p' + randomString(); - const reusableMethodName = '_rm' + randomString(); - const reusableSecret = '_r' + randomString(); - const siteObject = computeLimitedSiteObject(); - const initialScript = ` - /* global contentScopeFeatures */ - contentScopeFeatures.load({ - platform: { - name: 'extension' - }, - trackerLookup: ${JSON.stringify(trackerLookup)}, - site: ${JSON.stringify(siteObject)}, - documentOriginIsTracker: ${documentOriginIsTracker}, - bundledConfig: ${JSON.stringify(bundledConfig)} - }) - // Define a random function we call later. - // Use define property so isn't enumerable - Object.defineProperty(window, '${randomMethodName}', { - enumerable: false, - // configurable, To allow for deletion later - configurable: true, - writable: false, - // Use proxy to ensure stringification isn't possible - value: new Proxy(function () {}, { - apply(target, thisArg, args) { - if ('${randomPassword}' === args[0]) { - contentScopeFeatures.init(args[1]) - } else { - // TODO force enable all features if password is wrong - console.error("Password for hidden function wasn't correct! The page is likely attempting to attack the feature by DuckDuckGo"); - } - // This method is single use, clean up - delete window.${randomMethodName}; - } - }) - }); - // Define a random update function we call later. - // Use define property so isn't enumerable - Object.defineProperty(window, '${reusableMethodName}', { - enumerable: false, - // configurable, To allow for deletion later - configurable: true, - writable: false, - // Use proxy to ensure stringification isn't possible - value: new Proxy(function () {}, { - apply(target, thisArg, args) { - if ('${reusableSecret}' === args[0]) { - contentScopeFeatures.update(args[1]) - } - } - }) - }); - `; - inject(initialScript); - - chrome.runtime.sendMessage( - { - messageType: 'registeredContentScript', - options: { - documentUrl: window.location.href, - }, - }, - (message) => { - if (!message) { - // Remove injected function only as background has disabled feature - inject(`delete window.${randomMethodName}`); - return; - } - if (message.debug) { - window.addEventListener('message', (m) => { - if (m.data.action && m.data.message) { - chrome.runtime.sendMessage({ messageType: 'debuggerMessage', options: m.data }); - } - }); - } - message.messageSecret = messageSecret; - const stringifiedArgs = JSON.stringify(message); - const callRandomFunction = ` - window.${randomMethodName}('${randomPassword}', ${stringifiedArgs}); - `; - inject(callRandomFunction); - }, - ); - - chrome.runtime.onMessage.addListener((message) => { - // forward update messages to the embedded script - if (message && message.type === 'update') { - const stringifiedArgs = JSON.stringify(message); - const callRandomUpdateFunction = ` - window.${reusableMethodName}('${reusableSecret}', ${stringifiedArgs}); - `; - inject(callRandomUpdateFunction); - } - }); - - window.addEventListener('sendMessageProxy' + messageSecret, (event) => { - event.stopImmediatePropagation(); - - if (!(event instanceof CustomEvent) || !event?.detail) { - return console.warn('no details in sendMessage proxy', event); - } - - const eventDetail = JSON.parse(event.detail); - const messageType = eventDetail.messageType; - if (!allowedMessages.includes(messageType)) { - return console.warn('Ignoring invalid sendMessage messageType', messageType); - } - - chrome.runtime.sendMessage(eventDetail, (response) => { - const message = { - messageType: 'response', - responseMessageType: messageType, - response, - }; - const stringifiedArgs = JSON.stringify(message); - const callRandomUpdateFunction = ` - window.${reusableMethodName}('${reusableSecret}', ${stringifiedArgs}); - `; - inject(callRandomUpdateFunction); - }); - }); -} - -init(); diff --git a/injected/entry-points/extension-mv3.js b/injected/entry-points/extension-mv3.js index 57d17c4737..29f9f384b6 100644 --- a/injected/entry-points/extension-mv3.js +++ b/injected/entry-points/extension-mv3.js @@ -2,19 +2,14 @@ * @module main world integration for Chrome MV3 and Firefox (enhanced) MV2 */ import { load, init, update } from '../src/content-scope-features.js'; -import { isTrackerOrigin } from '../src/trackers.js'; import { computeLimitedSiteObject } from '../src/utils.js'; const secret = (crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32).toString().replace('0.', ''); -const trackerLookup = import.meta.trackerLookup; - load({ platform: { name: 'extension', }, - trackerLookup, - documentOriginIsTracker: isTrackerOrigin(trackerLookup), site: computeLimitedSiteObject(), // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f bundledConfig: $BUNDLED_CONFIG$, @@ -32,6 +27,10 @@ window.addEventListener(secret, ({ detail: encodedMessage }) => { case 'register': if (message.argumentsObject) { message.argumentsObject.messageSecret = secret; + if (!message.argumentsObject?.site?.enabledFeatures) { + // Potentially corrupted site object, don't init + return; + } init(message.argumentsObject); } break; diff --git a/injected/entry-points/integration.js b/injected/entry-points/integration.js index fe43e49e7b..d570be9d14 100644 --- a/injected/entry-points/integration.js +++ b/injected/entry-points/integration.js @@ -1,36 +1,43 @@ -import { load, init } from '../src/content-scope-features.js'; -import { isTrackerOrigin } from '../src/trackers'; +import { load, init, updateFeatureArgs } from '../src/content-scope-features.js'; import { TestTransportConfig } from '../../messaging/index.js'; -function getTopLevelURL() { - try { - // FROM: https://stackoverflow.com/a/7739035/73479 - // FIX: Better capturing of top level URL so that trackers in embedded documents are not considered first party - if (window.location !== window.parent.location) { - return new URL(window.location.href !== 'about:blank' ? document.referrer : window.parent.location.href); - } else { - return new URL(window.location.href); - } - } catch (error) { - return new URL(location.href); - } -} +import { getTabUrl } from '../src/utils.js'; function generateConfig() { - const topLevelUrl = getTopLevelURL(); - const trackerLookup = import.meta.trackerLookup; + const topLevelUrl = getTabUrl(); return { debug: false, sessionKey: 'randomVal', platform: { name: 'extension', }, + currentCohorts: [ + { + feature: 'contentScopeExperiments', + subfeature: 'bloops', + cohort: 'control', + }, + { + feature: 'contentScopeExperiments', + subfeature: 'test', + cohort: 'treatment', + }, + ], site: { - domain: topLevelUrl.hostname, + domain: topLevelUrl?.hostname || '', + url: topLevelUrl?.href || '', isBroken: false, allowlisted: false, - enabledFeatures: ['fingerprintingCanvas', 'fingerprintingScreenSize', 'navigatorInterface', 'cookie'], + enabledFeatures: [ + 'fingerprintingCanvas', + 'fingerprintingScreenSize', + 'navigatorInterface', + 'cookie', + 'webCompat', + 'apiManipulation', + 'duckPlayer', + 'duckPlayerNative', + ], }, - trackerLookup, }; } @@ -67,7 +74,7 @@ function mergeDeep(target, ...sources) { } async function initCode() { - const topLevelUrl = getTopLevelURL(); + const topLevelUrl = getTabUrl(); const processedConfig = generateConfig(); // mock Messaging and allow for tests to intercept them @@ -84,19 +91,20 @@ async function initCode() { }; }, }); + load({ // @ts-expect-error Types of property 'name' are incompatible. platform: processedConfig.platform, - trackerLookup: processedConfig.trackerLookup, - documentOriginIsTracker: isTrackerOrigin(processedConfig.trackerLookup), site: processedConfig.site, + bundledConfig: processedConfig.bundledConfig, messagingConfig: processedConfig.messagingConfig, + currentCohorts: processedConfig.currentCohorts, }); // mark this phase as loaded setStatus('loaded'); - if (!topLevelUrl.searchParams.has('wait-for-init-args')) { + if (!topLevelUrl?.searchParams.has('wait-for-init-args')) { await init(processedConfig); setStatus('initialized'); return; @@ -108,11 +116,15 @@ async function initCode() { async (evt) => { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f const merged = mergeDeep(processedConfig, evt.detail); + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + window.__testContentScopeArgs = merged; // init features await init(merged); + await updateFeatureArgs(merged); // set status to initialized so that tests can resume setStatus('initialized'); + document.dispatchEvent(new CustomEvent('content-scope-init-completed')); }, { once: true }, ); diff --git a/injected/entry-points/windows.js b/injected/entry-points/windows.js index 213066ee6d..6827165e59 100644 --- a/injected/entry-points/windows.js +++ b/injected/entry-points/windows.js @@ -2,8 +2,7 @@ * @module Windows integration */ import { load, init } from '../src/content-scope-features.js'; -import { processConfig, isGloballyDisabled, platformSpecificFeatures } from './../src/utils'; -import { isTrackerOrigin } from '../src/trackers'; +import { processConfig, platformSpecificFeatures } from './../src/utils'; import { WindowsMessagingConfig } from '../../messaging/index.js'; function initCode() { @@ -15,9 +14,7 @@ function initCode() { const userPreferences = $USER_PREFERENCES$; const processedConfig = processConfig(config, userUnprotectedDomains, userPreferences, platformSpecificFeatures); - if (isGloballyDisabled(processedConfig)) { - return; - } + processedConfig.messagingConfig = new WindowsMessagingConfig({ methods: { // @ts-expect-error - Type 'unknown' is not assignable to type... @@ -31,11 +28,10 @@ function initCode() { load({ platform: processedConfig.platform, - trackerLookup: processedConfig.trackerLookup, - documentOriginIsTracker: isTrackerOrigin(processedConfig.trackerLookup), site: processedConfig.site, bundledConfig: processedConfig.bundledConfig, messagingConfig: processedConfig.messagingConfig, + messageSecret: processedConfig.messageSecret, }); init(processedConfig); diff --git a/injected/integration-test/autofill-password-import.spec.js b/injected/integration-test/autofill-import.spec.js similarity index 81% rename from injected/integration-test/autofill-password-import.spec.js rename to injected/integration-test/autofill-import.spec.js index 198d64aa18..8d8538c4f7 100644 --- a/injected/integration-test/autofill-password-import.spec.js +++ b/injected/integration-test/autofill-import.spec.js @@ -1,15 +1,15 @@ import { test, expect } from '@playwright/test'; -import { OVERLAY_ID } from '../src/features/autofill-password-import'; +import { OVERLAY_ID } from '../src/features/autofill-import'; import { ResultsCollector } from './page-objects/results-collector.js'; -const HTML = '/autofill-password-import/index.html'; -const CONFIG = './integration-test/test-pages/autofill-password-import/config/config.json'; +const HTML = '/autofill-import/index.html'; +const CONFIG = './integration-test/test-pages/autofill-import/config/config.json'; test('Password import feature', async ({ page }, testInfo) => { const collector = ResultsCollector.create(page, testInfo.project.use); await collector.load(HTML, CONFIG); - const passwordImportFeature = new AutofillPasswordImportSpec(page); + const passwordImportFeature = new AutofillImportSpec(page); await passwordImportFeature.clickOnElement('Home page'); await passwordImportFeature.waitForAnimation(); @@ -25,7 +25,7 @@ test('Password import feature', async ({ page }, testInfo) => { await expect(overlay).not.toBeVisible(); }); -class AutofillPasswordImportSpec { +class AutofillImportSpec { /** * @param {import("@playwright/test").Page} page */ diff --git a/injected/integration-test/broker-protection-tests/broker-protection-captcha.spec.js b/injected/integration-test/broker-protection-tests/broker-protection-captcha.spec.js new file mode 100644 index 0000000000..3a91d6dfca --- /dev/null +++ b/injected/integration-test/broker-protection-tests/broker-protection-captcha.spec.js @@ -0,0 +1,189 @@ +import { test as base } from '@playwright/test'; +import { createConfiguredDbpTest } from './fixtures'; +import { + createGetRecaptchaInfoAction, + createSolveRecaptchaAction, + createGetImageCaptchaInfoAction, + createSolveImageCaptchaAction, + createGetCloudFlareCaptchaInfoAction, + createSolveCloudFlareCaptchaAction, +} from '../mocks/broker-protection/captcha.js'; +import { BROKER_PROTECTION_CONFIGS } from './tests-config.js'; + +const test = createConfiguredDbpTest(base); + +test.describe('Broker Protection Captcha', () => { + test.describe('recaptcha2', () => { + const recaptchaTargetPage = 're-captcha.html'; + const recaptchaResponseSelector = '#g-recaptcha-response'; + + test.describe('getCaptchaInfo', () => { + test('returns the expected response for the correct action data', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(recaptchaTargetPage); + await dbp.receivesInlineAction(createGetRecaptchaInfoAction()); + const sucessResponse = await dbp.getSuccessResponse(); + + dbp.isCaptchaMatch(sucessResponse, { captchaType: 'recaptcha2', targetPage: recaptchaTargetPage }); + }); + + test('returns the expected response for the correct action data without the "captchaType" field', async ({ + createConfiguredDbp, + }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(recaptchaTargetPage); + await dbp.receivesInlineAction(createGetRecaptchaInfoAction({ captchaType: undefined })); + const sucessResponse = await dbp.getSuccessResponse(); + + dbp.isCaptchaMatch(sucessResponse, { captchaType: 'recaptcha2', targetPage: recaptchaTargetPage }); + }); + + test('returns the expected type when the "captchaType" field does not match the detected captcha type', async ({ + createConfiguredDbp, + }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(recaptchaTargetPage); + await dbp.receivesInlineAction(createGetRecaptchaInfoAction({ captchaType: 'recaptchaEnterprise' })); + const sucessResponse = await dbp.getSuccessResponse(); + + dbp.isCaptchaMatch(sucessResponse, { captchaType: 'recaptcha2', targetPage: recaptchaTargetPage }); + }); + + test('returns an error response for an action data with an invalid "captchaType" field', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(recaptchaTargetPage); + await dbp.receivesInlineAction(createGetRecaptchaInfoAction({ captchaType: 'invalid' })); + + await dbp.isCaptchaError(); + }); + }); + + test.describe('solveCaptchaInfo', () => { + test('solves the captcha for the correct action data', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(recaptchaTargetPage); + await dbp.receivesInlineAction(createSolveRecaptchaAction()); + dbp.getSuccessResponse(); + + await dbp.isCaptchaTokenFilled(recaptchaResponseSelector); + }); + + test('solves the captcha for the correct action data without the "captchaType" field', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(recaptchaTargetPage); + await dbp.receivesInlineAction(createSolveRecaptchaAction({ captchaType: undefined })); + dbp.getSuccessResponse(); + + await dbp.isCaptchaTokenFilled(recaptchaResponseSelector); + }); + + test('solves the captcha for an action data when the "captchaType" field does not match the detected captcha type', async ({ + createConfiguredDbp, + }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(recaptchaTargetPage); + await dbp.receivesInlineAction(createSolveRecaptchaAction({ captchaType: 'recaptchaEnterprise' })); + dbp.getSuccessResponse(); + + await dbp.isCaptchaTokenFilled(recaptchaResponseSelector); + }); + + test('returns an error response for an action data with an invalid "captchaType" field', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(recaptchaTargetPage); + await dbp.receivesInlineAction(createSolveRecaptchaAction({ captchaType: 'invalid' })); + + await dbp.isCaptchaError(); + }); + }); + + test('remove query params from captcha url', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo('re-captcha.html?fname=john&lname=smith'); + await dbp.receivesInlineAction(createGetRecaptchaInfoAction()); + const sucessResponse = await dbp.getSuccessResponse(); + + dbp.isQueryParamRemoved(sucessResponse); + }); + }); + + test.describe('image captcha', () => { + const imageCaptchaTargetPage = 'image-captcha.html'; + const imageCaptchaResponseSelector = '#svgCaptchaInputId'; + + test.describe('getCaptchaInfo', () => { + test('returns the expected response for the correct action data', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(imageCaptchaTargetPage); + await dbp.receivesInlineAction(createGetImageCaptchaInfoAction({ selector: '#svg-captcha-rendering svg' })); + const sucessResponse = await dbp.getSuccessResponse(); + dbp.isCaptchaMatch(sucessResponse, { captchaType: 'image', targetPage: imageCaptchaTargetPage }); + }); + + test('returns an error response when the selector is not an svg or image tag', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(imageCaptchaTargetPage); + await dbp.receivesInlineAction(createGetImageCaptchaInfoAction({ selector: '#svg-captcha-rendering' })); + + await dbp.isCaptchaError(); + }); + }); + + test.describe('solveCaptchaInfo', () => { + test('solves the captcha for the correct action data', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(imageCaptchaTargetPage); + await dbp.receivesInlineAction(createSolveImageCaptchaAction({ selector: imageCaptchaResponseSelector })); + dbp.getSuccessResponse(); + + await dbp.isCaptchaTokenFilled(imageCaptchaResponseSelector); + }); + }); + }); + + test.describe('cloudflare turnstile', () => { + const cloudFlareCaptchaTargetPage = 'cloudflare-captcha.html'; + + test.describe('getCaptchaInfo', () => { + test('returns the expected response for the correct action data', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(cloudFlareCaptchaTargetPage); + await dbp.receivesInlineAction(createGetCloudFlareCaptchaInfoAction({ selector: '#captcha-widget' })); + const sucessResponse = await dbp.getSuccessResponse(); + + dbp.isCaptchaMatch(sucessResponse, { + captchaType: 'cloudFlareTurnstile', + targetPage: cloudFlareCaptchaTargetPage, + siteKey: '0x4AAAAAAA34NY6rivjWMWoq', + }); + }); + + test('returns an error if the sitekey attribute is missing', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(cloudFlareCaptchaTargetPage); + await dbp.receivesInlineAction(createGetCloudFlareCaptchaInfoAction({ selector: '#missing-sitekey' })); + + await dbp.isCaptchaError(); + }); + }); + + test.describe('solveCaptchaInfo', () => { + test('returns an error if the callback attribute is missing', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(cloudFlareCaptchaTargetPage); + await dbp.receivesInlineAction(createSolveCloudFlareCaptchaAction({ selector: '#missing-callback' })); + + await dbp.isCaptchaError(); + }); + + test('solves the captcha for the correct action data', async ({ createConfiguredDbp }) => { + const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default); + await dbp.navigatesTo(cloudFlareCaptchaTargetPage); + await dbp.receivesInlineAction(createSolveCloudFlareCaptchaAction({ selector: '#captcha-widget' })); + dbp.getSuccessResponse(); + + await dbp.isCaptchaTokenFilled("//input[@name='cf-turnstile-response']"); + }); + }); + }); +}); diff --git a/injected/integration-test/broker-protection.spec.js b/injected/integration-test/broker-protection-tests/broker-protection.spec.js similarity index 90% rename from injected/integration-test/broker-protection.spec.js rename to injected/integration-test/broker-protection-tests/broker-protection.spec.js index 74988f5921..6e1415c34a 100644 --- a/injected/integration-test/broker-protection.spec.js +++ b/injected/integration-test/broker-protection-tests/broker-protection.spec.js @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; -import { BrokerProtectionPage } from './page-objects/broker-protection.js'; +import { BrokerProtectionPage } from '../page-objects/broker-protection.js'; +import { BROKER_PROTECTION_CONFIGS } from './tests-config.js'; test.describe('Broker Protection communications', () => { test('sends an error when the action is not found', async ({ page }, workerInfo) => { @@ -335,7 +336,7 @@ test.describe('Broker Protection communications', () => { test.describe('Executes action and sends success message', () => { test('buildUrl', async ({ page }, workerInfo) => { const dbp = BrokerProtectionPage.create(page, workerInfo.project.use); - await dbp.enabled(); + await dbp.withFeatureConfig(BROKER_PROTECTION_CONFIGS.default); await dbp.navigatesTo('results.html'); await dbp.receivesAction('navigate.json'); const response = await dbp.collector.waitForMessage('actionCompleted'); @@ -469,44 +470,44 @@ test.describe('Broker Protection communications', () => { await page.waitForURL((url) => url.hash === '#no', { timeout: 2000 }); }); - test('getCaptchaInfo', async ({ page }, workerInfo) => { + test('clicking selectors that do not exists should fail', async ({ page }, workerInfo) => { const dbp = BrokerProtectionPage.create(page, workerInfo.project.use); await dbp.enabled(); - await dbp.navigatesTo('captcha.html'); - await dbp.receivesAction('get-captcha.json'); + await dbp.navigatesTo('clicks.html'); + await dbp.receivesAction('click-nonexistent-selector.json'); const response = await dbp.collector.waitForMessage('actionCompleted'); - dbp.isSuccessMessage(response); - dbp.isCaptchaMatch(response[0].payload?.params.result.success.response); + + dbp.isErrorMessage(response); }); - test('getCaptchaInfo (hcaptcha)', async ({ page }, workerInfo) => { + test('clicking buttons that are disabled should fail', async ({ page }, workerInfo) => { const dbp = BrokerProtectionPage.create(page, workerInfo.project.use); await dbp.enabled(); - await dbp.navigatesTo('captcha2.html'); - await dbp.receivesAction('get-captcha.json'); + await dbp.navigatesTo('clicks.html'); + await dbp.receivesAction('click-disabled-button.json'); const response = await dbp.collector.waitForMessage('actionCompleted'); - dbp.isSuccessMessage(response); - dbp.isHCaptchaMatch(response[0].payload?.params.result.success.response); + + dbp.isErrorMessage(response); }); - test('remove query params from captcha url', async ({ page }, workerInfo) => { + test('clicking selectors that do not exist when failSilently is enabled should not fail', async ({ page }, workerInfo) => { const dbp = BrokerProtectionPage.create(page, workerInfo.project.use); await dbp.enabled(); - await dbp.navigatesTo('captcha.html?fname=john&lname=smith'); - await dbp.receivesAction('get-captcha.json'); + await dbp.navigatesTo('clicks.html'); + await dbp.receivesAction('click-nonexistent-selector-failSilently.json'); const response = await dbp.collector.waitForMessage('actionCompleted'); + dbp.isSuccessMessage(response); - dbp.isQueryParamRemoved(response[0].payload?.params.result.success.response); }); - test('solveCaptcha', async ({ page }, workerInfo) => { + test('clicking buttons that are disabled when failSilently is enabled should not fail', async ({ page }, workerInfo) => { const dbp = BrokerProtectionPage.create(page, workerInfo.project.use); await dbp.enabled(); - await dbp.navigatesTo('captcha.html'); - await dbp.receivesAction('solve-captcha.json'); + await dbp.navigatesTo('clicks.html'); + await dbp.receivesAction('click-disabled-button-failSilently.json'); const response = await dbp.collector.waitForMessage('actionCompleted'); + dbp.isSuccessMessage(response); - await dbp.isCaptchaTokenFilled(); }); test('expectation', async ({ page }, workerInfo) => { @@ -663,4 +664,47 @@ test.describe('Broker Protection communications', () => { dbp.isErrorMessage(response); }); }); + + test.describe('condition', () => { + test('a successful condition returns success with steps in the response', async ({ page }, workerInfo) => { + const dbp = BrokerProtectionPage.create(page, workerInfo.project.use); + await dbp.enabled(); + await dbp.navigatesTo('form.html'); + await dbp.receivesAction('condition-success.json'); + const response = await dbp.collector.waitForMessage('actionCompleted'); + dbp.isSuccessMessage(response); + + // Check that the response contains an actions array + const successResponse = await dbp.getSuccessResponse(); + + expect(successResponse).toHaveProperty('actions'); + expect(Array.isArray(successResponse.actions)).toBe(true); + expect(successResponse.actions.length).toBeGreaterThan(0); + }); + + test('a condition with failSilently returns success with empty actions array', async ({ page }, workerInfo) => { + const dbp = BrokerProtectionPage.create(page, workerInfo.project.use); + await dbp.enabled(); + await dbp.navigatesTo('form.html'); + await dbp.receivesAction('condition-fail-silently.json'); + const response = await dbp.collector.waitForMessage('actionCompleted'); + dbp.isSuccessMessage(response); + + // Check that the response does not contain an actions array + const successResponse = await dbp.getSuccessResponse(); + + expect(successResponse).toHaveProperty('actions'); + expect(Array.isArray(successResponse.actions)).toBe(true); + expect(successResponse.actions.length).toBe(0); + }); + + test('a failing condition returns error', async ({ page }, workerInfo) => { + const dbp = BrokerProtectionPage.create(page, workerInfo.project.use); + await dbp.enabled(); + await dbp.navigatesTo('form.html'); + await dbp.receivesAction('condition-fail.json'); + const response = await dbp.collector.waitForMessage('actionCompleted'); + dbp.isErrorMessage(response); + }); + }); }); diff --git a/injected/integration-test/broker-protection-tests/fixtures.js b/injected/integration-test/broker-protection-tests/fixtures.js new file mode 100644 index 0000000000..edea06c1b1 --- /dev/null +++ b/injected/integration-test/broker-protection-tests/fixtures.js @@ -0,0 +1,56 @@ +import { BrokerProtectionPage } from '../page-objects/broker-protection.js'; +/** + * @import {PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestType} from "@playwright/test" + */ + +/** + * @param {typeof import("@playwright/test").test} test + * @return {TestType) => Promise }, {}>} + */ +export function createConfiguredDbpTest(test) { + return test.extend({ + createConfiguredDbp: async ({ page }, use, workerInfo) => { + /** + * @param {Record} config + */ + const createWithConfig = async (config) => { + const dbp = BrokerProtectionPage.create(page, workerInfo.project.use); + await dbp.withFeatureConfig(config); + return dbp; + }; + + await use(createWithConfig); + }, + }); +} + +/** + * @param {typeof import("@playwright/test").test} test + * @return {TestType) => Promise }, {}>} + */ +export function createConfiguredDbpTestWithNavigation(test) { + return test.extend({ + createConfiguredDbp: async ({ page }, use, workerInfo) => { + /** + * @param {object} params + * @param {string} params.targetPage + * @param {string|object} params.action + * @param {Record} params.config + */ + const createWithConfig = async (params) => { + const { config, targetPage, action } = params; + const dbp = BrokerProtectionPage.create(page, workerInfo.project.use); + await dbp.withFeatureConfig(config); + await dbp.navigatesTo(targetPage); + if (typeof action === 'string') { + dbp.receivesAction(action); + } else { + await dbp.receivesInlineAction(action); + } + return dbp; + }; + + await use(createWithConfig); + }, + }); +} diff --git a/injected/integration-test/broker-protection-tests/tests-config.js b/injected/integration-test/broker-protection-tests/tests-config.js new file mode 100644 index 0000000000..c026e57b47 --- /dev/null +++ b/injected/integration-test/broker-protection-tests/tests-config.js @@ -0,0 +1,5 @@ +import { createFeatureConfig } from '../mocks/broker-protection/feature-config'; + +export const BROKER_PROTECTION_CONFIGS = Object.freeze({ + default: createFeatureConfig(), +}); diff --git a/injected/integration-test/device-enumeration.spec.js b/injected/integration-test/device-enumeration.spec.js new file mode 100644 index 0000000000..6428a61cc5 --- /dev/null +++ b/injected/integration-test/device-enumeration.spec.js @@ -0,0 +1,47 @@ +import { gotoAndWait, testContextForExtension } from './helpers/harness.js'; +import { test as base, expect } from '@playwright/test'; + +const test = testContextForExtension(base); + +test.describe('Device Enumeration Feature', () => { + test.describe('disabled feature', () => { + test('should not intercept enumerateDevices when disabled', async ({ page }) => { + await gotoAndWait(page, '/webcompat/pages/device-enumeration.html', { + site: { enabledFeatures: [] }, + }); + + // Should use native implementation + const results = await page.evaluate(() => { + // @ts-expect-error - results is set by renderResults() + return window.results; + }); + + // The test should pass with native behavior + expect(results).toBeDefined(); + }); + }); + + test.describe('enabled feature', () => { + test('should intercept enumerateDevices when enabled', async ({ page }) => { + await gotoAndWait(page, '/webcompat/pages/device-enumeration.html', { + site: { + enabledFeatures: ['webCompat'], + }, + featureSettings: { + webCompat: { + enumerateDevices: 'enabled', + }, + }, + }); + + // Should use our implementation + const results = await page.evaluate(() => { + // @ts-expect-error - results is set by renderResults() + return window.results; + }); + + // The test should pass with our implementation + expect(results).toBeDefined(); + }); + }); +}); diff --git a/injected/integration-test/duck-ai-data-clearing.spec.js b/injected/integration-test/duck-ai-data-clearing.spec.js new file mode 100644 index 0000000000..b740b79e11 --- /dev/null +++ b/injected/integration-test/duck-ai-data-clearing.spec.js @@ -0,0 +1,183 @@ +import { test, expect } from '@playwright/test'; +import { ResultsCollector } from './page-objects/results-collector.js'; + +const HTML = '/duck-ai-data-clearing/index.html'; +const CONFIG = './integration-test/test-pages/duck-ai-data-clearing/config/enabled.json'; + +test('duck-ai-data-clearing feature clears localStorage and IndexedDB', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + collector.withUserPreferences({ + messageSecret: 'ABC', + javascriptInterface: 'javascriptInterface', + messageCallback: 'messageCallback', + }); + await collector.load(HTML, CONFIG); + + const duckAiDataClearing = new DuckAiDataClearingSpec(page); + + // Setup test data + await duckAiDataClearing.setupTestData(); + await duckAiDataClearing.waitForDataSetup(); + + // Trigger data clearing via messaging + await collector.simulateSubscriptionMessage('duckAiDataClearing', 'duckAiClearData', {}); + + // Wait for completion message + const messages = await collector.waitForMessage('duckAiClearDataCompleted', 1); + expect(messages).toHaveLength(1); + expect(messages[0].payload.method).toBe('duckAiClearDataCompleted'); + + // Verify data is actually cleared + await duckAiDataClearing.verifyDataCleared(); + await duckAiDataClearing.waitForVerification('Verification complete: All data cleared'); +}); + +test('duck-ai-data-clearing feature handles IndexedDB errors gracefully', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + collector.withUserPreferences({ + messageSecret: 'ABC', + javascriptInterface: 'javascriptInterface', + messageCallback: 'messageCallback', + }); + await collector.load(HTML, CONFIG); + + const duckAiDataClearing = new DuckAiDataClearingSpec(page); + + // Setup localStorage data only (no IndexedDB) + await duckAiDataClearing.setupLocalStorageOnly(); + + // Mock IndexedDB to fail + await page.evaluate(() => { + const originalOpen = window.indexedDB.open; + window.indexedDB.open = function () { + const request = originalOpen.call(window.indexedDB, 'nonexistent'); + // Immediately fire the error event + setTimeout(() => { + if (typeof request.onerror === 'function') { + // Create a fake event object + const event = new Event('error'); + request.onerror(event); + } + }, 0); + return request; + }; + }); + + // Trigger data clearing + await collector.simulateSubscriptionMessage('duckAiDataClearing', 'duckAiClearData', {}); + + // Should still get completion message (localStorage should clear successfully) + const messages = await collector.waitForMessage('duckAiClearDataFailed', 1); + expect(messages).toHaveLength(1); + expect(messages[0].payload.method).toBe('duckAiClearDataFailed'); +}); + +test('duck-ai-data-clearing feature handles localStorage errors gracefully', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + collector.withUserPreferences({ + messageSecret: 'ABC', + javascriptInterface: 'javascriptInterface', + messageCallback: 'messageCallback', + }); + await collector.load(HTML, CONFIG); + + // Mock localStorage to throw an error + await page.evaluate(() => { + Storage.prototype.removeItem = () => { + throw new Error('Simulated localStorage error'); + }; + }); + + // Trigger data clearing + await collector.simulateSubscriptionMessage('duckAiDataClearing', 'duckAiClearData', {}); + + // Should get failure message + const messages = await collector.waitForMessage('duckAiClearDataFailed', 1); + expect(messages).toHaveLength(1); + expect(messages[0].payload.method).toBe('duckAiClearDataFailed'); +}); + +test('duck-ai-data-clearing feature succeeds when data collections do not exist or are empty', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + collector.withUserPreferences({ + messageSecret: 'ABC', + javascriptInterface: 'javascriptInterface', + messageCallback: 'messageCallback', + }); + await collector.load(HTML, CONFIG); + + const duckAiDataClearing = new DuckAiDataClearingSpec(page); + + // Ensure localStorage item doesn't exist + await page.evaluate(() => { + localStorage.removeItem('savedAIChats'); + }); + + // Ensure IndexedDB is clean (no existing database or object store) + await page.evaluate(() => { + return new Promise((resolve) => { + const deleteRequest = indexedDB.deleteDatabase('savedAIChatData'); + deleteRequest.onsuccess = () => resolve(null); + deleteRequest.onerror = () => resolve(null); // Continue even if delete fails + deleteRequest.onblocked = () => resolve(null); // Continue even if blocked + }); + }); + + // Trigger data clearing on non-existent/empty data + await collector.simulateSubscriptionMessage('duckAiDataClearing', 'duckAiClearData', {}); + + // Should still get completion message since there's nothing to fail + const messages = await collector.waitForMessage('duckAiClearDataCompleted', 1); + expect(messages).toHaveLength(1); + expect(messages[0].payload.method).toBe('duckAiClearDataCompleted'); + + // Verify that subsequent verification shows no data exists + await duckAiDataClearing.verifyDataCleared(); + await duckAiDataClearing.waitForVerification('Verification complete: All data cleared'); +}); + +class DuckAiDataClearingSpec { + /** + * @param {import("@playwright/test").Page} page + */ + constructor(page) { + this.page = page; + } + + async setupTestData() { + await this.page.click('#setup-data'); + } + + async setupLocalStorageOnly() { + await this.page.evaluate(() => { + localStorage.setItem('savedAIChats', JSON.stringify([{ id: 1, message: 'test chat 1' }])); + }); + } + + async waitForDataSetup() { + await this.page.waitForFunction( + () => { + const status = document.getElementById('data-status'); + return status && status.textContent === 'Test data setup complete'; + }, + { timeout: 5000 }, + ); + } + + async verifyDataCleared() { + await this.page.click('#verify-data'); + } + + async waitForVerification(expectedText) { + await this.page.waitForFunction( + (expected) => { + const status = document.getElementById('verify-status'); + return status && status.textContent === expected; + }, + expectedText, + { timeout: 5000 }, + ); + } +} + +export { DuckAiDataClearingSpec }; diff --git a/injected/integration-test/duckplayer-mobile-drawer.spec.js b/injected/integration-test/duckplayer-mobile-drawer.spec.js new file mode 100644 index 0000000000..3d43d66a02 --- /dev/null +++ b/injected/integration-test/duckplayer-mobile-drawer.spec.js @@ -0,0 +1,196 @@ +import { expect, test } from '@playwright/test'; +import { DuckplayerOverlays } from './page-objects/duckplayer-overlays.js'; + +test.describe('Duck Player - Drawer UI variant', () => { + test.describe('Video Player overlays', () => { + test("Selecting 'watch here' on mobile", async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo); + await overlays.reducedMotion(); + + // Given drawer overlay variant is set + await overlays.withRemoteConfig({ json: 'overlays-drawer.json' }); + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask'); + await overlays.gotoPlayerPage(); + + // watch here = overlays removed + await overlays.mobile.drawerIsPresented(); + await overlays.mobile.choosesWatchHere(); + await overlays.mobile.overlayIsRemoved(); + await overlays.pixels.sendsPixels([ + { pixelName: 'overlay', params: {} }, + { pixelName: 'play.do_not_use', params: { remember: '0' } }, + ]); + }); + test("Selecting 'watch here' on mobile + remember", async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo); + await overlays.reducedMotion(); + + // Given drawer overlay variant is set + await overlays.withRemoteConfig({ json: 'overlays-drawer.json' }); + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask'); + await overlays.gotoPlayerPage(); + + // watch here = overlays removed + await overlays.mobile.drawerIsPresented(); + await overlays.mobile.selectsRemember(); + await overlays.mobile.choosesWatchHere(); + await overlays.mobile.overlayIsRemoved(); + await overlays.pixels.sendsPixels([ + { pixelName: 'overlay', params: {} }, + { pixelName: 'play.do_not_use', params: { remember: '1' } }, + ]); + await overlays.userSettingWasUpdatedTo('disabled'); + }); + test("Selecting 'watch in duckplayer' on mobile", async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo); + await overlays.reducedMotion(); + + // Given drawer overlay variant is set + await overlays.withRemoteConfig({ json: 'overlays-drawer.json' }); + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask'); + await overlays.gotoPlayerPage(); + + await overlays.mobile.drawerIsPresented(); + await overlays.mobile.choosesDuckPlayer(); + await overlays.pixels.sendsPixels([ + { pixelName: 'overlay', params: {} }, + { pixelName: 'play.use', params: { remember: '0' } }, + ]); + await overlays.userSettingWasUpdatedTo('always ask'); + }); + test("Selecting 'watch in duckplayer' on mobile + remember", async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo); + await overlays.reducedMotion(); + + // Given drawer overlay variant is set + await overlays.withRemoteConfig({ json: 'overlays-drawer.json' }); + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask'); + await overlays.gotoPlayerPage(); + + await overlays.mobile.drawerIsPresented(); + await overlays.mobile.selectsRemember(); + await overlays.mobile.choosesDuckPlayer(); + await overlays.pixels.sendsPixels([ + { pixelName: 'overlay', params: {} }, + { pixelName: 'play.use', params: { remember: '1' } }, + ]); + await overlays.userSettingWasUpdatedTo('enabled'); + }); + test('Clicking on video thumbnail dismisses overlay', async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo); + await overlays.reducedMotion(); + + // Given drawer overlay variant is set + await overlays.withRemoteConfig({ json: 'overlays-drawer.json' }); + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask'); + await overlays.gotoPlayerPage(); + + await overlays.mobile.clicksOnVideoThumbnail(); + await overlays.pixels.sendsPixels([ + { pixelName: 'overlay', params: {} }, + { pixelName: 'play.do_not_use.dismiss', params: {} }, + ]); + await overlays.userSettingWasNotUpdated(); + }); + test('Clicking on drawer backdrop dismisses overlay', async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo); + await overlays.reducedMotion(); + + // Given drawer overlay variant is set + await overlays.withRemoteConfig({ json: 'overlays-drawer.json' }); + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask'); + await overlays.gotoPlayerPage(); + + await overlays.mobile.clicksOnDrawerBackdrop(); + await overlays.pixels.sendsPixels([ + { pixelName: 'overlay', params: {} }, + { pixelName: 'play.do_not_use.dismiss', params: {} }, + ]); + await overlays.userSettingWasNotUpdated(); + }); + test('opens info', async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo); + await overlays.reducedMotion(); + + // Given drawer overlay variant is set + await overlays.withRemoteConfig({ json: 'overlays-drawer.json' }); + + // And my setting is 'always ask' + await overlays.userSettingIs('always ask'); + await overlays.gotoPlayerPage(); + await overlays.mobile.opensInfo(); + }); + }); + + /** + * Use this test in `--headed` mode to cycle through every language + */ + test.describe.skip('Translated Overlays', () => { + const items = [ + 'bg', + 'cs', + 'da', + 'de', + 'el', + 'en', + 'es', + 'et', + 'fi', + 'fr', + 'hr', + 'hu', + 'it', + 'lt', + 'lv', + 'nb', + 'nl', + 'pl', + 'pt', + 'ro', + 'ru', + 'sk', + 'sl', + 'sv', + 'tr', + ]; + // const items = ['en'] + for (const locale of items) { + test(`testing UI ${locale}`, async ({ page }, workerInfo) => { + // console.log(workerInfo.project.use.viewport.height) + // console.log(workerInfo.project.use.viewport.width) + const overlays = DuckplayerOverlays.create(page, workerInfo); + await overlays.withRemoteConfig({ json: 'overlays-drawer.json', locale }); + await overlays.userSettingIs('always ask'); + await overlays.gotoPlayerPage(); + await page.locator('ddg-video-drawer-mobile').nth(0).waitFor(); + await page.locator('.html5-video-player').screenshot({ path: `screens/se-2/${locale}.png` }); + }); + } + }); + + /** + * Use `npm run playwright-screenshots` to run this test only. + */ + test.describe('Overlay screenshot @screenshots', () => { + test("testing Overlay UI 'en'", async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo); + await overlays.withRemoteConfig({ json: 'overlays-drawer.json', locale: 'en' }); + await overlays.userSettingIs('always ask'); + await overlays.gotoPlayerPage(); + await page.locator('ddg-video-drawer-mobile').nth(0).waitFor(); + await expect(page.locator('ddg-video-drawer-mobile')).toHaveScreenshot('drawer.png', { maxDiffPixels: 20 }); + }); + }); +}); diff --git a/injected/integration-test/duckplayer-mobile-drawer.spec.js-snapshots/drawer-android-darwin.png b/injected/integration-test/duckplayer-mobile-drawer.spec.js-snapshots/drawer-android-darwin.png new file mode 100644 index 0000000000..da4b5f3729 Binary files /dev/null and b/injected/integration-test/duckplayer-mobile-drawer.spec.js-snapshots/drawer-android-darwin.png differ diff --git a/injected/integration-test/duckplayer-mobile-drawer.spec.js-snapshots/drawer-ios-darwin.png b/injected/integration-test/duckplayer-mobile-drawer.spec.js-snapshots/drawer-ios-darwin.png new file mode 100644 index 0000000000..e46b0bc218 Binary files /dev/null and b/injected/integration-test/duckplayer-mobile-drawer.spec.js-snapshots/drawer-ios-darwin.png differ diff --git a/injected/integration-test/duckplayer-mobile.spec.js-snapshots/overlay-android-darwin.png b/injected/integration-test/duckplayer-mobile.spec.js-snapshots/overlay-android-darwin.png index a21a654718..2824ab88f9 100644 Binary files a/injected/integration-test/duckplayer-mobile.spec.js-snapshots/overlay-android-darwin.png and b/injected/integration-test/duckplayer-mobile.spec.js-snapshots/overlay-android-darwin.png differ diff --git a/injected/integration-test/duckplayer-native.spec.js b/injected/integration-test/duckplayer-native.spec.js new file mode 100644 index 0000000000..35053543d2 --- /dev/null +++ b/injected/integration-test/duckplayer-native.spec.js @@ -0,0 +1,172 @@ +import { test } from '@playwright/test'; +import { DuckPlayerNative } from './page-objects/duckplayer-native.js'; + +test.describe('Duck Player Native messaging', () => { + test('Calls initial setup', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page + await duckPlayer.gotoYouTubePage(); + + // Then Initial Setup should be called + await duckPlayer.didSendInitialHandshake(); + + // And an onDuckPlayerScriptsReady event should be called + await duckPlayer.didSendDuckPlayerScriptsReady(); + }); + + test('Responds to onUrlChanged', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page + await duckPlayer.gotoYouTubePage(); + + // And the frontend receives an onUrlChanged event + await duckPlayer.sendURLChanged('NOCOOKIE'); + + // Then an onDuckPlayerScriptsReady event should be fired twice + await duckPlayer.didSendDuckPlayerScriptsReady(2); + }); + + test('Polls timestamp on YouTube', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page + await duckPlayer.gotoYouTubePage(); + + // Then the current timestamp should be polled back to the browser + await duckPlayer.didSendCurrentTimestamp(); + }); + + test('Polls timestamp on NoCookie page', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a NoCookie page + await duckPlayer.gotoNoCookiePage(); + + // Then the current timestamp should be polled back to the browser + await duckPlayer.didSendCurrentTimestamp(); + }); +}); + +test.describe('Duck Player Native thumbnail overlay', () => { + test('Shows overlay on YouTube player page', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page + await duckPlayer.gotoYouTubePage(); + await duckPlayer.sendOnMediaControl(); + + // Then I should see the thumbnail overlay in the page + await duckPlayer.didShowOverlay(); + await duckPlayer.didShowLogoInOverlay(); + }); + test('Does not duplicate overlay on repeated calls', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page + await duckPlayer.gotoYouTubePage(); + await duckPlayer.sendOnMediaControl(); + + // And the browser fires multiple pause messages + await duckPlayer.sendOnMediaControl(); + await duckPlayer.sendOnMediaControl(); + + // Then I should see only one thumbnail overlay on the page + await duckPlayer.overlayIsUnique(); + }); + test('Dismisses overlay on click', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page + await duckPlayer.gotoYouTubePage(); + await duckPlayer.sendOnMediaControl(); + + // And I see the thumbnail overlay in the page + await duckPlayer.didShowOverlay(); + + // And I click on the overlay + await duckPlayer.clickOnOverlay(); + + // Then the overlay should be dismissed + await duckPlayer.didDismissOverlay(); + + // And a didDismissOverlay event should be fired + await duckPlayer.didSendOverlayDismissalMessage(); + }); +}); + +test.describe('Duck Player Native custom error view', () => { + test('Shows sign-in error', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page with an age-restricted error + await duckPlayer.gotoSignInErrorPage(); + + // Then I should see the generic error screen + await duckPlayer.didShowSignInError(); + }); + + test('Shows age-restricted error', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page with an age-restricted error + await duckPlayer.gotoAgeRestrictedErrorPage(); + + // Then I should see the generic error screen + await duckPlayer.didShowAgeRestrictedError(); + }); + + test('Shows no-embed error', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page with an age-restricted error + await duckPlayer.gotoNoEmbedErrorPage(); + + // Then I should see the generic error screen + await duckPlayer.didShowNoEmbedError(); + }); + + test('Shows generic/unknown error', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + + // Given the duckPlayerNative feature is enabled + await duckPlayer.withRemoteConfig(); + + // When I go to a YouTube page with an age-restricted error + await duckPlayer.gotoUnknownErrorPage(); + + // Then I should see the generic error screen + await duckPlayer.didShowUnknownError(); + }); +}); diff --git a/injected/integration-test/favicon.spec.js b/injected/integration-test/favicon.spec.js new file mode 100644 index 0000000000..3a5b747946 --- /dev/null +++ b/injected/integration-test/favicon.spec.js @@ -0,0 +1,162 @@ +import { test, expect } from '@playwright/test'; +import { ResultsCollector } from './page-objects/results-collector.js'; + +const HTML = '/favicon/index.html'; +const CONFIG = './integration-test/test-pages/favicon/config/favicon-enabled.json'; + +test('favicon feature absent', async ({ page, baseURL }, testInfo) => { + const CONFIG = './integration-test/test-pages/favicon/config/favicon-absent.json'; + const favicon = ResultsCollector.create(page, testInfo.project.use); + await favicon.load(HTML, CONFIG); + + // ensure first favicon item was sent + const messages = await favicon.waitForMessage('faviconFound', 1); + const url = new URL('/favicon/favicon.png', baseURL); + + expect(messages[0].payload.params).toStrictEqual({ + favicons: [{ href: url.href, rel: 'shortcut icon' }], + documentUrl: 'http://localhost:3220/favicon/index.html', + }); +}); + +test('favicon + monitor', async ({ page, baseURL }, testInfo) => { + const favicon = ResultsCollector.create(page, testInfo.project.use); + await favicon.load(HTML, CONFIG); + + // ensure first favicon item was sent + await favicon.waitForMessage('faviconFound', 1); + + // now update it + await page.getByRole('button', { name: 'Set override' }).click(); + + // wait for the second message + const messages = await favicon.waitForMessage('faviconFound', 2); + + const url1 = new URL('/favicon/favicon.png', baseURL); + const url2 = new URL('/favicon/new_favicon.png', baseURL); + + expect(messages[0].payload.params).toStrictEqual({ + favicons: [{ href: url1.href, rel: 'shortcut icon' }], + documentUrl: 'http://localhost:3220/favicon/index.html', + }); + + expect(messages[1].payload.params).toStrictEqual({ + favicons: [{ href: url2.href, rel: 'shortcut icon' }], + documentUrl: 'http://localhost:3220/favicon/index.html', + }); +}); + +test('favicon + monitor + newly added links', async ({ page, baseURL }, testInfo) => { + const favicon = ResultsCollector.create(page, testInfo.project.use); + await favicon.load(HTML, CONFIG); + + // ensure first favicon item was sent + await favicon.waitForMessage('faviconFound', 1); + + // now cause a new item to be added + await page.getByRole('button', { name: 'Add new' }).click(); + + // wait for the second message + const messages = await favicon.waitForMessage('faviconFound', 2); + + const url1 = new URL('/favicon/favicon.png', baseURL); + const url2 = new URL('/favicon/new_favicon.png', baseURL); + + expect(messages[0].payload.params).toStrictEqual({ + favicons: [{ href: url1.href, rel: 'shortcut icon' }], + documentUrl: 'http://localhost:3220/favicon/index.html', + }); + + expect(messages[1].payload.params).toStrictEqual({ + favicons: [ + { href: url1.href, rel: 'shortcut icon' }, + { href: url2.href, rel: 'shortcut icon' }, + ], + documentUrl: 'http://localhost:3220/favicon/index.html', + }); +}); + +test('favicon + monitor (many updates)', async ({ page, baseURL }, testInfo) => { + const favicon = ResultsCollector.create(page, testInfo.project.use); + await page.clock.install(); + await favicon.load(HTML, CONFIG); + + // ensure first favicon item was sent + await favicon.waitForMessage('faviconFound', 1); + + // now update it + await page.getByRole('button', { name: 'Set many overrides' }).click(); + await page.clock.fastForward(20); + + const messages = await favicon.outgoingMessages(); + expect(messages).toHaveLength(1); + + await page.clock.fastForward(60); + await page.clock.fastForward(100); + + { + const messages = await favicon.outgoingMessages(); + expect(messages).toHaveLength(3); + } + + { + const url1 = new URL('/favicon/favicon.png', baseURL); + const url2 = new URL('/favicon/new_favicon.png?count=0', baseURL); + const url3 = new URL('/favicon/new_favicon.png?count=1', baseURL); + + const messages = await favicon.outgoingMessages(); + expect(messages.map((x) => /** @type {{params: any}} */ (x.payload).params)).toStrictEqual([ + { + favicons: [{ href: url1.href, rel: 'shortcut icon' }], + documentUrl: 'http://localhost:3220/favicon/index.html', + }, + { + favicons: [{ href: url2.href, rel: 'shortcut icon' }], + documentUrl: 'http://localhost:3220/favicon/index.html', + }, + { + favicons: [{ href: url3.href, rel: 'shortcut icon' }], + documentUrl: 'http://localhost:3220/favicon/index.html', + }, + ]); + } +}); + +test('favicon + monitor disabled', async ({ page }, testInfo) => { + const CONFIG = './integration-test/test-pages/favicon/config/favicon-monitor-disabled.json'; + const favicon = ResultsCollector.create(page, testInfo.project.use); + + await page.clock.install(); + + await favicon.load(HTML, CONFIG); + + // ensure first favicon item was sent + await favicon.waitForMessage('faviconFound', 1); + + // now update it + await page.getByRole('button', { name: 'Set override' }).click(); + + await expect(page.locator('link')).toHaveAttribute('href', './new_favicon.png'); + + // account for the debounce + await page.clock.fastForward(200); + + // ensure only 1 message was still sent (ie: the monitor is disabled) + const messages = await favicon.outgoingMessages(); + expect(messages).toHaveLength(1); +}); + +test('favicon feature disabled completely', async ({ page }, testInfo) => { + const CONFIG = './integration-test/test-pages/favicon/config/favicon-disabled.json'; + const favicon = ResultsCollector.create(page, testInfo.project.use); + + await favicon.load(HTML, CONFIG); + + // this is here purely to guard against a false positive in this test. + // without this manual `wait`, it might be possible for the following assertion to + // pass, but just because it was too quick (eg: the first message wasn't sent yet) + await page.waitForTimeout(100); + + const messages = await favicon.outgoingMessages(); + expect(messages).toHaveLength(0); +}); diff --git a/injected/integration-test/fingerprint.spec.js b/injected/integration-test/fingerprint.spec.js index 5aa9f65c9a..9c27ce97c1 100644 --- a/injected/integration-test/fingerprint.spec.js +++ b/injected/integration-test/fingerprint.spec.js @@ -2,7 +2,7 @@ * Tests for fingerprint defenses. Ensure that fingerprinting is actually being blocked. */ import { test as base, expect } from '@playwright/test'; -import { testContextForExtension } from './helpers/harness.js'; +import { testContextForExtension, gotoAndWait } from './helpers/harness.js'; import { createRequire } from 'node:module'; // eslint-disable-next-line no-redeclare @@ -23,13 +23,23 @@ const expectedFingerprintValues = { const pagePath = '/index.html'; const tests = [{ url: `http://localhost:3220${pagePath}` }, { url: `http://127.0.0.1:8383${pagePath}` }]; +const enabledCanvasArgs = { + site: { + enabledFeatures: ['fingerprintingCanvas'], + }, + featureSettings: { + fingerprintingCanvas: { + additionalEnabledCheck: 'enabled', + }, + }, +}; test.describe.serial('All Fingerprint Defense Tests (must run in serial)', () => { test.describe.serial('Fingerprint Defense Tests', () => { for (const _test of tests) { test(`${_test.url} should include anti-fingerprinting code`, async ({ page, altServerPort }) => { console.log('running:', altServerPort); - await page.goto(_test.url); + await gotoAndWait(page, _test.url, enabledCanvasArgs); const values = await page.evaluate(() => { return { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f @@ -64,7 +74,7 @@ test.describe.serial('All Fingerprint Defense Tests (must run in serial)', () => * @param {tests[number]} test */ async function runTest(page, test) { - await page.goto(test.url); + await gotoAndWait(page, test.url, enabledCanvasArgs); const lib = require.resolve('@fingerprintjs/fingerprintjs/dist/fp.js'); await page.addScriptTag({ path: lib }); @@ -117,7 +127,7 @@ test.describe.serial('All Fingerprint Defense Tests (must run in serial)', () => tests.forEach((testCase) => { test(`Fingerprints should not match across first parties ${testCase.url}`, async ({ page, altServerPort }) => { console.log('running:', altServerPort); - await page.goto(testCase.url); + await gotoAndWait(page, testCase.url, enabledCanvasArgs); // give it another second just to be sure await page.waitForTimeout(1000); diff --git a/injected/integration-test/helpers/harness.js b/injected/integration-test/helpers/harness.js index 8cdea4c442..44231c9261 100644 --- a/injected/integration-test/helpers/harness.js +++ b/injected/integration-test/helpers/harness.js @@ -8,9 +8,14 @@ import { polyfillProcessGlobals } from '../../unit-test/helpers/polyfill-process const DATA_DIR_PREFIX = 'ddg-temp-'; +/** + * @import {PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestType} from "@playwright/test" + */ + /** * A single place * @param {typeof import("@playwright/test").test} test + * @return {TestType} */ export function testContextForExtension(test) { return test.extend({ diff --git a/injected/integration-test/message-bridge-android.spec.js b/injected/integration-test/message-bridge-android.spec.js new file mode 100644 index 0000000000..6163b6564a --- /dev/null +++ b/injected/integration-test/message-bridge-android.spec.js @@ -0,0 +1,84 @@ +import { test, expect } from '@playwright/test'; +import { ResultsCollector } from './page-objects/results-collector.js'; +import { readOutgoingMessages } from '@duckduckgo/messaging/lib/test-utils.mjs'; + +const ENABLED_CONFIG = 'integration-test/test-pages/message-bridge/config/message-bridge-enabled.json'; +const DISABLED_CONFIG = 'integration-test/test-pages/message-bridge/config/message-bridge-disabled.json'; +const ENABLED_HTML = '/message-bridge/pages/enabled.html'; +const DISABLED_HTML = '/message-bridge/pages/disabled.html'; + +test('message bridge when enabled (android)', async ({ page }, testInfo) => { + const pageWorld = ResultsCollector.create(page, testInfo.project.use); + + // seed the request->re + pageWorld.withMockResponse({ + sampleData: /** @type {any} */ ({ + ghi: 'jkl', + }), + }); + + pageWorld.withUserPreferences({ + messageSecret: 'ABC', + javascriptInterface: 'javascriptInterface', + messageCallback: 'messageCallback', + }); + + // now load the page + await pageWorld.load(ENABLED_HTML, ENABLED_CONFIG); + + // simulate a push event + await pageWorld.simulateSubscriptionMessage('exampleFeature', 'onUpdate', { abc: 'def' }); + + // get all results + const results = await pageWorld.results(); + expect(results['Creating the bridge']).toStrictEqual([ + { name: 'bridge.notify', result: 'function', expected: 'function' }, + { name: 'bridge.request', result: 'function', expected: 'function' }, + { name: 'bridge.subscribe', result: 'function', expected: 'function' }, + { name: 'data', result: [{ abc: 'def' }, { ghi: 'jkl' }], expected: [{ abc: 'def' }, { ghi: 'jkl' }] }, + ]); + + // verify messaging calls + const calls = await page.evaluate(readOutgoingMessages); + expect(calls.length).toBe(2); + const pixel = calls[0].payload; + const request = calls[1].payload; + + expect(pixel).toStrictEqual({ + context: 'contentScopeScripts', + featureName: 'exampleFeature', + method: 'pixel', + params: {}, + }); + + const { id, ...rest } = /** @type {import("@duckduckgo/messaging").RequestMessage} */ (request); + + expect(rest).toStrictEqual({ + context: 'contentScopeScripts', + featureName: 'exampleFeature', + method: 'sampleData', + params: {}, + }); + + if (!('id' in request)) throw new Error('unreachable'); + + expect(typeof request.id).toBe('string'); + expect(request.id.length).toBeGreaterThan(10); +}); + +test('message bridge when disabled (android)', async ({ page }, testInfo) => { + const pageWorld = ResultsCollector.create(page, testInfo.project.use); + + // now load the main page + await pageWorld.load(DISABLED_HTML, DISABLED_CONFIG); + + // verify no outgoing calls were made + const calls = await page.evaluate(readOutgoingMessages); + expect(calls).toHaveLength(0); + + // get all results + const results = await pageWorld.results(); + expect(results['Creating the bridge, but it is unavailable']).toStrictEqual([ + { name: 'error', result: 'Did not install Message Bridge', expected: 'Did not install Message Bridge' }, + ]); +}); diff --git a/injected/integration-test/mocks/broker-protection/captcha.js b/injected/integration-test/mocks/broker-protection/captcha.js new file mode 100644 index 0000000000..fbb16933b3 --- /dev/null +++ b/injected/integration-test/mocks/broker-protection/captcha.js @@ -0,0 +1,144 @@ +import { createPirState, getBrokerProtectionTestPageUrl } from './utils'; +/** + * @import { PirAction } from '../../../src/features/broker-protection/types.js' + */ + +const MOCK_SITE_KEY = '6LeCl8UUAAAAAGssOpatU5nzFXH2D7UZEYelSLTn'; + +// Captcha actions + +/** + * @param {object} params + * @param {Omit} params.action + */ +export function createGetCaptchaInfoAction({ action }) { + return createPirState({ + action: { + id: '8324', + actionType: 'getCaptchaInfo', + ...action, + }, + }); +} + +/** + * @param {Partial} [actionOverrides] + */ +export function createGetRecaptchaInfoAction(actionOverrides = {}) { + return createGetCaptchaInfoAction({ + action: { + captchaType: 'recaptcha2', + selector: '.g-recaptcha', + ...actionOverrides, + }, + }); +} + +/** + * @param {Partial} [actionOverrides] + */ +export function createGetImageCaptchaInfoAction(actionOverrides = {}) { + return createGetCaptchaInfoAction({ + action: { + captchaType: 'image', + ...actionOverrides, + }, + }); +} + +/** + * @param {Partial} [actionOverrides] + */ +export function createGetCloudFlareCaptchaInfoAction(actionOverrides = {}) { + return createGetCaptchaInfoAction({ + action: { + captchaType: 'cloudFlareTurnstile', + ...actionOverrides, + }, + }); +} + +/** + * @param {object} params + * @param {Omit} params.action + * @param {Record} [params.data] + */ +export function createSolveCaptchaAction({ action, data }) { + return createPirState({ + action: { + id: '83241', + actionType: 'solveCaptcha', + ...action, + }, + data: { + token: 'test_token', + ...data, + }, + }); +} + +/** + * @param {Partial} [actionOverrides] + */ +export function createSolveRecaptchaAction(actionOverrides = {}) { + return createSolveCaptchaAction({ + action: { + captchaType: 'recaptcha2', + selector: '.g-recaptcha', + ...actionOverrides, + }, + }); +} + +/** + * @param {Partial} [actionOverrides] + */ +export function createSolveImageCaptchaAction(actionOverrides = {}) { + return createSolveCaptchaAction({ + action: { + captchaType: 'image', + ...actionOverrides, + }, + }); +} + +/** + * @param {Partial} [actionOverrides] + */ +export function createSolveCloudFlareCaptchaAction(actionOverrides = {}) { + return createSolveCaptchaAction({ + action: { + captchaType: 'cloudFlareTurnstile', + ...actionOverrides, + }, + }); +} + +// Captcha responses + +/** + * + * @param {object} param + * @param {string} param.captchaType + * @param {string} param.targetPage + * @returns {object} + */ +export function createCaptchaResponse({ captchaType, targetPage, ...overrides }) { + return { + siteKey: MOCK_SITE_KEY, + url: getBrokerProtectionTestPageUrl(targetPage), + type: captchaType, + ...overrides, + }; +} + +/** + * @param {object} params + * @param {string} params.targetPage + */ +export function createRecaptchaResponse(params) { + return createCaptchaResponse({ + captchaType: 'recaptcha2', + ...params, + }); +} diff --git a/injected/integration-test/mocks/broker-protection/feature-config.js b/injected/integration-test/mocks/broker-protection/feature-config.js new file mode 100644 index 0000000000..d142c46f6d --- /dev/null +++ b/injected/integration-test/mocks/broker-protection/feature-config.js @@ -0,0 +1,19 @@ +/** + * @param {object} brokerProtection + * @param {'enabled' | 'disabled'} [brokerProtection.state] - optional state of the broker protection feature + * @param {string[]} [brokerProtection.exceptions] - optional list of exceptions + * @param {object} [brokerProtection.settings] - optional settings + */ +export function createFeatureConfig(brokerProtection = {}) { + return { + unprotectedTemporary: [], + features: { + brokerProtection: { + state: 'enabled', + exceptions: [], + settings: {}, + ...brokerProtection, + }, + }, + }; +} diff --git a/injected/integration-test/mocks/broker-protection/types.js b/injected/integration-test/mocks/broker-protection/types.js new file mode 100644 index 0000000000..44f532ab82 --- /dev/null +++ b/injected/integration-test/mocks/broker-protection/types.js @@ -0,0 +1,6 @@ +/** + * @typedef {Object} PirState + * @property {Object} state + * @property {import("../../../src/features/broker-protection/types").PirAction} state.action + * @property {Record} [state.data] + */ diff --git a/injected/integration-test/mocks/broker-protection/utils.js b/injected/integration-test/mocks/broker-protection/utils.js new file mode 100644 index 0000000000..e03e260823 --- /dev/null +++ b/injected/integration-test/mocks/broker-protection/utils.js @@ -0,0 +1,14 @@ +/** + * @param {string} fileName + */ +export function getBrokerProtectionTestPageUrl(fileName) { + return `http://localhost:3220/broker-protection/pages/${fileName}`; +} + +/** + * @param {PirState['state']} state + * @returns {PirState} + */ +export function createPirState(state) { + return { state }; +} diff --git a/injected/integration-test/navigator-interface.spec.js b/injected/integration-test/navigator-interface.spec.js index 9873acf8b2..68b2d9904d 100644 --- a/injected/integration-test/navigator-interface.spec.js +++ b/injected/integration-test/navigator-interface.spec.js @@ -8,7 +8,7 @@ const test = testContextForExtension(base); test.describe('Ensure navigator interface is injected', () => { test('should expose navigator.navigator.isDuckDuckGo(): Promise and platform === "extension"', async ({ page }) => { - await gotoAndWait(page, '/blank.html', { platform: { name: 'extension' } }); + await gotoAndWait(page, '/blank.html'); const isDuckDuckGoResult = await page.evaluate(() => { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f const fn = navigator.duckduckgo?.isDuckDuckGo; diff --git a/injected/integration-test/page-objects/broker-protection.js b/injected/integration-test/page-objects/broker-protection.js index 2d6bdfab75..7e7e0ddc88 100644 --- a/injected/integration-test/page-objects/broker-protection.js +++ b/injected/integration-test/page-objects/broker-protection.js @@ -2,6 +2,8 @@ import { expect } from '@playwright/test'; import { readFileSync } from 'fs'; import { perPlatform } from '../type-helpers.mjs'; import { ResultsCollector } from './results-collector.js'; +import { createCaptchaResponse } from '../mocks/broker-protection/captcha.js'; +import { createFeatureConfig } from '../mocks/broker-protection/feature-config.js'; export class BrokerProtectionPage { /** @@ -14,9 +16,15 @@ export class BrokerProtectionPage { this.collector = new ResultsCollector(page, build, platform); } - // Given the "overlays" feature is enabled async enabled() { - await this.collector.setup({ config: loadConfig('enabled') }); + await this.collector.setup({ config: createFeatureConfig({ state: 'enabled' }) }); + } + + /** + * @param {object} config + */ + async withFeatureConfig(config) { + await this.collector.setup({ config }); } /** @@ -58,11 +66,12 @@ export class BrokerProtectionPage { } /** + * @param {string} responseElementSelector * @return {Promise} */ - async isCaptchaTokenFilled() { - const captchaTextArea = await this.page.$('#g-recaptcha-response'); - const captchaToken = await captchaTextArea?.evaluate((element) => element.innerHTML); + async isCaptchaTokenFilled(responseElementSelector) { + const captchaTarget = await this.page.$(responseElementSelector); + const captchaToken = await captchaTarget?.evaluate((element) => ('value' in element ? element.value : element.innerHTML)); expect(captchaToken).toBe('test_token'); } @@ -81,25 +90,31 @@ export class BrokerProtectionPage { } /** + * @param {object} response + * @param {object} captchaParams + * @param {string} captchaParams.captchaType + * @param {string} captchaParams.targetPage + * @param {string} [captchaParams.siteKey] + * * @return {void} */ - isCaptchaMatch(response) { - expect(response).toStrictEqual({ - siteKey: '6LeCl8UUAAAAAGssOpatU5nzFXH2D7UZEYelSLTn', - url: 'http://localhost:3220/broker-protection/pages/captcha.html', - type: 'recaptcha2', - }); + isCaptchaMatch(response, { captchaType, targetPage, ...overrides }) { + const expectedResponse = createCaptchaResponse({ captchaType, targetPage, ...overrides }); + + switch (captchaType) { + case 'image': + // Validate that the correct keys are present in the response + expect(Object.keys(response).sort()).toStrictEqual(Object.keys(expectedResponse).sort()); + // Validate that the siteKey looks like a base64 encoded image + expect(response.siteKey).toMatch(/^data:image\/jpeg;base64,/); + break; + default: + expect(response).toStrictEqual(expectedResponse); + } } - /** - * @return {void} - */ - isHCaptchaMatch(response) { - expect(response).toStrictEqual({ - siteKey: '6LeCl8UUAAAAAGssOpatU5nzFXH2D7UZEYelSLTn', - url: 'http://localhost:3220/broker-protection/pages/captcha2.html', - type: 'hcaptcha', - }); + async isCaptchaError() { + expect(await this.getErrorMessage()).not.toBeFalsy(); } /** @@ -150,17 +165,38 @@ export class BrokerProtectionPage { await this.collector.simulateSubscriptionMessage('brokerProtection', name, payload); } + async getActionCompletedParams() { + return await this.collector.waitForMessage('actionCompleted'); + } + + async getSuccessResponse() { + const response = await this.getActionCompletedParams(); + this.isSuccessMessage(response); + return this._getResultFromResponse(response).success.response; + } + + async getErrorMessage() { + const response = await this.getActionCompletedParams(); + this.isErrorMessage(response); + return this._getResultFromResponse(response).error.message; + } + /** * @param {object} response */ isErrorMessage(response) { - // eslint-disable-next-line no-unsafe-optional-chaining - expect('error' in response[0].payload?.params?.result).toBe(true); + expect('error' in this._getResultFromResponse(response)).toBe(true); } isSuccessMessage(response) { - // eslint-disable-next-line no-unsafe-optional-chaining - expect('success' in response[0].payload?.params?.result).toBe(true); + expect('success' in this._getResultFromResponse(response)).toBe(true); + } + + /** + * @param {object} response + */ + _getResultFromResponse(response) { + return response[0].payload?.params?.result; } /** @@ -174,11 +210,3 @@ export class BrokerProtectionPage { return new BrokerProtectionPage(page, build, platformInfo); } } - -/** - * @param {"enabled"} name - * @return {Record} - */ -function loadConfig(name) { - return JSON.parse(readFileSync(`./integration-test/test-pages/broker-protection/config/${name}.json`, 'utf8')); -} diff --git a/injected/integration-test/page-objects/duckplayer-native.js b/injected/integration-test/page-objects/duckplayer-native.js new file mode 100644 index 0000000000..34127dce81 --- /dev/null +++ b/injected/integration-test/page-objects/duckplayer-native.js @@ -0,0 +1,335 @@ +import { readFileSync } from 'fs'; +import { expect } from '@playwright/test'; +import { perPlatform } from '../type-helpers.mjs'; +import { ResultsCollector } from './results-collector.js'; + +/** + * @import { PageType } from '../../src/features/duckplayer-native/messages.js' + * @typedef {"default" | "incremental-dom" | "age-restricted-error" | "sign-in-error" | "no-embed-error" | "unknown-error"} PlayerPageVariants + */ + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const configFiles = /** @type {const} */ (['native.json']); + +const defaultInitialSetup = { + locale: 'en', +}; + +const featureName = 'duckPlayerNative'; + +export class DuckPlayerNative { + /** @type {Partial>} */ + pages = { + YOUTUBE: '/duckplayer-native/pages/player.html', + NOCOOKIE: '/duckplayer-native/pages/player.html', + }; + + /** + * @param {import("@playwright/test").Page} page + * @param {import("../type-helpers.mjs").Build} build + * @param {import("@duckduckgo/messaging/lib/test-utils.mjs").PlatformInfo} platform + */ + constructor(page, build, platform) { + this.page = page; + this.build = build; + this.platform = platform; + this.isMobile = platform.name === 'android' || platform.name === 'ios'; + this.collector = new ResultsCollector(page, build, platform); + this.collector.withMockResponse({ + initialSetup: defaultInitialSetup, + onCurrentTimestamp: {}, + }); + this.collector.withUserPreferences({ + messageSecret: 'ABC', + javascriptInterface: 'javascriptInterface', + messageCallback: 'messageCallback', + }); + page.on('console', (msg) => { + console.log(msg.type(), msg.text()); + }); + } + + async reducedMotion() { + await this.page.emulateMedia({ reducedMotion: 'reduce' }); + } + + /** + * @param {object} [params] + * @param {string} [params.videoID] + */ + async gotoYouTubePage(params = {}) { + await this.gotoPage('YOUTUBE', params); + } + + async gotoNoCookiePage() { + await this.gotoPage('NOCOOKIE', {}); + } + + async gotoAgeRestrictedErrorPage() { + await this.gotoPage('NOCOOKIE', { variant: 'age-restricted-error' }); + } + + async gotoNoEmbedErrorPage() { + await this.gotoPage('NOCOOKIE', { variant: 'no-embed-error' }); + } + + async gotoUnknownErrorPage() { + await this.gotoPage('NOCOOKIE', { variant: 'unknown-error' }); + } + + async gotoSignInErrorPage() { + await this.gotoPage('NOCOOKIE', { variant: 'sign-in-error' }); + } + + async gotoSERP() { + await this.gotoPage('SERP', {}); + } + + /** + * @param {PageType} pageType + * @param {object} [params] + * @param {PlayerPageVariants} [params.variant] + * @param {string} [params.videoID] + */ + async gotoPage(pageType, params = {}) { + await this.withPageType(pageType); + + const defaultVariant = this.isMobile ? 'mobile' : 'default'; + const { variant = defaultVariant, videoID = '123' } = params; + const urlParams = new URLSearchParams([ + ['v', videoID], + ['variant', variant], + ]); + + const page = this.pages[pageType]; + + await this.page.goto(page + '?' + urlParams.toString()); + } + + /** + * @param {object} [params] + * @param {configFiles[number]} [params.json="native"] - default is settings for localhost + * @param {string} [params.locale] - optional locale + */ + async withRemoteConfig(params = {}) { + const { json = 'native.json', locale = 'en' } = params; + + await this.collector.setup({ config: loadConfig(json), locale }); + } + + /** + * @param {PageType} pageType + * @return {Promise} + */ + async withPageType(pageType) { + const initialSetup = this.collector.mockResponses?.initialSetup || defaultInitialSetup; + + await this.collector.updateMockResponse({ + initialSetup: { pageType, ...initialSetup }, + }); + } + + /** + * @param {boolean} playbackPaused + * @return {Promise} + */ + async withPlaybackPaused(playbackPaused = true) { + const initialSetup = this.collector.mockResponses.initialSetup || defaultInitialSetup; + + await this.collector.updateMockResponse({ + initialSetup: { playbackPaused, ...initialSetup }, + }); + } + + /** + * @param {string} name + * @param {Record} payload + */ + async simulateSubscriptionMessage(name, payload) { + await this.collector.simulateSubscriptionMessage(featureName, name, payload); + } + + /** + * Helper for creating an instance per platform + * @param {import("@playwright/test").Page} page + * @param {import("@playwright/test").TestInfo} testInfo + */ + static create(page, testInfo) { + // Read the configuration object to determine which platform we're testing against + const { platformInfo, build } = perPlatform(testInfo.project.use); + return new DuckPlayerNative(page, build, platformInfo); + } + + /** + * @return {Promise} + */ + requestWillFail() { + return new Promise((resolve, reject) => { + // on windows it will be a failed request + const timer = setTimeout(() => { + reject(new Error('timed out')); + }, 5000); + this.page.on('framenavigated', (req) => { + clearTimeout(timer); + resolve(req.url()); + }); + }); + } + + /* Subscriptions */ + + /** + * @param {object} options + */ + async sendOnMediaControl(options = { pause: true }) { + await this.simulateSubscriptionMessage('onMediaControl', options); + } + + /** + * @param {object} options + */ + async sendOnSerpNotify(options = {}) { + await this.simulateSubscriptionMessage('onSerpNotify', options); + } + + /** + * @param {object} options + */ + async sendOnMuteAudio(options = { mute: true }) { + await this.simulateSubscriptionMessage('onMuteAudio', options); + } + + /** + * @param {PageType} pageType + */ + async sendURLChanged(pageType) { + await this.simulateSubscriptionMessage('onUrlChanged', { pageType }); + } + + /* Messaging assertions */ + + async didSendInitialHandshake() { + const messages = await this.collector.waitForMessage('initialSetup'); + expect(messages).toMatchObject([ + { + payload: { + context: this.collector.messagingContextName, + featureName, + method: 'initialSetup', + params: {}, + }, + }, + ]); + } + + async didSendCurrentTimestamp() { + const messages = await this.collector.waitForMessage('onCurrentTimestamp'); + expect(messages).toMatchObject([ + { + payload: { + context: this.collector.messagingContextName, + featureName, + method: 'onCurrentTimestamp', + params: { timestamp: '0' }, + }, + }, + ]); + } + + /* Thumbnail Overlay assertions */ + + async didShowOverlay() { + await this.page.locator('ddg-video-thumbnail-overlay-mobile').waitFor({ state: 'visible', timeout: 1000 }); + } + + async didShowLogoInOverlay() { + await this.page.locator('ddg-video-thumbnail-overlay-mobile .logo').waitFor({ state: 'visible', timeout: 1000 }); + } + + async overlayIsUnique() { + const count = await this.page.locator('ddg-video-thumbnail-overlay-mobile').count(); + expect(count).toBe(1); + } + + async clickOnOverlay() { + await this.page.locator('ddg-video-thumbnail-overlay-mobile').click(); + } + + async didDismissOverlay() { + await this.page.locator('ddg-video-thumbnail-overlay-mobile').waitFor({ state: 'hidden', timeout: 1000 }); + } + + async didSendOverlayDismissalMessage() { + const messages = await this.collector.waitForMessage('didDismissOverlay'); + expect(messages).toMatchObject([ + { + payload: { + context: this.collector.messagingContextName, + featureName, + method: 'didDismissOverlay', + params: {}, + }, + }, + ]); + } + + /* Custom Error assertions */ + + async didShowAgeRestrictedError() { + await expect(this.page.locator('ddg-video-error')).toMatchAriaSnapshot(` + - heading "Sorry, this video is age-restricted" [level=1] + - paragraph: To watch age-restricted videos, you need to sign in to YouTube to verify your age. + - paragraph: You can still watch this video, but you’ll have to sign in and watch it on YouTube without the added privacy of Duck Player. + `); + } + + async didShowNoEmbedError() { + await expect(this.page.locator('ddg-video-error')).toMatchAriaSnapshot(` + - heading "Sorry, this video can only be played on YouTube" [level=1] + - paragraph: The creator of this video has chosen not to allow it to be viewed on other sites. + - paragraph: You can still watch it on YouTube, but without the added privacy of Duck Player. + `); + } + + async didShowUnknownError() { + await expect(this.page.locator('ddg-video-error')).toMatchAriaSnapshot(` + - heading "Duck Player can’t load this video" [level=1] + - paragraph: This video can’t be viewed outside of YouTube. + - paragraph: You can still watch this video on YouTube, but without the added privacy of Duck Player. + `); + } + + async didShowSignInError() { + await expect(this.page.locator('ddg-video-error')).toMatchAriaSnapshot(` + - heading "Sorry, YouTube thinks you’re a bot" [level=1] + - paragraph: This can happen if you’re using a VPN. Try turning the VPN off or switching server locations and reloading this page. + - paragraph: If that doesn’t work, you’ll have to sign in and watch this video on YouTube without the added privacy of Duck Player. + `); + } + + /** + * @param {number} numberOfCalls - Number of times the message should be received + */ + async didSendDuckPlayerScriptsReady(numberOfCalls = 1) { + const expectedMessage = { + payload: { + context: this.collector.messagingContextName, + featureName, + method: 'onDuckPlayerScriptsReady', + params: {}, + }, + }; + const expectedMessages = Array(numberOfCalls).fill(expectedMessage); + const actualMessages = await this.collector.waitForMessage('onDuckPlayerScriptsReady'); + + expect(actualMessages).toMatchObject(expectedMessages); + } +} + +/** + * @param {configFiles[number]} name + * @return {Record} + */ +function loadConfig(name) { + return JSON.parse(readFileSync(`./integration-test/test-pages/duckplayer-native/config/${name}`, 'utf8')); +} diff --git a/injected/integration-test/page-objects/duckplayer-overlays.js b/injected/integration-test/page-objects/duckplayer-overlays.js index e860bbdfa2..1b0850a3ee 100644 --- a/injected/integration-test/page-objects/duckplayer-overlays.js +++ b/injected/integration-test/page-objects/duckplayer-overlays.js @@ -38,6 +38,7 @@ const uiSettings = { const configFiles = /** @type {const} */ ([ 'overlays.json', 'overlays-live.json', + 'overlays-drawer.json', 'disabled.json', 'thumbnail-overlays-disabled.json', 'click-interceptions-disabled.json', @@ -80,11 +81,20 @@ export class DuckplayerOverlays { }, sendDuckPlayerPixel: {}, }); + this.collector.withUserPreferences({ + messageSecret: 'ABC', + javascriptInterface: 'javascriptInterface', + messageCallback: 'messageCallback', + }); page.on('console', (msg) => { console.log(msg.type(), msg.text()); }); } + async reducedMotion() { + await this.page.emulateMedia({ reducedMotion: 'reduce' }); + } + /** * @param {object} params * @param {'default' | 'cookie_banner'} [params.variant] @@ -467,6 +477,17 @@ export class DuckplayerOverlays { ]); } + /** + * @return {Promise} + */ + async userSettingWasNotUpdated() { + const messages = await this.collector.outgoingMessages(); + // @ts-expect-error - Subscription is missing method property + const setUserValuesMessages = messages.filter((message) => message.payload?.method === 'setUserValues'); + + expect(setUserValuesMessages.length).toBe(0); + } + /** * Helper for creating an instance per platform * @param {import("@playwright/test").Page} page @@ -521,6 +542,10 @@ class DuckplayerOverlaysMobile { this.overlays = overlays; } + async drawerIsPresented() { + const { page } = this.overlays; + await page.locator('ddg-video-drawer-mobile').waitFor({ state: 'visible', timeout: 2000 }); + } async choosesWatchHere() { const { page } = this.overlays; await page.getByRole('button', { name: 'No Thanks' }).click(); @@ -531,6 +556,16 @@ class DuckplayerOverlaysMobile { await page.getByRole('link', { name: 'Turn On Duck Player' }).click(); } + async clicksOnVideoThumbnail() { + const { page } = this.overlays; + await page.locator('ddg-video-thumbnail-overlay-mobile .bg').click({ force: true }); + } + + async clicksOnDrawerBackdrop() { + const { page } = this.overlays; + await page.locator('ddg-video-drawer-mobile .ddg-mobile-drawer-background').click({ position: { x: 10, y: 10 } }); + } + async selectsRemember() { const { page } = this.overlays; await page.getByRole('switch').click(); diff --git a/injected/integration-test/page-objects/results-collector.js b/injected/integration-test/page-objects/results-collector.js index eae3aef368..debf17638b 100644 --- a/injected/integration-test/page-objects/results-collector.js +++ b/injected/integration-test/page-objects/results-collector.js @@ -15,6 +15,10 @@ import { windowsGlobalPolyfills } from '../shared.mjs'; import { processConfig } from '../../src/utils.js'; import { gotoAndWait } from '../helpers/harness.js'; +/** + * @typedef {import('../../src/utils.js').Platform} Platform + */ + /** * This is designed to allow you to execute Playwright tests using the various * artifacts we produce. For example, on the `apple` target this can be used to ensure @@ -59,8 +63,9 @@ export class ResultsCollector { /** * @param {string} htmlPath * @param {string} configPath + * @param {Partial} [platform] */ - async load(htmlPath, configPath) { + async load(htmlPath, configPath, platform) { /** * For now, take a separate path for the extension since it's setup * is quite different to browsers. We hide this setup step from consumers, @@ -68,9 +73,9 @@ export class ResultsCollector { * about the details. */ if (this.platform.name === 'extension') { - return await this._loadExtension(htmlPath, configPath); + return await this._loadExtension(htmlPath, configPath, platform); } - await this.setup({ config: configPath }); + await this.setup({ config: configPath, platform }); await this.page.goto(htmlPath); } @@ -104,14 +109,16 @@ export class ResultsCollector { /** * @param {string} htmlPath * @param {string} configPath + * @param {Partial} [platform] * @private */ - async _loadExtension(htmlPath, configPath) { + async _loadExtension(htmlPath, configPath, platform) { const config = JSON.parse(readFileSync(configPath, 'utf8')); /** @type {import('../../src/utils.js').UserPreferences} */ const userPreferences = { platform: { name: this.platform.name, + ...platform, }, sessionKey: 'test', }; @@ -129,10 +136,11 @@ export class ResultsCollector { * @param {object} params * @param {Record | string} params.config * @param {string} [params.locale] + * @param {Partial} [params.platform] * @return {Promise} */ async setup(params) { - let { config, locale } = params; + let { config, locale, platform } = params; if (typeof config === 'string') { config = JSON.parse(readFileSync(config, 'utf8')); @@ -155,7 +163,7 @@ export class ResultsCollector { android: async () => { // noop }, - 'android-autofill-password-import': async () => { + 'android-autofill-import': async () => { // noop }, }); @@ -165,20 +173,21 @@ export class ResultsCollector { 'apple-isolated': () => mockWebkitMessaging, windows: () => mockWindowsMessaging, android: () => mockAndroidMessaging, - 'android-autofill-password-import': () => mockAndroidMessaging, + 'android-autofill-import': () => mockAndroidMessaging, }); await this.page.addInitScript(messagingMock, { messagingContext: this.messagingContext('n/a'), responses: this.#mockResponses, messageCallback: 'messageCallback', + javascriptInterface: this.#userPreferences.javascriptInterface, }); const wrapFn = this.build.switch({ 'apple-isolated': () => wrapWebkitScripts, apple: () => wrapWebkitScripts, android: () => wrapWebkitScripts, - 'android-autofill-password-import': () => wrapWebkitScripts, + 'android-autofill-import': () => wrapWebkitScripts, windows: () => wrapWindowsScripts, }); @@ -187,7 +196,7 @@ export class ResultsCollector { $CONTENT_SCOPE$: config, $USER_UNPROTECTED_DOMAINS$: [], $USER_PREFERENCES$: { - platform: { name: this.platform.name }, + platform: { name: this.platform.name, ...platform }, debug: true, messageCallback: 'messageCallback', messageSecret: 'duckduckgo-android-messaging-secret', @@ -234,6 +243,8 @@ export class ResultsCollector { name, payload, injectName: this.build.name, + messageCallback: this.#userPreferences.messageCallback, + messageSecret: this.#userPreferences.messageSecret, }); } @@ -241,6 +252,10 @@ export class ResultsCollector { return this.build.name === 'apple-isolated' ? 'contentScopeScriptsIsolated' : 'contentScopeScripts'; } + get mockResponses() { + return this.#mockResponses; + } + /** * @param {string} featureName * @return {import("@duckduckgo/messaging").MessagingContext} @@ -265,14 +280,15 @@ export class ResultsCollector { /** * @param {string} method - * @return {Promise} + * @param {number} [count=1] + * @return {Promise[]>} */ - async waitForMessage(method) { + async waitForMessage(method, count = 1) { await this.page.waitForFunction( waitForCallCount, { method, - count: 1, + count, }, { timeout: 5000, polling: 100 }, ); @@ -294,6 +310,7 @@ export class ResultsCollector { */ static create(page, use) { // Read the configuration object to determine which platform we're testing against + page.on('console', (msg) => console[msg.type()](msg.text())); const { platformInfo, build } = perPlatform(use); return new ResultsCollector(page, build, platformInfo); } diff --git a/injected/integration-test/pages.spec.js b/injected/integration-test/pages.spec.js index 982e5f7d4c..d6b02bff2c 100644 --- a/injected/integration-test/pages.spec.js +++ b/injected/integration-test/pages.spec.js @@ -1,12 +1,42 @@ /** * Tests for shared pages. * Note: these only run in the extension setup for now. + * + * IMPORTANT TESTING GUIDELINES: + * + * 1. AVOID CUSTOM STATE IN SPEC FILES: + * It's unadvisable to add custom state for tests directly in .spec.js files as it makes + * validation difficult and reduces test reliability. If custom state is absolutely required, + * ensure this is clearly explained in the corresponding test HTML file with detailed + * comments about what state is being set and why it's necessary. + * + * 2. PLATFORM CONFIGURATION: + * The 'Platform' parameter can be passed as an argument to testPage() to simulate different + * platform environments. This is demonstrated in the version tests: + * - minSupportedVersion (string): Uses { version: '1.5.0' } + * - minSupportedVersion (int): Uses { version: 99 } + * - maxSupportedVersion (string): Uses { version: '1.5.0' } + * - maxSupportedVersion (int): Uses { version: 99 } + * + * This is needed when testing features that have platform-specific behavior or version + * requirements. The platform object allows testing how features behave under different + * version constraints without modifying the core test infrastructure. + * + * 3. CONFIG-DRIVEN TESTING: + * Where possible, prefer purely config-driven testing to validate features. This approach: + * - Makes tests more maintainable and readable + * - Reduces coupling between test logic and implementation details + * - Allows for easier test data management and updates + * - Provides better separation of concerns between test setup and validation */ import { test as base, expect } from '@playwright/test'; import { testContextForExtension } from './helpers/harness.js'; import { ResultsCollector } from './page-objects/results-collector.js'; const test = testContextForExtension(base); +/** + * @typedef {import('../../injected/src/utils.js').Platform} Platform + */ test.describe('Test integration pages', () => { /** @@ -14,20 +44,52 @@ test.describe('Test integration pages', () => { * @param {import("@playwright/test").TestInfo} testInfo * @param {string} html * @param {string} config + * @param {Partial} [platform] */ - async function testPage(page, testInfo, html, config) { - const collector = ResultsCollector.create(page, testInfo.project.use); - await collector.load(html, config); + async function testPage(page, testInfo, html, config, platform = {}) { + const collector = ResultsCollector.create(page, testInfo?.project?.use); + await collector.load(html, config, platform); const results = await collector.results(); for (const key in results) { for (const result of results[key]) { - await test.step(`${key}:\n ${result.name}`, () => { + await test.step(`${key}: ${result.name}`, () => { expect(result.result).toEqual(result.expected); }); } } } + test('Test infra', async ({ page }, testInfo) => { + await testPage( + page, + testInfo, + '/infra/pages/conditional-matching.html', + './integration-test/test-pages/infra/config/conditional-matching.json', + ); + }); + + test('Test infra with experiments', async ({ page }, testInfo) => { + await testPage( + page, + testInfo, + '/infra/pages/conditional-matching-experiments.html', + './integration-test/test-pages/infra/config/conditional-matching-experiments.json', + ); + }); + + test('Test infra fallback', async ({ page }, testInfo) => { + await page.addInitScript(() => { + // This ensures that our fallback code applies and so we simulate other platforms than Chromium. + delete globalThis.navigation; + }); + await testPage( + page, + testInfo, + '/infra/pages/conditional-matching.html', + './integration-test/test-pages/infra/config/conditional-matching.json', + ); + }); + test('Test manipulating APIs', async ({ page }, testInfo) => { await testPage( page, @@ -66,4 +128,53 @@ test.describe('Test integration pages', () => { `./integration-test/test-pages/webcompat/config/modify-cookies.json`, ); }); + + test('enumerateDevices API functionality', async ({ page }, testInfo) => { + await testPage( + page, + testInfo, + 'webcompat/pages/enumerate-devices-api-test.html', + './integration-test/test-pages/webcompat/config/enumerate-devices-api.json', + ); + }); + + test('minSupportedVersion (string)', async ({ page }, testInfo) => { + await testPage( + page, + testInfo, + '/infra/pages/min-supported-version-string.html', + './integration-test/test-pages/infra/config/min-supported-version-string.json', + { version: '1.5.0' }, + ); + }); + + test('minSupportedVersion (int)', async ({ page }, testInfo) => { + await testPage( + page, + testInfo, + '/infra/pages/min-supported-version-int.html', + './integration-test/test-pages/infra/config/min-supported-version-int.json', + { version: 99 }, + ); + }); + + test('maxSupportedVersion (string)', async ({ page }, testInfo) => { + await testPage( + page, + testInfo, + '/infra/pages/max-supported-version-string.html', + './integration-test/test-pages/infra/config/max-supported-version-string.json', + { version: '1.5.0' }, + ); + }); + + test('maxSupportedVersion (int)', async ({ page }, testInfo) => { + await testPage( + page, + testInfo, + '/infra/pages/max-supported-version-int.html', + './integration-test/test-pages/infra/config/max-supported-version-int.json', + { version: 99 }, + ); + }); }); diff --git a/injected/integration-test/test-pages/api-manipulation/config/apis.json b/injected/integration-test/test-pages/api-manipulation/config/apis.json index 49705b755f..565d663a6a 100644 --- a/injected/integration-test/test-pages/api-manipulation/config/apis.json +++ b/injected/integration-test/test-pages/api-manipulation/config/apis.json @@ -1,7 +1,11 @@ { + "readme": "This config is used to test the api manipulation feature.", + "version": 1, + "unprotectedTemporary": [], "features": { "apiManipulation": { "state": "enabled", + "exceptions": [], "settings": { "apiChanges": { "Navigator.prototype.hardwareConcurrency": { @@ -78,6 +82,21 @@ "getterValue": { "type": "undefined" } + }, + "window.definedByConfig": { + "type": "descriptor", + "getterValue": { + "type": "string", + "value": "defined!" + }, + "define": true + }, + "window.notDefinedByConfig": { + "type": "descriptor", + "getterValue": { + "type": "string", + "value": "should not exist" + } } } } diff --git a/injected/integration-test/test-pages/api-manipulation/pages/apis.html b/injected/integration-test/test-pages/api-manipulation/pages/apis.html index 41d7759a43..5ddb9e4d75 100644 --- a/injected/integration-test/test-pages/api-manipulation/pages/apis.html +++ b/injected/integration-test/test-pages/api-manipulation/pages/apis.html @@ -89,6 +89,21 @@ return result; }); + test('Define property with define: true', async () => { + return [ + { + name: "Property defined by config (define: true)", + result: window.definedByConfig, + expected: "defined!" + }, + { + name: "Property not defined by config (define not set)", + result: window.notDefinedByConfig, + expected: undefined + } + ]; + }); + // eslint-disable-next-line no-undef renderResults(); diff --git a/injected/integration-test/test-pages/autofill-password-import/config/config.json b/injected/integration-test/test-pages/autofill-import/config/config.json similarity index 87% rename from injected/integration-test/test-pages/autofill-password-import/config/config.json rename to injected/integration-test/test-pages/autofill-import/config/config.json index 3130dee4f5..d0a251a59a 100644 --- a/injected/integration-test/test-pages/autofill-password-import/config/config.json +++ b/injected/integration-test/test-pages/autofill-import/config/config.json @@ -1,6 +1,8 @@ { + "readme": "This config is used to test the autofill password import feature.", + "version": 1, "features": { - "autofillPasswordImport": { + "autofillImport": { "state": "enabled", "exceptions": [], "settings": { @@ -17,6 +19,7 @@ "value": { "settingsButton": { "shouldAutotap": false, + "tapOnce": false, "path": "/", "selectors": [ "a[href*='options']" @@ -27,6 +30,7 @@ }, "exportButton": { "shouldAutotap": false, + "tapOnce": false, "path": "/options", "selectors": [ "c-wiz[data-node-index*='2;0'][data-p*='options']", @@ -38,6 +42,7 @@ }, "signInButton": { "shouldAutotap": false, + "tapOnce": false, "path": "/intro", "selectors": [ "a[href*='ServiceLogin']:not([target='_top'])", diff --git a/injected/integration-test/test-pages/autofill-password-import/index.html b/injected/integration-test/test-pages/autofill-import/index.html similarity index 100% rename from injected/integration-test/test-pages/autofill-password-import/index.html rename to injected/integration-test/test-pages/autofill-import/index.html diff --git a/injected/integration-test/test-pages/blank.html b/injected/integration-test/test-pages/blank.html index 15c8ca4392..6b182eb07a 100644 --- a/injected/integration-test/test-pages/blank.html +++ b/injected/integration-test/test-pages/blank.html @@ -1,6 +1,6 @@ - -

Blank Integration page

-

This page is used by extension test code, which will inject a content script

- + +

Blank Integration page

+

This page is used by extension test code, which will inject a content script

+ diff --git a/injected/integration-test/test-pages/breakage-reporting/config/config.json b/injected/integration-test/test-pages/breakage-reporting/config/config.json index 050d5b7958..11e2165348 100644 --- a/injected/integration-test/test-pages/breakage-reporting/config/config.json +++ b/injected/integration-test/test-pages/breakage-reporting/config/config.json @@ -1,4 +1,6 @@ { + "readme": "This config is used to test the breakage reporting feature.", + "version": 1, "unprotectedTemporary": [], "features": { "breakageReporting": { diff --git a/injected/integration-test/test-pages/broker-protection/actions/click-disabled-button-failSilently.json b/injected/integration-test/test-pages/broker-protection/actions/click-disabled-button-failSilently.json new file mode 100644 index 0000000000..d83e149475 --- /dev/null +++ b/injected/integration-test/test-pages/broker-protection/actions/click-disabled-button-failSilently.json @@ -0,0 +1,16 @@ +{ + "state": { + "action": { + "actionType": "click", + "id": "1", + "elements": [ + { + "type": "button", + "selector": ".btn", + "failSilently": true + } + ] + } + } + } + \ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/actions/click-disabled-button.json b/injected/integration-test/test-pages/broker-protection/actions/click-disabled-button.json new file mode 100644 index 0000000000..b13e87c312 --- /dev/null +++ b/injected/integration-test/test-pages/broker-protection/actions/click-disabled-button.json @@ -0,0 +1,15 @@ +{ + "state": { + "action": { + "actionType": "click", + "id": "1", + "elements": [ + { + "type": "button", + "selector": ".btn" + } + ] + } + } + } + \ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/actions/click-nonexistent-selector-failSilently.json b/injected/integration-test/test-pages/broker-protection/actions/click-nonexistent-selector-failSilently.json new file mode 100644 index 0000000000..2d77354350 --- /dev/null +++ b/injected/integration-test/test-pages/broker-protection/actions/click-nonexistent-selector-failSilently.json @@ -0,0 +1,16 @@ +{ + "state": { + "action": { + "actionType": "click", + "id": "1", + "elements": [ + { + "type": "button", + "selector": ".test", + "failSilently": true + } + ] + } + } + } + \ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/actions/click-nonexistent-selector.json b/injected/integration-test/test-pages/broker-protection/actions/click-nonexistent-selector.json new file mode 100644 index 0000000000..8aa7698e15 --- /dev/null +++ b/injected/integration-test/test-pages/broker-protection/actions/click-nonexistent-selector.json @@ -0,0 +1,15 @@ +{ + "state": { + "action": { + "actionType": "click", + "id": "1", + "elements": [ + { + "type": "button", + "selector": ".test" + } + ] + } + } + } + \ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/actions/condition-fail-silently.json b/injected/integration-test/test-pages/broker-protection/actions/condition-fail-silently.json new file mode 100644 index 0000000000..6da6f57212 --- /dev/null +++ b/injected/integration-test/test-pages/broker-protection/actions/condition-fail-silently.json @@ -0,0 +1,30 @@ +{ + "state": { + "action": { + "actionType": "condition", + "retry": { + "environment": "web", + "interval": { "ms": 1000 }, + "maxAttempts": 1 + }, + "expectations": [ + { + "type": "element", + "selector": "form.fakeForm", + "failSilently": true + } + ], + "actions": [ + { + "actionType": "click", + "elements": [ + { + "type": "button", + "selector": ".btn-sbmt" + } + ] + } + ] + } + } + } \ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/actions/condition-fail.json b/injected/integration-test/test-pages/broker-protection/actions/condition-fail.json new file mode 100644 index 0000000000..b9128f5e93 --- /dev/null +++ b/injected/integration-test/test-pages/broker-protection/actions/condition-fail.json @@ -0,0 +1,29 @@ +{ + "state": { + "action": { + "actionType": "condition", + "retry": { + "environment": "web", + "interval": { "ms": 1000 }, + "maxAttempts": 1 + }, + "expectations": [ + { + "type": "element", + "selector": "form.fakeForm" + } + ], + "actions": [ + { + "actionType": "click", + "elements": [ + { + "type": "button", + "selector": ".btn-sbmt" + } + ] + } + ] + } + } + } \ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/actions/condition-success.json b/injected/integration-test/test-pages/broker-protection/actions/condition-success.json new file mode 100644 index 0000000000..ad90851027 --- /dev/null +++ b/injected/integration-test/test-pages/broker-protection/actions/condition-success.json @@ -0,0 +1,24 @@ +{ + "state": { + "action": { + "actionType": "condition", + "expectations": [ + { + "type": "element", + "selector": "form.ahm" + } + ], + "actions": [ + { + "actionType": "click", + "elements": [ + { + "type": "button", + "selector": ".btn-sbmt" + } + ] + } + ] + } + } + } \ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/actions/get-captcha.json b/injected/integration-test/test-pages/broker-protection/actions/get-captcha.json deleted file mode 100644 index 314b9d339d..0000000000 --- a/injected/integration-test/test-pages/broker-protection/actions/get-captcha.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "state": { - "action": { - "actionType": "getCaptchaInfo", - "selector": ".g-recaptcha", - "id": "8324" - } - } -} \ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/actions/solve-captcha.json b/injected/integration-test/test-pages/broker-protection/actions/solve-captcha.json deleted file mode 100644 index 3a7330c6db..0000000000 --- a/injected/integration-test/test-pages/broker-protection/actions/solve-captcha.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "state": { - "action": { - "actionType": "solveCaptcha", - "id": "83241" - }, - "data": { - "token": "test_token" - } - } -} \ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/config/enabled.json b/injected/integration-test/test-pages/broker-protection/config/enabled.json deleted file mode 100644 index 9ecc3bf9a8..0000000000 --- a/injected/integration-test/test-pages/broker-protection/config/enabled.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "unprotectedTemporary": [], - "features": { - "brokerProtection": { - "state": "enabled", - "exceptions": [], - "settings": {} - } - } -} diff --git a/injected/integration-test/test-pages/broker-protection/pages/captcha2.html b/injected/integration-test/test-pages/broker-protection/pages/captcha2.html deleted file mode 100644 index 64fc67f4e5..0000000000 --- a/injected/integration-test/test-pages/broker-protection/pages/captcha2.html +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/injected/integration-test/test-pages/broker-protection/pages/clicks.html b/injected/integration-test/test-pages/broker-protection/pages/clicks.html new file mode 100644 index 0000000000..13d06d3b00 --- /dev/null +++ b/injected/integration-test/test-pages/broker-protection/pages/clicks.html @@ -0,0 +1,20 @@ + + + + + + Broker Protection + + +
+
John Doe
+
32
+
+ New York, NY + Los Angeles, CA + View More +
+ +
+ + \ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/pages/cloudflare-captcha.html b/injected/integration-test/test-pages/broker-protection/pages/cloudflare-captcha.html new file mode 100644 index 0000000000..8de0d4c86c --- /dev/null +++ b/injected/integration-test/test-pages/broker-protection/pages/cloudflare-captcha.html @@ -0,0 +1,34 @@ + + + + Beenverified + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/pages/h-captcha.html b/injected/integration-test/test-pages/broker-protection/pages/h-captcha.html new file mode 100644 index 0000000000..4f85d57970 --- /dev/null +++ b/injected/integration-test/test-pages/broker-protection/pages/h-captcha.html @@ -0,0 +1,4 @@ +
+ + +
diff --git a/injected/integration-test/test-pages/broker-protection/pages/image-captcha.html b/injected/integration-test/test-pages/broker-protection/pages/image-captcha.html new file mode 100644 index 0000000000..bddd0e5fe6 --- /dev/null +++ b/injected/integration-test/test-pages/broker-protection/pages/image-captcha.html @@ -0,0 +1,66 @@ + + + Privatereports + + + + + + + + + + + + + + + + + +

5%

About Us

PrivateReports is a top, trusted company started in 2020 by data scientists, engineers and customer service professionals with more than 10 years experience delivering public record information via the Internet. Our goal is total customer satisfaction. We search over 12 billion public records - from thousands of sources - to create an accurate, comprehensive report on almost anyone in the USA. We specialize in running background checks, searching criminal records and digging up hidden information on the dark web -- all so you know the deep truth about the many people in your life. You can look up your new romances, old class mates, annoying neighbors, strange coworkers or even familiar friends and relatives you've known for years. They will never know you're looking them up because our searches are 100% anonymous, guaranteed. This helps you have peace-of-mind, assured you have the best available information to make the decisions that keep you and your loved one safe and secure.

View Sample Report

What are public records?

Public records can be found at the local, state, or federal level which are open to the public through the Freedom of Information Act. In some cases, you can write a letter to the government to receive public record data. In other cases, you may be required to visit the local county office in person. In most cases, you will need to visit hundreds of different public and private websites to get the single, easy-to-read report that PrivateReports provides. Go ahead and give it a try! Just enter someone's name and location, and we'll search our massive database to give you instant results. While it's free to search our site as many times as you want, we charge a nominal fee to receive our 100% comprehensive reports, in order to pay for the time and money required to collect this information, store it, and present it on our website for your easy consumption. It's just $1 for your first report, and then you pay a nominal monthly fee if you want to view more reports, as many as you want! Our goal is total customer satisfaction, and we are confident you will find great value in our data quality and our exceptional customer service.

Search

What comes in my report?

A PrivateReports background report can include:

  • Public Records
  • Sex Offender status
  • Court and Arrest Records
  • Marriage/Divorce Status
  • Social and
  • Dating Profiles
  • Census Data
  • Address History
  • Relatives
  • Contact information

Our service also includes an extended data search which accesses additional data sources and may reveal details like weapon permits, foreclosures, business associates, asset information, additional contact information and more. Keep in mind, a subscription with PrivateReports allows you to obtain unlimited comprehensive reports during your subscription period.

View Sample Report

+ + +
+
+
+
+ +
+
+ Please enter the characters exactly as they appear above. +
+
+ + +
+
+
+
+
\ No newline at end of file diff --git a/injected/integration-test/test-pages/broker-protection/pages/captcha.html b/injected/integration-test/test-pages/broker-protection/pages/re-captcha.html similarity index 100% rename from injected/integration-test/test-pages/broker-protection/pages/captcha.html rename to injected/integration-test/test-pages/broker-protection/pages/re-captcha.html diff --git a/injected/integration-test/test-pages/duck-ai-data-clearing/config/enabled.json b/injected/integration-test/test-pages/duck-ai-data-clearing/config/enabled.json new file mode 100644 index 0000000000..65106b8c4a --- /dev/null +++ b/injected/integration-test/test-pages/duck-ai-data-clearing/config/enabled.json @@ -0,0 +1,15 @@ +{ + "readme": "This config is used to test enabling the Duck.ai data clearing feature.", + "version": 1, + "features": { + "duckAiDataClearing": { + "state": "enabled", + "settings": { + "chatsLocalStorageKeys": ["savedAIChats"], + "chatImagesIndexDbNameObjectStoreNamePairs": [["savedAIChatData", "chat-images"]] + }, + "exceptions": [] + } + }, + "unprotectedTemporary": [] +} \ No newline at end of file diff --git a/injected/integration-test/test-pages/duck-ai-data-clearing/index.html b/injected/integration-test/test-pages/duck-ai-data-clearing/index.html new file mode 100644 index 0000000000..9e8bf60d0f --- /dev/null +++ b/injected/integration-test/test-pages/duck-ai-data-clearing/index.html @@ -0,0 +1,127 @@ + + + + + + Duck AI Data Clearing Test + + + +
+

Duck AI Data Clearing Test

+

This page tests the Duck AI data clearing functionality.

+
+ +
+
+

Setup Data

+ +
+
+ +
+

Clear Data

+ +
+
+ +
+

Verify Data Cleared

+ +
+
+
+ + + + + diff --git a/injected/integration-test/test-pages/duckplayer-native/config/native.json b/injected/integration-test/test-pages/duckplayer-native/config/native.json new file mode 100644 index 0000000000..a6e43fe326 --- /dev/null +++ b/injected/integration-test/test-pages/duckplayer-native/config/native.json @@ -0,0 +1,22 @@ +{ + "readme": "This config is used to test the duckplayer native feature.", + "version": 1, + "unprotectedTemporary": [], + "features": { + "duckPlayerNative": { + "state": "enabled", + "exceptions": [], + "settings": { + "selectors": { + "errorContainer": "body", + "signInRequiredError": "[href*=\"//support.google.com/youtube/answer/3037019\"]", + "videoElement": "#player video, video", + "videoElementContainer": "#player .html5-video-player", + "youtubeError": ".ytp-error", + "adShowing": ".html5-video-player.ad-showing" + }, + "domains": [] + } + } + } +} diff --git a/injected/integration-test/test-pages/duckplayer-native/pages/player.html b/injected/integration-test/test-pages/duckplayer-native/pages/player.html new file mode 100644 index 0000000000..dc153f6e57 --- /dev/null +++ b/injected/integration-test/test-pages/duckplayer-native/pages/player.html @@ -0,0 +1,446 @@ + + + + + + Duck Player Native - Player Overlay + + + + +

[Duck Player]

+ +
+
+ + +
+ +
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + diff --git a/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-dark.jpg b/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-dark.jpg new file mode 100644 index 0000000000..38797b8e29 Binary files /dev/null and b/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-dark.jpg differ diff --git a/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-light.jpg b/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-light.jpg new file mode 100644 index 0000000000..f2310b4b4e Binary files /dev/null and b/injected/integration-test/test-pages/duckplayer-native/pages/thumbnail-light.jpg differ diff --git a/injected/integration-test/test-pages/duckplayer/config/overlays-drawer.json b/injected/integration-test/test-pages/duckplayer/config/overlays-drawer.json new file mode 100644 index 0000000000..56d234a446 --- /dev/null +++ b/injected/integration-test/test-pages/duckplayer/config/overlays-drawer.json @@ -0,0 +1,62 @@ +{ + "unprotectedTemporary": [], + "features": { + "duckPlayer": { + "state": "enabled", + "exceptions": [], + "settings": { + "overlays": { + "youtube": { + "state": "disabled", + "selectors": { + "thumbLink": "a[href^='/watch']", + "excludedRegions": [ + "#playlist", + "ytd-movie-renderer", + "ytd-grid-movie-renderer" + ], + "videoElement": "#player video", + "videoElementContainer": "#player .html5-video-player", + "hoverExcluded": [".an-overlay-causing-breakage"], + "clickExcluded": [".an-overlay-causing-breakage"], + "allowedEventTargets": [], + "drawerContainer": "body" + }, + "thumbnailOverlays": { + "state": "enabled" + }, + "clickInterception": { + "state": "enabled" + }, + "videoOverlays": { + "state": "enabled" + }, + "videoDrawer": { + "state": "enabled" + } + }, + "serpProxy": { + "state": "disabled" + } + }, + "domains": [ + { + "domain": "localhost", + "patchSettings": [ + { + "op": "replace", + "path": "/overlays/youtube/state", + "value": "enabled" + }, + { + "op": "replace", + "path": "/overlays/serpProxy/state", + "value": "enabled" + } + ] + } + ] + } + } + } +} diff --git a/injected/integration-test/test-pages/duckplayer/pages/player.html b/injected/integration-test/test-pages/duckplayer/pages/player.html index 2dfa7c3f58..8788e0762f 100644 --- a/injected/integration-test/test-pages/duckplayer/pages/player.html +++ b/injected/integration-test/test-pages/duckplayer/pages/player.html @@ -67,7 +67,6 @@ -

[Duck Player]

@@ -154,13 +153,23 @@ + + \ No newline at end of file diff --git a/injected/integration-test/test-pages/favicon/new_favicon.png b/injected/integration-test/test-pages/favicon/new_favicon.png new file mode 100644 index 0000000000..c674557c9d Binary files /dev/null and b/injected/integration-test/test-pages/favicon/new_favicon.png differ diff --git a/injected/integration-test/test-pages/harmful-apis/config/apis.json b/injected/integration-test/test-pages/harmful-apis/config/apis.json index 58626aa309..d6ce7ba578 100644 --- a/injected/integration-test/test-pages/harmful-apis/config/apis.json +++ b/injected/integration-test/test-pages/harmful-apis/config/apis.json @@ -1,8 +1,11 @@ { + "readme": "This config is used to test the harmful APIs feature.", + "version": 1, "unprotectedTemporary": [], "features": { "windowsPermissionUsage": { - "state": "disabled" + "state": "disabled", + "exceptions": [] }, "harmfulApis": { "state": "enabled", diff --git a/injected/integration-test/test-pages/index.html b/injected/integration-test/test-pages/index.html index d727620122..d82db682e0 100644 --- a/injected/integration-test/test-pages/index.html +++ b/injected/integration-test/test-pages/index.html @@ -1,7 +1,7 @@ - -

Integration page

-

This loads the injection file as if it were loaded through the content script.

- - + +

Integration page

+

This loads the injection file as if it were loaded through the content script.

+ + diff --git a/injected/integration-test/test-pages/infra/config/conditional-matching-experiments.json b/injected/integration-test/test-pages/infra/config/conditional-matching-experiments.json new file mode 100644 index 0000000000..5bd4fc3a6f --- /dev/null +++ b/injected/integration-test/test-pages/infra/config/conditional-matching-experiments.json @@ -0,0 +1,100 @@ +{ + "version": 1, + "readme": "This config is used to test the conditional matching of experiments using the API manipulation feature.", + "features": { + "contentScopeExperiments": { + "exceptions": [], + "state": "enabled", + "features": { + "bloops": { + "state": "enabled", + "rollout": { + "steps": [ + { + "percent": 100 + } + ] + }, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "treatment", + "weight": 1 + } + ] + }, + "test": { + "state": "enabled", + "rollout": { + "steps": [ + { + "percent": 100 + } + ] + }, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "treatment", + "weight": 1 + } + ] + } + } + }, + "apiManipulation": { + "state": "enabled", + "settings": { + "apiChanges": { + "Navigator.prototype.hardwareConcurrency": { + "type": "descriptor", + "getterValue": { + "type": "number", + "value": 100 + } + } + }, + "conditionalChanges": [ + { + "condition": { + "experiment": { + "experimentName": "bloops", + "cohort": "treatment" + } + }, + "patchSettings": [ + { + "op": "replace", + "path": "/apiChanges/Navigator.prototype.hardwareConcurrency/getterValue/value", + "value": 200 + } + ] + }, + { + "condition": { + "experiment": { + "experimentName": "bloops", + "cohort": "control" + } + }, + "patchSettings": [ + { + "op": "replace", + "path": "/apiChanges/Navigator.prototype.hardwareConcurrency/getterValue/value", + "value": 300 + } + ] + } + ] + }, + "exceptions": [] + } + }, + "unprotectedTemporary": [] +} \ No newline at end of file diff --git a/injected/integration-test/test-pages/infra/config/conditional-matching.json b/injected/integration-test/test-pages/infra/config/conditional-matching.json new file mode 100644 index 0000000000..8827b2b573 --- /dev/null +++ b/injected/integration-test/test-pages/infra/config/conditional-matching.json @@ -0,0 +1,81 @@ +{ + "readme": "This config is used to test the conditional matching of experiments using the API manipulation feature.", + "version": 1, + "features": { + "apiManipulation": { + "state": "enabled", + "exceptions": [], + "settings": { + "apiChanges": { + "Navigator.prototype.hardwareConcurrency": { + "type": "descriptor", + "getterValue": { + "type": "number", + "value": 222 + } + }, + "testApi1": { + "type": "descriptor", + "getterValue": { + "type": "number", + "value": 100 + }, + "define": true + }, + "testApi2": { + "type": "descriptor", + "getterValue": { + "type": "number", + "value": 200 + }, + "define": true + } + }, + "conditionalChanges": [ + { + "condition": { + "urlPattern": "/test/*" + }, + "patchSettings": [ + { + "op": "replace", + "path": "/apiChanges/Navigator.prototype.hardwareConcurrency/getterValue/value", + "value": 333 + } + ] + }, + { + "condition": { + "context": { + "top": true + } + }, + "patchSettings": [ + { + "op": "replace", + "path": "/apiChanges/testApi1/getterValue/value", + "value": 43339 + } + ] + }, + { + "condition": { + "context": { + "frame": true + } + }, + "patchSettings": [ + { + "op": "replace", + "path": "/apiChanges/testApi1/getterValue/value", + "value": 43338 + } + ] + } + ] + } + } + }, + "unprotectedTemporary": [] +} + \ No newline at end of file diff --git a/injected/integration-test/test-pages/infra/config/max-supported-version-int.json b/injected/integration-test/test-pages/infra/config/max-supported-version-int.json new file mode 100644 index 0000000000..96314b433b --- /dev/null +++ b/injected/integration-test/test-pages/infra/config/max-supported-version-int.json @@ -0,0 +1,74 @@ +{ + "readme": "Test maxSupportedVersion (int) with apiManipulation and conditionalPatching.", + "version": 1, + "features": { + "apiManipulation": { + "state": "enabled", + "exceptions": [], + "settings": { + "apiChanges": { + }, + "conditionalChanges": [ + { + "condition": { + "maxSupportedVersion": 100 + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionIntTestAbove", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + }, + { + "condition": { + "maxSupportedVersion": 99 + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionIntTestSame", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + }, + { + "condition": { + "maxSupportedVersion": 98 + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionIntTestBelow", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + } + ] + } + } + }, + "unprotectedTemporary": [] +} diff --git a/injected/integration-test/test-pages/infra/config/max-supported-version-string.json b/injected/integration-test/test-pages/infra/config/max-supported-version-string.json new file mode 100644 index 0000000000..1c0c442668 --- /dev/null +++ b/injected/integration-test/test-pages/infra/config/max-supported-version-string.json @@ -0,0 +1,73 @@ +{ + "readme": "Test maxSupportedVersion (string) with apiManipulation and conditionalPatching.", + "version": 1, + "features": { + "apiManipulation": { + "state": "enabled", + "exceptions": [], + "settings": { + "apiChanges": {}, + "conditionalChanges": [ + { + "condition": { + "maxSupportedVersion": "2.0.0" + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionStringTestAbove", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + }, + { + "condition": { + "maxSupportedVersion": "1.5.0" + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionStringTestSame", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + }, + { + "condition": { + "maxSupportedVersion": "1.0.0" + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionStringTestBelow", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + } + ] + } + } + }, + "unprotectedTemporary": [] +} diff --git a/injected/integration-test/test-pages/infra/config/min-supported-version-int.json b/injected/integration-test/test-pages/infra/config/min-supported-version-int.json new file mode 100644 index 0000000000..300e3b07ed --- /dev/null +++ b/injected/integration-test/test-pages/infra/config/min-supported-version-int.json @@ -0,0 +1,74 @@ +{ + "readme": "Test minSupportedVersion (int) with apiManipulation and conditionalPatching.", + "version": 1, + "features": { + "apiManipulation": { + "state": "enabled", + "exceptions": [], + "settings": { + "apiChanges": { + }, + "conditionalChanges": [ + { + "condition": { + "minSupportedVersion": 98 + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionIntTestBelow", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + }, + { + "condition": { + "minSupportedVersion": 99 + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionIntTestSame", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + }, + { + "condition": { + "minSupportedVersion": 100 + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionIntTestAbove", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + } + ] + } + } + }, + "unprotectedTemporary": [] +} \ No newline at end of file diff --git a/injected/integration-test/test-pages/infra/config/min-supported-version-string.json b/injected/integration-test/test-pages/infra/config/min-supported-version-string.json new file mode 100644 index 0000000000..e63e04e732 --- /dev/null +++ b/injected/integration-test/test-pages/infra/config/min-supported-version-string.json @@ -0,0 +1,73 @@ +{ + "readme": "Test minSupportedVersion (string) with apiManipulation and conditionalPatching.", + "version": 1, + "features": { + "apiManipulation": { + "state": "enabled", + "exceptions": [], + "settings": { + "apiChanges": {}, + "conditionalChanges": [ + { + "condition": { + "minSupportedVersion": "1.0.0" + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionStringTestBelow", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + }, + { + "condition": { + "minSupportedVersion": "1.5.0" + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionStringTestSame", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + }, + { + "condition": { + "minSupportedVersion": "2.0.0" + }, + "patchSettings": [ + { + "op": "add", + "path": "/apiChanges/versionStringTestAbove", + "value": { + "type": "descriptor", + "getterValue": { + "type": "boolean", + "value": true + }, + "define": true + } + } + ] + } + ] + } + } + }, + "unprotectedTemporary": [] +} \ No newline at end of file diff --git a/injected/integration-test/test-pages/infra/index.html b/injected/integration-test/test-pages/infra/index.html new file mode 100644 index 0000000000..dc925ea943 --- /dev/null +++ b/injected/integration-test/test-pages/infra/index.html @@ -0,0 +1,16 @@ + + + + + + API Interventions + + + +

[Home]

+ + + diff --git a/injected/integration-test/test-pages/infra/pages/conditional-matching-experiments.html b/injected/integration-test/test-pages/infra/pages/conditional-matching-experiments.html new file mode 100644 index 0000000000..f9603aac00 --- /dev/null +++ b/injected/integration-test/test-pages/infra/pages/conditional-matching-experiments.html @@ -0,0 +1,40 @@ + + + + + + Conditional Matching experiments + + + + +

[Infra]

+ +

+ This page verifies that APIs get modified when in an experiment. Ensure you're sending the following cohorts: + + currentCohorts: [ { "feature": "contentScopeExperiments", "subfeature": "bloops", "cohort": "control", }, { "feature": + "contentScopeExperiments", "subfeature": "test", "cohort": "treatment", }, ], + +

+ + + + diff --git a/injected/integration-test/test-pages/infra/pages/conditional-matching.html b/injected/integration-test/test-pages/infra/pages/conditional-matching.html new file mode 100644 index 0000000000..b4b6182942 --- /dev/null +++ b/injected/integration-test/test-pages/infra/pages/conditional-matching.html @@ -0,0 +1,204 @@ + + + + + + Conditional Matching + + + + +

[Infra]

+ +

This page verifies that APIs get modified

+ + + + diff --git a/injected/integration-test/test-pages/infra/pages/max-supported-version-int.html b/injected/integration-test/test-pages/infra/pages/max-supported-version-int.html new file mode 100644 index 0000000000..13ec575e86 --- /dev/null +++ b/injected/integration-test/test-pages/infra/pages/max-supported-version-int.html @@ -0,0 +1,35 @@ + + + + + Max Supported Version (int) + + + + +

[Infra]

+

This page verifies maxSupportedVersion (int) conditional patching. Load with 99 as the version number.

+ + + diff --git a/injected/integration-test/test-pages/infra/pages/max-supported-version-string.html b/injected/integration-test/test-pages/infra/pages/max-supported-version-string.html new file mode 100644 index 0000000000..3cab7e5428 --- /dev/null +++ b/injected/integration-test/test-pages/infra/pages/max-supported-version-string.html @@ -0,0 +1,35 @@ + + + + + Max Supported Version (string) + + + + +

[Infra]

+

This page verifies maxSupportedVersion (string) conditional patching. Load with 1.5.0 as the version number.

+ + + diff --git a/injected/integration-test/test-pages/infra/pages/min-supported-version-int.html b/injected/integration-test/test-pages/infra/pages/min-supported-version-int.html new file mode 100644 index 0000000000..0caec548b9 --- /dev/null +++ b/injected/integration-test/test-pages/infra/pages/min-supported-version-int.html @@ -0,0 +1,36 @@ + + + + + + Min Supported Version (Int) + + + + +

[Infra]

+

This page verifies minSupportedVersion (int) conditional patching. Load with 99 as the version number.

+ + + diff --git a/injected/integration-test/test-pages/infra/pages/min-supported-version-string.html b/injected/integration-test/test-pages/infra/pages/min-supported-version-string.html new file mode 100644 index 0000000000..cab573d059 --- /dev/null +++ b/injected/integration-test/test-pages/infra/pages/min-supported-version-string.html @@ -0,0 +1,36 @@ + + + + + + Min Supported Version (String) + + + + +

[Infra]

+

This page verifies minSupportedVersion (string) conditional patching. Load with 1.5.0 as the version number.

+ + + diff --git a/injected/integration-test/test-pages/message-bridge/config/message-bridge-disabled.json b/injected/integration-test/test-pages/message-bridge/config/message-bridge-disabled.json index d9383fbac5..49b628f4ba 100644 --- a/injected/integration-test/test-pages/message-bridge/config/message-bridge-disabled.json +++ b/injected/integration-test/test-pages/message-bridge/config/message-bridge-disabled.json @@ -1,6 +1,12 @@ { + "readme": "This config is used to test disabling the message bridge feature.", + "version": 1, "unprotectedTemporary": [], "features": { + "favicon": { + "state": "disabled", + "exceptions": [] + }, "navigatorInterface": { "state": "enabled", "exceptions": [] diff --git a/injected/integration-test/test-pages/message-bridge/config/message-bridge-enabled.json b/injected/integration-test/test-pages/message-bridge/config/message-bridge-enabled.json index 4fd9ae5d12..6eece723b7 100644 --- a/injected/integration-test/test-pages/message-bridge/config/message-bridge-enabled.json +++ b/injected/integration-test/test-pages/message-bridge/config/message-bridge-enabled.json @@ -1,10 +1,16 @@ { + "readme": "This config is used to test the message bridge feature.", + "version": 1, "unprotectedTemporary": [], "features": { "navigatorInterface": { "state": "enabled", "exceptions": [] }, + "favicon": { + "state": "disabled", + "exceptions": [] + }, "messageBridge": { "exceptions": [], "state": "enabled", diff --git a/injected/integration-test/test-pages/permissions/config/permissions.json b/injected/integration-test/test-pages/permissions/config/permissions.json index f5c51e51f1..0f8b940958 100644 --- a/injected/integration-test/test-pages/permissions/config/permissions.json +++ b/injected/integration-test/test-pages/permissions/config/permissions.json @@ -1,4 +1,6 @@ { + "readme": "This config is used to test the windows permissions feature.", + "version": 1, "unprotectedTemporary": [], "features": { "windowsPermissionUsage": { diff --git a/injected/integration-test/test-pages/shared/utils.js b/injected/integration-test/test-pages/shared/utils.js index bf4519e659..14f2002ced 100644 --- a/injected/integration-test/test-pages/shared/utils.js +++ b/injected/integration-test/test-pages/shared/utils.js @@ -4,7 +4,6 @@ * @property {any} result The result of the test. * @property {any} expected The expected result. */ - function buildTableCell(value, tagName = 'td') { const td = document.createElement(tagName); td.textContent = value; @@ -58,13 +57,36 @@ const isReadyPromise = new Promise((resolve) => { isReadyPromiseResolve = resolve; }); const url = new URL(window.location.href); +createResultsHeader(); if (url.searchParams.get('automation')) { + createRunButton(); isInAutomation = true; window.addEventListener('content-scope-init-complete', () => { isReadyPromiseResolve(); }); } +function createResultsHeader() { + const summary = document.createElement('summary'); + summary.textContent = 'Test suite status: '; + const output = document.createElement('output'); + output.id = 'test-status'; + output.textContent = 'pending'; + summary.appendChild(output); + document.body.appendChild(summary); +} + +function createRunButton() { + const button = document.createElement('button'); + button.textContent = 'Run Tests'; + button.id = 'run-tests'; + button.addEventListener('click', () => { + button.disabled = true; + window.dispatchEvent(new Event('content-scope-init-complete')); + }); + document.body.appendChild(button); +} + // @ts-expect-error - ongoingTests is not defined in the type definition window.ongoingTests = []; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -73,6 +95,15 @@ function test(name, test) { window.ongoingTests.push({ name, test }); } +function updateResultsHeader(results) { + const totalTests = Object.values(results).flat().length; + const passed = Object.values(results) + .flat() + .filter((result) => result.result === result.expected).length; + const output = document.getElementById('test-status'); + output.textContent = totalTests > 0 && passed === totalTests ? 'pass' : 'fail'; +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars async function renderResults() { const results = {}; @@ -84,6 +115,7 @@ async function renderResults() { const result = await test.test().catch((e) => console.error(`${test.name} threw`, e)); results[test.name] = result; } + updateResultsHeader(results); // @ts-expect-error - buildResultTable is not defined in the type definition document.body.appendChild(buildResultTable(results)); // @ts-expect-error - results is not defined in the type definition diff --git a/injected/integration-test/test-pages/webcompat/config/enumerate-devices-api.json b/injected/integration-test/test-pages/webcompat/config/enumerate-devices-api.json new file mode 100644 index 0000000000..948e2ebae2 --- /dev/null +++ b/injected/integration-test/test-pages/webcompat/config/enumerate-devices-api.json @@ -0,0 +1,14 @@ +{ + "readme": "This config is used to test the enumerateDevices API proxy functionality.", + "version": 1, + "unprotectedTemporary": [], + "features": { + "webCompat": { + "state": "enabled", + "exceptions": [], + "settings": { + "enumerateDevices": "enabled" + } + } + } +} \ No newline at end of file diff --git a/injected/integration-test/test-pages/webcompat/config/message-handlers.json b/injected/integration-test/test-pages/webcompat/config/message-handlers.json index 8cd54bbe46..c131fb3dd7 100644 --- a/injected/integration-test/test-pages/webcompat/config/message-handlers.json +++ b/injected/integration-test/test-pages/webcompat/config/message-handlers.json @@ -1,4 +1,6 @@ { + "readme": "This config is used to test the webcompat message handlers feature.", + "version": 1, "unprotectedTemporary": [], "features": { "webCompat": { diff --git a/injected/integration-test/test-pages/webcompat/config/modify-cookies.json b/injected/integration-test/test-pages/webcompat/config/modify-cookies.json index e98a18ecce..30d83e657e 100644 --- a/injected/integration-test/test-pages/webcompat/config/modify-cookies.json +++ b/injected/integration-test/test-pages/webcompat/config/modify-cookies.json @@ -1,4 +1,6 @@ { + "readme": "This config is used to test the modify cookies feature.", + "version": 1, "unprotectedTemporary": [], "features": { "webCompat": { diff --git a/injected/integration-test/test-pages/webcompat/config/modify-localstorage.json b/injected/integration-test/test-pages/webcompat/config/modify-localstorage.json index 83ef58de34..201caba830 100644 --- a/injected/integration-test/test-pages/webcompat/config/modify-localstorage.json +++ b/injected/integration-test/test-pages/webcompat/config/modify-localstorage.json @@ -1,4 +1,6 @@ { + "readme": "This config is used to test the modify local storage feature.", + "version": 1, "unprotectedTemporary": [], "features": { "webCompat": { diff --git a/injected/integration-test/test-pages/webcompat/config/shims.json b/injected/integration-test/test-pages/webcompat/config/shims.json index 5574228b0b..41dc462390 100644 --- a/injected/integration-test/test-pages/webcompat/config/shims.json +++ b/injected/integration-test/test-pages/webcompat/config/shims.json @@ -1,7 +1,11 @@ { + "readme": "This config is used to test the webcompat shims.", + "version": 1, + "unprotectedTemporary": [], "features": { "webCompat": { "state": "enabled", + "exceptions": [], "settings": { "mediaSession": "enabled", "presentation": "enabled" diff --git a/injected/integration-test/test-pages/webcompat/index.html b/injected/integration-test/test-pages/webcompat/index.html index c7210bcb7e..4bea178bd5 100644 --- a/injected/integration-test/test-pages/webcompat/index.html +++ b/injected/integration-test/test-pages/webcompat/index.html @@ -12,6 +12,8 @@
  • Message Handlers - Config
  • Shims - Config
  • Modify localStorage - Config
  • +
  • Device Enumeration
  • +
  • Enumerate Devices API Test - Config
  • diff --git a/injected/integration-test/test-pages/webcompat/pages/device-enumeration.html b/injected/integration-test/test-pages/webcompat/pages/device-enumeration.html new file mode 100644 index 0000000000..5d1f93d595 --- /dev/null +++ b/injected/integration-test/test-pages/webcompat/pages/device-enumeration.html @@ -0,0 +1,81 @@ + + + + + + Device Enumeration Test + + + + +

    [Webcompat shims]

    + +

    This page tests the device enumeration feature

    + + + + \ No newline at end of file diff --git a/injected/integration-test/test-pages/webcompat/pages/enumerate-devices-api-test.html b/injected/integration-test/test-pages/webcompat/pages/enumerate-devices-api-test.html new file mode 100644 index 0000000000..05234a668a --- /dev/null +++ b/injected/integration-test/test-pages/webcompat/pages/enumerate-devices-api-test.html @@ -0,0 +1,236 @@ + + + + + + enumerateDevices API Test + + + + +

    [Webcompat API Tests]

    + +

    This page tests the enumerateDevices API proxy functionality

    + + + + \ No newline at end of file diff --git a/injected/integration-test/type-helpers.mjs b/injected/integration-test/type-helpers.mjs index 513a4bc158..b07842141d 100644 --- a/injected/integration-test/type-helpers.mjs +++ b/injected/integration-test/type-helpers.mjs @@ -59,9 +59,10 @@ export class Build { const path = this.switch({ windows: () => '../build/windows/contentScope.js', android: () => '../build/android/contentScope.js', - apple: () => '../Sources/ContentScopeScripts/dist/contentScope.js', - 'apple-isolated': () => '../Sources/ContentScopeScripts/dist/contentScopeIsolated.js', - 'android-autofill-password-import': () => '../build/android/autofillPasswordImport.js', + apple: () => '../build/apple/contentScope.js', + 'apple-isolated': () => '../build/apple/contentScopeIsolated.js', + 'android-autofill-import': () => '../build/android/autofillImport.js', + 'android-broker-protection': () => '../build/android/brokerProtection.js', }); return readFileSync(path, 'utf8'); } @@ -72,17 +73,7 @@ export class Build { */ static supported(name) { /** @type {ImportMeta['injectName'][]} */ - const items = [ - 'apple', - 'apple-isolated', - 'windows', - 'integration', - 'android', - 'android-autofill-password-import', - 'chrome-mv3', - 'chrome', - 'firefox', - ]; + const items = ['apple', 'apple-isolated', 'windows', 'integration', 'android', 'android-autofill-import', 'chrome-mv3', 'firefox']; if (items.includes(name)) { return name; } diff --git a/injected/integration-test/utils.spec.js b/injected/integration-test/utils.spec.js index e04cdbafea..6588f8cf66 100644 --- a/injected/integration-test/utils.spec.js +++ b/injected/integration-test/utils.spec.js @@ -8,7 +8,7 @@ const test = testContextForExtension(base); test.describe('Ensure utils behave as expected', () => { test('should toString DDGProxy correctly', async ({ page }) => { - await gotoAndWait(page, '/blank.html', { platform: { name: 'extension' } }); + await gotoAndWait(page, '/blank.html'); const toStringResult = await page.evaluate('HTMLCanvasElement.prototype.getContext.toString()'); expect(toStringResult).toEqual('function getContext() { [native code] }'); diff --git a/injected/integration-test/web-compat-android.spec.js b/injected/integration-test/web-compat-android.spec.js index 5ce753a827..48fa955aa2 100644 --- a/injected/integration-test/web-compat-android.spec.js +++ b/injected/integration-test/web-compat-android.spec.js @@ -76,14 +76,34 @@ test.describe('Web Share API', () => { }); test.describe('navigator.canShare()', () => { - test('should not let you share files', async ({ page }) => { + test('should allow empty files arrays', async ({ page }) => { await navigate(page); - const refuseFileShare = await page.evaluate(() => { + const allowEmptyFiles = await page.evaluate(() => { return navigator.canShare({ text: 'xxx', files: [] }); }); + expect(allowEmptyFiles).toEqual(true); + }); + + test('should not let you share non-empty files arrays', async ({ page }) => { + await navigate(page); + const refuseFileShare = await page.evaluate(() => { + // Create a mock File object + const mockFile = new File([''], 'test.txt', { type: 'text/plain' }); + return navigator.canShare({ text: 'xxx', files: [mockFile] }); + }); expect(refuseFileShare).toEqual(false); }); + test('should reject non-array files values', async ({ page }) => { + await navigate(page); + const rejectNonArrayFiles = await page.evaluate(() => { + // eslint-disable-next-line + // @ts-ignore intentionally testing invalid files type + return navigator.canShare({ text: 'xxx', files: 'not-an-array' }); + }); + expect(rejectNonArrayFiles).toEqual(false); + }); + test('should not let you share non-http urls', async ({ page }) => { await navigate(page); const refuseShare = await page.evaluate(() => { @@ -155,7 +175,6 @@ test.describe('Web Share API', () => { const result = await page.evaluate(payload).catch((e) => { return { threw: e }; }); - console.log('check share', result); const message = await page.evaluate(() => { console.log('did read?'); return globalThis.shareReq; @@ -219,10 +238,21 @@ test.describe('Web Share API', () => { expect(result).toBeUndefined(); }); - test('should throw when sharing files', async ({ page }) => { + test('should allow sharing with empty files array', async ({ page }) => { await navigate(page); await beforeEach(page); const { result, message } = await checkShare(page, { title: 'title', files: [] }); + expect(message).toMatchObject({ featureName: 'webCompat', method: 'webShare', params: { title: 'title', text: '' } }); + expect(result).toBeUndefined(); + }); + + test('should throw when sharing non-empty files arrays', async ({ page }) => { + await navigate(page); + await beforeEach(page); + const { result, message } = await checkShare(page, { + title: 'title', + files: [new File([''], 'test.txt', { type: 'text/plain' })], + }); expect(message).toBeNull(); expect(result.threw.message).toContain('TypeError: Invalid share data'); }); diff --git a/injected/integration-test/web-compat.spec.js b/injected/integration-test/web-compat.spec.js index aabfc247f8..9c3bf17e90 100644 --- a/injected/integration-test/web-compat.spec.js +++ b/injected/integration-test/web-compat.spec.js @@ -130,6 +130,26 @@ test.describe('Ensure Notification interface is injected', () => { return window.Notification.maxActions; }); expect(maxActionsPropDenied).toEqual(2); + + const notificationToString = await page.evaluate(() => { + return window.Notification.toString(); + }); + expect(notificationToString).toEqual('function Notification() { [native code] }'); + + const requestPermissionToString = await page.evaluate(() => { + return window.Notification.requestPermission.toString(); + }); + expect(requestPermissionToString).toEqual('function requestPermission() { [native code] }'); + + const notificationToStringToString = await page.evaluate(() => { + return window.Notification.toString.toString(); + }); + expect(notificationToStringToString).toEqual('function toString() { [native code] }'); + + const requestPermissionToStringToString = await page.evaluate(() => { + return window.Notification.requestPermission.toString.toString(); + }); + expect(requestPermissionToStringToString).toEqual('function toString() { [native code] }'); }); }); @@ -574,6 +594,25 @@ test.describe('Viewport fixes', () => { ); }); + test('should override minimum-scale, if it is set', async ({ page }) => { + await gotoAndWait( + page, + '/blank.html', + { + site: { enabledFeatures: ['webCompat'] }, + featureSettings: { webCompat: { viewportWidth: 'enabled' } }, + desktopModeEnabled: true, + }, + 'document.head.innerHTML += \'\'', + ); + const width = await page.evaluate('screen.width'); + const expectedWidth = width < 1280 ? 980 : 1280; + const viewportValue = await page.evaluate(getViewportValue); + expect(viewportValue).toEqual( + `width=${expectedWidth}, initial-scale=${(width / expectedWidth).toFixed(3)}, user-scalable=yes, minimum-scale=0, something-something`, + ); + }); + test('should force wide viewport, ignoring the viewport tag 2', async ({ page }) => { await gotoAndWait( page, diff --git a/injected/package.json b/injected/package.json index b28849efcc..4b42380ad6 100644 --- a/injected/package.json +++ b/injected/package.json @@ -3,54 +3,47 @@ "scripts": { "postinstall": "npm run copy-sjcl", "copy-sjcl": "node scripts/generateSJCL.js", + "build": "npm run build-types && npm run build-locales && npm run bundle-trackers && npm run bundle-entry-points", "bundle-config": "node scripts/bundleConfig.mjs", - "build": "npm run build-types && npm run build-locales && npm run bundle-trackers && npm run build-firefox && npm run build-chrome && npm run build-apple && npm run build-android && npm run build-windows && npm run build-integration && npm run build-chrome-mv3", + "bundle-entry-points": "node scripts/entry-points.js", + "build-chrome-mv3": "node scripts/entry-points.js", + "build-firefox": "node scripts/entry-points.js", "build-locales": "node scripts/buildLocales.js", - "build-firefox": "node scripts/entry-points.js --platform firefox", - "build-chrome": "node scripts/entry-points.js --platform chrome", - "build-chrome-mv3": "node scripts/entry-points.js --platform chrome-mv3", - "build-apple": "node scripts/entry-points.js --platform apple && node scripts/entry-points.js --platform apple-isolated", - "build-android": "node scripts/entry-points.js --platform android && node scripts/entry-points.js --platform android-autofill-password-import", - "build-windows": "node scripts/entry-points.js --platform windows", - "build-integration": "node scripts/entry-points.js --platform integration", "build-types": "node scripts/types.mjs", "bundle-trackers": "node scripts/bundleTrackers.mjs --output ../build/tracker-lookup.json", "test-unit": "jasmine --config=unit-test/config.json", - "test-int": "npm run build-integration && npm run playwright", + "test-int": "playwright test --grep-invert '@screenshots'", "test-int-x": "xvfb-run --server-args='-screen 0 1024x768x24' npm run test-int", - "test": "npm run lint && npm run test-unit && npm run test-int && npm run playwright", + "test-int-snapshots": "playwright test --grep '@screenshots'", + "test-int-snapshots-update": "playwright test --grep '@screenshots' --update-snapshots --last-failed --pass-with-no-tests", + "test": "npm run test-unit && npm run test-int && npm run playwright", "serve": "http-server -c-1 --port 3220 integration-test/test-pages", "playwright": "playwright test --grep-invert '@screenshots'", "playwright-screenshots": "playwright test --grep '@screenshots'", - "playwright-headed": "playwright test --headed", - "preplaywright": "npm run build-windows && npm run build-apple && npm run build-android", - "preplaywright-headed": "npm run build-windows && npm run build-apple && npm run build-android", "playwright-e2e": "playwright test -c playwright-e2e.config.js --project duckplayer-e2e", "playwright-e2e-headed": "npm run playwright-e2e -- --headed", - "preplaywright-e2e": "npm run build-windows && npm run build-apple" + "fake-extension": "node ./scripts/run-fake-extension.js" }, "type": "module", "dependencies": { + "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#1752154773643", + "esbuild": "^0.25.12", + "minimist": "^1.2.8", "parse-address": "^1.1.2", "seedrandom": "^3.0.5", - "sjcl": "^1.0.8" + "sjcl": "^1.0.8", + "urlpattern-polyfill": "^10.1.0" }, "devDependencies": { "@canvas/image-data": "^1.0.0", - "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#main", - "@fingerprintjs/fingerprintjs": "^4.5.1", - "@rollup/plugin-commonjs": "^28.0.2", - "@rollup/plugin-node-resolve": "^16.0.0", - "@rollup/plugin-replace": "^6.0.2", - "@types/chrome": "^0.0.289", - "@types/jasmine": "^5.1.5", - "@types/node": "^22.10.5", - "@typescript-eslint/eslint-plugin": "^8.19.0", - "fast-check": "^3.23.2", - "jasmine": "^5.5.0", - "minimist": "^1.2.8", - "rollup": "^4.30.1", - "rollup-plugin-import-css": "^3.5.8", - "rollup-plugin-svg-import": "^3.0.0" + "@fingerprintjs/fingerprintjs": "^5.0.1", + "@types/chrome": "^0.1.1", + "@types/jasmine": "^5.1.9", + "@types/node": "^24.1.0", + "@typescript-eslint/eslint-plugin": "^8.46.0", + "fast-check": "^4.2.0", + "jasmine": "^5.12.0", + "jsdom": "^27.1.0", + "web-ext": "^9.0.0" } } diff --git a/injected/playwright.config.js b/injected/playwright.config.js index 5155cc8cd7..5b059b7576 100644 --- a/injected/playwright.config.js +++ b/injected/playwright.config.js @@ -10,8 +10,9 @@ export default defineConfig({ 'integration-test/duckplayer-remote-config.spec.js', 'integration-test/harmful-apis.spec.js', 'integration-test/windows-permissions.spec.js', - 'integration-test/broker-protection.spec.js', + 'integration-test/broker-protection-tests/**/*.spec.js', 'integration-test/breakage-reporting.spec.js', + 'integration-test/duck-ai-data-clearing.spec.js', ], use: { injectName: 'windows', platform: 'windows' }, }, @@ -20,7 +21,8 @@ export default defineConfig({ testMatch: [ 'integration-test/duckplayer.spec.js', 'integration-test/duckplayer-remote-config.spec.js', - 'integration-test/broker-protection.spec.js', + 'integration-test/broker-protection-tests/**/*.spec.js', + 'integration-test/favicon.spec.js', ], use: { injectName: 'apple-isolated', platform: 'macos' }, }, @@ -36,21 +38,30 @@ export default defineConfig({ }, { name: 'ios', - testMatch: ['integration-test/duckplayer-mobile.spec.js'], + testMatch: [ + 'integration-test/duckplayer-mobile.spec.js', + 'integration-test/duckplayer-mobile-drawer.spec.js', + 'integration-test/duckplayer-native.spec.js', + ], use: { injectName: 'apple-isolated', platform: 'ios', ...devices['iPhone 13'] }, }, { name: 'android', - testMatch: ['integration-test/duckplayer-mobile.spec.js', 'integration-test/web-compat-android.spec.js'], + testMatch: [ + 'integration-test/duckplayer-mobile.spec.js', + 'integration-test/duckplayer-mobile-drawer.spec.js', + 'integration-test/web-compat-android.spec.js', + 'integration-test/message-bridge-android.spec.js', + ], use: { injectName: 'android', platform: 'android', ...devices['Galaxy S5'] }, }, { - name: 'android-autofill-password-import', - testMatch: ['integration-test/autofill-password-import.spec.js'], - use: { injectName: 'android-autofill-password-import', platform: 'android', ...devices['Galaxy S5'] }, + name: 'android-autofill-import', + testMatch: ['integration-test/autofill-import.spec.js'], + use: { injectName: 'android-autofill-import', platform: 'android', ...devices['Galaxy S5'] }, }, { - name: 'chrome', + name: 'chrome-mv3', testMatch: [ 'integration-test/remote-pages.spec.js', 'integration-test/cookie.spec.js', @@ -60,7 +71,7 @@ export default defineConfig({ 'integration-test/utils.spec.js', 'integration-test/web-compat.spec.js', ], - use: { injectName: 'chrome', platform: 'extension', ...devices['Desktop Chrome'] }, + use: { injectName: 'chrome-mv3', platform: 'extension', ...devices['Desktop Chrome'] }, }, { name: 'firefox', @@ -83,13 +94,13 @@ export default defineConfig({ /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - reporter: process.env.CI ? 'github' : [['html', { open: 'never' }]], + workers: process.env.CI ? 2 : undefined, + reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ webServer: { reuseExistingServer: true, ignoreHTTPSErrors: true, - command: 'npm run serve', + command: 'npm run bundle-entry-points && npm run serve', port: 3220, }, use: { @@ -99,5 +110,6 @@ export default defineConfig({ baseURL: 'http://localhost:3220/', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + video: { mode: 'on-first-retry' }, }, }); diff --git a/injected/scripts/entry-points.js b/injected/scripts/entry-points.js index 522edacfc4..fa851ed401 100644 --- a/injected/scripts/entry-points.js +++ b/injected/scripts/entry-points.js @@ -1,15 +1,10 @@ -import { postProcess, rollupScript } from './utils/build.js'; +import { bundle } from './utils/build.js'; import { parseArgs, write } from '../../scripts/script-utils.js'; -import { camelcase } from '../src/utils.js'; - -const contentScopePath = 'src/content-scope-features.js'; -const contentScopeName = 'contentScopeFeatures'; /** * @typedef Build * @property {string} input * @property {string[]} output - * @property {boolean} [postProcess] - optional value to post-process an output file * * @typedef {Record, Build>} BuildManifest */ @@ -22,20 +17,27 @@ const builds = { }, apple: { input: 'entry-points/apple.js', - postProcess: true, - output: ['../Sources/ContentScopeScripts/dist/contentScope.js'], + output: ['../build/apple/contentScope.js'], }, 'apple-isolated': { input: 'entry-points/apple.js', - output: ['../Sources/ContentScopeScripts/dist/contentScopeIsolated.js'], + output: ['../build/apple/contentScopeIsolated.js'], }, android: { input: 'entry-points/android.js', output: ['../build/android/contentScope.js'], }, - 'android-autofill-password-import': { - input: 'entry-points/android', - output: ['../build/android/autofillPasswordImport.js'], + 'android-broker-protection': { + input: 'entry-points/android.js', + output: ['../build/android/brokerProtection.js'], + }, + 'android-autofill-import': { + input: 'entry-points/android-adsjs.js', + output: ['../build/android/autofillImport.js'], + }, + 'android-adsjs': { + input: 'entry-points/android-adsjs.js', + output: ['../build/android/adsjsContentScope.js'], }, windows: { input: 'entry-points/windows.js', @@ -53,65 +55,33 @@ const builds = { input: 'entry-points/extension-mv3.js', output: ['../build/chrome-mv3/inject.js'], }, - chrome: { - input: 'entry-points/chrome.js', - output: ['../build/chrome/inject.js'], - }, }; -async function initOther(injectScriptPath, platformName) { - const identName = `inject${camelcase(platformName)}`; - const injectScript = await rollupScript({ - scriptPath: injectScriptPath, - name: identName, - platform: platformName, - }); - const outputScript = injectScript; - return outputScript; -} - -/** - * @param {string} entry - * @param {string} platformName - */ -async function initChrome(entry, platformName) { - const replaceString = '/* global contentScopeFeatures */'; - const injectScript = await rollupScript({ scriptPath: entry, platform: platformName }); - const contentScope = await rollupScript({ - scriptPath: contentScopePath, - name: contentScopeName, - platform: platformName, - }); - // Encode in URI format to prevent breakage (we could choose to just escape ` instead) - // NB: .replace(/\r\n/g, "\n") is needed because in Windows rollup generates CRLF line endings - const encodedString = encodeURI(contentScope.toString().replace(/\r\n/g, '\n')); - const outputScript = injectScript.toString().replace(replaceString, '${decodeURI("' + encodedString + '")}'); - return outputScript; -} - async function init() { // verify the input - const requiredFields = ['platform']; + const requiredFields = []; const args = parseArgs(process.argv.slice(2), requiredFields); - const build = builds[args.platform]; - - if (!build) { - throw new Error('unsupported platform: ' + args.platform); - } - let output; - if (args.platform === 'chrome') { - output = await initChrome(build.input, args.platform); - } else { - output = await initOther(build.input, args.platform); - if (build.postProcess) { - const processResult = await postProcess(output); - output = processResult.code; + // if a platform was given as an argument, just build that platform + if (args.platform) { + const build = builds[args.platform]; + if (!build) { + throw new Error('unsupported platform: ' + args.platform); } + const output = await bundle({ scriptPath: build.input, platform: args.platform }); + + // bundle and write the output + write([build.output], output); + + return; } - // bundle and write the output - write([build.output], output); + // otherwise, just build them all + for (const [injectName, build] of Object.entries(builds)) { + const output = await bundle({ scriptPath: build.input, platform: injectName }); + write(build.output, output); + console.log('✅', injectName, build.output[0]); + } } init(); diff --git a/injected/scripts/run-fake-extension.js b/injected/scripts/run-fake-extension.js new file mode 100644 index 0000000000..10375c383d --- /dev/null +++ b/injected/scripts/run-fake-extension.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import { spawn } from 'child_process'; +import waitOn from 'wait-on'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const fileName = fileURLToPath(import.meta.url); +const dirName = path.dirname(fileName); + +/** + * Run a command as a child process and return a Promise that resolves when it exits. + * @param {string} cmd + * @param {string[]} args + * @param {object} opts + * @returns {Promise} + */ +function run(cmd, args, opts = {}) { + return new Promise((resolve, reject) => { + const proc = spawn(cmd, args, { stdio: 'inherit', shell: true, ...opts }); + proc.on('close', (code) => { + if (code !== 0) reject(new Error(`${cmd} exited with code ${code}`)); + else resolve(); + }); + proc.on('error', reject); + }); +} + +async function main() { + // 1. Build + await run('npm', ['run', 'build']); + + // 2. Start server + const serveProc = spawn('npm', ['run', 'serve'], { + cwd: path.resolve(dirName, '..'), + stdio: 'ignore', + shell: true, + detached: true, + }); + + // Ensure server is killed on exit + const cleanup = () => { + if (serveProc.pid) { + try { + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(serveProc.pid), '/f', '/t']); + } else { + process.kill(-serveProc.pid, 'SIGTERM'); + } + } catch (e) {} + } + }; + process.on('exit', cleanup); + process.on('SIGINT', () => { + cleanup(); + process.exit(1); + }); + process.on('SIGTERM', () => { + cleanup(); + process.exit(1); + }); + + // 3. Wait for server + await waitOn({ resources: ['http://localhost:3220/index.html'] }); + + // 4. Run web-ext + try { + await run( + 'npx', + [ + 'web-ext', + 'run', + '--source-dir=integration-test/extension', + '--target=chromium', + '--start-url=http://localhost:3220/index.html', + '--start-url=https://privacy-test-pages.site', + ], + { cwd: path.resolve(dirName, '..') }, + ); + } finally { + cleanup(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/injected/scripts/utils/build.js b/injected/scripts/utils/build.js index bfca08680e..4b655aa1fb 100644 --- a/injected/scripts/utils/build.js +++ b/injected/scripts/utils/build.js @@ -1,32 +1,13 @@ -import * as rollup from 'rollup'; -import * as esbuild from 'esbuild'; -import commonjs from '@rollup/plugin-commonjs'; -import replace from '@rollup/plugin-replace'; -import resolve from '@rollup/plugin-node-resolve'; -import css from 'rollup-plugin-import-css'; -import svg from 'rollup-plugin-svg-import'; import { platformSupport } from '../../src/features.js'; import { readFileSync } from 'fs'; +import { cwd } from '../../../scripts/script-utils.js'; +import { join } from 'path'; +import * as esbuild from 'esbuild'; +import { commentPlugin } from './comment-plugin.js'; +const ROOT = join(cwd(import.meta.url), '..', '..'); +const DEBUG = false; -function prefixPlugin(prefixMessage) { - return { - name: 'prefix-plugin', - renderChunk(code) { - return `${prefixMessage}\n${code}`; - }, - }; -} - -function suffixPlugin(suffixMessage) { - return { - name: 'suffix-plugin', - renderChunk(code) { - return `${code}\n${suffixMessage}`; - }, - }; -} - -const prefixMessage = '/*! © DuckDuckGo ContentScopeScripts protections https://github.com/duckduckgo/content-scope-scripts/ */'; +const prefixMessage = '/*! © DuckDuckGo ContentScopeScripts $INJECT_NAME$ https://github.com/duckduckgo/content-scope-scripts/ */'; /** * @param {object} params @@ -36,55 +17,60 @@ const prefixMessage = '/*! © DuckDuckGo ContentScopeScripts protections https:/ * @param {string} [params.name] * @return {Promise} */ -export async function rollupScript(params) { +export async function bundle(params) { const { scriptPath, platform, name, featureNames } = params; - const extensions = ['firefox', 'chrome', 'chrome-mv3']; + const extensions = ['firefox', 'chrome-mv3']; const isExtension = extensions.includes(platform); let trackerLookup = '$TRACKER_LOOKUP$'; if (!isExtension) { const trackerLookupData = readFileSync('../build/tracker-lookup.json', 'utf8'); trackerLookup = trackerLookupData; } - const suffixMessage = `/*# sourceURL=duckduckgo-privacy-protection.js?scope=${name} */`; - const plugins = [ - css(), - svg({ - stringify: true, - }), - loadFeatures(platform, featureNames), - resolve(), - commonjs(), - replace({ - preventAssignment: true, - values: { - 'import.meta.injectName': JSON.stringify(platform), - // To be replaced by the extension, but prevents tree shaking - 'import.meta.trackerLookup': trackerLookup, - }, - }), - prefixPlugin(prefixMessage), - ]; - - if (platform === 'firefox') { - plugins.push(suffixPlugin(suffixMessage)); - } + const loadFeaturesPlugin = loadFeatures(platform, featureNames); + // The code is using a global, that we define here which means once tree shaken we get a browser specific output. - const bundle = await rollup.rollup({ - input: scriptPath, - plugins, - }); + const outputPrefixName = prefixMessage.replace('$INJECT_NAME$', platform); - const generated = await bundle.generate({ - dir: 'build', + /** @type {import("esbuild").BuildOptions} */ + const buildOptions = { + entryPoints: [scriptPath], + write: false, + outdir: 'build', + target: 'es2021', format: 'iife', - inlineDynamicImports: true, - name, - // This if for seedrandom causing build issues - globals: { crypto: 'undefined' }, - }); + bundle: true, + metafile: true, + legalComments: 'inline', + globalName: name, + loader: { + '.css': 'text', + '.svg': 'text', + }, + define: { + 'import.meta.env': 'development', + 'import.meta.injectName': JSON.stringify(platform), + 'import.meta.trackerLookup': trackerLookup, + }, + plugins: [loadFeaturesPlugin, commentPlugin()], + banner: { + js: outputPrefixName, + }, + }; + + const result = await esbuild.build(buildOptions); + + if (result.metafile && DEBUG) { + console.log(await esbuild.analyzeMetafile(result.metafile)); + } - return generated.output[0].code; + if (result.errors.length === 0 && result.outputFiles) { + return result.outputFiles[0].text || ''; + } else { + console.log(result.errors); + console.log(result.warnings); + throw new Error('could not continue'); + } } /** @@ -92,36 +78,43 @@ export async function rollupScript(params) { * * @param {string} platform * @param {string[]} featureNames + * @returns {import("esbuild").Plugin} */ function loadFeatures(platform, featureNames = platformSupport[platform]) { const pluginId = 'ddg:platformFeatures'; return { - name: pluginId, - resolveId(id) { - if (id === pluginId) return id; - return null; - }, - load(id) { - if (id !== pluginId) return null; - - // convert a list of feature names to - const imports = featureNames.map((featureName) => { - const fileName = getFileName(featureName); - const path = `./src/features/${fileName}.js`; - const ident = `ddg_feature_${featureName}`; + name: 'ddg:platformFeatures', + setup(build) { + build.onResolve({ filter: new RegExp(pluginId) }, (args) => { return { - ident, - importPath: path, + path: args.path, + namespace: pluginId, }; }); + build.onLoad({ filter: /.*/, namespace: pluginId }, () => { + // convert a list of feature names to + const imports = featureNames.map((featureName) => { + const fileName = getFileName(featureName); + const path = `./src/features/${fileName}.js`; + const ident = `ddg_feature_${featureName}`; + return { + ident, + importPath: path, + }; + }); - const importString = imports.map((imp) => `import ${imp.ident} from ${JSON.stringify(imp.importPath)}`).join(';\n'); + const importString = imports.map((imp) => `import ${imp.ident} from ${JSON.stringify(imp.importPath)}`).join(';\n'); - const exportsString = imports.map((imp) => `${imp.ident}`).join(',\n '); + const exportsString = imports.map((imp) => `${imp.ident}`).join(',\n '); - const exportString = `export default {\n ${exportsString}\n}`; + const exportString = `export default {\n ${exportsString}\n}`; - return [importString, exportString].join('\n'); + return { + loader: 'js', + resolveDir: ROOT, + contents: [importString, exportString].join('\n'), + }; + }); }, }; } @@ -135,18 +128,3 @@ function loadFeatures(platform, featureNames = platformSupport[platform]) { function getFileName(featureName) { return featureName.replace(/([a-zA-Z])(?=[A-Z0-9])/g, '$1-').toLowerCase(); } - -/** - * Apply additional processing to a bundle. This was - * added to solve an issue where certain syntax caused - * parsing to fail in macOS Catalina. - * - * `target: "es2021"` seems to be a 'low enough' target - it's also what's - * used in Autoconsent too. - * - * @param {string} content - * @return {Promise} - */ -export function postProcess(content) { - return esbuild.transform(content, { target: 'es2021', format: 'iife' }); -} diff --git a/injected/scripts/utils/comment-plugin.js b/injected/scripts/utils/comment-plugin.js new file mode 100644 index 0000000000..8e6b57289f --- /dev/null +++ b/injected/scripts/utils/comment-plugin.js @@ -0,0 +1,80 @@ +import { promises } from 'node:fs'; + +/** + * @returns {import("esbuild").Plugin} + */ +export function commentPlugin() { + const PLUGIN_ID = 'comment-override'; + + /** @type {import("esbuild").Plugin} */ + const plugin = { + name: PLUGIN_ID, + setup(build) { + build.onLoad({ filter: /.*/ }, async (args) => { + if (!args.path.includes('node_modules')) return undefined; + const text = await promises.readFile(args.path, 'utf8'); + return { + contents: convertToLegalComments(text.toString()), + loader: 'js', + }; + }); + }, + }; + return plugin; +} + +/** + * Detect the start of a particular comment and change the + * lines to have the prefix `//!` - this allows esbuild to keep it + * + * When a line is matched, continue to match further lines until a non-comment is seen. + * + * @param {string} source + */ +export function convertToLegalComments(source) { + // Process block comments - find all block comments + const blockComments = source.match(/\/\*[\s\S]*?\*\//g) || []; + + // Selectively replace only block comments that contain "copyright" + let modifiedSource = source; + for (const comment of blockComments) { + if (/copyright/i.test(comment)) { + // Replace only the block comments with copyright + modifiedSource = modifiedSource.replace(comment, comment.replace(/\/\*/, '/*!')); + } + } + + // Process line comments + const lines = modifiedSource.split('\n'); + const result = []; + let inCommentBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if the line contains a block comment - this breaks any line comment sequence + if (line.includes('/*') || line.includes('*/')) { + inCommentBlock = false; + result.push(line); + } + // Check if this line starts a line comment block with "copyright" + else if (!inCommentBlock && /^\s*\/\/(?=.*copyright.*$)/i.test(line)) { + // Start of a copyright comment - mark it and convert + inCommentBlock = true; + result.push(line.replace(/^\s*\/\//, (match) => match.replace('//', '//!'))); + } + // Check if we're continuing a line comment block + else if (inCommentBlock && /^\s*\/\//.test(line)) { + // Continue the comment block - convert the prefix + result.push(line.replace(/^\s*\/\//, (match) => match.replace('//', '//!'))); + } + // Check if we're exiting a comment block + else { + // Not a comment line or doesn't match our criteria, end the block + inCommentBlock = false; + result.push(line); + } + } + + return result.join('\n'); +} diff --git a/injected/src/captured-globals.js b/injected/src/captured-globals.js index 88d58d762c..2062f95cfb 100644 --- a/injected/src/captured-globals.js +++ b/injected/src/captured-globals.js @@ -24,3 +24,7 @@ export const String = globalThis.String; export const Map = globalThis.Map; export const Error = globalThis.Error; export const randomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto); +export const console = globalThis.console; +export const consoleLog = console.log.bind(console); +export const consoleWarn = console.warn.bind(console); +export const consoleError = console.error.bind(console); diff --git a/injected/src/config-feature.js b/injected/src/config-feature.js new file mode 100644 index 0000000000..d866d32af5 --- /dev/null +++ b/injected/src/config-feature.js @@ -0,0 +1,480 @@ +import { immutableJSONPatch } from 'immutable-json-patch'; +import { + camelcase, + computeEnabledFeatures, + matchHostname, + parseFeatureSettings, + computeLimitedSiteObject, + isSupportedVersion, + isMaxSupportedVersion, +} from './utils.js'; +import { URLPattern } from 'urlpattern-polyfill'; + +/** + * This class is extended by each feature to implement remote config handling: + * - Parsing the remote config, with conditional logic applied, + * - Providing API for features to check if they are enabled, + * - Providing API for features to get their config. + * - For external scripts, it provides API to update the site object for the feature, e.g when the URL has changed. + */ +export default class ConfigFeature { + /** @type {import('./utils.js').RemoteConfig | undefined} */ + #bundledConfig; + + /** @type {string} */ + name; + + /** + * @type {{ + * debug?: boolean, + * platform: import('./utils.js').Platform, + * desktopModeEnabled?: boolean, + * forcedZoomEnabled?: boolean, + * isDdgWebView?: boolean, + * featureSettings?: Record, + * assets?: import('./content-feature.js').AssetConfig | undefined, + * site: import('./content-feature.js').Site, + * messagingConfig?: import('@duckduckgo/messaging').MessagingConfig, + * currentCohorts?: [{feature: string, cohort: string, subfeature: string}], + * } | null} + */ + #args; + + /** + * @param {string} name + * @param {import('./content-scope-features.js').LoadArgs} args + */ + constructor(name, args) { + this.name = name; + const { bundledConfig, site, platform } = args; + this.#bundledConfig = bundledConfig; + this.#args = args; + + // If we have a bundled config, treat it as a regular config + // This will be overriden by the remote config if it is available + if (this.#bundledConfig && this.#args) { + const enabledFeatures = computeEnabledFeatures(bundledConfig, site.domain, platform.version); + this.#args.featureSettings = parseFeatureSettings(bundledConfig, enabledFeatures); + } + } + + /** + * Call this when the top URL has changed, to recompute the site object. + * This is used to update the path matching for urlPattern. + */ + recomputeSiteObject() { + if (this.#args) { + this.#args.site = computeLimitedSiteObject(); + } + } + + get args() { + return this.#args; + } + + set args(args) { + this.#args = args; + } + + get featureSettings() { + return this.#args?.featureSettings; + } + + /** + * Getter for injectName, will be overridden by subclasses (namely ContentFeature) + * @returns {string | undefined} + */ + get injectName() { + return undefined; + } + + /** + * Given a config key, interpret the value as a list of conditionals objects, and return the elements that match the current page + * Consider in your feature using patchSettings instead as per `getFeatureSetting`. + * @param {string} featureKeyName + * @return {any[]} + * @protected + */ + matchConditionalFeatureSetting(featureKeyName) { + const conditionalChanges = this._getFeatureSettings()?.[featureKeyName] || []; + return conditionalChanges.filter((rule) => { + let condition = rule.condition; + // Support shorthand for domain matching for backwards compatibility + if (condition === undefined && 'domain' in rule) { + condition = this._domainToConditonBlocks(rule.domain); + } + return this._matchConditionalBlockOrArray(condition); + }); + } + + /** + * Takes a list of domains and returns a list of condition blocks + * @param {string|string[]} domain + * @returns {ConditionBlock[]} + */ + _domainToConditonBlocks(domain) { + if (Array.isArray(domain)) { + return domain.map((domain) => ({ domain })); + } else { + return [{ domain }]; + } + } + + /** + * Used to match conditional changes for a settings feature. + * @typedef {object} ConditionBlock + * @property {string[] | string} [domain] + * @property {object} [urlPattern] + * @property {object} [minSupportedVersion] + * @property {object} [maxSupportedVersion] + * @property {object} [experiment] + * @property {string} [experiment.experimentName] + * @property {string} [experiment.cohort] + * @property {object} [context] + * @property {boolean} [context.frame] - true if the condition applies to frames + * @property {boolean} [context.top] - true if the condition applies to the top frame + * @property {string} [injectName] - the inject name to match against (e.g., "apple-isolated") + * @property {boolean} [internal] - true if the condition applies to internal builds + * @property {boolean} [preview] - true if the condition applies to preview builds + */ + + /** + * Takes multiple conditional blocks and returns true if any apply. + * @param {ConditionBlock|ConditionBlock[]} conditionBlock + * @returns {boolean} + */ + _matchConditionalBlockOrArray(conditionBlock) { + if (Array.isArray(conditionBlock)) { + return conditionBlock.some((block) => this._matchConditionalBlock(block)); + } + return this._matchConditionalBlock(conditionBlock); + } + + /** + * Takes a conditional block and returns true if it applies. + * All conditions must be met to return true. + * @param {ConditionBlock} conditionBlock + * @returns {boolean} + */ + _matchConditionalBlock(conditionBlock) { + // List of conditions that we support currently, these return truthy if the condition is met + /** @type {Record boolean>} */ + const conditionChecks = { + domain: this._matchDomainConditional, + context: this._matchContextConditional, + urlPattern: this._matchUrlPatternConditional, + experiment: this._matchExperimentConditional, + minSupportedVersion: this._matchMinSupportedVersion, + maxSupportedVersion: this._matchMaxSupportedVersion, + injectName: this._matchInjectNameConditional, + internal: this._matchInternalConditional, + preview: this._matchPreviewConditional, + }; + + for (const key in conditionBlock) { + /* + Unsupported condition so fail for backwards compatibility + If you wish to support older clients you should create an old condition block + without the unsupported key also. + Such as: + [ + { + condition: { + domain: 'example.com' + } + }, + { + condition: { + domain: 'example.com', + newKey: 'value' + } + } + ] + */ + if (!conditionChecks[key]) { + return false; + } else if (!conditionChecks[key].call(this, conditionBlock)) { + return false; + } + } + return true; + } + + /** + * Takes a condition block and returns true if the current experiment matches the experimentName and cohort. + * Expects: + * ```json + * { + * "experiment": { + * "experimentName": "experimentName", + * "cohort": "cohort-name" + * } + * } + * ``` + * Where featureName "contentScopeExperiments" has a subfeature "experimentName" and cohort "cohort-name" + * @param {ConditionBlock} conditionBlock + * @returns {boolean} + */ + _matchExperimentConditional(conditionBlock) { + if (!conditionBlock.experiment) return false; + const experiment = conditionBlock.experiment; + if (!experiment.experimentName || !experiment.cohort) return false; + const currentCohorts = this.args?.currentCohorts; + if (!currentCohorts) return false; + return currentCohorts.some((cohort) => { + return ( + cohort.feature === 'contentScopeExperiments' && + cohort.subfeature === experiment.experimentName && + cohort.cohort === experiment.cohort + ); + }); + } + + /** + * Takes a condition block and returns true if the current context matches the context. + * @param {ConditionBlock} conditionBlock + * @returns {boolean} + */ + _matchContextConditional(conditionBlock) { + if (!conditionBlock.context) return false; + const isFrame = window.self !== window.top; + if (conditionBlock.context.frame && isFrame) { + return true; + } + if (conditionBlock.context.top && !isFrame) { + return true; + } + return false; + } + + /** + * Takes a condtion block and returns true if the current url matches the urlPattern. + * @param {ConditionBlock} conditionBlock + * @returns {boolean} + */ + _matchUrlPatternConditional(conditionBlock) { + const url = this.args?.site.url; + if (!url) return false; + if (typeof conditionBlock.urlPattern === 'string') { + // Use the current URL as the base for matching + return new URLPattern(conditionBlock.urlPattern, url).test(url); + } + const pattern = new URLPattern(conditionBlock.urlPattern); + return pattern.test(url); + } + + /** + * Takes a condition block and returns true if the current domain matches the domain. + * @param {ConditionBlock} conditionBlock + * @returns {boolean} + */ + _matchDomainConditional(conditionBlock) { + if (!conditionBlock.domain) return false; + const domain = this.args?.site.domain; + if (!domain) return false; + if (Array.isArray(conditionBlock.domain)) { + // Explicitly check for an empty array as matchHostname will return true a single item array that matches + return false; + } + return matchHostname(domain, conditionBlock.domain); + } + + /** + * Takes a condition block and returns true if the current inject name matches the injectName. + * @param {ConditionBlock} conditionBlock + * @returns {boolean} + */ + _matchInjectNameConditional(conditionBlock) { + if (!conditionBlock.injectName) return false; + // Access injectName through the ContentFeature's getter + const currentInjectName = this.injectName; + if (!currentInjectName) return false; + return conditionBlock.injectName === currentInjectName; + } + + /** + * Takes a condition block and returns true if the internal state matches the condition. + * @param {ConditionBlock} conditionBlock + * @returns {boolean} + */ + _matchInternalConditional(conditionBlock) { + if (conditionBlock.internal === undefined) return false; + const isInternal = this.#args?.platform?.internal; + if (isInternal === undefined) return false; + return Boolean(conditionBlock.internal) === Boolean(isInternal); + } + + /** + * Takes a condition block and returns true if the preview state matches the condition. + * @param {ConditionBlock} conditionBlock + * @returns {boolean} + */ + _matchPreviewConditional(conditionBlock) { + if (conditionBlock.preview === undefined) return false; + const isPreview = this.#args?.platform?.preview; + if (isPreview === undefined) return false; + return Boolean(conditionBlock.preview) === Boolean(isPreview); + } + + /** + * Takes a condition block and returns true if the platform version satisfies the `minSupportedFeature` + * @param {ConditionBlock} conditionBlock + * @returns {boolean} + */ + _matchMinSupportedVersion(conditionBlock) { + if (!conditionBlock.minSupportedVersion) return false; + return isSupportedVersion(conditionBlock.minSupportedVersion, this.#args?.platform?.version); + } + + /** + * Takes a condition block and returns true if the platform version satisfies the `maxSupportedFeature` + * @param {ConditionBlock} conditionBlock + * @returns {boolean} + */ + _matchMaxSupportedVersion(conditionBlock) { + if (!conditionBlock.maxSupportedVersion) return false; + return isMaxSupportedVersion(conditionBlock.maxSupportedVersion, this.#args?.platform?.version); + } + + /** + * Return the settings object for a feature + * @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature + * @returns {any} + */ + _getFeatureSettings(featureName) { + const camelFeatureName = featureName || camelcase(this.name); + return this.featureSettings?.[camelFeatureName]; + } + + /** + * For simple boolean settings, return true if the setting is 'enabled' + * For objects, verify the 'state' field is 'enabled'. + * This allows for future forwards compatibility with more complex settings if required. + * For example: + * ```json + * { + * "toggle": "enabled" + * } + * ``` + * Could become later (without breaking changes): + * ```json + * { + * "toggle": { + * "state": "enabled", + * "someOtherKey": 1 + * } + * } + * ``` + * This also supports domain overrides as per `getFeatureSetting`. + * @param {string} featureKeyName + * @param {'enabled' | 'disabled'} [defaultState] + * @param {string} [featureName] + * @returns {boolean} + */ + getFeatureSettingEnabled(featureKeyName, defaultState, featureName) { + const result = this.getFeatureSetting(featureKeyName, featureName) || defaultState; + if (typeof result === 'object') { + return result.state === 'enabled'; + } + return result === 'enabled'; + } + + /** + * Return a specific setting from the feature settings + * If the "settings" key within the config has a "conditionalChanges" key, it will be used to override the settings. + * This uses JSONPatch to apply the patches to settings before getting the setting value. + * For example.com getFeatureSettings('val') will return 1: + * ```json + * { + * "settings": { + * "conditionalChanges": [ + * { + * "domain": "example.com", + * "patchSettings": [ + * { "op": "replace", "path": "/val", "value": 1 } + * ] + * } + * ] + * } + * } + * ``` + * "domain" can either be a string or an array of strings. + * Additionally we support urlPattern for more complex matching. + * For example.com getFeatureSettings('val') will return 1: + * ```json + * { + * "settings": { + * "conditionalChanges": [ + * { + * "condition": { + * "urlPattern": "https://example.com/*", + * }, + * "patchSettings": [ + * { "op": "replace", "path": "/val", "value": 1 } + * ] + * } + * ] + * } + * } + * ``` + * We also support multiple conditions: + * ```json + * { + * "settings": { + * "conditionalChanges": [ + * { + * "condition": [ + * { + * "urlPattern": "https://example.com/*", + * }, + * { + * "urlPattern": "https://other.com/path/something", + * }, + * ], + * "patchSettings": [ + * { "op": "replace", "path": "/val", "value": 1 } + * ] + * } + * ] + * } + * } + * ``` + * + * For boolean states you should consider using getFeatureSettingEnabled. + * @param {string} featureKeyName + * @param {string} [featureName] + * @returns {any} + */ + getFeatureSetting(featureKeyName, featureName) { + let result = this._getFeatureSettings(featureName); + if (featureKeyName in ['domains', 'conditionalChanges']) { + throw new Error(`${featureKeyName} is a reserved feature setting key name`); + } + // We only support one of these keys at a time, where conditionalChanges takes precedence + let conditionalMatches = []; + // Presence check using result to avoid the [] default response + if (result?.conditionalChanges) { + conditionalMatches = this.matchConditionalFeatureSetting('conditionalChanges'); + } else { + conditionalMatches = this.matchConditionalFeatureSetting('domains'); + } + for (const match of conditionalMatches) { + if (match.patchSettings === undefined) { + continue; + } + try { + result = immutableJSONPatch(result, match.patchSettings); + } catch (e) { + console.error('Error applying patch settings', e); + } + } + return result?.[featureKeyName]; + } + + /** + * @returns {import('./utils.js').RemoteConfig | undefined} + **/ + get bundledConfig() { + return this.#bundledConfig; + } +} diff --git a/injected/src/content-feature.js b/injected/src/content-feature.js index 9cec5ffb7b..a01f31faf3 100644 --- a/injected/src/content-feature.js +++ b/injected/src/content-feature.js @@ -1,11 +1,12 @@ -import { camelcase, matchHostname, processAttr, computeEnabledFeatures, parseFeatureSettings } from './utils.js'; -import { immutableJSONPatch } from 'immutable-json-patch'; +import { processAttr } from './utils.js'; import { PerformanceMonitor } from './performance.js'; import { defineProperty, shimInterface, shimProperty, wrapMethod, wrapProperty, wrapToString } from './wrapper-utils.js'; // eslint-disable-next-line no-redeclare -import { Proxy, Reflect } from './captured-globals.js'; +import { Proxy, Reflect, consoleLog, consoleWarn, consoleError } from './captured-globals.js'; import { Messaging, MessagingContext } from '../../messaging/index.js'; import { extensionConstructMessagingConfig } from './sendmessage-transport.js'; +import { isTrackerOrigin } from './trackers.js'; +import ConfigFeature from './config-feature.js'; /** * @typedef {object} AssetConfig @@ -16,46 +17,91 @@ import { extensionConstructMessagingConfig } from './sendmessage-transport.js'; /** * @typedef {object} Site * @property {string | null} domain + * @property {string | null} url * @property {boolean} [isBroken] * @property {boolean} [allowlisted] * @property {string[]} [enabledFeatures] */ -export default class ContentFeature { +export default class ContentFeature extends ConfigFeature { /** @type {import('./utils.js').RemoteConfig | undefined} */ - #bundledConfig; - /** @type {object | undefined} */ - #trackerLookup; - /** @type {boolean | undefined} */ - #documentOriginIsTracker; - /** @type {Record | undefined} */ - // eslint-disable-next-line no-unused-private-class-members - #bundledfeatureSettings; /** @type {import('../../messaging').Messaging} */ // eslint-disable-next-line no-unused-private-class-members #messaging; /** @type {boolean} */ #isDebugFlagSet = false; + /** + * Set this to true if you wish to listen to top level URL changes for config matching. + * @type {boolean} + */ + listenForUrlChanges = false; + + /** + * Set this to true if you wish to get update calls (legacy). + * @type {boolean} + */ + listenForUpdateChanges = false; + + /** + * Set this to true if you wish to receive configuration updates from initial ping responses (Android only). + * @type {boolean} + */ + listenForConfigUpdates = false; - /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record, assets?: AssetConfig | undefined, site: Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */ - #args; + /** @type {ImportMeta} */ + #importConfig; - constructor(featureName) { - this.name = featureName; - this.#args = null; + constructor(featureName, importConfig, args) { + super(featureName, args); + this.setArgs(this.args); this.monitor = new PerformanceMonitor(); + this.#importConfig = importConfig; } get isDebug() { - return this.#args?.debug || false; + return this.args?.debug || false; + } + + get shouldLog() { + return this.isDebug; + } + + /** + * Logging utility for this feature (Stolen some inspo from DuckPlayer logger, will unify in the future) + */ + get log() { + const shouldLog = this.shouldLog; + const prefix = `${this.name.padEnd(20, ' ')} |`; + + return { + // These are getters to have the call site be the reported line number. + get info() { + if (!shouldLog) { + return () => {}; + } + return consoleLog.bind(console, prefix); + }, + get warn() { + if (!shouldLog) { + return () => {}; + } + return consoleWarn.bind(console, prefix); + }, + get error() { + if (!shouldLog) { + return () => {}; + } + return consoleError.bind(console, prefix); + }, + }; } get desktopModeEnabled() { - return this.#args?.desktopModeEnabled || false; + return this.args?.desktopModeEnabled || false; } get forcedZoomEnabled() { - return this.#args?.forcedZoomEnabled || false; + return this.args?.forcedZoomEnabled || false; } /** @@ -74,28 +120,28 @@ export default class ContentFeature { * @type {AssetConfig | undefined} */ get assetConfig() { - return this.#args?.assets; + return this.args?.assets; } /** - * @returns {boolean} - */ - get documentOriginIsTracker() { - return !!this.#documentOriginIsTracker; + * @returns {ImportMeta['trackerLookup']} + **/ + get trackerLookup() { + return this.#importConfig.trackerLookup || {}; } /** - * @returns {object} - **/ - get trackerLookup() { - return this.#trackerLookup || {}; + * @returns {ImportMeta['injectName']} + */ + get injectName() { + return this.#importConfig.injectName; } /** - * @returns {import('./utils.js').RemoteConfig | undefined} - **/ - get bundledConfig() { - return this.#bundledConfig; + * @returns {boolean} + */ + get documentOriginIsTracker() { + return isTrackerOrigin(this.trackerLookup); } /** @@ -103,8 +149,7 @@ export default class ContentFeature { * @return {MessagingContext} */ _createMessagingContext() { - const injectName = import.meta.injectName; - const contextName = injectName === 'apple-isolated' ? 'contentScopeScriptsIsolated' : 'contentScopeScripts'; + const contextName = this.injectName === 'apple-isolated' ? 'contentScopeScriptsIsolated' : 'contentScopeScripts'; return new MessagingContext({ context: contextName, @@ -121,7 +166,7 @@ export default class ContentFeature { get messaging() { if (this._messaging) return this._messaging; const messagingContext = this._createMessagingContext(); - let messagingConfig = this.#args?.messagingConfig; + let messagingConfig = this.args?.messagingConfig; if (!messagingConfig) { if (this.platform?.name !== 'extension') throw new Error('Only extension messaging supported, all others should be passed in'); messagingConfig = extensionConstructMessagingConfig(); @@ -144,128 +189,23 @@ export default class ContentFeature { return processAttr(configSetting, defaultValue); } - /** - * Return a specific setting from the feature settings - * If the "settings" key within the config has a "domains" key, it will be used to override the settings. - * This uses JSONPatch to apply the patches to settings before getting the setting value. - * For example.com getFeatureSettings('val') will return 1: - * ```json - * { - * "settings": { - * "domains": [ - * { - * "domain": "example.com", - * "patchSettings": [ - * { "op": "replace", "path": "/val", "value": 1 } - * ] - * } - * ] - * } - * } - * ``` - * "domain" can either be a string or an array of strings. - - * For boolean states you should consider using getFeatureSettingEnabled. - * @param {string} featureKeyName - * @param {string} [featureName] - * @returns {any} - */ - getFeatureSetting(featureKeyName, featureName) { - let result = this._getFeatureSettings(featureName); - if (featureKeyName === 'domains') { - throw new Error('domains is a reserved feature setting key name'); - } - const domainMatch = [...this.matchDomainFeatureSetting('domains')].sort((a, b) => { - return a.domain.length - b.domain.length; - }); - for (const match of domainMatch) { - if (match.patchSettings === undefined) { - continue; - } - try { - result = immutableJSONPatch(result, match.patchSettings); - } catch (e) { - console.error('Error applying patch settings', e); - } - } - return result?.[featureKeyName]; - } - - /** - * Return the settings object for a feature - * @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature - * @returns {any} - */ - _getFeatureSettings(featureName) { - const camelFeatureName = featureName || camelcase(this.name); - return this.#args?.featureSettings?.[camelFeatureName]; - } - - /** - * For simple boolean settings, return true if the setting is 'enabled' - * For objects, verify the 'state' field is 'enabled'. - * This allows for future forwards compatibility with more complex settings if required. - * For example: - * ```json - * { - * "toggle": "enabled" - * } - * ``` - * Could become later (without breaking changes): - * ```json - * { - * "toggle": { - * "state": "enabled", - * "someOtherKey": 1 - * } - * } - * ``` - * This also supports domain overrides as per `getFeatureSetting`. - * @param {string} featureKeyName - * @param {string} [featureName] - * @returns {boolean} - */ - getFeatureSettingEnabled(featureKeyName, featureName) { - const result = this.getFeatureSetting(featureKeyName, featureName); - if (typeof result === 'object') { - return result.state === 'enabled'; - } - return result === 'enabled'; - } - - /** - * Given a config key, interpret the value as a list of domain overrides, and return the elements that match the current page - * Consider using patchSettings instead as per `getFeatureSetting`. - * @param {string} featureKeyName - * @return {any[]} - * @private - */ - matchDomainFeatureSetting(featureKeyName) { - const domain = this.#args?.site.domain; - if (!domain) return []; - const domains = this._getFeatureSettings()?.[featureKeyName] || []; - return domains.filter((rule) => { - if (Array.isArray(rule.domain)) { - return rule.domain.some((domainRule) => { - return matchHostname(domain, domainRule); - }); - } - return matchHostname(domain, rule.domain); - }); - } - - init(args) {} + init(_args) {} callInit(args) { const mark = this.monitor.mark(this.name + 'CallInit'); - this.#args = args; - this.platform = args.platform; - this.init(args); + this.setArgs(args); + // Passing this.args is legacy here and features should use this.args or other properties directly + this.init(this.args); mark.end(); this.measure(); } - load(args) {} + setArgs(args) { + this.args = args; + this.platform = args.platform; + } + + load(_args) {} /** * This is a wrapper around `this.messaging.notify` that applies the @@ -306,43 +246,47 @@ export default class ContentFeature { return this.messaging.subscribe(name, cb); } - /** - * @param {import('./content-scope-features.js').LoadArgs} args - */ - callLoad(args) { + callLoad() { const mark = this.monitor.mark(this.name + 'CallLoad'); - this.#args = args; - this.platform = args.platform; - this.#bundledConfig = args.bundledConfig; - // If we have a bundled config, treat it as a regular config - // This will be overriden by the remote config if it is available - if (this.#bundledConfig && this.#args) { - const enabledFeatures = computeEnabledFeatures(args.bundledConfig, args.site.domain, this.platform.version); - this.#args.featureSettings = parseFeatureSettings(args.bundledConfig, enabledFeatures); - } - this.#trackerLookup = args.trackerLookup; - this.#documentOriginIsTracker = args.documentOriginIsTracker; - this.load(args); + this.load(this.args); mark.end(); } measure() { - if (this.#args?.debug) { + if (this.isDebug) { this.monitor.measureAll(); } } + /** + * @deprecated - use messaging instead. + */ update() {} + /** + * Called when user preferences are merged from initial ping response. (Android only) + * Override this method in your feature to handle user preference updates. + * This only happens once during initialization when the platform responds with user-specific settings. + * @param {object} _updatedConfig - The configuration with merged user preferences + */ + onUserPreferencesMerged(_updatedConfig) { + // Default implementation does nothing + // Features can override this to handle user preference updates + } + /** * Register a flag that will be added to page breakage reports */ addDebugFlag() { if (this.#isDebugFlagSet) return; this.#isDebugFlagSet = true; - this.messaging?.notify('addDebugFlag', { - flag: this.name, - }); + try { + this.messaging?.notify('addDebugFlag', { + flag: this.name, + }); + } catch (_e) { + // Ignore thrown error from a potentially missing handler (on WebKit). + } } /** @@ -360,7 +304,7 @@ export default class ContentFeature { if (typeof descriptorProp === 'function') { const addDebugFlag = this.addDebugFlag.bind(this); const wrapper = new Proxy(descriptorProp, { - apply(target, thisArg, argumentsList) { + apply(_, thisArg, argumentsList) { addDebugFlag(); return Reflect.apply(descriptorProp, thisArg, argumentsList); }, @@ -401,7 +345,7 @@ export default class ContentFeature { * @param {import('./wrapper-utils').DefineInterfaceOptions} options */ shimInterface(interfaceName, ImplClass, options) { - return shimInterface(interfaceName, ImplClass, options, this.defineProperty.bind(this)); + return shimInterface(interfaceName, ImplClass, options, this.defineProperty.bind(this), this.injectName); } /** @@ -416,6 +360,6 @@ export default class ContentFeature { * @param {boolean} [readOnly] - whether the property should be read-only (default: false) */ shimProperty(instanceHost, instanceProp, implInstance, readOnly = false) { - return shimProperty(instanceHost, instanceProp, implInstance, readOnly, this.defineProperty.bind(this)); + return shimProperty(instanceHost, instanceProp, implInstance, readOnly, this.defineProperty.bind(this), this.injectName); } } diff --git a/injected/src/content-scope-features.js b/injected/src/content-scope-features.js index c7d56e850e..2b062f6c23 100644 --- a/injected/src/content-scope-features.js +++ b/injected/src/content-scope-features.js @@ -1,7 +1,8 @@ -import { initStringExemptionLists, isFeatureBroken, registerMessageSecret } from './utils'; +import { initStringExemptionLists, isFeatureBroken, isGloballyDisabled, platformSpecificFeatures, registerMessageSecret } from './utils'; import { platformSupport } from './features'; import { PerformanceMonitor } from './performance'; import platformFeatures from 'ddg:platformFeatures'; +import { registerForURLChanges } from './url-change'; let initArgs = null; const updates = []; @@ -22,11 +23,9 @@ const isHTMLDocument = * @typedef {object} LoadArgs * @property {import('./content-feature').Site} site * @property {import('./utils.js').Platform} platform - * @property {boolean} documentOriginIsTracker * @property {import('./utils.js').RemoteConfig} bundledConfig - * @property {string} [injectName] - * @property {object} trackerLookup - provided currently only by the extension * @property {import('@duckduckgo/messaging').MessagingConfig} [messagingConfig] + * @property {string} [messageSecret] - optional, used in the messageBridge creation */ /** @@ -38,13 +37,32 @@ export function load(args) { return; } - const featureNames = typeof import.meta.injectName === 'string' ? platformSupport[import.meta.injectName] : []; + const importConfig = { + trackerLookup: import.meta.trackerLookup, + injectName: import.meta.injectName, + }; - for (const featureName of featureNames) { - const ContentFeature = platformFeatures['ddg_feature_' + featureName]; - const featureInstance = new ContentFeature(featureName); - featureInstance.callLoad(args); - features.push({ featureName, featureInstance }); + const bundledFeatureNames = typeof importConfig.injectName === 'string' ? platformSupport[importConfig.injectName] : []; + + // prettier-ignore + const featuresToLoad = isGloballyDisabled(args) + // if we're globally disabled, only allow `platformSpecificFeatures` + ? platformSpecificFeatures + // if available, use `site.enabledFeatures`. The extension doesn't have `site.enabledFeatures` at this + // point, which is why we fall back to `bundledFeatureNames`. + : args.site.enabledFeatures || bundledFeatureNames; + + for (const featureName of bundledFeatureNames) { + if (featuresToLoad.includes(featureName)) { + const ContentFeature = platformFeatures['ddg_feature_' + featureName]; + const featureInstance = new ContentFeature(featureName, importConfig, args); + // Short term fix to disable the feature whilst we roll out Android adsjs + if (!featureInstance.getFeatureSettingEnabled('additionalCheck', 'enabled')) { + continue; + } + featureInstance.callLoad(); + features.push({ featureName, featureInstance }); + } } mark.end(); } @@ -60,7 +78,21 @@ export async function init(args) { const resolvedFeatures = await Promise.all(features); resolvedFeatures.forEach(({ featureInstance, featureName }) => { if (!isFeatureBroken(args, featureName) || alwaysInitExtensionFeatures(args, featureName)) { + // Short term fix to disable the feature whilst we roll out Android adsjs + if (!featureInstance.getFeatureSettingEnabled('additionalCheck', 'enabled')) { + return; + } featureInstance.callInit(args); + // Either listenForUrlChanges or urlChanged ensures the feature listens. + if (featureInstance.listenForUrlChanges || featureInstance.urlChanged) { + registerForURLChanges((navigationType) => { + // The rationale for the two separate call here is to ensure that + // extensions to the class don't need to call super.urlChanged() + featureInstance.recomputeSiteObject(); + // Called if the feature instance has a urlChanged method + featureInstance?.urlChanged(navigationType); + }); + } } }); // Fire off updates that came in faster than the init @@ -85,6 +117,34 @@ export function update(args) { updateFeaturesInner(args); } +/** + * Update the args for feature instances that opt in to configuration updates. + * This is useful for applying configuration updates received after initial loading. + * + * @param {object} updatedArgs - The new arguments to apply to opted-in features + */ +export async function updateFeatureArgs(updatedArgs) { + if (!isHTMLDocument) { + return; + } + + const resolvedFeatures = await Promise.all(features); + resolvedFeatures.forEach(({ featureInstance }) => { + // Only update features that have opted in to config updates + if (featureInstance && featureInstance.listenForConfigUpdates) { + // Update the feature's args + if (typeof featureInstance.setArgs === 'function') { + featureInstance.setArgs(updatedArgs); + } + + // Call the optional onUserPreferencesMerged method if it exists + if (typeof featureInstance.onUserPreferencesMerged === 'function') { + featureInstance.onUserPreferencesMerged(updatedArgs); + } + } + }); +} + function alwaysInitExtensionFeatures(args, featureName) { return args.platform.name === 'extension' && alwaysInitFeatures.has(featureName); } @@ -92,7 +152,7 @@ function alwaysInitExtensionFeatures(args, featureName) { async function updateFeaturesInner(args) { const resolvedFeatures = await Promise.all(features); resolvedFeatures.forEach(({ featureInstance, featureName }) => { - if (!isFeatureBroken(initArgs, featureName) && featureInstance.update) { + if (!isFeatureBroken(initArgs, featureName) && featureInstance.listenForUpdateChanges) { featureInstance.update(args); } }); diff --git a/injected/src/features.js b/injected/src/features.js index 895f80b8bc..f704269a41 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -1,3 +1,4 @@ +// Features must exist in either `baseFeatures` or `otherFeatures` export const baseFeatures = /** @type {const} */ ([ 'fingerprintingAudio', 'fingerprintingBattery', @@ -19,23 +20,61 @@ const otherFeatures = /** @type {const} */ ([ 'cookie', 'messageBridge', 'duckPlayer', + 'duckPlayerNative', + 'duckAiDataClearing', 'harmfulApis', 'webCompat', 'windowsPermissionUsage', 'brokerProtection', 'performanceMetrics', 'breakageReporting', - 'autofillPasswordImport', + 'autofillImport', + 'favicon', + 'webTelemetry', + 'pageContext', ]); /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ export const platformSupport = { - apple: ['webCompat', ...baseFeatures], - 'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge'], - android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer'], - 'android-autofill-password-import': ['autofillPasswordImport'], - windows: ['cookie', ...baseFeatures, 'windowsPermissionUsage', 'duckPlayer', 'brokerProtection', 'breakageReporting'], + apple: ['webCompat', 'duckPlayerNative', ...baseFeatures, 'duckAiDataClearing', 'pageContext'], + 'apple-isolated': [ + 'duckPlayer', + 'duckPlayerNative', + 'brokerProtection', + 'breakageReporting', + 'performanceMetrics', + 'clickToLoad', + 'messageBridge', + 'favicon', + ], + android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'], + 'android-broker-protection': ['brokerProtection'], + 'android-autofill-import': ['autofillImport'], + 'android-adsjs': [ + 'apiManipulation', + 'webCompat', + 'fingerprintingHardware', + 'fingerprintingScreenSize', + 'fingerprintingTemporaryStorage', + 'fingerprintingAudio', + 'fingerprintingBattery', + 'gpc', + 'breakageReporting', + ], + windows: [ + 'cookie', + ...baseFeatures, + 'webTelemetry', + 'windowsPermissionUsage', + 'duckPlayer', + 'brokerProtection', + 'breakageReporting', + 'messageBridge', + 'webCompat', + 'pageContext', + 'duckAiDataClearing', + ], firefox: ['cookie', ...baseFeatures, 'clickToLoad'], chrome: ['cookie', ...baseFeatures, 'clickToLoad'], 'chrome-mv3': ['cookie', ...baseFeatures, 'clickToLoad'], diff --git a/injected/src/features/api-manipulation.js b/injected/src/features/api-manipulation.js index 859c3a2186..fa08e18533 100644 --- a/injected/src/features/api-manipulation.js +++ b/injected/src/features/api-manipulation.js @@ -4,15 +4,17 @@ * * @module API manipulation */ -import ContentFeature from '../content-feature'; +import ContentFeature from '../content-feature.js'; // eslint-disable-next-line no-redeclare -import { hasOwnProperty } from '../captured-globals'; -import { processAttr } from '../utils'; +import { hasOwnProperty } from '../captured-globals.js'; +import { processAttr } from '../utils.js'; /** * @internal */ export default class ApiManipulation extends ContentFeature { + listenForUrlChanges = true; + init() { const apiChanges = this.getFeatureSetting('apiChanges'); if (apiChanges) { @@ -26,6 +28,10 @@ export default class ApiManipulation extends ContentFeature { } } + urlChanged() { + this.init(); + } + /** * Checks if the config API change is valid. * @param {any} change @@ -45,6 +51,9 @@ export default class ApiManipulation extends ContentFeature { if (change.configurable && typeof change.configurable !== 'boolean') { return false; } + if ('define' in change && typeof change.define !== 'boolean') { + return false; + } return typeof change.getterValue !== 'undefined'; } return false; @@ -59,6 +68,7 @@ export default class ApiManipulation extends ContentFeature { * @property {import('../utils.js').ConfigSetting} [getterValue] - The value returned from a getter. * @property {boolean} [enumerable] - Whether the property is enumerable. * @property {boolean} [configurable] - Whether the property is configurable. + * @property {boolean} [define] - Whether to define the property if it does not exist. */ /** @@ -111,6 +121,17 @@ export default class ApiManipulation extends ContentFeature { if ('configurable' in change) { descriptor.configurable = change.configurable; } + // If 'define' is true and property does not exist, define it directly + if (change.define === true && !(key in api)) { + // Ensure descriptor has required boolean fields + const defineDescriptor = { + ...descriptor, + enumerable: typeof descriptor.enumerable !== 'boolean' ? true : descriptor.enumerable, + configurable: typeof descriptor.configurable !== 'boolean' ? true : descriptor.configurable, + }; + this.defineProperty(api, key, defineDescriptor); + return; + } this.wrapProperty(api, key, descriptor); } } diff --git a/injected/src/features/autofill-password-import.js b/injected/src/features/autofill-import.js similarity index 60% rename from injected/src/features/autofill-password-import.js rename to injected/src/features/autofill-import.js index db6f14207c..1b5e3513d2 100644 --- a/injected/src/features/autofill-password-import.js +++ b/injected/src/features/autofill-import.js @@ -1,5 +1,6 @@ -import ContentFeature from '../content-feature'; -import { DDGProxy, DDGReflect, withExponentialBackoff } from '../utils'; +import { isBeingFramed, withRetry } from '../utils'; +import { ActionExecutorBase } from './broker-protection'; +import { ErrorResponse } from './broker-protection/types'; export const ANIMATION_DURATION_MS = 1000; export const ANIMATION_ITERATIONS = Infinity; @@ -7,6 +8,7 @@ export const BACKGROUND_COLOR_START = 'rgba(85, 127, 243, 0.10)'; export const BACKGROUND_COLOR_END = 'rgba(85, 127, 243, 0.25)'; export const OVERLAY_ID = 'ddg-password-import-overlay'; export const DELAY_BEFORE_ANIMATION = 300; +const MANAGE_ARCHIVE_DEFAULT_BASE = '/manage/archive'; /** * @typedef ButtonAnimationStyle @@ -20,9 +22,10 @@ export const DELAY_BEFORE_ANIMATION = 300; /** * @typedef ElementConfig * @property {HTMLElement|Element|SVGElement} element - * @property {ButtonAnimationStyle} animationStyle + * @property {ButtonAnimationStyle|null} animationStyle * @property {boolean} shouldTap * @property {boolean} shouldWatchForRemoval + * @property {boolean} tapOnce */ /** @@ -32,13 +35,15 @@ export const DELAY_BEFORE_ANIMATION = 300; * 2. Find the element to animate based on the path - using structural selectors first and then fallback to label texts), * 3. Animate the element, or tap it if it should be autotapped. */ -export default class AutofillPasswordImport extends ContentFeature { +export default class AutofillImport extends ActionExecutorBase { #exportButtonSettings; #settingsButtonSettings; #signInButtonSettings; + #exportConfirmButtonSettings; + /** @type {HTMLElement|Element|SVGElement|null} */ #elementToCenterOn; @@ -50,6 +55,13 @@ export default class AutofillPasswordImport extends ContentFeature { #domLoaded; + #processingBookmark; + + #isBookmarkModalVisible = false; + + /** @type {WeakSet} */ + #tappedElements = new WeakSet(); + /** * @returns {ButtonAnimationStyle} */ @@ -126,6 +138,50 @@ export default class AutofillPasswordImport extends ContentFeature { return this.#domLoaded; } + /** + * @returns {Promise} + */ + async runWithRetry(fn, maxAttempts = 4, delay = 500, strategy = 'exponential') { + try { + return await withRetry(fn, maxAttempts, delay, strategy); + } catch (error) { + return null; + } + } + + /** + * @returns {Promise} + */ + async getExportConfirmElementAndStyle() { + const exportConfirmElement = await this.findExportConfirmElement(); + const shouldAutotap = this.#exportConfirmButtonSettings?.shouldAutotap && exportConfirmElement != null; + return shouldAutotap + ? { + animationStyle: null, + element: exportConfirmElement, + shouldTap: true, + shouldWatchForRemoval: false, + tapOnce: false, + } + : null; + } + + /** + * @returns {Promise} + */ + async getExportElementAndStyle() { + const element = await this.findExportElement(); + return element != null + ? { + animationStyle: this.exportButtonAnimationStyle, + element, + shouldTap: this.#exportButtonSettings?.shouldAutotap ?? false, + shouldWatchForRemoval: true, + tapOnce: true, + } + : null; + } + /** * Takes a path and returns the element and style to animate. * @param {string} path @@ -140,18 +196,18 @@ export default class AutofillPasswordImport extends ContentFeature { element, shouldTap: this.#settingsButtonSettings?.shouldAutotap ?? false, shouldWatchForRemoval: false, + tapOnce: false, } : null; } else if (path === '/options') { - const element = await this.findExportElement(); - return element != null - ? { - animationStyle: this.exportButtonAnimationStyle, - element, - shouldTap: this.#exportButtonSettings?.shouldAutotap ?? false, - shouldWatchForRemoval: true, - } - : null; + // If we have found the popup element, then we return that early. + const isExportButtonTapped = + this.currentElementConfig?.element != null && this.#tappedElements.has(this.currentElementConfig?.element); + if (isExportButtonTapped) { + return await this.getExportConfirmElementAndStyle(); + } else { + return await this.getExportElementAndStyle(); + } } else if (path === '/intro') { const element = await this.findSignInButton(); return element != null @@ -160,6 +216,7 @@ export default class AutofillPasswordImport extends ContentFeature { element, shouldTap: this.#signInButtonSettings?.shouldAutotap ?? false, shouldWatchForRemoval: false, + tapOnce: false, } : null; } else { @@ -176,6 +233,9 @@ export default class AutofillPasswordImport extends ContentFeature { this.currentOverlay.remove(); this.currentOverlay = null; document.removeEventListener('scroll', this); + if (this.currentElementConfig?.element) { + this.#tappedElements.delete(this.currentElementConfig?.element); + } } } @@ -328,6 +388,10 @@ export default class AutofillPasswordImport extends ContentFeature { element.click(); } + async findExportConfirmElement() { + return await this.runWithRetry(() => document.querySelector(this.exportConfirmButtonSelector)); + } + /** * On passwords.google.com the export button is in a container that is quite ambiguious. * To solve for that we first try to find the container and then the button inside it. @@ -344,7 +408,7 @@ export default class AutofillPasswordImport extends ContentFeature { return document.querySelector(this.exportButtonLabelTextSelector); }; - return await withExponentialBackoff(() => findInContainer() ?? findWithLabel()); + return await this.runWithRetry(() => findInContainer() ?? findWithLabel()); } /** @@ -355,14 +419,14 @@ export default class AutofillPasswordImport extends ContentFeature { const settingsButton = document.querySelector(this.settingsButtonSelector); return settingsButton; }; - return await withExponentialBackoff(fn); + return await this.runWithRetry(fn); } /** * @returns {Promise} */ async findSignInButton() { - return await withExponentialBackoff(() => document.querySelector(this.signinButtonSelector)); + return await this.runWithRetry(() => document.querySelector(this.signinButtonSelector)); } /** @@ -380,7 +444,8 @@ export default class AutofillPasswordImport extends ContentFeature { setCurrentElementConfig(config) { if (config != null) { this.#currentElementConfig = config; - this.setElementToCenterOn(config.element, config.animationStyle); + + if (config.animationStyle != null) this.setElementToCenterOn(config.element, config.animationStyle); } } @@ -390,18 +455,61 @@ export default class AutofillPasswordImport extends ContentFeature { * @returns {boolean} */ isSupportedPath(path) { - return [this.#exportButtonSettings?.path, this.#settingsButtonSettings?.path, this.#signInButtonSettings?.path].includes(path); + return [ + this.#exportButtonSettings?.path, + this.#settingsButtonSettings?.path, + this.#signInButtonSettings?.path, + this.#exportConfirmButtonSettings?.path, + ].includes(path); } - async handlePath(path) { + async handlePasswordManagerPath(pathname) { this.removeOverlayIfNeeded(); - if (this.isSupportedPath(path)) { + if (this.isSupportedPath(pathname)) { try { - this.setCurrentElementConfig(await this.getElementAndStyleFromPath(path)); - await this.animateOrTapElement(); + this.setCurrentElementConfig(await this.getElementAndStyleFromPath(pathname)); + if (this.currentElementConfig?.element && !this.#tappedElements.has(this.currentElementConfig?.element)) { + await this.animateOrTapElement(); + if (this.currentElementConfig?.shouldTap && this.currentElementConfig?.tapOnce) { + this.#tappedElements.add(this.currentElementConfig.element); + } + } } catch { - console.error('password-import: failed for path:', path); + this.log.error('password-import: failed for path:', pathname); + } + } + } + + /** + * @returns {Array>} + */ + get bookmarkImportActionSettings() { + return this.getFeatureSetting('actions') || []; + } + + /** + * @returns {Record} + */ + get bookmarkImportSelectorSettings() { + return this.getFeatureSetting('selectors'); + } + + /** + * @param {Location} location + * + */ + async handleLocation(location) { + const { pathname } = location; + if (this.bookmarkImportActionSettings.length > 0) { + if (this.#processingBookmark) { + return; } + this.#processingBookmark = true; + await this.handleBookmarkImportPath(pathname); + } else if (this.getFeatureSetting('settingsButton')) { + await this.handlePasswordManagerPath(pathname); + } else { + // Unknown feature, we bail out } } @@ -411,12 +519,14 @@ export default class AutofillPasswordImport extends ContentFeature { */ async animateOrTapElement() { const { element, animationStyle, shouldTap, shouldWatchForRemoval } = this.currentElementConfig ?? {}; - if (element != null && animationStyle != null) { + if (element != null) { if (shouldTap) { this.autotapElement(element); } else { - await this.domLoaded; - this.animateElement(element, animationStyle); + if (animationStyle != null) { + await this.domLoaded; + this.animateElement(element, animationStyle); + } } if (shouldWatchForRemoval) { // Sometimes navigation events are not triggered, then we need to watch for removal @@ -434,6 +544,13 @@ export default class AutofillPasswordImport extends ContentFeature { return this.#exportButtonSettings?.selectors?.join(','); } + /** + * @returns {string} + */ + get exportConfirmButtonSelector() { + return this.#exportConfirmButtonSettings?.selectors?.join(','); + } + /** * @returns {string} */ @@ -469,29 +586,123 @@ export default class AutofillPasswordImport extends ContentFeature { return `${this.#settingsButtonSettings?.selectors?.join(',')}, ${this.settingsLabelTextSelector}`; } - setButtonSettings() { + /** Bookmark import code */ + get defaultRetrySettings() { + return { + maxAttempts: this.getFeatureSetting('downloadRetryLimit') ?? Infinity, + interval: this.getFeatureSetting('downloadRetryInterval') ?? 1000, + }; + } + + async downloadData() { + // Run with retry forever until the download link is available, + // Android is the one that timesout anyway and closes the whole tab if this doesn't complete + + const exportId = window.location.pathname + .split('/') + .filter((segment) => segment) + .pop(); + + if (!exportId) { + this.postBookmarkImportMessage('actionCompleted', { + result: new ErrorResponse({ + actionID: 'download-data', + message: 'User id or export id not found', + }), + }); + return; + } + + const downloadLinkSelector = this.bookmarkImportSelectorSettings.downloadLink ?? `a[href*="&i=0&user="]`; + const downloadButton = /** @type {HTMLAnchorElement|null} */ ( + await this.runWithRetry(() => document.querySelector(downloadLinkSelector), 5, 1000, 'linear') + ); + if (downloadButton == null) { + // If there was no download link, it was likely a 404 + // so we reload the page to try again + window.location.reload(); + } + + downloadButton?.click(); + } + + /** + * Here we ignore the action and return a default retry config + * as for now the retry doesn't need to be per action. + */ + retryConfigFor(_) { + const { interval, maxAttempts } = this.defaultRetrySettings; + return { + interval: { ms: interval }, + maxAttempts, + }; + } + + postBookmarkImportMessage(name, data) { + globalThis.ddgBookmarkImport?.postMessage( + JSON.stringify({ + name, + data, + }), + ); + } + + patchMessagingAndProcessAction(action) { + // Ideally we should be usuing standard messaging in Android, but we are not ready yet + // So just patching the notify method to post a message to the Android side + this.messaging.notify = this.postBookmarkImportMessage.bind(this); + return this.processActionAndNotify(action, {}); + } + + async handleBookmarkImportPath(pathname) { + if (pathname === '/' && !this.#isBookmarkModalVisible) { + for (const action of this.bookmarkImportActionSettings) { + await this.patchMessagingAndProcessAction(action); + } + + // Parse the export id from the page and then navigate to the 'manage' page + const exportId = await this.getExportId(); + window.location.href = `${MANAGE_ARCHIVE_DEFAULT_BASE}/${exportId}`; + } else if (pathname.startsWith(MANAGE_ARCHIVE_DEFAULT_BASE)) { + // If we're on the 'manage' page, we can download the data + await this.downloadData(); + } else { + // Unhandled path, we bail out + } + } + + setPasswordImportSettings() { this.#exportButtonSettings = this.getFeatureSetting('exportButton'); this.#signInButtonSettings = this.getFeatureSetting('signInButton'); this.#settingsButtonSettings = this.getFeatureSetting('settingsButton'); + this.#exportConfirmButtonSettings = this.getFeatureSetting('exportConfirmButton'); + } + + findExportId() { + const panels = document.querySelectorAll(this.bookmarkImportSelectorSettings.tabPanel); + const exportPanel = panels[panels.length - 1]; + const dataArchiveIdSelector = this.bookmarkImportSelectorSettings.dataArchiveId ?? `div[data-archive-id]`; + return exportPanel.querySelector(dataArchiveIdSelector)?.getAttribute('data-archive-id'); + } + + async getExportId() { + const { maxAttempts, interval } = this.defaultRetrySettings; + return await this.runWithRetry(() => this.findExportId(), maxAttempts, interval, 'linear'); + } + + urlChanged() { + this.handleLocation(window.location); } init() { - this.setButtonSettings(); - - const handlePath = this.handlePath.bind(this); - const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', { - async apply(target, thisArg, args) { - const path = args[1] === '' ? args[2].split('?')[0] : args[1]; - await handlePath(path); - return DDGReflect.apply(target, thisArg, args); - }, - }); - historyMethodProxy.overload(); - // listen for popstate events in order to run on back/forward navigations - window.addEventListener('popstate', async () => { - const path = window.location.pathname; - await handlePath(path); - }); + if (isBeingFramed()) { + return; + } + + if (this.getFeatureSetting('settingsButton')) { + this.setPasswordImportSettings(); + } + const handleLocation = this.handleLocation.bind(this); this.#domLoaded = new Promise((resolve) => { if (document.readyState !== 'loading') { @@ -505,8 +716,7 @@ export default class AutofillPasswordImport extends ContentFeature { async () => { // @ts-expect-error - caller doesn't expect a value here resolve(); - const path = window.location.pathname; - await handlePath(path); + await handleLocation(window.location); }, { once: true }, ); diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index fca6012125..b94172bfa7 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -1,16 +1,23 @@ import ContentFeature from '../content-feature'; -import { getJsPerformanceMetrics } from './breakage-reporting/utils.js'; +import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; export default class BreakageReporting extends ContentFeature { init() { - this.messaging.subscribe('getBreakageReportValues', () => { + const isExpandedPerformanceMetricsEnabled = this.getFeatureSettingEnabled('expandedPerformanceMetrics', 'enabled'); + this.messaging.subscribe('getBreakageReportValues', async () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; - - this.messaging.notify('breakageReportResult', { + const result = { jsPerformance, referrer, - }); + }; + if (isExpandedPerformanceMetricsEnabled) { + const expandedPerformanceMetrics = await getExpandedPerformanceMetrics(); + if (expandedPerformanceMetrics.success) { + result.expandedPerformanceMetrics = expandedPerformanceMetrics.metrics; + } + } + this.messaging.notify('breakageReportResult', result); }); } } diff --git a/injected/src/features/breakage-reporting/utils.js b/injected/src/features/breakage-reporting/utils.js index e1e0776da8..aa7f9e66a7 100644 --- a/injected/src/features/breakage-reporting/utils.js +++ b/injected/src/features/breakage-reporting/utils.js @@ -6,3 +6,124 @@ export function getJsPerformanceMetrics() { const firstPaint = paintResources.find((entry) => entry.name === 'first-contentful-paint'); return firstPaint ? [firstPaint.startTime] : []; } + +/** @typedef {{error: string, success: false}} ErrorObject */ +/** @typedef {{success: true, metrics: any}} PerformanceMetricsResponse */ + +/** + * Convenience function to return an error object + * @param {string} errorMessage + * @returns {ErrorObject} + */ +function returnError(errorMessage) { + return { error: errorMessage, success: false }; +} + +/** + * @returns {Promise} + */ +function waitForLCP(timeoutMs = 500) { + return new Promise((resolve) => { + // eslint-disable-next-line prefer-const + let timeoutId; + // eslint-disable-next-line prefer-const + let observer; + + const cleanup = () => { + if (observer) observer.disconnect(); + if (timeoutId) clearTimeout(timeoutId); + }; + + // Set timeout + timeoutId = setTimeout(() => { + cleanup(); + resolve(null); // Resolve with null instead of hanging + }, timeoutMs); + + // Try to get existing LCP + observer = new PerformanceObserver((list) => { + const entries = list.getEntries(); + const lastEntry = entries[entries.length - 1]; + if (lastEntry) { + cleanup(); + resolve(lastEntry.startTime); + } + }); + + try { + observer.observe({ type: 'largest-contentful-paint', buffered: true }); + } catch (error) { + // Handle browser compatibility issues + cleanup(); + resolve(null); + } + }); +} + +/** + * Get the expanded performance metrics + * @returns {Promise} + */ +export async function getExpandedPerformanceMetrics() { + try { + if (document.readyState !== 'complete') { + return returnError('Document not ready'); + } + + const navigation = /** @type {PerformanceNavigationTiming} */ (performance.getEntriesByType('navigation')[0]); + const paint = performance.getEntriesByType('paint'); + const resources = /** @type {PerformanceResourceTiming[]} */ (performance.getEntriesByType('resource')); + + // Find FCP + const fcp = paint.find((p) => p.name === 'first-contentful-paint'); + + // Get largest contentful paint if available + let largestContentfulPaint = null; + if (PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint')) { + largestContentfulPaint = await waitForLCP(); + } + + // Calculate total resource sizes + const totalResourceSize = resources.reduce((sum, r) => sum + (r.transferSize || 0), 0); + + if (navigation) { + return { + success: true, + metrics: { + // Core timing metrics (in milliseconds) + loadComplete: navigation.loadEventEnd - navigation.fetchStart, + domComplete: navigation.domComplete - navigation.fetchStart, + domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart, + domInteractive: navigation.domInteractive - navigation.fetchStart, + + // Paint metrics + firstContentfulPaint: fcp ? fcp.startTime : null, + largestContentfulPaint, + + // Network metrics + timeToFirstByte: navigation.responseStart - navigation.fetchStart, + responseTime: navigation.responseEnd - navigation.responseStart, + serverTime: navigation.responseStart - navigation.requestStart, + + // Size metrics (in octets) + transferSize: navigation.transferSize, + encodedBodySize: navigation.encodedBodySize, + decodedBodySize: navigation.decodedBodySize, + + // Resource metrics + resourceCount: resources.length, + totalResourcesSize: totalResourceSize, + + // Additional metadata + protocol: navigation.nextHopProtocol, + redirectCount: navigation.redirectCount, + navigationType: navigation.type, + }, + }; + } + + return returnError('No navigation timing found'); + } catch (e) { + return returnError('JavaScript execution error: ' + e.message); + } +} diff --git a/injected/src/features/broker-protection.js b/injected/src/features/broker-protection.js index 0f32586b79..05294dc88e 100644 --- a/injected/src/features/broker-protection.js +++ b/injected/src/features/broker-protection.js @@ -3,50 +3,46 @@ import { execute } from './broker-protection/execute.js'; import { retry } from '../timer-utils.js'; import { ErrorResponse } from './broker-protection/types.js'; -/** - * @typedef {import("./broker-protection/types.js").ActionResponse} ActionResponse - */ -export default class BrokerProtection extends ContentFeature { - init() { - this.messaging.subscribe('onActionReceived', async (/** @type {any} */ params) => { - try { - const action = params.state.action; - const data = params.state.data; - - if (!action) { - return this.messaging.notify('actionError', { error: 'No action found.' }); - } +export class ActionExecutorBase extends ContentFeature { + /** + * @param {any} action + * @param {Record} data + */ + async processActionAndNotify(action, data) { + try { + if (!action) { + return this.messaging.notify('actionError', { error: 'No action found.' }); + } - const { results, exceptions } = await this.exec(action, data); + const { results, exceptions } = await this.exec(action, data); - if (results) { - // there might only be a single result. - const parent = results[0]; - const errors = results.filter((x) => 'error' in x); + if (results) { + // there might only be a single result. + const parent = results[0]; + const errors = results.filter((x) => 'error' in x); - // if there are no secondary actions, or just no errors in general, just report the parent action - if (results.length === 1 || errors.length === 0) { - return this.messaging.notify('actionCompleted', { result: parent }); - } + // if there are no secondary actions, or just no errors in general, just report the parent action + if (results.length === 1 || errors.length === 0) { + return this.messaging.notify('actionCompleted', { result: parent }); + } - // here we must have secondary actions that failed. - // so we want to create an error response with the parent ID, but with the errors messages from - // the children - const joinedErrors = errors.map((x) => x.error.message).join(', '); - const response = new ErrorResponse({ - actionID: action.id, - message: 'Secondary actions failed: ' + joinedErrors, - }); + // here we must have secondary actions that failed. + // so we want to create an error response with the parent ID, but with the errors messages from + // the children + const joinedErrors = errors.map((x) => x.error.message).join(', '); + const response = new ErrorResponse({ + actionID: action.id, + message: 'Secondary actions failed: ' + joinedErrors, + }); - return this.messaging.notify('actionCompleted', { result: response }); - } else { - return this.messaging.notify('actionError', { error: 'No response found, exceptions: ' + exceptions.join(', ') }); - } - } catch (e) { - console.log('unhandled exception: ', e); - this.messaging.notify('actionError', { error: e.toString() }); + return this.messaging.notify('actionCompleted', { result: response }); + } else { + return this.messaging.notify('actionError', { error: 'No response found, exceptions: ' + exceptions.join(', ') }); } - }); + } catch (e) { + this.log.error('unhandled exception: ', e); + return this.messaging.notify('actionError', { error: e.toString() }); + } } /** @@ -58,7 +54,7 @@ export default class BrokerProtection extends ContentFeature { */ async exec(action, data) { const retryConfig = this.retryConfigFor(action); - const { result, exceptions } = await retry(() => execute(action, data), retryConfig); + const { result, exceptions } = await retry(() => execute(action, data, document), retryConfig); if (result) { if ('success' in result && Array.isArray(result.success.next)) { @@ -78,6 +74,25 @@ export default class BrokerProtection extends ContentFeature { return { results: [], exceptions }; } + /** + * @returns {any} + */ + retryConfigFor(action) { + this.log.error('unimplemented method: retryConfigFor:', action); + } +} + +/** + * @typedef {import("./broker-protection/types.js").ActionResponse} ActionResponse + */ +export default class BrokerProtection extends ActionExecutorBase { + init() { + this.messaging.subscribe('onActionReceived', async (/** @type {any} */ params) => { + const { action, data } = params.state; + return await this.processActionAndNotify(action, data); + }); + } + /** * Define default retry configurations for certain actions * @@ -100,9 +115,9 @@ export default class BrokerProtection extends ContentFeature { }; } /** - * Special case for when expectation contains a check for an element, retry it + * Special case for when expectation or condition contains a check for an element, retry it */ - if (!retryConfig && action.actionType === 'expectation') { + if (!retryConfig && (action.actionType === 'expectation' || action.actionType === 'condition')) { if (action.expectations.some((x) => x.type === 'element')) { return { interval: { ms: 1000 }, diff --git a/injected/src/features/broker-protection/actions/actions.js b/injected/src/features/broker-protection/actions/actions.js new file mode 100644 index 0000000000..9471631f37 --- /dev/null +++ b/injected/src/features/broker-protection/actions/actions.js @@ -0,0 +1,8 @@ +export { extract } from './extract.js'; +export { fillForm } from './fill-form.js'; +export { click } from './click.js'; +export { expectation } from './expectation.js'; +export { navigate } from './navigate.js'; +export { getCaptchaInfo, solveCaptcha } from '../captcha-services/captcha.service.js'; +export { condition } from './condition.js'; +export { scroll } from './scroll.js'; diff --git a/injected/src/features/broker-protection/actions/build-url-transforms.js b/injected/src/features/broker-protection/actions/build-url-transforms.js index f942a5523f..3ed9b0756c 100644 --- a/injected/src/features/broker-protection/actions/build-url-transforms.js +++ b/injected/src/features/broker-protection/actions/build-url-transforms.js @@ -61,7 +61,7 @@ const optionalTransforms = new Map([ ['defaultIfEmpty', (value, argument) => value || argument || ''], [ 'ageRange', - (value, argument, action) => { + (value, _, action) => { if (!action.ageRange) return value; const ageNumber = Number(value); // find matching age range @@ -142,7 +142,7 @@ export function processTemplateStringWithUserData(input, action, userData) { * Note: this regex covers both pathname + query params. * This is why we're handling both encoded and un-encoded. */ - return String(input).replace(/\$%7B(.+?)%7D|\$\{(.+?)}/g, (match, encodedValue, plainValue) => { + return String(input).replace(/\$%7B(.+?)%7D|\$\{(.+?)}/g, (_, encodedValue, plainValue) => { const comparison = encodedValue ?? plainValue; const [dataKey, ...transforms] = comparison.split(/\||%7C/); const data = userData[dataKey]; diff --git a/injected/src/features/broker-protection/actions/captcha.js b/injected/src/features/broker-protection/actions/captcha-deprecated.js similarity index 94% rename from injected/src/features/broker-protection/actions/captcha.js rename to injected/src/features/broker-protection/actions/captcha-deprecated.js index a065b2859f..68512ecc8b 100644 --- a/injected/src/features/broker-protection/actions/captcha.js +++ b/injected/src/features/broker-protection/actions/captcha-deprecated.js @@ -1,16 +1,20 @@ import { captchaCallback } from './captcha-callback.js'; -import { getElement } from '../utils.js'; +import { getElement } from '../utils/utils.js'; import { ErrorResponse, SuccessResponse } from '../types.js'; /** * Gets the captcha information to send to the backend * - * @param action + * @param {import('../types.js').PirAction} action * @param {Document | HTMLElement} root * @return {import('../types.js').ActionResponse} */ export function getCaptchaInfo(action, root = document) { const pageUrl = window.location.href; + if (!action.selector) { + return new ErrorResponse({ actionID: action.id, message: 'missing selector' }); + } + const captchaDiv = getElement(root, action.selector); // if 'captchaDiv' was missing, cannot continue diff --git a/injected/src/features/broker-protection/actions/click.js b/injected/src/features/broker-protection/actions/click.js index 7beed13f0f..f3d824c353 100644 --- a/injected/src/features/broker-protection/actions/click.js +++ b/injected/src/features/broker-protection/actions/click.js @@ -1,4 +1,4 @@ -import { getElements } from '../utils.js'; +import { getElements } from '../utils/utils.js'; import { ErrorResponse, SuccessResponse } from '../types.js'; import { extractProfiles } from './extract.js'; import { processTemplateStringWithUserData } from './build-url-transforms.js'; @@ -51,6 +51,10 @@ export function click(action, userData, root = document) { const elements = getElements(rootElement, element.selector); if (!elements?.length) { + if (element.failSilently) { + return new SuccessResponse({ actionID: action.id, actionType: action.actionType, response: null }); + } + return new ErrorResponse({ actionID: action.id, message: `could not find element to click with selector '${element.selector}'!`, @@ -63,7 +67,7 @@ export function click(action, userData, root = document) { const elem = elements[i]; if ('disabled' in elem) { - if (elem.disabled) { + if (elem.disabled && !element.failSilently) { return new ErrorResponse({ actionID: action.id, message: `could not click disabled element ${element.selector}'!` }); } } diff --git a/injected/src/features/broker-protection/actions/condition.js b/injected/src/features/broker-protection/actions/condition.js new file mode 100644 index 0000000000..4750d43646 --- /dev/null +++ b/injected/src/features/broker-protection/actions/condition.js @@ -0,0 +1,39 @@ +import { ErrorResponse, SuccessResponse } from '../types.js'; +import { expectMany } from '../utils/expectations.js'; + +/** + * @param {Record} action + * @param {Document} root + * @return {import('../types.js').ActionResponse} + */ +export function condition(action, root = document) { + const results = expectMany(action.expectations, root); + + // filter out good results + silent failures, leaving only fatal errors + const errors = results + .filter((x, index) => { + if (x.result === true) return false; + if (action.expectations[index].failSilently) return false; + return true; + }) + .map((x) => { + return 'error' in x ? x.error : 'unknown error'; + }); + + if (errors.length > 0) { + return new ErrorResponse({ actionID: action.id, message: errors.join(', ') }); + } + + // only return actions if every expectation was met (these actions will be executed by the native clients) + const returnActions = results.every((x) => x.result === true); + + if (action.actions?.length && returnActions) { + return new SuccessResponse({ + actionID: action.id, + actionType: action.actionType, + response: { actions: action.actions }, + }); + } + + return new SuccessResponse({ actionID: action.id, actionType: action.actionType, response: { actions: [] } }); +} diff --git a/injected/src/features/broker-protection/actions/expectation.js b/injected/src/features/broker-protection/actions/expectation.js index 0af3a71bb4..2686da8748 100644 --- a/injected/src/features/broker-protection/actions/expectation.js +++ b/injected/src/features/broker-protection/actions/expectation.js @@ -1,5 +1,5 @@ -import { getElement } from '../utils.js'; import { ErrorResponse, SuccessResponse } from '../types.js'; +import { expectMany } from '../utils/expectations.js'; /** * @param {Record} action @@ -38,125 +38,3 @@ export function expectation(action, root = document) { return new SuccessResponse({ actionID: action.id, actionType: action.actionType, response: null }); } - -/** - * Return a true/false result for every expectation - * - * @param {import("../types").Expectation[]} expectations - * @param {Document | HTMLElement} root - * @return {import("../types").BooleanResult[]} - */ -export function expectMany(expectations, root) { - return expectations.map((expectation) => { - switch (expectation.type) { - case 'element': - return elementExpectation(expectation, root); - case 'text': - return textExpectation(expectation, root); - case 'url': - return urlExpectation(expectation); - default: { - return { - result: false, - error: `unknown expectation type: ${expectation.type}`, - }; - } - } - }); -} - -/** - * Verify that an element exists. If the `.parent` property exists, - * scroll it into view first - * - * @param {import("../types").Expectation} expectation - * @param {Document | HTMLElement} root - * @return {import("../types").BooleanResult} - */ -export function elementExpectation(expectation, root) { - if (expectation.parent) { - const parent = getElement(root, expectation.parent); - if (!parent) { - return { - result: false, - error: `parent element not found with selector: ${expectation.parent}`, - }; - } - parent.scrollIntoView(); - } - - const elementExists = getElement(root, expectation.selector) !== null; - - if (!elementExists) { - return { - result: false, - error: `element with selector ${expectation.selector} not found.`, - }; - } - return { result: true }; -} - -/** - * Check that an element includes a given text string - * - * @param {import("../types").Expectation} expectation - * @param {Document | HTMLElement} root - * @return {import("../types").BooleanResult} - */ -export function textExpectation(expectation, root) { - // get the target element first - const elem = getElement(root, expectation.selector); - if (!elem) { - return { - result: false, - error: `element with selector ${expectation.selector} not found.`, - }; - } - - // todo: remove once we have stronger types - if (!expectation.expect) { - return { - result: false, - error: "missing key: 'expect'", - }; - } - - // todo: is this too strict a match? we may also want to try innerText - const textExists = Boolean(elem?.textContent?.includes(expectation.expect)); - - if (!textExists) { - return { - result: false, - error: `expected element with selector ${expectation.selector} to have text: ${expectation.expect}, but it didn't`, - }; - } - - return { result: true }; -} - -/** - * Check that the current URL includes a given string - * - * @param {import("../types").Expectation} expectation - * @return {import("../types").BooleanResult} - */ -export function urlExpectation(expectation) { - const url = window.location.href; - - // todo: remove once we have stronger types - if (!expectation.expect) { - return { - result: false, - error: "missing key: 'expect'", - }; - } - - if (!url.includes(expectation.expect)) { - return { - result: false, - error: `expected URL to include ${expectation.expect}, but it didn't`, - }; - } - - return { result: true }; -} diff --git a/injected/src/features/broker-protection/actions/extract.js b/injected/src/features/broker-protection/actions/extract.js index 39df1e95c1..e6013145e9 100644 --- a/injected/src/features/broker-protection/actions/extract.js +++ b/injected/src/features/broker-protection/actions/extract.js @@ -1,4 +1,4 @@ -import { cleanArray, getElement, getElementMatches, getElements, sortAddressesByStateAndCity } from '../utils.js'; // Assuming you have imported the address comparison function +import { cleanArray, getElement, getElementMatches, getElements, sortAddressesByStateAndCity } from '../utils/utils.js'; // Assuming you have imported the address comparison function import { ErrorResponse, ProfileResult, SuccessResponse } from '../types.js'; import { isSameAge } from '../comparisons/is-same-age.js'; import { isSameName } from '../comparisons/is-same-name.js'; @@ -91,7 +91,7 @@ export function extractProfiles(action, userData, root = document) { return { results: profilesElementList.map((element) => { - const elementFactory = (key, value) => { + const elementFactory = (_, value) => { return value?.findElements ? cleanArray(getElements(element, value.selector)) : cleanArray(getElement(element, value.selector) || getElementMatches(element, value.selector)); @@ -160,15 +160,26 @@ export function createProfile(elementFactory, extractData) { } /** - * @param {{innerText: string}[]} elements + * @param {({ textContent: string } | { innerText: string })[]} elements * @param {string} key * @param {ExtractProfileProperty} extractField * @return {string[]} */ -function stringValuesFromElements(elements, key, extractField) { +export function stringValuesFromElements(elements, key, extractField) { return elements.map((element) => { - // todo: should we use textContent here? - let elementValue = rules[key]?.(element) ?? element?.innerText ?? null; + let elementValue; + + if ('innerText' in element) { + elementValue = rules[key]?.(element) ?? element?.innerText ?? null; + + // In instances where we use the text() node test, innerText will be undefined, and we fall back to textContent + } else if ('textContent' in element) { + elementValue = rules[key]?.(element) ?? element?.textContent ?? null; + } + + if (!elementValue) { + return elementValue; + } if (extractField?.afterText) { elementValue = elementValue?.split(extractField.afterText)[1]?.trim() || elementValue; diff --git a/injected/src/features/broker-protection/actions/fill-form.js b/injected/src/features/broker-protection/actions/fill-form.js index e1591c16c5..24f04c904f 100644 --- a/injected/src/features/broker-protection/actions/fill-form.js +++ b/injected/src/features/broker-protection/actions/fill-form.js @@ -1,4 +1,4 @@ -import { getElement, generateRandomInt } from '../utils.js'; +import { getElement, generateRandomInt } from '../utils/utils.js'; import { ErrorResponse, SuccessResponse } from '../types.js'; import { generatePhoneNumber, generateZipCode, generateStreetAddress } from './generators.js'; diff --git a/injected/src/features/broker-protection/actions/generators.js b/injected/src/features/broker-protection/actions/generators.js index f6624420b0..66dae2451f 100644 --- a/injected/src/features/broker-protection/actions/generators.js +++ b/injected/src/features/broker-protection/actions/generators.js @@ -1,4 +1,4 @@ -import { generateRandomInt } from '../utils.js'; +import { generateRandomInt } from '../utils/utils.js'; export function generatePhoneNumber() { /** diff --git a/injected/src/features/broker-protection/actions/navigate.js b/injected/src/features/broker-protection/actions/navigate.js new file mode 100644 index 0000000000..4a16d5da2d --- /dev/null +++ b/injected/src/features/broker-protection/actions/navigate.js @@ -0,0 +1,31 @@ +import { getSupportingCodeToInject } from '../captcha-services/captcha.service'; +import { ErrorResponse, SuccessResponse } from '../types'; +import { buildUrl } from './build-url'; + +/** + * This builds the proper URL given the URL template and userData. + * Also, if the action requires a captcha handler, it will inject the necessary code. + * + * @param {import('../types.js').PirAction} action + * @param {Record} userData + * @return {import('../types.js').ActionResponse} + */ +export function navigate(action, userData) { + const { id: actionID, actionType } = action; + const urlResult = buildUrl(action, userData); + if (urlResult instanceof ErrorResponse) { + return urlResult; + } + + const codeToInjectResponse = getSupportingCodeToInject(action); + if (codeToInjectResponse instanceof ErrorResponse) { + return codeToInjectResponse; + } + + const response = { + ...urlResult.success.response, + ...codeToInjectResponse.success.response, + }; + + return new SuccessResponse({ actionID, actionType, response }); +} diff --git a/injected/src/features/broker-protection/actions/scroll.js b/injected/src/features/broker-protection/actions/scroll.js new file mode 100644 index 0000000000..f72182ceae --- /dev/null +++ b/injected/src/features/broker-protection/actions/scroll.js @@ -0,0 +1,15 @@ +import { ErrorResponse, SuccessResponse } from '../types'; +import { getElement } from '../utils/utils'; + +/** + * @param {Record} action + * @param {Document} root + * @return {import('../types.js').ActionResponse} + */ +// eslint-disable-next-line no-redeclare +export function scroll(action, root = document) { + const element = getElement(root, action.selector); + if (!element) return new ErrorResponse({ actionID: action.id, message: 'missing element' }); + element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + return new SuccessResponse({ actionID: action.id, actionType: action.actionType, response: null }); +} diff --git a/injected/src/features/broker-protection/captcha-services/captcha.service.js b/injected/src/features/broker-protection/captcha-services/captcha.service.js new file mode 100644 index 0000000000..6200f621d2 --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/captcha.service.js @@ -0,0 +1,138 @@ +import { getElement } from '../utils/utils.js'; +import { removeUrlQueryParams } from '../utils/url.js'; +import { ErrorResponse, PirError, SuccessResponse } from '../types'; +import { getCaptchaProvider, getCaptchaSolveProvider } from './get-captcha-provider'; +import { captchaFactory } from './providers/registry.js'; +import { getCaptchaInfo as getCaptchaInfoDeprecated, solveCaptcha as solveCaptchaDeprecated } from '../actions/captcha-deprecated'; + +/** + * + * @param {Document | HTMLElement} root + * @param {import('../types.js').PirAction['selector']} [selector] + * @returns {HTMLElement | import('../types.js').PirError} + */ +const getCaptchaContainer = (root, selector) => { + if (!selector) { + return PirError.create('missing selector'); + } + + const captchaContainer = getElement(root, selector); + if (!captchaContainer) { + return PirError.create(`could not find captcha container with selector ${selector}`); + } + + return captchaContainer; +}; + +/** + * Returns the supporting code to inject for the given captcha type + * + * @param {import('../types.js').PirAction} action + * @return {import('../types.js').ActionResponse} + */ +export function getSupportingCodeToInject(action) { + const { id: actionID, actionType, injectCaptchaHandler: captchaType } = action; + const createError = ErrorResponse.generateErrorResponseFunction({ actionID, context: 'getSupportingCodeToInject' }); + if (!captchaType) { + // ensures backward compatibility with old actions + return SuccessResponse.create({ actionID, actionType, response: {} }); + } + + const captchaProvider = captchaFactory.getProviderByType(captchaType); + if (!captchaProvider) { + return createError(`could not find captchaProvider with type ${captchaType}`); + } + + return SuccessResponse.create({ actionID, actionType, response: { code: captchaProvider.getSupportingCodeToInject() } }); +} + +/** + * Gets the captcha information to send to the backend + * + * @param {import('../types.js').PirAction} action + * @param {Document | HTMLElement} root + * @return {Promise} + */ +export async function getCaptchaInfo(action, root = document) { + const { id: actionID, actionType, captchaType, selector } = action; + if (!captchaType) { + // ensures backward compatibility with old actions + return getCaptchaInfoDeprecated(action, root); + } + + const createError = ErrorResponse.generateErrorResponseFunction({ actionID, context: `[getCaptchaInfo] captchaType: ${captchaType}` }); + + const captchaContainer = getCaptchaContainer(root, selector); + if (PirError.isError(captchaContainer)) { + return createError(captchaContainer.error.message); + } + + const captchaProvider = getCaptchaProvider(root, captchaContainer, captchaType); + if (PirError.isError(captchaProvider)) { + return createError(captchaProvider.error.message); + } + + const captchaIdentifier = await captchaProvider.getCaptchaIdentifier(captchaContainer); + if (!captchaIdentifier) { + return createError(`could not extract captcha identifier from the container with selector ${selector}`); + } + + if (PirError.isError(captchaIdentifier)) { + return createError(captchaIdentifier.error.message); + } + + const response = { + url: removeUrlQueryParams(window.location.href), // query params (which may include PII) + siteKey: captchaIdentifier, + type: captchaProvider.getType(), + }; + + return SuccessResponse.create({ actionID, actionType, response }); +} + +/** + * Takes the solved captcha token and injects it into the page to solve the captcha + * + * @param {import('../types.js').PirAction} action + * @param {string} token + * @param {Document} root + * @return {import('../types.js').ActionResponse} + */ +export function solveCaptcha(action, token, root = document) { + const { id: actionID, actionType, captchaType, selector } = action; + if (!captchaType) { + // ensures backward compatibility with old actions + return solveCaptchaDeprecated(action, token, root); + } + + const createError = ErrorResponse.generateErrorResponseFunction({ actionID, context: `[solveCaptcha] captchaType: ${captchaType}` }); + + const captchaContainer = getCaptchaContainer(root, selector); + if (PirError.isError(captchaContainer)) { + return createError(captchaContainer.error.message); + } + + const captchaSolveProvider = getCaptchaSolveProvider(captchaContainer, captchaType); + if (PirError.isError(captchaSolveProvider)) { + return createError(captchaSolveProvider.error.message); + } + + if (!captchaSolveProvider.canSolve(captchaContainer)) { + return createError('cannot solve captcha'); + } + + const tokenResponse = captchaSolveProvider.injectToken(captchaContainer, token); + if (PirError.isError(tokenResponse)) { + return createError(tokenResponse.error.message); + } + + if (!tokenResponse.response.injected) { + return createError('could not inject token'); + } + + return SuccessResponse.create({ + actionID, + actionType, + response: { callback: { eval: captchaSolveProvider.getSolveCallback(captchaContainer, token) } }, + }); +} diff --git a/injected/src/features/broker-protection/captcha-services/factory.js b/injected/src/features/broker-protection/captcha-services/factory.js new file mode 100644 index 0000000000..517226d727 --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/factory.js @@ -0,0 +1,53 @@ +/** + * Factory for captcha providers + */ +export class CaptchaFactory { + constructor() { + this.providers = new Map(); + } + + /** + * Register a captcha provider + * @param {import('./providers/provider.interface').CaptchaProvider} provider - The provider to register + */ + registerProvider(provider) { + this.providers.set(provider.getType(), provider); + } + + /** + * Get a provider by type + * @param {string} type - The provider type + * @returns {import('./providers/provider.interface').CaptchaProvider|null} + */ + getProviderByType(type) { + return this.providers.get(type) || null; + } + + /** + * Detect the captcha provider based on the element + * @param {Document | HTMLElement} root + * @param {HTMLElement} element - The element to check + * @returns {import('./providers/provider.interface').CaptchaProvider|null} + */ + detectProvider(root, element) { + return this._getAllProviders().find((provider) => provider.isSupportedForElement(root, element)) || null; + } + + /** + * Detect the captcha provider based on the root document + * @param {HTMLElement} element - The element to check + * @returns {import('./providers/provider.interface').CaptchaProvider|null} + */ + detectSolveProvider(element) { + return this._getAllProviders().find((provider) => provider.canSolve(element)) || null; + } + + /** + * Get all registered providers + * @private + * @returns {Array} + */ + _getAllProviders() { + return Array.from(this.providers.values()); + } +} diff --git a/injected/src/features/broker-protection/captcha-services/get-captcha-container.js b/injected/src/features/broker-protection/captcha-services/get-captcha-container.js new file mode 100644 index 0000000000..9a1e966105 --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/get-captcha-container.js @@ -0,0 +1,21 @@ +import { getElement } from '../utils/utils.js'; +import { PirError } from '../types'; + +/** + * + * @param {Document | HTMLElement} root + * @param {import('../types.js').PirAction['selector']} [selector] + * @returns {HTMLElement | PirError} + */ +export function getCaptchaContainer(root, selector) { + if (!selector) { + return PirError.create('missing selector'); + } + + const captchaContainer = getElement(root, selector); + if (!captchaContainer) { + return PirError.create(`could not find captcha container with selector ${selector}`); + } + + return captchaContainer; +} diff --git a/injected/src/features/broker-protection/captcha-services/get-captcha-provider.js b/injected/src/features/broker-protection/captcha-services/get-captcha-provider.js new file mode 100644 index 0000000000..af4dd5ca69 --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/get-captcha-provider.js @@ -0,0 +1,65 @@ +import { PirError } from '../types'; +import { captchaFactory } from './providers/registry'; + +/** + * Gets the captcha provider for the getCaptchaInfo action + * @param {Document | HTMLElement} root + * @param {HTMLElement} captchaContainer + * @param {string} captchaType + */ +export function getCaptchaProvider(root, captchaContainer, captchaType) { + const captchaProvider = captchaFactory.getProviderByType(captchaType); + if (!captchaProvider) { + return PirError.create(`[getCaptchaProvider] could not find captcha provider with type ${captchaType}`); + } + + if (captchaProvider.isSupportedForElement(root, captchaContainer)) { + return captchaProvider; + } + + const detectedProvider = captchaFactory.detectProvider(root, captchaContainer); + if (!detectedProvider) { + return PirError.create( + `[getCaptchaProvider] could not detect captcha provider for ${captchaType} captcha and element ${captchaContainer}`, + ); + } + + // TODO fire a pixel + // if the captcha provider type is different from the expected type, log a warning + console.warn( + `[getCaptchaProvider] mismatch between expected capctha type ${captchaType} and detected type ${detectedProvider.getType()}`, + ); + + return detectedProvider; +} + +/** + * Gets the captcha provider for the solveCaptcha action + * @param {HTMLElement} captchaContainer + * @param {string} captchaType + */ +export function getCaptchaSolveProvider(captchaContainer, captchaType) { + const captchaProvider = captchaFactory.getProviderByType(captchaType); + if (!captchaProvider) { + return PirError.create(`[getCaptchaSolveProvider] could not find captcha provider with type ${captchaType}`); + } + + if (captchaProvider.canSolve(captchaContainer)) { + return captchaProvider; + } + + const detectedProvider = captchaFactory.detectSolveProvider(captchaContainer); + if (!detectedProvider) { + return PirError.create( + `[getCaptchaSolveProvider] could not detect captcha provider for ${captchaType} captcha and element ${captchaContainer}`, + ); + } + + // TODO fire a pixel + // if the captcha provider type is different from the expected type, log a warning + console.warn( + `[getCaptchaSolveProvider] mismatch between expected captha type ${captchaType} and detected type ${detectedProvider.getType()}`, + ); + + return detectedProvider; +} diff --git a/injected/src/features/broker-protection/captcha-services/providers/cloudflare-turnstile.js b/injected/src/features/broker-protection/captcha-services/providers/cloudflare-turnstile.js new file mode 100644 index 0000000000..d868100f08 --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/providers/cloudflare-turnstile.js @@ -0,0 +1,136 @@ +import { getElementByTagName, getElementWithSrcStart } from '../../utils/utils'; +import { safeCallWithError } from '../../utils/safe-call'; +import { getAttributeValue } from '../utils/attribute'; +import { injectTokenIntoElement } from '../utils/token'; +import { stringifyFunction } from '../utils/stringify-function'; +import { PirError } from '../../types'; + +/** + * @typedef {Object} CloudFlareTurnstileProviderConfig + * @property {string} providerUrl - The captcha provider URL + * @property {string} responseElementName - The name of the captcha response element + */ + +/** + * @import { CaptchaProvider } from './provider.interface'; + * @implements {CaptchaProvider} + */ +export class CloudFlareTurnstileProvider { + /** + * @type {CloudFlareTurnstileProviderConfig} + */ + #config; + + constructor() { + this.#config = { + providerUrl: 'https://challenges.cloudflare.com/turnstile/v0', + responseElementName: 'cf-turnstile-response', + }; + } + + getType() { + return 'cloudFlareTurnstile'; + } + + /** + * @param {Document | HTMLElement} root + * @param {HTMLElement} _captchaContainerElement + * @returns {boolean} Whether the captcha is supported for the element + */ + isSupportedForElement(root, _captchaContainerElement) { + // Typically we look within captcha container for isSupportedForElement, but CloudFlare puts the iFrame into the shadow DOM, + // so we need to look at the script tags on the page instead + return !!this._getCaptchaScript(root); + } + + /** + * @param {HTMLElement} captchaContainerElement - The element containing the captcha + */ + getCaptchaIdentifier(captchaContainerElement) { + const sitekeyAttribute = 'data-sitekey'; + + return Promise.resolve( + safeCallWithError(() => getAttributeValue({ element: captchaContainerElement, attrName: sitekeyAttribute }), { + errorMessage: `[CloudFlareTurnstileProvider.getCaptchaIdentifier] could not extract site key from attribute: ${sitekeyAttribute}`, + }), + ); + } + + getSupportingCodeToInject() { + return null; + } + + /** + * @param {HTMLElement} captchaContainerElement - The element containing the captcha + * @returns {boolean} Whether the captcha can be solved + */ + canSolve(captchaContainerElement) { + const callbackAttribute = 'data-callback'; + + const hasCallback = safeCallWithError(() => getAttributeValue({ element: captchaContainerElement, attrName: callbackAttribute }), { + errorMessage: `[CloudFlareTurnstileProvider.canSolve] could not extract callback function name from attribute: ${callbackAttribute}`, + }); + + if (PirError.isError(hasCallback)) { + return false; + } + + const hasResponseElement = safeCallWithError(() => getElementByTagName(captchaContainerElement, this.#config.responseElementName), { + errorMessage: `[CloudFlareTurnstileProvider.canSolve] could not find response element: ${this.#config.responseElementName}`, + }); + + if (PirError.isError(hasResponseElement)) { + return false; + } + + return true; + } + + /** + * @param {HTMLElement} captchaContainerElement - The element containing the captcha + * @param {string} token - The solved captcha token + */ + injectToken(captchaContainerElement, token) { + return injectTokenIntoElement({ captchaContainerElement, elementName: this.#config.responseElementName, token }); + } + + /** + * @param {HTMLElement} captchaContainerElement - The element containing the captcha + * @param {string} token - The solved captcha token + */ + getSolveCallback(captchaContainerElement, token) { + const callbackAttribute = 'data-callback'; + + const callbackFunctionName = safeCallWithError( + () => getAttributeValue({ element: captchaContainerElement, attrName: callbackAttribute }), + { + errorMessage: `[CloudFlareTurnstileProvider.getSolveCallback] could not extract callback function name from attribute: ${callbackAttribute}`, + }, + ); + + if (PirError.isError(callbackFunctionName)) { + return callbackFunctionName; + } + + return stringifyFunction({ + /** + * @param {Object} args - The arguments passed to the function + * @param {string} args.callbackFunctionName - The callback function name + * @param {string} args.token - The solved captcha token + */ + functionBody: function cloudflareCaptchaCallback(args) { + window[args.callbackFunctionName](args.token); + }, + functionName: 'cloudflareCaptchaCallback', + args: { callbackFunctionName, token }, + }); + } + + /** + * @private + * @param {Document | HTMLElement} root - The root element to search in + */ + _getCaptchaScript(root) { + return getElementWithSrcStart(root, this.#config.providerUrl); + } +} diff --git a/injected/src/features/broker-protection/captcha-services/providers/hcaptcha.js b/injected/src/features/broker-protection/captcha-services/providers/hcaptcha.js new file mode 100644 index 0000000000..105a4b9ddf --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/providers/hcaptcha.js @@ -0,0 +1,58 @@ +import { PirError } from '../../types'; + +/** + * @import { CaptchaProvider } from './provider.interface'; + * @implements {CaptchaProvider} + */ +export class HCaptchaProvider { + getType() { + return 'hcaptcha'; + } + + /** + * @param {Document | HTMLElement} _root + * @param {HTMLElement} _captchaContainerElement - The element to check + */ + isSupportedForElement(_root, _captchaContainerElement) { + // TODO: Implement + return false; + } + + /** + * @param {HTMLElement} _captchaContainerElement - The element containing the captcha + */ + getCaptchaIdentifier(_captchaContainerElement) { + // TODO: Implement + return Promise.resolve(null); + } + + getSupportingCodeToInject() { + // TODO: Implement + return null; + } + + /** + * @param {HTMLElement} _captchaContainerElement - The element containing the captcha + */ + canSolve(_captchaContainerElement) { + // TODO: Implement + return false; + } + + /** + * @param {HTMLElement} _captchaContainerElement - The element containing the captcha + * @param {string} _token - The solved captcha token + */ + injectToken(_captchaContainerElement, _token) { + // TODO: Implement + return PirError.create('Not implemented'); + } + + /** + * @param {HTMLElement} _captchaContainerElement - The element containing the captcha + * @param {string} _token - The solved captcha token + */ + getSolveCallback(_captchaContainerElement, _token) { + return null; + } +} diff --git a/injected/src/features/broker-protection/captcha-services/providers/image.js b/injected/src/features/broker-protection/captcha-services/providers/image.js new file mode 100644 index 0000000000..09aab1e32a --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/providers/image.js @@ -0,0 +1,91 @@ +import { PirError } from '../../types'; +import { svgToBase64Jpg, imageToBase64 } from '../utils/image'; +import { injectTokenIntoElement } from '../utils/token'; +import { isElementType } from '../utils/element'; +import { stringifyFunction } from '../utils/stringify-function'; + +/** + * @import { CaptchaProvider } from './provider.interface'; + * @implements {CaptchaProvider} + */ +export class ImageProvider { + getType() { + return 'image'; + } + + /** + * @param {Document | HTMLElement} _root + * @param {HTMLElement} captchaImageElement - The captcha image element + */ + isSupportedForElement(_root, captchaImageElement) { + if (!captchaImageElement) { + return false; + } + + return isElementType(captchaImageElement, ['img', 'svg']); + } + + /** + * @param {HTMLElement} captchaImageElement - The captcha image element + */ + async getCaptchaIdentifier(captchaImageElement) { + if (isSVGElement(captchaImageElement)) { + return await svgToBase64Jpg(captchaImageElement); + } + + if (isImgElement(captchaImageElement)) { + return imageToBase64(captchaImageElement); + } + + return PirError.create( + `[ImageProvider.getCaptchaIdentifier] could not extract Base64 from image with tag name: ${captchaImageElement.tagName}`, + ); + } + + getSupportingCodeToInject() { + return null; + } + + /** + * @param {HTMLElement} captchaInputElement - The captcha input element + */ + canSolve(captchaInputElement) { + return isElementType(captchaInputElement, ['input', 'textarea']); + } + + /** + * @param {HTMLInputElement} captchaInputElement - The captcha input element + * @param {string} token - The solved captcha token + */ + injectToken(captchaInputElement, token) { + return injectTokenIntoElement({ captchaInputElement, token }); + } + + /** + * @param {HTMLElement} _captchaInputElement - The element containing the captcha + * @param {string} _token - The solved captcha token + */ + getSolveCallback(_captchaInputElement, _token) { + return stringifyFunction({ + functionBody: function callbackNoop() {}, + functionName: 'callbackNoop', + args: {}, + }); + } +} + +/** + * @param {HTMLElement} element + * @return {element is SVGElement} + */ +function isSVGElement(element) { + return isElementType(element, 'svg'); +} + +/** + * @param {HTMLElement} element + * @return {element is HTMLImageElement} + */ +function isImgElement(element) { + return isElementType(element, 'img'); +} diff --git a/injected/src/features/broker-protection/captcha-services/providers/provider.interface.js b/injected/src/features/broker-protection/captcha-services/providers/provider.interface.js new file mode 100644 index 0000000000..bc293d5bfc --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/providers/provider.interface.js @@ -0,0 +1,77 @@ +/** + * Base interface for captcha providers + * @import {PirError, PirResponse} from '../../types'; + * @interface + * @abstract + */ +export class CaptchaProvider { + /** + * Returns the unique identifier for this captcha provider + * @abstract + * @returns {string} The provider's unique type identifier + */ + getType() { + throw new Error('getType() missing implementation'); + } + + /** + * Checks if this provider supports the given element + * @abstract + * @param {Document | HTMLElement} _root + * @param {HTMLElement} _captchaContainerElement - The element containing the captcha + * @returns {boolean} True if this provider can handle the element + */ + isSupportedForElement(_root, _captchaContainerElement) { + throw new Error('isSupportedForElement() missing implementation'); + } + + /** + * Extracts the site key from the captcha container element + * @abstract + * @param {HTMLElement} _captchaContainerElement - The element containing the captcha + * @returns {Promise} The site key or null if not found + */ + getCaptchaIdentifier(_captchaContainerElement) { + return Promise.reject(new Error('getCaptchaIdentifier() missing implementation')); + } + + /** + * Returns code that should be injected before page load to support this captcha provider + * @returns {string|null} Code to inject or null if not needed + */ + getSupportingCodeToInject() { + return null; + } + + /** + * Checks if this provider can solve the captcha on the current page + * @abstract + * @param {HTMLElement} _captchaContainerElement - The element containing the captcha + * @returns {boolean} True if provider can solve captchas found in the document + */ + canSolve(_captchaContainerElement) { + throw new Error('canSolve() missing implementation'); + } + + /** + * Injects the solved token into the captcha on the page + * @abstract + * @param {HTMLElement} _captchaContainerElement - The element containing the captcha + * @param {string} _token - The solved captcha token + * @returns {PirResponse<{ injected: boolean }>} - Whether the token was injected + */ + injectToken(_captchaContainerElement, _token) { + throw new Error('injectToken() missing implementation'); + } + + /** + * Creates a callback function to execute when the captcha is solved + * @abstract + * @param {HTMLElement} _captchaContainerElement - The element containing the captcha + * @param {string} _token - The solved captcha token + * @returns {PirError|string|null} Callback function to execute when the captcha is solved + */ + getSolveCallback(_captchaContainerElement, _token) { + throw new Error('getSolveCallback() missing implementation'); + } +} diff --git a/injected/src/features/broker-protection/captcha-services/providers/recaptcha.js b/injected/src/features/broker-protection/captcha-services/providers/recaptcha.js new file mode 100644 index 0000000000..9b312ea217 --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/providers/recaptcha.js @@ -0,0 +1,96 @@ +import { getElementByTagName, getElementWithSrcStart } from '../../utils/utils'; +import { getSiteKeyFromSearchParam } from '../utils/sitekey'; +import { stringifyFunction } from '../utils/stringify-function'; +import { injectTokenIntoElement } from '../utils/token'; +// TODO move on the same folder level once we deprecate the existing captcha scripts +import { captchaCallback } from '../../actions/captcha-callback'; +import { safeCallWithError } from '../../utils/safe-call'; + +// define the config below to reuse it in the class +/** + * @typedef {Object} ReCaptchaProviderConfig + * @property {string} type - The captcha type + * @property {string} providerUrl - The captcha provider URL + * @property {string} responseElementName - The name of the captcha response element + */ + +/** + * @import { CaptchaProvider } from './provider.interface'; + * @implements {CaptchaProvider} + */ +export class ReCaptchaProvider { + /** + * @type {ReCaptchaProviderConfig} + */ + #config; + + /** + * @param {ReCaptchaProviderConfig} config + */ + constructor(config) { + this.#config = config; + } + + getType() { + return this.#config.type; + } + + /** + * @param {Document | HTMLElement} _root + * @param {HTMLElement} captchaContainerElement + */ + isSupportedForElement(_root, captchaContainerElement) { + return !!this._getCaptchaElement(captchaContainerElement); + } + + /** + * @param {HTMLElement} captchaContainerElement + */ + getCaptchaIdentifier(captchaContainerElement) { + return Promise.resolve( + safeCallWithError( + () => getSiteKeyFromSearchParam({ captchaElement: this._getCaptchaElement(captchaContainerElement), siteKeyAttrName: 'k' }), + { errorMessage: '[ReCaptchaProvider.getCaptchaIdentifier] could not extract site key' }, + ), + ); + } + + getSupportingCodeToInject() { + return null; + } + + /** + * @param {HTMLElement} _captchaContainerElement - The element containing the captcha + * @param {string} token + */ + getSolveCallback(_captchaContainerElement, token) { + return stringifyFunction({ + functionBody: captchaCallback, + functionName: 'captchaCallback', + args: { token }, + }); + } + + /** + * @param {HTMLElement} captchaContainerElement - The element containing the captcha + */ + canSolve(captchaContainerElement) { + return !!getElementByTagName(captchaContainerElement, this.#config.responseElementName); + } + + /** + * @param {HTMLElement} captchaContainerElement - The element containing the captcha + * @param {string} token + */ + injectToken(captchaContainerElement, token) { + return injectTokenIntoElement({ captchaContainerElement, elementName: this.#config.responseElementName, token }); + } + + /** + * @private + * @param {HTMLElement} captchaContainerElement + */ + _getCaptchaElement(captchaContainerElement) { + return getElementWithSrcStart(captchaContainerElement, this.#config.providerUrl); + } +} diff --git a/injected/src/features/broker-protection/captcha-services/providers/registry.js b/injected/src/features/broker-protection/captcha-services/providers/registry.js new file mode 100644 index 0000000000..cc389339bb --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/providers/registry.js @@ -0,0 +1,27 @@ +import { CaptchaFactory } from '../factory'; +import { ReCaptchaProvider } from './recaptcha'; +import { ImageProvider } from './image'; +import { CloudFlareTurnstileProvider } from './cloudflare-turnstile'; + +const captchaFactory = new CaptchaFactory(); + +captchaFactory.registerProvider( + new ReCaptchaProvider({ + type: 'recaptcha2', + providerUrl: 'https://www.google.com/recaptcha/api2', + responseElementName: 'g-recaptcha-response', + }), +); + +captchaFactory.registerProvider( + new ReCaptchaProvider({ + type: 'recaptchaEnterprise', + providerUrl: 'https://www.google.com/recaptcha/enterprise', + responseElementName: 'g-recaptcha-response', + }), +); + +captchaFactory.registerProvider(new CloudFlareTurnstileProvider()); +captchaFactory.registerProvider(new ImageProvider()); + +export { captchaFactory }; diff --git a/injected/src/features/broker-protection/captcha-services/utils/attribute.js b/injected/src/features/broker-protection/captcha-services/utils/attribute.js new file mode 100644 index 0000000000..ab06c70320 --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/utils/attribute.js @@ -0,0 +1,21 @@ +/** + * Extracts the value from an elment's attribute. + * + * @param {Object} options - The options object + * @param {HTMLElement } options.element - The element to extract the attribute from + * @param {string} options.attrName - The name of the attribute to extract + * @returns {string} The value of the requested attribute + * @throws {Error} If the element is not found or the attribute is missing + */ +export function getAttributeValue({ element, attrName }) { + if (!element) { + throw Error('[getAttributeValue] element parameter is required'); + } + + const attributeValue = element.getAttribute(attrName); + if (!attributeValue) { + throw Error(`[getAttributeValue] ${attrName} is not defined or has no value`); + } + + return attributeValue; +} diff --git a/injected/src/features/broker-protection/captcha-services/utils/element.js b/injected/src/features/broker-protection/captcha-services/utils/element.js new file mode 100644 index 0000000000..99e4b498cf --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/utils/element.js @@ -0,0 +1,13 @@ +/** + * + * @param {HTMLElement} element - The element to check + * @param {string | string[]} tag - The tag name(s) to check against + * @returns {boolean} - True if the element is of the specified tag name(s), false otherwise + */ +export function isElementType(element, tag) { + if (Array.isArray(tag)) { + return tag.some((t) => isElementType(element, t)); + } + + return element.tagName.toLowerCase() === tag.toLowerCase(); +} diff --git a/injected/src/features/broker-protection/captcha-services/utils/image.js b/injected/src/features/broker-protection/captcha-services/utils/image.js new file mode 100644 index 0000000000..eed0d39989 --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/utils/image.js @@ -0,0 +1,65 @@ +/** + * Converts an SVG element to a base64-encoded JPEG string. + * + * @param {SVGElement} svgElement - The SVG element to convert + * @param {string} [backgroundColor='white'] - The background color for the JPEG image + * @return {Promise} - A promise that resolves to the base64-encoded JPEG image + */ +export function svgToBase64Jpg(svgElement, backgroundColor = 'white') { + const svgString = new XMLSerializer().serializeToString(svgElement); + const svgDataUrl = 'data:image/svg+xml;base64,' + btoa(svgString); + + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Could not get 2D context from canvas')); + return; + } + + canvas.width = img.width; + canvas.height = img.height; + + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0); + + const jpgBase64 = canvas.toDataURL('image/jpeg'); + + resolve(jpgBase64); + }; + img.onerror = (error) => { + reject(error); + }; + + img.src = svgDataUrl; + }); +} + +/** + * Converts an image element to a base64-encoded JPEG string + * + * @param {HTMLImageElement} imageElement - The image element to convert. + * @return {string} - The base64-encoded JPEG string. + * @throws {Error} - Throws an error if the canvas context cannot be obtained. + */ +export function imageToBase64(imageElement) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw Error('[imageToBase64] Could not get 2D context from canvas'); + } + + canvas.width = imageElement.width; + canvas.height = imageElement.height; + + ctx.drawImage(imageElement, 0, 0, canvas.width, canvas.height); + + const base64String = canvas.toDataURL('image/jpeg'); // You can change the format if needed + + return base64String; +} diff --git a/injected/src/features/broker-protection/captcha-services/utils/sitekey.js b/injected/src/features/broker-protection/captcha-services/utils/sitekey.js new file mode 100644 index 0000000000..29d5b11cb5 --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/utils/sitekey.js @@ -0,0 +1,22 @@ +import { getUrlParameter } from '../../utils/url'; + +/** + * Extracts a site key from a captcha element's URL search parameters. + * + * @param {Object} options - The options object + * @param {HTMLElement | null} options.captchaElement - The DOM element containing the captcha + * @param {string} options.siteKeyAttrName - The name of the search parameter containing the site key + * @returns {string | null} The site key extracted from the captcha element's URL or null if not found + * @throws {Error} + */ +export function getSiteKeyFromSearchParam({ captchaElement, siteKeyAttrName }) { + if (!captchaElement) { + throw Error('[getSiteKeyFromSearchParam] could not find captcha'); + } + + if (!('src' in captchaElement)) { + throw Error('[getSiteKeyFromSearchParam] missing src attribute'); + } + + return getUrlParameter(String(captchaElement.src), siteKeyAttrName); +} diff --git a/injected/src/features/broker-protection/captcha-services/utils/stringify-function.js b/injected/src/features/broker-protection/captcha-services/utils/stringify-function.js new file mode 100644 index 0000000000..67a6b3381a --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/utils/stringify-function.js @@ -0,0 +1,18 @@ +import { safeCall } from '../../utils/safe-call'; + +/** + * @param {Object} params + * @param {string} params.functionName - The name of the function + * @param {Function} params.functionBody - The function to stringify + * @param {Object} params.args - The arguments to pass to the function + * @returns {string|null} - The stringified function + */ +export function stringifyFunction({ functionName, functionBody, args }) { + return safeCall( + () => `;(function(args) { + ${functionBody.toString()}; + ${functionName}(args); + })(${JSON.stringify(args)});`, + { errorMessage: `[stringifyFunction] error stringifying function ${functionName}` }, + ); +} diff --git a/injected/src/features/broker-protection/captcha-services/utils/token.js b/injected/src/features/broker-protection/captcha-services/utils/token.js new file mode 100644 index 0000000000..4360a08ff4 --- /dev/null +++ b/injected/src/features/broker-protection/captcha-services/utils/token.js @@ -0,0 +1,58 @@ +import { PirError, PirSuccess } from '../../types'; +import { safeCallWithError } from '../../utils/safe-call'; +import { getElementByTagName } from '../../utils/utils'; +import { isElementType } from './element'; + +/** + * Inject a token into a named element + * @import { PirResponse } from '../../types'; + * @param {object} params + * @param {HTMLElement} [params.captchaContainerElement] - The element containing the captcha + * @param {HTMLElement} [params.captchaInputElement] - The element containing the captcha (optional) + * @param {string} [params.elementName] - Name attribute of the element to inject into (optional) + * @param {string} params.token - The token to inject + * @returns {PirResponse<{ injected: boolean }>} - Whether the token was injected + */ +export function injectTokenIntoElement({ captchaContainerElement, captchaInputElement, elementName, token }) { + let element; + + if (captchaInputElement) { + element = captchaInputElement; + } else if (elementName) { + element = getElementByTagName(captchaContainerElement, elementName); + } else { + return PirError.create(`[injectTokenIntoElement] must pass in either captcha input element or element name`); + } + + if (!element) { + return PirError.create(`[injectTokenIntoElement] could not find element to inject token into`); + } + + return safeCallWithError( + () => { + if ((isInputElement(element) && ['text', 'hidden'].includes(element.type)) || isTextAreaElement(element)) { + element.value = token; + return PirSuccess.create({ injected: true }); + } else { + return PirError.create(`[injectTokenIntoElement] element is neither a text input or textarea`); + } + }, + { errorMessage: `[injectTokenIntoElement] error injecting token into element` }, + ); +} + +/** + * @param {HTMLElement} element + * @return {element is HTMLInputElement} + */ +function isInputElement(element) { + return isElementType(element, 'input'); +} + +/** + * @param {HTMLElement} element + * @return {element is HTMLTextAreaElement} + */ +function isTextAreaElement(element) { + return isElementType(element, 'textarea'); +} diff --git a/injected/src/features/broker-protection/comparisons/address.js b/injected/src/features/broker-protection/comparisons/address.js index d5ac3debc5..b703ff6e5e 100644 --- a/injected/src/features/broker-protection/comparisons/address.js +++ b/injected/src/features/broker-protection/comparisons/address.js @@ -1,5 +1,5 @@ import { states } from './constants.js'; -import { matchingPair } from '../utils.js'; +import { matchingPair } from '../utils/utils.js'; /** * @param {{city: string; state: string | null}[]} userAddresses diff --git a/injected/src/features/broker-protection/execute.js b/injected/src/features/broker-protection/execute.js index 78b139bd11..784cac1ade 100644 --- a/injected/src/features/broker-protection/execute.js +++ b/injected/src/features/broker-protection/execute.js @@ -1,16 +1,9 @@ -import { buildUrl } from './actions/build-url.js'; -import { extract } from './actions/extract.js'; -import { fillForm } from './actions/fill-form.js'; -import { getCaptchaInfo, solveCaptcha } from './actions/captcha.js'; -import { click } from './actions/click.js'; -import { expectation } from './actions/expectation.js'; -import { ErrorResponse } from './types.js'; +// eslint-disable-next-line no-redeclare +import { navigate, extract, click, scroll, expectation, fillForm, getCaptchaInfo, solveCaptcha, condition } from './actions/actions'; +import { ErrorResponse } from './types'; /** - * @param {object} action - * @param {string} action.id - * @param {string} [action.dataSource] - optional data source - * @param {"extract" | "fillForm" | "click" | "expectation" | "getCaptchaInfo" | "solveCaptcha" | "navigate"} action.actionType + * @param {import('./types.js').PirAction} action * @param {Record} inputData * @param {Document} [root] - optional root element * @return {Promise} @@ -19,7 +12,7 @@ export async function execute(action, inputData, root = document) { try { switch (action.actionType) { case 'navigate': - return buildUrl(action, data(action, inputData, 'userProfile')); + return navigate(action, data(action, inputData, 'userProfile')); case 'extract': return await extract(action, data(action, inputData, 'userProfile'), root); case 'click': @@ -29,9 +22,13 @@ export async function execute(action, inputData, root = document) { case 'fillForm': return fillForm(action, data(action, inputData, 'extractedProfile'), root); case 'getCaptchaInfo': - return getCaptchaInfo(action, root); + return await getCaptchaInfo(action, root); case 'solveCaptcha': return solveCaptcha(action, data(action, inputData, 'token'), root); + case 'condition': + return condition(action, root); + case 'scroll': + return scroll(action, root); default: { return new ErrorResponse({ actionID: action.id, diff --git a/injected/src/features/broker-protection/extractors/address.js b/injected/src/features/broker-protection/extractors/address.js index 5ba1390147..49934e4102 100644 --- a/injected/src/features/broker-protection/extractors/address.js +++ b/injected/src/features/broker-protection/extractors/address.js @@ -53,6 +53,9 @@ function getCityStateCombos(inputList) { // Strip out the zip code since we're only interested in city/state here. item = item.replace(/,?\s*\d{5}(-\d{4})?/, ''); + // Replace any commas at the end of the string that could confuse the city/state split. + item = item.replace(/,$/, ''); + if (item.includes(',')) { words = item.split(',').map((item) => item.trim()); } else { diff --git a/injected/src/features/broker-protection/extractors/profile-url.js b/injected/src/features/broker-protection/extractors/profile-url.js index 6fb8fbde51..cd80e843c4 100644 --- a/injected/src/features/broker-protection/extractors/profile-url.js +++ b/injected/src/features/broker-protection/extractors/profile-url.js @@ -1,6 +1,6 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars import { AsyncProfileTransform, Extractor } from '../types.js'; -import { hashObject } from '../utils.js'; +import { hashObject } from '../utils/utils.js'; /** * @implements {Extractor<{profileUrl: string; identifier: string} | null>} diff --git a/injected/src/features/broker-protection/types.js b/injected/src/features/broker-protection/types.js index 669bac9cce..58c4cbe762 100644 --- a/injected/src/features/broker-protection/types.js +++ b/injected/src/features/broker-protection/types.js @@ -4,6 +4,96 @@ * @typedef {{type: "element" | "text" | "url"; selector: string; parent?: string; expect?: string; failSilently?: boolean}} Expectation */ +/** + * @typedef {object} PirAction + * @property {string} id + * @property {"extract" | "fillForm" | "click" | "expectation" | "getCaptchaInfo" | "solveCaptcha" | "navigate" | "condition" | "scroll"} actionType + * @property {string} [selector] + * @property {string} [captchaType] + * @property {string} [injectCaptchaHandler] + * @property {string} [dataSource] + * @property {string} [url] + */ + +/** + * @template T + * @typedef {PirError | PirSuccess} PirResponse + */ + +export class PirError { + /** + * @param {object} params + * @param {boolean} params.success + * @param {object} params.error + * @param {string} params.error.message + */ + constructor(params) { + this.success = params.success; + this.error = params.error; + } + + /** + * @param {string} message + * @return {PirError} + * @static + * @memberof PirError + */ + static create(message) { + return new PirError({ success: false, error: { message } }); + } + + /** + * @param {object} error + * @return {error is PirError} + * @static + * @memberof PirError + */ + static isError(error) { + return error instanceof PirError && error.success === false; + } +} + +/** + * Represents a successful response + * @template T + */ +export class PirSuccess { + /** + * @param {object} params + * @param {boolean} params.success + * @param {T} params.response + */ + constructor(params) { + this.success = params.success; + this.response = params.response; + } + + /** + * @template T + * @param {T} response + * @return {PirSuccess} + * @static + * @memberof PirSuccess + */ + static create(response) { + return new PirSuccess({ success: true, response }); + } + + static createEmpty() { + return new PirSuccess({ success: true, response: null }); + } + + /** + * @param {object} params + * @return {params is PirSuccess} + * @static + * @memberof PirSuccess + */ + static isSuccess(params) { + return params instanceof PirSuccess && params.success === true; + } +} + /** * Represents an error */ @@ -16,23 +106,59 @@ export class ErrorResponse { constructor(params) { this.error = params; } + + /** + * @param {ActionResponse} response + * @return {response is ErrorResponse} + * @static + * @memberof ErrorResponse + */ + static isErrorResponse(response) { + return response instanceof ErrorResponse; + } + + /** + * @param {object} params + * @param {PirAction['id']} params.actionID + * @param {string} [params.context] + * @return {(message: string) => ErrorResponse} + * @static + * @memberof ErrorResponse + */ + static generateErrorResponseFunction({ actionID, context = '' }) { + return (message) => new ErrorResponse({ actionID, message: [context, message].filter(Boolean).join(': ') }); + } } +/** + * @typedef {object} SuccessResponseInterface + * @property {PirAction['id']} actionID + * @property {PirAction['actionType']} actionType + * @property {any} response + * @property {import("./actions/extract").Action[]} [next] + * @property {Record} [meta] - optional meta data + */ + /** * Represents success, `response` can contain other complex types */ export class SuccessResponse { /** - * @param {object} params - * @param {string} params.actionID - * @param {string} params.actionType - * @param {any} params.response - * @param {import("./actions/extract").Action[]} [params.next] - * @param {Record} [params.meta] - optional meta data + * @param {SuccessResponseInterface} params */ constructor(params) { this.success = params; } + + /** + * @param {SuccessResponseInterface} params + * @return {SuccessResponse} + * @static + * @memberof SuccessResponse + */ + static create(params) { + return new SuccessResponse(params); + } } /** @@ -76,12 +202,12 @@ export class ProfileResult { */ export class Extractor { /** - * @param {string[]} noneEmptyStringArray - * @param {import("./actions/extract").ExtractorParams} extractorParams + * @param {string[]} _noneEmptyStringArray + * @param {import("./actions/extract").ExtractorParams} _extractorParams * @return {JsonValue} */ - extract(noneEmptyStringArray, extractorParams) { + extract(_noneEmptyStringArray, _extractorParams) { throw new Error('must implement extract'); } } @@ -91,12 +217,12 @@ export class Extractor { */ export class AsyncProfileTransform { /** - * @param {Record} profile - The current profile value - * @param {Record} profileParams - the original action params from `action.profile` + * @param {Record} _profile - The current profile value + * @param {Record} _profileParams - the original action params from `action.profile` * @return {Promise>} */ - transform(profile, profileParams) { + transform(_profile, _profileParams) { throw new Error('must implement extract'); } } diff --git a/injected/src/features/broker-protection/utils/expectations.js b/injected/src/features/broker-protection/utils/expectations.js new file mode 100644 index 0000000000..7c70b83903 --- /dev/null +++ b/injected/src/features/broker-protection/utils/expectations.js @@ -0,0 +1,123 @@ +import { getElement } from './utils.js'; + +/** + * Return a true/false result for every expectation + * + * @param {import("../types").Expectation[]} expectations + * @param {Document | HTMLElement} root + * @return {import("../types").BooleanResult[]} + */ +export function expectMany(expectations, root) { + return expectations.map((expectation) => { + switch (expectation.type) { + case 'element': + return elementExpectation(expectation, root); + case 'text': + return textExpectation(expectation, root); + case 'url': + return urlExpectation(expectation); + default: { + return { + result: false, + error: `unknown expectation type: ${expectation.type}`, + }; + } + } + }); +} + +/** + * Verify that an element exists. If the `.parent` property exists, + * scroll it into view first + * + * @param {import("../types").Expectation} expectation + * @param {Document | HTMLElement} root + * @return {import("../types").BooleanResult} + */ +export function elementExpectation(expectation, root) { + if (expectation.parent) { + const parent = getElement(root, expectation.parent); + if (!parent) { + return { + result: false, + error: `parent element not found with selector: ${expectation.parent}`, + }; + } + parent.scrollIntoView(); + } + + const elementExists = getElement(root, expectation.selector) !== null; + + if (!elementExists) { + return { + result: false, + error: `element with selector ${expectation.selector} not found.`, + }; + } + return { result: true }; +} + +/** + * Check that an element includes a given text string + * + * @param {import("../types").Expectation} expectation + * @param {Document | HTMLElement} root + * @return {import("../types").BooleanResult} + */ +export function textExpectation(expectation, root) { + // get the target element first + const elem = getElement(root, expectation.selector); + if (!elem) { + return { + result: false, + error: `element with selector ${expectation.selector} not found.`, + }; + } + + // todo: remove once we have stronger types + if (!expectation.expect) { + return { + result: false, + error: "missing key: 'expect'", + }; + } + + // todo: is this too strict a match? we may also want to try innerText + const textExists = Boolean(elem?.textContent?.includes(expectation.expect)); + + if (!textExists) { + return { + result: false, + error: `expected element with selector ${expectation.selector} to have text: ${expectation.expect}, but it didn't`, + }; + } + + return { result: true }; +} + +/** + * Check that the current URL includes a given string + * + * @param {import("../types").Expectation} expectation + * @return {import("../types").BooleanResult} + */ +export function urlExpectation(expectation) { + const url = window.location.href; + + // todo: remove once we have stronger types + if (!expectation.expect) { + return { + result: false, + error: "missing key: 'expect'", + }; + } + + if (!url.includes(expectation.expect)) { + return { + result: false, + error: `expected URL to include ${expectation.expect}, but it didn't`, + }; + } + + return { result: true }; +} diff --git a/injected/src/features/broker-protection/utils/safe-call.js b/injected/src/features/broker-protection/utils/safe-call.js new file mode 100644 index 0000000000..7e457d4dbe --- /dev/null +++ b/injected/src/features/broker-protection/utils/safe-call.js @@ -0,0 +1,30 @@ +import { PirError } from '../types'; + +/** + * @template T + * @param {function(): T} fn - The function to call safely + * @param {object} [options] + * @param {string} [options.errorMessage] - The error message to log + * @returns {T|null} - The result of the function call, or null if an error occurred + */ +export function safeCall(fn, { errorMessage } = {}) { + try { + return fn(); + } catch (e) { + console.error(errorMessage ?? '[safeCall] Error:', e); + // TODO fire pixel + return null; + } +} + +/** + * @template T + * @param {function(): T} fn - The function to call safely + * @param {object} [options] + * @param {string} [options.errorMessage] - The error message to log + * @returns {T|PirError} - The result of the function call, or an error response if an error occurred + */ +export function safeCallWithError(fn, { errorMessage } = {}) { + const message = errorMessage ?? '[safeCallWithError] Error'; + return safeCall(fn, { errorMessage: message }) ?? PirError.create(message); +} diff --git a/injected/src/features/broker-protection/utils/url.js b/injected/src/features/broker-protection/utils/url.js new file mode 100644 index 0000000000..aa12ac2dae --- /dev/null +++ b/injected/src/features/broker-protection/utils/url.js @@ -0,0 +1,48 @@ +import { safeCall } from './safe-call'; + +/** + * Get a URL parameter from a URL string + * @param {string} url - The URL to parse + * @param {string} param - The parameter name + * @returns {string|null} - The parameter value or null + */ +export function getUrlParameter(url, param) { + if (!url || !param) { + return null; + } + + return safeCall(() => new URL(url).searchParams.get(param), { errorMessage: `[getUrlParameter] Error parsing URL: ${url}` }); +} + +/** + * Get a URL parameter from a URL hash fragment + * @param {string} url - The URL to parse + * @param {string} param - The parameter name + * @returns {string|null} - The parameter value or null + */ +export function getUrlHashParameter(url, param) { + if (!url || !param) { + return null; + } + + return safeCall( + () => { + const hash = new URL(url).hash.slice(1); + return new URLSearchParams(hash).get(param); + }, + { errorMessage: '[getUrlHashParameter] error' }, + ); +} + +/** + * Remove query parameters from a URL + * @param {string} url - The URL to clean + * @returns {string} - URL without query parameters + */ +export function removeUrlQueryParams(url) { + if (!url) { + return ''; + } + + return url.split('?')[0]; +} diff --git a/injected/src/features/broker-protection/utils.js b/injected/src/features/broker-protection/utils/utils.js similarity index 93% rename from injected/src/features/broker-protection/utils.js rename to injected/src/features/broker-protection/utils/utils.js index c299145254..8429232ead 100644 --- a/injected/src/features/broker-protection/utils.js +++ b/injected/src/features/broker-protection/utils/utils.js @@ -13,6 +13,27 @@ export function getElement(doc = document, selector) { return safeQuerySelector(doc, selector); } +/** + * Get an element by name. + * + * @param {Node} doc + * @param {string} name + * @return {HTMLElement | null} + */ +export function getElementByTagName(doc = document, name) { + return safeQuerySelector(doc, `[name="${name}"]`); +} + +/** + * Get an element by src. + * @param {Node} node + * @param {string} src + * @return {HTMLElement | null} + */ +export function getElementWithSrcStart(node = document, src) { + return safeQuerySelector(node, `[src^="${src}"]`); +} + /** * Get an array of elements * diff --git a/injected/src/features/click-to-load.js b/injected/src/features/click-to-load.js index 5d90430fed..2e1876f5a5 100644 --- a/injected/src/features/click-to-load.js +++ b/injected/src/features/click-to-load.js @@ -1778,6 +1778,8 @@ export default class ClickToLoad extends ContentFeature { /** @type {MessagingContext} */ #messagingContext; + listenForUpdateChanges = true; + async init(args) { /** * Bail if no messaging backend - this is a debugging feature to ensure we don't diff --git a/injected/src/features/cookie.js b/injected/src/features/cookie.js index 73647ca2ed..1fd9e36ff6 100644 --- a/injected/src/features/cookie.js +++ b/injected/src/features/cookie.js @@ -21,7 +21,7 @@ import { isTrackerOrigin } from '../trackers.js'; function initialShouldBlockTrackerCookie() { const injectName = import.meta.injectName; - return injectName === 'chrome' || injectName === 'firefox' || injectName === 'chrome-mv3' || injectName === 'windows'; + return injectName === 'firefox' || injectName === 'chrome-mv3' || injectName === 'windows'; } // Initial cookie policy pre init diff --git a/injected/src/features/duck-ai-data-clearing.js b/injected/src/features/duck-ai-data-clearing.js new file mode 100644 index 0000000000..7b38dab945 --- /dev/null +++ b/injected/src/features/duck-ai-data-clearing.js @@ -0,0 +1,97 @@ +import ContentFeature from '../content-feature.js'; + +/** + * This feature is responsible for clearing Duck.ai-related data when the `duckAiClearData` + * message is received. It clears the `savedAIChats` item from localStorage and the `chat-images` + * object store from IndexedDB, then sends a `duckAiClearDataCompleted` message if successful + * or a `duckAiClearDataFailed` message if either call is unsuccessful. + */ +export class DuckAiDataClearing extends ContentFeature { + init() { + this.messaging.subscribe('duckAiClearData', (_) => this.clearData()); + } + + async clearData() { + let success = true; + + const localStorageKeys = this.getFeatureSetting('chatsLocalStorageKeys'); + for (const localStorageKey of localStorageKeys) { + try { + this.clearSavedAIChats(localStorageKey); + } catch (error) { + success = false; + this.log.error('Error clearing saved chats:', error); + } + } + + const indexDbNameObjectStoreNamePairs = this.getFeatureSetting('chatImagesIndexDbNameObjectStoreNamePairs'); + for (const [indexDbName, objectStoreName] of indexDbNameObjectStoreNamePairs) { + try { + await this.clearChatImagesStore(indexDbName, objectStoreName); + } catch (error) { + success = false; + this.log.error('Error clearing saved chat images:', error); + } + } + + if (success) { + this.notify('duckAiClearDataCompleted'); + } else { + this.notify('duckAiClearDataFailed'); + } + } + + clearSavedAIChats(localStorageKey) { + this.log.info(`Clearing '${localStorageKey}'`); + window.localStorage.removeItem(localStorageKey); + } + + clearChatImagesStore(indexDbName, objectStoreName) { + this.log.info(`Clearing '${indexDbName}' object store`); + + return new Promise((resolve, reject) => { + const request = window.indexedDB.open(indexDbName); + request.onerror = (event) => { + this.log.error('Error opening IndexedDB:', event); + reject(event); + }; + request.onsuccess = (_) => { + const db = request.result; + if (!db) { + this.log.error('IndexedDB onsuccess but no db result'); + reject(new Error('No DB result')); + return; + } + + // Check if the object store exists + if (!db.objectStoreNames.contains(objectStoreName)) { + this.log.info(`'${objectStoreName}' object store does not exist, nothing to clear`); + db.close(); + resolve(null); + return; + } + + try { + const transaction = db.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + const clearRequest = objectStore.clear(); + clearRequest.onsuccess = () => { + db.close(); + resolve(null); + }; + clearRequest.onerror = (err) => { + this.log.error('Error clearing object store:', err); + db.close(); + reject(err); + }; + } catch (err) { + this.log.error('Exception during IndexedDB clearing:', err); + db.close(); + reject(err); + } + }; + }); + } +} + +export default DuckAiDataClearing; diff --git a/injected/src/features/duck-player-native.js b/injected/src/features/duck-player-native.js new file mode 100644 index 0000000000..d8b822916a --- /dev/null +++ b/injected/src/features/duck-player-native.js @@ -0,0 +1,134 @@ +import ContentFeature from '../content-feature.js'; +import { isBeingFramed } from '../utils.js'; +import { DuckPlayerNativeMessages } from './duckplayer-native/messages.js'; +import { setupDuckPlayerForNoCookie, setupDuckPlayerForSerp, setupDuckPlayerForYouTube } from './duckplayer-native/sub-feature.js'; +import { Environment } from './duckplayer/environment.js'; +import { Logger } from './duckplayer/util.js'; + +/** + * @import {DuckPlayerNativeSubFeature} from './duckplayer-native/sub-feature.js' + * @import {DuckPlayerNativeSettings} from '@duckduckgo/privacy-configuration/schema/features/duckplayer-native.js' + * @import {UrlChangeSettings} from './duckplayer-native/messages.js' + */ + +/** + * @typedef InitialSettings - The initial payload used to communicate render-blocking information + * @property {string} locale - UI locale + * @property {UrlChangeSettings['pageType']} pageType - The type of the current page + * @property {boolean} playbackPaused - Should video start playing or paused + */ + +/** + * @import localeStrings from '../locales/duckplayer/en/native.json' + * @typedef {(key: keyof localeStrings) => string} TranslationFn + */ + +export class DuckPlayerNativeFeature extends ContentFeature { + /** @type {DuckPlayerNativeSubFeature | null} */ + currentPage; + /** @type {TranslationFn} */ + t; + + async init(args) { + /** + * This feature never operates in a frame + */ + if (isBeingFramed()) return; + + const selectors = this.getFeatureSetting('selectors'); + if (!selectors) { + console.warn('No selectors found. Check remote config. Feature will not be initialized.'); + return; + } + + const locale = args?.locale || args?.language || 'en'; + const env = new Environment({ + debug: this.isDebug, + injectName: import.meta.injectName, + platform: this.platform, + locale, + }); + + // Translation function to be used by view components + this.t = (key) => env.strings('native.json')[key]; + + const messages = new DuckPlayerNativeMessages(this.messaging, env); + messages.subscribeToURLChange(({ pageType }) => { + const playbackPaused = false; // This can be added to the event data in the future if needed + this.urlDidChange(pageType, selectors, playbackPaused, env, messages); + }); + + /** @type {InitialSettings} */ + let initialSetup; + + try { + initialSetup = await messages.initialSetup(); + } catch (e) { + console.warn('Failed to get initial setup', e); + return; + } + + if (initialSetup.pageType) { + const playbackPaused = initialSetup.playbackPaused || false; + this.urlDidChange(initialSetup.pageType, selectors, playbackPaused, env, messages); + } + } + + /** + * + * @param {UrlChangeSettings['pageType']} pageType + * @param {DuckPlayerNativeSettings['selectors']} selectors + * @param {boolean} playbackPaused + * @param {Environment} env + * @param {DuckPlayerNativeMessages} messages + */ + urlDidChange(pageType, selectors, playbackPaused, env, messages) { + /** @type {DuckPlayerNativeSubFeature | null} */ + let nextPage = null; + + const logger = new Logger({ + id: 'DUCK_PLAYER_NATIVE', + shouldLog: () => env.isTestMode(), + }); + + switch (pageType) { + case 'NOCOOKIE': + nextPage = setupDuckPlayerForNoCookie(selectors, env, messages, this.t); + break; + case 'YOUTUBE': + nextPage = setupDuckPlayerForYouTube(selectors, playbackPaused, env, messages); + break; + case 'SERP': + nextPage = setupDuckPlayerForSerp(); + break; + case 'UNKNOWN': + default: + logger.log('No known pageType'); + } + + if (this.currentPage) { + this.currentPage.destroy(); + } + + if (nextPage) { + logger.log('Running init handlers'); + nextPage.onInit(); + this.currentPage = nextPage; + + if (document.readyState === 'loading') { + const loadHandler = () => { + logger.log('Running deferred load handlers'); + nextPage.onLoad(); + messages.notifyScriptIsReady(); + }; + document.addEventListener('DOMContentLoaded', loadHandler, { once: true }); + } else { + logger.log('Running load handlers immediately'); + nextPage.onLoad(); + messages.notifyScriptIsReady(); + } + } + } +} + +export default DuckPlayerNativeFeature; diff --git a/injected/src/features/duck-player.js b/injected/src/features/duck-player.js index 4c97397131..db6d1cb8fa 100644 --- a/injected/src/features/duck-player.js +++ b/injected/src/features/duck-player.js @@ -35,7 +35,8 @@ import ContentFeature from '../content-feature.js'; import { DuckPlayerOverlayMessages, OpenInDuckPlayerMsg, Pixel } from './duckplayer/overlay-messages.js'; import { isBeingFramed } from '../utils.js'; -import { Environment, initOverlays } from './duckplayer/overlays.js'; +import { initOverlays } from './duckplayer/overlays.js'; +import { Environment } from './duckplayer/environment.js'; /** * @typedef UserValues - A way to communicate user settings @@ -94,7 +95,7 @@ export default class DuckPlayerFeature extends ContentFeature { const locale = args?.locale || args?.language || 'en'; const env = new Environment({ - debug: args.debug, + debug: this.isDebug, injectName: import.meta.injectName, platform: this.platform, locale, @@ -107,10 +108,6 @@ export default class DuckPlayerFeature extends ContentFeature { comms.serpProxy(); } } - - load(args) { - super.load(args); - } } /** diff --git a/injected/src/features/duckplayer-native/constants.js b/injected/src/features/duckplayer-native/constants.js new file mode 100644 index 0000000000..6709bf59b3 --- /dev/null +++ b/injected/src/features/duckplayer-native/constants.js @@ -0,0 +1,10 @@ +export const MSG_NAME_INITIAL_SETUP = 'initialSetup'; +export const MSG_NAME_CURRENT_TIMESTAMP = 'onCurrentTimestamp'; +export const MSG_NAME_MEDIA_CONTROL = 'onMediaControl'; +export const MSG_NAME_MUTE_AUDIO = 'onMuteAudio'; +export const MSG_NAME_SERP_NOTIFY = 'onSerpNotify'; +export const MSG_NAME_YOUTUBE_ERROR = 'onYoutubeError'; +export const MSG_NAME_URL_CHANGE = 'onUrlChanged'; +export const MSG_NAME_FEATURE_READY = 'onDuckPlayerFeatureReady'; +export const MSG_NAME_SCRIPTS_READY = 'onDuckPlayerScriptsReady'; +export const MSG_NAME_DISMISS_OVERLAY = 'didDismissOverlay'; diff --git a/injected/src/features/duckplayer-native/custom-error/custom-error.css b/injected/src/features/duckplayer-native/custom-error/custom-error.css new file mode 100644 index 0000000000..22dbb81c2b --- /dev/null +++ b/injected/src/features/duckplayer-native/custom-error/custom-error.css @@ -0,0 +1,195 @@ +/* -- VIDEO PLAYER OVERLAY */ +:host { + --title-size: 16px; + --title-line-height: 20px; + --title-gap: 16px; + --button-gap: 6px; + --padding: 4px; + --logo-size: 32px; + --logo-gap: 8px; + --gutter: 16px; + --background-color: black; + --background-color-alt: #2f2f2f; + + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + z-index: 1000; + height: 100vh; +} +/* iphone 15 */ +@media screen and (min-width: 390px) { + :host { + --title-size: 20px; + --title-line-height: 25px; + --button-gap: 16px; + --logo-size: 40px; + --logo-gap: 12px; + --title-gap: 16px; + } +} +/* iphone 15 Pro Max */ +@media screen and (min-width: 430px) { + :host { + --title-size: 22px; + --title-gap: 24px; + --button-gap: 20px; + --logo-gap: 16px; + } +} +/* small landscape */ +@media screen and (min-width: 568px) { +} +/* large landscape */ +@media screen and (min-width: 844px) { + :host { + --title-gap: 30px; + --button-gap: 24px; + --logo-size: 48px; + } +} + + +:host * { + font-family: system, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +:root *, :root *:after, :root *:before { + box-sizing: border-box; +} + +.wrapper { + align-items: center; + background-color: var(--background-color); + display: flex; + height: 100%; + justify-content: center; + padding: var(--padding); +} + +.error { + align-items: center; + display: grid; + justify-items: center; +} + +.error.mobile { + border-radius: var(--inner-radius); + overflow: auto; + + /* Prevents automatic text resizing */ + text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + + @media screen and (min-width: 600px) and (min-height: 600px) { + aspect-ratio: 16 / 9; + } +} + +.error.framed { + padding: 4px; + border: 4px solid var(--background-color-alt); + border-radius: 16px; +} + +.container { + background: var(--background-color); + column-gap: 24px; + display: flex; + flex-flow: row; + margin: 0; + max-width: 680px; + padding: 0 40px; + row-gap: 4px; +} + +.mobile .container { + flex-flow: column; + padding: 0 24px; + + @media screen and (min-height: 320px) { + margin: 16px 0; + } + + @media screen and (min-width: 375px) and (min-height: 400px) { + margin: 36px 0; + } +} + +.content { + display: flex; + flex-direction: column; + gap: 4px; + margin: 16px 0; + + @media screen and (min-width: 600px) { + margin: 24px 0; + } +} + + +.icon { + align-self: center; + display: flex; + justify-content: center; + + &::before { + content: ' '; + display: block; + background-image: url("data:image/svg+xml,%3Csvg fill='none' viewBox='0 0 96 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='red' d='M47.5 70.802c1.945 0 3.484-1.588 3.841-3.5C53.076 58.022 61.218 51 71 51h4.96c2.225 0 4.04-1.774 4.04-4 0-.026-.007-9.022-1.338-14.004a8.02 8.02 0 0 0-5.659-5.658C68.014 26 48 26 48 26s-20.015 0-25.004 1.338a8.01 8.01 0 0 0-5.658 5.658C16 37.986 16 48.401 16 48.401s0 10.416 1.338 15.405a8.01 8.01 0 0 0 5.658 5.658c4.99 1.338 24.504 1.338 24.504 1.338'/%3E%3Cpath fill='%23fff' d='m41.594 58 16.627-9.598-16.627-9.599z'/%3E%3Cpath fill='%23EB102D' d='M87 71c0 8.837-7.163 16-16 16s-16-7.163-16-16 7.163-16 16-16 16 7.163 16 16'/%3E%3Cpath fill='%23fff' d='M73 77.8a2 2 0 1 1-4 0 2 2 0 0 1 4 0m-2.039-4.4c-.706 0-1.334-.49-1.412-1.12l-.942-8.75c-.079-.7.55-1.33 1.412-1.33h1.962c.785 0 1.492.63 1.413 1.33l-.942 8.75c-.157.63-.784 1.12-1.49 1.12Z'/%3E%3Cpath fill='%23CCC' d='M92.501 59c.298 0 .595.12.823.354.454.468.454 1.23 0 1.698l-2.333 2.4a1.145 1.145 0 0 1-1.65 0 1.227 1.227 0 0 1 0-1.698l2.333-2.4c.227-.234.524-.354.822-.354zm-1.166 10.798h3.499c.641 0 1.166.54 1.166 1.2s-.525 1.2-1.166 1.2h-3.499c-.641 0-1.166-.54-1.166-1.2s.525-1.2 1.166-1.2m-1.982 8.754c.227-.234.525-.354.822-.354h.006c.297 0 .595.12.822.354l2.332 2.4c.455.467.455 1.23 0 1.697a1.145 1.145 0 0 1-1.65 0l-2.332-2.4a1.227 1.227 0 0 1 0-1.697'/%3E%3C/svg%3E%0A"); + background-repeat: no-repeat; + height: 96px; + width: 96px; + } + + @media screen and (max-width: 320px) { + display: none; + } + + @media screen and (min-width: 600px) and (min-height: 600px) { + justify-content: start; + + &::before { + background-image: url("data:image/svg+xml,%3Csvg fill='none' viewBox='0 0 128 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23888' d='M16.912 31.049a1.495 1.495 0 0 1 2.114-2.114l1.932 1.932 1.932-1.932a1.495 1.495 0 0 1 2.114 2.114l-1.932 1.932 1.932 1.932a1.495 1.495 0 0 1-2.114 2.114l-1.932-1.933-1.932 1.933a1.494 1.494 0 1 1-2.114-2.114l1.932-1.932zM.582 52.91a1.495 1.495 0 0 1 2.113-2.115l1.292 1.292 1.291-1.292a1.495 1.495 0 1 1 2.114 2.114L6.1 54.2l1.292 1.292a1.495 1.495 0 1 1-2.113 2.114l-1.292-1.292-1.292 1.292a1.495 1.495 0 1 1-2.114-2.114l1.292-1.291zm104.972-15.452a1.496 1.496 0 0 1 2.114-2.114l1.291 1.292 1.292-1.292a1.495 1.495 0 0 1 2.114 2.114l-1.292 1.291 1.292 1.292a1.494 1.494 0 1 1-2.114 2.114l-1.292-1.292-1.291 1.292a1.495 1.495 0 0 1-2.114-2.114l1.292-1.292zM124.5 54c-.825 0-1.5-.675-1.5-1.5s.675-1.5 1.5-1.5 1.5.675 1.5 1.5-.675 1.5-1.5 1.5M24 67c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2' opacity='.2'/%3E%3Cpath fill='red' d='M63.5 70.802c1.945 0 3.484-1.588 3.841-3.5C69.076 58.022 77.218 51 87 51h4.96c2.225 0 4.04-1.774 4.04-4 0-.026-.007-9.022-1.338-14.004a8.02 8.02 0 0 0-5.659-5.658C84.014 26 64 26 64 26s-20.014 0-25.004 1.338a8.01 8.01 0 0 0-5.658 5.658C32 37.986 32 48.401 32 48.401s0 10.416 1.338 15.405a8.01 8.01 0 0 0 5.658 5.658c4.99 1.338 24.504 1.338 24.504 1.338'/%3E%3Cpath fill='%23fff' d='m57.594 58 16.627-9.598-16.627-9.599z'/%3E%3Cpath fill='%23EB102D' d='M103 71c0 8.837-7.163 16-16 16s-16-7.163-16-16 7.163-16 16-16 16 7.163 16 16'/%3E%3Cpath fill='%23fff' d='M89 77.8a2 2 0 1 1-4 0 2 2 0 0 1 4 0m-2.039-4.4c-.706 0-1.334-.49-1.412-1.12l-.942-8.75c-.079-.7.55-1.33 1.412-1.33h1.962c.785 0 1.492.63 1.413 1.33l-.942 8.75c-.157.63-.784 1.12-1.49 1.12Z'/%3E%3Cpath fill='%23CCC' d='M108.501 59c.298 0 .595.12.823.354.454.468.454 1.23 0 1.698l-2.333 2.4a1.145 1.145 0 0 1-1.65 0 1.226 1.226 0 0 1 0-1.698l2.332-2.4c.228-.234.525-.354.823-.354zm-1.166 10.798h3.499c.641 0 1.166.54 1.166 1.2s-.525 1.2-1.166 1.2h-3.499c-.641 0-1.166-.54-1.166-1.2s.525-1.2 1.166-1.2m-1.982 8.754c.227-.234.525-.354.822-.354h.006c.297 0 .595.12.822.354l2.333 2.4c.454.467.454 1.23 0 1.697a1.146 1.146 0 0 1-1.651 0l-2.332-2.4a1.226 1.226 0 0 1 0-1.697'/%3E%3C/svg%3E%0A"); + height: 96px; + width: 128px; + } + } +} + +.heading { + color: #fff; + font-size: 20px; + font-weight: 700; + line-height: calc(24 / 20); + margin: 0; +} + +.messages { + color: #ccc; + font-size: 16px; + line-height: calc(24 / 16); +} + +div.messages { + display: flex; + flex-direction: column; + gap: 24px; + + & p { + margin: 0; + } +} + +p.messages { + margin: 0; +} + +ul.messages { + li { + list-style: disc; + margin-left: 24px; + } +} diff --git a/injected/src/features/duckplayer-native/custom-error/custom-error.js b/injected/src/features/duckplayer-native/custom-error/custom-error.js new file mode 100644 index 0000000000..60d9869cd1 --- /dev/null +++ b/injected/src/features/duckplayer-native/custom-error/custom-error.js @@ -0,0 +1,146 @@ +import css from './custom-error.css'; +import { Logger } from '../../duckplayer/util.js'; +import { createPolicy, html } from '../../../dom-utils.js'; +import { customElementsDefine, customElementsGet } from '../../../captured-globals.js'; + +/** + * @import {YouTubeError} from '../error-detection' + * @import {TranslationFn} from '../../duck-player-native.js' + **/ + +/** + * @typedef ErrorStrings + * @property {string} title + * @property {string[]} messages + */ + +/** + * The custom element that we use to present our UI elements + * over the YouTube player + */ +export class CustomError extends HTMLElement { + static CUSTOM_TAG_NAME = 'ddg-video-error'; + + policy = createPolicy(); + /** @type {Logger} */ + logger; + /** @type {boolean} */ + testMode = false; + /** @type {YouTubeError} */ + error; + /** @type {string} */ + title = ''; + /** @type {string[]} */ + messages = []; + + static register() { + if (!customElementsGet(CustomError.CUSTOM_TAG_NAME)) { + customElementsDefine(CustomError.CUSTOM_TAG_NAME, CustomError); + } + } + + connectedCallback() { + this.createMarkupAndStyles(); + } + + createMarkupAndStyles() { + const shadow = this.attachShadow({ mode: this.testMode ? 'open' : 'closed' }); + + const style = document.createElement('style'); + style.innerText = css; + + const container = document.createElement('div'); + container.classList.add('wrapper'); + const content = this.render(); + container.innerHTML = this.policy.createHTML(content); + shadow.append(style, container); + this.container = container; + + this.logger?.log('Created', CustomError.CUSTOM_TAG_NAME, 'with container', container); + } + + /** + * @returns {string} + */ + render() { + if (!this.title || !this.messages) { + console.warn('Missing error title or messages. Please assign before rendering'); + return ''; + } + + const { title, messages } = this; + const messagesHtml = messages.map((message) => html`

    ${message}

    `); + + return html` +
    +
    + + +
    +

    ${title}

    +
    ${messagesHtml}
    +
    +
    +
    + `.toString(); + } +} + +/** + * @param {YouTubeError} errorId + * @param {TranslationFn} t - Translation function + * @returns {ErrorStrings} + */ +function getErrorStrings(errorId, t) { + switch (errorId) { + case 'sign-in-required': + return { + title: t('signInRequiredErrorHeading2'), + messages: [t('signInRequiredErrorMessage2a'), t('signInRequiredErrorMessage2b')], + }; + case 'age-restricted': + return { + title: t('ageRestrictedErrorHeading2'), + messages: [t('ageRestrictedErrorMessage2a'), t('ageRestrictedErrorMessage2b')], + }; + case 'no-embed': + return { + title: t('noEmbedErrorHeading2'), + messages: [t('noEmbedErrorMessage2a'), t('noEmbedErrorMessage2b')], + }; + case 'unknown': + default: + return { + title: t('unknownErrorHeading2'), + messages: [t('unknownErrorMessage2a'), t('unknownErrorMessage2b')], + }; + } +} + +/** + * + * @param {HTMLElement} targetElement + * @param {YouTubeError} errorId + * @param {import('../../duckplayer/environment.js').Environment} environment + * @param {TranslationFn} t - Translation function + */ +export function showError(targetElement, errorId, environment, t) { + const { title, messages } = getErrorStrings(errorId, t); + const logger = new Logger({ + id: 'CUSTOM_ERROR', + shouldLog: () => environment.isTestMode(), + }); + + CustomError.register(); + + const customError = /** @type {CustomError} */ (document.createElement(CustomError.CUSTOM_TAG_NAME)); + customError.logger = logger; + customError.testMode = environment.isTestMode(); + customError.title = title; + customError.messages = messages; + targetElement.appendChild(customError); + + return () => { + document.querySelector(CustomError.CUSTOM_TAG_NAME)?.remove(); + }; +} diff --git a/injected/src/features/duckplayer-native/error-detection.js b/injected/src/features/duckplayer-native/error-detection.js new file mode 100644 index 0000000000..3f6d7167b7 --- /dev/null +++ b/injected/src/features/duckplayer-native/error-detection.js @@ -0,0 +1,107 @@ +import { Logger } from '../duckplayer/util.js'; +import { checkForError, getErrorType } from './youtube-errors.js'; + +/** + * @import {DuckPlayerNativeSettings} from "@duckduckgo/privacy-configuration/schema/features/duckplayer-native.js" + * @typedef {"age-restricted" | "sign-in-required" | "no-embed" | "unknown"} YouTubeError + * @typedef {DuckPlayerNativeSettings['selectors']} DuckPlayerNativeSelectors + * @typedef {(error: YouTubeError) => void} ErrorDetectionCallback + */ + +/** + * @typedef {object} ErrorDetectionSettings + * @property {DuckPlayerNativeSelectors} selectors + * @property {ErrorDetectionCallback} callback + * @property {boolean} testMode + */ + +/** + * Detects YouTube errors based on DOM queries + */ +export class ErrorDetection { + /** @type {Logger} */ + logger; + /** @type {DuckPlayerNativeSelectors} */ + selectors; + /** @type {ErrorDetectionCallback} */ + callback; + /** @type {boolean} */ + testMode; + + /** + * @param {ErrorDetectionSettings} settings + */ + constructor({ selectors, callback, testMode = false }) { + if (!selectors?.youtubeError || !selectors?.signInRequiredError || !callback) { + throw new Error('Missing selectors or callback props'); + } + this.selectors = selectors; + this.callback = callback; + this.testMode = testMode; + this.logger = new Logger({ + id: 'ERROR_DETECTION', + shouldLog: () => this.testMode, + }); + } + + /** + * + * @returns {(() => void)|void} + */ + observe() { + const documentBody = document?.body; + if (documentBody) { + // Check if iframe already contains error + if (checkForError(this.selectors.youtubeError, documentBody)) { + const error = getErrorType(window, this.selectors.signInRequiredError, this.logger); + this.handleError(error); + return; + } + + // Create a MutationObserver instance + const observer = new MutationObserver(this.handleMutation.bind(this)); + + // Start observing the iframe's document for changes + observer.observe(documentBody, { + childList: true, + subtree: true, // Observe all descendants of the body + }); + + return () => { + observer.disconnect(); + }; + } + } + + /** + * + * @param {YouTubeError} errorId + */ + handleError(errorId) { + if (this.callback) { + this.logger.log('Calling error handler for', errorId); + this.callback(errorId); + } else { + this.logger.warn('No error callback found'); + } + } + + /** + * Mutation handler that checks new nodes for error states + * + * @type {MutationCallback} + */ + handleMutation(mutationsList) { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (checkForError(this.selectors.youtubeError, node)) { + this.logger.log('A node with an error has been added to the document:', node); + const error = getErrorType(window, this.selectors.signInRequiredError, this.logger); + this.handleError(error); + } + }); + } + } + } +} diff --git a/injected/src/features/duckplayer-native/get-current-timestamp.js b/injected/src/features/duckplayer-native/get-current-timestamp.js new file mode 100644 index 0000000000..2a27305367 --- /dev/null +++ b/injected/src/features/duckplayer-native/get-current-timestamp.js @@ -0,0 +1,40 @@ +/** + * @import { DuckPlayerNativeSelectors } from './sub-feature.js'; + */ + +/** + * @param {string} selector - Selector for the video element + * @returns {number} + */ +export function getCurrentTimestamp(selector) { + const video = /** @type {HTMLVideoElement|null} */ (document.querySelector(selector)); + return video?.currentTime || 0; +} + +/** + * Sends the timestamp to the browser at an interval + * + * @param {number} interval - Polling interval + * @param {(timestamp: number) => void} callback - Callback handler for polling event + * @param {DuckPlayerNativeSelectors} selectors - Selectors for the player + */ +export function pollTimestamp(interval = 300, callback, selectors) { + if (!callback || !selectors) { + console.error('Timestamp polling failed. No callback or selectors defined'); + return () => {}; + } + + const isShowingAd = () => { + return selectors.adShowing && !!document.querySelector(selectors.adShowing); + }; + + const timestampPolling = setInterval(() => { + if (isShowingAd()) return; + const timestamp = getCurrentTimestamp(selectors.videoElement); + callback(timestamp); + }, interval); + + return () => { + clearInterval(timestampPolling); + }; +} diff --git a/injected/src/features/duckplayer-native/messages.js b/injected/src/features/duckplayer-native/messages.js new file mode 100644 index 0000000000..2cfecf7f8c --- /dev/null +++ b/injected/src/features/duckplayer-native/messages.js @@ -0,0 +1,114 @@ +import * as constants from './constants.js'; + +/** @import {YouTubeError} from './error-detection.js' */ +/** @import {Environment} from '../duckplayer/environment.js' */ + +/** + * @typedef {object} MuteSettings - Settings passed to the onMute callback + * @property {boolean} mute - Set to true to mute the video, false to unmute + */ + +/** + * @typedef {object} MediaControlSettings - Settings passed to the onMediaControll callback + * @property {boolean} pause - Set to true to pause the video, false to play + */ + +/** + * @typedef {object} UrlChangeSettings - Settings passed to the onURLChange callback + * @property {PageType} pageType + */ + +/** + * @typedef {'UNKNOWN'|'YOUTUBE'|'NOCOOKIE'|'SERP'} PageType + */ + +/** + * @import {Messaging} from '@duckduckgo/messaging' + * + * A wrapper for all communications. + * + * Please see https://duckduckgo.github.io/content-scope-utils/modules/Webkit_Messaging for the underlying + * messaging primitives. + */ +export class DuckPlayerNativeMessages { + /** + * @param {Messaging} messaging + * @param {Environment} environment + * @internal + */ + constructor(messaging, environment) { + /** + * @internal + */ + this.messaging = messaging; + this.environment = environment; + } + + /** + * @returns {Promise} + */ + initialSetup() { + return this.messaging.request(constants.MSG_NAME_INITIAL_SETUP); + } + + /** + * Notifies with current timestamp as a string + * @param {string} timestamp + */ + notifyCurrentTimestamp(timestamp) { + return this.messaging.notify(constants.MSG_NAME_CURRENT_TIMESTAMP, { timestamp }); + } + + /** + * Subscribe to media control events + * @param {(mediaControlSettings: MediaControlSettings) => void} callback + */ + subscribeToMediaControl(callback) { + return this.messaging.subscribe(constants.MSG_NAME_MEDIA_CONTROL, callback); + } + + /** + * Subscribe to mute audio events + * @param {(muteSettings: MuteSettings) => void} callback + */ + subscribeToMuteAudio(callback) { + return this.messaging.subscribe(constants.MSG_NAME_MUTE_AUDIO, callback); + } + + /** + * Subscribe to URL change events + * @param {(urlSettings: UrlChangeSettings) => void} callback + */ + subscribeToURLChange(callback) { + return this.messaging.subscribe(constants.MSG_NAME_URL_CHANGE, callback); + } + + /** + * Notifies browser of YouTube error + * @param {YouTubeError} error + */ + notifyYouTubeError(error) { + this.messaging.notify(constants.MSG_NAME_YOUTUBE_ERROR, { error }); + } + + /** + * Notifies browser that the feature is ready + */ + notifyFeatureIsReady() { + this.messaging.notify(constants.MSG_NAME_FEATURE_READY, {}); + } + + /** + * Notifies browser that scripts are ready to be acalled + */ + notifyScriptIsReady() { + this.messaging.notify(constants.MSG_NAME_SCRIPTS_READY, {}); + } + + /** + * Notifies browser that the overlay was dismissed + */ + notifyOverlayDismissed() { + this.messaging.notify(constants.MSG_NAME_DISMISS_OVERLAY, {}); + } +} diff --git a/injected/src/features/duckplayer-native/mute-audio.js b/injected/src/features/duckplayer-native/mute-audio.js new file mode 100644 index 0000000000..7ad265f160 --- /dev/null +++ b/injected/src/features/duckplayer-native/mute-audio.js @@ -0,0 +1,8 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck - Typing will be fixed in the future + +export function muteAudio(mute) { + document.querySelectorAll('audio, video').forEach((media) => { + media.muted = mute; + }); +} diff --git a/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.css b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.css new file mode 100644 index 0000000000..405c319fb7 --- /dev/null +++ b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.css @@ -0,0 +1,96 @@ +/* -- VIDEO PLAYER OVERLAY */ +:host { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + z-index: 10000; + --title-size: 16px; + --title-line-height: 20px; + --title-gap: 16px; + --button-gap: 6px; + --logo-size: 32px; + --logo-gap: 8px; + --gutter: 16px; +} +/* iphone 15 */ +@media screen and (min-width: 390px) { + :host { + --title-size: 20px; + --title-line-height: 25px; + --button-gap: 16px; + --logo-size: 40px; + --logo-gap: 12px; + --title-gap: 16px; + } +} +/* iphone 15 Pro Max */ +@media screen and (min-width: 430px) { + :host { + --title-size: 22px; + --title-gap: 24px; + --button-gap: 20px; + --logo-gap: 16px; + } +} +/* small landscape */ +@media screen and (min-width: 568px) { +} +/* large landscape */ +@media screen and (min-width: 844px) { + :host { + --title-gap: 30px; + --button-gap: 24px; + --logo-size: 48px; + } +} + + +:host * { + font-family: system, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +:root *, :root *:after, :root *:before { + box-sizing: border-box; +} + +.ddg-video-player-overlay { + width: 100%; + height: 100%; + padding-left: var(--gutter); + padding-right: var(--gutter); + + @media screen and (min-width: 568px) { + padding: 0; + } +} + +.bg { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + color: white; + background: rgba(0, 0, 0, 0.6); + background-position: center; + text-align: center; +} + +.logo { + content: " "; + position: absolute; + display: block; + width: 100%; + height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: transparent; + background-image: url('data:image/svg+xml,'); + background-size: 90px 64px; + background-position: center center; + background-repeat: no-repeat; +} diff --git a/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js new file mode 100644 index 0000000000..2b663a1c9d --- /dev/null +++ b/injected/src/features/duckplayer-native/overlays/thumbnail-overlay.js @@ -0,0 +1,115 @@ +import css from './thumbnail-overlay.css'; +import { createPolicy, html } from '../../../dom-utils.js'; +import { customElementsDefine, customElementsGet } from '../../../captured-globals.js'; +import { VideoParams, appendImageAsBackground, Logger } from '../../duckplayer/util.js'; + +/** + * The custom element that we use to present our UI elements + * over the YouTube player + */ +export class DDGVideoThumbnailOverlay extends HTMLElement { + static CUSTOM_TAG_NAME = 'ddg-video-thumbnail-overlay-mobile'; + static OVERLAY_CLICKED = 'overlay-clicked'; + + policy = createPolicy(); + /** @type {Logger} */ + logger; + /** @type {boolean} */ + testMode = false; + /** @type {HTMLElement} */ + container; + /** @type {string} */ + href; + + static register() { + if (!customElementsGet(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME)) { + customElementsDefine(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME, DDGVideoThumbnailOverlay); + } + } + + connectedCallback() { + this.createMarkupAndStyles(); + } + + createMarkupAndStyles() { + const shadow = this.attachShadow({ mode: this.testMode ? 'open' : 'closed' }); + + const style = document.createElement('style'); + style.innerText = css; + + const container = document.createElement('div'); + container.classList.add('wrapper'); + const content = this.render(); + container.innerHTML = this.policy.createHTML(content); + shadow.append(style, container); + this.container = container; + + // Add click event listener to the overlay + const overlay = container.querySelector('.ddg-video-player-overlay'); + if (overlay) { + overlay.addEventListener('click', () => { + this.dispatchEvent(new Event(DDGVideoThumbnailOverlay.OVERLAY_CLICKED)); + }); + } + + this.logger?.log('Created', DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME, 'with container', container); + this.appendThumbnail(); + } + + appendThumbnail() { + const params = VideoParams.forWatchPage(this.href); + const imageUrl = params?.toLargeThumbnailUrl(); + + if (!imageUrl) { + this.logger?.warn('Could not get thumbnail url for video id', params?.id); + return; + } + + if (this.testMode) { + this.logger?.log('Appending thumbnail', imageUrl); + } + appendImageAsBackground(this.container, '.ddg-vpo-bg', imageUrl); + } + + /** + * @returns {string} + */ + render() { + return html` +
    +
    + +
    + `.toString(); + } +} + +/** + * + * @param {HTMLElement} targetElement + * @param {import("../../duckplayer/environment").Environment} environment + * @param {() => void} [onClick] Optional callback to be called when the overlay is clicked + */ +export function showThumbnailOverlay(targetElement, environment, onClick) { + const logger = new Logger({ + id: 'THUMBNAIL_OVERLAY', + shouldLog: () => environment.isTestMode(), + }); + + DDGVideoThumbnailOverlay.register(); + + const overlay = /** @type {DDGVideoThumbnailOverlay} */ (document.createElement(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME)); + overlay.logger = logger; + overlay.testMode = environment.isTestMode(); + overlay.href = environment.getPlayerPageHref(); + + if (onClick) { + overlay.addEventListener(DDGVideoThumbnailOverlay.OVERLAY_CLICKED, onClick); + } + + targetElement.appendChild(overlay); + + return () => { + document.querySelector(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME)?.remove(); + }; +} diff --git a/injected/src/features/duckplayer-native/pause-video.js b/injected/src/features/duckplayer-native/pause-video.js new file mode 100644 index 0000000000..41b37a84e2 --- /dev/null +++ b/injected/src/features/duckplayer-native/pause-video.js @@ -0,0 +1,69 @@ +/** + * Pause a YouTube video + * + * @param {string} videoSelector + * @returns {() => void} A function that allows the video to play again + */ +export function stopVideoFromPlaying(videoSelector) { + /** + * Set up the interval - keep calling .pause() to prevent + * the video from playing + */ + const int = setInterval(() => { + const video = /** @type {HTMLVideoElement} */ (document.querySelector(videoSelector)); + if (video?.isConnected) { + video.pause(); + } + }, 10); + + /** + * To clean up, we need to stop the interval + * and then call .play() on the original element, if it's still connected + */ + return () => { + clearInterval(int); + + const video = /** @type {HTMLVideoElement} */ (document.querySelector(videoSelector)); + if (video?.isConnected) { + video.play(); + } + }; +} + +const MUTE_ELEMENTS_QUERY = 'audio, video'; + +/** + * Mute all audio and video elements + * + * @returns {() => void} A function that allows the elements to be unmuted + */ +export function muteAllElements() { + /** + * Set up the interval + */ + const int = setInterval(() => { + /** @type {(HTMLAudioElement | HTMLVideoElement)[]} */ + const elements = Array.from(document.querySelectorAll(MUTE_ELEMENTS_QUERY)); + elements.forEach((element) => { + if (element?.isConnected) { + element.muted = true; + } + }); + }, 10); + + /** + * To clean up, we need to stop the interval + * and then call .play() on the original element, if it's still connected + */ + return () => { + clearInterval(int); + + /** @type {(HTMLAudioElement | HTMLVideoElement)[]} */ + const elements = Array.from(document.querySelectorAll(MUTE_ELEMENTS_QUERY)); + elements.forEach((element) => { + if (element?.isConnected) { + element.muted = false; + } + }); + }; +} diff --git a/injected/src/features/duckplayer-native/sub-feature.js b/injected/src/features/duckplayer-native/sub-feature.js new file mode 100644 index 0000000000..c4b1c0d638 --- /dev/null +++ b/injected/src/features/duckplayer-native/sub-feature.js @@ -0,0 +1,73 @@ +import { DuckPlayerNativeYoutube } from './sub-features/duck-player-native-youtube.js'; +import { DuckPlayerNativeNoCookie } from './sub-features/duck-player-native-no-cookie.js'; +import { DuckPlayerNativeSerp } from './sub-features/duck-player-native-serp.js'; + +/** + * @import {DuckPlayerNativeMessages} from './messages.js' + * @import {Environment} from '../duckplayer/environment.js' + * @import {TranslationFn} from '../duck-player-native.js' + * @import {DuckPlayerNativeSettings} from "@duckduckgo/privacy-configuration/schema/features/duckplayer-native.js" + * @typedef {DuckPlayerNativeSettings['selectors']} DuckPlayerNativeSelectors + */ + +/** + * @interface + */ +export class DuckPlayerNativeSubFeature { + /** + * Called immediately when an instance is created + */ + onInit() {} + /** + * Called when the page is in a ready state (could be immediately following 'onInit') + */ + onLoad() {} + /** + * Called when effects should be cleaned up + */ + destroy() {} +} + +/** + * Sets up Duck Player for a YouTube watch page + * + * @param {DuckPlayerNativeSelectors} selectors + * @param {boolean} paused + * @param {Environment} environment + * @param {DuckPlayerNativeMessages} messages + * @return {DuckPlayerNativeSubFeature} + */ +export function setupDuckPlayerForYouTube(selectors, paused, environment, messages) { + return new DuckPlayerNativeYoutube({ + selectors, + environment, + messages, + paused, + }); +} + +/** + * Sets up Duck Player for a video player in the YouTube no-cookie domain + * + * @param {DuckPlayerNativeSelectors} selectors + * @param {Environment} environment + * @param {DuckPlayerNativeMessages} messages + * @param {TranslationFn} t + * @return {DuckPlayerNativeSubFeature} + */ +export function setupDuckPlayerForNoCookie(selectors, environment, messages, t) { + return new DuckPlayerNativeNoCookie({ + selectors, + environment, + messages, + t, + }); +} + +/** + * Sets up Duck Player events for the SERP + * @return {DuckPlayerNativeSubFeature} + */ +export function setupDuckPlayerForSerp() { + return new DuckPlayerNativeSerp(); +} diff --git a/injected/src/features/duckplayer-native/sub-features/duck-player-native-no-cookie.js b/injected/src/features/duckplayer-native/sub-features/duck-player-native-no-cookie.js new file mode 100644 index 0000000000..c08fac1100 --- /dev/null +++ b/injected/src/features/duckplayer-native/sub-features/duck-player-native-no-cookie.js @@ -0,0 +1,91 @@ +import { Logger, SideEffects } from '../../duckplayer/util.js'; +import { pollTimestamp } from '../get-current-timestamp.js'; +import { showError } from '../custom-error/custom-error.js'; +import { ErrorDetection } from '../error-detection.js'; + +/** + * @import {DuckPlayerNativeMessages} from '../messages.js' + * @import {Environment} from '../../duckplayer/environment.js' + * @import {ErrorDetectionSettings} from '../error-detection.js' + * @import {DuckPlayerNativeSelectors} from '../sub-feature.js' + * @import {TranslationFn} from '../../duck-player-native.js' + */ +/** + * @import {DuckPlayerNativeSubFeature} from "../sub-feature.js" + * @implements {DuckPlayerNativeSubFeature} + */ +export class DuckPlayerNativeNoCookie { + /** + * @param {object} options + * @param {Environment} options.environment + * @param {DuckPlayerNativeMessages} options.messages + * @param {DuckPlayerNativeSelectors} options.selectors + * @param {TranslationFn} options.t + */ + constructor({ environment, messages, selectors, t }) { + this.environment = environment; + this.selectors = selectors; + this.messages = messages; + this.t = t; + this.sideEffects = new SideEffects({ + debug: environment.isTestMode(), + }); + this.logger = new Logger({ + id: 'DUCK_PLAYER_NATIVE', + shouldLog: () => this.environment.isTestMode(), + }); + } + + onInit() {} + + onLoad() { + this.sideEffects.add('started polling current timestamp', () => { + const handler = (timestamp) => { + this.messages.notifyCurrentTimestamp(timestamp.toFixed(0)); + }; + + return pollTimestamp(300, handler, this.selectors); + }); + + this.logger.log('Setting up error detection'); + const errorContainer = this.selectors?.errorContainer; + const signInRequiredError = this.selectors?.signInRequiredError; + if (!errorContainer || !signInRequiredError) { + this.logger.warn('Missing error selectors in configuration'); + return; + } + + /** @type {(errorId: import('../error-detection.js').YouTubeError) => void} */ + const errorHandler = (errorId) => { + this.logger.log('Received error', errorId); + + // Notify the browser of the error + this.messages.notifyYouTubeError(errorId); + + const targetElement = document.querySelector(errorContainer); + if (targetElement) { + showError(/** @type {HTMLElement} */ (targetElement), errorId, this.environment, this.t); + } + }; + + /** @type {ErrorDetectionSettings} */ + const errorDetectionSettings = { + selectors: this.selectors, + testMode: this.environment.isTestMode(), + callback: errorHandler, + }; + + this.sideEffects.add('setting up error detection', () => { + const errorDetection = new ErrorDetection(errorDetectionSettings); + const destroy = errorDetection.observe(); + + return () => { + if (destroy) destroy(); + }; + }); + } + + destroy() { + this.sideEffects.destroy(); + } +} diff --git a/injected/src/features/duckplayer-native/sub-features/duck-player-native-serp.js b/injected/src/features/duckplayer-native/sub-features/duck-player-native-serp.js new file mode 100644 index 0000000000..225901e92a --- /dev/null +++ b/injected/src/features/duckplayer-native/sub-features/duck-player-native-serp.js @@ -0,0 +1,24 @@ +/** + * @import {DuckPlayerNativeSubFeature} from "../sub-feature.js" + * @implements {DuckPlayerNativeSubFeature} + */ +export class DuckPlayerNativeSerp { + onLoad() { + window.dispatchEvent( + new CustomEvent('ddg-serp-yt-response', { + detail: { + kind: 'initialSetup', + data: { + privatePlayerMode: { enabled: {} }, + overlayInteracted: false, + }, + }, + composed: true, + bubbles: true, + }), + ); + } + + onInit() {} + destroy() {} +} diff --git a/injected/src/features/duckplayer-native/sub-features/duck-player-native-youtube.js b/injected/src/features/duckplayer-native/sub-features/duck-player-native-youtube.js new file mode 100644 index 0000000000..ded9c42c35 --- /dev/null +++ b/injected/src/features/duckplayer-native/sub-features/duck-player-native-youtube.js @@ -0,0 +1,108 @@ +import { Logger, SideEffects } from '../../duckplayer/util.js'; +import { muteAudio } from '../mute-audio.js'; +import { pollTimestamp } from '../get-current-timestamp.js'; +import { stopVideoFromPlaying, muteAllElements } from '../pause-video.js'; +import { showThumbnailOverlay } from '../overlays/thumbnail-overlay.js'; + +/** + * @import {DuckPlayerNativeMessages} from '../messages.js' + * @import {Environment} from '../../duckplayer/environment.js' + * @import {DuckPlayerNativeSelectors} from '../sub-feature.js' + */ + +/** + * @import {DuckPlayerNativeSubFeature} from "../sub-feature.js" + * @implements {DuckPlayerNativeSubFeature} + */ +export class DuckPlayerNativeYoutube { + /** + * @param {object} options + * @param {DuckPlayerNativeSelectors} options.selectors + * @param {Environment} options.environment + * @param {DuckPlayerNativeMessages} options.messages + * @param {boolean} options.paused + */ + constructor({ selectors, environment, messages, paused }) { + this.environment = environment; + this.messages = messages; + this.selectors = selectors; + this.paused = paused; + this.sideEffects = new SideEffects({ + debug: environment.isTestMode(), + }); + this.logger = new Logger({ + id: 'DUCK_PLAYER_NATIVE', + shouldLog: () => this.environment.isTestMode(), + }); + } + + onInit() { + this.sideEffects.add('subscribe to media control', () => { + return this.messages.subscribeToMediaControl(({ pause }) => { + this.mediaControlHandler(pause); + }); + }); + + this.sideEffects.add('subscribing to mute audio', () => { + return this.messages.subscribeToMuteAudio(({ mute }) => { + this.logger.log('Running mute audio handler. Mute:', mute); + muteAudio(mute); + }); + }); + } + + onLoad() { + this.sideEffects.add('started polling current timestamp', () => { + const handler = (timestamp) => { + this.messages.notifyCurrentTimestamp(timestamp.toFixed(0)); + }; + + return pollTimestamp(300, handler, this.selectors); + }); + + if (this.paused) { + this.mediaControlHandler(!!this.paused); + } + } + + /** + * @param {boolean} pause + */ + mediaControlHandler(pause) { + this.logger.log('Running media control handler. Pause:', pause); + + const videoElement = this.selectors?.videoElement; + const videoElementContainer = this.selectors?.videoElementContainer; + if (!videoElementContainer || !videoElement) { + this.logger.warn('Missing media control selectors in config'); + return; + } + + const targetElement = document.querySelector(videoElementContainer); + if (targetElement) { + // Prevent repeat execution + if (this.paused === pause) return; + this.paused = pause; + + if (pause) { + this.sideEffects.add('stopping video from playing', () => stopVideoFromPlaying(videoElement)); + this.sideEffects.add('muting all elements', () => muteAllElements()); + this.sideEffects.add('appending thumbnail', () => { + const clickHandler = () => { + this.messages.notifyOverlayDismissed(); + this.mediaControlHandler(false); + }; + return showThumbnailOverlay(/** @type {HTMLElement} */ (targetElement), this.environment, clickHandler); + }); + } else { + this.sideEffects.destroy('stopping video from playing'); + this.sideEffects.destroy('muting all elements'); + this.sideEffects.destroy('appending thumbnail'); + } + } + } + + destroy() { + this.sideEffects.destroy(); + } +} diff --git a/injected/src/features/duckplayer-native/youtube-errors.js b/injected/src/features/duckplayer-native/youtube-errors.js new file mode 100644 index 0000000000..45936ca712 --- /dev/null +++ b/injected/src/features/duckplayer-native/youtube-errors.js @@ -0,0 +1,102 @@ +/** + * @import {DuckPlayerNativeSettings} from "@duckduckgo/privacy-configuration/schema/features/duckplayer-native.js" + * @import {Logger} from "../duckplayer/util.js" + * @typedef {"age-restricted" | "sign-in-required" | "no-embed" | "unknown"} YouTubeError + */ + +export const YOUTUBE_ERROR_EVENT = 'ddg-duckplayer-youtube-error'; + +/** @type {Record} */ +export const YOUTUBE_ERRORS = { + ageRestricted: 'age-restricted', + signInRequired: 'sign-in-required', + noEmbed: 'no-embed', + unknown: 'unknown', +}; + +/** @type {YouTubeError[]} */ +export const YOUTUBE_ERROR_IDS = Object.values(YOUTUBE_ERRORS); + +/** + * Analyses a node and its children to determine if it contains an error state + * + * @param {string} errorSelector + * @param {Node} [node] + */ +export function checkForError(errorSelector, node) { + if (node?.nodeType === Node.ELEMENT_NODE) { + const element = /** @type {HTMLElement} */ (node); + // Check if element has the error class or contains any children with that class + const isError = element.matches(errorSelector) || !!element.querySelector(errorSelector); + return isError; + } + + return false; +} + +/** + * Attempts to detect the type of error in the YouTube embed iframe + * @param {Window|null} windowObject + * @param {string} [signInRequiredSelector] + * @param {Logger} [logger] + * @returns {YouTubeError} + */ +export function getErrorType(windowObject, signInRequiredSelector, logger) { + const currentWindow = /** @type {Window & typeof globalThis & { ytcfg: object }} */ (windowObject); + const currentDocument = currentWindow.document; + + if (!currentWindow || !currentDocument) { + logger?.warn('Window or document missing!'); + return YOUTUBE_ERRORS.unknown; + } + + let playerResponse; + + if (!currentWindow.ytcfg) { + logger?.warn('ytcfg missing!'); + } else { + logger?.log('Got ytcfg', currentWindow.ytcfg); + } + + try { + const playerResponseJSON = currentWindow.ytcfg?.get('PLAYER_VARS')?.embedded_player_response; + logger?.log('Player response', playerResponseJSON); + + playerResponse = JSON.parse(playerResponseJSON); + } catch (e) { + logger?.log('Could not parse player response', e); + } + + if (typeof playerResponse === 'object') { + const { + previewPlayabilityStatus: { desktopLegacyAgeGateReason, status }, + } = playerResponse; + + // 1. Check for UNPLAYABLE status + if (status === 'UNPLAYABLE') { + // 1.1. Check for presence of desktopLegacyAgeGateReason + if (desktopLegacyAgeGateReason === 1) { + logger?.log('AGE RESTRICTED ERROR'); + return YOUTUBE_ERRORS.ageRestricted; + } + + // 1.2. Fall back to embed not allowed error + logger?.log('NO EMBED ERROR'); + return YOUTUBE_ERRORS.noEmbed; + } + } + + // 2. Check for sign-in support link + try { + if (signInRequiredSelector && !!currentDocument.querySelector(signInRequiredSelector)) { + logger?.log('SIGN-IN ERROR'); + return YOUTUBE_ERRORS.signInRequired; + } + } catch (e) { + logger?.log('Sign-in required query failed', e); + } + + // 3. Fall back to unknown error + logger?.log('UNKNOWN ERROR'); + return YOUTUBE_ERRORS.unknown; +} diff --git a/injected/src/features/duckplayer/assets/info-solid.svg b/injected/src/features/duckplayer/assets/info-solid.svg new file mode 100644 index 0000000000..a647330670 --- /dev/null +++ b/injected/src/features/duckplayer/assets/info-solid.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/injected/src/features/duckplayer/assets/mobile-video-drawer.css b/injected/src/features/duckplayer/assets/mobile-video-drawer.css new file mode 100644 index 0000000000..df1c8ebc89 --- /dev/null +++ b/injected/src/features/duckplayer/assets/mobile-video-drawer.css @@ -0,0 +1,376 @@ +/* -- VIDEO PLAYER OVERLAY */ +:host { + position: absolute; + bottom: 0; + right: 0; + left: 0; + top: 0; + z-index: 10010; + --title-size: 16px; + --title-line-height: 20px; + --title-gap: 16px; + --button-gap: 6px; + --logo-size: 32px; + --logo-gap: 8px; + --gutter: 16px; +} +/* iphone 15 */ +@media screen and (min-width: 390px) { + :host { + --title-size: 20px; + --title-line-height: 25px; + --button-gap: 16px; + --logo-size: 40px; + --logo-gap: 12px; + --title-gap: 16px; + } +} +/* iphone 15 Pro Max */ +@media screen and (min-width: 430px) { + :host { + --title-size: 22px; + --title-gap: 24px; + --button-gap: 20px; + --logo-gap: 16px; + } +} +/* small landscape */ +@media screen and (min-width: 568px) { +} +/* large landscape */ +@media screen and (min-width: 844px) { + :host { + --title-gap: 30px; + --button-gap: 24px; + --logo-size: 48px; + } +} + + +:host * { + font-family: system, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +:root *, :root *:after, :root *:before { + box-sizing: border-box; +} + +.ddg-mobile-drawer-overlay { + --overlay-background: rgba(0, 0, 0, 0.6); + --drawer-background: #fafafa; + --drawer-color: rgba(0, 0, 0, 0.84); + --button-background: rgba(0, 0, 0, 0.06); + --button-color: rgba(0, 0, 0, 0.84); + --button-accent-background: #3969ef; + --button-accent-color: #fff; + --switch-off-background: #888; + --switch-on-background: #3969ef; + --switch-thumb-background: #fff; + --info-color: #000; + + --drawer-padding-block: 24px; + --drawer-padding-inline: 16px; + --drawer-buffer: 48px; + + height: 100%; + position: absolute; + width: 100%; +} + +@media (prefers-color-scheme: dark) { + .ddg-mobile-drawer-overlay { + --drawer-background: #333; + --drawer-color: rgba(255, 255, 255, 0.84); + --button-background: rgba(255, 255, 255, 0.18); + --button-color: #fff; + --button-accent-background: #7295f6; + --button-accent-color: rgba(0, 0, 0, 0.84); + --switch-off-background: #888; + --switch-on-background: #7295f6; + --switch-thumb-background: #fff; + --info-color: rgba(255, 255, 255, 0.84); + } +} + +.ddg-mobile-drawer-background { + background: var(--overlay-background); + bottom: 0; + left: 0; + opacity: 0; + position: fixed; + right: 0; + top: 0; +} + +.ddg-mobile-drawer { + background: var(--drawer-background); + border-top-left-radius: 10px; + border-top-right-radius: 10px; + bottom: -100vh; + box-shadow: 0px -4px 12px 0px rgba(0, 0, 0, 0.10), 0px -20px 40px 0px rgba(0, 0, 0, 0.08); + box-sizing: border-box; + color: var(--drawer-color); + display: flex; + flex-direction: column; + gap: 12px; + left: 0; + position: fixed; + width: 100%; + + /* Apply safe-area padding as fallback in case media query below gets removed in the future */ + padding-top: var(--drawer-padding-block); + padding-right: calc(var(--drawer-padding-inline) + env(safe-area-inset-right)); + padding-bottom: calc(var(--drawer-padding-block) + var(--drawer-buffer)); + padding-left: calc(var(--drawer-padding-inline) + env(safe-area-inset-left)); +} + +/* Apply a blanket 18% inline padding on viewports wider than 700px */ +@media screen and (min-width: 700px) { + .ddg-mobile-drawer { + padding-left: 18%; + padding-right: 18%; + } +} + +/* ANIMATIONS */ + +.animateIn .ddg-mobile-drawer-background { + animation: fade-in 300ms ease-out 100ms 1 both; +} + +.animateOut .ddg-mobile-drawer-background { + animation: fade-out 300ms ease-out 10ms 1 both; +} + +.animateIn .ddg-mobile-drawer { + animation: slide-in 300ms cubic-bezier(0.34, 1.3, 0.64, 1) 100ms 1 both; +} + +.animateOut .ddg-mobile-drawer { + animation: slide-out 300ms cubic-bezier(0.36, 0, 0.66, -0.3) 100ms 1 both; +} + +@media (prefers-reduced-motion) { + .animateIn *, + .animateOut * { + animation-duration: 0s !important; + } +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes fade-out { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +@keyframes slide-in { + 0% { + bottom: -100vh; + } + + 100% { + bottom: calc(-1 * var(--drawer-buffer)); + } +} + +@keyframes slide-out { + 0% { + bottom: calc(-1 * var(--drawer-buffer)); + } + + 100% { + bottom: -100vh; + } +} + +.heading { + align-items: center; + display: flex; + gap: 12px; + margin-bottom: 4px; +} + +.logo { + flex: 0 0 32px; + height: 32px; + width: 32px; +} + +.title { + flex: 1 1 auto; + font-size: 19px; + font-weight: 700; + line-height: calc(24 / 19); +} + +.info { + align-self: start; + flex: 0 0 16px; + height: 32px; + position: relative; + width: 16px; +} + +/* BUTTONS */ + +.buttons { + gap: 8px; + display: flex; +} + +.button { + flex: 1 1 50%; + margin: 0; + appearance: none; + background: none; + box-shadow: none; + border: none; + display: flex; + align-items: center; + justify-content: center; + background: var(--button-background); + color: var(--button-color); + text-decoration: none; + line-height: 20px; + padding: 12px 16px; + font-size: 15px; + font-weight: 600; + border-radius: 8px; +} + +.info-button { + appearance: none; + background: none; + border: 0; + height: 40px; + margin: 0; + padding: 12px; + position: absolute; + right: calc(-1 * var(--drawer-padding-inline)); + top: calc(-1 * var(--drawer-padding-block)); + width: 40px; +} + +.info-button svg { + display: block; + width: 16px; + height: 16px; +} + +.info-button svg path { + fill: var(--info-color); +} + +.open { + background: var(--button-accent-background); + color: var(--button-accent-color); + text-align: center; + width: 100%; + + @media screen and (min-width: 568px) { + flex: inherit; + padding-left: 24px; + padding-right: 24px; + } +} + +/* REMEMBER ME */ + +.remember { + height: 40px; + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; + padding: 0 8px; +} + +.remember-label { + display: flex; + align-items: center; + flex: 1; +} + +.remember-text { + display: block; + font-size: 14px; + font-weight: 700; + line-height: calc(18 / 14); +} +.remember-checkbox { + margin-left: auto; + display: flex; +} + +/* SWITCH */ + +.switch { + margin: 0; + padding: 0; + width: 52px; + height: 32px; + border: 0; + box-shadow: none; + background: var(--switch-off-background); + border-radius: 32px; + position: relative; + transition: all .3s; +} + +.switch:active .thumb { + scale: 1.15; +} + +.thumb { + width: 24px; + height: 24px; + border-radius: 100%; + background: var(--switch-thumb-background); + position: absolute; + top: 4px; + left: 4px; + pointer-events: none; + transition: .2s left ease-in-out; +} + +.switch[aria-checked="true"] .thumb { + left: calc(100% - 32px + 4px); +} +.switch[aria-checked="true"] { + background: var(--switch-on-background); +} + +.ios-switch { + width: 51px; + height: 31px; +} + +.ios-switch .thumb { + top: 2px; + left: 2px; + width: 27px; + height: 27px; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.25); +} + +.ios-switch:active .thumb { + scale: 1; +} + +.ios-switch[aria-checked="true"] .thumb { + left: calc(100% - 32px + 3px); +} diff --git a/injected/src/features/duckplayer/assets/mobile-video-thumbnail-overlay.css b/injected/src/features/duckplayer/assets/mobile-video-thumbnail-overlay.css new file mode 100644 index 0000000000..405c319fb7 --- /dev/null +++ b/injected/src/features/duckplayer/assets/mobile-video-thumbnail-overlay.css @@ -0,0 +1,96 @@ +/* -- VIDEO PLAYER OVERLAY */ +:host { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + z-index: 10000; + --title-size: 16px; + --title-line-height: 20px; + --title-gap: 16px; + --button-gap: 6px; + --logo-size: 32px; + --logo-gap: 8px; + --gutter: 16px; +} +/* iphone 15 */ +@media screen and (min-width: 390px) { + :host { + --title-size: 20px; + --title-line-height: 25px; + --button-gap: 16px; + --logo-size: 40px; + --logo-gap: 12px; + --title-gap: 16px; + } +} +/* iphone 15 Pro Max */ +@media screen and (min-width: 430px) { + :host { + --title-size: 22px; + --title-gap: 24px; + --button-gap: 20px; + --logo-gap: 16px; + } +} +/* small landscape */ +@media screen and (min-width: 568px) { +} +/* large landscape */ +@media screen and (min-width: 844px) { + :host { + --title-gap: 30px; + --button-gap: 24px; + --logo-size: 48px; + } +} + + +:host * { + font-family: system, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +:root *, :root *:after, :root *:before { + box-sizing: border-box; +} + +.ddg-video-player-overlay { + width: 100%; + height: 100%; + padding-left: var(--gutter); + padding-right: var(--gutter); + + @media screen and (min-width: 568px) { + padding: 0; + } +} + +.bg { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + color: white; + background: rgba(0, 0, 0, 0.6); + background-position: center; + text-align: center; +} + +.logo { + content: " "; + position: absolute; + display: block; + width: 100%; + height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: transparent; + background-image: url('data:image/svg+xml,'); + background-size: 90px 64px; + background-position: center center; + background-repeat: no-repeat; +} diff --git a/injected/src/features/duckplayer/components/ddg-video-drawer-mobile.js b/injected/src/features/duckplayer/components/ddg-video-drawer-mobile.js new file mode 100644 index 0000000000..1dff9bedc6 --- /dev/null +++ b/injected/src/features/duckplayer/components/ddg-video-drawer-mobile.js @@ -0,0 +1,221 @@ +import mobilecss from '../assets/mobile-video-drawer.css'; +import dax from '../assets/dax.svg'; +import info from '../assets/info-solid.svg'; +import { createPolicy, html, trustedUnsafe } from '../../../dom-utils.js'; +import { DDGVideoThumbnailOverlay } from './ddg-video-thumbnail-overlay-mobile'; + +/** + * @typedef {ReturnType} TextVariants + * @typedef {TextVariants[keyof TextVariants]} Text + */ + +/** + * The custom element that we use to present our UI elements + * over the YouTube player + */ +export class DDGVideoDrawerMobile extends HTMLElement { + static CUSTOM_TAG_NAME = 'ddg-video-drawer-mobile'; + static OPEN_INFO = 'open-info'; + static OPT_IN = 'opt-in'; + static OPT_OUT = 'opt-out'; + static DISMISS = 'dismiss'; + static THUMBNAIL_CLICK = 'thumbnail-click'; + static DID_EXIT = 'did-exit'; + + policy = createPolicy(); + /** @type {boolean} */ + testMode = false; + /** @type {Text | null} */ + text = null; + /** @type {HTMLElement | null} */ + container; + /** @type {HTMLElement | null} */ + drawer; + /** @type {HTMLElement | null} */ + overlay; + + /** @type {'idle'|'animating'} */ + animationState = 'idle'; + + connectedCallback() { + this.createMarkupAndStyles(); + } + + createMarkupAndStyles() { + const shadow = this.attachShadow({ mode: this.testMode ? 'open' : 'closed' }); + const style = document.createElement('style'); + style.innerText = mobilecss; + const overlayElement = document.createElement('div'); + const content = this.mobileHtml(); + overlayElement.innerHTML = this.policy.createHTML(content); + shadow.append(style, overlayElement); + this.setupEventHandlers(overlayElement); + this.animateOverlay('in'); + } + + /** + * @returns {string} + */ + mobileHtml() { + if (!this.text) { + console.warn('missing `text`. Please assign before rendering'); + return ''; + } + const svgIcon = trustedUnsafe(dax); + const infoIcon = trustedUnsafe(info); + + return html` +
    +
    +
    +
    + +
    ${this.text.title}
    +
    + +
    +
    +
    + + ${this.text.buttonOpen} +
    +
    +
    + ${this.text.rememberLabel} + + + + +
    +
    +
    +
    + `.toString(); + } + + /** + * + * @param {'in'|'out'} direction + */ + animateOverlay(direction) { + if (!this.overlay) return; + this.animationState = 'animating'; + + switch (direction) { + case 'in': + this.overlay.classList.remove('animateOut'); + this.overlay.classList.add('animateIn'); + break; + case 'out': + this.overlay.classList.remove('animateIn'); + this.overlay.classList.add('animateOut'); + break; + } + } + + /** + * @param {() => void} callback + */ + onAnimationEnd(callback) { + if (this.animationState !== 'animating') callback(); + + this.overlay?.addEventListener( + 'animationend', + () => { + callback(); + }, + { once: true }, + ); + } + + /** + * @param {HTMLElement} [container] + * @returns + */ + setupEventHandlers(container) { + if (!container) { + console.warn('Error setting up drawer component'); + return; + } + + const switchElem = container.querySelector('[role=switch]'); + const infoButton = container.querySelector('.info-button'); + const remember = container.querySelector('input[name="ddg-remember"]'); + const cancelElement = container.querySelector('.ddg-vpo-cancel'); + const watchInPlayer = container.querySelector('.ddg-vpo-open'); + const background = container.querySelector('.ddg-mobile-drawer-background'); + const overlay = container.querySelector('.ddg-mobile-drawer-overlay'); + const drawer = container.querySelector('.ddg-mobile-drawer'); + + if ( + !cancelElement || + !watchInPlayer || + !switchElem || + !infoButton || + !background || + !overlay || + !drawer || + !(remember instanceof HTMLInputElement) + ) { + return console.warn('missing elements'); + } + + this.container = container; + this.overlay = /** @type {HTMLElement} */ (overlay); + this.drawer = /** @type {HTMLElement} */ (drawer); + + infoButton.addEventListener('click', () => { + this.dispatchEvent(new Event(DDGVideoDrawerMobile.OPEN_INFO)); + }); + + switchElem.addEventListener('pointerdown', () => { + const current = switchElem.getAttribute('aria-checked'); + if (current === 'false') { + switchElem.setAttribute('aria-checked', 'true'); + remember.checked = true; + } else { + switchElem.setAttribute('aria-checked', 'false'); + remember.checked = false; + } + }); + + cancelElement.addEventListener('click', (e) => { + if (!e.isTrusted) return; + e.preventDefault(); + e.stopImmediatePropagation(); + this.animateOverlay('out'); + this.dispatchEvent(new CustomEvent(DDGVideoDrawerMobile.OPT_OUT, { detail: { remember: remember.checked } })); + }); + + background.addEventListener('click', (e) => { + if (!e.isTrusted || e.target !== background) return; + e.preventDefault(); + e.stopImmediatePropagation(); + this.animateOverlay('out'); + + const mouseEvent = /** @type {MouseEvent} */ (e); + let eventName = DDGVideoDrawerMobile.DISMISS; + for (const element of document.elementsFromPoint(mouseEvent.clientX, mouseEvent.clientY)) { + if (element.tagName === DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME.toUpperCase()) { + eventName = DDGVideoDrawerMobile.THUMBNAIL_CLICK; + break; + } + } + + this.dispatchEvent(new CustomEvent(eventName)); + }); + + watchInPlayer.addEventListener('click', (e) => { + if (!e.isTrusted) return; + e.preventDefault(); + e.stopImmediatePropagation(); + this.dispatchEvent(new CustomEvent(DDGVideoDrawerMobile.OPT_IN, { detail: { remember: remember.checked } })); + }); + + overlay.addEventListener('animationend', () => { + this.animationState = 'idle'; + }); + } +} diff --git a/injected/src/features/duckplayer/components/ddg-video-overlay.js b/injected/src/features/duckplayer/components/ddg-video-overlay.js index 9712b637fb..8b797d5dbc 100644 --- a/injected/src/features/duckplayer/components/ddg-video-overlay.js +++ b/injected/src/features/duckplayer/components/ddg-video-overlay.js @@ -15,7 +15,7 @@ export class DDGVideoOverlay extends HTMLElement { static CUSTOM_TAG_NAME = 'ddg-video-overlay'; /** * @param {object} options - * @param {import("../overlays.js").Environment} options.environment + * @param {import("../environment.js").Environment} options.environment * @param {import("../util").VideoParams} options.params * @param {import("../../duck-player.js").UISettings} options.ui * @param {VideoOverlay} options.manager diff --git a/injected/src/features/duckplayer/components/ddg-video-thumbnail-overlay-mobile.js b/injected/src/features/duckplayer/components/ddg-video-thumbnail-overlay-mobile.js new file mode 100644 index 0000000000..f13911248f --- /dev/null +++ b/injected/src/features/duckplayer/components/ddg-video-thumbnail-overlay-mobile.js @@ -0,0 +1,46 @@ +import mobilecss from '../assets/mobile-video-thumbnail-overlay.css'; +import { createPolicy, html } from '../../../dom-utils.js'; + +/** + * @typedef {ReturnType} TextVariants + * @typedef {TextVariants[keyof TextVariants]} Text + */ + +/** + * The custom element that we use to present our UI elements + * over the YouTube player + */ +export class DDGVideoThumbnailOverlay extends HTMLElement { + static CUSTOM_TAG_NAME = 'ddg-video-thumbnail-overlay-mobile'; + + policy = createPolicy(); + /** @type {boolean} */ + testMode = false; + + connectedCallback() { + this.createMarkupAndStyles(); + } + + createMarkupAndStyles() { + const shadow = this.attachShadow({ mode: this.testMode ? 'open' : 'closed' }); + const style = document.createElement('style'); + style.innerText = mobilecss; + const container = document.createElement('div'); + const content = this.mobileHtml(); + container.innerHTML = this.policy.createHTML(content); + shadow.append(style, container); + this.container = container; + } + + /** + * @returns {string} + */ + mobileHtml() { + return html` +
    +
    + +
    + `.toString(); + } +} diff --git a/injected/src/features/duckplayer/components/index.js b/injected/src/features/duckplayer/components/index.js index 730b22e9ec..4ab9f52e7e 100644 --- a/injected/src/features/duckplayer/components/index.js +++ b/injected/src/features/duckplayer/components/index.js @@ -1,6 +1,8 @@ import { DDGVideoOverlay } from './ddg-video-overlay.js'; import { customElementsDefine, customElementsGet } from '../../../captured-globals.js'; import { DDGVideoOverlayMobile } from './ddg-video-overlay-mobile.js'; +import { DDGVideoThumbnailOverlay } from './ddg-video-thumbnail-overlay-mobile.js'; +import { DDGVideoDrawerMobile } from './ddg-video-drawer-mobile.js'; /** * Register custom elements in this wrapper function to be called only when we need to @@ -14,4 +16,10 @@ export function registerCustomElements() { if (!customElementsGet(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)) { customElementsDefine(DDGVideoOverlayMobile.CUSTOM_TAG_NAME, DDGVideoOverlayMobile); } + if (!customElementsGet(DDGVideoDrawerMobile.CUSTOM_TAG_NAME)) { + customElementsDefine(DDGVideoDrawerMobile.CUSTOM_TAG_NAME, DDGVideoDrawerMobile); + } + if (!customElementsGet(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME)) { + customElementsDefine(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME, DDGVideoThumbnailOverlay); + } } diff --git a/injected/src/features/duckplayer/environment.js b/injected/src/features/duckplayer/environment.js new file mode 100644 index 0000000000..ac9942ae07 --- /dev/null +++ b/injected/src/features/duckplayer/environment.js @@ -0,0 +1,115 @@ +import strings from '../../../../build/locales/duckplayer-locales.js'; + +export class Environment { + allowedProxyOrigins = ['duckduckgo.com']; + _strings = JSON.parse(strings); + + /** + * @param {object} params + * @param {{name: string}} params.platform + * @param {boolean|null|undefined} [params.debug] + * @param {ImportMeta['injectName']} params.injectName + * @param {string} params.locale + */ + constructor(params) { + this.debug = Boolean(params.debug); + this.injectName = params.injectName; + this.platform = params.platform; + this.locale = params.locale; + } + + /** + * @param {"overlays.json" | "native.json"} named + * @returns {Record} + */ + strings(named) { + const matched = this._strings[this.locale]; + if (matched) return matched[named]; + return this._strings.en[named]; + } + + /** + * This is the URL of the page that the user is currently on + * It's abstracted so that we can mock it in tests + * @return {string} + */ + getPlayerPageHref() { + if (this.debug) { + const url = new URL(window.location.href); + if (url.hostname === 'www.youtube.com') return window.location.href; + + // reflect certain query params, this is useful for testing + if (url.searchParams.has('v')) { + const base = new URL('/watch', 'https://youtube.com'); + base.searchParams.set('v', url.searchParams.get('v') || ''); + return base.toString(); + } + + return 'https://youtube.com/watch?v=123'; + } + return window.location.href; + } + + getLargeThumbnailSrc(videoId) { + const url = new URL(`/vi/${videoId}/maxresdefault.jpg`, 'https://i.ytimg.com'); + return url.href; + } + + setHref(href) { + window.location.href = href; + } + + hasOneTimeOverride() { + try { + // #ddg-play is a hard requirement, regardless of referrer + if (window.location.hash !== '#ddg-play') return false; + + // double-check that we have something that might be a parseable URL + if (typeof document.referrer !== 'string') return false; + if (document.referrer.length === 0) return false; // can be empty! + + const { hostname } = new URL(document.referrer); + const isAllowed = this.allowedProxyOrigins.includes(hostname); + return isAllowed; + } catch (e) { + console.error(e); + } + return false; + } + + isIntegrationMode() { + return this.debug === true && this.injectName === 'integration'; + } + + isTestMode() { + return this.debug === true; + } + + get opensVideoOverlayLinksViaMessage() { + return this.platform.name !== 'windows'; + } + + /** + * @return {boolean} + */ + get isMobile() { + return this.platform.name === 'ios' || this.platform.name === 'android'; + } + + /** + * @return {boolean} + */ + get isDesktop() { + return !this.isMobile; + } + + /** + * @return {'desktop' | 'mobile'} + */ + get layout() { + if (this.platform.name === 'ios' || this.platform.name === 'android') { + return 'mobile'; + } + return 'desktop'; + } +} diff --git a/injected/src/features/duckplayer/overlay-messages.js b/injected/src/features/duckplayer/overlay-messages.js index a394f6e25b..3e61e5f471 100644 --- a/injected/src/features/duckplayer/overlay-messages.js +++ b/injected/src/features/duckplayer/overlay-messages.js @@ -12,7 +12,7 @@ import * as constants from './constants.js'; export class DuckPlayerOverlayMessages { /** * @param {Messaging} messaging - * @param {import('./overlays.js').Environment} environment + * @param {import('./environment.js').Environment} environment * @internal */ constructor(messaging, environment) { @@ -156,7 +156,8 @@ export class Pixel { * @param {{name: "overlay"} * | {name: "play.use", remember: "0" | "1"} * | {name: "play.use.thumbnail"} - * | {name: "play.do_not_use", remember: "0" | "1"}} input + * | {name: "play.do_not_use", remember: "0" | "1"} + * | {name: "play.do_not_use.dismiss"}} input */ constructor(input) { this.input = input; @@ -176,6 +177,8 @@ export class Pixel { case 'play.do_not_use': { return { remember: this.input.remember }; } + case 'play.do_not_use.dismiss': + return {}; default: throw new Error('unreachable'); } diff --git a/injected/src/features/duckplayer/overlays.js b/injected/src/features/duckplayer/overlays.js index 4fc4909ffb..9de25b5442 100644 --- a/injected/src/features/duckplayer/overlays.js +++ b/injected/src/features/duckplayer/overlays.js @@ -2,7 +2,6 @@ import { DomState } from './util.js'; import { ClickInterception, Thumbnails } from './thumbnails.js'; import { VideoOverlay } from './video-overlay.js'; import { registerCustomElements } from './components/index.js'; -import strings from '../../../../build/locales/duckplayer-locales.js'; /** * @typedef {object} OverlayOptions @@ -10,12 +9,12 @@ import strings from '../../../../build/locales/duckplayer-locales.js'; * @property {import("../duck-player.js").OverlaysFeatureSettings} settings * @property {import("../duck-player.js").DuckPlayerOverlayMessages} messages * @property {import("../duck-player.js").UISettings} ui - * @property {Environment} environment + * @property {import("./environment.js").Environment} environment */ /** * @param {import("../duck-player.js").OverlaysFeatureSettings} settings - methods to read environment-sensitive things like the current URL etc - * @param {import("./overlays.js").Environment} environment - methods to read environment-sensitive things like the current URL etc + * @param {import("./environment.js").Environment} environment - methods to read environment-sensitive things like the current URL etc * @param {import("./overlay-messages.js").DuckPlayerOverlayMessages} messages - methods to communicate with a native backend */ export async function initOverlays(settings, environment, messages) { @@ -27,12 +26,12 @@ export async function initOverlays(settings, environment, messages) { try { initialSetup = await messages.initialSetup(); } catch (e) { - console.error(e); + console.warn(e); return; } if (!initialSetup) { - console.error('cannot continue without user settings'); + console.warn('cannot continue without user settings'); return; } @@ -167,113 +166,3 @@ function videoOverlaysFeatureFromSettings({ userValues, settings, messages, envi return new VideoOverlay({ userValues, settings, environment, messages, ui }); } - -export class Environment { - allowedProxyOrigins = ['duckduckgo.com']; - _strings = JSON.parse(strings); - - /** - * @param {object} params - * @param {{name: string}} params.platform - * @param {boolean|null|undefined} [params.debug] - * @param {ImportMeta['injectName']} params.injectName - * @param {string} params.locale - */ - constructor(params) { - this.debug = Boolean(params.debug); - this.injectName = params.injectName; - this.platform = params.platform; - this.locale = params.locale; - } - - get strings() { - const matched = this._strings[this.locale]; - if (matched) return matched['overlays.json']; - return this._strings.en['overlays.json']; - } - - /** - * This is the URL of the page that the user is currently on - * It's abstracted so that we can mock it in tests - * @return {string} - */ - getPlayerPageHref() { - if (this.debug) { - const url = new URL(window.location.href); - if (url.hostname === 'www.youtube.com') return window.location.href; - - // reflect certain query params, this is useful for testing - if (url.searchParams.has('v')) { - const base = new URL('/watch', 'https://youtube.com'); - base.searchParams.set('v', url.searchParams.get('v') || ''); - return base.toString(); - } - - return 'https://youtube.com/watch?v=123'; - } - return window.location.href; - } - - getLargeThumbnailSrc(videoId) { - const url = new URL(`/vi/${videoId}/maxresdefault.jpg`, 'https://i.ytimg.com'); - return url.href; - } - - setHref(href) { - window.location.href = href; - } - - hasOneTimeOverride() { - try { - // #ddg-play is a hard requirement, regardless of referrer - if (window.location.hash !== '#ddg-play') return false; - - // double-check that we have something that might be a parseable URL - if (typeof document.referrer !== 'string') return false; - if (document.referrer.length === 0) return false; // can be empty! - - const { hostname } = new URL(document.referrer); - const isAllowed = this.allowedProxyOrigins.includes(hostname); - return isAllowed; - } catch (e) { - console.error(e); - } - return false; - } - - isIntegrationMode() { - return this.debug === true && this.injectName === 'integration'; - } - - isTestMode() { - return this.debug === true; - } - - get opensVideoOverlayLinksViaMessage() { - return this.platform.name !== 'windows'; - } - - /** - * @return {boolean} - */ - get isMobile() { - return this.platform.name === 'ios' || this.platform.name === 'android'; - } - - /** - * @return {boolean} - */ - get isDesktop() { - return !this.isMobile; - } - - /** - * @return {'desktop' | 'mobile'} - */ - get layout() { - if (this.platform.name === 'ios' || this.platform.name === 'android') { - return 'mobile'; - } - return 'desktop'; - } -} diff --git a/injected/src/features/duckplayer/thumbnails.js b/injected/src/features/duckplayer/thumbnails.js index aad5b64f80..4192408090 100644 --- a/injected/src/features/duckplayer/thumbnails.js +++ b/injected/src/features/duckplayer/thumbnails.js @@ -54,13 +54,13 @@ import { SideEffects, VideoParams } from './util.js'; import { IconOverlay } from './icon-overlay.js'; -import { Environment } from './overlays.js'; +import { Environment } from './environment.js'; import { OpenInDuckPlayerMsg, Pixel } from './overlay-messages.js'; /** * @typedef ThumbnailParams * @property {import("../duck-player.js").OverlaysFeatureSettings} settings - * @property {import("./overlays.js").Environment} environment + * @property {import("./environment.js").Environment} environment * @property {import("../duck-player.js").DuckPlayerOverlayMessages} messages */ diff --git a/injected/src/features/duckplayer/util.js b/injected/src/features/duckplayer/util.js index d39d44615d..30b6f96cb9 100644 --- a/injected/src/features/duckplayer/util.js +++ b/injected/src/features/duckplayer/util.js @@ -1,17 +1,4 @@ /* eslint-disable promise/prefer-await-to-then */ -/** - * Add an event listener to an element that is only executed if it actually comes from a user action - * @param {Element} element - to attach event to - * @param {string} event - * @param {function} callback - */ -export function addTrustedEventListener(element, event, callback) { - element.addEventListener(event, (e) => { - if (e.isTrusted) { - callback(e); - } - }); -} /** * Try to load an image first. If the status code is 2xx, then continue @@ -116,9 +103,11 @@ export class SideEffects { /** * Remove elements, event listeners etc + * @param {string} [name] */ - destroy() { - for (const cleanup of this._cleanups) { + destroy(name) { + const cleanups = name ? this._cleanups.filter((c) => c.name === name) : this._cleanups; + for (const cleanup of cleanups) { if (typeof cleanup.fn === 'function') { try { if (this.debug) { @@ -132,7 +121,11 @@ export class SideEffects { throw new Error('invalid cleanup'); } } - this._cleanups = []; + if (name) { + this._cleanups = this._cleanups.filter((c) => c.name !== name); + } else { + this._cleanups = []; + } } } @@ -179,6 +172,16 @@ export class VideoParams { return duckUrl.href; } + /** + * Get the large thumbnail URL for the current video id + * + * @returns {string} + */ + toLargeThumbnailUrl() { + const url = new URL(`/vi/${this.id}/maxresdefault.jpg`, 'https://i.ytimg.com'); + return url.href; + } + /** * Create a VideoParams instance from a href, only if it's on the watch page * @@ -235,13 +238,6 @@ export class VideoParams { const vParam = url.searchParams.get('v'); const tParam = url.searchParams.get('t'); - // don't continue if 'list' is present, but 'index' is not. - // valid: '/watch?v=321&list=123&index=1234' - // invalid: '/watch?v=321&list=123' <- index absent - if (url.searchParams.has('list') && !url.searchParams.has('index')) { - return null; - } - let time = null; // ensure youtube video id is good @@ -282,3 +278,45 @@ export class DomState { this.loadedCallbacks.push(loadedCallback); } } + +export class Logger { + /** @type {string} */ + id; + /** @type {() => boolean} */ + shouldLog; + + /** + * @param {object} options + * @param {string} options.id - Prefix added to log output + * @param {() => boolean} options.shouldLog - Tells logger whether to output to console + */ + constructor({ id, shouldLog }) { + if (!id || !shouldLog) { + throw new Error('Missing props in Logger'); + } + this.shouldLog = shouldLog; + this.id = id; + } + + error(...args) { + this.output(console.error, args); + } + + info(...args) { + this.output(console.info, args); + } + + log(...args) { + this.output(console.log, args); + } + + warn(...args) { + this.output(console.warn, args); + } + + output(handler, args) { + if (this.shouldLog()) { + handler(`${this.id.padEnd(20, ' ')} |`, ...args); + } + } +} diff --git a/injected/src/features/duckplayer/video-overlay.js b/injected/src/features/duckplayer/video-overlay.js index fc391f2c82..129de68800 100644 --- a/injected/src/features/duckplayer/video-overlay.js +++ b/injected/src/features/duckplayer/video-overlay.js @@ -25,12 +25,14 @@ * - if the user previously clicked 'watch here + remember', just add the small dax * - otherwise, stop the video playing + append our overlay */ -import { SideEffects, VideoParams } from './util.js'; +import { SideEffects, VideoParams, appendImageAsBackground } from './util.js'; import { DDGVideoOverlay } from './components/ddg-video-overlay.js'; import { OpenInDuckPlayerMsg, Pixel } from './overlay-messages.js'; import { IconOverlay } from './icon-overlay.js'; import { mobileStrings } from './text.js'; import { DDGVideoOverlayMobile } from './components/ddg-video-overlay-mobile.js'; +import { DDGVideoThumbnailOverlay } from './components/ddg-video-thumbnail-overlay-mobile.js'; +import { DDGVideoDrawerMobile } from './components/ddg-video-drawer-mobile.js'; /** * Handle the switch between small & large overlays @@ -49,7 +51,7 @@ export class VideoOverlay { * @param {object} options * @param {import("../duck-player.js").UserValues} options.userValues * @param {import("../duck-player.js").OverlaysFeatureSettings} options.settings - * @param {import("./overlays.js").Environment} options.environment + * @param {import("./environment.js").Environment} options.environment * @param {import("./overlay-messages.js").DuckPlayerOverlayMessages} options.messages * @param {import("../duck-player.js").UISettings} options.ui */ @@ -177,8 +179,9 @@ export class VideoOverlay { * Don't continue until we've been able to find the HTML elements that we inject into */ const videoElement = document.querySelector(this.settings.selectors.videoElement); - const playerContainer = document.querySelector(this.settings.selectors.videoElementContainer); - if (!videoElement || !playerContainer) { + const targetElement = document.querySelector(this.settings.selectors.videoElementContainer); + + if (!videoElement || !targetElement) { return null; } @@ -217,50 +220,120 @@ export class VideoOverlay { // if we get here, we're trying to prevent the video playing this.stopVideoFromPlaying(); - this.appendOverlayToPage(playerContainer, params); + + if (this.environment.layout === 'mobile') { + if (this.shouldShowDrawerVariant()) { + const drawerTargetElement = document.querySelector(/** @type {string} */ (this.settings.selectors.drawerContainer)); + if (drawerTargetElement) { + return this.appendMobileDrawer(targetElement, drawerTargetElement, params); + } + } + + return this.appendMobileOverlay(targetElement, params); + } + + return this.appendDesktopOverlay(targetElement, params); } } } + shouldShowDrawerVariant() { + return this.settings.videoDrawer?.state === 'enabled' && this.settings.selectors.drawerContainer; + } + + /** + * @param {Element} targetElement + * @param {import("./util").VideoParams} params + */ + appendMobileOverlay(targetElement, params) { + this.messages.sendPixel(new Pixel({ name: 'overlay' })); + + this.sideEffects.add(`appending ${DDGVideoOverlayMobile.CUSTOM_TAG_NAME} to the page`, () => { + const elem = /** @type {DDGVideoOverlayMobile} */ (document.createElement(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)); + elem.testMode = this.environment.isTestMode(); + elem.text = mobileStrings(this.environment.strings('overlays.json')); + elem.addEventListener(DDGVideoOverlayMobile.OPEN_INFO, () => this.messages.openInfo()); + elem.addEventListener(DDGVideoOverlayMobile.OPT_OUT, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { + return this.mobileOptOut(e.detail.remember).catch(console.error); + }); + elem.addEventListener(DDGVideoOverlayMobile.OPT_IN, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { + return this.mobileOptIn(e.detail.remember, params).catch(console.error); + }); + targetElement.appendChild(elem); + + return () => { + document.querySelector(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)?.remove(); + }; + }); + } + /** * @param {Element} targetElement + * @param {Element} drawerTargetElement * @param {import("./util").VideoParams} params */ - appendOverlayToPage(targetElement, params) { - this.sideEffects.add(`appending ${DDGVideoOverlay.CUSTOM_TAG_NAME} or ${DDGVideoOverlayMobile.CUSTOM_TAG_NAME} to the page`, () => { - this.messages.sendPixel(new Pixel({ name: 'overlay' })); - const controller = new AbortController(); - const { environment } = this; - - if (this.environment.layout === 'mobile') { - const elem = /** @type {DDGVideoOverlayMobile} */ (document.createElement(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)); - elem.testMode = this.environment.isTestMode(); - elem.text = mobileStrings(this.environment.strings); - elem.addEventListener(DDGVideoOverlayMobile.OPEN_INFO, () => this.messages.openInfo()); - elem.addEventListener(DDGVideoOverlayMobile.OPT_OUT, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { + appendMobileDrawer(targetElement, drawerTargetElement, params) { + this.messages.sendPixel(new Pixel({ name: 'overlay' })); + + this.sideEffects.add( + `appending ${DDGVideoDrawerMobile.CUSTOM_TAG_NAME} and ${DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME} to the page`, + () => { + const thumbnailOverlay = /** @type {DDGVideoThumbnailOverlay} */ ( + document.createElement(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME) + ); + thumbnailOverlay.testMode = this.environment.isTestMode(); + targetElement.appendChild(thumbnailOverlay); + + const drawer = /** @type {DDGVideoDrawerMobile} */ (document.createElement(DDGVideoDrawerMobile.CUSTOM_TAG_NAME)); + drawer.testMode = this.environment.isTestMode(); + drawer.text = mobileStrings(this.environment.strings('overlays.json')); + drawer.addEventListener(DDGVideoDrawerMobile.OPEN_INFO, () => this.messages.openInfo()); + drawer.addEventListener(DDGVideoDrawerMobile.OPT_OUT, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { return this.mobileOptOut(e.detail.remember).catch(console.error); }); - elem.addEventListener(DDGVideoOverlayMobile.OPT_IN, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { - return this.mobileOptIn(e.detail.remember, params).catch(console.error); + drawer.addEventListener(DDGVideoDrawerMobile.DISMISS, () => { + return this.dismissOverlay(); }); - targetElement.appendChild(elem); - } else { - const elem = new DDGVideoOverlay({ - environment, - params, - ui: this.ui, - manager: this, + drawer.addEventListener(DDGVideoDrawerMobile.THUMBNAIL_CLICK, () => { + return this.dismissOverlay(); }); - targetElement.appendChild(elem); - } + drawer.addEventListener(DDGVideoDrawerMobile.OPT_IN, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { + return this.mobileOptIn(e.detail.remember, params).catch(console.error); + }); + drawerTargetElement.appendChild(drawer); + + if (thumbnailOverlay.container) { + this.appendThumbnail(thumbnailOverlay.container); + } + + return () => { + document.querySelector(DDGVideoThumbnailOverlay.CUSTOM_TAG_NAME)?.remove(); + drawer?.onAnimationEnd(() => { + document.querySelector(DDGVideoDrawerMobile.CUSTOM_TAG_NAME)?.remove(); + }); + }; + }, + ); + } + + /** + * @param {Element} targetElement + * @param {import("./util").VideoParams} params + */ + appendDesktopOverlay(targetElement, params) { + this.messages.sendPixel(new Pixel({ name: 'overlay' })); + + this.sideEffects.add(`appending ${DDGVideoOverlay.CUSTOM_TAG_NAME} to the page`, () => { + const elem = new DDGVideoOverlay({ + environment: this.environment, + params, + ui: this.ui, + manager: this, + }); + targetElement.appendChild(elem); - /** - * To cleanup just find and remove the element - */ return () => { document.querySelector(DDGVideoOverlay.CUSTOM_TAG_NAME)?.remove(); - document.querySelector(DDGVideoOverlayMobile.CUSTOM_TAG_NAME)?.remove(); - controller.abort(); }; }); } @@ -296,6 +369,17 @@ export class VideoOverlay { }); } + /** + * @param {HTMLElement} overlayElement + */ + appendThumbnail(overlayElement) { + const params = VideoParams.forWatchPage(this.environment.getPlayerPageHref()); + const videoId = params?.id; + + const imageUrl = this.environment.getLargeThumbnailSrc(videoId); + appendImageAsBackground(overlayElement, '.ddg-vpo-bg', imageUrl); + } + /** * If the checkbox was checked, this action means that we want to 'always' * use the private player @@ -424,6 +508,13 @@ export class VideoOverlay { this.destroy(); } + dismissOverlay() { + const pixel = new Pixel({ name: 'play.do_not_use.dismiss' }); + this.messages.sendPixel(pixel); + + return this.destroy(); + } + /** * Remove elements, event listeners etc */ diff --git a/injected/src/features/element-hiding.js b/injected/src/features/element-hiding.js index 84974ff627..25629f583e 100644 --- a/injected/src/features/element-hiding.js +++ b/injected/src/features/element-hiding.js @@ -1,5 +1,57 @@ import ContentFeature from '../content-feature'; -import { isBeingFramed, DDGProxy, DDGReflect, injectGlobalStyles } from '../utils'; +import { isBeingFramed, injectGlobalStyles } from '../utils'; + +/** + * @typedef {Object} ElementHidingValue + * @property {string} property + * @property {string} value + */ + +/** + * @typedef {Object} ElementHidingRuleHide + * @property {string} selector + * @property {'hide-empty' | 'hide' | 'closest-empty' | 'override'} type + */ + +/** + * @typedef {Object} ElementHidingRuleModify + * @property {string} selector + * @property {'modify-style' | 'modify-attr'} type + * @property {ElementHidingValue[]} values + */ + +/** + * @typedef {Object} ElementHidingRuleWithoutSelector + * @property {'disable-default'} type + */ + +/** + * @typedef {ElementHidingRuleHide | ElementHidingRuleModify | ElementHidingRuleWithoutSelector} ElementHidingRule + */ + +/** + * @typedef {Object} ElementHidingDomain + * @property {string | string[]} domain + * @property {ElementHidingRule[]} rules + */ + +/** + * @typedef {Object} StyleTagException + * @property {string} domain + * @property {string} reason + */ + +/** + * @typedef {Object} ElementHidingConfiguration + * @property {boolean} [useStrictHideStyleTag] + * @property {ElementHidingRule[]} rules + * @property {ElementHidingDomain[]} domains + * @property {number[]} [hideTimeouts] + * @property {number[]} [unhideTimeouts] + * @property {string} [mediaAndFormSelectors] + * @property {string[]} [adLabelStrings] + * @property {StyleTagException[]} [styleTagExceptions] + */ let adLabelStrings = []; const parser = new DOMParser(); @@ -7,6 +59,7 @@ let hiddenElements = new WeakMap(); let modifiedElements = new WeakMap(); let appliedRules = new Set(); let shouldInjectStyleTag = false; +let styleTagInjected = false; let mediaAndFormSelectors = 'video,canvas,embed,object,audio,map,form,input,textarea,select,option,button'; let hideTimeouts = [0, 100, 300, 500, 1000, 2000, 3000]; let unhideTimeouts = [1250, 2250, 3000]; @@ -17,7 +70,7 @@ let featureInstance; /** * Hide DOM element if rule conditions met * @param {HTMLElement} element - * @param {Object} rule + * @param {ElementHidingRule} rule * @param {HTMLElement} [previousElement] */ function collapseDomNode(element, rule, previousElement) { @@ -66,7 +119,7 @@ function collapseDomNode(element, rule, previousElement) { /** * Unhide previously hidden DOM element if content loaded into it * @param {HTMLElement} element - * @param {Object} rule + * @param {ElementHidingRule} rule */ function expandNonEmptyDomNode(element, rule) { if (!element) { @@ -184,9 +237,7 @@ function isDomNodeEmpty(node) { /** * Modify specified attribute(s) on element * @param {HTMLElement} element - * @param {Object[]} values - * @param {string} values[].property - * @param {string} values[].value + * @param {ElementHidingValue[]} values */ function modifyAttribute(element, values) { values.forEach((item) => { @@ -198,9 +249,7 @@ function modifyAttribute(element, values) { /** * Modify specified style(s) on element * @param {HTMLElement} element - * @param {Object[]} values - * @param {string} values[].property - * @param {string} values[].value + * @param {ElementHidingValue[]} values */ function modifyStyle(element, values) { values.forEach((item) => { @@ -211,9 +260,7 @@ function modifyStyle(element, values) { /** * Separate strict hide rules to inject as style tag if enabled - * @param {Object[]} rules - * @param {string} rules[].selector - * @param {string} rules[].type + * @param {ElementHidingRule[]} rules */ function extractTimeoutRules(rules) { if (!shouldInjectStyleTag) { @@ -237,39 +284,40 @@ function extractTimeoutRules(rules) { /** * Create styletag for strict hide rules and append it to the document - * @param {Object[]} rules - * @param {string} rules[].selector - * @param {string} rules[].type + * @param {ElementHidingRule[]} rules */ function injectStyleTag(rules) { + // if style tag already injected on SPA url change, don't inject again + if (styleTagInjected) { + return; + } // wrap selector list in :is(...) to make it a forgiving selector list. this enables // us to use selectors not supported in all browsers, eg :has in Firefox let selector = ''; rules.forEach((rule, i) => { if (i !== rules.length - 1) { - selector = selector.concat(rule.selector, ','); + selector = selector.concat(/** @type {ElementHidingRuleHide | ElementHidingRuleModify} */ (rule).selector, ','); } else { - selector = selector.concat(rule.selector); + selector = selector.concat(/** @type {ElementHidingRuleHide | ElementHidingRuleModify} */ (rule).selector); } }); const styleTagProperties = 'display:none!important;min-height:0!important;height:0!important;'; const styleTagContents = `${forgivingSelector(selector)} {${styleTagProperties}}`; injectGlobalStyles(styleTagContents); + styleTagInjected = true; } /** * Apply list of active element hiding rules to page - * @param {Object[]} rules - * @param {string} rules[].selector - * @param {string} rules[].type + * @param {ElementHidingRule[]} rules */ function hideAdNodes(rules) { const document = globalThis.document; rules.forEach((rule) => { - const selector = forgivingSelector(rule.selector); + const selector = forgivingSelector(/** @type {ElementHidingRuleHide | ElementHidingRuleModify} */ (rule).selector); const matchingElementArray = [...document.querySelectorAll(selector)]; matchingElementArray.forEach((element) => { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f @@ -311,22 +359,29 @@ export default class ElementHiding extends ContentFeature { } let activeRules; + /** @type {ElementHidingRule[]} */ const globalRules = this.getFeatureSetting('rules'); - adLabelStrings = this.getFeatureSetting('adLabelStrings'); - shouldInjectStyleTag = this.getFeatureSetting('useStrictHideStyleTag'); + /** @type {string[]} */ + adLabelStrings = this.getFeatureSetting('adLabelStrings') || []; + /** @type {boolean} */ + shouldInjectStyleTag = this.getFeatureSetting('useStrictHideStyleTag') || false; + /** @type {number[]} */ hideTimeouts = this.getFeatureSetting('hideTimeouts') || hideTimeouts; + /** @type {number[]} */ unhideTimeouts = this.getFeatureSetting('unhideTimeouts') || unhideTimeouts; - mediaAndFormSelectors = this.getFeatureSetting('mediaAndFormSelectors') || mediaAndFormSelectors; + /** @type {string} */ + mediaAndFormSelectors = this.getFeatureSetting('mediaAndFormSelectors'); + // Fall back to default value if setting is missing, null, empty, or other falsy values + if (!mediaAndFormSelectors) { + mediaAndFormSelectors = 'video,canvas,embed,object,audio,map,form,input,textarea,select,option,button'; + } - // determine whether strict hide rules should be injected as a style tag if (shouldInjectStyleTag) { - // @ts-expect-error: Accessing private method - shouldInjectStyleTag = this.matchDomainFeatureSetting('styleTagExceptions').length === 0; + shouldInjectStyleTag = this.matchConditionalFeatureSetting('styleTagExceptions').length === 0; } // collect all matching rules for domain - // @ts-expect-error: Accessing private method - const activeDomainRules = this.matchDomainFeatureSetting('domains').flatMap((item) => item.rules); + const activeDomainRules = this.matchConditionalFeatureSetting('domains').flatMap((item) => item.rules); const overrideRules = activeDomainRules.filter((rule) => { return rule.type === 'override'; @@ -362,26 +417,18 @@ export default class ElementHiding extends ContentFeature { } else { applyRules(activeRules); } - // single page applications don't have a DOMContentLoaded event on navigations, so - // we use proxy/reflect on history.pushState to call applyRules on page navigations - const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', { - apply(target, thisArg, args) { - applyRules(activeRules); - return DDGReflect.apply(target, thisArg, args); - }, - }); - historyMethodProxy.overload(); - // listen for popstate events in order to run on back/forward navigations - window.addEventListener('popstate', () => { - applyRules(activeRules); - }); + this.activeRules = activeRules; + } + + urlChanged() { + if (this.activeRules) { + this.applyRules(this.activeRules); + } } /** * Apply relevant hiding rules to page at set intervals - * @param {Object[]} rules - * @param {string} rules[].selector - * @param {string} rules[].type + * @param {ElementHidingRule[]} rules */ applyRules(rules) { const timeoutRules = extractTimeoutRules(rules); diff --git a/injected/src/features/favicon.js b/injected/src/features/favicon.js new file mode 100644 index 0000000000..37f6f2fbfd --- /dev/null +++ b/injected/src/features/favicon.js @@ -0,0 +1,103 @@ +import ContentFeature from '../content-feature.js'; +import { isBeingFramed } from '../utils.js'; + +export class Favicon extends ContentFeature { + init() { + if (this.platform.name === 'ios') return; + + /** + * This feature never operates in a frame + */ + if (isBeingFramed()) return; + + window.addEventListener('DOMContentLoaded', () => { + // send once, immediately + this.send(); + + // then optionally watch for changes + this.monitorChanges(); + }); + } + + monitorChanges() { + // if there was an explicit opt-out, do nothing + // this allows the remote config to be absent for this feature + if (this.getFeatureSetting('monitor') === false) return; + + let trailing; + let lastEmitTime = performance.now(); + const interval = 50; + + monitor(() => { + clearTimeout(trailing); + const currentTime = performance.now(); + const delta = currentTime - lastEmitTime; + if (delta >= interval) { + this.send(); + } else { + trailing = setTimeout(() => { + this.send(); + }, 50); + } + lastEmitTime = currentTime; + }); + } + + send() { + const favicons = getFaviconList(); + this.notify('faviconFound', { favicons, documentUrl: document.URL }); + } +} + +export default Favicon; + +/** + * @param {()=>void} changeObservedCallback + */ +function monitor(changeObservedCallback) { + const target = document.head; + if (!target) return; + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes' && mutation.target instanceof HTMLLinkElement) { + changeObservedCallback(); + return; + } + if (mutation.type === 'childList') { + for (const addedNode of mutation.addedNodes) { + if (addedNode instanceof HTMLLinkElement) { + changeObservedCallback(); + return; + } + } + for (const removedNode of mutation.removedNodes) { + if (removedNode instanceof HTMLLinkElement) { + changeObservedCallback(); + return; + } + } + } + } + }); + + observer.observe(target, { attributeFilter: ['rel', 'href'], attributes: true, subtree: true, childList: true }); +} + +/** + * @returns {import('../types/favicon.js').FaviconAttrs[]} + */ +export function getFaviconList() { + const selectors = [ + "link[href][rel='favicon']", + "link[href][rel*='icon']", + "link[href][rel='apple-touch-icon']", + "link[href][rel='apple-touch-icon-precomposed']", + ]; + const elements = document.head.querySelectorAll(selectors.join(',')); + return Array.from(elements).map((/** @type {HTMLLinkElement} */ link) => { + const href = link.href || ''; + const rel = link.getAttribute('rel') || ''; + return { href, rel }; + }); +} diff --git a/injected/src/features/fingerprinting-canvas.js b/injected/src/features/fingerprinting-canvas.js index 70eed1468d..ce079310b0 100644 --- a/injected/src/features/fingerprinting-canvas.js +++ b/injected/src/features/fingerprinting-canvas.js @@ -6,6 +6,12 @@ export default class FingerprintingCanvas extends ContentFeature { init(args) { const { sessionKey, site } = args; const domainKey = site.domain; + const additionalEnabledCheck = this.getFeatureSettingEnabled('additionalEnabledCheck'); + if (!additionalEnabledCheck) { + // If additionalEnabledCheck is not enabled bail out early. + // This is a temporary measure to allow for experiment rollout without feature enabling in C-S-S experiments. + return; + } const supportsWebGl = this.getFeatureSettingEnabled('webGl'); const unsafeCanvases = new WeakSet(); @@ -41,7 +47,7 @@ export default class FingerprintingCanvas extends ContentFeature { proxy.overload(); // Known data methods - const safeMethods = ['putImageData', 'drawImage']; + const safeMethods = this.getFeatureSetting('safeMethods') ?? ['putImageData', 'drawImage']; for (const methodName of safeMethods) { const safeMethodProxy = new DDGProxy(this, CanvasRenderingContext2D.prototype, methodName, { apply(target, thisArg, args) { @@ -58,7 +64,7 @@ export default class FingerprintingCanvas extends ContentFeature { safeMethodProxy.overload(); } - const unsafeMethods = [ + const unsafeMethods = this.getFeatureSetting('unsafeMethods') ?? [ 'strokeRect', 'bezierCurveTo', 'quadraticCurveTo', @@ -94,7 +100,7 @@ export default class FingerprintingCanvas extends ContentFeature { } if (supportsWebGl) { - const unsafeGlMethods = [ + const unsafeGlMethods = this.getFeatureSetting('unsafeGlMethods') ?? [ 'commit', 'compileShader', 'shaderSource', @@ -164,7 +170,7 @@ export default class FingerprintingCanvas extends ContentFeature { return result; } - const canvasMethods = ['toDataURL', 'toBlob']; + const canvasMethods = this.getFeatureSetting('canvasMethods') ?? ['toDataURL', 'toBlob']; for (const methodName of canvasMethods) { const proxy = new DDGProxy(this, HTMLCanvasElement.prototype, methodName, { apply(target, thisArg, args) { diff --git a/injected/src/features/message-bridge.js b/injected/src/features/message-bridge.js index 1e2be75eaf..d4552c9ca6 100644 --- a/injected/src/features/message-bridge.js +++ b/injected/src/features/message-bridge.js @@ -69,7 +69,7 @@ export class MessageBridge extends ContentFeature { * @param {{name: string; id: string} & Record} incoming */ const reply = (incoming) => { - if (!args.messageSecret) return this.log('ignoring because args.messageSecret was absent'); + if (!args.messageSecret) return this.log.info('ignoring because args.messageSecret was absent'); const eventName = appendToken(incoming.name + '-' + incoming.id); const event = new captured.CustomEvent(eventName, { detail: incoming }); captured.dispatchEvent(event); @@ -82,12 +82,12 @@ export class MessageBridge extends ContentFeature { */ const accept = (ClassType, callback) => { captured.addEventListener(appendToken(ClassType.NAME), (/** @type {CustomEvent} */ e) => { - this.log(`${ClassType.NAME}`, JSON.stringify(e.detail)); + this.log.info(`${ClassType.NAME}`, JSON.stringify(e.detail)); const instance = ClassType.create(e.detail); if (instance) { callback(instance); } else { - this.log('Failed to create an instance'); + this.log.info('Failed to create an instance'); } }); }; @@ -95,7 +95,7 @@ export class MessageBridge extends ContentFeature { /** * These are all the messages we accept from the page-world. */ - this.log(`bridge is installing...`); + this.log.info(`bridge is installing...`); accept(InstallProxy, (install) => { this.installProxyFor(install, args.messagingConfig, reply); }); @@ -115,17 +115,17 @@ export class MessageBridge extends ContentFeature { */ installProxyFor(install, config, reply) { const { id, featureName } = install; - if (this.proxies.has(featureName)) return this.log('ignoring `installProxyFor` because it exists', featureName); + if (this.proxies.has(featureName)) return this.log.info('ignoring `installProxyFor` because it exists', featureName); const allowed = this.getFeatureSettingEnabled(featureName); if (!allowed) { - return this.log('not installing proxy, because', featureName, 'was not enabled'); + return this.log.info('not installing proxy, because', featureName, 'was not enabled'); } const ctx = { ...this.messaging.messagingContext, featureName }; const messaging = new Messaging(ctx, config); this.proxies.set(featureName, messaging); - this.log('did install proxy for ', featureName); + this.log.info('did install proxy for ', featureName); reply(new DidInstall({ id })); } @@ -137,9 +137,9 @@ export class MessageBridge extends ContentFeature { const { id, featureName, method, params } = request; const proxy = this.proxies.get(featureName); - if (!proxy) return this.log('proxy was not installed for ', featureName); + if (!proxy) return this.log.info('proxy was not installed for ', featureName); - this.log('will proxy', request); + this.log.info('will proxy', request); try { const result = await proxy.request(method, params); @@ -168,9 +168,9 @@ export class MessageBridge extends ContentFeature { proxySubscription(subscription, reply) { const { id, featureName, subscriptionName } = subscription; const proxy = this.proxies.get(subscription.featureName); - if (!proxy) return this.log('proxy was not installed for', featureName); + if (!proxy) return this.log.info('proxy was not installed for', featureName); - this.log('will setup subscription', subscription); + this.log.info('will setup subscription', subscription); // cleanup existing subscriptions first const prev = this.subscriptions.get(id); @@ -196,7 +196,7 @@ export class MessageBridge extends ContentFeature { */ removeSubscription(id) { const unsubscribe = this.subscriptions.get(id); - this.log(`will remove subscription`, id); + this.log.info(`will remove subscription`, id); unsubscribe?.(); this.subscriptions.delete(id); } @@ -206,22 +206,13 @@ export class MessageBridge extends ContentFeature { */ proxyNotification(notification) { const proxy = this.proxies.get(notification.featureName); - if (!proxy) return this.log('proxy was not installed for', notification.featureName); + if (!proxy) return this.log.info('proxy was not installed for', notification.featureName); - this.log('will proxy notification', notification); + this.log.info('will proxy notification', notification); proxy.notify(notification.method, notification.params); } - /** - * @param {Parameters} args - */ - log(...args) { - if (this.isDebug) { - console.log('[isolated]', ...args); - } - } - - load(args) {} + load(_args) {} } export default MessageBridge; diff --git a/injected/src/features/message-bridge/create-page-world-bridge.js b/injected/src/features/message-bridge/create-page-world-bridge.js index 2ef49910ce..11983f6f3f 100644 --- a/injected/src/features/message-bridge/create-page-world-bridge.js +++ b/injected/src/features/message-bridge/create-page-world-bridge.js @@ -11,6 +11,9 @@ import { } from './schema.js'; import { isBeingFramed } from '../../utils.js'; +/** + * @typedef {import('../../content-feature.js').default} ContentFeature + */ /** * @import { MessagingInterface } from "./schema.js" * @typedef {Pick) => void} send * @param {(s: string) => string} appendToken + * @param {ContentFeature} [context] * @returns {MessagingInterface} */ -function createMessagingInterface(featureName, send, appendToken) { +function createMessagingInterface(featureName, send, appendToken, context) { return { /** * @param {string} method * @param {Record} params */ notify(method, params) { + context?.log.info('sending notify', method, params); send( new ProxyNotification({ method, @@ -129,6 +135,7 @@ function createMessagingInterface(featureName, send, appendToken) { * @returns {Promise} */ request(method, params) { + context?.log.info('sending request', method, params); const id = random(); send( @@ -143,6 +150,7 @@ function createMessagingInterface(featureName, send, appendToken) { return new Promise((resolve, reject) => { const responseName = appendToken(ProxyResponse.NAME + '-' + id); const handler = (/** @type {CustomEvent} */ e) => { + context?.log.info('received response', e.detail); const response = ProxyResponse.create(e.detail); if (response && response.id === id) { if ('error' in response && response.error) { @@ -164,6 +172,7 @@ function createMessagingInterface(featureName, send, appendToken) { */ subscribe(name, callback) { const id = random(); + context?.log.info('subscribing', name); send( new SubscriptionRequest({ @@ -174,6 +183,7 @@ function createMessagingInterface(featureName, send, appendToken) { ); const handler = (/** @type {CustomEvent} */ e) => { + context?.log.info('received subscription response', e.detail); const subscriptionEvent = SubscriptionResponse.create(e.detail); if (subscriptionEvent) { const { id: eventId, params } = subscriptionEvent; diff --git a/injected/src/features/navigator-interface.js b/injected/src/features/navigator-interface.js index 9591a39165..26c320576d 100644 --- a/injected/src/features/navigator-interface.js +++ b/injected/src/features/navigator-interface.js @@ -2,10 +2,11 @@ import { DDGPromise } from '../utils'; import ContentFeature from '../content-feature'; import { createPageWorldBridge } from './message-bridge/create-page-world-bridge.js'; +const store = {}; + export default class NavigatorInterface extends ContentFeature { load(args) { - // @ts-expect-error: Accessing private method - if (this.matchDomainFeatureSetting('privilegedDomains').length) { + if (this.matchConditionalFeatureSetting('privilegedDomains').length) { this.injectNavigatorInterface(args); } } @@ -23,6 +24,8 @@ export default class NavigatorInterface extends ContentFeature { if (!args.platform || !args.platform.name) { return; } + // eslint-disable-next-line @typescript-eslint/no-this-alias + const context = this; this.defineProperty(Navigator.prototype, 'duckduckgo', { value: { platform: args.platform.name, @@ -36,7 +39,13 @@ export default class NavigatorInterface extends ContentFeature { * @throws {Error} */ createMessageBridge(featureName) { - return createPageWorldBridge(featureName, args.messageSecret); + const existingBridge = store[featureName]; + if (existingBridge) return existingBridge; + + const bridge = createPageWorldBridge(featureName, args.messageSecret, context); + + store[featureName] = bridge; + return bridge; }, }, enumerable: true, diff --git a/injected/src/features/page-context.js b/injected/src/features/page-context.js new file mode 100644 index 0000000000..8d375b49f0 --- /dev/null +++ b/injected/src/features/page-context.js @@ -0,0 +1,609 @@ +import ContentFeature from '../content-feature.js'; +import { getFaviconList } from './favicon.js'; +import { isDuckAi, isBeingFramed, getTabUrl } from '../utils.js'; +const MSG_PAGE_CONTEXT_RESPONSE = 'collectionResult'; + +export function checkNodeIsVisible(node) { + try { + const style = window.getComputedStyle(node); + + // Check primary visibility properties + if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) { + return false; + } + return true; + } catch (e) { + return false; + } +} + +function collapseWhitespace(str) { + return typeof str === 'string' ? str.replace(/\s+/g, ' ') : ''; +} + +/** + * Check if a node is an HTML element + * @param {Node} node + * @returns {node is HTMLElement} + **/ +function isHtmlElement(node) { + return node.nodeType === Node.ELEMENT_NODE; +} + +/** + * Check if an iframe is same-origin and return its content document + * @param {HTMLIFrameElement} iframe + * @returns {Document | null} + */ +function getSameOriginIframeDocument(iframe) { + // Pre-check conditions that would prevent access without triggering security errors + const src = iframe.src; + + // Skip sandboxed iframes unless they explicitly allow scripts + // Avoids: Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set. + // Note: iframe.sandbox always returns a DOMTokenList, so check hasAttribute instead + if (iframe.hasAttribute('sandbox') && !iframe.sandbox.contains('allow-scripts')) { + return null; + } + + // Check for cross-origin URLs (but allow about:blank and empty src as they inherit parent origin) + if (src && src !== 'about:blank' && src !== '') { + try { + const iframeUrl = new URL(src, window.location.href); + if (iframeUrl.origin !== window.location.origin) { + return null; + } + } catch (e) { + // Invalid URL, skip + return null; + } + } + + try { + // Try to access the contentDocument - this will throw if cross-origin + const doc = iframe.contentDocument; + if (doc && doc.documentElement) { + return doc; + } + } catch (e) { + // Cross-origin iframe - cannot access content + return null; + } + return null; +} + +/** + * Stringify the children of a node to markdown + * @param {NodeListOf} childNodes + * @param {DomToMarkdownSettings} settings + * @param {number} depth + * @returns {string} + */ +function domToMarkdownChildren(childNodes, settings, depth = 0) { + if (depth > settings.maxDepth) { + return ''; + } + let children = ''; + for (const childNode of childNodes) { + const childContent = domToMarkdown(childNode, settings, depth + 1); + children += childContent; + if (children.length > settings.maxLength) { + children = children.substring(0, settings.maxLength) + '...'; + break; + } + } + return children; +} + +/** + * @typedef {Object} DomToMarkdownSettings + * @property {number} maxLength - Maximum length of content + * @property {number} maxDepth - Maximum depth to traverse + * @property {string | null} excludeSelectors - CSS selectors to exclude from processing + * @property {boolean} includeIframes - Whether to include iframe content + * @property {boolean} trimBlankLinks - Whether to trim blank links + */ + +/** + * Convert a DOM node to markdown + * @param {Node} node + * @param {DomToMarkdownSettings} settings + * @param {number} depth + * @returns {string} + */ +export function domToMarkdown(node, settings, depth = 0) { + if (depth > settings.maxDepth) { + return ''; + } + if (node.nodeType === Node.TEXT_NODE) { + return collapseWhitespace(node.textContent); + } + if (!isHtmlElement(node)) { + return ''; + } + if (!checkNodeIsVisible(node) || (settings.excludeSelectors && node.matches(settings.excludeSelectors))) { + return ''; + } + + const tag = node.tagName.toLowerCase(); + + // Build children string incrementally to exit early when maxLength is exceeded + let children = domToMarkdownChildren(node.childNodes, settings, depth + 1); + + if (node.shadowRoot) { + children += domToMarkdownChildren(node.shadowRoot.childNodes, settings, depth + 1); + } + + switch (tag) { + case 'strong': + case 'b': + return `**${children}**`; + case 'em': + case 'i': + return `*${children}*`; + case 'h1': + return `\n# ${children}\n`; + case 'h2': + return `\n## ${children}\n`; + case 'h3': + return `\n### ${children}\n`; + case 'p': + return `${children}\n`; + case 'br': + return `\n`; + case 'img': + return `\n![${getAttributeOrBlank(node, 'alt')}](${getAttributeOrBlank(node, 'src')})\n`; + case 'ul': + case 'ol': + return `\n${children}\n`; + case 'li': + return `\n- ${collapseAndTrim(children)}\n`; + case 'a': + return getLinkText(node, children, settings); + case 'iframe': { + if (!settings.includeIframes) { + return children; + } + // Try to access same-origin iframe content + const iframeDoc = getSameOriginIframeDocument(/** @type {HTMLIFrameElement} */ (node)); + if (iframeDoc && iframeDoc.body) { + const iframeContent = domToMarkdown(iframeDoc.body, settings, depth + 1); + return iframeContent ? `\n\n--- Iframe Content ---\n${iframeContent}\n--- End Iframe ---\n\n` : children; + } + // If we can't access the iframe content (cross-origin), return the children or empty string + return children; + } + default: + return children; + } +} + +/** + * @param {Element} node + * @param {string} attr + * @returns {string} + */ +function getAttributeOrBlank(node, attr) { + const attrValue = node.getAttribute(attr) ?? ''; + return attrValue.trim(); +} + +function collapseAndTrim(str) { + return collapseWhitespace(str).trim(); +} + +function getLinkText(node, children, settings) { + const href = node.getAttribute('href'); + const trimmedContent = collapseAndTrim(children); + if (settings.trimBlankLinks && trimmedContent.length === 0) { + return ''; + } + // The difference in whitespace handling is intentional here. + // Where we don't wrap in a link: + // we should retain at least one preceding and following space. + return href ? `[${trimmedContent}](${href})` : collapseWhitespace(children); +} + +export default class PageContext extends ContentFeature { + /** @type {any} */ + #cachedContent = undefined; + #cachedTimestamp = 0; + /** @type {MutationObserver | null} */ + mutationObserver = null; + lastSentContent = null; + listenForUrlChanges = true; + /** @type {ReturnType | null} */ + #delayedRecheckTimer = null; + recheckCount = 0; + recheckLimit = 0; + + init() { + this.recheckLimit = this.getFeatureSetting('recheckLimit') || 5; + if (!this.shouldActivate()) { + return; + } + this.setupListeners(); + } + + resetRecheckCount() { + this.recheckCount = 0; + } + + setupListeners() { + this.observeContentChanges(); + if (this.getFeatureSettingEnabled('subscribeToCollect', 'enabled')) { + this.messaging.subscribe('collect', () => { + this.invalidateCache(); + this.handleContentCollectionRequest(); + }); + } + window.addEventListener('load', () => { + this.handleContentCollectionRequest(); + }); + if (this.getFeatureSettingEnabled('subscribeToHashChange', 'enabled')) { + window.addEventListener('hashchange', () => { + this.handleContentCollectionRequest(); + }); + } + if (this.getFeatureSettingEnabled('subscribeToPageShow', 'enabled')) { + window.addEventListener('pageshow', () => { + this.handleContentCollectionRequest(); + }); + } + if (this.getFeatureSettingEnabled('subscribeToVisibilityChange', 'enabled')) { + window.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + return; + } + this.handleContentCollectionRequest(); + }); + } + + // Set up content collection infrastructure + if (document.body) { + this.setup(); + } else { + window.addEventListener( + 'DOMContentLoaded', + () => { + this.setup(); + }, + { once: true }, + ); + } + } + + shouldActivate() { + if (isBeingFramed() || isDuckAi()) { + return false; + } + const tabUrl = getTabUrl(); + // Ignore duck:// urls for now + if (tabUrl?.protocol === 'duck:') { + return false; + } + return true; + } + + /** + * @param {NavigationType} _navigationType + */ + urlChanged(_navigationType) { + if (!this.shouldActivate()) { + return; + } + this.handleContentCollectionRequest(); + } + + setup() { + this.handleContentCollectionRequest(); + this.startObserving(); + } + + get cachedContent() { + if (!this.#cachedContent || this.isCacheExpired()) { + // Clean up if we had content but it's expired + if (this.#cachedContent) { + this.invalidateCache(); + } + return undefined; + } + return this.#cachedContent; + } + + invalidateCache() { + this.log.info('Invalidating cache'); + this.#cachedContent = undefined; + this.#cachedTimestamp = 0; + this.stopObserving(); + } + + /** + * Clear all pending timers + */ + clearTimers() { + if (this.#delayedRecheckTimer) { + clearTimeout(this.#delayedRecheckTimer); + this.#delayedRecheckTimer = null; + } + } + + set cachedContent(content) { + if (content === undefined) { + this.invalidateCache(); + return; + } + + this.#cachedContent = /** @type {any} */ (content); + this.#cachedTimestamp = Date.now(); + this.startObserving(); + } + + isCacheExpired() { + const cacheExpiration = this.getFeatureSetting('cacheExpiration') || 30000; + return Date.now() - this.#cachedTimestamp > cacheExpiration; + } + + observeContentChanges() { + // Use MutationObserver to detect content changes + if (window.MutationObserver) { + this.mutationObserver = new MutationObserver((_mutations) => { + this.log.info('MutationObserver', _mutations); + // Invalidate cache when content changes + this.cachedContent = undefined; + + this.scheduleDelayedRecheck(); + }); + } + } + + /** + * Schedule a delayed recheck after navigation events + */ + scheduleDelayedRecheck() { + // Clear any existing delayed recheck + this.clearTimers(); + if (this.recheckLimit > 0 && this.recheckCount >= this.recheckLimit) { + return; + } + + const delayMs = this.getFeatureSetting('navigationRecheckDelayMs') || 1500; + + this.log.info('Scheduling delayed recheck', { delayMs }); + this.#delayedRecheckTimer = setTimeout(() => { + this.log.info('Performing delayed recheck after navigation'); + this.recheckCount++; + this.invalidateCache(); + + this.handleContentCollectionRequest(false); + }, delayMs); + } + + startObserving() { + this.log.info('Starting observing', this.mutationObserver, this.#cachedContent); + if (this.mutationObserver && this.#cachedContent && !this.isObserving && document.body) { + this.isObserving = true; + this.mutationObserver.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + }); + } + } + + stopObserving() { + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + this.isObserving = false; + } + } + + handleContentCollectionRequest(resetRecheckCount = true) { + this.log.info('Handling content collection request'); + if (resetRecheckCount) { + this.resetRecheckCount(); + } + try { + const content = this.collectPageContent(); + this.sendContentResponse(content); + } catch (error) { + this.sendErrorResponse(error); + } + } + + collectPageContent() { + // Check cache first - getter handles expiry and cleanup + if (this.cachedContent) { + this.log.info('Returning cached content', this.cachedContent); + return this.cachedContent; + } + + const mainContent = this.getMainContent(); + const truncated = mainContent.endsWith('...'); + + const content = { + favicon: getFaviconList(), + title: this.getPageTitle(), + content: mainContent, + truncated, + fullContentLength: this.fullContentLength, // Include full content length before truncation + timestamp: Date.now(), + url: window.location.href, + }; + + if (this.getFeatureSettingEnabled('includeMetaDescription', 'disabled')) { + content.metaDescription = this.getMetaDescription(); + } + if (this.getFeatureSettingEnabled('includeHeadings', 'disabled')) { + content.headings = this.getHeadings(); + } + if (this.getFeatureSettingEnabled('includeLinks', 'disabled')) { + content.links = this.getLinks(); + } + if (this.getFeatureSettingEnabled('includeImages', 'disabled')) { + content.images = this.getImages(); + } + + // Cache the result - setter handles timestamp and observer + if (content.content.length > 0) { + this.cachedContent = content; + } + return content; + } + + getPageTitle() { + const title = document.title || ''; + const maxTitleLength = this.getFeatureSetting('maxTitleLength') || 100; + + if (title.length > maxTitleLength) { + return title.substring(0, maxTitleLength).trim() + '...'; + } + + return title; + } + + getMetaDescription() { + const metaDesc = document.querySelector('meta[name="description"]'); + return metaDesc ? metaDesc.getAttribute('content') || '' : ''; + } + + getMainContent() { + const maxLength = this.getFeatureSetting('maxContentLength') || 9500; + // Used to avoid large content serialization + const upperLimit = this.getFeatureSetting('upperLimit') || 500000; + // We should refactor to use iteration but for now this just caps overflow. + const maxDepth = this.getFeatureSetting('maxDepth') || 5000; + let excludeSelectors = this.getFeatureSetting('excludeSelectors') || ['.ad', '.sidebar', '.footer', '.nav', '.header']; + const excludedInertElements = this.getFeatureSetting('excludedInertElements') || [ + 'img', // Note we're currently disabling images which we're handling in domToMarkdown (this can be per-site enabled in the config if needed). + 'script', + 'style', + 'link', + 'meta', + 'noscript', + 'svg', + 'canvas', + ]; + excludeSelectors = excludeSelectors.concat(excludedInertElements); + const excludeSelectorsString = excludeSelectors.join(','); + + let content = ''; + // Get content from main content areas + const mainContentSelector = this.getFeatureSetting('mainContentSelector') || 'main, article, .content, .main, #content, #main'; + let mainContent = document.querySelector(mainContentSelector); + const mainContentLength = this.getFeatureSetting('mainContentLength') || 100; + // Fast path to avoid processing main content if it's too short + if (mainContent && mainContent.innerHTML.trim().length <= mainContentLength) { + mainContent = null; + } + let contentRoot = mainContent || document.body; + + // Use a closure to reuse the domToMarkdown parameters + const extractContent = (root) => { + this.log.info('Getting content', root); + const result = domToMarkdown(root, { + maxLength: upperLimit, + maxDepth, + includeIframes: this.getFeatureSettingEnabled('includeIframes', 'enabled'), + excludeSelectors: excludeSelectorsString, + trimBlankLinks: this.getFeatureSettingEnabled('trimBlankLinks', 'enabled'), + }).trim(); + this.log.info('Content markdown', result, root); + return result; + }; + + if (contentRoot) { + content += extractContent(contentRoot); + } + // If the main content is empty, use the body + if (content.length === 0 && contentRoot !== document.body && this.getFeatureSettingEnabled('bodyFallback', 'enabled')) { + contentRoot = document.body; + content += extractContent(contentRoot); + } + + // Store the full content length before truncation + this.fullContentLength = content.length; + + // Limit content length + if (content.length > maxLength) { + this.log.info('Truncating content', { + content, + contentLength: content.length, + maxLength, + }); + content = content.substring(0, maxLength) + '...'; + } + + return content; + } + + getHeadings() { + const headings = []; + const headingSelector = this.getFeatureSetting('headingSelector') || 'h1, h2, h3, h4, h5, h6'; + const headingElements = document.querySelectorAll(headingSelector); + + headingElements.forEach((heading) => { + const level = parseInt(heading.tagName.charAt(1)); + const text = heading.textContent?.trim(); + if (text) { + headings.push({ level, text }); + } + }); + + return headings; + } + + getLinks() { + const links = []; + const linkSelector = this.getFeatureSetting('linkSelector') || 'a[href]'; + const linkElements = document.querySelectorAll(linkSelector); + + linkElements.forEach((link) => { + const text = link.textContent?.trim(); + const href = link.getAttribute('href'); + if (text && href && text.length > 0) { + links.push({ text, href }); + } + }); + + return links; + } + + getImages() { + const images = []; + const imgSelector = this.getFeatureSetting('imgSelector') || 'img'; + const imgElements = document.querySelectorAll(imgSelector); + + imgElements.forEach((img) => { + const alt = img.getAttribute('alt') || ''; + const src = img.getAttribute('src') || ''; + if (src) { + images.push({ alt, src }); + } + }); + + return images; + } + + sendContentResponse(content) { + if (this.lastSentContent && this.lastSentContent === content) { + this.log.info('Content already sent'); + return; + } + this.lastSentContent = content; + this.log.info('Sending content response', content); + this.messaging.notify(MSG_PAGE_CONTEXT_RESPONSE, { + // TODO: This is a hack to get the data to the browser. We should probably not be paying this cost. + serializedPageData: JSON.stringify(content), + }); + } + + sendErrorResponse(error) { + this.log.error('Error sending content response', error); + this.messaging.notify(MSG_PAGE_CONTEXT_RESPONSE, { + success: false, + error: error.message || 'Unknown error occurred', + timestamp: Date.now(), + }); + } +} diff --git a/injected/src/features/performance-metrics.js b/injected/src/features/performance-metrics.js index 049c72e586..251b140d39 100644 --- a/injected/src/features/performance-metrics.js +++ b/injected/src/features/performance-metrics.js @@ -1,5 +1,6 @@ import ContentFeature from '../content-feature'; -import { getJsPerformanceMetrics } from './breakage-reporting/utils.js'; +import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; +import { isBeingFramed } from '../utils.js'; export default class PerformanceMetrics extends ContentFeature { init() { @@ -7,5 +8,38 @@ export default class PerformanceMetrics extends ContentFeature { const vitals = getJsPerformanceMetrics(); this.messaging.notify('vitalsResult', { vitals }); }); + + // If the document is being framed, we don't want to collect expanded performance metrics + if (isBeingFramed()) return; + + // If the feature is enabled, we want to collect expanded performance metrics + if (this.getFeatureSettingEnabled('expandedPerformanceMetricsOnLoad', 'enabled')) { + this.waitForAfterPageLoad(() => { + this.triggerExpandedPerformanceMetrics(); + }); + } + } + + waitForNextTask(callback) { + setTimeout(callback, 0); + } + + waitForAfterPageLoad(callback) { + if (document.readyState === 'complete') { + this.waitForNextTask(callback); + } else { + window.addEventListener( + 'load', + () => { + this.waitForNextTask(callback); + }, + { once: true }, + ); + } + } + + async triggerExpandedPerformanceMetrics() { + const expandedPerformanceMetrics = await getExpandedPerformanceMetrics(); + this.messaging.notify('expandedPerformanceMetricsResult', expandedPerformanceMetrics); } } diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index d761564102..47a9969de5 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -1,7 +1,8 @@ import ContentFeature from '../content-feature.js'; // eslint-disable-next-line no-redeclare import { URL } from '../captured-globals.js'; - +import { DDGProxy, DDGReflect } from '../utils'; +import { wrapToString } from '../wrapper-utils.js'; /** * Fixes incorrect sizing value for outerHeight and outerWidth */ @@ -17,11 +18,24 @@ const MSG_WEB_SHARE = 'webShare'; const MSG_PERMISSIONS_QUERY = 'permissionsQuery'; const MSG_SCREEN_LOCK = 'screenLock'; const MSG_SCREEN_UNLOCK = 'screenUnlock'; +const MSG_DEVICE_ENUMERATION = 'deviceEnumeration'; function canShare(data) { if (typeof data !== 'object') return false; - if (!('url' in data) && !('title' in data) && !('text' in data)) return false; // At least one of these is required - if ('files' in data) return false; // File sharing is not supported at the moment + // Make an in-place shallow copy of the data + data = Object.assign({}, data); + // Delete undefined or null values + for (const key of ['url', 'title', 'text', 'files']) { + if (data[key] === undefined || data[key] === null) { + delete data[key]; + } + } + // After pruning we should still have at least one of these + if (!('url' in data) && !('title' in data) && !('text' in data)) return false; + if ('files' in data) { + if (!(Array.isArray(data.files) || data.files instanceof FileList)) return false; + if (data.files.length > 0) return false; // File sharing is not supported at the moment + } if ('title' in data && typeof data.title !== 'string') return false; if ('text' in data && typeof data.text !== 'string') return false; if ('url' in data) { @@ -33,7 +47,6 @@ function canShare(data) { return false; } } - if (window !== window.top) return false; // Not supported in iframes return true; } @@ -75,6 +88,9 @@ export class WebCompat extends ContentFeature { /** @type {Promise | null} */ #activeScreenLockRequest = null; + // Opt in to receive configuration updates from initial ping responses + listenForConfigUpdates = true; + init() { if (this.getFeatureSettingEnabled('windowSizing')) { windowSizingFix(); @@ -111,10 +127,6 @@ export class WebCompat extends ContentFeature { this.shimWebShare(); } - if (this.getFeatureSettingEnabled('viewportWidth')) { - this.viewportWidthFix(); - } - if (this.getFeatureSettingEnabled('screenLock')) { this.screenLockFix(); } @@ -126,6 +138,27 @@ export class WebCompat extends ContentFeature { if (this.getFeatureSettingEnabled('modifyCookies')) { this.modifyCookies(); } + if (this.getFeatureSettingEnabled('enumerateDevices')) { + this.deviceEnumerationFix(); + } + // Used by Android in the non adsjs version + // This has to be enabled in the config for the injectName='android' now. + if (this.getFeatureSettingEnabled('viewportWidthLegacy', 'disabled')) { + this.viewportWidthFix(); + } + } + + /** + * Handle user preference updates when merged during initialization. + * Re-applies viewport fixes if viewport configuration has changed. + * Used in the injectName='android-adsjs' instead of 'viewportWidthLegacy' from init. + * @param {object} _updatedConfig - The configuration with merged user preferences + */ + onUserPreferencesMerged(_updatedConfig) { + // Re-apply viewport width fix if viewport settings might have changed + if (this.getFeatureSettingEnabled('viewportWidth')) { + this.viewportWidthFix(); + } } /** Shim Web Share API in Android WebView */ @@ -184,33 +217,52 @@ export class WebCompat extends ContentFeature { if (window.Notification) { return; } + // Expose the API + // window.Notification polyfill is intentionally incompatible with DOM lib types + const NotificationConstructor = function Notification() { + throw new TypeError("Failed to construct 'Notification': Illegal constructor"); + }; + + const wrappedNotification = wrapToString( + NotificationConstructor, + NotificationConstructor, + 'function Notification() { [native code] }', + ); + this.defineProperty(window, 'Notification', { - value: () => { - // noop - }, + value: wrappedNotification, writable: true, configurable: true, enumerable: false, }); - this.defineProperty(window.Notification, 'requestPermission', { - value: () => { - return Promise.resolve('denied'); - }, - writable: true, + this.defineProperty(window.Notification, 'permission', { + value: 'denied', + writable: false, configurable: true, enumerable: true, }); - this.defineProperty(window.Notification, 'permission', { - get: () => 'denied', + this.defineProperty(window.Notification, 'maxActions', { + get: () => 2, configurable: true, - enumerable: false, + enumerable: true, }); - this.defineProperty(window.Notification, 'maxActions', { - get: () => 2, + const requestPermissionFunc = function requestPermission() { + return Promise.resolve('denied'); + }; + + const wrappedRequestPermission = wrapToString( + requestPermissionFunc, + requestPermissionFunc, + 'function requestPermission() { [native code] }', + ); + + this.defineProperty(window.Notification, 'requestPermission', { + value: wrappedRequestPermission, + writable: true, configurable: true, enumerable: true, }); @@ -445,7 +497,7 @@ export class WebCompat extends ContentFeature { }); // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f this.defineProperty(window.safari.pushNotification, 'requestPermission', { - value: (name, domain, options, callback) => { + value: (_name, _domain, _options, callback) => { if (typeof callback === 'function') { callback(new SafariRemoteNotificationPermission()); return; @@ -463,7 +515,7 @@ export class WebCompat extends ContentFeature { mediaSessionFix() { try { - if (window.navigator.mediaSession && import.meta.injectName !== 'integration') { + if (window.navigator.mediaSession && this.injectName !== 'integration') { return; } @@ -509,7 +561,7 @@ export class WebCompat extends ContentFeature { presentationFix() { try { // @ts-expect-error due to: Property 'presentation' does not exist on type 'Navigator' - if (window.navigator.presentation && import.meta.injectName !== 'integration') { + if (window.navigator.presentation && this.injectName !== 'integration') { return; } @@ -649,6 +701,10 @@ export class WebCompat extends ContentFeature { } viewportWidthFix() { + if (this._viewportWidthFixApplied) { + return; + } + this._viewportWidthFixApplied = true; if (document.readyState === 'loading') { // if the document is not ready, we may miss the original viewport tag document.addEventListener('DOMContentLoaded', () => this.viewportWidthFixInner()); @@ -719,6 +775,11 @@ export class WebCompat extends ContentFeature { // Race condition: depending on the loading state of the page, initial scale may or may not be respected, so the page may look zoomed-in after applying this hack. // Usually this is just an annoyance, but it may be a bigger issue if user-scalable=no is set, so we remove it too. forcedValues['user-scalable'] = 'yes'; + const minimumScalePart = parsedViewportContent.find(([key]) => key === 'minimum-scale'); + if (minimumScalePart) { + // override minimum-scale to make sure you can zoom out to see the whole page. See https://app.asana.com/1/137249556945/project/1206777341262243/task/1207691440660873 + forcedValues['minimum-scale'] = 0; + } } else { // mobile mode with a viewport tag // fix an edge case where WebView forces the wide viewport @@ -748,6 +809,139 @@ export class WebCompat extends ContentFeature { this.forceViewportTag(viewportTag, newContent.join(', ')); } } + + /** + * Creates a valid MediaDeviceInfo or InputDeviceInfo object that passes instanceof checks + * @param {'videoinput' | 'audioinput' | 'audiooutput'} kind - The device kind + * @returns {MediaDeviceInfo | InputDeviceInfo} + */ + createMediaDeviceInfo(kind) { + // Create an empty object with the correct prototype + let deviceInfo; + if (kind === 'videoinput' || kind === 'audioinput') { + // Input devices should inherit from InputDeviceInfo.prototype if available + if (typeof InputDeviceInfo !== 'undefined' && InputDeviceInfo.prototype) { + deviceInfo = Object.create(InputDeviceInfo.prototype); + } else { + deviceInfo = Object.create(MediaDeviceInfo.prototype); + } + } else { + // Output devices inherit from MediaDeviceInfo.prototype + deviceInfo = Object.create(MediaDeviceInfo.prototype); + } + + // Define read-only properties from the start + Object.defineProperties(deviceInfo, { + deviceId: { + value: 'default', + writable: false, + configurable: false, + enumerable: true, + }, + kind: { + value: kind, + writable: false, + configurable: false, + enumerable: true, + }, + label: { + value: '', + writable: false, + configurable: false, + enumerable: true, + }, + groupId: { + value: 'default-group', + writable: false, + configurable: false, + enumerable: true, + }, + toJSON: { + value: function () { + return { + deviceId: this.deviceId, + kind: this.kind, + label: this.label, + groupId: this.groupId, + }; + }, + writable: false, + configurable: false, + enumerable: true, + }, + }); + + return deviceInfo; + } + + /** + * Helper to wrap a promise with timeout + * @param {Promise} promise - Promise to wrap + * @param {number} timeoutMs - Timeout in milliseconds + * @returns {Promise} Promise that rejects on timeout + */ + withTimeout(promise, timeoutMs) { + const timeout = new Promise((_resolve, reject) => setTimeout(() => reject(new Error('Request timeout')), timeoutMs)); + return Promise.race([promise, timeout]); + } + + /** + * Fixes device enumeration to handle permission prompts gracefully + */ + deviceEnumerationFix() { + if (!window.MediaDevices) { + return; + } + + const enumerateDevicesProxy = new DDGProxy(this, MediaDevices.prototype, 'enumerateDevices', { + /** + * @param {MediaDevices['enumerateDevices']} target + * @param {MediaDevices} thisArg + * @param {Parameters} args + * @returns {Promise} + */ + apply: async (target, thisArg, args) => { + const settings = this.getFeatureSetting('enumerateDevices') || {}; + const timeoutEnabled = settings.timeoutEnabled !== false; + const timeoutMs = settings.timeoutMs ?? 2000; + + try { + const messagingPromise = this.messaging.request(MSG_DEVICE_ENUMERATION, {}); + const response = timeoutEnabled ? await this.withTimeout(messagingPromise, timeoutMs) : await messagingPromise; + + // Check if native indicates that prompts would be required + if (response.willPrompt) { + // If prompts would be required, return a manipulated response + // that includes the device types that are available + /** @type {MediaDeviceInfo[]} */ + const devices = []; + + if (response.videoInput) { + devices.push(this.createMediaDeviceInfo('videoinput')); + } + + if (response.audioInput) { + devices.push(this.createMediaDeviceInfo('audioinput')); + } + + if (response.audioOutput) { + devices.push(this.createMediaDeviceInfo('audiooutput')); + } + + return Promise.resolve(devices); + } else { + // If no prompts would be required, proceed with the regular device enumeration + return DDGReflect.apply(target, thisArg, args); + } + } catch (err) { + // If the native request fails or times out, fall back to the original implementation + return DDGReflect.apply(target, thisArg, args); + } + }, + }); + + enumerateDevicesProxy.overload(); + } } /** @typedef {{title?: string, url?: string, text?: string}} ShareRequestData */ diff --git a/injected/src/features/web-telemetry.js b/injected/src/features/web-telemetry.js new file mode 100644 index 0000000000..b41b6d8495 --- /dev/null +++ b/injected/src/features/web-telemetry.js @@ -0,0 +1,144 @@ +import ContentFeature from '../content-feature.js'; + +/** + * @typedef {import('../url-change.js').NavigationType} NavigationType + */ + +const MSG_VIDEO_PLAYBACK = 'video-playback'; +const MSG_URL_CHANGED = 'url-changed'; + +export class WebTelemetry extends ContentFeature { + listenForUrlChanges = true; + + constructor(featureName, importConfig, args) { + super(featureName, importConfig, args); + this.seenVideoElements = new WeakSet(); + this.seenVideoUrls = new Set(); + } + + init() { + if (this.getFeatureSettingEnabled('videoPlayback')) { + this.videoPlaybackObserve(); + } + } + + /** + * @param {NavigationType} navigationType + */ + urlChanged(navigationType) { + if (this.getFeatureSettingEnabled('urlChanged')) { + this.fireTelemetryForUrlChanged(navigationType); + } + } + + getVideoUrl(video) { + // Try to get the video URL from various sources + if (video.src) { + return video.src; + } + if (video.currentSrc) { + return video.currentSrc; + } + // Check for source elements + const source = video.querySelector('source'); + if (source && source.src) { + return source.src; + } + return null; + } + + /** + * @param {NavigationType} navigationType + */ + fireTelemetryForUrlChanged(navigationType) { + this.messaging.notify(MSG_URL_CHANGED, { + url: window.location.href, + navigationType, + }); + } + + fireTelemetryForVideo(video) { + const videoUrl = this.getVideoUrl(video); + if (this.seenVideoUrls.has(videoUrl)) { + return; + } + // If we have a URL, store it just to deduplicate + // This will clear on page change and isn't sent to native/server. + if (videoUrl) { + this.seenVideoUrls.add(videoUrl); + } + const message = { + userInteraction: navigator.userActivation.isActive, + }; + this.messaging.notify(MSG_VIDEO_PLAYBACK, message); + } + + addPlayObserver(video) { + if (this.seenVideoElements.has(video)) { + return; // already observed + } + this.seenVideoElements.add(video); + video.addEventListener('play', () => this.fireTelemetryForVideo(video)); + } + + addListenersToAllVideos(node) { + if (!node) { + return; + } + const videos = node.querySelectorAll('video'); + videos.forEach((video) => { + this.addPlayObserver(video); + }); + } + + videoPlaybackObserve() { + if (document.body) { + this.setup(); + } else { + window.addEventListener( + 'DOMContentLoaded', + () => { + this.setup(); + }, + { once: true }, + ); + } + } + + setup() { + const documentBody = document.body; + if (!documentBody) return; + + this.addListenersToAllVideos(documentBody); + + // Backfill: fire telemetry for already playing videos + documentBody.querySelectorAll('video').forEach((video) => { + if (!video.paused && !video.ended) { + this.fireTelemetryForVideo(video); + } + }); + + const observerCallback = (mutationsList) => { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + if (node.tagName === 'VIDEO') { + this.addPlayObserver(node); + } else { + this.addListenersToAllVideos(node); + } + } + }); + } + } + }; + const observer = new MutationObserver(observerCallback); + observer.observe(documentBody, { + childList: true, + subtree: true, + }); + } +} + +export default WebTelemetry; diff --git a/injected/src/features/windows-permission-usage.js b/injected/src/features/windows-permission-usage.js index c8401a9e4e..4f28f828eb 100644 --- a/injected/src/features/windows-permission-usage.js +++ b/injected/src/features/windows-permission-usage.js @@ -17,7 +17,12 @@ export default class WindowsPermissionUsage extends ContentFeature { Paused: 'paused', }; - const isFrameInsideFrame = window.self !== window.top && window.parent !== window.top; + // isDdgWebView is a Windows-specific property injected via userPreferences + const isDdgWebView = this.args?.isDdgWebView; + + const isFrameInsideFrameInWebView2 = isDdgWebView + ? false // In DDG WebView, we can handle nested frames properly + : window.self !== window.top && window.parent !== window.top; // In WebView2, we need to deny permission for nested frames function windowsPostMessage(name, data) { // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f @@ -38,8 +43,8 @@ export default class WindowsPermissionUsage extends ContentFeature { // proxy for navigator.geolocation.watchPosition -> show red geolocation indicator const watchPositionProxy = new DDGProxy(this, Geolocation.prototype, 'watchPosition', { apply(target, thisArg, args) { - if (isFrameInsideFrame) { - // we can't communicate with iframes inside iframes -> deny permission instead of putting users at risk + if (isFrameInsideFrameInWebView2) { + // we can't communicate with iframes inside iframes in WebView2 -> deny permission instead of putting users at risk throw new DOMException('Permission denied'); } @@ -313,8 +318,8 @@ export default class WindowsPermissionUsage extends ContentFeature { if (window.MediaDevices) { const getUserMediaProxy = new DDGProxy(this, MediaDevices.prototype, 'getUserMedia', { apply(target, thisArg, args) { - if (isFrameInsideFrame) { - // we can't communicate with iframes inside iframes -> deny permission instead of putting users at risk + if (isFrameInsideFrameInWebView2) { + // we can't communicate with iframes inside iframes in WebView2-> deny permission instead of putting users at risk return Promise.reject(new DOMException('Permission denied')); } @@ -391,7 +396,9 @@ export default class WindowsPermissionUsage extends ContentFeature { ]; for (const { name, prototype, method, isPromise } of permissionsToDisable) { try { - const proxy = new DDGProxy(this, prototype(), method, { + const protoObj = prototype(); + if (!protoObj || !(method in protoObj)) continue; + const proxy = new DDGProxy(this, protoObj, method, { apply() { if (isPromise) { return Promise.reject(new DOMException('Permission denied')); diff --git a/injected/src/globals.d.ts b/injected/src/globals.d.ts index 8898a2ee37..58bbcbac5f 100644 --- a/injected/src/globals.d.ts +++ b/injected/src/globals.d.ts @@ -19,8 +19,9 @@ interface ImportMeta { | 'windows' | 'integration' | 'chrome-mv3' - | 'chrome' - | 'android-autofill-password-import'; + | 'android-broker-protection' + | 'android-autofill-import' + | 'android-adsjs'; trackerLookup?: Record; pageName?: string; } @@ -43,6 +44,9 @@ declare module '*.riv' { } declare module 'ddg:platformFeatures' { - const output: Record import('./content-feature').default>; + const output: Record< + string, + new (featureName: string, importConfig: ImportConfig, args: LoadArgs) => import('./content-feature').default + >; export default output; } diff --git a/injected/src/locales/duckplayer/en/native.json b/injected/src/locales/duckplayer/en/native.json new file mode 100644 index 0000000000..6cd2422191 --- /dev/null +++ b/injected/src/locales/duckplayer/en/native.json @@ -0,0 +1,80 @@ +{ + "smartling": { + "string_format": "icu", + "translate_paths": [ + { + "path": "*/title", + "key": "{*}/title", + "instruction": "*/note" + } + ] + }, + "unknownErrorHeading2": { + "title": "Duck Player can’t load this video", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a": { + "title": "This video can’t be viewed outside of YouTube.", + "note": "Explanation on why the error is happening." + }, + "unknownErrorMessage2b": { + "title": "You can still watch this video on YouTube, but without the added privacy of Duck Player.", + "note": "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2": { + "title": "Sorry, this video is age-restricted", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a": { + "title": "To watch age-restricted videos, you need to sign in to YouTube to verify your age.", + "note": "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b": { + "title": "You can still watch this video, but you’ll have to sign in and watch it on YouTube without the added privacy of Duck Player.", + "note": "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2": { + "title": "Sorry, this video can only be played on YouTube", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a": { + "title": "The creator of this video has chosen not to allow it to be viewed on other sites.", + "note": "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b": { + "title": "You can still watch it on YouTube, but without the added privacy of Duck Player.", + "note": "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading": { + "title": "YouTube won’t let Duck Player load this video", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1": { + "title": "YouTube doesn’t allow this video to be viewed outside of YouTube.", + "note": "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2": { + "title": "You can still watch this video on YouTube, but without the added privacy of Duck Player.", + "note": "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2": { + "title": "Sorry, YouTube thinks you’re a bot", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1": { + "title": "YouTube is blocking this video from loading. If you’re using a VPN, try turning it off and reloading this page.", + "note": "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2": { + "title": "If this doesn’t work, you can still watch this video on YouTube, but without the added privacy of Duck Player.", + "note": "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a": { + "title": "This can happen if you’re using a VPN. Try turning the VPN off or switching server locations and reloading this page.", + "note": "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b": { + "title": "If that doesn’t work, you’ll have to sign in and watch this video on YouTube without the added privacy of Duck Player.", + "note": "More troubleshooting tips for this specific error" + } +} diff --git a/injected/src/messages/duckplayer-native/youtubeError.json b/injected/src/messages/duckplayer-native/youtubeError.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/injected/src/messages/favicon/faviconFound.notify.json b/injected/src/messages/favicon/faviconFound.notify.json new file mode 100644 index 0000000000..ede8f44a31 --- /dev/null +++ b/injected/src/messages/favicon/faviconFound.notify.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "FaviconFound", + "required": ["favicons", "documentUrl"], + "properties": { + "favicons": { + "type": "array", + "items": { + "type": "object", + "required": ["rel", "href"], + "title": "Favicon Attrs", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + } + } + } + }, + "documentUrl": { + "type": "string" + } + } +} diff --git a/injected/src/messages/web-compat/deviceEnumeration.request.json b/injected/src/messages/web-compat/deviceEnumeration.request.json new file mode 100644 index 0000000000..4c490b2a13 --- /dev/null +++ b/injected/src/messages/web-compat/deviceEnumeration.request.json @@ -0,0 +1,27 @@ +{ + "description": "Request device enumeration information from native layer", + "params": {}, + "response": { + "description": "Device enumeration information from native layer", + "properties": { + "videoInput": { + "description": "Whether video input devices are available", + "type": "boolean" + }, + "audioInput": { + "description": "Whether audio input devices are available", + "type": "boolean" + }, + "audioOutput": { + "description": "Whether audio output devices are available", + "type": "boolean" + }, + "willPrompt": { + "description": "Whether the API would prompt for permissions", + "type": "boolean" + } + }, + "required": ["videoInput", "audioInput", "audioOutput", "willPrompt"], + "type": "object" + } +} \ No newline at end of file diff --git a/injected/src/trackers.js b/injected/src/trackers.js index 08830ae6ae..7c7775a117 100644 --- a/injected/src/trackers.js +++ b/injected/src/trackers.js @@ -1,9 +1,14 @@ +import { getGlobal } from './utils.js'; + /** * Check if the current document origin is on the tracker list, using the provided lookup trie. * @param {object} trackerLookup Trie lookup of tracker domains * @returns {boolean} True iff the origin is a tracker. + * + * Note: getGlobal() is used in testing to get the global object, + * it's a work around for ESM modules are essentially singletons preventing overriding of global variables. */ -export function isTrackerOrigin(trackerLookup, originHostname = document.location.hostname) { +export function isTrackerOrigin(trackerLookup, originHostname = getGlobal().document.location.hostname) { const parts = originHostname.split('.').reverse(); let node = trackerLookup; for (const sub of parts) { diff --git a/injected/src/types/favicon.ts b/injected/src/types/favicon.ts new file mode 100644 index 0000000000..71807a3fb9 --- /dev/null +++ b/injected/src/types/favicon.ts @@ -0,0 +1,35 @@ +/** + * These types are auto-generated from schema files. + * scripts/build-types.mjs is responsible for type generation. + * **DO NOT** edit this file directly as your changes will be lost. + * + * @module Favicon Messages + */ + +/** + * Requests, Notifications and Subscriptions from the Favicon feature + */ +export interface FaviconMessages { + notifications: FaviconFoundNotification; +} +/** + * Generated from @see "../messages/favicon/faviconFound.notify.json" + */ +export interface FaviconFoundNotification { + method: "faviconFound"; + params: FaviconFound; +} +export interface FaviconFound { + favicons: FaviconAttrs[]; + documentUrl: string; +} +export interface FaviconAttrs { + href: string; + rel: string; +} + +declare module "../features/favicon.js" { + export interface Favicon { + notify: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['notify'] + } +} \ No newline at end of file diff --git a/injected/src/types/web-compat.ts b/injected/src/types/web-compat.ts index ecf46f1b16..e4a641c18e 100644 --- a/injected/src/types/web-compat.ts +++ b/injected/src/types/web-compat.ts @@ -10,7 +10,19 @@ * Requests, Notifications and Subscriptions from the WebCompat feature */ export interface WebCompatMessages { - requests: WebShareRequest; + requests: DeviceEnumerationRequest | WebShareRequest; +} +/** + * Generated from @see "../messages/web-compat/deviceEnumeration.request.json" + */ +export interface DeviceEnumerationRequest { + method: "deviceEnumeration"; + /** + * Request device enumeration information from native layer + */ + params: { + [k: string]: unknown; + }; } /** * Generated from @see "../messages/web-compat/webShare.request.json" diff --git a/injected/src/url-change.js b/injected/src/url-change.js new file mode 100644 index 0000000000..57be1d8a7a --- /dev/null +++ b/injected/src/url-change.js @@ -0,0 +1,90 @@ +import { DDGProxy, DDGReflect, isBeingFramed } from './utils.js'; +import ContentFeature from './content-feature.js'; + +/** + * @typedef {'push' | 'replace' | 'reload' | 'traverse' | 'unknown'} NavigationType + * An enumerated value representing the type of navigation. + * + * Possible values: + * - `'push'` - A new location is navigated to, causing a new entry to be pushed onto the history list. + * - `'replace'` - The Navigation.currentEntry is replaced with a new history entry. + * - `'reload'` - The Navigation.currentEntry is reloaded. + * - `'traverse'` - The browser navigates from one existing history entry to another existing history entry. + * - `'unknown'` - Fallback but highly unlikely. If the WeakMap lookup fails that means the navigate event wasn't captured. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/NavigateEvent/navigationType + */ + +/** + * @typedef {(navigationType: NavigationType) => void} URLChangeListener + */ + +const urlChangeListeners = new Set(); + +/** + * Register a listener to be called when the URL changes. + * @param {URLChangeListener} listener - Callback function that receives the navigation type + */ +export function registerForURLChanges(listener) { + if (urlChangeListeners.size === 0) { + listenForURLChanges(); + } + urlChangeListeners.add(listener); +} + +/** + * @param {NavigationType} navigationType - The type of navigation that occurred + */ +function handleURLChange(navigationType = 'unknown') { + for (const listener of urlChangeListeners) { + listener(navigationType); + } +} + +function listenForURLChanges() { + const urlChangedInstance = new ContentFeature('urlChanged', {}, {}); + // if the browser supports the navigation API, use that to listen for URL changes + if ('navigation' in globalThis && 'addEventListener' in globalThis.navigation) { + // We listen to `navigatesuccess` instead of `navigate` to ensure the navigation is committed. + // But, `navigatesuccess` does not provide the navigationType, so we capture it at `navigate` time + // then look it up later. This allows consumers to filter on navigationType. + // WeakMap ensures we don't hold onto the event.target longer than necessary and can be freed. + const navigations = new WeakMap(); + globalThis.navigation.addEventListener('navigate', (event) => { + navigations.set(event.target, event.navigationType); + }); + globalThis.navigation.addEventListener('navigatesuccess', (event) => { + const navigationType = navigations.get(event.target); + handleURLChange(navigationType); + navigations.delete(event.target); + }); + // Exit early if the navigation API is supported, i.e. history proxy and popState listener aren't created. + return; + } + if (isBeingFramed()) { + // don't run if we're in an iframe + return; + } + // single page applications don't have a DOMContentLoaded event on navigations, so + // we use proxy/reflect on history.pushState to call applyRules on page navigations + const historyMethodProxy = new DDGProxy(urlChangedInstance, History.prototype, 'pushState', { + apply(target, thisArg, args) { + const changeResult = DDGReflect.apply(target, thisArg, args); + handleURLChange('push'); + return changeResult; + }, + }); + historyMethodProxy.overload(); + const historyMethodProxyReplace = new DDGProxy(urlChangedInstance, History.prototype, 'replaceState', { + apply(target, thisArg, args) { + const changeResult = DDGReflect.apply(target, thisArg, args); + handleURLChange('replace'); + return changeResult; + }, + }); + historyMethodProxyReplace.overload(); + // listen for popstate events in order to run on back/forward navigations + window.addEventListener('popstate', () => { + handleURLChange('traverse'); + }); +} diff --git a/injected/src/utils.js b/injected/src/utils.js index 2f7800d240..4a451076ba 100644 --- a/injected/src/utils.js +++ b/injected/src/utils.js @@ -7,6 +7,8 @@ let globalObj = typeof window === 'undefined' ? globalThis : window; let Error = globalObj.Error; let messageSecret; +let isAppleSiliconCache = null; + // save a reference to original CustomEvent amd dispatchEvent so they can't be overriden to forge messages export const OriginalCustomEvent = typeof CustomEvent === 'undefined' ? null : CustomEvent; export const originalWindowDispatchEvent = typeof window === 'undefined' ? null : window.dispatchEvent.bind(window); @@ -49,6 +51,14 @@ export function setGlobal(globalObjIn) { Error = globalObj.Error; } +/** + * Used for testing to allow other files to override the globals used within this file. + * @returns {globalThis} the global object + */ +export function getGlobal() { + return globalObj; +} + // linear feedback shift register to find a random approximation export function nextRandom(v) { return Math.abs((v >> 1) | (((v << 62) ^ (v << 61)) & (~(~0 << 63) << 62))); @@ -118,31 +128,48 @@ export function hasThirdPartyOrigin(scriptOrigins) { } /** - * Best guess effort of the tabs hostname; where possible always prefer the args.site.domain - * @returns {string|null} inferred tab hostname + * @returns {URL | null} */ -export function getTabHostname() { - let framingOrigin = null; +export function getTabUrl() { + let framingURLString = null; try { // @ts-expect-error - globalThis.top is possibly 'null' here - framingOrigin = globalThis.top.location.href; + framingURLString = globalThis.top.location.href; } catch { - framingOrigin = globalThis.document.referrer; + // If there's no URL then let's fall back to using the frame ancestors origin which won't have path + // Fall back to the referrer if we can't get the top level origin + framingURLString = getTopLevelOriginFromFrameAncestors() ?? globalThis.document.referrer; } + let framingURL; + try { + framingURL = new URL(framingURLString); + } catch { + framingURL = null; + } + return framingURL; +} + +/** + * @returns {string | null} + */ +function getTopLevelOriginFromFrameAncestors() { + // For about:blank, we can't get the top location // Not supported in Firefox if ('ancestorOrigins' in globalThis.location && globalThis.location.ancestorOrigins.length) { // ancestorOrigins is reverse order, with the last item being the top frame - framingOrigin = globalThis.location.ancestorOrigins.item(globalThis.location.ancestorOrigins.length - 1); + return globalThis.location.ancestorOrigins.item(globalThis.location.ancestorOrigins.length - 1); } + return null; +} - try { - // @ts-expect-error - framingOrigin is possibly 'null' here - framingOrigin = new URL(framingOrigin).hostname; - } catch { - framingOrigin = null; - } - return framingOrigin; +/** + * Best guess effort of the tabs hostname; where possible always prefer the args.site.domain + * @returns {string|null} inferred tab hostname + */ +export function getTabHostname() { + const topURLString = getTabUrl()?.hostname; + return topURLString || null; } /** @@ -219,27 +246,42 @@ export function iterateDataKey(key, callback) { } } +/** + * Check if a feature is considered broken/disabled for the current site + * @param {import('./content-scope-features.js').LoadArgs} args - Configuration arguments containing site information + * @param {string} feature - The feature name to check + * @returns {boolean} True if the feature is broken/disabled, false if it should be enabled + */ export function isFeatureBroken(args, feature) { - return isPlatformSpecificFeature(feature) - ? !args.site.enabledFeatures.includes(feature) - : args.site.isBroken || args.site.allowlisted || !args.site.enabledFeatures.includes(feature); + const isFeatureEnabled = args.site.enabledFeatures?.includes(feature) ?? false; + + if (isPlatformSpecificFeature(feature)) { + return !isFeatureEnabled; + } + + return args.site.isBroken || args.site.allowlisted || !isFeatureEnabled; } export function camelcase(dashCaseText) { - return dashCaseText.replace(/-(.)/g, (match, letter) => { + return dashCaseText.replace(/-(.)/g, (_, letter) => { return letter.toUpperCase(); }); } // We use this method to detect M1 macs and set appropriate API values to prevent sites from detecting fingerprinting protections function isAppleSilicon() { + // Cache the result since hardware doesn't change + if (isAppleSiliconCache !== null) { + return isAppleSiliconCache; + } const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl'); // Best guess if the device is an Apple Silicon // https://stackoverflow.com/a/65412357 - // @ts-expect-error - Object is possibly 'null' - return gl.getSupportedExtensions().indexOf('WEBGL_compressed_texture_etc') !== -1; + const compressedTextureValue = gl?.getSupportedExtensions()?.indexOf('WEBGL_compressed_texture_etc'); + isAppleSiliconCache = typeof compressedTextureValue === 'number' && compressedTextureValue !== -1; + return isAppleSiliconCache; } /** @@ -281,14 +323,16 @@ const functionMap = { * @typedef {object} ConfigSetting * @property {'undefined' | 'number' | 'string' | 'function' | 'boolean' | 'null' | 'array' | 'object'} type * @property {string} [functionName] - * @property {boolean | string | number} value + * @property {*} [value] - Any value type (string, number, boolean, object, array, null, undefined) + * @property {ConfigSetting} [functionValue] - For function type, the value to return from the function + * @property {boolean} [async] - Whether to wrap the value in a Promise * @property {object} [criteria] - * @property {string} criteria.arch + * @property {string} [criteria.arch] */ /** * Processes a structured config setting and returns the value according to its type - * @param {ConfigSetting} configSetting + * @param {ConfigSetting | ConfigSetting[]} configSetting * @param {*} [defaultValue] * @returns */ @@ -301,10 +345,12 @@ export function processAttr(configSetting, defaultValue) { switch (configSettingType) { case 'object': if (Array.isArray(configSetting)) { - configSetting = processAttrByCriteria(configSetting); - if (configSetting === undefined) { + const selectedSetting = processAttrByCriteria(configSetting); + if (selectedSetting === undefined) { return defaultValue; } + // Now process the selected setting as a single ConfigSetting + return processAttr(selectedSetting, defaultValue); } if (!configSetting.type) { @@ -315,12 +361,22 @@ export function processAttr(configSetting, defaultValue) { if (configSetting.functionName && functionMap[configSetting.functionName]) { return functionMap[configSetting.functionName]; } + if (configSetting.functionValue) { + const functionValue = configSetting.functionValue; + // Return a function that processes the functionValue using processAttr + return () => processAttr(functionValue, undefined); + } } if (configSetting.type === 'undefined') { return undefined; } + // Handle async wrapping for all types including arrays + if (configSetting.async) { + return DDGPromise.resolve(configSetting.value); + } + // All JSON expressable types are handled here return configSetting.value; default: @@ -505,6 +561,8 @@ export function isUnprotectedDomain(topLevelHostname, featureList) { * @typedef {object} Platform * @property {'ios' | 'macos' | 'extension' | 'android' | 'windows'} name * @property {string | number } [version] + * @property {boolean} [internal] - Internal build flag + * @property {boolean} [preview] - Preview build flag */ /** @@ -521,9 +579,10 @@ export function isUnprotectedDomain(topLevelHostname, featureList) { * Used to inialize extension code in the load phase */ export function computeLimitedSiteObject() { - const topLevelHostname = getTabHostname(); + const tabURL = getTabUrl(); return { - domain: topLevelHostname, + domain: tabURL?.hostname || null, + url: tabURL?.href || null, }; } @@ -533,6 +592,11 @@ export function computeLimitedSiteObject() { * @returns {string | number | undefined} */ function getPlatformVersion(preferences) { + // Check for platform.version first + if (preferences.platform?.version !== undefined && preferences.platform?.version !== '') { + return preferences.platform.version; + } + // Fallback to legacy version fields if (preferences.versionNumber) { return preferences.versionNumber; } @@ -595,7 +659,7 @@ export function satisfiesMinVersion(minVersionString, applicationVersionString) * @param {string | number | undefined} currentVersion * @returns {boolean} */ -function isSupportedVersion(minSupportedVersion, currentVersion) { +export function isSupportedVersion(minSupportedVersion, currentVersion) { if (typeof currentVersion === 'string' && typeof minSupportedVersion === 'string') { if (satisfiesMinVersion(minSupportedVersion, currentVersion)) { return true; @@ -608,6 +672,24 @@ function isSupportedVersion(minSupportedVersion, currentVersion) { return false; } +/** + * @param {string | number | undefined} maxSupportedVersion + * @param {string | number | undefined} currentVersion + * @returns {boolean} + */ +export function isMaxSupportedVersion(maxSupportedVersion, currentVersion) { + if (typeof currentVersion === 'string' && typeof maxSupportedVersion === 'string') { + if (satisfiesMinVersion(currentVersion, maxSupportedVersion)) { + return true; + } + } else if (typeof currentVersion === 'number' && typeof maxSupportedVersion === 'number') { + if (maxSupportedVersion >= currentVersion) { + return true; + } + } + return false; +} + /** * @typedef RemoteConfig * @property {Record} features @@ -642,7 +724,6 @@ export function processConfig(data, userList, preferences, platformSpecificFeatu // Copy feature settings from remote config to preferences object output.featureSettings = parseFeatureSettings(data, enabledFeatures); - output.trackerLookup = import.meta.trackerLookup; output.bundledConfig = data; return output; @@ -704,7 +785,7 @@ export function isGloballyDisabled(args) { * @import {FeatureName} from "./features"; * @type {FeatureName[]} */ -export const platformSpecificFeatures = ['windowsPermissionUsage', 'messageBridge']; +export const platformSpecificFeatures = ['navigatorInterface', 'windowsPermissionUsage', 'messageBridge', 'favicon']; export function isPlatformSpecificFeature(featureName) { return platformSpecificFeatures.includes(featureName); @@ -728,30 +809,32 @@ export function legacySendMessage(messageType, options) { } /** - * Takes a function that returns an element and tries to find it with exponential backoff. + * Takes a function that returns an element and tries to execute it until it returns a valid result or the max attempts are reached. * @param {number} delay * @param {number} [maxAttempts=4] - The maximum number of attempts to find the element. * @param {number} [delay=500] - The initial delay to be used to create the exponential backoff. - * @returns {Promise} + * @returns {Promise} */ -export function withExponentialBackoff(fn, maxAttempts = 4, delay = 500) { +export function withRetry(fn, maxAttempts = 4, delay = 500, strategy = 'exponential') { return new Promise((resolve, reject) => { let attempts = 0; const tryFn = () => { attempts += 1; - const error = new Error('Element not found'); + const error = new Error('Result is invalid or max attempts reached'); try { - const element = fn(); - if (element) { - resolve(element); + const result = fn(); + if (result) { + resolve(result); } else if (attempts < maxAttempts) { - setTimeout(tryFn, delay * Math.pow(2, attempts)); + const retryDelay = strategy === 'linear' ? delay : delay * Math.pow(2, attempts); + setTimeout(tryFn, retryDelay); } else { reject(error); } } catch { if (attempts < maxAttempts) { - setTimeout(tryFn, delay * Math.pow(2, attempts)); + const retryDelay = strategy === 'linear' ? delay : delay * Math.pow(2, attempts); + setTimeout(tryFn, retryDelay); } else { reject(error); } @@ -760,3 +843,21 @@ export function withExponentialBackoff(fn, maxAttempts = 4, delay = 500) { tryFn(); }); } + +export function isDuckAi() { + const tabUrl = getTabUrl(); + const domains = ['duckduckgo.com', 'duck.ai', 'duck.co']; + if (tabUrl?.hostname && domains.some((domain) => matchHostname(tabUrl?.hostname, domain))) { + const url = new URL(tabUrl?.href); + return url.searchParams.has('duckai') || url.searchParams.get('ia') === 'chat'; + } + return false; +} + +export function isDuckAiSidebar() { + const tabUrl = getTabUrl(); + if (!tabUrl || !isDuckAi()) { + return false; + } + return tabUrl.searchParams.get('placement') === 'sidebar'; +} diff --git a/injected/src/wrapper-utils.js b/injected/src/wrapper-utils.js index 9d8bdf2780..c543edd687 100644 --- a/injected/src/wrapper-utils.js +++ b/injected/src/wrapper-utils.js @@ -104,7 +104,7 @@ export function wrapFunction(functionValue, realTarget) { } return Reflect.get(target, prop, receiver); }, - apply(target, thisArg, argumentsList) { + apply(_, thisArg, argumentsList) { // This is where we call our real function return Reflect.apply(functionValue, thisArg, argumentsList); }, @@ -193,9 +193,10 @@ export function wrapMethod(object, propertyName, wrapperFn, definePropertyFn) { * @param {typeof globalThis[StandardInterfaceName]} ImplClass - the class to use as the shim implementation * @param {DefineInterfaceOptions} options - options for defining the interface * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property + * @param {ImportMeta['injectName']} [injectName] - the name of the inject to use for the shim */ -export function shimInterface(interfaceName, ImplClass, options, definePropertyFn) { - if (import.meta.injectName === 'integration') { +export function shimInterface(interfaceName, ImplClass, options, definePropertyFn, injectName) { + if (injectName === 'integration') { if (!globalThis.origInterfaceDescriptors) globalThis.origInterfaceDescriptors = {}; const descriptor = Object.getOwnPropertyDescriptor(globalThis, interfaceName); globalThis.origInterfaceDescriptors[interfaceName] = descriptor; @@ -227,7 +228,7 @@ export function shimInterface(interfaceName, ImplClass, options, definePropertyF // handle the case where the constructor is called without new if (fullOptions.allowConstructorCall) { // make the constructor function callable without new - proxyHandler.apply = function (target, thisArg, argumentsList) { + proxyHandler.apply = function (target, _thisArg, argumentsList) { return Reflect.construct(target, argumentsList, target); }; } @@ -270,7 +271,7 @@ export function shimInterface(interfaceName, ImplClass, options, definePropertyF } } - if (import.meta.injectName === 'integration') { + if (injectName === 'integration') { // mark the class as a shimmed class // we do it only in test mode, to avoid potential side effects in production. See the playwright test in integration-test/test-pages/webcompat/pages/shims.html definePropertyFn(ImplClass, ddgShimMark, { @@ -304,12 +305,13 @@ export function shimInterface(interfaceName, ImplClass, options, definePropertyF * @param {Base[K]} implInstance - instance to use as the shim (e.g. new MyMediaSession()) * @param {boolean} readOnly - whether the property should be read-only * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property + * @param {ImportMeta['injectName']} [injectName] - the name of the inject to use for the shim */ -export function shimProperty(baseObject, propertyName, implInstance, readOnly, definePropertyFn) { +export function shimProperty(baseObject, propertyName, implInstance, readOnly, definePropertyFn, injectName) { // @ts-expect-error - implInstance is a class instance const ImplClass = implInstance.constructor; - if (import.meta.injectName === 'integration') { + if (injectName === 'integration') { if (!globalThis.origPropDescriptors) globalThis.origPropDescriptors = []; const descriptor = Object.getOwnPropertyDescriptor(baseObject, propertyName); globalThis.origPropDescriptors.push([baseObject, propertyName, descriptor]); diff --git a/injected/unit-test/broker-protection-extract.js b/injected/unit-test/broker-protection-extract.js index 02eb005f1c..c3f087cf45 100644 --- a/injected/unit-test/broker-protection-extract.js +++ b/injected/unit-test/broker-protection-extract.js @@ -1,5 +1,5 @@ -import { aggregateFields, createProfile } from '../src/features/broker-protection/actions/extract.js'; -import { cleanArray } from '../src/features/broker-protection/utils.js'; +import { aggregateFields, createProfile, stringValuesFromElements } from '../src/features/broker-protection/actions/extract.js'; +import { cleanArray } from '../src/features/broker-protection/utils/utils.js'; describe('create profiles from extracted data', () => { describe('cleanArray', () => { @@ -360,4 +360,19 @@ describe('create profiles from extracted data', () => { expect(actual.alternativeNames).toEqual(['Fred Firth', 'Jerry Doug', 'Marvin Smith', 'Roger Star']); }); + + it('should extract innerText by default', () => { + const element = { + innerText: 'John Smith, 39', + textContent: 'Ignore me', + }; + expect(stringValuesFromElements([element], 'testKey', { selector: 'example' })).toEqual(['John Smith, 39']); + }); + + it('should extract textElement if innerText is not present', () => { + const element = { + textContent: 'John Smith, 39', + }; + expect(stringValuesFromElements([element], 'testKey', { selector: 'example' })).toEqual(['John Smith, 39']); + }); }); diff --git a/injected/unit-test/broker-protection-extractors.js b/injected/unit-test/broker-protection-extractors.js index 5fb47026b2..61b9268b86 100644 --- a/injected/unit-test/broker-protection-extractors.js +++ b/injected/unit-test/broker-protection-extractors.js @@ -1,5 +1,5 @@ import fc from 'fast-check'; -import { cleanArray } from '../src/features/broker-protection/utils.js'; +import { cleanArray } from '../src/features/broker-protection/utils/utils.js'; import { PhoneExtractor } from '../src/features/broker-protection/extractors/phone.js'; import { ProfileUrlExtractor } from '../src/features/broker-protection/extractors/profile-url.js'; diff --git a/injected/unit-test/broker-protection.js b/injected/unit-test/broker-protection.js index 495692dfa3..9b7ae75477 100644 --- a/injected/unit-test/broker-protection.js +++ b/injected/unit-test/broker-protection.js @@ -6,11 +6,12 @@ import { addressMatch } from '../src/features/broker-protection/comparisons/addr import { replaceTemplatedUrl } from '../src/features/broker-protection/actions/build-url.js'; import { processTemplateStringWithUserData } from '../src/features/broker-protection/actions/build-url-transforms.js'; import { names } from '../src/features/broker-protection/comparisons/constants.js'; -import { generateRandomInt, hashObject, sortAddressesByStateAndCity } from '../src/features/broker-protection/utils.js'; +import { generateRandomInt, hashObject, sortAddressesByStateAndCity } from '../src/features/broker-protection/utils/utils.js'; import { generatePhoneNumber, generateZipCode, generateStreetAddress } from '../src/features/broker-protection/actions/generators.js'; import { CityStateExtractor } from '../src/features/broker-protection/extractors/address.js'; import { ProfileHashTransformer } from '../src/features/broker-protection/extractors/profile-url.js'; import { getComparisonFunction } from '../src/features/broker-protection/actions/click.js'; +import { isElementType } from '../src/features/broker-protection/captcha-services/utils/element.js'; describe('Actions', () => { describe('extract', () => { @@ -678,6 +679,32 @@ describe('generators', () => { }); }); +describe('captcha-services', () => { + describe('utils', () => { + describe('isElementType', () => { + it('should return true for single valid element type', () => { + const type = 'img'; + const element = /** @type {HTMLElement} */ ({ tagName: type }); + expect(isElementType(element, type)).toBe(true); + }); + + it('should return true for multiple valid element types', () => { + const validElementTypes = ['input', 'textarea', 'img', 'svg']; + const type = 'svg'; + const element = /** @type {HTMLElement} */ ({ tagName: type }); + expect(isElementType(element, validElementTypes)).toBe(true); + }); + + it('should return false for invalid element types', () => { + const validElementTypes = ['input', 'textarea', 'img', 'svg']; + const type = 'div'; + const element = /** @type {HTMLElement} */ ({ tagName: type }); + expect(isElementType(element, validElementTypes)).toBe(false); + }); + }); + }); +}); + describe('utils', () => { describe('generateRandomInt', () => { it('generates an integers between the min and max values', () => { diff --git a/injected/unit-test/comment-plugin.js b/injected/unit-test/comment-plugin.js new file mode 100644 index 0000000000..120a0425ca --- /dev/null +++ b/injected/unit-test/comment-plugin.js @@ -0,0 +1,199 @@ +import { convertToLegalComments } from '../scripts/utils/comment-plugin.js'; + +describe('convertToLegalComments', () => { + it('should convert single line comments with copyright', () => { + const input = `// This is a copyright notice +const foo = 'bar';`; + + const expected = `//! This is a copyright notice +const foo = 'bar';`; + + expect(convertToLegalComments(input)).toEqual(expected); + }); + + it('should convert multiple consecutive line comments following a copyright line', () => { + const input = `// This is a copyright notice +// This is a second line +// This is a third line +const foo = 'bar';`; + + const expected = `//! This is a copyright notice +//! This is a second line +//! This is a third line +const foo = 'bar';`; + + expect(convertToLegalComments(input)).toEqual(expected); + }); + + it('should stop converting after a non-comment line is encountered', () => { + const input = `// This is a copyright notice +// This is a second line +const foo = 'bar'; +// This is a regular comment that should not be converted`; + + const expected = `//! This is a copyright notice +//! This is a second line +const foo = 'bar'; +// This is a regular comment that should not be converted`; + + expect(convertToLegalComments(input)).toEqual(expected); + }); + + it('should handle multiple separate comment blocks', () => { + const input = `// This is a regular comment +const a = 1; + +// This has copyright info +// And continues here +const b = 2; + +// Another copyright notice +// With more details +// And even more info +const c = 3;`; + + const expected = `// This is a regular comment +const a = 1; + +//! This has copyright info +//! And continues here +const b = 2; + +//! Another copyright notice +//! With more details +//! And even more info +const c = 3;`; + + expect(convertToLegalComments(input)).toEqual(expected); + }); + + it('should handle indented comments', () => { + const input = `function test() { + // This has copyright info + // This is indented + return true; +}`; + + const expected = `function test() { + //! This has copyright info + //! This is indented + return true; +}`; + + expect(convertToLegalComments(input)).toEqual(expected); + }); + + it('should handle block comments with copyright', () => { + const input = `/* This is a copyright block comment */ +const foo = 'bar'; + +/* This is a regular + multiline comment */`; + + const expected = `/*! This is a copyright block comment */ +const foo = 'bar'; + +/* This is a regular + multiline comment */`; + + expect(convertToLegalComments(input)).toEqual(expected); + }); + + it('should handle mixed comment types', () => { + const input = `// This has copyright info +// This continues +/* This is a regular block comment */ +const foo = 'bar'; + +/* This is a copyright block comment */ +// This is a regular comment after a block`; + + const expected = `//! This has copyright info +//! This continues +/* This is a regular block comment */ +const foo = 'bar'; + +/*! This is a copyright block comment */ +// This is a regular comment after a block`; + + expect(convertToLegalComments(input)).toEqual(expected); + }); + + it('should handle block comments breaking line comment sequences', () => { + const input = `// This has copyright info +// This line should be converted +/* This block comment breaks the sequence */ +// This line should NOT be converted +// Even though it follows another comment`; + + const expected = `//! This has copyright info +//! This line should be converted +/* This block comment breaks the sequence */ +// This line should NOT be converted +// Even though it follows another comment`; + + expect(convertToLegalComments(input)).toEqual(expected); + }); + + it('should handle case insensitivity for "copyright"', () => { + const input = `// This has COPYRIGHT info +// This continues +const foo = 'bar'; + +// This has Copyright mixed case +// More comments +const baz = 'qux';`; + + const expected = `//! This has COPYRIGHT info +//! This continues +const foo = 'bar'; + +//! This has Copyright mixed case +//! More comments +const baz = 'qux';`; + + expect(convertToLegalComments(input)).toEqual(expected); + }); + + it('should not convert comments without copyright', () => { + const input = `// This is a regular comment +// Another regular comment +const foo = 'bar';`; + + const expected = `// This is a regular comment +// Another regular comment +const foo = 'bar';`; + + expect(convertToLegalComments(input)).toEqual(expected); + }); + + it('should handle code with no comments', () => { + const input = `const foo = 'bar'; +function test() { + return true; +} +const obj = { key: 'value' };`; + + const expected = input; // Should remain unchanged + + expect(convertToLegalComments(input)).toEqual(expected); + }); + + it('should treat empty lines as non-comment lines that break the sequence', () => { + const input = `// This has copyright info +// This continues + +// These comments should NOT be converted +// Because empty line breaks the sequence +const foo = 'bar';`; + + const expected = `//! This has copyright info +//! This continues + +// These comments should NOT be converted +// Because empty line breaks the sequence +const foo = 'bar';`; + + expect(convertToLegalComments(input)).toEqual(expected); + }); +}); diff --git a/injected/unit-test/content-feature.js b/injected/unit-test/content-feature.js index f9e28737ea..95299be1d3 100644 --- a/injected/unit-test/content-feature.js +++ b/injected/unit-test/content-feature.js @@ -9,13 +9,27 @@ describe('ContentFeature class', () => { expect(this.getFeatureSetting('otherTest')).toBe('enabled'); expect(this.getFeatureSetting('otherOtherTest')).toBe('ding'); expect(this.getFeatureSetting('arrayTest')).toBe('enabledArray'); + // Following key doesn't exist so it should return false + expect(this.getFeatureSettingEnabled('someNonExistantKey')).toBe(false); + expect(this.getFeatureSettingEnabled('someNonExistantKey', 'enabled')).toBe(true); + expect(this.getFeatureSettingEnabled('someNonExistantKey', 'disabled')).toBe(false); + expect(this.getFeatureSettingEnabled('disabledStatus')).toBe(false); + expect(this.getFeatureSettingEnabled('internalStatus')).toBe(false); + expect(this.getFeatureSettingEnabled('enabledStatus')).toBe(true); + expect(this.getFeatureSettingEnabled('enabledStatus', 'enabled')).toBe(true); + expect(this.getFeatureSettingEnabled('enabledStatus', 'disabled')).toBe(true); + expect(this.getFeatureSettingEnabled('overridenStatus')).toBe(false); + expect(this.getFeatureSettingEnabled('disabledOverridenStatus')).toBe(true); + expect(this.getFeatureSettingEnabled('statusObject')).toBe(true); + expect(this.getFeatureSettingEnabled('statusDisabledObject')).toBe(false); didRun = true; } } - const me = new MyTestFeature('test'); - me.callInit({ + + const args = { site: { domain: 'beep.example.com', + url: 'http://beep.example.com', }, featureSettings: { test: { @@ -23,12 +37,27 @@ describe('ContentFeature class', () => { otherTest: 'disabled', otherOtherTest: 'ding', arrayTest: 'enabled', + disabledStatus: 'disabled', + internalStatus: 'internal', // not currently supported + enabledStatus: 'enabled', + overridenStatus: 'enabled', + disabledOverridenStatus: 'disabled', + statusObject: { + state: 'enabled', + bloop: true, + }, + statusDisabledObject: { + state: 'disabled', + bloop2: true, + }, domains: [ { domain: 'example.com', patchSettings: [ { op: 'replace', path: '/test', value: 'enabled2' }, { op: 'replace', path: '/otherTest', value: 'enabled' }, + { op: 'replace', path: '/overridenStatus', value: 'disabled' }, + { op: 'replace', path: '/disabledOverridenStatus', value: 'enabled' }, ], }, { @@ -42,7 +71,364 @@ describe('ContentFeature class', () => { ], }, }, - }); + }; + const me = new MyTestFeature('test', {}, args); + me.callInit(args); + expect(didRun).withContext('Should run').toBeTrue(); + }); + + it('Should trigger getFeatureSettingEnabled for the correct domain', () => { + let didRun = false; + class MyTestFeature2 extends ContentFeature { + init() { + expect(this.getFeatureSetting('test')).toBe('enabled3'); + expect(this.getFeatureSetting('otherTest')).toBe('enabled'); + expect(this.getFeatureSetting('otherOtherTest')).toBe('ding'); + expect(this.getFeatureSetting('arrayTest')).toBe('enabledArray'); + expect(this.getFeatureSetting('pathTest')).toBe('beep'); + expect(this.getFeatureSetting('pathTestNotApply')).toBe('nope'); + expect(this.getFeatureSetting('pathTestShort')).toBe('beep'); + expect(this.getFeatureSetting('pathTestAsterix')).toBe('comic'); + expect(this.getFeatureSetting('pathTestPlaceholder')).toBe('place'); + expect(this.getFeatureSetting('domainWildcard')).toBe('wildwest'); + expect(this.getFeatureSetting('domainWildcardNope')).toBe('nope'); + expect(this.getFeatureSetting('invalidCheck')).toBe('nope'); + didRun = true; + } + } + + const args = { + site: { + domain: 'beep.example.com', + url: 'http://beep.example.com/path/path/me', + }, + featureSettings: { + test: { + test: 'enabled', + otherTest: 'disabled', + otherOtherTest: 'ding', + arrayTest: 'enabled', + pathTest: 'nope', + pathTestNotApply: 'nope', + pathTestShort: 'nope', + pathTestAsterix: 'nope', + pathTestPlaceholder: 'nope', + domainWildcard: 'nope', + domainWildcardNope: 'nope', + invalidCheck: 'nope', + conditionalChanges: [ + { + domain: 'example.com', + patchSettings: [ + { op: 'replace', path: '/test', value: 'enabled2' }, + { op: 'replace', path: '/otherTest', value: 'enabled' }, + ], + }, + { + domain: 'beep.example.com', + patchSettings: [{ op: 'replace', path: '/test', value: 'enabled3' }], + }, + { + domain: ['meep.com', 'example.com'], + patchSettings: [{ op: 'replace', path: '/arrayTest', value: 'enabledArray' }], + }, + { + condition: { + urlPattern: { + path: '/path/path/me', + }, + }, + patchSettings: [{ op: 'replace', path: '/pathTest', value: 'beep' }], + }, + { + condition: { + urlPattern: { + hostname: 'beep.nope.com', + path: '/path/path/me', + }, + }, + patchSettings: [{ op: 'replace', path: '/pathTestNotApply', value: 'yep' }], + }, + { + condition: { + urlPattern: 'http://beep.example.com/path/path/me', + }, + patchSettings: [{ op: 'replace', path: '/pathTestShort', value: 'beep' }], + }, + { + condition: { + urlPattern: 'http://beep.example.com/*/path/me', + }, + patchSettings: [{ op: 'replace', path: '/pathTestAsterix', value: 'comic' }], + }, + { + condition: { + urlPattern: 'http://beep.example.com/:something/path/me', + }, + patchSettings: [{ op: 'replace', path: '/pathTestPlaceholder', value: 'place' }], + }, + { + condition: { + urlPattern: 'http://beep.*.com/*/path/me', + }, + patchSettings: [{ op: 'replace', path: '/domainWildcard', value: 'wildwest' }], + }, + { + condition: { + urlPattern: 'http://nope.*.com/*/path/me', + }, + patchSettings: [{ op: 'replace', path: '/domainWildcardNope', value: 'wildwest' }], + }, + { + condition: { + somethingInvalid: true, + urlPattern: 'http://beep.example.com/*/path/me', + }, + patchSettings: [{ op: 'replace', path: '/invalidCheck', value: 'neverhappened' }], + }, + ], + }, + }, + }; + const me = new MyTestFeature2('test', {}, args); + me.callInit(args); + expect(didRun).withContext('Should run').toBeTrue(); + }); + + it('Should trigger getFeatureSetting for the correct conditions', () => { + let didRun = false; + class MyTestFeature3 extends ContentFeature { + init() { + expect(this.getFeatureSetting('test')).toBe('enabled'); + expect(this.getFeatureSetting('otherTest')).toBe('disabled'); + expect(this.getFeatureSetting('test2')).toBe('noop'); + expect(this.getFeatureSetting('otherTest2')).toBe('me'); + expect(this.getFeatureSetting('test3')).toBe('yep'); + expect(this.getFeatureSetting('otherTest3')).toBe('expected'); + expect(this.getFeatureSetting('test4')).toBe('yep'); + expect(this.getFeatureSetting('otherTest4')).toBe('expected'); + expect(this.getFeatureSetting('test5')).toBe('yep'); + expect(this.getFeatureSetting('otherTest5')).toBe('expected'); + expect(this.getFeatureSetting('notPresent')).toBeUndefined(); + didRun = true; + } + } + + const args = { + site: { + domain: 'beep.example.com', + url: 'http://beep.example.com/path/path/me', + }, + featureSettings: { + test: { + test: 'enabled', + otherTest: 'disabled', + test4: 'yep', + otherTest4: 'expected', + conditionalChanges: [ + { + condition: { + // This array case is unsupported currently. + domain: ['example.com'], + }, + patchSettings: [ + { op: 'add', path: '/test', value: 'enabled2' }, + { op: 'add', path: '/otherTest', value: 'bloop' }, + ], + }, + { + condition: [ + { + domain: 'example.com', + }, + { + domain: 'other.com', + }, + ], + patchSettings: [ + { op: 'add', path: '/test2', value: 'noop' }, + { op: 'add', path: '/otherTest2', value: 'me' }, + ], + }, + { + condition: [ + { + urlPattern: '*://*.example.com', + }, + { + urlPattern: '*://other.com', + }, + ], + patchSettings: [ + { op: 'add', path: '/test3', value: 'yep' }, + { op: 'add', path: '/otherTest3', value: 'expected' }, + ], + }, + { + condition: [ + { + // This is at the apex so should not match + urlPattern: '*://example.com', + }, + { + urlPattern: '*://other.com', + }, + ], + patchSettings: [ + { op: 'add', path: '/test4', value: 'nope' }, + { op: 'add', path: '/otherTest4', value: 'notexpected' }, + ], + }, + { + condition: [ + { + urlPattern: { + hostname: '*.example.com', + }, + }, + ], + patchSettings: [ + { op: 'add', path: '/test5', value: 'yep' }, + { op: 'add', path: '/otherTest5', value: 'expected' }, + // This should not be added as replace state + { op: 'replace', path: '/notPresent', value: 'notpresent' }, + ], + }, + ], + }, + }, + }; + const me = new MyTestFeature3('test', {}, args); + me.callInit(args); + expect(didRun).withContext('Should run').toBeTrue(); + }); + it('Should respect minSupportedVersion as a condition', () => { + let didRun = false; + class MyTestFeature3 extends ContentFeature { + init() { + expect(this.getFeatureSetting('aiChat')).toBe('enabled'); + expect(this.getFeatureSetting('subscriptions')).toBe('disabled'); + didRun = true; + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + version: '1.1.0', + }, + bundledConfig: { + features: { + test: { + state: 'enabled', + exceptions: [], + settings: { + aiChat: 'disabled', + subscriptions: 'disabled', + conditionalChanges: [ + { + condition: { + domain: 'example.com', + minSupportedVersion: '1.1.0', + }, + patchSettings: [ + { + op: 'replace', + path: '/aiChat', + value: 'enabled', + }, + ], + }, + { + condition: { + domain: 'example.com', + minSupportedVersion: '1.2.0', + }, + patchSettings: [ + { + op: 'replace', + path: '/subscriptions', + value: 'enabled', + }, + ], + }, + ], + }, + }, + }, + unprotectedTemporary: [], + }, + }; + const me = new MyTestFeature3('test', {}, args); + me.callInit(args); + expect(didRun).withContext('Should run').toBeTrue(); + }); + + it('Should respect maxSupportedVersion as a condition', () => { + let didRun = false; + class MyTestFeature4 extends ContentFeature { + init() { + expect(this.getFeatureSetting('aiChat')).toBe('enabled'); + expect(this.getFeatureSetting('subscriptions')).toBe('disabled'); + didRun = true; + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + version: '1.1.0', + }, + bundledConfig: { + features: { + test: { + state: 'enabled', + exceptions: [], + settings: { + aiChat: 'disabled', + subscriptions: 'disabled', + conditionalChanges: [ + { + condition: { + domain: 'example.com', + maxSupportedVersion: '1.1.0', + }, + patchSettings: [ + { + op: 'replace', + path: '/aiChat', + value: 'enabled', + }, + ], + }, + { + condition: { + domain: 'example.com', + maxSupportedVersion: '1.0.0', + }, + patchSettings: [ + { + op: 'replace', + path: '/subscriptions', + value: 'enabled', + }, + ], + }, + ], + }, + }, + }, + }, + }; + + const me = new MyTestFeature4('test', {}, args); + me.callInit(args); expect(didRun).withContext('Should run').toBeTrue(); }); @@ -51,12 +437,12 @@ describe('ContentFeature class', () => { // eslint-disable-next-line // @ts-ignore partial mock messaging = { - notify(name, data) {}, + notify(_name, _data) {}, }; } let feature; beforeEach(() => { - feature = new MyTestFeature('someFeatureName'); + feature = new MyTestFeature('someFeatureName', {}, {}); }); it('should not send duplicate flags', () => { @@ -85,7 +471,7 @@ describe('ContentFeature class', () => { } let feature; beforeEach(() => { - feature = new MyTestFeature('someFeatureName'); + feature = new MyTestFeature('someFeatureName', {}, {}); }); it('should add debug flag to value descriptors', () => { @@ -175,4 +561,633 @@ describe('ContentFeature class', () => { expect(object.someProp.toString.toString.toString()).not.toBe(fn.toString.toString.toString()); }); }); + + describe('injectName condition', () => { + it('should match when injectName condition is met', () => { + class MyTestFeature extends ContentFeature { + /** @returns {'apple-isolated'} */ + get injectName() { + return 'apple-isolated'; + } + + testMatchInjectNameConditional(conditionBlock) { + return this._matchInjectNameConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInjectNameConditional({ + injectName: 'apple-isolated', + }); + expect(result).toBe(true); + }); + + it('should not match when injectName condition is not met', () => { + class MyTestFeature extends ContentFeature { + /** @returns {'apple-isolated'} */ + get injectName() { + return 'apple-isolated'; + } + + testMatchInjectNameConditional(conditionBlock) { + return this._matchInjectNameConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInjectNameConditional({ + injectName: 'firefox', + }); + expect(result).toBe(false); + }); + + it('should handle undefined injectName gracefully', () => { + class MyTestFeature extends ContentFeature { + /** @returns {undefined} */ + get injectName() { + return undefined; + } + + testMatchInjectNameConditional(conditionBlock) { + return this._matchInjectNameConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInjectNameConditional({ + injectName: 'apple-isolated', + }); + expect(result).toBe(false); + }); + + it('should handle missing injectName condition', () => { + class MyTestFeature extends ContentFeature { + /** @returns {'apple-isolated'} */ + get injectName() { + return 'apple-isolated'; + } + + testMatchInjectNameConditional(conditionBlock) { + return this._matchInjectNameConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInjectNameConditional({}); + expect(result).toBe(false); + }); + }); + + describe('maxSupportedVersion condition', () => { + it('should match when current version is less than max', () => { + class MyTestFeature extends ContentFeature { + testMatchMaxSupportedVersion(conditionBlock) { + return this._matchMaxSupportedVersion(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + version: '1.5.0', + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchMaxSupportedVersion({ + maxSupportedVersion: '2.0.0', + }); + expect(result).toBe(true); + }); + + it('should match when current version equals max', () => { + class MyTestFeature extends ContentFeature { + testMatchMaxSupportedVersion(conditionBlock) { + return this._matchMaxSupportedVersion(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + version: '1.5.0', + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchMaxSupportedVersion({ + maxSupportedVersion: '1.5.0', + }); + expect(result).toBe(true); + }); + + it('should not match when current version is greater than max', () => { + class MyTestFeature extends ContentFeature { + testMatchMaxSupportedVersion(conditionBlock) { + return this._matchMaxSupportedVersion(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + version: '1.5.0', + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchMaxSupportedVersion({ + maxSupportedVersion: '1.0.0', + }); + expect(result).toBe(false); + }); + + it('should handle integer versions', () => { + class MyTestFeature extends ContentFeature { + testMatchMaxSupportedVersion(conditionBlock) { + return this._matchMaxSupportedVersion(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + version: 99, + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchMaxSupportedVersion({ + maxSupportedVersion: 100, + }); + expect(result).toBe(true); + }); + + it('should handle missing maxSupportedVersion condition', () => { + class MyTestFeature extends ContentFeature { + testMatchMaxSupportedVersion(conditionBlock) { + return this._matchMaxSupportedVersion(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + version: '1.5.0', + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchMaxSupportedVersion({}); + expect(result).toBe(false); + }); + }); + + describe('internal condition', () => { + it('should match when internal is true and condition is true', () => { + class MyTestFeature extends ContentFeature { + testMatchInternalConditional(conditionBlock) { + return this._matchInternalConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + internal: true, + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInternalConditional({ + internal: true, + }); + expect(result).toBe(true); + }); + + it('should match when internal is false and condition is false', () => { + class MyTestFeature extends ContentFeature { + testMatchInternalConditional(conditionBlock) { + return this._matchInternalConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + internal: false, + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInternalConditional({ + internal: false, + }); + expect(result).toBe(true); + }); + + it('should not match when internal is true but condition is false', () => { + class MyTestFeature extends ContentFeature { + testMatchInternalConditional(conditionBlock) { + return this._matchInternalConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + internal: true, + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInternalConditional({ + internal: false, + }); + expect(result).toBe(false); + }); + + it('should not match when internal is false but condition is true', () => { + class MyTestFeature extends ContentFeature { + testMatchInternalConditional(conditionBlock) { + return this._matchInternalConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + internal: false, + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInternalConditional({ + internal: true, + }); + expect(result).toBe(false); + }); + + it('should handle undefined internal state gracefully', () => { + class MyTestFeature extends ContentFeature { + testMatchInternalConditional(conditionBlock) { + return this._matchInternalConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + // internal not set + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInternalConditional({ + internal: true, + }); + expect(result).toBe(false); + }); + + it('should handle missing internal condition', () => { + class MyTestFeature extends ContentFeature { + testMatchInternalConditional(conditionBlock) { + return this._matchInternalConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + internal: true, + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInternalConditional({}); + expect(result).toBe(false); + }); + + it('should handle truthy values for internal condition', () => { + class MyTestFeature extends ContentFeature { + testMatchInternalConditional(conditionBlock) { + return this._matchInternalConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + internal: 1, // truthy value + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInternalConditional({ + internal: true, + }); + expect(result).toBe(true); + }); + + it('should handle falsy values for internal condition', () => { + class MyTestFeature extends ContentFeature { + testMatchInternalConditional(conditionBlock) { + return this._matchInternalConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + internal: 0, // falsy value + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchInternalConditional({ + internal: false, + }); + expect(result).toBe(true); + }); + }); + + describe('preview condition', () => { + it('should match when preview is true and condition is true', () => { + class MyTestFeature extends ContentFeature { + testMatchPreviewConditional(conditionBlock) { + return this._matchPreviewConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + preview: true, + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchPreviewConditional({ + preview: true, + }); + expect(result).toBe(true); + }); + + it('should match when preview is false and condition is false', () => { + class MyTestFeature extends ContentFeature { + testMatchPreviewConditional(conditionBlock) { + return this._matchPreviewConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + preview: false, + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchPreviewConditional({ + preview: false, + }); + expect(result).toBe(true); + }); + + it('should not match when preview is true but condition is false', () => { + class MyTestFeature extends ContentFeature { + testMatchPreviewConditional(conditionBlock) { + return this._matchPreviewConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + preview: true, + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchPreviewConditional({ + preview: false, + }); + expect(result).toBe(false); + }); + + it('should not match when preview is false but condition is true', () => { + class MyTestFeature extends ContentFeature { + testMatchPreviewConditional(conditionBlock) { + return this._matchPreviewConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + preview: false, + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchPreviewConditional({ + preview: true, + }); + expect(result).toBe(false); + }); + + it('should handle undefined preview state gracefully', () => { + class MyTestFeature extends ContentFeature { + testMatchPreviewConditional(conditionBlock) { + return this._matchPreviewConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + // preview not set + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchPreviewConditional({ + preview: true, + }); + expect(result).toBe(false); + }); + + it('should handle missing preview condition', () => { + class MyTestFeature extends ContentFeature { + testMatchPreviewConditional(conditionBlock) { + return this._matchPreviewConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + preview: true, + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchPreviewConditional({}); + expect(result).toBe(false); + }); + + it('should handle truthy values for preview condition', () => { + class MyTestFeature extends ContentFeature { + testMatchPreviewConditional(conditionBlock) { + return this._matchPreviewConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + preview: 1, // truthy value + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchPreviewConditional({ + preview: true, + }); + expect(result).toBe(true); + }); + + it('should handle falsy values for preview condition', () => { + class MyTestFeature extends ContentFeature { + testMatchPreviewConditional(conditionBlock) { + return this._matchPreviewConditional(conditionBlock); + } + } + + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { + name: 'test', + preview: 0, // falsy value + }, + }; + + const feature = new MyTestFeature('test', {}, args); + const result = feature.testMatchPreviewConditional({ + preview: false, + }); + expect(result).toBe(true); + }); + }); }); diff --git a/injected/unit-test/content-scope-features.js b/injected/unit-test/content-scope-features.js new file mode 100644 index 0000000000..973889c9f5 --- /dev/null +++ b/injected/unit-test/content-scope-features.js @@ -0,0 +1,350 @@ +import ContentFeature from '../src/content-feature.js'; + +/** + * Test the additionalCheck conditional logic in content-scope-features.js + * + * This tests the logic at lines 60-62 and 82-84: + * if (!featureInstance.getFeatureSettingEnabled('additionalCheck', 'enabled')) { + * return; + * } + */ +describe('content-scope-features additionalCheck conditional', () => { + describe('additionalCheck feature setting with conditional patching', () => { + it('should return false when additionalCheck is disabled via conditional patching', () => { + // Setup: Create new feature instance with conditional patching that disables additionalCheck + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { name: 'test' }, + bundledConfig: { + features: { + testFeature: { + state: 'enabled', + exceptions: [], + settings: { + additionalCheck: 'enabled', // Base setting + conditionalChanges: [ + { + condition: { + domain: 'example.com', + }, + patchSettings: [{ op: 'replace', path: '/additionalCheck', value: 'disabled' }], + }, + ], + }, + }, + }, + unprotectedTemporary: [], + }, + }; + + // Create feature instance with conditional patching + const testFeatureInstance = new ContentFeature('testFeature', {}, args); + + // Act: Check if the feature setting is enabled after conditional patching + const isEnabled = testFeatureInstance.getFeatureSettingEnabled('additionalCheck', 'enabled'); + + // Assert: Should be false due to conditional patching + expect(isEnabled).toBe(false); + }); + + it('should return true when additionalCheck is enabled via conditional patching', () => { + // Setup: Create new feature instance with conditional patching that enables additionalCheck + const args = { + site: { + domain: 'trusted-site.com', + url: 'http://trusted-site.com', + }, + platform: { name: 'test' }, + bundledConfig: { + features: { + testFeature: { + state: 'enabled', + exceptions: [], + settings: { + additionalCheck: 'disabled', // Base setting + conditionalChanges: [ + { + condition: { + domain: 'trusted-site.com', + }, + patchSettings: [{ op: 'replace', path: '/additionalCheck', value: 'enabled' }], + }, + ], + }, + }, + }, + unprotectedTemporary: [], + }, + }; + + // Create feature instance with conditional patching + const testFeatureInstance = new ContentFeature('testFeature', {}, args); + + // Act: Check if the feature setting is enabled after conditional patching + const isEnabled = testFeatureInstance.getFeatureSettingEnabled('additionalCheck', 'enabled'); + + // Assert: Should be true due to conditional patching + expect(isEnabled).toBe(true); + }); + + it('should handle URL pattern based conditional patching', () => { + // Setup: Create new feature instance with URL pattern conditional patching + const args = { + site: { + domain: 'example.com', + url: 'http://example.com/sensitive/path', + }, + platform: { name: 'test' }, + bundledConfig: { + features: { + testFeature: { + state: 'enabled', + exceptions: [], + settings: { + additionalCheck: 'enabled', // Base setting + conditionalChanges: [ + { + condition: { + urlPattern: 'http://example.com/sensitive/*', + }, + patchSettings: [{ op: 'replace', path: '/additionalCheck', value: 'disabled' }], + }, + ], + }, + }, + }, + unprotectedTemporary: [], + }, + }; + + // Create feature instance with conditional patching + const testFeatureInstance = new ContentFeature('testFeature', {}, args); + + // Act: Check if the feature setting is disabled by URL pattern + const isEnabled = testFeatureInstance.getFeatureSettingEnabled('additionalCheck', 'enabled'); + + // Assert: Should be false due to URL pattern match + expect(isEnabled).toBe(false); + }); + + it('should not match URL pattern when path does not match', () => { + // Setup: Create new feature instance with different path that shouldn't match + const args = { + site: { + domain: 'example.com', + url: 'http://example.com/public/path', + }, + platform: { name: 'test' }, + bundledConfig: { + features: { + testFeature: { + state: 'enabled', + exceptions: [], + settings: { + additionalCheck: 'enabled', // Base setting + conditionalChanges: [ + { + condition: { + urlPattern: 'http://example.com/sensitive/*', + }, + patchSettings: [{ op: 'replace', path: '/additionalCheck', value: 'disabled' }], + }, + ], + }, + }, + }, + unprotectedTemporary: [], + }, + }; + + // Create feature instance with conditional patching + const testFeatureInstance = new ContentFeature('testFeature', {}, args); + + // Act: Check if the feature setting remains enabled + const isEnabled = testFeatureInstance.getFeatureSettingEnabled('additionalCheck', 'enabled'); + + // Assert: Should be true because URL pattern doesn't match + expect(isEnabled).toBe(true); + }); + + it('should use default value when additionalCheck setting does not exist', () => { + // Setup: Create new feature instance without additionalCheck setting + const args = { + site: { + domain: 'example.com', + url: 'http://example.com', + }, + platform: { name: 'test' }, + bundledConfig: { + features: { + testFeature: { + state: 'enabled', + exceptions: [], + settings: { + someOtherSetting: 'value', + // No additionalCheck setting + }, + }, + }, + unprotectedTemporary: [], + }, + }; + + // Create feature instance without additionalCheck + const testFeatureInstance = new ContentFeature('testFeature', {}, args); + + // Act: Check if the feature setting uses default value + const isEnabledWithDefault = testFeatureInstance.getFeatureSettingEnabled('additionalCheck', 'enabled'); + const isDisabledWithDefault = testFeatureInstance.getFeatureSettingEnabled('additionalCheck', 'disabled'); + + // Assert: Should use the default values + expect(isEnabledWithDefault).toBe(true); // Default 'enabled' -> true + expect(isDisabledWithDefault).toBe(false); // Default 'disabled' -> false + }); + + it('should handle multiple conditions with domain and URL pattern', () => { + // Setup: Create new feature instance with complex conditional patching + const args = { + site: { + domain: 'trusted-site.com', + url: 'http://trusted-site.com/app/dashboard', + }, + platform: { name: 'test' }, + bundledConfig: { + features: { + testFeature: { + state: 'enabled', + exceptions: [], + settings: { + additionalCheck: 'disabled', // Base setting + conditionalChanges: [ + { + condition: [ + { + domain: 'trusted-site.com', + }, + { + urlPattern: 'http://trusted-site.com/app/*', + }, + ], + patchSettings: [{ op: 'replace', path: '/additionalCheck', value: 'enabled' }], + }, + ], + }, + }, + }, + unprotectedTemporary: [], + }, + }; + + // Create feature instance with conditional patching + const testFeatureInstance = new ContentFeature('testFeature', {}, args); + + // Act: Check if the feature setting is enabled + const isEnabled = testFeatureInstance.getFeatureSettingEnabled('additionalCheck', 'enabled'); + + // Assert: Should be true because conditions match + expect(isEnabled).toBe(true); + }); + }); + + describe('simulated load/init behavior', () => { + it('should demonstrate how additionalCheck gates feature loading', () => { + // This test demonstrates the pattern used in content-scope-features.js + // Lines 60-62: if (!featureInstance.getFeatureSettingEnabled('additionalCheck', 'enabled')) { return; } + + class MockFeature extends ContentFeature { + constructor(featureName, importConfig, args) { + super(featureName, importConfig, args); + this.loadCalled = false; + this.initCalled = false; + } + + callLoad() { + // Simulate the additionalCheck gate in content-scope-features.js load function + if (!this.getFeatureSettingEnabled('additionalCheck', 'enabled')) { + return; // Early return when disabled + } + this.loadCalled = true; + } + + callInit() { + // Simulate the additionalCheck gate in content-scope-features.js init function + if (!this.getFeatureSettingEnabled('additionalCheck', 'enabled')) { + return; // Early return when disabled + } + this.initCalled = true; + } + } + + // Test case 1: additionalCheck disabled + const disabledArgs = { + site: { domain: 'blocked-site.com', url: 'http://blocked-site.com' }, + platform: { name: 'test' }, + bundledConfig: { + features: { + testFeature: { + state: 'enabled', + exceptions: [], + settings: { + additionalCheck: 'enabled', // Base setting + conditionalChanges: [ + { + condition: { + domain: 'blocked-site.com', + }, + patchSettings: [{ op: 'replace', path: '/additionalCheck', value: 'disabled' }], + }, + ], + }, + }, + }, + unprotectedTemporary: [], + }, + }; + + const disabledFeature = new MockFeature('testFeature', {}, disabledArgs); + disabledFeature.callLoad(); + disabledFeature.callInit(); + + expect(disabledFeature.loadCalled).toBe(false); // Should not load + expect(disabledFeature.initCalled).toBe(false); // Should not init + + // Test case 2: additionalCheck enabled + const enabledArgs = { + site: { domain: 'trusted-site.com', url: 'http://trusted-site.com' }, + platform: { name: 'test' }, + bundledConfig: { + features: { + testFeature: { + state: 'enabled', + exceptions: [], + settings: { + additionalCheck: 'disabled', // Base setting + conditionalChanges: [ + { + condition: { + domain: 'trusted-site.com', + }, + patchSettings: [{ op: 'replace', path: '/additionalCheck', value: 'enabled' }], + }, + ], + }, + }, + }, + unprotectedTemporary: [], + }, + }; + + const enabledFeature = new MockFeature('testFeature', {}, enabledArgs); + enabledFeature.callLoad(); + enabledFeature.callInit(); + + expect(enabledFeature.loadCalled).toBe(true); // Should load + expect(enabledFeature.initCalled).toBe(true); // Should init + }); + }); +}); diff --git a/injected/unit-test/dependency-format.spec.js b/injected/unit-test/dependency-format.spec.js new file mode 100644 index 0000000000..08d7abf172 --- /dev/null +++ b/injected/unit-test/dependency-format.spec.js @@ -0,0 +1,14 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +describe('Dependency format check', () => { + it('should use a 13-digit numeric tag (not a commit hash or short hash) for @duckduckgo/privacy-configuration', () => { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf-8')); + const dep = pkg.dependencies['@duckduckgo/privacy-configuration']; + // Only allow 13-digit numeric tags and not a commit hash or short hash + expect(dep).toMatch(/^github:duckduckgo\/privacy-configuration#\d{13}$/); + }); +}); diff --git a/injected/unit-test/features.js b/injected/unit-test/features.js index 85b9cf6829..b46413511f 100644 --- a/injected/unit-test/features.js +++ b/injected/unit-test/features.js @@ -1,23 +1,156 @@ import { platformSupport } from '../src/features.js'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { readFile } from 'fs/promises'; +import * as glob from 'glob'; +import { formatErrors } from '@duckduckgo/privacy-configuration/tests/schema-validation.js'; +import ApiManipulation from '../src/features/api-manipulation.js'; + +// TODO: Ignore eslint redeclare as we're linting for esm and cjs +// eslint-disable-next-line no-redeclare +const __filename = fileURLToPath(import.meta.url); +// eslint-disable-next-line no-redeclare +const __dirname = path.dirname(__filename); describe('Features definition', () => { it('calls `webCompat` before `fingerPrintingScreenSize` https://app.asana.com/0/1177771139624306/1204944717262422/f', () => { - // ensuring this order doesn't change, as it recently caused breakage - expect(platformSupport.apple).toEqual([ - 'webCompat', - 'fingerprintingAudio', - 'fingerprintingBattery', - 'fingerprintingCanvas', - 'googleRejected', - 'gpc', - 'fingerprintingHardware', - 'referrer', - 'fingerprintingScreenSize', - 'fingerprintingTemporaryStorage', - 'navigatorInterface', - 'elementHiding', - 'exceptionHandler', + const arr = platformSupport.apple; + const webCompatIdx = arr.indexOf('webCompat'); + const fpScreenSizeIdx = arr.indexOf('fingerprintingScreenSize'); + expect(webCompatIdx).not.toBe(-1); + expect(fpScreenSizeIdx).not.toBe(-1); + expect(webCompatIdx).toBeLessThan(fpScreenSizeIdx); + }); +}); + +describe('test-pages/*/config/*.json schema validation', () => { + let Ajv, schemaGenerator; + beforeAll(async () => { + Ajv = (await import('ajv')).default; + schemaGenerator = await import('ts-json-schema-generator'); + }); + + // TODO make the config export all of this so it can be imported + function createGenerator() { + return schemaGenerator.createGenerator({ + path: path.resolve(__dirname, '../../node_modules/@duckduckgo/privacy-configuration/schema/config.ts'), + }); + } + + function getSchema(schemaName) { + return createGenerator().createSchema(schemaName); + } + + function createValidator(schemaName) { + const ajv = new Ajv({ allowUnionTypes: true }); + return ajv.compile(getSchema(schemaName)); + } + + // Utility to ensure 'hash' exists on all features in the config + // Ideally we would not have these required in the config, it's pretty unnecessary for tests. + function ensureHashOnFeatures(config) { + if (config && typeof config === 'object' && config.features) { + for (const featureKey of Object.keys(config.features)) { + if (config.features[featureKey] && typeof config.features[featureKey] === 'object') { + if (!('hash' in config.features[featureKey])) { + config.features[featureKey].hash = ''; + } + } + } + } + return config; + } + + const configFiles = glob + .sync('../integration-test/test-pages/*/config/*.json', { cwd: __dirname }) + .map((p) => path.resolve(__dirname, p)); + + // Legacy allowlist: skip schema validation for these known legacy files + // Some of these have expected invalid configs + const legacyAllowlist = [ + // Favicon configs + path.resolve(__dirname, '../integration-test/test-pages/favicon/config/favicon-disabled.json'), + path.resolve(__dirname, '../integration-test/test-pages/favicon/config/favicon-absent.json'), + path.resolve(__dirname, '../integration-test/test-pages/favicon/config/favicon-enabled.json'), + path.resolve(__dirname, '../integration-test/test-pages/favicon/config/favicon-monitor-disabled.json'), + // Duckplayer configs + path.resolve(__dirname, '../integration-test/test-pages/duckplayer/config/overlays-live.json'), + path.resolve(__dirname, '../integration-test/test-pages/duckplayer/config/click-interceptions-disabled.json'), + path.resolve(__dirname, '../integration-test/test-pages/duckplayer/config/disabled.json'), + path.resolve(__dirname, '../integration-test/test-pages/duckplayer/config/overlays-drawer.json'), + path.resolve(__dirname, '../integration-test/test-pages/duckplayer/config/overlays.json'), + path.resolve(__dirname, '../integration-test/test-pages/duckplayer/config/thumbnail-overlays-disabled.json'), + path.resolve(__dirname, '../integration-test/test-pages/duckplayer/config/video-alt-selectors.json'), + path.resolve(__dirname, '../integration-test/test-pages/duckplayer/config/video-overlays-disabled.json'), + // Message bridge configs + path.resolve(__dirname, '../integration-test/test-pages/message-bridge/config/message-bridge-enabled.json'), + path.resolve(__dirname, '../integration-test/test-pages/message-bridge/config/message-bridge-disabled.json'), + ]; + for (const configPath of configFiles) { + if (legacyAllowlist.includes(configPath)) { + xit(`LEGACY: skipped schema validation for ${path.relative(process.cwd(), configPath)}`, () => {}); + continue; + } + it(`should match the CurrentGenericConfig schema: ${path.relative(process.cwd(), configPath)}`, async () => { + let config = JSON.parse(await readFile(configPath, 'utf-8')); + config = ensureHashOnFeatures(config); + const validate = createValidator('CurrentGenericConfig'); + const valid = validate(config); + if (!valid) { + throw new Error(`Schema validation failed for ${configPath}: ` + formatErrors(validate.errors)); + } + }); + } +}); + +describe('ApiManipulation', () => { + let apiManipulation; + let dummyTarget; + + beforeEach(() => { + apiManipulation = new ApiManipulation( 'apiManipulation', - ]); + {}, + { + bundledConfig: { features: { apiManipulation: { state: 'enabled', exceptions: [] } } }, + site: { domain: 'test.com' }, + platform: { version: '1.0.0' }, + }, + ); + dummyTarget = {}; + }); + + it('defines a new property if define: true is set and property does not exist', () => { + const change = { + type: 'descriptor', + getterValue: { type: 'string', value: 'defined!' }, + define: true, + }; + apiManipulation.wrapApiDescriptor(dummyTarget, 'definedByConfig', change); + expect(dummyTarget.definedByConfig).toBe('defined!'); + }); + + it('does not define a property if define is not set and property does not exist', () => { + const change = { + type: 'descriptor', + getterValue: { type: 'string', value: 'should not exist' }, + }; + apiManipulation.wrapApiDescriptor(dummyTarget, 'notDefinedByConfig', change); + expect(dummyTarget.notDefinedByConfig).toBeUndefined(); + }); + + it('wraps an existing property if present', () => { + Object.defineProperty(dummyTarget, 'hardwareConcurrency', { + get: () => 4, + configurable: true, + enumerable: true, + }); + const change = { + type: 'descriptor', + getterValue: { type: 'number', value: 222 }, + }; + apiManipulation.wrapApiDescriptor(dummyTarget, 'hardwareConcurrency', change); + // The getter should now return 222 + expect(dummyTarget.hardwareConcurrency).toBe(222); }); }); diff --git a/injected/unit-test/fixtures/page-context/README.md b/injected/unit-test/fixtures/page-context/README.md new file mode 100644 index 0000000000..9d0c990f8d --- /dev/null +++ b/injected/unit-test/fixtures/page-context/README.md @@ -0,0 +1,53 @@ +# Page Context DOM-to-Markdown Tests + +This directory contains test fixtures for testing the `domToMarkdown` function from `page-context.js`. + +## Directory Structure + +- `output/` - Generated markdown files from test runs (temporary, regenerated on each run) +- `expected/` - Expected markdown output files (committed to git) + +## How It Works + +The test suite (`page-context-dom.spec.js`) does the following: + +1. **Creates test cases** with HTML snippets and settings for `domToMarkdown` +2. **Converts HTML to Markdown** using JSDom to simulate a browser environment +3. **Writes output** to `output/` directory for inspection +4. **Compares output** with expected files in `expected/` directory +5. **Fails if different** - Any difference between output and expected causes test failure + +## Test Cases + +The suite includes 20 test cases covering: + +- Basic HTML elements (paragraphs, headings, lists, links, images) +- Formatting (bold, italic, mixed formatting) +- Complex structures (nested lists, articles, blog posts) +- Edge cases (hidden content, empty links, whitespace handling) +- Configuration options (max length truncation, excluded selectors, trim blank links) + +## Updating Expected Output + +When the `domToMarkdown` function behavior changes: + +1. Review the changes in `output/` directory +2. If changes are correct, copy them to `expected/`: + ```bash + cp unit-test/fixtures/page-context/output/*.md unit-test/fixtures/page-context/expected/ + ``` +3. Commit the updated expected files + +## Running Tests + +```bash +npm run test-unit -- unit-test/page-context-dom.spec.js +``` + +## Why This Approach? + +- **Visibility**: Output files make it easy to review markdown generation +- **Regression detection**: Tests fail on any unintended changes +- **Documentation**: Expected files serve as examples of the function's behavior +- **Easy updates**: Simple to update baselines when behavior intentionally changes + diff --git a/injected/unit-test/fixtures/page-context/expected/article-structure.md b/injected/unit-test/fixtures/page-context/expected/article-structure.md new file mode 100644 index 0000000000..f4d481a9f3 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/article-structure.md @@ -0,0 +1,15 @@ +# Article Title + By **Author Name** + This is the introduction paragraph with some *emphasis*. + +## First Section + Content of the first section. + + +- Point one + +- Point two + + +## Second Section + Content with a [link](https://example.com). \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/blog-post.md b/injected/unit-test/fixtures/page-context/expected/blog-post.md new file mode 100644 index 0000000000..38fbfb0c5d --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/blog-post.md @@ -0,0 +1,16 @@ +# Blog Post Title + Published on January 1, 2024 + +![Header image](header.jpg) + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + +## Key Takeaways + + +- First takeaway + +- Second takeaway + +- Third takeaway + + Read more on [our blog](https://blog.example.com). \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/bold-and-italic.md b/injected/unit-test/fixtures/page-context/expected/bold-and-italic.md new file mode 100644 index 0000000000..e18fa29377 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/bold-and-italic.md @@ -0,0 +1 @@ +This is **bold** and this is *italic*. \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/complex-nested.md b/injected/unit-test/fixtures/page-context/expected/complex-nested.md new file mode 100644 index 0000000000..c39c6e49a1 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/complex-nested.md @@ -0,0 +1,5 @@ +# Article Title +Introduction paragraph. + +## Section 1 +Section content with **bold** text. \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/empty-link-with-trim.md b/injected/unit-test/fixtures/page-context/expected/empty-link-with-trim.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/injected/unit-test/fixtures/page-context/expected/empty-link-without-trim.md b/injected/unit-test/fixtures/page-context/expected/empty-link-without-trim.md new file mode 100644 index 0000000000..48dae97634 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/empty-link-without-trim.md @@ -0,0 +1 @@ +[](https://example.com) \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/excluded-selectors.md b/injected/unit-test/fixtures/page-context/expected/excluded-selectors.md new file mode 100644 index 0000000000..cd47d64871 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/excluded-selectors.md @@ -0,0 +1,2 @@ +Keep this +Keep this too \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/headings.md b/injected/unit-test/fixtures/page-context/expected/headings.md new file mode 100644 index 0000000000..c9ec461e3b --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/headings.md @@ -0,0 +1,5 @@ +# Main Heading + +## Subheading + +### Sub-subheading \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/hidden-content.md b/injected/unit-test/fixtures/page-context/expected/hidden-content.md new file mode 100644 index 0000000000..ce67d2bb43 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/hidden-content.md @@ -0,0 +1 @@ +Visible text \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/image.md b/injected/unit-test/fixtures/page-context/expected/image.md new file mode 100644 index 0000000000..529d6c90fc --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/image.md @@ -0,0 +1 @@ +![A beautiful landscape](photo.jpg) \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/line-breaks.md b/injected/unit-test/fixtures/page-context/expected/line-breaks.md new file mode 100644 index 0000000000..2a7f79494c --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/line-breaks.md @@ -0,0 +1,3 @@ +First line +Second line +Third line \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/links.md b/injected/unit-test/fixtures/page-context/expected/links.md new file mode 100644 index 0000000000..976fc64897 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/links.md @@ -0,0 +1 @@ +Visit [our website](https://example.com) for more info. \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/max-length-truncation.md b/injected/unit-test/fixtures/page-context/expected/max-length-truncation.md new file mode 100644 index 0000000000..ccb2be2678 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/max-length-truncation.md @@ -0,0 +1 @@ +This is a very long paragraph ... \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/mixed-formatting.md b/injected/unit-test/fixtures/page-context/expected/mixed-formatting.md new file mode 100644 index 0000000000..bfdba83b1a --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/mixed-formatting.md @@ -0,0 +1 @@ +This has ***bold and italic*** together. \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/multiple-paragraphs.md b/injected/unit-test/fixtures/page-context/expected/multiple-paragraphs.md new file mode 100644 index 0000000000..13a988ab4a --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/multiple-paragraphs.md @@ -0,0 +1,3 @@ +First paragraph. +Second paragraph. +Third paragraph. \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/nested-lists.md b/injected/unit-test/fixtures/page-context/expected/nested-lists.md new file mode 100644 index 0000000000..cc0a7d1918 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/nested-lists.md @@ -0,0 +1,3 @@ +- Item 1 - Subitem 1.1 - Subitem 1.2 + +- Item 2 \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/ordered-list.md b/injected/unit-test/fixtures/page-context/expected/ordered-list.md new file mode 100644 index 0000000000..3eac2a7795 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/ordered-list.md @@ -0,0 +1,5 @@ +- First step + +- Second step + +- Third step \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/simple-paragraph.md b/injected/unit-test/fixtures/page-context/expected/simple-paragraph.md new file mode 100644 index 0000000000..72652b639c --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/simple-paragraph.md @@ -0,0 +1 @@ +This is a simple paragraph. \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/unordered-list.md b/injected/unit-test/fixtures/page-context/expected/unordered-list.md new file mode 100644 index 0000000000..1a073aeff5 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/unordered-list.md @@ -0,0 +1,5 @@ +- First item + +- Second item + +- Third item \ No newline at end of file diff --git a/injected/unit-test/fixtures/page-context/expected/whitespace-handling.md b/injected/unit-test/fixtures/page-context/expected/whitespace-handling.md new file mode 100644 index 0000000000..5a5f796431 --- /dev/null +++ b/injected/unit-test/fixtures/page-context/expected/whitespace-handling.md @@ -0,0 +1 @@ +Text with multiple spaces \ No newline at end of file diff --git a/injected/unit-test/helpers/polyfill-process-globals.js b/injected/unit-test/helpers/polyfill-process-globals.js index 3ae7bff555..b3bb45a563 100644 --- a/injected/unit-test/helpers/polyfill-process-globals.js +++ b/injected/unit-test/helpers/polyfill-process-globals.js @@ -1,31 +1,68 @@ -export function polyfillProcessGlobals() { +/** + * Creates a mock location object for testing purposes. + * @returns {Location} A mock location object. + */ +export function createLocationObject(href, frameAncestorsList = []) { + return { + href, + // @ts-expect-error - ancestorOrigins is not defined in the type definition + ancestorOrigins: createDomStringList(frameAncestorsList), + }; +} + +export function createDomStringList(list) { + const domStringList = { + length: list.length, + item(index) { + if (index < 0 || index >= list.length) { + return null; + } + return list[index]; + }, + contains(item) { + return list.includes(item); + }, + }; + + // Add index access support + for (let i = 0; i < list.length; i++) { + Object.defineProperty(domStringList, i, { + get() { + return list[i]; + }, + enumerable: true, + }); + } + + return domStringList; +} + +export function polyfillProcessGlobals(defaultLocation = 'http://localhost:8080', frameAncestorsList = [], topisNull = false) { // Store original values to restore later const originalDocument = globalThis.document; const originalLocation = globalThis.location; + const originalTop = globalThis.top; // Apply the patch + // @ts-expect-error - document is not defined in the type definition globalThis.document = { - referrer: 'http://localhost:8080', - location: { - href: 'http://localhost:8080', - // @ts-expect-error - ancestorOrigins is not defined in the type definition - ancestorOrigins: { - length: 0, - }, - }, + referrer: defaultLocation, + location: createLocationObject(defaultLocation, frameAncestorsList), }; - globalThis.location = { - href: 'http://localhost:8080', - // @ts-expect-error - ancestorOrigins is not defined in the type definition - ancestorOrigins: { - length: 0, - }, - }; + globalThis.location = createLocationObject(defaultLocation, frameAncestorsList); + + globalThis.top = Object.assign({}, originalTop, { + location: createLocationObject(defaultLocation, frameAncestorsList), + }); + if (topisNull) { + globalThis.top = null; + } // Return a cleanup function return function cleanup() { globalThis.document = originalDocument; globalThis.location = originalLocation; + globalThis.top = originalTop; }; } diff --git a/injected/unit-test/messaging.js b/injected/unit-test/messaging.js index ba6f6d4820..f604e3925b 100644 --- a/injected/unit-test/messaging.js +++ b/injected/unit-test/messaging.js @@ -49,6 +49,29 @@ describe('Messaging Transports', () => { }), ); }); + it("calls transport with a NotificationMessage and doesn't throw (but does log)", () => { + const { messaging, transport } = createMessaging(); + const notifySpy = spyOn(transport, 'notify').and.throwError('Test error 1'); + const errorLoggingSpy = spyOn(console, 'error'); + + try { + messaging.notify('helloWorld', { foo: 'bar' }); + } catch (e) { + fail('Should not throw'); + } + + expect(notifySpy).toHaveBeenCalledWith( + new NotificationMessage({ + context: 'contentScopeScripts', + featureName: 'hello-world', + method: 'helloWorld', + params: { foo: 'bar' }, + }), + ); + + expect(errorLoggingSpy.calls.first().args[0]).toContain('[Messaging] Failed to send notification:'); + expect(errorLoggingSpy.calls.first().args[1].message).toEqual('Test error 1'); + }); it('calls transport with a Subscription', () => { const { messaging, transport } = createMessaging(); @@ -217,7 +240,7 @@ describe('Android', () => { function createMessaging() { /** @type {import("@duckduckgo/messaging").MessagingTransport} */ const transport = { - notify(msg) { + notify(_) { // test }, diff --git a/injected/unit-test/page-context-dom.spec.js b/injected/unit-test/page-context-dom.spec.js new file mode 100644 index 0000000000..a036d120a8 --- /dev/null +++ b/injected/unit-test/page-context-dom.spec.js @@ -0,0 +1,197 @@ +import { JSDOM } from 'jsdom'; +import { writeFileSync, existsSync, mkdirSync, readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { domToMarkdown } from '../src/features/page-context.js'; + +const currentFilename = fileURLToPath(import.meta.url); +const currentDirname = dirname(currentFilename); + +/** + * @typedef {Object} DomToMarkdownSettings + * @property {number} maxLength - Maximum length of content + * @property {number} maxDepth - Maximum depth to traverse + * @property {string} excludeSelectors - CSS selectors to exclude from processing + * @property {boolean} includeIframes - Whether to include iframe content + * @property {boolean} trimBlankLinks - Whether to trim blank links + */ + +describe('page-context.js - domToMarkdown', () => { + const fixturesDir = join(currentDirname, 'fixtures', 'page-context'); + const outputDir = join(fixturesDir, 'output'); + + // Ensure output directory exists + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }); + } + + const defaultSettings = { maxLength: 10000, maxDepth: 100, excludeSelectors: null, includeIframes: false, trimBlankLinks: false }; + + const testCases = [ + { + name: 'simple-paragraph', + html: '

    This is a simple paragraph.

    ', + settings: defaultSettings, + }, + { + name: 'multiple-paragraphs', + html: '

    First paragraph.

    Second paragraph.

    Third paragraph.

    ', + settings: defaultSettings, + }, + { + name: 'headings', + html: '

    Main Heading

    Subheading

    Sub-subheading

    ', + settings: defaultSettings, + }, + { + name: 'bold-and-italic', + html: '

    This is bold and this is italic.

    ', + settings: defaultSettings, + }, + { + name: 'links', + html: '

    Visit our website for more info.

    ', + settings: defaultSettings, + }, + { + name: 'unordered-list', + html: '
    • First item
    • Second item
    • Third item
    ', + settings: defaultSettings, + }, + { + name: 'ordered-list', + html: '
    1. First step
    2. Second step
    3. Third step
    ', + settings: defaultSettings, + }, + { + name: 'nested-lists', + html: '
    • Item 1
      • Subitem 1.1
      • Subitem 1.2
    • Item 2
    ', + settings: defaultSettings, + }, + { + name: 'image', + html: 'A beautiful landscape', + settings: defaultSettings, + }, + { + name: 'line-breaks', + html: '

    First line
    Second line
    Third line

    ', + settings: defaultSettings, + }, + { + name: 'complex-nested', + html: '

    Article Title

    Introduction paragraph.

    Section 1

    Section content with bold text.

    ', + settings: defaultSettings, + }, + { + name: 'whitespace-handling', + html: '

    Text with multiple spaces

    ', + settings: defaultSettings, + }, + { + name: 'hidden-content', + html: '

    Visible text

    Hidden text

    ', + settings: defaultSettings, + }, + { + name: 'excluded-selectors', + html: '

    Keep this

    Remove this ad

    Keep this too

    ', + settings: { maxLength: 10000, maxDepth: 100, excludeSelectors: '.ad', includeIframes: false, trimBlankLinks: false }, + }, + { + name: 'max-length-truncation', + html: '

    This is a very long paragraph that should be truncated at the maximum length setting.

    ', + settings: { maxLength: 30, maxDepth: 100, excludeSelectors: null, includeIframes: false, trimBlankLinks: false }, + }, + { + name: 'empty-link-with-trim', + html: '', + settings: { maxLength: 10000, maxDepth: 100, excludeSelectors: null, includeIframes: false, trimBlankLinks: true }, + }, + { + name: 'empty-link-without-trim', + html: '', + settings: { maxLength: 10000, maxDepth: 100, excludeSelectors: null, includeIframes: false, trimBlankLinks: false }, + }, + { + name: 'mixed-formatting', + html: '

    This has bold and italic together.

    ', + settings: defaultSettings, + }, + { + name: 'article-structure', + html: `
    +

    Article Title

    +

    By Author Name

    +

    This is the introduction paragraph with some emphasis.

    +

    First Section

    +

    Content of the first section.

    +
      +
    • Point one
    • +
    • Point two
    • +
    +

    Second Section

    +

    Content with a link.

    +
    `, + settings: defaultSettings, + }, + { + name: 'blog-post', + html: `
    +

    Blog Post Title

    +

    Published on January 1, 2024

    + Header image +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit.

    +

    Key Takeaways

    +
      +
    1. First takeaway
    2. +
    3. Second takeaway
    4. +
    5. Third takeaway
    6. +
    +

    Read more on our blog.

    +
    `, + settings: defaultSettings, + }, + ]; + + for (const testCase of testCases) { + it(`should convert ${testCase.name} to markdown`, () => { + // Create a JSDOM instance + const dom = new JSDOM(`${testCase.html}`); + const { window } = dom; + const { document } = window; + + // Save original globals + const originalWindow = global.window; + const originalNode = global.Node; + + // Set up global window and Node for the imported function + global.window = window; + global.Node = window.Node; + + try { + // Convert to markdown + const markdown = domToMarkdown(document.body, testCase.settings, 0).trim(); + + // Write output file + const outputFile = join(outputDir, `${testCase.name}.md`); + writeFileSync(outputFile, markdown, 'utf8'); + + // Check if expected file exists + const expectedFile = join(fixturesDir, 'expected', `${testCase.name}.md`); + if (existsSync(expectedFile)) { + const expected = readFileSync(expectedFile, 'utf8').trim(); + expect(markdown).toEqual(expected); + } else { + // On first run, we'll just generate the output files + // User needs to review and move them to expected/ directory + console.log(`Generated output for ${testCase.name} - review and move to expected/`); + } + } finally { + // Restore original globals + global.window = originalWindow; + global.Node = originalNode; + } + }); + } +}); diff --git a/injected/unit-test/utils.js b/injected/unit-test/utils.js index 68acaf7a70..76a68a6d46 100644 --- a/injected/unit-test/utils.js +++ b/injected/unit-test/utils.js @@ -1,6 +1,19 @@ -import { matchHostname, postDebugMessage, initStringExemptionLists, processConfig, satisfiesMinVersion } from '../src/utils.js'; +import { + matchHostname, + postDebugMessage, + initStringExemptionLists, + processConfig, + satisfiesMinVersion, + isMaxSupportedVersion, + getTabHostname, + processAttr, +} from '../src/utils.js'; import { polyfillProcessGlobals } from './helpers/polyfill-process-globals.js'; +/** + * @typedef {import('../src/utils.js').ConfigSetting} ConfigSetting + */ + polyfillProcessGlobals(); describe('Helpers checks', () => { @@ -69,6 +82,7 @@ describe('Helpers checks', () => { expect(processedConfig).toEqual({ site: { domain: 'localhost', + url: 'http://localhost:8080/', isBroken: false, allowlisted: false, // testFeatureTooBig is not enabled because it's minSupportedVersion is 100 @@ -89,8 +103,6 @@ describe('Helpers checks', () => { }, versionNumber: 99, sessionKey: 'testSessionKey', - // import.meta.trackerLookup is undefined because we've not overloaded it - trackerLookup: undefined, bundledConfig: configIn, }); }); @@ -149,6 +161,7 @@ describe('Helpers checks', () => { expect(processedConfig).toEqual({ site: { domain: 'localhost', + url: 'http://localhost:8080/', isBroken: false, allowlisted: false, // testFeatureTooBig is not enabled because it's minSupportedVersion is 100 @@ -169,11 +182,42 @@ describe('Helpers checks', () => { }, versionString: '0.9.9', sessionKey: 'testSessionKey', - trackerLookup: undefined, bundledConfig: configIn, }); }); + it('does not enable features with state "preview"', () => { + const configIn = { + features: { + testFeature: { + state: 'enabled', + settings: {}, + exceptions: [], + }, + previewFeature: { + state: 'preview', + settings: {}, + exceptions: [], + }, + }, + unprotectedTemporary: [], + }; + const processedConfig = processConfig( + configIn, + [], + { + platform: { + name: 'android', + }, + versionNumber: 99, + sessionKey: 'testSessionKey', + }, + [], + ); + expect(processedConfig.site.enabledFeatures).toEqual(['testFeature']); + expect(processedConfig.featureSettings).toEqual({ testFeature: {} }); + }); + describe('utils.satisfiesMinVersion', () => { // Min version, Extension version, outcome /** @type {[string, string, boolean][]} */ @@ -213,6 +257,84 @@ describe('Helpers checks', () => { } }); + describe('utils.isMaxSupportedVersion', () => { + // Max version, Current version, outcome (should current version be allowed) + /** @type {[string, string, boolean][]} */ + const stringCases = [ + ['12', '13', false], + ['12', '12', true], + ['12', '11', true], + ['12.1', '12.2', false], + ['12.1', '12.1', true], + ['12.1', '12.0', true], + ['12.1.1', '12.1.2', false], + ['12.1.1', '12.1.1', true], + ['12.1.1', '12.1.0', true], + ['12.2.0', '12.2.1', false], + ['12.2.0', '12.1.1', true], + ['12.12.12', '13.12.12', false], + ['12.12.12', '12.13.12', false], + ['12.12.12', '12.12.13', false], + ['12.12.12', '12.12.12', true], + ['12.12.12', '12.12.11', true], + ['102.12.11', '102.12.12', false], + ['102.12.12', '102.12.12', true], + ['102.12.13', '102.12.14', false], + ['102.12.13', '102.12.12', true], + ['101', '102.12.12.1', false], + ['103', '102.12.12.1', true], + ['104', '102.12.12.1', true], + ['102.12.12.1', '103', false], + ['102.12.12.1', '101', true], + ['102.13.12', '102.14.12', false], + ['102.13.12', '102.12.12.1', true], + ['102.12.12', '102.12.12.1', false], + ['102.12.12.1', '102.12.12.2', false], + ['102.12.12.2', '102.12.12.3', false], + ['102.12.12.2', '102.12.12.1', true], + ['102.12.12.1', '102.12.12.1', true], + ['102.12.12.3', '102.12.12.4', false], + ['102.12.12.3', '102.12.12.1', true], + ['102.12.12.1.1', '102.12.12.1.2', false], + ['102.12.12.1.1', '102.12.12.1', true], + ['102.12.12.2.1', '102.12.12.2.2', false], + ['102.12.12.2.1', '102.12.12.1', true], + ['102.12.12.1.1.1.1.1.1.1', '102.12.12.1.1.1.1.1.1.2', false], + ['102.12.12.1.1.1.1.1.1.1', '102.12.12.1', true], + ]; + for (const testCase of stringCases) { + const [maxVersionString, currentVersionString, expectedOutcome] = testCase; + it(`returns ${JSON.stringify(expectedOutcome)} for max version ${maxVersionString} with current ${currentVersionString}`, () => { + expect(isMaxSupportedVersion(maxVersionString, currentVersionString)).toEqual(expectedOutcome); + }); + } + + // Max version, Current version, outcome (integers) + /** @type {[number, number, boolean][]} */ + const intCases = [ + [100, 99, true], + [100, 100, true], + [100, 101, false], + [99, 99, true], + [99, 98, true], + [98, 99, false], + [0, 0, true], + [0, 1, false], + [1, 0, true], + [200, 199, true], + [200, 201, false], + [150, 150, true], + [150, 151, false], + [151, 150, true], + ]; + for (const testCase of intCases) { + const [maxVersion, currentVersion, expectedOutcome] = testCase; + it(`returns ${JSON.stringify(expectedOutcome)} for max version ${maxVersion} with current ${currentVersion} (integers)`, () => { + expect(isMaxSupportedVersion(maxVersion, currentVersion)).toEqual(expectedOutcome); + }); + } + }); + describe('utils.postDebugMessage', () => { const counters = new Map(); globalThis.postMessage = (message) => { @@ -244,4 +366,350 @@ describe('Helpers checks', () => { expect(counters.get('testf')).toEqual(5000); }); }); + + describe('utils.getTabHostname', () => { + it('returns the hostname of the URL', () => { + const hostname = getTabHostname(); + expect(hostname).toEqual('localhost'); + + const reset = polyfillProcessGlobals('http://example.com'); + const hostname2 = getTabHostname(); + expect(hostname2).toEqual('example.com'); + reset(); + + const hostname3 = getTabHostname(); + expect(hostname3).toEqual('localhost'); + + // Validates when we're in a frame thats sandboxed so top is null + const reset2 = polyfillProcessGlobals('https://bloop.com', ['http://example.com'], true); + const hostname5 = getTabHostname(); + expect(hostname5).toEqual('example.com'); + reset2(); + }); + }); + + describe('processAttr', () => { + describe('Basic types', () => { + it('returns default value when configSetting is undefined', () => { + expect(processAttr(/** @type {any} */ (undefined), 'default')).toBe('default'); + }); + + it('returns default value when configSetting is not an object', () => { + expect(processAttr(/** @type {any} */ ('string'), 'default')).toBe('default'); + expect(processAttr(/** @type {any} */ (123), 'default')).toBe('default'); + expect(processAttr(/** @type {any} */ (true), 'default')).toBe('default'); + }); + + it('returns default value when configSetting has no type', () => { + expect(processAttr(/** @type {any} */ ({}), 'default')).toBe('default'); + }); + + it('handles undefined type', () => { + /** @type {ConfigSetting} */ + const configSetting = { type: 'undefined' }; + expect(processAttr(configSetting)).toBe(undefined); + }); + + it('handles string type', () => { + /** @type {ConfigSetting} */ + const configSetting = { type: 'string', value: 'hello' }; + expect(processAttr(configSetting)).toBe('hello'); + }); + + it('handles number type', () => { + /** @type {ConfigSetting} */ + const configSetting = { type: 'number', value: 42 }; + expect(processAttr(configSetting)).toBe(42); + }); + + it('handles boolean type', () => { + /** @type {ConfigSetting} */ + const configSetting = { type: 'boolean', value: true }; + expect(processAttr(configSetting)).toBe(true); + }); + + it('handles null type', () => { + /** @type {ConfigSetting} */ + const configSetting = { type: 'null', value: null }; + expect(processAttr(configSetting)).toBe(null); + }); + + it('handles array type', () => { + /** @type {ConfigSetting} */ + const configSetting = { type: 'array', value: [1, 2, 3] }; + expect(processAttr(configSetting)).toEqual([1, 2, 3]); + }); + + it('handles object type', () => { + /** @type {ConfigSetting} */ + const configSetting = { type: 'object', value: { key: 'value' } }; + expect(processAttr(configSetting)).toEqual({ key: 'value' }); + }); + }); + + describe('Function type', () => { + it('handles function type with functionName', () => { + /** @type {ConfigSetting} */ + const configSetting = { type: 'function', functionName: 'noop' }; + const result = processAttr(configSetting); + expect(typeof result).toBe('function'); + expect(result()).toBe(undefined); // noop returns undefined + }); + + it('handles function type with functionValue', () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'function', + functionValue: { + type: 'number', + value: 1, + }, + }; + const result = processAttr(configSetting); + expect(typeof result).toBe('function'); + expect(result()).toBe(1); + }); + + it('handles function type with complex functionValue', () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'function', + functionValue: { + type: 'string', + value: 'hello world', + }, + }; + const result = processAttr(configSetting); + expect(typeof result).toBe('function'); + expect(result()).toBe('hello world'); + }); + + it('handles function type with nested object functionValue', () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'function', + functionValue: { + type: 'object', + value: { message: 'test', count: 5 }, + }, + }; + const result = processAttr(configSetting); + expect(typeof result).toBe('function'); + expect(result()).toEqual({ message: 'test', count: 5 }); + }); + + it('handles function type with array functionValue', () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'function', + functionValue: { + type: 'array', + value: ['a', 'b', 'c'], + }, + }; + const result = processAttr(configSetting); + expect(typeof result).toBe('function'); + expect(result()).toEqual(['a', 'b', 'c']); + }); + + it('prefers functionName over functionValue when both are present', () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'function', + functionName: 'noop', + functionValue: { + type: 'string', + value: 'should not be used', + }, + }; + const result = processAttr(configSetting); + expect(typeof result).toBe('function'); + expect(result()).toBe(undefined); // noop returns undefined + }); + }); + + describe('Async support', () => { + it('handles async string', async () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'string', + value: 'boop', + async: true, + }; + const result = processAttr(configSetting); + expect(result instanceof Promise).toBe(true); + const resolvedValue = await result; + expect(resolvedValue).toBe('boop'); + }); + + it('handles async number', async () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'number', + value: 123, + async: true, + }; + const result = processAttr(configSetting); + expect(result instanceof Promise).toBe(true); + const resolvedValue = await result; + expect(resolvedValue).toBe(123); + }); + + it('handles async boolean', async () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'boolean', + value: false, + async: true, + }; + const result = processAttr(configSetting); + expect(result instanceof Promise).toBe(true); + const resolvedValue = await result; + expect(resolvedValue).toBe(false); + }); + + it('handles async array', async () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'array', + value: [1, 2, 'test'], + async: true, + }; + const result = processAttr(configSetting); + expect(result instanceof Promise).toBe(true); + const resolvedValue = await result; + expect(resolvedValue).toEqual([1, 2, 'test']); + }); + + it('handles async object', async () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'object', + value: { key: 'async-value', nested: { prop: 'test' } }, + async: true, + }; + const result = processAttr(configSetting); + expect(result instanceof Promise).toBe(true); + const resolvedValue = await result; + expect(resolvedValue).toEqual({ key: 'async-value', nested: { prop: 'test' } }); + }); + + it('handles async null', async () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'null', + value: null, + async: true, + }; + const result = processAttr(configSetting); + expect(result instanceof Promise).toBe(true); + const resolvedValue = await result; + expect(resolvedValue).toBe(null); + }); + + it('non-async values should not be wrapped in Promise', () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'string', + value: 'not async', + }; + const result = processAttr(configSetting); + expect(result instanceof Promise).toBe(false); + expect(result).toBe('not async'); + }); + }); + + describe('Criteria support', () => { + it('handles array with criteria selection - fallback case', () => { + /** @type {ConfigSetting[]} */ + const configSetting = [ + { + type: 'string', + value: 'fallback', + }, + { + type: 'string', + value: 'criteria-based', + criteria: { arch: 'SomeOtherArch' }, // This won't match, so should use fallback + }, + ]; + const result = processAttr(configSetting); + expect(result).toBe('fallback'); + }); + + it('handles async array with criteria selection - fallback case', async () => { + /** @type {ConfigSetting[]} */ + const configSetting = [ + { + type: 'string', + value: 'fallback', + async: true, + }, + { + type: 'string', + value: 'criteria-based', + criteria: { arch: 'SomeOtherArch' }, // This won't match, so should use fallback + async: true, + }, + ]; + const result = processAttr(configSetting); + expect(result instanceof Promise).toBe(true); + const resolvedValue = await result; + expect(resolvedValue).toBe('fallback'); + }); + }); + + describe('Complex combinations', () => { + it('handles function with async functionValue', () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'function', + functionValue: { + type: 'string', + value: 'async result', + async: true, + }, + }; + const result = processAttr(configSetting); + expect(typeof result).toBe('function'); + const functionResult = result(); + expect(functionResult instanceof Promise).toBe(true); + }); + + it('function returns correct async value', async () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'function', + functionValue: { + type: 'object', + value: { data: 'complex async object' }, + async: true, + }, + }; + const result = processAttr(configSetting); + const functionResult = result(); + const resolvedValue = await functionResult; + expect(resolvedValue).toEqual({ data: 'complex async object' }); + }); + + it('nested function with nested processAttr calls', () => { + /** @type {ConfigSetting} */ + const configSetting = { + type: 'function', + functionValue: { + type: 'function', + functionValue: { + type: 'number', + value: 42, + }, + }, + }; + const result = processAttr(configSetting); + expect(typeof result).toBe('function'); + const nestedFunction = result(); + expect(typeof nestedFunction).toBe('function'); + expect(nestedFunction()).toBe(42); + }); + }); + }); }); diff --git a/injected/unit-test/verify-artifacts.js b/injected/unit-test/verify-artifacts.js index 144d12970e..f5daddbb8d 100644 --- a/injected/unit-test/verify-artifacts.js +++ b/injected/unit-test/verify-artifacts.js @@ -6,10 +6,8 @@ import { cwd } from '../../scripts/script-utils.js'; const ROOT = join(cwd(import.meta.url), '..', '..'); console.log(ROOT); const BUILD = join(ROOT, 'build'); -const APPLE_BUILD = join(ROOT, 'Sources/ContentScopeScripts/dist'); -console.log(APPLE_BUILD); -let CSS_OUTPUT_SIZE = 760_000; -const CSS_OUTPUT_SIZE_CHROME = CSS_OUTPUT_SIZE * 1.45; // 45% larger for Chrome MV2 due to base64 encoding + +let CSS_OUTPUT_SIZE = 800_000; if (process.platform === 'win32') { CSS_OUTPUT_SIZE = CSS_OUTPUT_SIZE * 1.1; // 10% larger for Windows due to line endings } @@ -17,23 +15,15 @@ if (process.platform === 'win32') { const checks = { android: { file: join(BUILD, 'android/contentScope.js'), - tests: [ - { kind: 'maxFileSize', value: CSS_OUTPUT_SIZE }, - { kind: 'containsString', text: 'output.trackerLookup = {', includes: true }, - ], - }, - chrome: { - file: join(BUILD, 'chrome/inject.js'), - tests: [ - { kind: 'maxFileSize', value: CSS_OUTPUT_SIZE_CHROME }, - { kind: 'containsString', text: '$TRACKER_LOOKUP$', includes: true }, - ], + tests: [{ kind: 'maxFileSize', value: CSS_OUTPUT_SIZE }], }, 'chrome-mv3': { file: join(BUILD, 'chrome-mv3/inject.js'), tests: [ { kind: 'maxFileSize', value: CSS_OUTPUT_SIZE }, { kind: 'containsString', text: '$TRACKER_LOOKUP$', includes: true }, + { kind: 'containsString', text: 'Copyright (C) 2010 by Johannes Baagøe ', includes: true }, + { kind: 'containsString', text: 'Copyright 2019 David Bau.', includes: true }, ], }, firefox: { @@ -45,21 +35,24 @@ const checks = { }, integration: { file: join(BUILD, 'integration/contentScope.js'), - tests: [{ kind: 'containsString', text: 'const trackerLookup = {', includes: true }], + tests: [{ kind: 'containsString', text: 'init_define_import_meta_trackerLookup = ', includes: true }], }, windows: { file: join(BUILD, 'windows/contentScope.js'), + tests: [{ kind: 'maxFileSize', value: CSS_OUTPUT_SIZE }], + }, + apple: { + file: join(BUILD, 'apple/contentScope.js'), tests: [ { kind: 'maxFileSize', value: CSS_OUTPUT_SIZE }, - { kind: 'containsString', text: 'output.trackerLookup = {', includes: true }, + { kind: 'containsString', text: '#bundledConfig', includes: false }, ], }, - apple: { - file: join(APPLE_BUILD, 'contentScope.js'), + 'apple-isolated': { + file: join(BUILD, 'apple/contentScopeIsolated.js'), tests: [ { kind: 'maxFileSize', value: CSS_OUTPUT_SIZE }, - { kind: 'containsString', text: 'output.trackerLookup = {', includes: true }, - { kind: 'containsString', text: '#bundledConfig', includes: false }, + { kind: 'containsString', text: 'Copyright (c) 2014-2015, hassansin', includes: true }, ], }, }; @@ -77,7 +70,6 @@ describe('checks', () => { if (check.kind === 'containsString') { it(`${platformName}: '${localPath}' contains ${check.text}`, () => { const fileContents = readFileSync(platformChecks.file).toString(); - // @ts-expect-error - can't infer that value is a number without adding types const includes = fileContents.includes(check.text); if (check.includes) { expect(includes).toBeTrue(); diff --git a/messaging/docs/examples.md b/messaging/docs/examples.md new file mode 100644 index 0000000000..68a8e0204a --- /dev/null +++ b/messaging/docs/examples.md @@ -0,0 +1,130 @@ +--- +title: Example JSON payloads +--- + +## Example JSON payloads + +## Notifications + +**{@link Messaging.NotificationMessage}** + +```json +{ + "context": "contentScopeScripts", + "featureName": "duckPlayer", + "method": "saveUserValues" +} +``` + +**{@link Messaging.NotificationMessage} with params** + +```json +{ + "context": "contentScopeScripts", + "featureName": "duckPlayer", + "method": "saveUserValues", + "params": { "hello": "world" } +} +``` + +**{@link Messaging.NotificationMessage} with `invalid` params** + +```json +{ + "context": "contentScopeScripts", + "featureName": "duckPlayer", + "method": "getUserValues", + "params": "oops! <- cannot be a string/number/boolean/null" +} +``` + +## Requests + +**{@link Messaging.RequestMessage}** + +```json +{ + "context": "contentScopeScripts", + "featureName": "duckPlayer", + "method": "getUserValues", + "id": "abc123" +} +``` + + +**{@link Messaging.RequestMessage} with params** + +```json +{ + "context": "contentScopeScripts", + "featureName": "duckPlayer", + "method": "getUserValues", + "params": { "hello": "world" }, + "id": "abc123" +} +``` + + +**{@link Messaging.RequestMessage} with invalid params** + +```json +{ + "context": "contentScopeScripts", + "featureName": "duckPlayer", + "method": "getUserValues", + "params": "oops! <- cannot be a string/number/boolean/null", + "id": "abc123" +} +``` + +## Responses + +**{@link Messaging.MessageResponse} with data** + +```json +{ + "context": "contentScopeScripts", + "featureName": "duckPlayer", + "id": "abc123", + "result": { "hello": "world" } +} +``` + +## Error Response + +**{@link Messaging.MessageResponse} with error** + +```json +{ + "context": "contentScopeScripts", + "featureName": "duckPlayer", + "id": "abc123", + "error": { + "message": "Method not found" + } +} +``` + + +## Subscriptions + +**{@link Messaging.SubscriptionEvent} without data** + +```json +{ + "context": "contentScopeScripts", + "featureName": "duckPlayer", + "subscriptionName": "onUserValuesUpdated" +} +``` + +**{@link Messaging.SubscriptionEvent} with data** + +```json +{ + "context": "contentScopeScripts", + "featureName": "duckPlayer", + "subscriptionName": "onUserValuesUpdated", + "params": { "hello": "world" } +} +``` \ No newline at end of file diff --git a/messaging/docs/implementation-guide.md b/messaging/docs/implementation-guide.md new file mode 100644 index 0000000000..1621dcd37c --- /dev/null +++ b/messaging/docs/implementation-guide.md @@ -0,0 +1,82 @@ +--- +title: Implementation Guide +--- + +# Messaging Implementation Guide + +## Step 1) Receiving a notification or request message: + +Each platform will 'receive' messages according to their own best practices, the following spec describes everything **after** +the message has been delivered from the clientside JavaScript (deliberately avoiding the platform specifics of *how* messages arrive) + +For example, in Android this would be what happens within a `@Javascript` Interface, but on macOS it would be within +the WebKit messaging protocol, etc. + +### Algorithm + +1. let `s` be an incoming raw `JSON` payload +2. let `msg` be the result of parsing `s` into key/value pairs + - 2.1 Note: 'parsing' here may not be required if the platform in question receives JSON data directly (ie: JavaScript environments) +3. if parsing was not successful, log an "invalid message" exception and exit. +4. validate that `msg.context` exists and is a `string` value +5. validate that `msg.featureName` exists and is a `string` value +6. validate that `msg.method` exists and is a `string` value + - 6.1 if `context`, `featureName` or `method` are invalid (not a string, or missing), log an "invalid message" Exception and exit. +7. let `params` be a reference to `msg.params` or a new, empty key/value structure +8. if `params` is not a valid key/value structure, log an "invalid params" exception and exit. +9. if the `msg.id` field is absent, then: + - 9.1. mark `msg` as being of type {@link Messaging.NotificationMessage} +10. if the `msg.id` field is present, then: + - 10.1. validate that `msg.id` a string value, log an "invalid id" exception if it isn't, and exit. + - 10.2. mark `msg` as being of type {@link Messaging.RequestMessage} +11. At this point, you should have a structure that represents either a {@link Messaging.NotificationMessage} or + {@link Messaging.RequestMessage}. Then move to Step 2) + + +## Step 2) Choosing and executing a handler + +Once you've completed Step 1), you'll know whether you are dealing with a notification or a request (something you need +to respond to). At this point you don't know which feature will attempt the message, you just know the format was correct. + +### Algorithm + +1. let `feature` be the result of looking up a feature that matches name `msg.featureName` +2. if `feature` is not found: + - 2.1 if `msg` was marked as type `Request`, return a "feature not found" [Error Response](./examples.md#error-response) + - 2.2 if `msg` was marked as type `Notification`, optionally log a "feature not found" exception and exit +3. let `handler` be the result of calling `feature.handlerFor(msg.method)` +4. if `handler` is not found: + - 4.1 if `msg` was marked as type `Request`, return a "method not found" [Error Response](./examples.md#error-response) + - 4.2 if `msg` was marked as type `Notification`, optionally log a "feature not found" exception and exit +5. execute `handler` with `msg.params` + - 5.1. if `msg` was marked as a {@link Messaging.NotificationMessage} (via step 1), then: + 1. do not wait for a response + 2. if the platform must respond (to prevent errors), then: + 1. respond with an empty key/value JSON structure `{}` + - 5.2. if `msg` was marked as a {@link Messaging.RequestMessage}, then: + 1. let `response` be a new instance of {@link Messaging.MessageResponse} + 1. assign `msg.context` to `response.context` + 2. assign `msg.featureName` to `response.featureName` + 3. assign `msg.id` to `response.id` + 2. let `result` be the return value of _executing_ `handler(msg.params)` + 3. if `result` is empty, assign `result` to an empty key/value structure + 4. if an **error** occurred during execution, then: + 1. let `error` be a new instance of {@link Messaging.MessageError} + 2. assign a descriptive message if possible, to `error.message` + 3. assign `error` to `response.error` + 5. if an error **did not occur**, assign `result` to `response.result` + 6. let `json` be the string result of converting `response` into JSON + 7. deliver the JSON response in the platform-specified way + +## Step 3) Push-based messaging + +1. let `event` be a new instance of {@link Messaging.SubscriptionEvent} +2. assign `event.context` to the target context +3. assign `event.featureName` to the target feature +4. assign `event.subscriptionName` to the target subscriptionName +5. if the message contains data, then + 1. let `params` be a key/value structure + 1. Note: only key/value structures are permitted. + 2. assign `params` to `event.params` +6. let `json` be the string result of converting `event` into JSON +7. deliver the JSON response in the platform-specified way diff --git a/messaging/docs/messaging.md b/messaging/docs/messaging.md new file mode 100644 index 0000000000..dafcf5ebbe --- /dev/null +++ b/messaging/docs/messaging.md @@ -0,0 +1,89 @@ +--- +title: Messaging +children: + - ./implementation-guide.md + - ./examples.md +--- + +# Messaging + +An abstraction for communications between JavaScript and host platforms. Note: We avoid the term 'client' in these docs +since it can be confused with the Browser itself, which is often referred to as the 'client' in other contexts. + +The purpose of this library is to enable three idiomatic JavaScript methods for communicating with native platforms: + +**tl;dr:** + +```javascript +// notification +messaging.notify("helloWorld", { some: "data" }) + +// requests +const response = await messaging.request("helloWorld", { some: "data" }); + +// subscriptions +const unsubscribe = messaging.subscribe("helloWorld", (data) => { + console.log(data) +}); +``` + +## Notifications + +Notifications do not produce a response, they are fire+forget by nature. A call to `.notify(method, params)` will never +throw an exception, so is safe to call at all times. If you need acknowledgement, or a response, use a Request instead. + +```js +// with params +messaging.notify("helloWorld", { some: "data" }) + +// without params +messaging.notify("helloWorld") +``` + +## Requests + +Request should be used when you require acknowledgement or a response. Calls to `.request(method, params)` return a promise +that can be awaited. Note: calls to `.request()` can throw exceptions - this is deliberate to ensure compatibility with +JavaScript APIs like `Promise.all([...])`. + +**A single request->response** +```js +const response = await messaging.request("helloWorld", { some: "data" }); +``` + +**With try/catch** +```js +try { + const response = await messaging.request("helloWorld", { some: "data" }); + // use the response +} catch (e) { + // handle the error +} +``` + +### Ignoring errors (default values) +In cases where you don't need to handle the error, use the await/catch pattern to simulate a default value + +```js +const response = await messaging.request("helloWorld", { some: "data" }).catch(() => null); +``` + +**With Platform APIs, like `Promise.all`** +```js +const request1 = messaging.request("helloWorld", { some: "data" }); +const request2 = messaging.request("other"); + +const [response1, response2] = await Promise.all([request1, request2]) +``` + +## Subscriptions + +A subscription is created in JavaScript as a means for the host platform to _push_ values. Note: Subscriptions are created +in JavaScript and DO NOT include acknowledgment from the host platform. If you need that kind of guarantee, +use a request first successful request->response. + +```js +const unsubscribe = messaging.subscribe("helloWorld", (data) => { + console.log(data) +}); +``` diff --git a/messaging/index.js b/messaging/index.js index 8001f49394..db602a3225 100644 --- a/messaging/index.js +++ b/messaging/index.js @@ -30,6 +30,7 @@ import { import { WebkitMessagingConfig, WebkitMessagingTransport } from './lib/webkit.js'; import { NotificationMessage, RequestMessage, Subscription, MessageResponse, MessageError, SubscriptionEvent } from './schema.js'; import { AndroidMessagingConfig, AndroidMessagingTransport } from './lib/android.js'; +import { AndroidAdsjsMessagingConfig, AndroidAdsjsMessagingTransport } from './lib/android-adsjs.js'; import { createTypedMessages } from './lib/typed-messages.js'; /** @@ -51,7 +52,7 @@ export class MessagingContext { } /** - * @typedef {WebkitMessagingConfig | WindowsMessagingConfig | AndroidMessagingConfig | TestTransportConfig} MessagingConfig + * @typedef {WebkitMessagingConfig | WindowsMessagingConfig | AndroidMessagingConfig | AndroidAdsjsMessagingConfig | TestTransportConfig} MessagingConfig */ /** @@ -69,7 +70,6 @@ export class Messaging { /** * Send a 'fire-and-forget' message. - * @throws {MissingHandler} * * @example * @@ -87,12 +87,21 @@ export class Messaging { method: name, params: data, }); - this.transport.notify(message); + try { + this.transport.notify(message); + } catch (e) { + // Silently ignoring any transport errors in production, as per section 4.1 of https://www.jsonrpc.org/specification + // Notifications are fire+forget and should be able to be sent without any knowledge of the receiving ends support + if (this.messagingContext.env === 'development') { + console.error('[Messaging] Failed to send notification:', e); + console.error('[Messaging] Message details:', { name, data }); + } + } } /** - * Send a request, and wait for a response - * @throws {MissingHandler} + * Send a request and wait for a response + * @throws {Error} * * @example * ``` @@ -136,31 +145,31 @@ export class Messaging { */ export class MessagingTransport { /** - * @param {NotificationMessage} msg + * @param {NotificationMessage} _msg * @returns {void} */ - notify(msg) { - throw new Error("must implement 'notify'"); + notify(_msg) { + throw new Error('must implement'); } /** - * @param {RequestMessage} msg - * @param {{signal?: AbortSignal}} [options] + * @param {RequestMessage} _msg + * @param {{signal?: AbortSignal}} [_options] * @return {Promise} */ - request(msg, options = {}) { + request(_msg, _options = {}) { throw new Error('must implement'); } /** - * @param {Subscription} msg - * @param {(value: unknown) => void} callback + * @param {Subscription} _msg + * @param {(value: unknown) => void} _callback * @return {() => void} */ - subscribe(msg, callback) { + subscribe(_msg, _callback) { throw new Error('must implement'); } } @@ -207,7 +216,7 @@ export class TestTransport { } /** - * @param {WebkitMessagingConfig | WindowsMessagingConfig | AndroidMessagingConfig | TestTransportConfig} config + * @param {WebkitMessagingConfig | WindowsMessagingConfig | AndroidMessagingConfig | AndroidAdsjsMessagingConfig | TestTransportConfig} config * @param {MessagingContext} messagingContext * @returns {MessagingTransport} */ @@ -221,6 +230,9 @@ function getTransport(config, messagingContext) { if (config instanceof AndroidMessagingConfig) { return new AndroidMessagingTransport(config, messagingContext); } + if (config instanceof AndroidAdsjsMessagingConfig) { + return new AndroidAdsjsMessagingTransport(config, messagingContext); + } if (config instanceof TestTransportConfig) { return new TestTransport(config, messagingContext); } @@ -260,5 +272,7 @@ export { WindowsRequestMessage, AndroidMessagingConfig, AndroidMessagingTransport, + AndroidAdsjsMessagingConfig, + AndroidAdsjsMessagingTransport, createTypedMessages, }; diff --git a/messaging/lib/android-adsjs.js b/messaging/lib/android-adsjs.js new file mode 100644 index 0000000000..1bab6d131c --- /dev/null +++ b/messaging/lib/android-adsjs.js @@ -0,0 +1,352 @@ +/** + * + * A wrapper for messaging on Android using addWebMessageListener API. + * + * This transport uses the Android WebView addWebMessageListener API for communication + * between JavaScript and native Android code. + * + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { MessagingTransport, MessageResponse, SubscriptionEvent } from '../index.js'; +import { isResponseFor, isSubscriptionEventFor, RequestMessage } from '../schema.js'; +import { isBeingFramed } from '../../injected/src/utils.js'; + +/** + * @typedef {import('../index.js').Subscription} Subscription + * @typedef {import('../index.js').MessagingContext} MessagingContext + * @typedef {import('../index.js').NotificationMessage} NotificationMessage + */ + +/** + * An implementation of {@link MessagingTransport} for Android using addWebMessageListener + * + * All messages go through the Android WebView addWebMessageListener API + * + * @implements {MessagingTransport} + */ +export class AndroidAdsjsMessagingTransport { + /** + * @param {AndroidAdsjsMessagingConfig} config + * @param {MessagingContext} messagingContext + * @internal + */ + constructor(config, messagingContext) { + this.messagingContext = messagingContext; + this.config = config; + } + + /** + * @param {NotificationMessage} msg + */ + notify(msg) { + try { + this.config.sendMessageThrows?.(msg); + } catch (e) { + console.error('.notify failed', e); + } + } + + /** + * @param {RequestMessage} msg + * @return {Promise} + */ + request(msg) { + return new Promise((resolve, reject) => { + // subscribe early + const unsub = this.config.subscribe(msg.id, handler); + + try { + this.config.sendMessageThrows?.(msg); + } catch (e) { + unsub(); + reject(new Error('request failed to send: ' + e.message || 'unknown error')); + } + + function handler(data) { + if (isResponseFor(msg, data)) { + // success case, forward .result only + if (data.result) { + resolve(data.result || {}); + return unsub(); + } + + // error case, forward the error as a regular promise rejection + if (data.error) { + reject(new Error(data.error.message)); + return unsub(); + } + + // getting here is undefined behavior + unsub(); + throw new Error('unreachable: must have `result` or `error` key by this point'); + } + } + }); + } + + /** + * @param {Subscription} msg + * @param {(value: unknown | undefined) => void} callback + */ + subscribe(msg, callback) { + const unsub = this.config.subscribe(msg.subscriptionName, (data) => { + if (isSubscriptionEventFor(msg, data)) { + callback(data.params || {}); + } + }); + return () => { + unsub(); + }; + } +} + +/** + * Android shared messaging configuration for addWebMessageListener API. + * This class should be constructed once and then shared between features. + * + * The following example shows all the fields that are required to be passed in: + * + * ```js + * const config = new AndroidAdsjsMessagingConfig({ + * // a value that native has injected into the script + * messageSecret: 'abc', + * + * // the object name that will be used for addWebMessageListener + * objectName: "androidAdsjs", + * + * // the global object where methods will be registered + * target: globalThis + * }); + * ``` + * + * ## Native integration + * + * The native Android code should use addWebMessageListener to listen for messages: + * + * ```java + * WebViewCompat.addWebMessageListener( + * webView, + * "androidAdsjs", + * Set.of("*"), + * new WebMessageListener() { + * @Override + * public void onPostMessage(WebView view, WebMessageCompat message, Uri sourceOrigin, boolean isMainFrame, JavaScriptReplyProxy replyProxy) { + * // Handle the message here + * String data = message.getData(); + * // Process the message and send response via replyProxy.postMessage() + * } + * } + * ); + * ``` + * + * The JavaScript side uses postMessage() to send messages, which the native side receives + * through the WebMessageListener. Responses from the native side are delivered through + * addEventListener on the captured handler. + */ +export class AndroidAdsjsMessagingConfig { + /** @type {{ + * postMessage: (message: string) => void, + * addEventListener: (type: string, listener: (event: MessageEvent) => void) => void, + * } | null} */ + _capturedHandler; + + /** + * @param {object} params + * @param {Record} params.target + * @param {boolean} params.debug + * @param {string} params.objectName - the object name for addWebMessageListener + */ + constructor(params) { + this.target = params.target; + this.debug = params.debug; + this.objectName = params.objectName; + + /** + * @type {Map void>} + * @internal + */ + this.listeners = new globalThis.Map(); + + /** + * Capture the global handler and remove it from the global object. + */ + this._captureGlobalHandler(); + + /** + * Set up event listener for incoming messages. + */ + this._setupEventListener(); + } + + /** + * The transport can call this to transmit a JSON payload along with a secret + * to the native Android handler via postMessage. + * + * Note: This can throw - it's up to the transport to handle the error. + * + * @type {(json: object) => void} + * @throws + * @internal + */ + sendMessageThrows(message) { + if (!this.objectName) { + throw new Error('Object name not set for WebMessageListener'); + } + + // Use postMessage to send to the native side + // The native Android code will have set up addWebMessageListener to receive this + if (this._capturedHandler && this._capturedHandler.postMessage) { + this._capturedHandler.postMessage(JSON.stringify(message)); + } else { + throw new Error('postMessage not available'); + } + } + + /** + * A subscription on Android is just a named listener. All messages from + * android -> are delivered through a single function, and this mapping is used + * to route the messages to the correct listener. + * + * Note: Use this to implement request->response by unsubscribing after the first + * response. + * + * @param {string} id + * @param {(msg: MessageResponse | SubscriptionEvent) => void} callback + * @returns {() => void} + * @internal + */ + subscribe(id, callback) { + this.listeners.set(id, callback); + return () => { + this.listeners.delete(id); + }; + } + + /** + * Accept incoming messages and try to deliver it to a registered listener. + * + * This code is defensive to prevent any single handler from affecting another if + * it throws (producer interference). + * + * @param {MessageResponse | SubscriptionEvent} payload + * @internal + */ + _dispatch(payload) { + // do nothing if the response is empty + // this prevents the next `in` checks from throwing in test/debug scenarios + if (!payload) return this._log('no response'); + + // if the payload has an 'id' field, then it's a message response + if ('id' in payload) { + if (this.listeners.has(payload.id)) { + this._tryCatch(() => this.listeners.get(payload.id)?.(payload)); + } else { + this._log('no listeners for ', payload); + } + } + + // if the payload has an 'subscriptionName' field, then it's a push event + if ('subscriptionName' in payload) { + if (this.listeners.has(payload.subscriptionName)) { + this._tryCatch(() => this.listeners.get(payload.subscriptionName)?.(payload)); + } else { + this._log('no subscription listeners for ', payload); + } + } + } + + /** + * + * @param {(...args: any[]) => any} fn + * @param {string} [context] + */ + _tryCatch(fn, context = 'none') { + try { + return fn(); + } catch (e) { + if (this.debug) { + console.error('AndroidAdsjsMessagingConfig error:', context); + console.error(e); + } + } + } + + /** + * @param {...any} args + */ + _log(...args) { + if (this.debug) { + console.log('AndroidAdsjsMessagingConfig', ...args); + } + } + + /** + * Capture the global handler and remove it from the global object. + */ + _captureGlobalHandler() { + const { target, objectName } = this; + + if (Object.prototype.hasOwnProperty.call(target, objectName)) { + this._capturedHandler = target[objectName]; + delete target[objectName]; + } else { + this._capturedHandler = null; + this._log('Android adsjs messaging interface not available', objectName); + } + } + + /** + * Set up event listener for incoming messages from the captured handler. + */ + _setupEventListener() { + if (!this._capturedHandler || !this._capturedHandler.addEventListener) { + this._log('No event listener support available'); + return; + } + + this._capturedHandler.addEventListener('message', (event) => { + try { + const data = /** @type {MessageEvent} */ (event).data; + if (typeof data === 'string') { + const parsedData = JSON.parse(data); + + // Dispatch the message + this._dispatch(parsedData); + } + } catch (e) { + this._log('Error processing incoming message:', e); + } + }); + } + + /** + * Send an initial ping message to the platform to establish communication. + * This is a fire-and-forget notification that signals the JavaScript side is ready. + * Only sends in top context (not in frames) and if the messaging interface is available. + * + * @param {MessagingContext} messagingContext + * @returns {boolean} true if ping was sent, false if in frame or interface not ready + */ + sendInitialPing(messagingContext) { + // Only send ping in top context, not in frames + if (isBeingFramed()) { + this._log('Skipping initial ping - running in frame context'); + return false; + } + + try { + const message = new RequestMessage({ + id: 'initialPing', + context: messagingContext.context, + featureName: 'messaging', + method: 'initialPing', + }); + this.sendMessageThrows(message); + this._log('Initial ping sent successfully'); + return true; + } catch (e) { + this._log('Failed to send initial ping:', e); + return false; + } + } +} diff --git a/messaging/lib/test-utils.mjs b/messaging/lib/test-utils.mjs index 2b7c920a57..3efa56543f 100644 --- a/messaging/lib/test-utils.mjs +++ b/messaging/lib/test-utils.mjs @@ -274,6 +274,7 @@ export function mockWebkitMessaging(params) { * messagingContext: import('../index.js').MessagingContext, * responses: Record, * messageCallback: string + * javascriptInterface?: string * }} params */ export function mockAndroidMessaging(params) { @@ -284,7 +285,8 @@ export function mockAndroidMessaging(params) { outgoing: [], }, }; - window[params.messagingContext.context] = { + if (!params.javascriptInterface) throw new Error('`javascriptInterface` is required for Android mocking'); + window[params.javascriptInterface] = { /** * @param {string} jsonString * @param {string} secret @@ -322,7 +324,7 @@ export function mockAndroidMessaging(params) { id: msg.id, }; - globalThis.messageCallback?.(secret, r); + globalThis[params.messageCallback]?.(secret, r); }, }; } @@ -406,6 +408,8 @@ export function wrapWebkitScripts(js, replacements) { * @param {string} params.name * @param {Record} params.payload * @param {NonNullable} params.injectName + * @param {string} [params.messageCallback] - optional name of a global method where messages can be delivered (android) + * @param {string} [params.messageSecret] - optional message secret for platforms that require it (android) */ export function simulateSubscriptionMessage(params) { const subscriptionEvent = { @@ -421,6 +425,13 @@ export function simulateSubscriptionMessage(params) { fn(subscriptionEvent); break; } + case 'android': { + if (!params.messageCallback || !params.messageSecret) + throw new Error('`messageCallback` + `messageSecret` needed to simulate subscription event on Android'); + + window[params.messageCallback]?.(params.messageSecret, subscriptionEvent); + break; + } case 'apple': case 'apple-isolated': { if (!(params.name in window)) throw new Error('subscription fn not found for: ' + params.injectName); diff --git a/messaging/lib/typed-messages.js b/messaging/lib/typed-messages.js index ed12a277e0..f57fca609e 100644 --- a/messaging/lib/typed-messages.js +++ b/messaging/lib/typed-messages.js @@ -7,11 +7,11 @@ * into only calls supported by your schema * * @template {Partial} BaseClass - * @param {BaseClass} base - the class onto which you've added the properties from `MessagingBase` - * @param {import("@duckduckgo/messaging").Messaging} messaging + * @param {BaseClass} _base - the class onto which you've added the properties from `MessagingBase` + * @param {import("@duckduckgo/messaging").Messaging} _messaging * @returns {BaseClass} */ -export function createTypedMessages(base, messaging) { - const asAny = /** @type {any} */ (messaging); +export function createTypedMessages(_base, _messaging) { + const asAny = /** @type {any} */ (_messaging); return /** @type {BaseClass} */ (asAny); } diff --git a/messaging/native.js b/messaging/native.js index 9cc57311fb..b50162198e 100644 --- a/messaging/native.js +++ b/messaging/native.js @@ -1,220 +1,5 @@ /** - * Messages will be **sent** to native platforms in 2 formats - notifications and requests. - * Notifications do not require a response, but requests do. The following spec explains the difference and - * how to handle each. - * - * The purpose of this library is to enable 3 idiomatic JavaScript methods for communicating with native platforms: - * - * ```javascript - * // notifications - * messaging.notify("helloWorld", { some: "data" }) - * - * // requests - * await messaging.request("helloWorld", { some: "data" }) - * - * // subscriptions - * const unsubscribe = messaging.subscribe("helloWorld", (data) => { - * console.log(data) - * }); - * ``` - * - * The following describes how you [native engineers] can implement support for this. - * - * ## Step 1) Receiving a notification or request message: - * - * Each platform will 'receive' messages according to their own best practices, the following spec describes everything **after** - * the message has been delivered from the clientside JavaScript (deliberately avoiding the platform specifics of *how* messages arrive) - * - * For example, in Android this would be what happens within a `@Javascript` Interface, but on macOS it would be within - * the WebKit messaging protocol, etc. - * - * ### Algorithm - * - * 1. let `s` be an incoming raw `JSON` payload - * 2. let `msg` be the result of parsing `s` into key/value pairs - * - 2.1 Note: 'parsing' here may not be required if the platform in question receives JSON data directly (ie: JavaScript environments) - * 3. if parsing was not successful, throw an "invalid message" Exception - * 4. validate that `msg.context` exists and is a `string` value - * 5. validate that `msg.featureName` exists and is a `string` value - * 6. validate that `msg.method` exists and is a `string` value - * - 6.1 if parsing fails for `context`, `featureName` or `method`, throw an "invalid format" Exception - * 7. let `params` be a reference to `msg.params` or a new, empty key/value structure - * 8. if `params` is not a valid key/value structure, throw an "invalid params" Exception - * 9. if the `msg.id` field is absent, then: - * - 9.1. mark `msg` as being of type {@link Messaging.NotificationMessage} - * 10. if the `msg.id` field is present, then: - * - 10.1. validate that `msg.id` a string value, throw an "invalid params" Exception if it isn't - * - 10.2. mark `msg` as being of type {@link Messaging.RequestMessage} - * 11. At this point, you should have a structure that represents either a {@link Messaging.NotificationMessage} or - * {@link Messaging.RequestMessage}. Then move to Step 2) - * - * - * ## Step 2) Choosing and executing a handler - * - * Once you've completed Step 1), you'll know whether you are dealing with a notification or a request (something you need - * to respond to). At this point you don't know which feature will attempt the message, you just know the format was correct. - * - * ### Algorithm - * - * 1. let `feature` be the result of looking up a feature that matches name `msg.featureName` - * 2. if `feature` is not found, throw a "feature not found" Exception - * 3. let `handler` be the result of calling `feature.handlerFor(msg.method)` - * 4. if `handler` is not found, throw a "handler not found" exception - * 5. execute `handler` with `msg.params` - * - 5.1. if `msg` was marked as a {@link Messaging.NotificationMessage} (via step 1), then: - * 1. do not wait for a response - * 2. if the platform must respond (to prevent errors), then: - * 1. respond with an empty key/value JSON structure `{}` - * - 5.2. if `msg` was marked as a {@link Messaging.RequestMessage}, then: - * 1. let `response` be a new instance of {@link Messaging.MessageResponse} - * 1. assign `msg.context` to `response.context` - * 2. assign `msg.featureName` to `response.featureName` - * 3. assign `msg.id` to `response.id` - * 2. let `result` be the return value of _executing_ `handler(msg.params)` - * 3. if `result` is empty, assign `result` to an empty key/value structure - * 4. if an **error** occurred during execution then: - * 1. let `error` be a new instance of {@link Messaging.MessageError} - * 2. assign a descriptive message if possible, to `error.message` - * 3. assign `error` to `response.error` - * 5. if an error **did not occur**, assign `result` to `response.result` - * 6. let `json` be the string result of converting `response` into JSON - * 7. deliver the JSON response in the platform specified way - * - * ## Step 3) Push-based messaging - * - * 1. let `event` be a new instance of {@link Messaging.SubscriptionEvent} - * 2. assign `event.context` to the target context - * 3. assign `event.featureName` to the target feature - * 4. assign `event.subscriptionName` to the target subscriptionName - * 5. if the message contains data, then - * 1. let `params` be a key/value structure - * 1. Note: only key/value structures are permitted. - * 2. assign `params` to `event.params` - * 6. let `json` be the string result of converting `event` into JSON - * 7. deliver the JSON response in the platform specified way - * - * --- - * - * ## Notifications - * - * **{@link Messaging.NotificationMessage}** - * - * ```json - * { - * "context": "contentScopeScripts", - * "featureName": "duckPlayer", - * "method": "saveUserValues" - * } - * ``` - * - * **{@link Messaging.NotificationMessage} with params** - * - * ```json - * { - * "context": "contentScopeScripts", - * "featureName": "duckPlayer", - * "method": "saveUserValues", - * "params": { "hello": "world" } - * } - * ``` - * - * **{@link Messaging.NotificationMessage} with `invalid` params** - * - * ```json - * { - * "context": "contentScopeScripts", - * "featureName": "duckPlayer", - * "method": "getUserValues", - * "params": "oops! <- cannot be a string/number/boolean/null" - * } - * ``` - * - * ## Requests - * - * **{@link Messaging.RequestMessage}** - * - * ```json - * { - * "context": "contentScopeScripts", - * "featureName": "duckPlayer", - * "method": "getUserValues", - * "id": "abc123" - * } - * ``` - * - * - * **{@link Messaging.RequestMessage} with params** - * - * ```json - * { - * "context": "contentScopeScripts", - * "featureName": "duckPlayer", - * "method": "getUserValues", - * "params": { "hello": "world" }, - * "id": "abc123" - * } - * ``` - * - * - * **{@link Messaging.RequestMessage} with invalid params** - * - * ```json - * { - * "context": "contentScopeScripts", - * "featureName": "duckPlayer", - * "method": "getUserValues", - * "params": "oops! <- cannot be a string/number/boolean/null", - * "id": "abc123" - * } - * ``` - * - * **{@link Messaging.RequestMessage} -> {@link Messaging.MessageResponse} with data** - * - * ```json - * { - * "context": "contentScopeScripts", - * "featureName": "duckPlayer", - * "id": "abc123", - * "result": { "hello": "world" } - * } - * ``` - * - * **{@link Messaging.RequestMessage} -> {@link Messaging.MessageResponse} with error** - * - * ```json - * { - * "context": "contentScopeScripts", - * "featureName": "duckPlayer", - * "id": "abc123", - * "error": { - * "message": "oops!" - * } - * } - * ``` - * - * - * ## Subscriptions - * - * **{@link Messaging.SubscriptionEvent} without data** - * - * ```json - * { - * "context": "contentScopeScripts", - * "featureName": "duckPlayer", - * "subscriptionName": "onUserValuesUpdated" - * } - * ``` - * - * **{@link Messaging.SubscriptionEvent} with data** - * - * ```json - * { - * "context": "contentScopeScripts", - * "featureName": "duckPlayer", - * "subscriptionName": "onUserValuesUpdated", - * "params": { "hello": "world" } - * } - * ``` + * Moved: [messaging/docs/implementation-guide.md](./docs/implementation-guide.md) * * @module Messaging Implementation Guide */ diff --git a/package-lock.json b/package-lock.json index c200a80040..739ce110fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,47 +13,49 @@ "types-generator" ], "dependencies": { - "immutable-json-patch": "^6.0.1" + "immutable-json-patch": "^6.0.2", + "urlpattern-polyfill": "^10.1.0" }, "devDependencies": { "@duckduckgo/eslint-config": "github:duckduckgo/eslint-config#v0.1.0", - "@playwright/test": "^1.49.1", - "@types/eslint__js": "^8.42.3", - "eslint": "^9.17.0", + "@playwright/test": "^1.52.0", + "ajv": "^8.17.1", + "esbuild": "^0.25.12", + "eslint": "^9.37.0", "minimist": "^1.2.8", - "prettier": "3.4.2", + "prettier": "3.6.2", "stylelint": "^15.11.0", "stylelint-config-standard": "^34.0.0", "stylelint-csstree-validator": "^3.0.0", - "typedoc": "^0.27.6", - "typescript": "^5.7.2", - "typescript-eslint": "^8.19.1" + "ts-json-schema-generator": "^2.4.0", + "typedoc": "^0.28.8", + "typescript": "^5.8.3", + "typescript-eslint": "^8.36.0", + "wait-on": "^9.0.1" } }, "injected": { "hasInstallScript": true, "dependencies": { + "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#1752154773643", + "esbuild": "^0.25.12", + "minimist": "^1.2.8", "parse-address": "^1.1.2", "seedrandom": "^3.0.5", - "sjcl": "^1.0.8" + "sjcl": "^1.0.8", + "urlpattern-polyfill": "^10.1.0" }, "devDependencies": { "@canvas/image-data": "^1.0.0", - "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration#main", - "@fingerprintjs/fingerprintjs": "^4.5.1", - "@rollup/plugin-commonjs": "^28.0.2", - "@rollup/plugin-node-resolve": "^16.0.0", - "@rollup/plugin-replace": "^6.0.2", - "@types/chrome": "^0.0.289", - "@types/jasmine": "^5.1.5", - "@types/node": "^22.10.5", - "@typescript-eslint/eslint-plugin": "^8.19.0", - "fast-check": "^3.23.2", - "jasmine": "^5.5.0", - "minimist": "^1.2.8", - "rollup": "^4.30.1", - "rollup-plugin-import-css": "^3.5.8", - "rollup-plugin-svg-import": "^3.0.0" + "@fingerprintjs/fingerprintjs": "^5.0.1", + "@types/chrome": "^0.1.1", + "@types/jasmine": "^5.1.9", + "@types/node": "^24.1.0", + "@typescript-eslint/eslint-plugin": "^8.46.0", + "fast-check": "^4.2.0", + "jasmine": "^5.12.0", + "jsdom": "^27.1.0", + "web-ext": "^9.0.0" } }, "messaging": { @@ -61,11 +63,17 @@ "version": "1.0.0", "license": "ISC" }, + "node_modules/@acemir/cssom": { + "version": "0.9.19", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.19.tgz", + "integrity": "sha512-Pp2gAQXPZ2o7lt4j0IMwNRXqQ3pagxtDj5wctL5U2Lz4oV0ocDNlkgx4DpxfyKav4S/bePuI+SMqcBSUHLy9kg==", + "dev": true, + "license": "MIT" + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.7.2", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz", "integrity": "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA==", - "dev": true, "license": "MIT", "dependencies": { "@jsdevtools/ono": "^7.1.3", @@ -79,10 +87,181 @@ "url": "https://github.com/sponsors/philsturgeon" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", + "integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.1" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz", + "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.2" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@atlaskit/pragmatic-drag-and-drop": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.4.0.tgz", - "integrity": "sha512-qRY3PTJIcxfl/QB8Gwswz+BRvlmgAC5pB+J2hL6dkIxgqAgVwOhAamMUKsrOcFU/axG2Q7RbNs1xfoLKDuhoPg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.6.tgz", + "integrity": "sha512-gJklsBQp5eSKsdaynmJXNuWINmuoQ5+D/m3+4JIFQ/ZwQitOJDuDbyLyDQMbS5BUA8pEiGPLtT4banjCNOBrfw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.0.0", @@ -91,34 +270,34 @@ } }, "node_modules/@atlaskit/pragmatic-drag-and-drop-hitbox": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.0.3.tgz", - "integrity": "sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.1.0.tgz", + "integrity": "sha512-JWt6eVp6Br2FPHRM8s0dUIHQk/jFInGP1f3ti5CdtM1Ji5/pt8Akm44wDC063Gv2i5RGseixtbW0z/t6RYtbdg==", "license": "Apache-2.0", "dependencies": { - "@atlaskit/pragmatic-drag-and-drop": "^1.1.0", + "@atlaskit/pragmatic-drag-and-drop": "^1.6.0", "@babel/runtime": "^7.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, "license": "MIT", "engines": { @@ -126,13 +305,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -144,6 +320,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/css-parser-algorithms": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", @@ -167,6 +363,26 @@ "@csstools/css-tokenizer": "^2.4.1" } }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.15.tgz", + "integrity": "sha512-q0p6zkVq2lJnmzZVPR33doA51G7YOja+FBvRdp5ISIthL0MtFCgYHHhR563z9WFGxcOn0WfjSkPDJ5Qig3H3Sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/css-tokenizer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz", @@ -234,6 +450,58 @@ "postcss-selector-parser": "^6.0.13" } }, + "node_modules/@devicefarmer/adbkit": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@devicefarmer/adbkit/-/adbkit-3.3.8.tgz", + "integrity": "sha512-7rBLLzWQnBwutH2WZ0EWUkQdihqrnLYCUMaB44hSol9e0/cdIhuNFcqZO0xNheAU6qqHVA8sMiLofkYTgb+lmw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@devicefarmer/adbkit-logcat": "^2.1.2", + "@devicefarmer/adbkit-monkey": "~1.2.1", + "bluebird": "~3.7", + "commander": "^9.1.0", + "debug": "~4.3.1", + "node-forge": "^1.3.1", + "split": "~1.0.1" + }, + "bin": { + "adbkit": "bin/adbkit" + }, + "engines": { + "node": ">= 0.10.4" + } + }, + "node_modules/@devicefarmer/adbkit-logcat": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@devicefarmer/adbkit-logcat/-/adbkit-logcat-2.1.3.tgz", + "integrity": "sha512-yeaGFjNBc/6+svbDeul1tNHtNChw6h8pSHAt5D+JsedUrMTN7tla7B15WLDyekxsuS2XlZHRxpuC6m92wiwCNw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@devicefarmer/adbkit-monkey": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@devicefarmer/adbkit-monkey/-/adbkit-monkey-1.2.1.tgz", + "integrity": "sha512-ZzZY/b66W2Jd6NHbAhLyDWOEIBWC11VizGFk7Wx7M61JZRz7HR9Cq5P+65RKWUU7u6wgsE8Lmh9nE4Mz+U2eTg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.10.4" + } + }, + "node_modules/@devicefarmer/adbkit/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/@duckduckgo/eslint-config": { "version": "1.0.0", "resolved": "git+ssh://git@github.com/duckduckgo/eslint-config.git#09f3780bb1826fe123ef3e4eb36dcbd53ca1fd80", @@ -256,23 +524,23 @@ }, "node_modules/@duckduckgo/privacy-configuration": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#0479dfed196beeed125ffe57ef034ae5b76fc208", - "integrity": "sha512-mbva0iUpEaprFl0udO++h5cf5YBwOfsyqZdjdyPO0gO5LSxL4N3a0AofrWX6y6qm5fzHnvCbT0MxzNA0iQjnTQ==", - "dev": true, + "resolved": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#f8e6f16413398cda2b0509f3a635531b0f50f209", + "integrity": "sha512-IjwlCrrMZIrFKjqE9uNg9F1CdhWzdIYmlHoD5RAjQQwWxmk2kNNc+I/56KcddktsE/MWwKaTEf5rfs1s2V7URA==", "license": "Apache 2.0", "dependencies": { + "eslint-plugin-json": "^4.0.1", "node-fetch": "^3.3.2", "tldts": "^6.1.71" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -282,13 +550,13 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -298,13 +566,13 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -314,13 +582,13 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -330,13 +598,13 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -346,13 +614,13 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -362,13 +630,13 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -378,13 +646,13 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -394,13 +662,13 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -410,13 +678,13 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -426,13 +694,13 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -442,13 +710,13 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -458,13 +726,13 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -474,13 +742,13 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -490,13 +758,13 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -506,13 +774,13 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -522,13 +790,13 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -538,13 +806,13 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -554,13 +822,13 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -570,13 +838,13 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -586,13 +854,13 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -601,14 +869,30 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -618,13 +902,13 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -634,13 +918,13 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -650,13 +934,13 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -666,9 +950,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -708,12 +992,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", - "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.5", + "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -721,25 +1006,40 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/core": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz", - "integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", + "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@types/json-schema": "^7.0.15" + "@eslint/core": "^0.16.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", - "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "node_modules/@eslint/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "ajv": "^6.12.4", + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", @@ -756,30 +1056,61 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { - "version": "9.17.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", - "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", + "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", - "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz", - "integrity": "sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", "dev": true, + "license": "Apache-2.0", "dependencies": { + "@eslint/core": "^0.16.0", "levn": "^0.4.1" }, "engines": { @@ -787,30 +1118,105 @@ } }, "node_modules/@fingerprintjs/fingerprintjs": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-4.5.1.tgz", - "integrity": "sha512-hKJaRoLHNeUUPhb+Md3pTlY/Js2YR4aXjroaDHpxrjoM8kGnEFyZVZxXo6l3gRyKnQN52Uoqsycd3M73eCdMzw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.0.1.tgz", + "integrity": "sha512-KbaeE/rk2WL8MfpRP6jTI4lSr42SJPjvkyrjP3QU6uUDkOMWWYC2Ts1sNSYcegHC8avzOoYTHBj+2fTqvZWQBA==", "dev": true, - "license": "BUSL-1.1", - "dependencies": { - "tslib": "^2.4.1" + "license": "MIT" + }, + "node_modules/@fluent/syntax": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@fluent/syntax/-/syntax-0.19.0.tgz", + "integrity": "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" } }, "node_modules/@formkit/auto-animate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.2.tgz", - "integrity": "sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.9.0.tgz", + "integrity": "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==", "license": "MIT" }, + "node_modules/@fregante/relaxed-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@fregante/relaxed-json/-/relaxed-json-2.0.0.tgz", + "integrity": "sha512-PyUXQWB42s4jBli435TDiYuVsadwRHnMc27YaLouINktvTWsL3FcKrRMGawTayFk46X+n5bE23RjUTWQwrukWw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/@gerrit0/mini-shiki": { - "version": "1.24.4", - "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-1.24.4.tgz", - "integrity": "sha512-YEHW1QeAg6UmxEmswiQbOVEg1CW22b1XUD/lNTliOsu0LD0wqoyleFMnmbTp697QE0pcadQiR5cVtbbAPncvpw==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.8.1.tgz", + "integrity": "sha512-HVZW+8pxoOExr5ZMPK15U79jQAZTO/S6i5byQyyZGjtNj+qaYd82cizTncwFzTQgiLo8uUBym6vh+/1tfJklTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.8.1", + "@shikijs/langs": "^3.8.1", + "@shikijs/themes": "^3.8.1", + "@shikijs/types": "^3.8.1", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.3.tgz", + "integrity": "sha512-QIvUMB5VZ8HMLZF9A2oWr3AFM430QC8oGd0L35y2jHpuW6bIIca6x/xL7zUf4J7L9WJ3qjz+iJII8ncaeMbpSg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@shikijs/engine-oniguruma": "^1.24.2", - "@shikijs/types": "^1.24.2", - "@shikijs/vscode-textmate": "^9.3.1" + "@hapi/hoek": "^11.0.2" } }, "node_modules/@humanfs/core": { @@ -851,6 +1257,22 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -865,10 +1287,18 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -879,6 +1309,29 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -951,20 +1404,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "dev": true, "license": "MIT" }, + "node_modules/@mdn/browser-compat-data": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-7.1.7.tgz", + "integrity": "sha512-bpWZ7hidvjrwNWcMngZ8nTMTxn8WhnQntsGqEYgPr1vjy66kfwfDVizwXg6PvsgoANZ7nhuRBmvzjpCMk4ITDw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1015,12 +1467,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", - "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright": "1.49.1" + "playwright": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -1029,481 +1482,157 @@ "node": ">=18" } }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz", + "integrity": "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@preact/signals": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.3.1.tgz", - "integrity": "sha512-nNvSF2O7RDzxp1Rm7SkA5QhN1a2kN8pGE8J5o6UjgDof0F0Vlg6d6HUUVxxqZ1uJrN9xnH2DpL6rpII3Es0SsQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.2.1.tgz", + "integrity": "sha512-cX3mijdjHbbz3dBoJ6z687CGYEOp9ifj3uFnm4UKW+DxXKPMvE2y/VSdm0PXhXmHnr6F0iSnDJ+dLwmV7CYT5A==", + "license": "MIT", "dependencies": { - "@preact/signals-core": "^1.7.0" + "@preact/signals-core": "^1.11.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" }, "peerDependencies": { - "preact": "10.x" + "preact": ">= 10.25.0" } }, "node_modules/@preact/signals-core": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.8.0.tgz", - "integrity": "sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.11.0.tgz", + "integrity": "sha512-jglbibeWHuFRzEWVFY/TT7wB1PppJxmcSfUHcK+2J9vBRtiooMfw6tAPttojNYrrpdGViqAYCbPpmWYlMm+eMQ==", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" } }, "node_modules/@rive-app/canvas-single": { - "version": "2.25.3", - "resolved": "https://registry.npmjs.org/@rive-app/canvas-single/-/canvas-single-2.25.3.tgz", - "integrity": "sha512-0G0Bqh0az+kAvga6FbCU9+6LJVDCfGTD7EZCPdisc73ChgtNtog795w9P4pCN+wSvZ+uV0olj4q6DZd2pRaYTg==" - }, - "node_modules/@rollup/plugin-commonjs": { - "version": "28.0.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.2.tgz", - "integrity": "sha512-BEFI2EDqzl+vA1rl97IDRZ61AIwGH093d9nz8+dThxJNH8oSoB7MjWvPCX3dkaK1/RCJ/1v/R1XB15FuSs0fQw==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "fdir": "^6.2.0", - "is-reference": "1.2.1", - "magic-string": "^0.30.3", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=16.0.0 || 14 >= 14.17" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } + "version": "2.31.5", + "resolved": "https://registry.npmjs.org/@rive-app/canvas-single/-/canvas-single-2.31.5.tgz", + "integrity": "sha512-bzr2CVfr9Wta6xj1GkGQuCm63cFowVJF6UOam0/UsOhoqhm9R7S5M9DgfmoAlKMCjiFwCBo3KYmT13QsYLhwpQ==", + "license": "MIT" }, - "node_modules/@rollup/plugin-commonjs/node_modules/fdir": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz", - "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==", + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } + "license": "MIT" }, - "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.8.1.tgz", + "integrity": "sha512-KGQJZHlNY7c656qPFEQpIoqOuC4LrxjyNndRdzk5WKB/Ie87+NJCF1xo9KkOUxwxylk7rT6nhlZyTGTC4fCe1g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.0.tgz", - "integrity": "sha512-0FPvAeVUT/zdWoO0jnb/V5BlBsUSNfkIOtFHzMO4H9MOklrmQFY6FduVHKucNb/aTFxvnGhj4MNj/T1oNdDfNg==", - "dev": true, "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "@shikijs/types": "3.8.1", + "@shikijs/vscode-textmate": "^10.0.2" } }, - "node_modules/@rollup/plugin-replace": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.2.tgz", - "integrity": "sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ==", + "node_modules/@shikijs/langs": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.8.1.tgz", + "integrity": "sha512-TjOFg2Wp1w07oKnXjs0AUMb4kJvujML+fJ1C5cmEj45lhjbUXtziT1x2bPQb9Db6kmPhkG5NI2tgYW1/DzhUuQ==", "dev": true, + "license": "MIT", "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "magic-string": "^0.30.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "@shikijs/types": "3.8.1" } }, - "node_modules/@rollup/pluginutils": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.3.tgz", - "integrity": "sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==", + "node_modules/@shikijs/themes": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.8.1.tgz", + "integrity": "sha512-Vu3t3BBLifc0GB0UPg2Pox1naTemrrvyZv2lkiSw3QayVV60me1ujFQwPZGgUTmwXl1yhCPW8Lieesm0CYruLQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "@shikijs/types": "3.8.1" } }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "node_modules/@shikijs/types": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.8.1.tgz", + "integrity": "sha512-5C39Q8/8r1I26suLh+5TPk1DTrbY/kn3IdWA5HdizR0FhlhD05zx5nKCqhzSfDHH3p4S0ZefxWd77DLV+8FhGg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.30.1.tgz", - "integrity": "sha512-pSWY+EVt3rJ9fQ3IqlrEUtXh3cGqGtPDH1FQlNZehO2yYxCHEX1SPsz1M//NXwYfbTlcKr9WObLnJX9FsS9K1Q==", - "cpu": [ - "arm" - ], + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "dev": true, - "optional": true, - "os": [ - "android" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.30.1.tgz", - "integrity": "sha512-/NA2qXxE3D/BRjOJM8wQblmArQq1YoBVJjrjoTSBS09jgUisq7bqxNHJ8kjCHeV21W/9WDGwJEWSN0KQ2mtD/w==", - "cpu": [ - "arm64" - ], + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "dev": true, - "optional": true, - "os": [ - "android" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.30.1.tgz", - "integrity": "sha512-r7FQIXD7gB0WJ5mokTUgUWPl0eYIH0wnxqeSAhuIwvnnpjdVB8cRRClyKLQr7lgzjctkbp5KmswWszlwYln03Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.30.1.tgz", - "integrity": "sha512-x78BavIwSH6sqfP2xeI1hd1GpHL8J4W2BXcVM/5KYKoAD3nNsfitQhvWSw+TFtQTLZ9OmlF+FEInEHyubut2OA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.30.1.tgz", - "integrity": "sha512-HYTlUAjbO1z8ywxsDFWADfTRfTIIy/oUlfIDmlHYmjUP2QRDTzBuWXc9O4CXM+bo9qfiCclmHk1x4ogBjOUpUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.30.1.tgz", - "integrity": "sha512-1MEdGqogQLccphhX5myCJqeGNYTNcmTyaic9S7CG3JhwuIByJ7J05vGbZxsizQthP1xpVx7kd3o31eOogfEirw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.30.1.tgz", - "integrity": "sha512-PaMRNBSqCx7K3Wc9QZkFx5+CX27WFpAMxJNiYGAXfmMIKC7jstlr32UhTgK6T07OtqR+wYlWm9IxzennjnvdJg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.30.1.tgz", - "integrity": "sha512-B8Rcyj9AV7ZlEFqvB5BubG5iO6ANDsRKlhIxySXcF1axXYUyqwBok+XZPgIYGBgs7LDXfWfifxhw0Ik57T0Yug==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.30.1.tgz", - "integrity": "sha512-hqVyueGxAj3cBKrAI4aFHLV+h0Lv5VgWZs9CUGqr1z0fZtlADVV1YPOij6AhcK5An33EXaxnDLmJdQikcn5NEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.30.1.tgz", - "integrity": "sha512-i4Ab2vnvS1AE1PyOIGp2kXni69gU2DAUVt6FSXeIqUCPIR3ZlheMW3oP2JkukDfu3PsexYRbOiJrY+yVNSk9oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.30.1.tgz", - "integrity": "sha512-fARcF5g296snX0oLGkVxPmysetwUk2zmHcca+e9ObOovBR++9ZPOhqFUM61UUZ2EYpXVPN1redgqVoBB34nTpQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.30.1.tgz", - "integrity": "sha512-GLrZraoO3wVT4uFXh67ElpwQY0DIygxdv0BNW9Hkm3X34wu+BkqrDrkcsIapAY+N2ATEbvak0XQ9gxZtCIA5Rw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.30.1.tgz", - "integrity": "sha512-0WKLaAUUHKBtll0wvOmh6yh3S0wSU9+yas923JIChfxOaaBarmb/lBKPF0w/+jTVozFnOXJeRGZ8NvOxvk/jcw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.30.1.tgz", - "integrity": "sha512-GWFs97Ruxo5Bt+cvVTQkOJ6TIx0xJDD/bMAOXWJg8TCSTEK8RnFeOeiFTxKniTc4vMIaWvCplMAFBt9miGxgkA==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.1.tgz", - "integrity": "sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.30.1.tgz", - "integrity": "sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.30.1.tgz", - "integrity": "sha512-WabtHWiPaFF47W3PkHnjbmWawnX/aE57K47ZDT1BXTS5GgrBUEpvOzq0FI0V/UYzQJgdb8XlhVNH8/fwV8xDjw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.30.1.tgz", - "integrity": "sha512-pxHAU+Zv39hLUTdQQHUVHf4P+0C47y/ZloorHpzs2SXMRqeAWmGghzAhfOlzFHHwjvgokdFAhC4V+6kC1lRRfw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.30.1.tgz", - "integrity": "sha512-D6qjsXGcvhTjv0kI4fU8tUuBDF/Ueee4SVX79VfNDXZa64TfCW1Slkb6Z7O1p7vflqZjcmOVdZlqf8gvJxc6og==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "1.24.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.24.2.tgz", - "integrity": "sha512-ZN6k//aDNWRJs1uKB12pturKHh7GejKugowOFGAuG7TxDRLod1Bd5JhpOikOiFqPmKjKEPtEA6mRCf7q3ulDyQ==", - "dev": true, - "dependencies": { - "@shikijs/types": "1.24.2", - "@shikijs/vscode-textmate": "^9.3.0" - } - }, - "node_modules/@shikijs/types": { - "version": "1.24.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.24.2.tgz", - "integrity": "sha512-bdeWZiDtajGLG9BudI0AHet0b6e7FbR0EsE4jpGaI0YwHm/XJunI9+3uZnzFtX65gsyJ6ngCIWUfA4NWRPnBkQ==", - "dev": true, - "dependencies": { - "@shikijs/vscode-textmate": "^9.3.0", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-9.3.1.tgz", - "integrity": "sha512-79QfK1393x9Ho60QFyLti+QfdJzRQCVLFb97kOIV7Eo9vQU/roINgk7m24uv0a7AUvN//RDH36FLjjK48v0s9g==", - "dev": true - }, - "node_modules/@types/chrome": { - "version": "0.0.289", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.289.tgz", - "integrity": "sha512-JQifH2d4TFeIZ+ySBSQxCd4D+J6W0jIAyQipAzG1up4O7WApTYrBqnmpNbMqFR5f70wJ1FE6+axqtpfTtPzy/g==", + "node_modules/@types/chrome": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.1.tgz", + "integrity": "sha512-MLtFW++/n+OPQIaf5hA6pmURd3Zn+OxuvASyf2mYh8B8pHDpbhHjwlVHMw3H/aJC9Z7Z3itO0AFaZeegrGk0yA==", "dev": true, + "license": "MIT", "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint__js": { - "version": "8.42.3", - "resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-8.42.3.tgz", - "integrity": "sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1540,21 +1669,22 @@ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/unist": "*" } }, "node_modules/@types/jasmine": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.5.tgz", - "integrity": "sha512-SaCZ3kM5NjOiJqMRYwHpLbTfUC2Dyk1KS3QanNFsUYPGTk70CWVK/J9ueun6zNhw/UkgV7xl8V4ZLQZNRbfnNw==", - "dev": true + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.9.tgz", + "integrity": "sha512-8t4HtkW4wxiPVedMpeZ63n3vlWxEIquo/zc1Tm8ElU+SqVV7+D3Na2PWaJUp179AzTragMWVwkMv7mvty0NfyQ==", + "dev": true, + "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -1568,6 +1698,12 @@ "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "license": "MIT" + }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", "dev": true, "license": "MIT" }, @@ -1579,12 +1715,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", - "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.8.0" } }, "node_modules/@types/normalize-package-data": { @@ -1594,34 +1731,39 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz", - "integrity": "sha512-tJzcVyvvb9h/PB96g30MpxACd9IrunT7GF9wfA9/0TJ1LxGOJx1TdPzSbBBnNED7K9Ka8ybJsnEpiXPktolTLg==", + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz", + "integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/type-utils": "8.19.1", - "@typescript-eslint/utils": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", + "@typescript-eslint/scope-manager": "8.46.0", + "@typescript-eslint/type-utils": "8.46.0", + "@typescript-eslint/utils": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0", "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1631,21 +1773,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "@typescript-eslint/parser": "^8.46.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.1.tgz", - "integrity": "sha512-67gbfv8rAwawjYx3fYArwldTQKoYfezNUT4D5ioWetr/xCrxXxvleo3uuiFuKfejipvq+og7mjz3b0G2bVyUCw==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz", + "integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/typescript-estree": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", + "@typescript-eslint/tsconfig-utils": "^8.46.0", + "@typescript-eslint/types": "^8.46.0", "debug": "^4.3.4" }, "engines": { @@ -1656,18 +1797,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.1.tgz", - "integrity": "sha512-60L9KIuN/xgmsINzonOcMDSB8p82h95hoBfSBtXuO4jlR1R9L1xSkmVZKgCPVfavDlXihh4ARNjXhh1gGnLC7Q==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz", + "integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1" + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1677,17 +1818,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.1.tgz", - "integrity": "sha512-Rp7k9lhDKBMRJB/nM9Ksp1zs4796wVNyihG9/TU9R6KCJDNkQbc2EOKjrBtLYh3396ZdpXLtr/MkaSEmNMtykw==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz", + "integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==", "dev": true, - "dependencies": { - "@typescript-eslint/typescript-estree": "8.19.1", - "@typescript-eslint/utils": "8.19.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" - }, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1696,15 +1832,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.1.tgz", - "integrity": "sha512-JBVHMLj7B1K1v1051ZaMMgLW4Q/jre5qGK0Ew6UgXz1Rqh+/xPzV1aW581OM00X6iOfyr1be+QyW8LOUf19BbA==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz", + "integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1713,20 +1849,23 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.1.tgz", - "integrity": "sha512-jk/TZwSMJlxlNnqhy0Eod1PNEvCkpY6MXOXE/WLlblZ6ibb32i2We4uByoKPv1d0OD2xebDv4hbs3fm11SMw8Q==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz", + "integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", + "@typescript-eslint/project-service": "8.46.0", + "@typescript-eslint/tsconfig-utils": "8.46.0", + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1736,23 +1875,77 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz", + "integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.0", + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/typescript-estree": "8.46.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz", + "integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", + "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -1763,11 +1956,12 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -1775,16 +1969,18 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.1.tgz", - "integrity": "sha512-IxG5gLO0Ne+KaUc8iW1A+XuKLd63o4wlbI1Zp692n1xojCl/THvgIKXJXBZixTh5dd5+yTJ/VXH7GJaaw21qXA==", + "node_modules/@typescript-eslint/parser": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.36.0.tgz", + "integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/typescript-estree": "8.19.1" + "@typescript-eslint/scope-manager": "8.36.0", + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/typescript-estree": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0", + "debug": "^4.3.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1795,17 +1991,19 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.1.tgz", - "integrity": "sha512-fzmjU8CHK853V/avYZAvuVut3ZTfwN5YtMaoi+X9Y9MA9keaWNHC3zEQ9zvyX/7Hj+5JkNyK1l7TOR2hevHB6Q==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.36.0.tgz", + "integrity": "sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.1", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/tsconfig-utils": "^8.36.0", + "@typescript-eslint/types": "^8.36.0", + "debug": "^4.3.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1813,97 +2011,1398 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - } + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.36.0.tgz", + "integrity": "sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.36.0.tgz", + "integrity": "sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz", + "integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/typescript-estree": "8.46.0", + "@typescript-eslint/utils": "8.46.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz", + "integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.0", + "@typescript-eslint/types": "^8.46.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz", + "integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz", + "integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz", + "integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz", + "integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.0", + "@typescript-eslint/tsconfig-utils": "8.46.0", + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz", + "integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.0", + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/typescript-estree": "8.46.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz", + "integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, + "license": "ISC", "bin": { - "acorn": "bin/acorn" + "semver": "bin/semver.js" }, "engines": { - "node": ">=0.4.0" + "node": ">=10" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@typescript-eslint/types": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.36.0.tgz", + "integrity": "sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.36.0.tgz", + "integrity": "sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg==", "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.36.0", + "@typescript-eslint/tsconfig-utils": "8.36.0", + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.36.0.tgz", + "integrity": "sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.36.0", + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/typescript-estree": "8.36.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.36.0.tgz", + "integrity": "sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.36.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/addons-linter": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/addons-linter/-/addons-linter-8.0.0.tgz", + "integrity": "sha512-atU+Y6zKv22LUSYU6JQ7f7no+l3etUp/XFpQglClL5gO7CPaLq03/1wcvBs5qQl0icGLY6ZnM60flnrsp0otNA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@fluent/syntax": "0.19.0", + "@fregante/relaxed-json": "2.0.0", + "@mdn/browser-compat-data": "7.1.7", + "addons-moz-compare": "1.3.0", + "addons-scanner-utils": "9.13.0", + "ajv": "8.17.1", + "chalk": "4.1.2", + "cheerio": "1.1.2", + "columnify": "1.6.0", + "common-tags": "1.8.2", + "deepmerge": "4.3.1", + "eslint": "8.57.1", + "eslint-plugin-no-unsanitized": "4.1.4", + "eslint-visitor-keys": "4.2.1", + "espree": "10.4.0", + "esprima": "4.0.1", + "fast-json-patch": "3.1.1", + "image-size": "2.0.2", + "json-merge-patch": "1.0.2", + "pino": "9.11.0", + "semver": "7.7.2", + "source-map-support": "0.5.21", + "upath": "2.0.1", + "yargs": "17.7.2", + "yauzl": "2.10.0" + }, + "bin": { + "addons-linter": "bin/addons-linter" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/addons-linter/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/addons-linter/node_modules/addons-scanner-utils": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/addons-scanner-utils/-/addons-scanner-utils-9.13.0.tgz", + "integrity": "sha512-8OnHK/pbvgbCejGlnEYw+V3URSTVHLkMZmV270QtNh8N9pAgK10IaiJ9DcL0FsrufZ9HxRcR8/wkavh1FgK6Kg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@types/yauzl": "2.10.3", + "common-tags": "1.8.2", + "first-chunk-stream": "3.0.0", + "strip-bom-stream": "4.0.0", + "upath": "2.0.1", + "yauzl": "2.10.0" + }, + "peerDependencies": { + "body-parser": "1.20.3", + "express": "4.21.2", + "node-fetch": "2.6.11", + "safe-compare": "1.1.4" + }, + "peerDependenciesMeta": { + "body-parser": { + "optional": true + }, + "express": { + "optional": true + }, + "node-fetch": { + "optional": true + }, + "safe-compare": { + "optional": true + } + } + }, + "node_modules/addons-linter/node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/addons-linter/node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/addons-linter/node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/eslint/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/addons-linter/node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/addons-linter/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/addons-linter/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/addons-linter/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/addons-linter/node_modules/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/addons-linter/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/addons-linter/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/addons-moz-compare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/addons-moz-compare/-/addons-moz-compare-1.3.0.tgz", + "integrity": "sha512-/rXpQeaY0nOKhNx00pmZXdk5Mu+KhVlL3/pSBuAYwrxRrNiTvI/9xfQI8Lmm7DMMl+PDhtfAHY/0ibTpdeoQQQ==", + "dev": true, + "license": "MPL-2.0" + }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-differ": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-4.0.0.tgz", + "integrity": "sha512-Q6VPTLMsmXZ47ENG3V+wQyZS1ZxXMxFyYzA+Z/GMrJ6yIutAIEf9wTyroTzmGjNfox9/h3GdGBCVh43GVFx4Uw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", + "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/atomically": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", + "integrity": "sha512-kU6FmrwZ3Lx7/7y3hPS5QnbJfaohcIul5fGqf7ok+4KklIEk9tJ0C2IQPdacSbVUWv6zVHXEBWoWd6NrVMT7Cw==", + "dev": true, + "dependencies": { + "stubborn-fs": "^1.2.5", + "when-exit": "^2.1.1" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/bind-event-listener": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz", + "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==", + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/boxen/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/boxen/node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/boxen/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/boxen/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "*" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "run-applescript": "^7.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -1912,249 +3411,292 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", - "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "node_modules/camelcase-keys": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz", + "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "camelcase": "^6.3.0", + "map-obj": "^4.1.0", + "quick-lru": "^5.1.1", + "type-fest": "^1.2.1" }, "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "node_modules/cheerio": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", "dev": true, "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.12.0", + "whatwg-mimetype": "^4.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=20.18.1" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" } }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/astral-regex": { + "node_modules/cheerio-select/node_modules/dom-serializer": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "dev": true, "license": "MIT", "dependencies": { - "lodash": "^4.17.14" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "node_modules/cheerio-select/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "possible-typed-array-names": "^1.0.0" + "domelementtype": "^2.3.0" }, "engines": { - "node": ">= 0.4" + "node": ">= 4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/cheerio-select/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "dev": true, - "license": "MIT" + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "node_modules/cheerio/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "dev": true, "license": "MIT", "dependencies": { - "safe-buffer": "5.1.2" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" }, - "engines": { - "node": ">= 0.8" + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/bind-event-listener": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz", - "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==", - "license": "MIT" + "node_modules/cheerio/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/cheerio/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/cheerio/node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" - }, + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/cheerio/node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=8" + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, - "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">= 14.16.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://paulmillr.com/funding/" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/chrome-launcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.0.tgz", + "integrity": "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^2.0.1" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.cjs" + }, "engines": { - "node": ">=6" + "node": ">=12.13.0" } }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", "dev": true, "license": "MIT", "engines": { @@ -2164,63 +3706,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/camelcase-keys": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz", - "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "camelcase": "^6.3.0", - "map-obj": "^4.1.0", - "quick-lru": "^5.1.1", - "type-fest": "^1.2.1" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "dev": true, - "dependencies": { - "readdirp": "^4.0.1" - }, + "license": "MIT", "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=0.8" } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2248,12 +3776,52 @@ "dev": true, "license": "MIT" }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "node_modules/columnify": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/columnify/-/columnify-1.6.0.tgz", + "integrity": "sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "strip-ansi": "^6.0.1", + "wcwidth": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } }, "node_modules/concat-map": { "version": "0.0.1", @@ -2262,6 +3830,92 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/configstore": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.0.0.tgz", + "integrity": "sha512-yk7/5PN5im4qwz0WFZW3PXnzHgPu9mX29Y8uZ3aefe2lBPC1FYttWZRcaW9fKkT0pBCJyuQ2HfbmPVaODi9jcQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "atomically": "^2.0.3", + "dot-prop": "^9.0.0", + "graceful-fs": "^4.2.11", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/yeoman/configstore?sponsor=1" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, "node_modules/corser": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", @@ -2323,6 +3977,69 @@ "node": ">=12 || >=16" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/css-tree": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", @@ -2337,6 +4054,19 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2347,17 +4077,103 @@ "cssesc": "bin/cssesc" }, "engines": { - "node": ">=4" + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.2.tgz", + "integrity": "sha512-zDMqXh8Vs1CdRYZQ2M633m/SFgcjlu8RB8b/1h82i+6vpArF507NSYIWJHGlJaTWoS+imcnctmEz43txhbVkOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.0.3", + "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle/node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", "dev": true, "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, "engines": { - "node": ">= 12" + "node": ">=20" } }, "node_modules/data-view-buffer": { @@ -2414,6 +4230,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -2482,6 +4305,23 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2499,6 +4339,49 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2517,6 +4400,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -2535,6 +4431,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2662,6 +4568,50 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2676,6 +4626,33 @@ "dev": true, "license": "MIT" }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/enhanced-resolve": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", @@ -2775,14 +4752,11 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -2798,9 +4772,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, "license": "MIT", "dependencies": { @@ -2811,15 +4785,16 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -2853,12 +4828,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -2866,31 +4848,42 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/escape-goat": { @@ -2920,21 +4913,23 @@ } }, "node_modules/eslint": { - "version": "9.17.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", - "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", + "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.9.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.17.0", - "@eslint/plugin-kit": "^0.2.3", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.4.0", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.37.0", + "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", + "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -2942,9 +4937,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -3136,6 +5131,19 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-json": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-json/-/eslint-plugin-json-4.0.1.tgz", + "integrity": "sha512-3An5ISV5dq/kHfXdNyY5TUe2ONC3yXFSkLX2gu+W8xAhKhfvrRvkSAeKXCxZqZ0KJLX15ojBuLPyj+UikQMkOA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "vscode-json-languageservice": "^4.1.6" + }, + "engines": { + "node": ">=18.0" + } + }, "node_modules/eslint-plugin-n": { "version": "17.13.1", "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.13.1.tgz", @@ -3214,6 +5222,16 @@ "node": ">=10" } }, + "node_modules/eslint-plugin-no-unsanitized": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-no-unsanitized/-/eslint-plugin-no-unsanitized-4.1.4.tgz", + "integrity": "sha512-cjAoZoq3J+5KJuycYYOWrc0/OpZ7pl2Z3ypfFq4GtaAgheg+L7YGxUo2YS3avIvo/dYU5/zR2hXu3v81M9NxhQ==", + "dev": true, + "license": "MPL-2.0", + "peerDependencies": { + "eslint": "^8 || ^9" + } + }, "node_modules/eslint-plugin-promise": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.1.0.tgz", @@ -3231,9 +5249,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", - "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3248,9 +5266,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3260,15 +5278,40 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3277,6 +5320,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -3313,13 +5370,6 @@ "node": ">=4.0" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3338,9 +5388,9 @@ "license": "MIT" }, "node_modules/fast-check": { - "version": "3.23.2", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", - "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.2.0.tgz", + "integrity": "sha512-buxrKEaSseOwFjt6K1REcGMeFOrb0wk3cXifeMAG8yahcE9kV20PjQn1OdzPGL6OBFTbYXfjleNBARf/aCfV1A==", "dev": true, "funding": [ { @@ -3352,11 +5402,12 @@ "url": "https://opencollective.com/fast-check" } ], + "license": "MIT", "dependencies": { - "pure-rand": "^6.1.0" + "pure-rand": "^7.0.0" }, "engines": { - "node": ">=8.0.0" + "node": ">=12.17.0" } }, "node_modules/fast-deep-equal": { @@ -3396,11 +5447,19 @@ "node": ">= 6" } }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -3409,6 +5468,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-uri": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", @@ -3436,11 +5505,20 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, "funding": [ { "type": "github", @@ -3503,6 +5581,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firefox-profile": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/firefox-profile/-/firefox-profile-4.7.0.tgz", + "integrity": "sha512-aGApEu5bfCNbA4PGUZiRJAIU6jKmghV2UVdklXAofnNtiDjqYw0czLS46W7IfFqVKgKhFB8Ao2YoNGHY4BoIMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "adm-zip": "~0.5.x", + "fs-extra": "^11.2.0", + "ini": "^4.1.3", + "minimist": "^1.2.8", + "xml2js": "^0.6.2" + }, + "bin": { + "firefox-profile": "lib/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/firefox-profile/node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/first-chunk-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-3.0.0.tgz", + "integrity": "sha512-LNRvR4hr/S8cXXkIY5pTgVP7L3tq6LlYWcg9nWBuW7o1NMxKZo6oOVa/6GIekMGI0Iw7uC+HWimMe9u/VAeKqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -3556,27 +5674,43 @@ } }, "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 6" } }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, "license": "MIT", "dependencies": { "fetch-blob": "^3.1.2" @@ -3585,6 +5719,21 @@ "node": ">=12.20.0" } }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3646,18 +5795,98 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-intrinsic": { + "node_modules/fx-runner": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/fx-runner/-/fx-runner-1.4.0.tgz", + "integrity": "sha512-rci1g6U0rdTg6bAaBboP7XdRu01dzTAaKXxFf+PUqGuCv6Xu7o8NZdY1D5MvKGIjb6EdS1g3VlXOgksir1uGkg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "commander": "2.9.0", + "shell-quote": "1.7.3", + "spawn-sync": "1.0.15", + "when": "3.7.7", + "which": "1.2.4", + "winreg": "0.0.12" + }, + "bin": { + "fx-runner": "bin/fx-runner" + } + }, + "node_modules/fx-runner/node_modules/commander": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", + "integrity": "sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-readlink": ">= 1.0.0" + }, + "engines": { + "node": ">= 0.6.x" + } + }, + "node_modules/fx-runner/node_modules/isexe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-1.1.2.tgz", + "integrity": "sha512-d2eJzK691yZwPHcv1LbeAOa91yMJ9QmfTgSO1oXB65ezVhXQsxBac2vEB4bMVms9cGzaA99n6V2viHMq82VLDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fx-runner/node_modules/which": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "resolved": "https://registry.npmjs.org/which/-/which-1.2.4.tgz", + "integrity": "sha512-zDRAqDSBudazdfM9zpiI30Fu9ve47htYXcGi3ln0wfKu2a7SmrT6F3VDoYONu//48V8Vz4TdCRNPjtvyRO3yBA==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-absolute": "^0.1.7", + "isexe": "^1.1.1" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3666,6 +5895,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-symbol-description": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", @@ -3731,6 +5974,13 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -3757,6 +6007,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/global-modules": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", @@ -3803,6 +6079,7 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -3856,13 +6133,13 @@ "license": "MIT" }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3875,6 +6152,13 @@ "dev": true, "license": "ISC" }, + "node_modules/graceful-readlink": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", + "integrity": "sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==", + "dev": true, + "license": "MIT" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -3882,6 +6166,13 @@ "dev": true, "license": "MIT" }, + "node_modules/growly": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", + "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==", + "dev": true, + "license": "MIT" + }, "node_modules/hard-rejection": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", @@ -3939,9 +6230,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -4070,6 +6361,20 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-server": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", @@ -4098,6 +6403,20 @@ "node": ">=12" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -4121,10 +6440,30 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "dev": true, + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/immutable-json-patch": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/immutable-json-patch/-/immutable-json-patch-6.0.1.tgz", - "integrity": "sha512-BHL/cXMjwFZlTOffiWNdY8ZTvNyYLrutCnWxrcKPHr5FqpAb6vsO6WWSPnVSys3+DruFN6lhHJJPHi8uELQL5g==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/immutable-json-patch/-/immutable-json-patch-6.0.2.tgz", + "integrity": "sha512-KwCA5DXJiyldda8SPha1zB+6+vbEi5/jRRcYii/6yFXlyu9ZjiSH/wPq8Ri2Hk8iGjjTMcHW3Z21S4MOpl7sOw==", "license": "ISC" }, "node_modules/import-fresh": { @@ -4177,6 +6516,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4222,6 +6574,19 @@ "node": ">= 0.4" } }, + "node_modules/is-absolute": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-0.1.7.tgz", + "integrity": "sha512-Xi9/ZSn4NFapG8RP98iNPMOeaV3mXPisxKxzKtHVqr3g56j/fBn+yZmnxSVAA8lmZbl2J9b/a4kJvfU3hqQYgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-relative": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -4337,11 +6702,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4361,7 +6741,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4370,12 +6749,73 @@ "node": ">=0.10.0" } }, - "node_modules/is-module": { + "node_modules/is-in-ci": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", "dev": true, - "license": "MIT" + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/is-negative-zero": { "version": "2.0.3", @@ -4390,6 +6830,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-npm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", + "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4416,6 +6869,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", @@ -4436,15 +6902,12 @@ "node": ">=0.10.0" } }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } + "license": "MIT" }, "node_modules/is-regex": { "version": "1.1.4", @@ -4463,6 +6926,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-relative": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-0.1.3.tgz", + "integrity": "sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-shared-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", @@ -4527,6 +6999,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", + "dev": true, + "license": "MIT" + }, "node_modules/is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -4540,6 +7019,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -4571,23 +7063,54 @@ } }, "node_modules/jasmine": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-5.5.0.tgz", - "integrity": "sha512-JKlEVCVD5QBPYLsg/VE+IUtjyseDCrW8rMBu8la+9ysYashDgavMLM9Kotls1FhI6dCJLJ40dBCIfQjGLPZI1Q==", + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-5.12.0.tgz", + "integrity": "sha512-KmKeTNuH8rgAuPRL5AUsXWSdJVlDu+pgqi2dLXoZUSH/g3kR+7Ho8B7hEhwDu0fu1PLuiXZtfaxmQ/mB5wqihw==", "dev": true, + "license": "MIT", "dependencies": { "glob": "^10.2.2", - "jasmine-core": "~5.5.0" + "jasmine-core": "~5.12.0" }, "bin": { "jasmine": "bin/jasmine.js" } }, "node_modules/jasmine-core": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.5.0.tgz", - "integrity": "sha512-NHOvoPO6o9gVR6pwqEACTEpbgcH+JJ6QDypyymGbSUIFIFsMMbBJ/xsFNud8MSClfnWclXd7RQlAZBz7yVo5TQ==", - "dev": true + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.12.0.tgz", + "integrity": "sha512-QqO4pX33GEML5JoGQU6BM5NHKPgEsg+TXp3jCIDek9MbfEp2JUYEFBo9EF1+hegWy/bCHS1m5nP0BOp18G6rVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/joi": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.1.tgz", + "integrity": "sha512-IiQpRyypSnLisQf3PwuN2eIHAsAIGZIrLZkd4zdvIar2bDyhM91ubRjy8a3eYablXsh9BeI/c7dmPYHca5qtoA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } }, "node_modules/js-tokens": { "version": "4.0.0", @@ -4600,7 +7123,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4609,6 +7131,135 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.1.0.tgz", + "integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.19", + "@asamuzakjp/dom-selector": "^6.7.3", + "cssstyle": "^5.3.2", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4616,6 +7267,16 @@ "dev": true, "license": "MIT" }, + "node_modules/json-merge-patch": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-merge-patch/-/json-merge-patch-1.0.2.tgz", + "integrity": "sha512-M6Vp2GN9L7cfuMXiWOmHj9bEFbeC250iVtcKQbqVgEsDVYnIsrNsbU+h/Y/PkbBQCtEa4Bez+Ebv0zfbC8ObLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -4624,10 +7285,9 @@ "license": "MIT" }, "node_modules/json-schema-to-typescript": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.3.tgz", - "integrity": "sha512-iOKdzTUWEVM4nlxpFudFsWyUiu/Jakkga4OZPEt7CGoSEsAsUgdOZqR6pcgx2STBek9Gm4hcarJpXSzIvZ/hKA==", - "dev": true, + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", "license": "MIT", "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.5.5", @@ -4648,10 +7308,11 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -4673,6 +7334,71 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4700,6 +7426,35 @@ "dev": true, "license": "MIT" }, + "node_modules/ky": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.8.1.tgz", + "integrity": "sha512-7Bp3TpsE+L+TARSnnDpk3xg8Idi8RwSLdj6CMbNWoOARIrGrbuLGusV0dYwbZOm4bB3jHNxSw8Wk/ByDqJEnDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/latest-version": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", + "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "package-json": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4714,6 +7469,44 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lighthouse-logger": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.1.tgz", + "integrity": "sha512-ioBrW3s2i97noEmnXxmUq7cjIcVRjT5HBpAYy8zE11CxU9HqlWHHeRxfeN1tn8F7OEMVPIC9x1f8t3Z7US9ehQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4751,7 +7544,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -4768,6 +7560,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lottie-web": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", + "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -4788,15 +7586,12 @@ "dev": true, "license": "MIT" }, - "node_modules/magic-string": { - "version": "0.30.12", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", - "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } + "license": "ISC" }, "node_modules/map-obj": { "version": "4.3.0", @@ -4829,6 +7624,23 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", @@ -4918,6 +7730,29 @@ "node": ">=4" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4945,7 +7780,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4996,6 +7830,38 @@ "dev": true, "license": "MIT" }, + "node_modules/multimatch": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-6.0.0.tgz", + "integrity": "sha512-I7tSVxHGPlmPN/enE3mS1aOSo6bWBfls+3HmuEeCUBCE7gWnm3cBXCBkpurzFjVRwC6Kld8lLaZ1Iv5vOcjvcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^3.0.5", + "array-differ": "^4.0.0", + "array-union": "^3.0.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/multimatch/node_modules/array-union": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", + "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -5026,7 +7892,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "dev": true, "funding": [ { "type": "github", @@ -5046,7 +7911,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -5061,6 +7925,44 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-notifier": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz", + "integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "growly": "^1.3.0", + "is-wsl": "^2.2.0", + "semver": "^7.3.5", + "shellwords": "^0.1.1", + "uuid": "^8.3.2", + "which": "^2.0.2" + } + }, + "node_modules/node-notifier/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-package-data": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", @@ -5100,6 +8002,19 @@ "node": ">=0.10.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-inspect": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", @@ -5194,6 +8109,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5204,6 +8129,25 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -5232,6 +8176,15 @@ "node": ">= 0.8.0" } }, + "node_modules/os-shim": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/os-shim/-/os-shim-0.1.3.tgz", + "integrity": "sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5264,6 +8217,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", + "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ky": "^1.2.0", + "registry-auth-token": "^5.0.2", + "registry-url": "^6.0.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -5271,6 +8243,26 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/package-json/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5312,6 +8304,75 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5383,6 +8444,13 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5403,13 +8471,54 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.11.0.tgz", + "integrity": "sha512-+YIodBB9sxcWeR8PrXC2K3gEDyfkUuVEITOcbqrfcj+z5QW4ioIcqZfYFbrLTYLsmAwunbS7nfU/dpBB6PZc1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "dev": true, + "license": "MIT" + }, "node_modules/playwright": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", - "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.49.1" + "playwright-core": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -5422,10 +8531,11 @@ } }, "node_modules/playwright-core": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", - "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", "dev": true, + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, @@ -5543,9 +8653,9 @@ "license": "MIT" }, "node_modules/preact": { - "version": "10.24.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", - "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "version": "10.26.9", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.9.tgz", + "integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==", "license": "MIT", "funding": { "type": "opencollective", @@ -5563,10 +8673,10 @@ } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", - "dev": true, + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -5577,11 +8687,63 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/promise-toolbox": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/promise-toolbox/-/promise-toolbox-0.21.0.tgz", + "integrity": "sha512-NV8aTmpwrZv+Iys54sSFOBx3tuVaOBvvrft5PNppnxy9xpU/akHbaWIril22AB22zaPgrgwKdD0KsrM0ptUtpg==", + "dev": true, + "license": "ISC", + "dependencies": { + "make-error": "^1.3.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -5596,10 +8758,39 @@ "node": ">=6" } }, + "node_modules/pupa": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", + "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pupa/node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", "dev": true, "funding": [ { @@ -5650,6 +8841,13 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "dev": true, + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -5669,6 +8867,32 @@ "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-pkg": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", @@ -5719,6 +8943,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redent": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz", @@ -5736,12 +8970,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -5761,6 +8989,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/registry-auth-token": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.0.tgz", + "integrity": "sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^2.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5852,87 +9119,31 @@ "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.1.tgz", - "integrity": "sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.6" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.30.1", - "@rollup/rollup-android-arm64": "4.30.1", - "@rollup/rollup-darwin-arm64": "4.30.1", - "@rollup/rollup-darwin-x64": "4.30.1", - "@rollup/rollup-freebsd-arm64": "4.30.1", - "@rollup/rollup-freebsd-x64": "4.30.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.30.1", - "@rollup/rollup-linux-arm-musleabihf": "4.30.1", - "@rollup/rollup-linux-arm64-gnu": "4.30.1", - "@rollup/rollup-linux-arm64-musl": "4.30.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.30.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.30.1", - "@rollup/rollup-linux-riscv64-gnu": "4.30.1", - "@rollup/rollup-linux-s390x-gnu": "4.30.1", - "@rollup/rollup-linux-x64-gnu": "4.30.1", - "@rollup/rollup-linux-x64-musl": "4.30.1", - "@rollup/rollup-win32-arm64-msvc": "4.30.1", - "@rollup/rollup-win32-ia32-msvc": "4.30.1", - "@rollup/rollup-win32-x64-msvc": "4.30.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup-plugin-import-css": { - "version": "3.5.8", - "resolved": "https://registry.npmjs.org/rollup-plugin-import-css/-/rollup-plugin-import-css-3.5.8.tgz", - "integrity": "sha512-a3YsZnwHz66mRHCKHjaPCSfWczczvS/HTkgDc+Eogn0mt/0JZXz0WjK0fzM5WwBpVtOqHB4/gHdmEY40ILsaVg==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^5.1.3" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">=16" + "node": "*" }, - "peerDependencies": { - "rollup": "^2.x.x || ^3.x.x || ^4.x.x" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rollup-plugin-svg-import": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-svg-import/-/rollup-plugin-svg-import-3.0.0.tgz", - "integrity": "sha512-5fUESTM5hdqJojrwO53JQUO7NespLNx4iLeMsToQfuaGGqGT5sz85Ns5gCDNxLO6yBPbn7p0A/6YA+Rq3clg4Q==", + "node_modules/run-applescript": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", "dev": true, "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1" - }, "engines": { - "node": ">=18.0.0" + "node": ">=18" }, - "peerDependencies": { - "rollup": "^3.0.0||^4.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/run-parallel": { @@ -5959,6 +9170,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", @@ -6003,6 +9224,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6010,6 +9241,26 @@ "dev": true, "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "dev": true, + "license": "ISC" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/secure-compare": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", @@ -6067,6 +9318,13 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6090,6 +9348,20 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/shellwords": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", + "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", + "dev": true, + "license": "MIT" + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -6159,6 +9431,26 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6169,6 +9461,29 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spawn-sync": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz", + "integrity": "sha512-9DWBgrgYZzNghseho0JOuh+5fg9u6QWhAWa51QC7+U5rCheZ/j1DrEZnyE0RBBRqZ9uEXGPgSSM0nky6burpVw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "concat-stream": "^1.4.7", + "os-shim": "^0.1.2" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -6209,6 +9524,29 @@ "resolved": "special-pages", "link": true }, + "node_modules/split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -6329,6 +9667,33 @@ "node": ">=4" } }, + "node_modules/strip-bom-buf": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-buf/-/strip-bom-buf-2.0.0.tgz", + "integrity": "sha512-gLFNHucd6gzb8jMsl5QmZ3QgnUJmp7qn4uUSHNwEXumAp7YizoGYw19ZUVfuq4aBOQUtyn2k8X/CwzWB73W2lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-utf8": "^0.2.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-4.0.0.tgz", + "integrity": "sha512-0ApK3iAkHv6WbgLICw/J4nhwHeDZsBxIIsOD+gHgZICL6SeJ0S9f/WZqemka9cjkTyMN5geId6e8U5WGFAn3cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "first-chunk-stream": "^3.0.0", + "strip-bom-buf": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-indent": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz", @@ -6350,6 +9715,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -6357,6 +9723,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stubborn-fs": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", + "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==", + "dev": true + }, "node_modules/style-search": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", @@ -6563,6 +9935,13 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/table": { "version": "6.8.2", "resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz", @@ -6580,45 +9959,44 @@ "node": ">=10.0.0" } }, - "node_modules/table/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", "dev": true, "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=6" } }, - "node_modules/table/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, "license": "MIT" }, - "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "real-require": "^0.2.0" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz", "integrity": "sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.4.2", @@ -6632,7 +10010,6 @@ "version": "6.4.2", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz", "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==", - "dev": true, "license": "MIT", "peerDependencies": { "picomatch": "^3 || ^4" @@ -6647,7 +10024,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6660,7 +10036,6 @@ "version": "6.1.71", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.71.tgz", "integrity": "sha512-LQIHmHnuzfZgZWAf2HzL83TIIrD8NhhI0DVxqo9/FdOd4ilec+NTNZOlDZf7EwrTNoutccbsHjvWHYXLAtvxjw==", - "dev": true, "dependencies": { "tldts-core": "^6.1.71" }, @@ -6671,8 +10046,17 @@ "node_modules/tldts-core": { "version": "6.1.71", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.71.tgz", - "integrity": "sha512-LRbChn2YRpic1KxY+ldL1pGXN/oVvKfCVufwfVzEQdFYNo39uF7AJa/WXdo+gYO7PTvdfkCPCed6Hkvz/kR7jg==", - "dev": true + "integrity": "sha512-LRbChn2YRpic1KxY+ldL1pGXN/oVvKfCVufwfVzEQdFYNo39uF7AJa/WXdo+gYO7PTvdfkCPCed6Hkvz/kR7jg==" + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -6687,36 +10071,189 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tough-cookie/node_modules/tldts": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", + "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.17" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tough-cookie/node_modules/tldts-core": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", + "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", + "dev": true, + "license": "MIT" + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "dev": true, - "license": "MIT" + "license": "MIT" + }, + "node_modules/trim-newlines": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz", + "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-json-schema-generator": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-json-schema-generator/-/ts-json-schema-generator-2.4.0.tgz", + "integrity": "sha512-HbmNsgs58CfdJq0gpteRTxPXG26zumezOs+SB9tgky6MpqiFgQwieCn2MW70+sxpHouZ/w9LW0V6L4ZQO4y1Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "commander": "^13.1.0", + "glob": "^11.0.1", + "json5": "^2.2.3", + "normalize-path": "^3.0.0", + "safe-stable-stringify": "^2.5.0", + "tslib": "^2.8.1", + "typescript": "^5.8.2" + }, + "bin": { + "ts-json-schema-generator": "bin/ts-json-schema-generator.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/ts-json-schema-generator/node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-json-schema-generator/node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-json-schema-generator/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-json-schema-generator/node_modules/lru-cache": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, - "node_modules/trim-newlines": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz", - "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", + "node_modules/ts-json-schema-generator/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, "engines": { - "node": ">=12" + "node": "20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/ts-api-utils": { + "node_modules/ts-json-schema-generator/node_modules/path-scurry": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, "engines": { - "node": ">=18.12" + "node": "20 || >=22" }, - "peerDependencies": { - "typescript": ">=4.8.4" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/tsconfig-paths": { @@ -6842,26 +10379,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/typedoc": { - "version": "0.27.6", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.27.6.tgz", - "integrity": "sha512-oBFRoh2Px6jFx366db0lLlihcalq/JzyCVp7Vaq1yphL/tbgx2e+bkpkCgJPunaPvPwoTOXSwasfklWHm7GfAw==", + "version": "0.28.8", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.8.tgz", + "integrity": "sha512-16GfLopc8icHfdvqZDqdGBoS2AieIRP2rpf9mU+MgN+gGLyEQvAO0QgOa6NJ5QNmQi0LFrDY9in4F2fUNKgJKA==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@gerrit0/mini-shiki": "^1.24.0", + "@gerrit0/mini-shiki": "^3.7.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", - "yaml": "^2.6.1" + "yaml": "^2.8.0" }, "bin": { "typedoc": "bin/typedoc" }, "engines": { - "node": ">= 18" + "node": ">= 18", + "pnpm": ">= 10" }, "peerDependencies": { - "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x" + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x" } }, "node_modules/typedoc/node_modules/brace-expansion": { @@ -6895,10 +10441,11 @@ "link": true }, "node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6908,14 +10455,69 @@ } }, "node_modules/typescript-eslint": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.19.1.tgz", - "integrity": "sha512-LKPUQpdEMVOeKluHi8md7rwLcoXHhwvWp3x+sJkMuq3gGm9yaYJtPo8sRZSblMFJ5pcOGCAak/scKf1mvZDlQw==", + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.36.0.tgz", + "integrity": "sha512-fTCqxthY+h9QbEgSIBfL9iV6CvKDFuoxg6bHPNpJ9HIUzS+jy2lCEyCmGyZRWEBSaykqcDPf1SJ+BfCI8DRopA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.36.0", + "@typescript-eslint/parser": "8.36.0", + "@typescript-eslint/utils": "8.36.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz", + "integrity": "sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.36.0", + "@typescript-eslint/type-utils": "8.36.0", + "@typescript-eslint/utils": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.36.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.36.0.tgz", + "integrity": "sha512-5aaGYG8cVDd6cxfk/ynpYzxBRZJk7w/ymto6uiyUFtdCozQIsQWh7M28/6r57Fwkbweng8qAzoMCPwSJfWlmsg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.19.1", - "@typescript-eslint/parser": "8.19.1", - "@typescript-eslint/utils": "8.19.1" + "@typescript-eslint/typescript-estree": "8.36.0", + "@typescript-eslint/utils": "8.36.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6926,7 +10528,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/uc.micro": { @@ -6942,76 +10554,396 @@ "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", + "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-notifier": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", + "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "configstore": "^7.0.0", + "is-in-ci": "^1.0.0", + "is-installed-globally": "^1.0.0", + "is-npm": "^6.0.0", + "latest-version": "^9.0.0", + "pupa": "^3.1.0", + "semver": "^7.6.3", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/valid-data-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz", + "integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vscode-json-languageservice": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.2.1.tgz", + "integrity": "sha512-xGmv9QIWs2H8obGbWg+sIPI/3/pFgj/5OWBhNzs00BkYQ9UaB2F6JJaGB/2/YOZJ3BvLXQTC4Q7muqU25QgAhA==", + "license": "MIT", + "dependencies": { + "jsonc-parser": "^3.0.0", + "vscode-languageserver-textdocument": "^1.0.3", + "vscode-languageserver-types": "^3.16.0", + "vscode-nls": "^5.0.0", + "vscode-uri": "^3.0.3" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wait-on": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.1.tgz", + "integrity": "sha512-noeCAI+XbqWMXY23sKril0BSURhuLYarkVXwJv1uUWwoojZJE7pmX3vJ7kh7SZaNgPGzfsCSQIZM/AGvu0Q9pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.12.2", + "joi": "^18.0.1", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-ext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/web-ext/-/web-ext-9.0.0.tgz", + "integrity": "sha512-QheVP1seAMB5EOnTxlR8mOgQU85ik44K6FedEzqmOEq9kpCy1NrycekMw1mk8qwlkeN0RbJmEK65NIDCRthy1Q==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@babel/runtime": "7.28.4", + "@devicefarmer/adbkit": "3.3.8", + "addons-linter": "8.0.0", + "camelcase": "8.0.0", + "chrome-launcher": "1.2.0", + "debounce": "1.2.1", + "decamelize": "6.0.1", + "es6-error": "4.1.1", + "firefox-profile": "4.7.0", + "fx-runner": "1.4.0", + "https-proxy-agent": "^7.0.0", + "jose": "5.9.6", + "jszip": "3.10.1", + "multimatch": "6.0.0", + "node-notifier": "10.0.1", + "open": "10.2.0", + "parse-json": "8.3.0", + "pino": "9.11.0", + "promise-toolbox": "0.21.0", + "source-map-support": "0.5.21", + "strip-bom": "5.0.0", + "strip-json-comments": "5.0.3", + "tmp": "0.2.5", + "update-notifier": "7.3.1", + "watchpack": "2.4.4", + "yargs": "17.7.2", + "zip-dir": "2.0.0" + }, + "bin": { + "web-ext": "bin/web-ext.js" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + } + }, + "node_modules/web-ext/node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/web-ext/node_modules/decamelize": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-6.0.1.tgz", + "integrity": "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true - }, - "node_modules/union": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", - "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "node_modules/web-ext/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "dev": true, + "license": "MIT", "dependencies": { - "qs": "^6.4.0" + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/web-ext/node_modules/strip-bom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-5.0.0.tgz", + "integrity": "sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A==", "dev": true, - "dependencies": { - "punycode": "^2.1.0" + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/url-join": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", - "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", - "dev": true, - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/valid-data-url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz", - "integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==", + "node_modules/web-ext/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "node_modules/web-ext/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/web-resource-inliner": { @@ -7070,7 +11002,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -7096,6 +11027,16 @@ "node": ">=12" } }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -7107,6 +11048,20 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/when": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/when/-/when-3.7.7.tgz", + "integrity": "sha512-9lFZp/KHoqH6bPKjbWqa+3Dg/K/r2v0X/3/G2x4DBGchVS2QX2VXL3cZV994WQVnTM1/PD71Az25nAzryEUugw==", + "dev": true, + "license": "MIT" + }, + "node_modules/when-exit": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.4.tgz", + "integrity": "sha512-4rnvd3A1t16PWzrBUcSDZqcAmsUIy4minDXT/CZ8F2mVDgd65i4Aalimgz1aQkRGU0iH5eT5+6Rx2TK8o443Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7160,6 +11115,83 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/winreg": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/winreg/-/winreg-0.0.12.tgz", + "integrity": "sha512-typ/+JRmi7RqP1NanzFULK36vczznSNN8kWVA9vIqXyv8GhghUlwhGp1Xj3Nms1FsPcNnsQrJOR10N58/nQ9hQ==", + "dev": true, + "license": "BSD" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7295,12 +11327,130 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xregexp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-3.2.0.tgz", "integrity": "sha512-tWodXkrdYZPGadukpkmhKAbyp37CV5ZiFHacIVPhRZ4/sSt7qtOYHLv2dAqcPN0mBsViY2Qai9JkO7v2TBP6hg==", "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -7309,15 +11459,35 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", - "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", "dev": true, + "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" } }, "node_modules/yargs-parser": { @@ -7330,6 +11500,27 @@ "node": ">=10" } }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7343,32 +11534,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/zip-dir/-/zip-dir-2.0.0.tgz", + "integrity": "sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.0", + "jszip": "^3.2.2" + } + }, + "node_modules/zip-dir/node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "special-pages": { "version": "1.0.0", "license": "ISC", "dependencies": { - "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", - "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", - "@formkit/auto-animate": "^0.8.2", - "@preact/signals": "^1.3.1", - "@rive-app/canvas-single": "^2.25.3", + "@atlaskit/pragmatic-drag-and-drop": "^1.7.6", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", + "@formkit/auto-animate": "^0.9.0", + "@preact/signals": "^2.2.1", + "@rive-app/canvas-single": "^2.31.5", "classnames": "^2.5.1", - "preact": "^10.24.3" + "lottie-web": "^5.13.0", + "preact": "^10.26.9" }, "devDependencies": { "@duckduckgo/messaging": "*", "chokidar": "^4.0.3", - "esbuild": "^0.24.2", - "fast-check": "^3.23.2", + "fast-check": "^4.2.0", "http-server": "^14.1.1", "web-resource-inliner": "^6.0.1" } }, "types-generator": { + "dependencies": { + "json-schema-to-typescript": "^15.0.4" + }, "devDependencies": { - "@types/jasmine": "^5.1.5", - "jasmine": "^5.5.0", - "json-schema-to-typescript": "^15.0.2" + "@types/jasmine": "^5.1.9", + "jasmine": "^5.12.0" } } } diff --git a/package.json b/package.json index 47cffe830f..fea652ee5f 100644 --- a/package.json +++ b/package.json @@ -6,27 +6,28 @@ "injected", "special-pages", "messaging", - "build", - "Sources/ContentScopeScripts/dist" + "build" ], "scripts": { "build": "npm run build --workspaces --if-present", "test-unit": "npm run test-unit --workspaces --if-present", "test-int": "npm run test-int --workspaces --if-present", "test-int-x": "npm run test-int-x --workspaces --if-present", + "test-int-snapshots": "npm run test-int-snapshots --workspaces --if-present", + "test-int-snapshots-update": "npm run test-int-snapshots-update --workspaces --if-present", "test-clean-tree": "npm run build && sh scripts/check-for-changes.sh", "docs": "typedoc", "docs-watch": "typedoc --watch", "tsc": "tsc", "tsc-watch": "tsc --watch", "lint": "eslint . && npm run tsc && npm run lint-no-output-globals && npx prettier . --check", - "lint-no-output-globals": "eslint --no-inline-config --config build-output.eslint.config.js Sources/ContentScopeScripts/dist/contentScope.js", + "lint-no-output-globals": "eslint --no-inline-config --config build-output.eslint.config.js build/apple/contentScope.js", "postlint": "npm run lint --workspaces --if-present", "lint-fix": "eslint . --fix && npx prettier . --write && npm run tsc", "stylelint": "npx stylelint \"**/*.css\"", "stylelint-fix": "npx stylelint \"**/*.css\" --fix", - "serve": "http-server -c-1 --port 3220 integration-test/test-pages", - "serve-special-pages": "http-server -c-1 --port 3221 build/integration/pages" + "serve": "npm run serve --workspace=injected", + "serve-special-pages": "npm run serve --workspace=special-pages" }, "type": "module", "workspaces": [ @@ -37,19 +38,23 @@ ], "devDependencies": { "@duckduckgo/eslint-config": "github:duckduckgo/eslint-config#v0.1.0", - "@playwright/test": "^1.49.1", - "@types/eslint__js": "^8.42.3", - "eslint": "^9.17.0", + "@playwright/test": "^1.52.0", + "ajv": "^8.17.1", + "esbuild": "^0.25.12", + "eslint": "^9.37.0", "minimist": "^1.2.8", - "prettier": "3.4.2", + "prettier": "3.6.2", "stylelint": "^15.11.0", "stylelint-config-standard": "^34.0.0", "stylelint-csstree-validator": "^3.0.0", - "typedoc": "^0.27.6", - "typescript": "^5.7.2", - "typescript-eslint": "^8.19.1" + "ts-json-schema-generator": "^2.4.0", + "typedoc": "^0.28.8", + "typescript": "^5.8.3", + "typescript-eslint": "^8.36.0", + "wait-on": "^9.0.1" }, "dependencies": { - "immutable-json-patch": "^6.0.1" + "immutable-json-patch": "^6.0.2", + "urlpattern-polyfill": "^10.1.0" } } diff --git a/special-pages/README.md b/special-pages/README.md index 298b38f0db..9e82f0426c 100644 --- a/special-pages/README.md +++ b/special-pages/README.md @@ -1,3 +1,69 @@ +# Special Pages + +## Overview + +Special Pages gives us a single place to implement isolated HTML/CSS/Javascript projects that can be loaded into a web context that has privileged access to API. + +## Getting Started with Special Pages Development + +### Prerequisites + +Before starting Special Pages development, ensure you have completed the initial setup: + +> **Repository access and initial setup**: See [Development Utilities - Development Setup](../injected/docs/development-utilities.md#development-setup) for repository access, cloning, and initial build setup. + +### Step 1: Make a Change to the 'Example' Special Page + +Edit the file `App.jsx` within `special-pages/pages/example/app/components` + +### Step 2: Rebuild + +Since Content Scope Scripts contains additional projects, you can just rebuild the special-pages parts: + +```shell +cd special-pages +npm run build +``` + +### Step 3: Review Your Change + +Still within `special-pages`, you can now serve the built pages: + +```shell +npm run serve +``` + +Then access the Example page you edited via: `http://127.0.0.1:3210/example/` + +> **Note**: The output of the build command is just plain HTML/CSS/JS, so you can serve the build directory in any way that suits you. + + +### Step 4: Watch Mode + +The build command builds every special page, but to run just 1 in isolation: + +```shell +npm run watch -- --page= +``` + +Check the terminal for the dev URL, but it's normally `localhost:8000`. + +> **Note**: Any changes you make here will not be automatically reflected in the build folder output. + +### Step 5: Create a PR + Preview the Change + +Content Scope Scripts uses Netlify preview deployments, so opening a PR will create a preview URL. + +During the deployment: +- Docs are generated +- All special pages are built + +When you access the Netlify URL, you'll land on the docs homepage. Append `/build/pages/example` to the URL to see the changes you made to the Example application. + +**Example URLs:** +- Preview: `https://content-scope-scripts.netlify.app/build/pages/example/` +- Production: `https://content-scope-scripts.netlify.app` + ## Architecture Special Pages gives us a single place to implement isolated HTML/CSS/Javascript projects that can be loaded into a web context that has privileged access to API. diff --git a/special-pages/index.mjs b/special-pages/index.mjs index 3b08c9d9df..3827d8c848 100644 --- a/special-pages/index.mjs +++ b/special-pages/index.mjs @@ -13,7 +13,6 @@ import { baseEsbuildOptions } from './opts.mjs'; const CWD = cwd(import.meta.url); const ROOT = join(CWD, '../'); const BUILD = join(ROOT, 'build'); -const APPLE_BUILD = join(ROOT, 'Sources/ContentScopeScripts/dist'); const args = parseArgs(process.argv.slice(2), []); const NODE_ENV = args.env || 'production'; const DEBUG = Boolean(args.debug); @@ -45,11 +44,13 @@ export const support = { 'release-notes': { integration: ['copy', 'build-js'], apple: ['copy', 'build-js'], + windows: ['copy', 'build-js'], }, /** @type {Partial>} */ 'special-error': { integration: ['copy', 'build-js'], apple: ['copy', 'build-js', 'inline-html'], + windows: ['copy', 'build-js', 'inline-html'], }, /** @type {Partial>} */ 'new-tab': { @@ -57,6 +58,12 @@ export const support = { windows: ['copy', 'build-js'], apple: ['copy', 'build-js'], }, + /** @type {Partial>} */ + history: { + integration: ['copy', 'build-js'], + windows: ['copy', 'build-js'], + apple: ['copy', 'build-js'], + }, }; /** @type {{src: string, dest: string, dist: string, injectName: string}[]} */ @@ -74,9 +81,9 @@ for (const [pageName, injectNames] of Object.entries(support)) { errors.push(`${publicDir} does not exist. Each page must have a 'src' directory`); continue; } - for (const [injectName, jobs] of Object.entries(injectNames)) { + for (const [injectNameKey, jobs] of Object.entries(injectNames)) { // output main dir - const buildDir = injectName === 'apple' ? APPLE_BUILD : join(BUILD, injectName); + const buildDir = join(BUILD, injectNameKey); const pageOutputDirectory = join(buildDir, 'pages', pageName); @@ -86,14 +93,14 @@ for (const [pageName, injectNames] of Object.entries(support)) { src: publicDir, dist: join(publicDir, 'dist'), dest: pageOutputDirectory, - injectName, + injectName: injectNameKey, }); } if (job === 'build-js') { const outputDir = join(pageOutputDirectory, 'dist'); buildJobs.push({ outputDir, - injectName: /** @type {ImportMeta['injectName']} */ (injectName), + injectName: /** @type {ImportMeta['injectName']} */ (injectNameKey), pageName, }); } diff --git a/special-pages/package.json b/special-pages/package.json index f130dfc202..9efd08598f 100644 --- a/special-pages/package.json +++ b/special-pages/package.json @@ -10,10 +10,12 @@ "build": "node index.mjs", "build.dev": "npm run build -- --env development", "lint-fix": "cd ../ && npm run lint-fix", - "test-unit": "node --test unit-test/translations.mjs pages/duckplayer/unit-tests/embed-settings.mjs pages/new-tab/app/freemium-pir-banner/unit-tests/utils.spec.mjs", - "test-int": "npm run test-unit && npm run build.dev && playwright test --grep-invert '@screenshots'", + "test-unit": "node --test \"unit-test/*\" \"pages/history/unit-tests/*\" \"pages/duckplayer/unit-tests/*\" \"pages/new-tab/app/omnibar/unit-tests/*\"", + "test-int": "playwright test --grep-invert '@screenshots'", "test-int-x": "npm run test-int", - "test.screenshots": "npm run test-unit && npm run build.dev && playwright test --grep '@screenshots'", + "test-int-snapshots": "playwright test --grep '@screenshots'", + "test-int-snapshots-update": "playwright test --grep '@screenshots' --update-snapshots --last-failed --pass-with-no-tests", + "test.screenshots": "playwright test --grep '@screenshots'", "test.windows": "npm run test-int -- --project windows", "test.macos": "npm run test-int -- --project macos", "test.ios": "npm run test-int -- --project ios", @@ -27,19 +29,19 @@ "license": "ISC", "devDependencies": { "@duckduckgo/messaging": "*", - "esbuild": "^0.24.2", + "chokidar": "^4.0.3", + "fast-check": "^4.2.0", "http-server": "^14.1.1", - "web-resource-inliner": "^6.0.1", - "fast-check": "^3.23.2", - "chokidar": "^4.0.3" + "web-resource-inliner": "^6.0.1" }, "dependencies": { - "preact": "^10.24.3", - "@preact/signals": "^1.3.1", + "@atlaskit/pragmatic-drag-and-drop": "^1.7.6", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", + "@formkit/auto-animate": "^0.9.0", + "@preact/signals": "^2.2.1", + "@rive-app/canvas-single": "^2.31.5", "classnames": "^2.5.1", - "@formkit/auto-animate": "^0.8.2", - "@rive-app/canvas-single": "^2.25.3", - "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", - "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3" + "lottie-web": "^5.13.0", + "preact": "^10.26.9" } } diff --git a/special-pages/pages/duckplayer/app/components/Background.jsx b/special-pages/pages/duckplayer/app/components/Background.jsx deleted file mode 100644 index d09468336d..0000000000 --- a/special-pages/pages/duckplayer/app/components/Background.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import { h } from 'preact'; -import styles from './Background.module.css'; - -export function Background() { - return
    ; -} diff --git a/special-pages/pages/duckplayer/app/components/Background.module.css b/special-pages/pages/duckplayer/app/components/Background.module.css deleted file mode 100644 index 47f0ede6a5..0000000000 --- a/special-pages/pages/duckplayer/app/components/Background.module.css +++ /dev/null @@ -1,52 +0,0 @@ -.bg { - background: url('../img/player-bg.jpg'); - background-size: cover; -} - -[data-layout="mobile"] .bg { - background: url('../img/mobile-bg.jpg'); - background-size: cover; -} - -.bg { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - width: 100%; - height: 100%; -} - -.bg::before { - content: ''; - position: absolute; - inset: 0; - height: 100%; - background: linear-gradient(0deg, rgba(0, 0, 0, 0.00) 0%, rgba(0, 0, 0, 0.48) 32.23%, #000 93.87%); - transition: all .3s ease-in-out; -} - -.bg::after { - content: ''; - position: absolute; - inset: 0; - height: 100%; - background: linear-gradient(0deg, rgba(0, 0, 0, 0.48) 0%, rgba(0, 0, 0, 0.90) 34.34%, #000 100%); - opacity: 0; - visibility: hidden; - transition: all .3s ease-in-out; -} - -[data-focus-mode="on"] .bg::before { - transition-delay: .1s; - opacity: 0; -} -[data-focus-mode="off"] .bg::after { - transition-delay: .1s; -} - -[data-focus-mode="on"] .bg::after { - opacity: 1; - visibility: visible; -} diff --git a/special-pages/pages/duckplayer/app/components/Button.jsx b/special-pages/pages/duckplayer/app/components/Button.jsx index 766d0e5d1a..e7504f8db2 100644 --- a/special-pages/pages/duckplayer/app/components/Button.jsx +++ b/special-pages/pages/duckplayer/app/components/Button.jsx @@ -7,16 +7,26 @@ import styles from './Button.module.css'; * @param {object} props * @param {import("preact").ComponentChild} props.children * @param {"mobile" | "desktop"} [props.formfactor] + * @param {"standard" | "accent"} [props.variant] * @param {boolean} [props.icon] * @param {boolean} [props.fill] * @param {boolean} [props.highlight] * @param {import("preact").ComponentProps<"button">} [props.buttonProps] */ -export function Button({ children, formfactor = 'mobile', icon = false, fill = false, highlight = false, buttonProps = {} }) { +export function Button({ + children, + formfactor = 'mobile', + variant = 'standard', + icon = false, + fill = false, + highlight = false, + buttonProps = {}, +}) { const classes = cn({ [styles.button]: true, [styles.desktop]: formfactor === 'desktop', [styles.highlight]: highlight === true, + [styles.accent]: variant === 'accent', [styles.fill]: fill === true, [styles.iconOnly]: icon === true, }); @@ -58,3 +68,18 @@ export function Icon({ src }) { ); } + +export function OpenInIcon() { + return ( + + + + + ); +} diff --git a/special-pages/pages/duckplayer/app/components/Button.module.css b/special-pages/pages/duckplayer/app/components/Button.module.css index e33abfddec..fe5e9e6342 100644 --- a/special-pages/pages/duckplayer/app/components/Button.module.css +++ b/special-pages/pages/duckplayer/app/components/Button.module.css @@ -1,7 +1,13 @@ .button { + --button-background: rgba(255, 255, 255, 0.18); + --button-color: rgba(255, 255, 255, 1); + --button-background-hover: rgba(255, 255, 255, 0.2); + + align-items: center; border: none; outline: none; display: flex; + gap: 8px; height: 44px; line-height: 44px; font-size: 15px; @@ -9,15 +15,19 @@ padding: 0 20px; flex-shrink: 0; box-shadow: none; - background: rgba(255, 255, 255, 0.12); + background: var(--button-background); border-radius: var(--inner-radius); - color: rgba(255, 255, 255, 1); + color: var(--button-color); text-decoration: none; + + [data-layout="mobile"] & { + --button-background: rgba(255, 255, 255, 0.12); + } } .button:hover, .button:focus-visible { + background: var(--button-background-hover); cursor: pointer; - background: rgba(255, 255, 255, 0.2); } .fill { @@ -54,7 +64,6 @@ transform: translateY(-50%) translateX(-50%); } -.icon {} .icon img { display: block; width: 100%; @@ -75,3 +84,17 @@ transition-delay: 2s; transform: scale(1.1); } + +/* Accent variant */ + +.button.accent { + --button-background: #3969ef; + --button-color: #fff; + --button-background-hover: #2b55ca; +} + +.svgIcon { + width: 16px; + height: 16px; + margin-left: -8px; +} \ No newline at end of file diff --git a/special-pages/pages/duckplayer/app/components/Components.jsx b/special-pages/pages/duckplayer/app/components/Components.jsx index 3adc3f5aa5..3316e69866 100644 --- a/special-pages/pages/duckplayer/app/components/Components.jsx +++ b/special-pages/pages/duckplayer/app/components/Components.jsx @@ -8,26 +8,25 @@ import { FloatingBar } from './FloatingBar.jsx'; import { SwitchBarMobile } from './SwitchBarMobile.jsx'; import { InfoBar, InfoBarContainer, InfoIcon } from './InfoBar.jsx'; import { Wordmark } from './Wordmark.jsx'; -import { Background } from './Background.jsx'; import { Player, PlayerError } from './Player.jsx'; import { SettingsProvider } from '../providers/SettingsProvider.jsx'; import { Settings } from '../settings.js'; import { EmbedSettings } from '../embed-settings.js'; import { SwitchBarDesktop } from './SwitchBarDesktop.jsx'; import { SwitchProvider } from '../providers/SwitchProvider.jsx'; +import { YouTubeError } from './YouTubeError'; +import { YouTubeErrorProvider } from '../providers/YouTubeErrorProvider'; export function Components() { const settings = new Settings({ platform: { name: 'macos' }, + customError: { state: 'enabled' }, }); - let embed = EmbedSettings.fromHref('https://localhost?videoID=123'); + let embed = /** @type {EmbedSettings} */ (EmbedSettings.fromHref('https://localhost?videoID=123')); let url = embed?.toEmbedUrl(); if (!url) throw new Error('unreachable'); return ( <> -
    - -
    @@ -77,7 +76,7 @@ export function Components() { - + @@ -85,6 +84,66 @@ export function Components() {
    + + + + + + + + + + +
    + + + + + + + + + + + +
    + + + + + + + + + + + +
    + + + + + + + + + + + +
    + + + + + + + + + + + +
    +

    inset=true (mobile)

    @@ -94,7 +153,56 @@ export function Components() { +
    + + + + + + + + +
    + + + + + + + + + +
    + + + + + + + + + +
    + + + + + + + + + +
    + + + + + + + + +
    diff --git a/special-pages/pages/duckplayer/app/components/Components.module.css b/special-pages/pages/duckplayer/app/components/Components.module.css index 9c0b4d88ef..7aa2c776f1 100644 --- a/special-pages/pages/duckplayer/app/components/Components.module.css +++ b/special-pages/pages/duckplayer/app/components/Components.module.css @@ -1,4 +1,5 @@ .main { + background-color: #000; color: white; max-width: 3840px; margin: 0 auto; diff --git a/special-pages/pages/duckplayer/app/components/DesktopApp.jsx b/special-pages/pages/duckplayer/app/components/DesktopApp.jsx index ca4638c4f9..969ed1d45d 100644 --- a/special-pages/pages/duckplayer/app/components/DesktopApp.jsx +++ b/special-pages/pages/duckplayer/app/components/DesktopApp.jsx @@ -1,12 +1,13 @@ import { h, Fragment } from 'preact'; import styles from './DesktopApp.module.css'; -import { Background } from './Background.jsx'; import { InfoBar, InfoBarContainer } from './InfoBar.jsx'; import { PlayerContainer } from './PlayerContainer.jsx'; import { Player, PlayerError } from './Player.jsx'; +import { YouTubeError } from './YouTubeError'; import { useSettings } from '../providers/SettingsProvider.jsx'; import { createAppFeaturesFrom } from '../features/app.js'; import { HideInFocusMode } from './FocusMode.jsx'; +import { useShowCustomError } from '../providers/YouTubeErrorProvider'; /** * @param {object} props @@ -15,11 +16,12 @@ import { HideInFocusMode } from './FocusMode.jsx'; export function DesktopApp({ embed }) { const settings = useSettings(); const features = createAppFeaturesFrom(settings); + const showCustomError = useShowCustomError(); + return ( <> - {features.focusMode()} -
    +
    @@ -31,11 +33,14 @@ export function DesktopApp({ embed }) { * @param {import("../embed-settings.js").EmbedSettings|null} props.embed */ function DesktopLayout({ embed }) { + const showCustomError = useShowCustomError(); + return (
    {embed === null && } - {embed !== null && } + {embed !== null && showCustomError && } + {embed !== null && !showCustomError && } diff --git a/special-pages/pages/duckplayer/app/components/InfoBar.jsx b/special-pages/pages/duckplayer/app/components/InfoBar.jsx index 0c5f6297d1..8ad2bbb051 100644 --- a/special-pages/pages/duckplayer/app/components/InfoBar.jsx +++ b/special-pages/pages/duckplayer/app/components/InfoBar.jsx @@ -11,12 +11,14 @@ import { SwitchContext, SwitchProvider } from '../providers/SwitchProvider.jsx'; import { Tooltip } from './Tooltip.jsx'; import { useSetFocusMode } from './FocusMode.jsx'; import { useTypedTranslation } from '../types.js'; +import { useShowCustomError } from '../providers/YouTubeErrorProvider'; /** * @param {object} props * @param {import("../embed-settings.js").EmbedSettings|null} props.embed */ export function InfoBar({ embed }) { + const showCustomError = useShowCustomError(); return (
    @@ -28,9 +30,11 @@ export function InfoBar({ embed }) {
    -
    - -
    + {!showCustomError && ( +
    + +
    + )}
    @@ -100,6 +104,8 @@ export function InfoIcon({ debugStyles = false }) { function ControlBarDesktop({ embed }) { const settingsUrl = useSettingsUrl(); const openOnYoutube = useOpenOnYoutubeHandler(); + const showCustomError = useShowCustomError(); // When there's a YouTube error, the watch on YouTube button is shown in the error screen instead + const { t } = useTypedTranslation(); const { state } = useContext(SwitchContext); return ( @@ -116,16 +122,18 @@ function ControlBarDesktop({ embed }) { > - + {!showCustomError && ( + + )}
    ); } diff --git a/special-pages/pages/duckplayer/app/components/InfoBar.module.css b/special-pages/pages/duckplayer/app/components/InfoBar.module.css index 05c7001017..770e0ff11e 100644 --- a/special-pages/pages/duckplayer/app/components/InfoBar.module.css +++ b/special-pages/pages/duckplayer/app/components/InfoBar.module.css @@ -7,7 +7,7 @@ .container { padding: 12px; - background: rgba(0, 0, 0, 0.3); + background: rgba(255, 255, 255, 0.12); position: relative; z-index: 1; border-bottom-left-radius: 14px; diff --git a/special-pages/pages/duckplayer/app/components/MobileApp.jsx b/special-pages/pages/duckplayer/app/components/MobileApp.jsx index 3040e8bf39..97d332548e 100644 --- a/special-pages/pages/duckplayer/app/components/MobileApp.jsx +++ b/special-pages/pages/duckplayer/app/components/MobileApp.jsx @@ -1,8 +1,8 @@ import { h, Fragment } from 'preact'; import cn from 'classnames'; import styles from './MobileApp.module.css'; -import { Background } from './Background.jsx'; import { Player, PlayerError } from './Player.jsx'; +import { YouTubeError } from './YouTubeError'; import { usePlatformName, useSettings } from '../providers/SettingsProvider.jsx'; import { SwitchBarMobile } from './SwitchBarMobile.jsx'; import { MobileWordmark } from './Wordmark.jsx'; @@ -12,6 +12,7 @@ import { MobileButtons } from './MobileButtons.jsx'; import { OrientationProvider } from '../providers/OrientationProvider.jsx'; import { FocusMode } from './FocusMode.jsx'; import { useTelemetry } from '../types.js'; +import { useShowCustomError } from '../providers/YouTubeErrorProvider'; const DISABLED_HEIGHT = 450; @@ -22,13 +23,16 @@ const DISABLED_HEIGHT = 450; export function MobileApp({ embed }) { const settings = useSettings(); const telemetry = useTelemetry(); + const showCustomError = useShowCustomError(); const features = createAppFeaturesFrom(settings); + return ( <> - - {features.focusMode()} + {!showCustomError && features.focusMode()} { + if (showCustomError) return; + if (orientation === 'portrait') { return FocusMode.enable(); } @@ -53,23 +57,28 @@ export function MobileApp({ embed }) { */ function MobileLayout({ embed }) { const platformName = usePlatformName(); + const showCustomError = useShowCustomError(); + return ( -
    +
    {embed === null && } - {embed !== null && } + {embed !== null && showCustomError && } + {embed !== null && !showCustomError && }
    - - - + {!showCustomError && ( + + + + )}
    - +
    ); diff --git a/special-pages/pages/duckplayer/app/components/MobileApp.module.css b/special-pages/pages/duckplayer/app/components/MobileApp.module.css index a8429b0d88..d0003c087f 100644 --- a/special-pages/pages/duckplayer/app/components/MobileApp.module.css +++ b/special-pages/pages/duckplayer/app/components/MobileApp.module.css @@ -27,13 +27,15 @@ html[data-focus-mode="on"] .hideInFocus { display: none; } .main { - --bg-color: rgba(0, 0, 0, 0.3); + --bg-color: #222; --logo-spacing: 185px; --gutter-width: 8px; --outer-radius: 16px; --inner-radius: 12px; --logo-width: 157px; --inner-padding: 8px; + --mobile-buttons-padding: 8px; + position: relative; max-width: 100vh; margin: 0 auto; @@ -87,6 +89,12 @@ body:has([data-state="completed"] [aria-checked="true"]) .switch { padding-bottom: 0; border-top-left-radius: var(--outer-radius); border-top-right-radius: var(--outer-radius); + + transition: background-color .3s; + + html[data-focus-mode="on"] & { + transition: none; + } } .logo { @@ -107,6 +115,16 @@ body:has([data-state="completed"] [aria-checked="true"]) .switch { height: 44px; } +.detachedControls { + grid-area: detached; + display: flex; + flex-flow: column; + gap: 8px; + padding: 8px; + background: #2f2f2f; + border-radius: 12px; +} + @media screen and (min-width: 425px) and (max-height: 600px) { .main { /* reset logo positioning */ @@ -216,3 +234,108 @@ body:has([data-state="completed"] [aria-checked="true"]) .switch { justify-content: end; } } + +/* Different layout for YouTube Errors on mobile */ +.main[data-youtube-error="true"] { + @media screen and (max-width: 599px) { + --bg-color: transparent; + --inner-padding: 4px; + + grid-template-areas: + 'logo' + 'gap3' + 'embed' + 'gap4' + 'switch' + 'buttons'; + grid-template-rows: + max-content + 16px + auto + 12px + max-content + max-content; + + & .embed { + background: #2f2f2f; + border-radius: var(--outer-radius); + padding: 4px; + } + + & .buttons { + background: #2f2f2f; + padding: 8px; + + border-top-left-radius: var(--outer-radius); + border-top-right-radius: var(--outer-radius); + transition: all 0.3s; + } + + & .switch { + display: none; + } + + &:has([data-state="completed"]) { + & .buttons { + border-radius: var(--outer-radius); + } + + & .switch { + background: transparent; + max-height: 0; + } + } + } + + /* Hide chrome on smaller screens */ + @media screen and (max-width: 599px) and (max-height: 599px) { + max-width: unset; + + grid-template-rows: + 0 + 0 + auto + 12px + 0 + max-content; + + & .logo, + & .switch { + display: none; + } + + & .buttons { + border-radius: var(--outer-radius); + } + } + + /* Show buttons on landscape */ + @media screen and (min-width: 600px) and (max-height: 450px) { + grid-template-areas: + 'embed' + 'buttons' + 'gap5'; + + grid-template-rows: + auto + max-content + 8px; + + & .buttons { + border-radius: var(--outer-radius); + display: block; + } + } + + /* Sticky buttons on very low heights */ + @media screen and (max-height: 320px) { + & .embed { + overflow-y: auto; + } + + & .buttons { + bottom: 0; + position: sticky; + } + } +} \ No newline at end of file diff --git a/special-pages/pages/duckplayer/app/components/MobileButtons.jsx b/special-pages/pages/duckplayer/app/components/MobileButtons.jsx index 2c6e0bd2d2..74aef96ab8 100644 --- a/special-pages/pages/duckplayer/app/components/MobileButtons.jsx +++ b/special-pages/pages/duckplayer/app/components/MobileButtons.jsx @@ -10,8 +10,9 @@ import cog from '../img/cog.data.svg'; /** * @param {object} props * @param {import("../embed-settings.js").EmbedSettings|null} props.embed + * @param {boolean} [props.accentedWatchButton] */ -export function MobileButtons({ embed }) { +export function MobileButtons({ embed, accentedWatchButton = false }) { const openSettings = useOpenSettingsHandler(); const openInfo = useOpenInfoHandler(); const openOnYoutube = useOpenOnYoutubeHandler(); @@ -38,6 +39,7 @@ export function MobileButtons({ embed }) { +
    + )} +
    +
    + + ); +} diff --git a/special-pages/pages/duckplayer/app/components/YouTubeError.module.css b/special-pages/pages/duckplayer/app/components/YouTubeError.module.css new file mode 100644 index 0000000000..da8aaf137e --- /dev/null +++ b/special-pages/pages/duckplayer/app/components/YouTubeError.module.css @@ -0,0 +1,147 @@ +.error { + --youtube-error-background-color: #141414; + --youtube-error-text-color: #fff; + --youtube-error-text-color-secondary: #ccc; + + align-items: center; + background: var(--youtube-error-background-color); + display: grid; + height: 100%; + justify-items: center; +} + +.error.desktop { + height: var(--frame-height); + overflow: hidden; + position: relative; + z-index: 1; +} + +.error.mobile { + border-radius: var(--inner-radius); + height: 100%; + overflow: auto; + + /* Prevents automatic text resizing */ + text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + + @media screen and (min-width: 600px) and (min-height: 600px) { + aspect-ratio: 16 / 9; + } +} + +.desktop { + border-top-left-radius: var(--outer-radius); + border-top-right-radius: var(--outer-radius); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.container { + column-gap: 24px; + display: flex; + flex-flow: row; + margin: 0; + max-width: 680px; + padding: 0 40px; + row-gap: 4px; +} + +.mobile .container { + flex-flow: column; + padding: 0 24px; + + @media screen and (min-height: 320px) { + margin: 16px 0; + } + + @media screen and (min-width: 375px) and (min-height: 400px) { + margin: 36px 0; + } +} + +.content { + display: flex; + flex-direction: column; + gap: 4px; + margin: 16px 0; + + @media screen and (min-width: 600px) { + margin: 24px 0; + } +} + + +.icon { + align-self: center; + display: flex; + justify-content: center; + + &::before { + content: ' '; + display: block; + background: url('../img/warning-96.data.svg') no-repeat; + height: 96px; + width: 96px; + } + + @media screen and (max-width: 320px) { + display: none; + } + + @media screen and (min-width: 600px) and (min-height: 600px) { + justify-content: start; + + &::before { + background-image: url('../img/warning-128.data.svg'); + height: 96px; + width: 128px; + } + } +} + +.heading { + color: var(--youtube-error-text-color); + font-size: 20px; + font-weight: 700; + line-height: calc(24 / 20); + margin: 0; +} + +.messages { + color: var(--youtube-error-text-color-secondary); + font-size: 16px; + line-height: calc(24 / 16); +} + +div.messages { + display: flex; + flex-direction: column; + gap: 24px; + + & p { + margin: 0; + } +} + +p.messages { + margin: 0; +} + +ul.messages { + li { + list-style: disc; + margin-left: 24px; + } +} + +.buttons { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.buttons .spacer { + flex: 0 0 16px; +} \ No newline at end of file diff --git a/special-pages/pages/duckplayer/app/embed-settings.js b/special-pages/pages/duckplayer/app/embed-settings.js index 7120e5eeb3..59060aaf45 100644 --- a/special-pages/pages/duckplayer/app/embed-settings.js +++ b/special-pages/pages/duckplayer/app/embed-settings.js @@ -71,6 +71,7 @@ export class EmbedSettings { url.searchParams.set('rel', '0'); // shows related videos from the same channel as the video url.searchParams.set('modestbranding', '1'); // disables showing the YouTube logo in the video control bar + url.searchParams.set('color', 'white'); // Forces legacy YouTube player UI if (this.timestamp && this.timestamp.seconds > 0) { url.searchParams.set('start', String(this.timestamp.seconds)); // if timestamp supplied, start video at specific point diff --git a/special-pages/pages/duckplayer/app/features/error-detection.js b/special-pages/pages/duckplayer/app/features/error-detection.js new file mode 100644 index 0000000000..18ebcfc029 --- /dev/null +++ b/special-pages/pages/duckplayer/app/features/error-detection.js @@ -0,0 +1,88 @@ +import { YOUTUBE_ERROR_EVENT, checkForError, getErrorType } from '../../../../../injected/src/features/duckplayer-native/youtube-errors.js'; + +/** + * @typedef {import("./iframe").IframeFeature} IframeFeature + * @typedef {import('../../types/duckplayer').YouTubeError} YouTubeError + * @typedef {import('../../types/duckplayer').CustomErrorSettings} CustomErrorSettings + */ + +/** + * Detects YouTube errors based on DOM queries + * + * @implements IframeFeature + */ +export class ErrorDetection { + /** @type {HTMLIFrameElement} */ + iframe; + + /** @type {CustomErrorSettings} */ + options; + + /** + * @param {CustomErrorSettings} options + */ + constructor(options) { + this.options = options; + this.errorSelector = options?.settings?.youtubeErrorSelector || '.ytp-error'; + console.log('options', options); + } + + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad(iframe) { + this.iframe = iframe; + + if (this.options?.state !== 'enabled') { + console.log('Error detection disabled'); + return null; + } + + const contentWindow = iframe.contentWindow; + const documentBody = contentWindow?.document?.body; + if (contentWindow && documentBody) { + // Check if iframe already contains error + if (checkForError(this.errorSelector, documentBody)) { + const error = getErrorType(contentWindow, this.options.settings?.signInRequiredSelector); + window.dispatchEvent(new CustomEvent(YOUTUBE_ERROR_EVENT, { detail: { error } })); + + return null; + } + + // Create a MutationObserver instance + const observer = new MutationObserver(this.handleMutation.bind(this)); + + // Start observing the iframe's document for changes + observer.observe(documentBody, { + childList: true, + subtree: true, // Observe all descendants of the body + }); + + return () => { + observer.disconnect(); + }; + } + + return null; + } + + /** + * Mutation handler that checks new nodes for error states + * + * @type {MutationCallback} + */ + handleMutation(mutationsList) { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (checkForError(this.errorSelector, node)) { + console.log('A node with an error has been added to the document:', node); + const error = getErrorType(this.iframe.contentWindow, this.options.settings?.signInRequiredSelector); + + window.dispatchEvent(new CustomEvent(YOUTUBE_ERROR_EVENT, { detail: { error } })); + } + }); + } + } + } +} diff --git a/special-pages/pages/duckplayer/app/features/iframe.js b/special-pages/pages/duckplayer/app/features/iframe.js index b252724b2a..ac222778f4 100644 --- a/special-pages/pages/duckplayer/app/features/iframe.js +++ b/special-pages/pages/duckplayer/app/features/iframe.js @@ -3,6 +3,12 @@ import { AutoFocus } from './autofocus.js'; import { ClickCapture } from './click-capture.js'; import { TitleCapture } from './title-capture.js'; import { MouseCapture } from './mouse-capture.js'; +import { ErrorDetection } from './error-detection.js'; +import { ReplaceWatchLinks } from './replace-watch-links.js'; + +/** + * @import {EmbedSettings} from '../embed-settings.js'; + */ /** * Represents an individual piece of functionality in the iframe. @@ -11,11 +17,11 @@ import { MouseCapture } from './mouse-capture.js'; */ export class IframeFeature { /** - * @param {HTMLIFrameElement} iframe + * @param {HTMLIFrameElement} _iframe * @returns {(() => void) | null} */ - iframeDidLoad(iframe) { + iframeDidLoad(_iframe) { return () => { console.log('teardown'); }; @@ -35,9 +41,9 @@ export class IframeFeature { * global `Settings` * * @param {import("../settings").Settings} settings - * @returns {Record IframeFeature>} + * @param {EmbedSettings} embed */ -export function createIframeFeatures(settings) { +export function createIframeFeatures(settings, embed) { return { /** * @return {IframeFeature} @@ -74,5 +80,18 @@ export function createIframeFeatures(settings) { mouseCapture: () => { return new MouseCapture(); }, + /** + * @return {IframeFeature} + */ + errorDetection: () => { + return new ErrorDetection(settings.customError); + }, + /** + * @param {() => void} handler - what to invoke when a watch-link was clicked + * @return {IframeFeature} + */ + replaceWatchLinks: (handler) => { + return new ReplaceWatchLinks(embed.videoId.id, handler); + }, }; } diff --git a/special-pages/pages/duckplayer/app/features/replace-watch-links.js b/special-pages/pages/duckplayer/app/features/replace-watch-links.js new file mode 100644 index 0000000000..488ade3301 --- /dev/null +++ b/special-pages/pages/duckplayer/app/features/replace-watch-links.js @@ -0,0 +1,64 @@ +/** + * @typedef {import("./iframe").IframeFeature} IframeFeature + */ + +import { VideoParams } from 'injected/src/features/duckplayer/util'; + +/** + * @implements IframeFeature + */ +export class ReplaceWatchLinks { + /** + * @param {string} videoId + * @param {() => void} handler - what to invoke when a watch-link was clicked + */ + constructor(videoId, handler) { + this.videoId = videoId; + this.handler = handler; + } + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad(iframe) { + const doc = iframe.contentDocument; + const win = iframe.contentWindow; + + if (!doc) { + console.log('could not access contentDocument'); + return () => {}; + } + + if (win && doc) { + doc.addEventListener( + 'click', + (e) => { + if (!(e.target instanceof /** @type {any} */ (win).Element)) return; + + /** @type {HTMLLinkElement|null} */ + const closestLink = /** @type {Element} */ (e.target).closest('a[href]'); + if (closestLink && this.isWatchLink(closestLink.href)) { + e.preventDefault(); + e.stopPropagation(); + this.handler(); + } + }, + { + capture: true, + }, + ); + } else { + console.warn('could not access iframe?.contentWindow && iframe?.contentDocument'); + } + + return null; + } + + /** + * @param {string} href + * @return {boolean} + */ + isWatchLink(href) { + const videoParams = VideoParams.forWatchPage(href); + return videoParams?.id === this.videoId; + } +} diff --git a/special-pages/pages/duckplayer/app/img/warning-128.data.svg b/special-pages/pages/duckplayer/app/img/warning-128.data.svg new file mode 100644 index 0000000000..b0392efdce --- /dev/null +++ b/special-pages/pages/duckplayer/app/img/warning-128.data.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/special-pages/pages/duckplayer/app/img/warning-96.data.svg b/special-pages/pages/duckplayer/app/img/warning-96.data.svg new file mode 100644 index 0000000000..af6bc6fbe9 --- /dev/null +++ b/special-pages/pages/duckplayer/app/img/warning-96.data.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/special-pages/pages/duckplayer/app/index.css b/special-pages/pages/duckplayer/app/index.css index f1c55304d6..b8dcd4ba8e 100644 --- a/special-pages/pages/duckplayer/app/index.css +++ b/special-pages/pages/duckplayer/app/index.css @@ -9,7 +9,7 @@ html:has(body[data-display="app"]) { body[data-display="app"] { color: rgba(242, 242, 242, 1); - background: #101010; + background: #000; height: 100vh; overflow: hidden; padding: 16px; diff --git a/special-pages/pages/duckplayer/app/index.js b/special-pages/pages/duckplayer/app/index.js index da81d0f8b9..9b604739b1 100644 --- a/special-pages/pages/duckplayer/app/index.js +++ b/special-pages/pages/duckplayer/app/index.js @@ -14,6 +14,9 @@ import { Fallback } from '../../../shared/components/Fallback/Fallback.jsx'; import { Components } from './components/Components.jsx'; import { MobileApp } from './components/MobileApp.jsx'; import { DesktopApp } from './components/DesktopApp.jsx'; +import { YouTubeErrorProvider } from './providers/YouTubeErrorProvider'; + +/** @typedef {import('../types/duckplayer').YouTubeError} YouTubeError */ /** * @param {import("../src/index.js").DuckplayerPage} messaging @@ -55,7 +58,11 @@ export async function init(messaging, telemetry, baseEnvironment) { .withFeatureState('pip', init.settings.pip) .withFeatureState('autoplay', init.settings.autoplay) .withFeatureState('focusMode', init.settings.focusMode) - .withDisabledFocusMode(baseEnvironment.urlParams.get('focusMode')); + .withFeatureState('customError', init.settings.customError) + .withDisabledFocusMode(baseEnvironment.urlParams.get('focusMode')) + .withCustomError(baseEnvironment.urlParams.get('customError')); + + const initialYouTubeError = /** @type {YouTubeError} */ (baseEnvironment.urlParams.get('youtubeError')); console.log(settings); @@ -79,27 +86,29 @@ export async function init(messaging, telemetry, baseEnvironment) { - - {settings.layout === 'desktop' && ( - - - - )} - {settings.layout === 'mobile' && ( - - - - )} - - + + + {settings.layout === 'desktop' && ( + + + + )} + {settings.layout === 'mobile' && ( + + + + )} + + + diff --git a/special-pages/pages/duckplayer/app/providers/SettingsProvider.jsx b/special-pages/pages/duckplayer/app/providers/SettingsProvider.jsx index a91cdb9683..f7722072b1 100644 --- a/special-pages/pages/duckplayer/app/providers/SettingsProvider.jsx +++ b/special-pages/pages/duckplayer/app/providers/SettingsProvider.jsx @@ -3,10 +3,13 @@ import { createContext } from 'preact'; import { Settings } from '../settings'; import { useContext } from 'preact/hooks'; import { useMessaging } from '../types.js'; -import { EmbedSettings } from '../embed-settings'; const SettingsContext = createContext(/** @type {{settings: Settings}} */ ({})); +/** + * @import {EmbedSettings} from '../embed-settings.js'; + */ + /** * @param {object} params * @param {Settings} params.settings diff --git a/special-pages/pages/duckplayer/app/providers/YouTubeErrorProvider.jsx b/special-pages/pages/duckplayer/app/providers/YouTubeErrorProvider.jsx new file mode 100644 index 0000000000..b862e290e5 --- /dev/null +++ b/special-pages/pages/duckplayer/app/providers/YouTubeErrorProvider.jsx @@ -0,0 +1,73 @@ +import { useContext, useState } from 'preact/hooks'; +import { h, createContext } from 'preact'; +import { useEffect } from 'preact/hooks'; +import { useMessaging } from '../types'; +import { useSetFocusMode } from '../components/FocusMode'; +import { YOUTUBE_ERROR_IDS, YOUTUBE_ERROR_EVENT } from '../../../../../injected/src/features/duckplayer-native/youtube-errors.js'; +import { useSettings } from './SettingsProvider'; + +/** + * @import {YouTubeError} from '../../types/duckplayer' + */ + +const YouTubeErrorContext = createContext({ + /** @type {YouTubeError|null} */ + error: null, + /** @type {string} - Enables showing different error messages based on locale */ + locale: 'en', +}); + +/** + * @param {object} props + * @param {YouTubeError|null} [props.initial=null] + * @param {string} props.locale + * @param {import("preact").ComponentChild} props.children + */ +export function YouTubeErrorProvider({ initial = null, locale, children }) { + // initial state + let initialError = null; + if (initial && YOUTUBE_ERROR_IDS.includes(initial)) { + initialError = initial; + } + const [error, setError] = useState(initialError); + + const messaging = useMessaging(); + const setFocusMode = useSetFocusMode(); + + // listen for updates + useEffect(() => { + /** @type {(event: CustomEvent) => void} */ + const errorEventHandler = (event) => { + const eventError = event.detail?.error; + if (YOUTUBE_ERROR_IDS.includes(eventError) || eventError === null) { + if (eventError && eventError !== error) { + setFocusMode('paused'); + messaging.reportYouTubeError({ error: eventError }); + } else { + setFocusMode('enabled'); + } + setError(eventError); + } + }; + + window.addEventListener(YOUTUBE_ERROR_EVENT, errorEventHandler); + + return () => window.removeEventListener(YOUTUBE_ERROR_EVENT, errorEventHandler); + }, []); + + return {children}; +} + +export function useYouTubeError() { + return useContext(YouTubeErrorContext).error; +} + +export function useLocale() { + return useContext(YouTubeErrorContext).locale; +} + +export function useShowCustomError() { + const settings = useSettings(); + const youtubeError = useContext(YouTubeErrorContext).error; + return youtubeError !== null && settings.customError?.state === 'enabled'; +} diff --git a/special-pages/pages/duckplayer/app/settings.js b/special-pages/pages/duckplayer/app/settings.js index f0f52adc70..43e06ed147 100644 --- a/special-pages/pages/duckplayer/app/settings.js +++ b/special-pages/pages/duckplayer/app/settings.js @@ -1,3 +1,5 @@ +const DEFAULT_SIGN_IN_REQURED_HREF = '[href*="//support.google.com/youtube/answer/3037019"]'; + export class Settings { /** * @param {object} params @@ -5,17 +7,56 @@ export class Settings { * @param {{state: 'enabled' | 'disabled'}} [params.pip] * @param {{state: 'enabled' | 'disabled'}} [params.autoplay] * @param {{state: 'enabled' | 'disabled'}} [params.focusMode] + * @param {import("../types/duckplayer.js").DuckPlayerPageSettings['customError']} [params.customError] */ constructor({ platform = { name: 'macos' }, pip = { state: 'disabled' }, autoplay = { state: 'enabled' }, focusMode = { state: 'enabled' }, + customError = { state: 'disabled', settings: {}, signInRequiredSelector: '' }, }) { this.platform = platform; this.pip = pip; this.autoplay = autoplay; this.focusMode = focusMode; + this.customError = this.parseLegacyCustomError(customError); + } + + /** + * Parses custom error settings so that both old and new schemas are accepted. + * + * Old schema: + * { + * state: "enabled", + * signInRequiredSelector: "div" + * } + * + * New schema: + * { + * state: "disabled", + * settings: { + * signInRequiredSelector: "div" + * } + * } + * + * @param {import("../types/duckplayer.js").DuckPlayerPageSettings['customError']} initialSettings + * @return {import("../types/duckplayer.js").CustomErrorSettings} + */ + parseLegacyCustomError(initialSettings) { + if (initialSettings?.state !== 'enabled') { + return { state: 'disabled' }; + } + + const { settings, signInRequiredSelector } = initialSettings; + + return { + state: 'enabled', + settings: { + ...settings, + ...(signInRequiredSelector && { signInRequiredSelector }), + }, + }; } /** @@ -26,7 +67,7 @@ export class Settings { withFeatureState(named, settings) { if (!settings) return this; /** @type {(keyof import("../types/duckplayer.js").DuckPlayerPageSettings)[]} */ - const valid = ['pip', 'autoplay', 'focusMode']; + const valid = ['pip', 'autoplay', 'focusMode', 'customError']; if (!valid.includes(named)) { console.warn(`Excluding invalid feature key ${named}`); return this; @@ -68,6 +109,31 @@ export class Settings { return this; } + /** + * @param {string|null|undefined} newState + * @return {Settings} + */ + withCustomError(newState) { + if (newState === 'disabled') { + return new Settings({ + ...this, + customError: { state: 'disabled' }, + }); + } + + if (newState === 'enabled') { + return new Settings({ + ...this, + customError: { + state: 'enabled', + signOnRequiredSelector: DEFAULT_SIGN_IN_REQURED_HREF, + }, + }); + } + + return this; + } + /** * @return {string} */ diff --git a/special-pages/pages/duckplayer/integration-tests/duck-player.js b/special-pages/pages/duckplayer/integration-tests/duck-player.js index 4ea48c3ee6..51460d6f6a 100644 --- a/special-pages/pages/duckplayer/integration-tests/duck-player.js +++ b/special-pages/pages/duckplayer/integration-tests/duck-player.js @@ -5,9 +5,10 @@ import { perPlatform } from 'injected/integration-test/type-helpers.mjs'; const MOCK_VIDEO_ID = 'VIDEO_ID'; const MOCK_VIDEO_TITLE = 'Embedded Video - YouTube'; -const youtubeEmbed = (id) => 'https://www.youtube-nocookie.com/embed/' + id + '?iv_load_policy=1&autoplay=1&rel=0&modestbranding=1'; +const youtubeEmbed = (id) => + 'https://www.youtube-nocookie.com/embed/' + id + '?iv_load_policy=1&autoplay=1&rel=0&modestbranding=1&color=white'; const youtubeEmbedIOS = (id) => - 'https://www.youtube-nocookie.com/embed/' + id + '?iv_load_policy=1&autoplay=1&muted=1&rel=0&modestbranding=1'; + 'https://www.youtube-nocookie.com/embed/' + id + '?iv_load_policy=1&autoplay=1&muted=1&rel=0&modestbranding=1&color=white'; const html = { unsupported: `${MOCK_VIDEO_TITLE} @@ -22,6 +23,26 @@ const html = { +`, + signInRequired: `${MOCK_VIDEO_TITLE} + + + `, }; @@ -156,6 +177,14 @@ export class DuckPlayerPage { }); } + if (urlParams.get('videoID') === 'SIGN_IN_REQUIRED') { + return request.fulfill({ + status: 200, + body: html.signInRequired, + contentType: 'text/html', + }); + } + const mp4VideoPlaceholderAsDataURI = 'data:video/mp4;base64,AAAAHGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAwFtZGF0AAACogYF//+b3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE1MiByMjg1NCBlMjA5YTFjIC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzowMTMzIHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02MyBsb29rYWhlYWRfdGhyZWFkcz0yIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTMgYl9weXJhbWlkPTIgYl9hZGFwdD1xLTIgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtleWludD0yNTAga2V5aW50X21pbj0yNSBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmM9bG9va2FoZWFkIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCB2YnY9MCBjbG9zZWRfZ29wPTAgY3V0X3Rocm91Z2g9MCAnbm8tZGlndHMuanBnLTFgcC1mbHWinS3SlB8AP0AAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABSAAAAAAAAAAAAAAAAAAABBZHJ0AAAAAAAAAA=='; return request.fulfill({ @@ -214,6 +243,17 @@ export class DuckPlayerPage { await this.openPage(params); } + /** + * @param {import('../types/duckplayer.ts').YouTubeError} [youtubeError] + * @param {string} [videoID] + * @param {string} [locale] + * @returns {Promise} + */ + async openWithYouTubeError(youtubeError = 'unknown', videoID = 'e90eWYPNtJ8', locale = 'en') { + const params = new URLSearchParams({ youtubeError, videoID, customError: 'enabled', focusMode: 'disabled', locale }); + await this.openPage(params); + } + async openWithException() { const params = new URLSearchParams({ willThrow: String(true) }); await this.openPage(params); @@ -306,8 +346,12 @@ export class DuckPlayerPage { await expect(this.page.locator('iframe')).toHaveAttribute('src', expected); } - async hasShownErrorMessage() { - await expect(this.page.getByText('ERROR: Invalid video id')).toBeVisible(); + /** + * + * @param {string} text + */ + async hasShownErrorMessage(text = 'ERROR: Invalid video id') { + await expect(this.page.getByText(text)).toBeVisible(); } async hasNotAddedIframe() { @@ -396,6 +440,40 @@ export class DuckPlayerPage { }); } + async opensDuckPlayerYouTubeLinkFromError({ videoID = 'UNSUPPORTED' }) { + const action = () => this.page.getByRole('button', { name: 'Watch on YouTube' }).click(); + await this.build.switch({ + windows: async () => { + const failure = new Promise((resolve) => { + this.page.context().on('requestfailed', (f) => { + resolve(f.url()); + }); + }); + await action(); + expect(await failure).toEqual(`duck://player/openInYoutube?v=${videoID}`); + }, + apple: async () => { + if (this.platform.name === 'ios') { + // todo: why does this not work on ios?? + await action(); + return; + } + await action(); + await this.page.waitForURL(`https://www.youtube.com/watch?v=${videoID}`); + }, + android: async () => { + // const failure = new Promise(resolve => { + // this.page.context().on('requestfailed', f => { + // resolve(f.url()) + // }) + // }) + // todo: why does this not work on android? + await action(); + // expect(await failure).toEqual(`duck://player/openInYoutube?v=${videoID}`) + }, + }); + } + /** * @return {Promise} */ @@ -482,7 +560,7 @@ export class DuckPlayerPage { return this.build.switch({ windows: () => '../build/windows/pages/duckplayer', android: () => '../build/android/pages/duckplayer', - apple: () => '../Sources/ContentScopeScripts/dist/pages/duckplayer', + apple: () => '../build/apple/pages/duckplayer', }); } @@ -536,4 +614,69 @@ export class DuckPlayerPage { const { page } = this; await page.getByRole('button', { name: 'Open Info' }).click(); } + + /* Aria Snapshots */ + async didShowGenericError() { + await expect(this.page.getByTestId('YouTubeErrorContent')).toMatchAriaSnapshot(` + - heading "Duck Player can’t load this video" [level=1] + - paragraph: This video can’t be viewed outside of YouTube. + - paragraph: You can still watch this video on YouTube, but without the added privacy of Duck Player. + `); + } + + async didShowGenericErrorInSpanish() { + await expect(this.page.getByTestId('YouTubeErrorContent')).toMatchAriaSnapshot(` + - heading "Duck Player no puede cargar este vídeo" [level=1] + - paragraph: Este vídeo no se puede ver fuera de YouTube. + - paragraph: Sigues pudiendo ver este vídeo en YouTube, pero sin la privacidad adicional que ofrece Duck Player. + `); + } + + async didShowAgeRestrictedError() { + await expect(this.page.getByTestId('YouTubeErrorContent')).toMatchAriaSnapshot(` + - heading "Sorry, this video is age-restricted" [level=1] + - paragraph: To watch age-restricted videos, you need to sign in to YouTube to verify your age. + - paragraph: You can still watch this video, but you’ll have to sign in and watch it on YouTube without the added privacy of Duck Player. + `); + } + + async didShowAgeRestrictedErrorInSpanish() { + await expect(this.page.getByTestId('YouTubeErrorContent')).toMatchAriaSnapshot(` + - heading "Lo sentimos, este vídeo está restringido por edad" [level=1] + - paragraph: Para ver vídeos con restricción de edad, necesitas iniciar sesión en YouTube para verificar tu edad. + - paragraph: Todavía puedes ver este vídeo, pero tendrás que iniciar sesión y verlo en YouTube sin la privacidad adicional de Duck Player. + `); + } + + async didShowNoEmbedError() { + await expect(this.page.getByTestId('YouTubeErrorContent')).toMatchAriaSnapshot(` + - heading "Sorry, this video can only be played on YouTube" [level=1] + - paragraph: The creator of this video has chosen not to allow it to be viewed on other sites. + - paragraph: You can still watch it on YouTube, but without the added privacy of Duck Player. + `); + } + + async didShowNoEmbedErrorInSpanish() { + await expect(this.page.getByTestId('YouTubeErrorContent')).toMatchAriaSnapshot(` + - heading "Lo sentimos, este vídeo solo se puede reproducir en YouTube" [level=1] + - paragraph: El creador de este vídeo ha decidido no permitir que se vea en otros sitios. + - paragraph: Sigues pudiendo verlo en YouTube, pero sin la privacidad adicional que ofrece Duck Player. + `); + } + + async didShowSignInRequiredError() { + await expect(this.page.getByTestId('YouTubeErrorContent')).toMatchAriaSnapshot(` + - heading "Sorry, YouTube thinks you’re a bot" [level=1] + - paragraph: This can happen if you’re using a VPN. Try turning the VPN off or switching server locations and reloading this page. + - paragraph: If that doesn’t work, you’ll have to sign in and watch this video on YouTube without the added privacy of Duck Player. + `); + } + + async didShowSignInRequiredErrorInSpanish() { + await expect(this.page.getByTestId('YouTubeErrorContent')).toMatchAriaSnapshot(` + - heading "Lo sentimos, YouTube piensa que eres un bot" [level=1] + - paragraph: Esto puede ocurrir si estás usando una VPN. Intenta desactivar la VPN o cambiar la ubicación del servidor y recarga la página. + - paragraph: Si eso no funciona, tendrás que iniciar sesión y ver el vídeo en YouTube sin la privacidad adicional que ofrece Duck Player. + `); + } } diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js index 41485178df..eac374476b 100644 --- a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js +++ b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js @@ -1,9 +1,7 @@ -/* global process */ import { expect, test } from '@playwright/test'; import { DuckPlayerPage } from './duck-player.js'; test.describe('screenshots @screenshots', () => { - test.skip(process.env.CI === 'true'); test('regular layout', async ({ page }, workerInfo) => { const duckplayer = DuckPlayerPage.create(page, workerInfo); // load as normal @@ -26,6 +24,34 @@ test.describe('screenshots @screenshots', () => { await duckplayer.hasShownErrorMessage(); await expect(page).toHaveScreenshot('error-layout.png', { maxDiffPixels: 20 }); }); + test('youtube sign-in error', async ({ page }, workerInfo) => { + test.skip(!isApple(workerInfo)); + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('sign-in-required'); + await duckplayer.didShowSignInRequiredError(); + await expect(page).toHaveScreenshot('youtube-error-sign-in-required.png', { maxDiffPixels: 20 }); + }); + test('youtube age-restricted error', async ({ page }, workerInfo) => { + test.skip(!isApple(workerInfo)); + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('age-restricted'); + await duckplayer.didShowAgeRestrictedError(); + await expect(page).toHaveScreenshot('youtube-error-age-restricted.png', { maxDiffPixels: 20 }); + }); + test('youtube no-embed error', async ({ page }, workerInfo) => { + test.skip(!isApple(workerInfo)); + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('no-embed'); + await duckplayer.didShowNoEmbedError(); + await expect(page).toHaveScreenshot('youtube-error-no-embed.png', { maxDiffPixels: 20 }); + }); + test('youtube generic error', async ({ page }, workerInfo) => { + test.skip(!isApple(workerInfo)); + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('unknown'); + await duckplayer.didShowGenericError(); + await expect(page).toHaveScreenshot('youtube-error-unknown.png', { maxDiffPixels: 20 }); + }); test('tooltip shown on hover', async ({ page }, workerInfo) => { test.skip(isMobile(workerInfo)); const duckplayer = DuckPlayerPage.create(page, workerInfo); @@ -45,3 +71,11 @@ function isMobile(testInfo) { const u = /** @type {any} */ (testInfo.project.use); return u?.platform === 'android' || u?.platform === 'ios'; } + +/** + * @param {import("@playwright/test").TestInfo} testInfo + */ +function isApple(testInfo) { + const u = /** @type {any} */ (testInfo.project.use); + return u?.platform === 'macos' || u?.platform === 'ios'; +} diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-darwin.png index 8e68f9f1df..5064a26244 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-android-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-ios-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-ios-darwin.png index 1e19b10c6a..a4a09e2bd9 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-ios-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-ios-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-macos-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-macos-darwin.png index f8880ab999..559ad99a5f 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-macos-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-macos-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-windows-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-windows-darwin.png index 3e78d3c6bf..c5cf27b86a 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-windows-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/enabled-layout-windows-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-darwin.png index 27c786f358..5190305cd0 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-android-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-ios-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-ios-darwin.png index c00bedf088..655b0337d0 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-ios-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-ios-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-macos-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-macos-darwin.png index 2715e14e69..aab6356ed0 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-macos-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-macos-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-windows-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-windows-darwin.png index 455b28d1ab..f1b84bb421 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-windows-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/error-layout-windows-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-darwin.png index ec5792c059..bfc3752026 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-android-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-ios-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-ios-darwin.png index a328d5e6e3..31c18dbc13 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-ios-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-ios-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-macos-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-macos-darwin.png index 0d8c5e9220..2f6bb490c0 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-macos-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-macos-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-windows-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-windows-darwin.png index 613ae613ed..da07c19310 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-windows-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/regular-layout-windows-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/tooltip-macos-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/tooltip-macos-darwin.png index 1427668599..adf321527d 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/tooltip-macos-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/tooltip-macos-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/tooltip-windows-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/tooltip-windows-darwin.png index 7b9a4472c5..4523e15987 100644 Binary files a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/tooltip-windows-darwin.png and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/tooltip-windows-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-age-restricted-ios-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-age-restricted-ios-darwin.png new file mode 100644 index 0000000000..7e2773931f Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-age-restricted-ios-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-age-restricted-macos-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-age-restricted-macos-darwin.png new file mode 100644 index 0000000000..cda6022ce1 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-age-restricted-macos-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-no-embed-ios-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-no-embed-ios-darwin.png new file mode 100644 index 0000000000..5812c82ebb Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-no-embed-ios-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-no-embed-macos-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-no-embed-macos-darwin.png new file mode 100644 index 0000000000..90bcc9a285 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-no-embed-macos-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-ios-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-ios-darwin.png new file mode 100644 index 0000000000..c0affdd601 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-ios-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-macos-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-macos-darwin.png new file mode 100644 index 0000000000..1668b8072b Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-sign-in-required-macos-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-ios-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-ios-darwin.png new file mode 100644 index 0000000000..9fbcd05f2c Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-ios-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-macos-darwin.png b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-macos-darwin.png new file mode 100644 index 0000000000..353dd40ad6 Binary files /dev/null and b/special-pages/pages/duckplayer/integration-tests/duckplayer-screenshots.spec.js-snapshots/youtube-error-unknown-macos-darwin.png differ diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js b/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js index d2aa8456dc..7d3a1155e1 100644 --- a/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js +++ b/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js @@ -96,6 +96,73 @@ test.describe('duckplayer iframe', () => { }); }); +test.describe('duckplayer custom error', () => { + test('shows custom error screen for videos that require sign-in', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('sign-in-required', 'e90eWYPNtJ8'); + await duckplayer.didShowSignInRequiredError(); + }); + test('supports "watch on youtube" for videos that require sign-in', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('sign-in-required', 'e90eWYPNtJ8'); + await duckplayer.opensDuckPlayerYouTubeLinkFromError({ videoID: 'e90eWYPNtJ8' }); + }); + test('shows custom error screen for videos that are age-restricted', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('age-restricted', 'e90eWYPNtJ8'); + await duckplayer.didShowAgeRestrictedError(); + }); + test('supports "watch on youtube" for videos that are age-restricted', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('age-restricted', 'e90eWYPNtJ8'); + await duckplayer.opensDuckPlayerYouTubeLinkFromError({ videoID: 'e90eWYPNtJ8' }); + }); + test('shows custom error screen for videos that can’t be embedded', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('no-embed', 'e90eWYPNtJ8'); + await duckplayer.didShowNoEmbedError(); + }); + test('supports "watch on youtube" for videos that can’t be embedded', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('no-embed', 'e90eWYPNtJ8'); + await duckplayer.opensDuckPlayerYouTubeLinkFromError({ videoID: 'e90eWYPNtJ8' }); + }); + test('shows custom error screen for videos with unknown errors', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('unknown', 'e90eWYPNtJ8'); + await duckplayer.didShowGenericError(); + }); + test('supports "watch on youtube" for videos with unknown errors', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('unknown', 'e90eWYPNtJ8'); + await duckplayer.opensDuckPlayerYouTubeLinkFromError({ videoID: 'e90eWYPNtJ8' }); + }); + test('shows custom error screen, in Spanish, for videos that require sign-in', async ({ page }, workerInfo) => { + test.skip(isDesktop(workerInfo)); + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('sign-in-required', 'e90eWYPNtJ8', 'es'); + await duckplayer.didShowSignInRequiredErrorInSpanish(); + }); + test('shows custom error screen, in Spanish, for videos that are age-restricted', async ({ page }, workerInfo) => { + test.skip(isDesktop(workerInfo)); + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('age-restricted', 'e90eWYPNtJ8', 'es'); + await duckplayer.didShowAgeRestrictedErrorInSpanish(); + }); + test('shows custom error screen, in Spanish, for videos that can’t be embedded', async ({ page }, workerInfo) => { + test.skip(isDesktop(workerInfo)); + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('no-embed', 'e90eWYPNtJ8', 'es'); + await duckplayer.didShowNoEmbedErrorInSpanish(); + }); + test('shows custom error screen, in Spanish, for videos with unknown errors', async ({ page }, workerInfo) => { + test.skip(isDesktop(workerInfo)); + const duckplayer = DuckPlayerPage.create(page, workerInfo); + await duckplayer.openWithYouTubeError('unknown', 'e90eWYPNtJ8', 'es'); + await duckplayer.didShowGenericErrorInSpanish(); + }); +}); + test.describe('duckplayer toolbar', () => { test('hides toolbar based on user activity', async ({ page }, workerInfo) => { test.skip(isMobile(workerInfo)); diff --git a/special-pages/pages/duckplayer/messages/customErrorSettings.shared.json b/special-pages/pages/duckplayer/messages/customErrorSettings.shared.json new file mode 100644 index 0000000000..5fc90b6f03 --- /dev/null +++ b/special-pages/pages/duckplayer/messages/customErrorSettings.shared.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CustomErrorSettings", + "type": "object", + "description": "Configures a custom error message for YouTube errors", + "required": ["state"], + "properties": { + "state": { + "type": "string", + "enum": ["enabled", "disabled"] + }, + "settings": { + "type": "object", + "description": "Custom error settings", + "properties": { + "signInRequiredSelector": { + "description": "A selector that, when not empty, indicates a sign-in required error", + "type": "string" + }, + "youtubeErrorSelector": { + "description": "A selector that, when not empty, indicates a general YouTube error", + "type": "string" + } + } + } + } +} diff --git a/special-pages/pages/duckplayer/messages/initialSetup.response.json b/special-pages/pages/duckplayer/messages/initialSetup.response.json index 75a99ca5cd..f04ce739fb 100644 --- a/special-pages/pages/duckplayer/messages/initialSetup.response.json +++ b/special-pages/pages/duckplayer/messages/initialSetup.response.json @@ -38,6 +38,20 @@ "enum": ["enabled", "disabled"] } } + }, + "customError": { + "allOf": [ + { "$ref": "customErrorSettings.shared.json" }, + { + "properties": { + "signInRequiredSelector": { + "type": "string", + "description": "A selector that, when not empty, indicates a sign-in required error", + "$comment": "This setting is duplicated at the top level until Apple and Windows are migrated to the settings object above" + } + } + } + ] } } }, diff --git a/special-pages/pages/duckplayer/messages/reportYouTubeError.notify.json b/special-pages/pages/duckplayer/messages/reportYouTubeError.notify.json new file mode 100644 index 0000000000..f7b5b4c4b7 --- /dev/null +++ b/special-pages/pages/duckplayer/messages/reportYouTubeError.notify.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["error"], + "properties": { + "error": { "$ref": "youtubeError.shared.json"} + } +} diff --git a/special-pages/pages/duckplayer/messages/youtubeError.shared.json b/special-pages/pages/duckplayer/messages/youtubeError.shared.json new file mode 100644 index 0000000000..4616843e4b --- /dev/null +++ b/special-pages/pages/duckplayer/messages/youtubeError.shared.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "YouTubeError", + "type": "string", + "enum": ["age-restricted", "sign-in-required", "no-embed", "unknown"] +} diff --git a/special-pages/pages/duckplayer/public/locales/bg/duckplayer.json b/special-pages/pages/duckplayer/public/locales/bg/duckplayer.json index d7f3d20ef7..c4e0e078ef 100644 --- a/special-pages/pages/duckplayer/public/locales/bg/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/bg/duckplayer.json @@ -32,6 +32,74 @@ "title" : "ГРЕШКА: невалиден идентификатор на видеоклипа", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player не може да зареди това видео", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Това видео не може да бъде гледано извън YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Все пак можете да гледате това видео в YouTube, но без допълнителната поверителност на Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Съжаляваме, но това видео има възрастово ограничение", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "За да гледате видеа с възрастово ограничение, трябва да влезете в YouTube, за да потвърдите възрастта си.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Все пак можете да гледате това видео, но ще трябва да влезете и да го гледате в YouTube без допълнителната поверителност на Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Съжаляваме, но това видео може да се гледа само в YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Създателят на това видео е избрал да не позволява то да бъде гледано на други сайтове.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Все пак можете да го гледате в YouTube, но без допълнителната поверителност на Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube няма да позволи на Duck Player да зареди това видео", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube не позволява това видео да бъде гледано извън YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Все пак можете да гледате това видео в YouTube, но без допълнителната поверителност на Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Съжаляваме, но YouTube мисли, че сте бот", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube блокира зареждането на това видео. Ако използвате VPN, опитайте да го изключите и презаредете тази страница.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Ако това не свърши работа, можете да гледате това видео в YouTube, но без допълнителната поверителност на Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Това може да се случи, ако използвате VPN. Опитайте да изключите VPN или да промените местоположението на сървъра и да презаредите тази страница.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Ако това не проработи, ще трябва да влезете и да гледате това видео в YouTube без допълнителната поверителност на Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player осигурява чисто изживяване без персонализирани реклами в YouTube и предотвратява влиянието на вече гледаните видеоклипове върху препоръките на YouTube." } diff --git a/special-pages/pages/duckplayer/public/locales/cs/duckplayer.json b/special-pages/pages/duckplayer/public/locales/cs/duckplayer.json index 8d24c57265..fe15117eb4 100644 --- a/special-pages/pages/duckplayer/public/locales/cs/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/cs/duckplayer.json @@ -32,6 +32,74 @@ "title" : "CHYBA: Neplatné ID videa", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player nemůže načíst tohle video", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Na video se nedá dívat mimo YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Na tohle video se můžeš pořád podívat na YouTube, ale bez ochrany soukromí, jakou nabízí Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Omlouváme se, ale tohle video je věkově omezené", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Pokud chceš sledovat videa s věkovým omezením, musíš se přihlásit na YouTube a ověřit svůj věk.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Na tohle video se i tak můžeš podívat, ale budeš se muset přihlásit a pustit si ho na YouTube bez ochrany soukromí, kterou nabízí Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Omlouváme se, ale tohle video se dá přehrát jen na YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Autor tohoto videa se rozhodl nepovolit jeho zobrazení na jiných stránkách.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Pořád se na něj můžeš podívat na YouTube, ale bez ochrany soukromí, jakou nabízí Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube nedovoluje přehrávači Duck Player načíst tohle video", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube nedovoluje spuštění videa mimo YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Na tohle video se můžeš pořád podívat na YouTube, ale bez ochrany soukromí, jakou nabízí Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Omlouváme se, ale YouTube si myslí, že jsi robot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube blokuje načítání tohohle videa. Pokud používáš VPN, zkus ji vypnout a stránku znovu načíst.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Pokud to nefunguje, můžeš se na video podívat na YouTube, ale bez ochrany soukromí, jakou nabízí Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "To se může stát, pokud používáš VPN. Zkus VPN vypnout nebo změnit umístění serveru a načíst stránku znovu.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Pokud to nezabere, budeš se muset přihlásit a video si na YouTube přehrát bez ochrany soukromí, kterou nabízí Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Přehrávač Duck Player nabízí sledování v minimalistickém prostředí bez personalizovaných reklam a brání tomu, aby sledovaná videa ovlivňovala tvoje doporučení na YouTube." } diff --git a/special-pages/pages/duckplayer/public/locales/da/duckplayer.json b/special-pages/pages/duckplayer/public/locales/da/duckplayer.json index f99ab298e4..3a99dc63e0 100644 --- a/special-pages/pages/duckplayer/public/locales/da/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/da/duckplayer.json @@ -32,6 +32,74 @@ "title" : "FEJL: Ugyldigt video-ID", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player kan ikke indlæse denne video", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Denne video kan ikke vises uden for YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Du kan stadig se denne video på YouTube, men uden den ekstra fortrolighed, som Duck Player giver.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Beklager, denne video har aldersbegrænsning", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "For at se aldersbegrænsede videoer skal du logge ind på YouTube for at bekræfte din alder.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Du kan stadig se denne video, men du skal logge ind og se den på YouTube uden den ekstra fortrolighed, som Duck Player giver.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Beklager, denne video kan kun afspilles på YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Skaberen af denne video har valgt ikke at tillade, at den kan vises på andre hjemmesider.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Du kan stadig se den på YouTube, men uden den ekstra fortrolighed, som Duck Player giver.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube vil ikke lade Duck Player indlæse denne video", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube tillader ikke, at denne video vises uden for YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Du kan stadig se denne video på YouTube, men uden den ekstra fortrolighed, som Duck Player giver.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Beklager, YouTube tror, at du er en bot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube blokerer for, at denne video kan indlæses. Hvis du bruger en VPN, så prøv at slå den fra og genindlæse denne side.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Hvis dette ikke virker, kan du stadig se denne video på YouTube, men uden den ekstra fortrolighed, som Duck Player giver.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Dette kan ske, hvis du bruger en VPN. Prøv at slå VPN fra eller skifte serverplacering og genindlæse denne side.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Hvis det ikke virker, skal du logge ind og se denne video på YouTube uden den ekstra fortrolighed, som Duck Player giver.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player giver en ren seeroplevelse uden målrettede annoncer og forhindrer, at visningsaktivitet påvirker dine YouTube-anbefalinger." } diff --git a/special-pages/pages/duckplayer/public/locales/de/duckplayer.json b/special-pages/pages/duckplayer/public/locales/de/duckplayer.json index 0ddca103a2..6451d52cf6 100644 --- a/special-pages/pages/duckplayer/public/locales/de/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/de/duckplayer.json @@ -32,6 +32,74 @@ "title" : "FEHLER: Ungültige Video-ID", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player kann dieses Video nicht laden", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Dieses Video kann nicht außerhalb von YouTube angesehen werden.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Du kannst dieses Video auf YouTube ansehen, aber ohne die zusätzliche Privatsphäre des Duck Players.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Leider hat dieses Video eine Altersbeschränkung", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Wenn du Videos mit Altersbeschränkung ansehen möchtest, musst du dich bei YouTube anmelden und dein Alter bestätigen.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Du kannst dir dieses Video trotzdem ansehen, aber du musst dich anmelden und es auf YouTube ohne die zusätzliche Privatsphäre des Duck Players ansehen.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Es tut uns leid, dieses Video kann nur auf YouTube abgespielt werden", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Der Ersteller dieses Videos hat festgelegt, dass es nicht auf anderen Websites angesehen werden darf.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Du kannst es auf YouTube ansehen, aber ohne die zusätzliche Privatsphäre des Duck Players.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube lässt den Duck Player dieses Video nicht laden", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube erlaubt nicht, dass dieses Video außerhalb von YouTube angesehen wird.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Du kannst dieses Video auf YouTube ansehen, aber ohne die zusätzliche Privatsphäre des Duck Players.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Es tut uns leid, YouTube denkt, du seist ein Bot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube blockiert das Laden dieses Videos. Falls du ein VPN benutzt, deaktiviere es und lade diese Seite neu.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Falls das nicht funktioniert, kannst du das Video dennoch auf YouTube ansehen, jedoch ohne die zusätzliche Privatsphäre des Duck Players.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Dies kann passieren, wenn ein VPN verwendet wird. Schalte das VPN aus oder wechsle den Serverstandort und lade diese Seite neu.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Falls das nicht funktioniert, musst du dich anmelden und dieses Video auf YouTube ohne die zusätzliche Privatsphäre des Duck Players ansehen.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Mit Duck Player kannst du dir ungestört und ohne personalisierte Werbung Inhalte ansehen. Er verhindert, dass das, was du dir ansiehst, deine YouTube-Empfehlungen beeinflussen." } diff --git a/special-pages/pages/duckplayer/public/locales/el/duckplayer.json b/special-pages/pages/duckplayer/public/locales/el/duckplayer.json index d00195f5e3..36e64335d0 100644 --- a/special-pages/pages/duckplayer/public/locales/el/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/el/duckplayer.json @@ -32,6 +32,74 @@ "title" : "ΣΦΑΛΜΑ: Μη έγκυρο αναγνωριστικό βίντεο", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Το Duck Player δεν μπορεί να φορτώσει το βίντεο αυτό", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Το βίντεο αυτό δεν μπορεί να προβληθεί εκτός YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Μπορείτε ακόμα να παρακολουθήσετε αυτό το βίντεο στο YouTube, αλλά χωρίς την πρόσθετη ιδιωτικότητα του Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Δυστυχώς, αυτό το βίντεο έχει περιορισμό ηλικίας", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Για να παρακολουθήσετε βίντεο με περιορισμό ηλικίας, πρέπει να συνδεθείτε στο YouTube για να επαληθεύσετε την ηλικία σας.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Μπορείτε ακόμα να παρακολουθήσετε αυτό το βίντεο, ωστόσο θα πρέπει να συνδεθείτε και να το παρακολουθήσετε στο YouTube χωρίς το πρόσθετο απόρρητο του Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Δυστυχώς, αυτό το βίντεο μπορεί να αναπαραχθεί μόνο στο YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Ο δημιουργός αυτού του βίντεο έχει επιλέξει να μην επιτρέπει την προβολή του σε άλλους ιστότοπους.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Μπορείτε ακόμα να το παρακολουθήσετε στο YouTube, αλλά χωρίς την πρόσθετη ιδιωτικότητα του Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "Το YouTube δεν θα αφήσει το Duck Player να φορτώσει το βίντεο αυτό", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "Το YouTube δεν επιτρέπει την προβολή αυτού του βίντεο εκτός YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Μπορείτε ακόμα να παρακολουθήσετε αυτό το βίντεο στο YouTube, αλλά χωρίς την πρόσθετη ιδιωτικότητα του Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Δυστυχώς, το YouTube πιστεύει ότι είστε μποτ", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "Το YouTube μπλοκάρει τη φόρτωση αυτού του βίντεο. Εάν χρησιμοποιείτε VPN, δοκιμάστε να το απενεργοποιήσετε και να φορτώσετε εκ νέου αυτήν τη σελίδα.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Εάν δεν λειτουργήσει αυτό, μπορείτε να παρακολουθήσετε αυτό το βίντεο στο YouTube, ωστόσο χωρίς την πρόσθετη ιδιωτικότητα του Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Αυτό μπορεί να συμβαίνει εάν χρησιμοποιείτε VPN. Δοκιμάστε να απενεργοποιήσετε το VPN ή να αλλάξετε τοποθεσία διακομιστή και να φορτώσετε εκ νέου αυτήν τη σελίδα.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Εάν δεν λειτουργήσει αυτό, θα πρέπει να συνδεθείτε και να παρακολουθήσετε αυτό το βίντεο στο YouTube χωρίς την πρόσθετη ιδιωτικότητα του Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Το Duck Player παρέχει μια καθαρή εμπειρία προβολής χωρίς εξατομικευμένες διαφημίσεις, ενώ εμποδίζει τη δραστηριότητα προβολής να επηρεάσει τις συστάσεις που θα λαμβάνετε στο YouTube." } diff --git a/special-pages/pages/duckplayer/public/locales/en/duckplayer.json b/special-pages/pages/duckplayer/public/locales/en/duckplayer.json index c2b5683b9f..7ff83d7292 100644 --- a/special-pages/pages/duckplayer/public/locales/en/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/en/duckplayer.json @@ -33,6 +33,74 @@ "title": "ERROR: Invalid video id", "note": "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2": { + "title": "Duck Player can’t load this video", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a": { + "title": "This video can’t be viewed outside of YouTube.", + "note": "Explanation on why the error is happening." + }, + "unknownErrorMessage2b": { + "title": "You can still watch this video on YouTube, but without the added privacy of Duck Player.", + "note": "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2": { + "title": "Sorry, this video is age-restricted", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a": { + "title": "To watch age-restricted videos, you need to sign in to YouTube to verify your age.", + "note": "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b": { + "title": "You can still watch this video, but you’ll have to sign in and watch it on YouTube without the added privacy of Duck Player.", + "note": "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2": { + "title": "Sorry, this video can only be played on YouTube", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a": { + "title": "The creator of this video has chosen not to allow it to be viewed on other sites.", + "note": "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b": { + "title": "You can still watch it on YouTube, but without the added privacy of Duck Player.", + "note": "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading": { + "title": "YouTube won’t let Duck Player load this video", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1": { + "title": "YouTube doesn’t allow this video to be viewed outside of YouTube.", + "note": "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2": { + "title": "You can still watch this video on YouTube, but without the added privacy of Duck Player.", + "note": "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2": { + "title": "Sorry, YouTube thinks you’re a bot", + "note": "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1": { + "title": "YouTube is blocking this video from loading. If you’re using a VPN, try turning it off and reloading this page.", + "note": "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2": { + "title": "If this doesn’t work, you can still watch this video on YouTube, but without the added privacy of Duck Player.", + "note": "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a": { + "title": "This can happen if you’re using a VPN. Try turning the VPN off or switching server locations and reloading this page.", + "note": "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b": { + "title": "If that doesn’t work, you’ll have to sign in and watch this video on YouTube without the added privacy of Duck Player.", + "note": "More troubleshooting tips for this specific error" + }, "tooltipInfo": { "title": "Duck Player provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations." } diff --git a/special-pages/pages/duckplayer/public/locales/es/duckplayer.json b/special-pages/pages/duckplayer/public/locales/es/duckplayer.json index 1b5d8b9585..fa8f6d6292 100644 --- a/special-pages/pages/duckplayer/public/locales/es/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/es/duckplayer.json @@ -32,6 +32,74 @@ "title" : "ERROR: ID de vídeo no válida", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player no puede cargar este vídeo", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Este vídeo no se puede ver fuera de YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Sigues pudiendo ver este vídeo en YouTube, pero sin la privacidad adicional que ofrece Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Lo sentimos, este vídeo está restringido por edad", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Para ver vídeos con restricción de edad, necesitas iniciar sesión en YouTube para verificar tu edad.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Todavía puedes ver este vídeo, pero tendrás que iniciar sesión y verlo en YouTube sin la privacidad adicional de Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Lo sentimos, este vídeo solo se puede reproducir en YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "El creador de este vídeo ha decidido no permitir que se vea en otros sitios.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Sigues pudiendo verlo en YouTube, pero sin la privacidad adicional que ofrece Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube no permite que Duck Player cargue este vídeo", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube no permite que este vídeo se vea fuera de YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Sigues pudiendo ver este vídeo en YouTube, pero sin la privacidad adicional que ofrece Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Lo sentimos, YouTube piensa que eres un bot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube está bloqueando la carga de este vídeo. Si estás usando una VPN, intenta desactivarla y volver a cargar la página.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Si esto no funciona, sigues pudiendo ver este vídeo en YouTube, pero sin la privacidad adicional de Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Esto puede ocurrir si estás usando una VPN. Intenta desactivar la VPN o cambiar la ubicación del servidor y recarga la página.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Si eso no funciona, tendrás que iniciar sesión y ver el vídeo en YouTube sin la privacidad adicional que ofrece Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player ofrece una experiencia de visualización limpia sin anuncios personalizados e impide que la actividad de visualización influya en tus recomendaciones de YouTube." } diff --git a/special-pages/pages/duckplayer/public/locales/et/duckplayer.json b/special-pages/pages/duckplayer/public/locales/et/duckplayer.json index c9863f4f50..669c163611 100644 --- a/special-pages/pages/duckplayer/public/locales/et/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/et/duckplayer.json @@ -32,6 +32,74 @@ "title" : "VIGA: vale video ID", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player ei saa seda videot laadida", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Seda videot ei saa väljaspool YouTube'i vaadata.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Saate seda videot endiselt YouTube'is vaadata, kuid ilma Duck Player'i lisatud privaatsuseta.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Vabandust, see video on vanusepiiranguga", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Vanusepiiranguga videote vaatamiseks pead oma vanuse kontrollimiseks YouTube'i sisse logima.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Saad seda videot endiselt vaadata, kuid pead sisse logima ja vaatama seda YouTube'is ilma Duck Playeri lisatud privaatsuseta.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Vabandust, seda videot saab esitada ainult YouTube'is", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Selle video looja on otsustanud, et seda ei saa teistel saitidel vaadata.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Saate seda endiselt YouTube'is vaadata, kuid ilma Duck Playeri lisatud privaatsuseta.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube ei luba Duck Playeril seda videot laadida", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube ei luba seda videot väljaspool YouTube'i vaadata.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Saate seda videot endiselt YouTube'is vaadata, kuid ilma Duck Player'i lisatud privaatsuseta.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Vabandust, YouTube arvab, et sa oled robot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube blokeerib selle video laadimise. Kui kasutate VPN-i, proovige see välja lülitada ning leht uuesti laadida.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Kui see ei aita, saate seda videot ikkagi YouTube'is vaadata, kuid ilma Duck Playeri lisatud privaatsuseta.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "See võib juhtuda, kui kasutad VPN-i. Proovi VPN välja lülitada või serveri asukohta vahetada ja see leht uuesti laadida.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Kui see ei aita, pead sisse logima ja seda videot YouTube'is vaatama ilma Duck Playeri lisatud privaatsuseta.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player pakub isikupärastatud reklaamidest vaba vaatamiskogemust ja takistab, et vaatamisaktiivsus mõjutaks sinu YouTube'i soovitusi." } diff --git a/special-pages/pages/duckplayer/public/locales/fi/duckplayer.json b/special-pages/pages/duckplayer/public/locales/fi/duckplayer.json index e73022b8fa..6bdf94e6eb 100644 --- a/special-pages/pages/duckplayer/public/locales/fi/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/fi/duckplayer.json @@ -32,6 +32,74 @@ "title" : "VIRHE: virheellinen videotunnus", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player ei voi ladata tätä videota", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Tätä videota ei voi katsoa YouTuben ulkopuolella.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Voit yhä katsoa tämän videon YouTubessa, mutta ilman Duck Playerin tarjoamaa ylimääräistä tietosuojaa.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Valitettavasti tämä video on ikärajoitettu.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Jos haluat katsella ikärajoitettuja videoita, sinun täytyy kirjautua sisään YouTubeen vahvistaaksesi ikäsi.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Voit silti katsoa videon, mutta sinun täytyy kirjautua sisään ja katsoa se YouTubessa ilman Duck Playerin tarjoamaa lisätietosuojaa.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Valitettavasti tämän videon voi katsoa vain YouTubessa.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Videon tekijä on päättänyt estää sen katselun muilla sivustoilla.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Voit silti katsoa sen YouTubessa, mutta ilman Duck Playerin tarjoamaa ylimääräistä tietosuojaa.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube ei salli Duck Playerin ladata tätä videota.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube ei salli tämän videon katsomista YouTuben ulkopuolella.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Voit yhä katsoa tämän videon YouTubessa, mutta ilman Duck Playerin tarjoamaa ylimääräistä tietosuojaa.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Valitettavasti YouTube luulee, että olet botti", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube estää tämän videon latautumisen. Jos käytät VPN:ää, kytke se pois päältä ja lataa tämä sivu uudelleen.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Jos tämä ei toimi, voit silti katsoa tämän videon YouTubessa, mutta ilman Duck Playerin tarjoamaa ylimääräistä tietosuojaa.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Näin voi käydä, jos käytät VPN-yhteyttä. Kokeile kytkeä VPN pois päältä tai vaihtaa palvelimen sijaintia ja lataa sivu uudelleen.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Jos tämä ei auta, sinun on kirjauduttava sisään ja katsottava tämä video YouTubessa ilman Duck Playerin tarjoamaa ylimääräistä tietosuojaa.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player tarjoaa puhtaan katselukokemuksen ilman kohdennettuja mainoksia ja estää katseluhistoriaa vaikuttamasta YouTube-suosituksiisi." } diff --git a/special-pages/pages/duckplayer/public/locales/fr/duckplayer.json b/special-pages/pages/duckplayer/public/locales/fr/duckplayer.json index 716f0c0710..d762946ba3 100644 --- a/special-pages/pages/duckplayer/public/locales/fr/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/fr/duckplayer.json @@ -32,6 +32,74 @@ "title" : "ERREUR : identifiant vidéo non valide", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player ne peut pas charger cette vidéo", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Cette vidéo ne peut pas être visionnée en dehors de YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Vous pouvez toujours regarder cette vidéo sur YouTube, mais sans la confidentialité renforcée de Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Désolé, cette vidéo est soumise à une limite d'âge", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Pour regarder des vidéos soumises à une limite d'âge, vous devez vous connecter à YouTube pour confirmer votre âge.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Vous pouvez toujours regarder cette vidéo, mais vous devrez vous connecter et la regarder sur YouTube sans la confidentialité renforcée de Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Désolé, cette vidéo ne peut être lue que sur YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Le créateur/La créatrice de cette vidéo a choisi de ne pas autoriser son visionnage sur d'autres sites.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Vous pouvez toujours la regarder sur YouTube, mais sans la confidentialité renforcée de Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube ne permet pas à Duck Player de charger cette vidéo", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube n'autorise pas le visionnage de cette vidéo en dehors de YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Vous pouvez toujours regarder cette vidéo sur YouTube, mais sans la confidentialité renforcée de Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Désolé, YouTube pense que vous êtes un bot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube bloque le chargement de cette vidéo. Si vous utilisez un VPN, essayez de le désactiver et de recharger cette page.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Si cela ne fonctionne pas, vous pouvez toujours regarder cette vidéo sur YouTube, mais sans la confidentialité renforcée de Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Cela peut arriver si vous utilisez un VPN. Essayez de désactiver le VPN ou de changer l'emplacement du serveur et de recharger cette page.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Si cela ne fonctionne pas, vous devrez vous connecter et regarder cette vidéo sur YouTube sans la confidentialité renforcée de Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player offre une expérience de visionnage épurée, sans publicités personnalisées, et empêche l'activité de visionnage d'influencer vos recommandations YouTube." } diff --git a/special-pages/pages/duckplayer/public/locales/hr/duckplayer.json b/special-pages/pages/duckplayer/public/locales/hr/duckplayer.json index 3f0e8aeaee..deb265f412 100644 --- a/special-pages/pages/duckplayer/public/locales/hr/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/hr/duckplayer.json @@ -32,6 +32,74 @@ "title" : "POGREŠKA: Nevažeći ID videozapisa", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player ne može učitati ovaj videozapis.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Ovaj se videozapis ne može gledati izvan YouTubea.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Još uvijek možeš gledati ovaj videozapis na YouTubeu, ali bez dodatne privatnosti Duck Playera.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Nažalost, ovaj videozapis sadrži dobno ograničenje.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Da bi gledao videozapise s ograničenjem dobi, moraš se prijaviti na YouTube kako bi se provjerila tvoja dob.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Još uvijek možeš gledati ovaj videozapis, ali morat ćeš se prijaviti i gledati ga na YouTubeu bez dodatne privatnosti Duck Playera.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Žao nam je, ovaj video možeš gledati samo na YouTubeu.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Autor ovog videozapisa odlučio je ne dopustiti njegovo gledanje na drugim web stranicama.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Još uvijek ga možeš gledati na YouTubeu, ali bez dodatne privatnosti Duck Playera.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube ne dopušta Duck Playeru da učita ovaj videozapis.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube ne dopušta da se ovaj videozapis gleda izvan YouTubea.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Još uvijek možeš gledati ovaj videozapis na YouTubeu, ali bez dodatne privatnosti Duck Playera.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Žao nam je, YouTube misli da si bot.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube blokira učitavanje ovog videozapisa. Ako koristiš VPN, pokušaj ga isključiti i ponovno učitati ovu stranicu.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Ako to ne uspije, i dalje možeš gledati ovaj videozapis na YouTubeu, ali bez dodatne privatnosti Duck Playera.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "To se može dogoditi ako koristiš VPN. Pokušaj isključiti VPN ili promijeniti lokacije poslužitelja i ponovno učitati ovu stranicu.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Ako to ne uspije, morat ćeš se prijaviti i gledati ovaj videozapis na YouTubeu bez dodatne privatnosti Duck Playera.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player pruža čisti doživljaj gledanja bez personaliziranih oglasa i sprječava da aktivnosti gledanja utječu na tvoje preporuke na YouTubeu." } diff --git a/special-pages/pages/duckplayer/public/locales/hu/duckplayer.json b/special-pages/pages/duckplayer/public/locales/hu/duckplayer.json index 3bbe06210f..94ede7bf51 100644 --- a/special-pages/pages/duckplayer/public/locales/hu/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/hu/duckplayer.json @@ -32,6 +32,74 @@ "title" : "HIBA: Érvénytelen videoazonosító", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "A Duck Player nem tudja betölteni ezt a videót.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Ezt a videót nem lehet a YouTube-on kívül megtekinteni.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Megnézheted a videót a YouTube-on, de a Duck Player által nyújtott extra adatvédelem nélkül.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Sajnáljuk, ez a videó korhatáros.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "A korhatáros videók megtekintéséhez a YouTube-ra való bejelentkezéssel meg kell erősítened az életkorodat.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Megnézheted a videót, de be kell jelentkezned a YouTube-ra, és ott a Duck Player által nyújtott extra adatvédelem nélkül láthatod.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Sajnáljuk, ez a videó csak a YouTube-on játszható le.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "A videó készítője úgy döntött, hogy nem engedélyezi a más oldalakon való lejátszását.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Megnézheted a YouTube-on, de a Duck Player által nyújtott extra adatvédelem nélkül.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "A YouTube nem engedi, hogy a Duck Player betöltse ezt a videót", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "A YouTube nem engedi, hogy ezt a videót a YouTube-on kívül nézd meg.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Megnézheted a videót a YouTube-on, de a Duck Player által nyújtott extra adatvédelem nélkül.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Sajnáljuk, a YouTube azt hiszi, hogy bot vagy", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "A YouTube blokkolja ennek a videónak a betöltését. Ha VPN-t használsz, próbáld meg, hogy kikapcsolod, majd újra betöltöd ezt az oldalt.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Ha ez nem működik, akkor is megnézheted ezt a videót a YouTube-on, de a Duck Player által nyújtott extra adatvédelem nélkül.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Ez előfordulhat, ha VPN-t használsz. Próbáld meg kikapcsolni a VPN-t, vagy válts szerverhelyszínt, és töltsd be újra az oldalt.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Ha ez nem működik, be kell jelentkezned a YouTube-ra, és a videót ott a Duck Player által nyújtott extra adatvédelem nélkül láthatod.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "A Duck Player személyre szabott hirdetések nélküli, letisztult megtekintési élményt nyújt, és megakadályozza, hogy a megtekintési tevékenységed befolyásolja a neked szóló YouTube-ajánlásokat." } diff --git a/special-pages/pages/duckplayer/public/locales/it/duckplayer.json b/special-pages/pages/duckplayer/public/locales/it/duckplayer.json index 9ce2166770..83e19342cc 100644 --- a/special-pages/pages/duckplayer/public/locales/it/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/it/duckplayer.json @@ -32,6 +32,74 @@ "title" : "ERRORE: ID video non valido", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player non può caricare il video", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Questo video non si può visualizzare al di fuori di YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Puoi ancora guardare questo video su YouTube, ma senza la privacy aggiuntiva offerta da Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Spiacenti, questo video prevede restrizioni di età", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Per guardare video con restrizioni di età, accedi a YouTube per verificare la tua età.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Puoi comunque guardare il video, ma dovrai accedere e guardarlo su YouTube senza la privacy aggiuntiva di Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Questo video può essere riprodotto solo su YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Il creatore di questo video ha scelto di non permettere la visione su altri siti.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Puoi comunque guardarlo su YouTube, ma senza la privacy aggiuntiva offerta da Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube non consente a Duck Player di caricare questo video", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "Questo video si può vedere solo su YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Puoi ancora guardare questo video su YouTube, ma senza la privacy aggiuntiva offerta da Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Mi dispiace, YouTube ti ritiene un bot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube sta impedendo il caricamento di questo video. Se stai utilizzando una VPN, prova a disattivarla e a ricaricare questa pagina.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Se il problema non si risolve, puoi comunque guardare questo video su YouTube, ma senza la privacy aggiuntiva offerta da Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Questo può succedere se usi una VPN. Prova a disattivare la VPN o a cambiare la posizione del server e a ricaricare la pagina.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Se il problema persiste, dovrai accedere e guardare il video su YouTube senza la privacy aggiuntiva offerta da Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player offre un'esperienza di visualizzazione pulita, senza annunci personalizzati, e impedisce che l'attività di visualizzazione incida sulle raccomandazioni di YouTube." } diff --git a/special-pages/pages/duckplayer/public/locales/lt/duckplayer.json b/special-pages/pages/duckplayer/public/locales/lt/duckplayer.json index 1b282dd2cc..59a200a068 100644 --- a/special-pages/pages/duckplayer/public/locales/lt/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/lt/duckplayer.json @@ -32,6 +32,74 @@ "title" : "KLAIDA: netinkamas vaizdo įrašo ID", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "„Duck Player“ negali įkelti šio vaizdo įrašo", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Šį vaizdo įrašą galima žiūrėti tik „YouTube“ platformoje.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Šį vaizdo įrašą vis dar gali žiūrėti „YouTube“, bet be papildomo „Duck Player“ privatumo.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Atsiprašome, šiam vaizdo įrašui taikomas amžiaus apribojimas", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Norint žiūrėti vaizdo įrašus su amžiaus apribojimais, reikia prisijungti prie „YouTube“ ir patvirtinti savo amžių.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Vis dar gali žiūrėti šį vaizdo įrašą, bet reikės prisijungti ir žiūrėti jį per „YouTube“ be papildomos „Duck Player“ privatumo funkcijos.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Atsiprašome, šį vaizdo įrašą galima žiūrėti tik per „YouTube“.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Šio vaizdo įrašo kūrėjas pasirinko neleisti jo žiūrėti kitose svetainėse.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Vis tiek gali žiūrėti jį „YouTube“, bet be papildomos „Duck Player“ privatumo funkcijos.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "„YouTube“ neleidžia „Duck Player“ įkelti šio vaizdo įrašo", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "„YouTube“ neleidžia šio vaizdo įrašo žiūrėti ne „YouTube“ platformoje.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Šį vaizdo įrašą vis dar gali žiūrėti „YouTube“, bet be papildomo „Duck Player“ privatumo.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Atsiprašome, „YouTube“ mano, kad tu esi robotas.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "„YouTube“ blokuoja šio vaizdo įrašo įkėlimą. Jei naudoji VPN, pabandyk jį išjungti ir iš naujo įkelti šį puslapį.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Jei tai neveikia, vis tiek gali žiūrėti šį vaizdo įrašą „YouTube“, bet be papildomo „Duck Player“ privatumo.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Taip gali nutikti, jei naudoji VPN. Pabandyk išjungti VPN arba pakeisti serverio vietą ir iš naujo įkelti šį puslapį.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Jei tai neveikia, turėsite prisijungti ir žiūrėti šį vaizdo įrašą „YouTube“ be papildomos „Duck Player“ privatumo funkcijos.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "„Duck Player“ užtikrina nepriekaištingą žiūrėjimo patirtį be suasmenintų reklamų ir neleidžia žiūrėjimo veiklai daryti įtakos „YouTube“ rekomendacijoms." } diff --git a/special-pages/pages/duckplayer/public/locales/lv/duckplayer.json b/special-pages/pages/duckplayer/public/locales/lv/duckplayer.json index 46d0bee8e6..5ddf7ce816 100644 --- a/special-pages/pages/duckplayer/public/locales/lv/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/lv/duckplayer.json @@ -32,6 +32,74 @@ "title" : "KĻŪDA: Nederīgs video ID", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player nevar ielādēt šo video.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Šo video nevar skatīties ārpus YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Šo videoklipu joprojām vari skatīties vietnē YouTube, taču bez papildu Duck Player konfidencialitātes.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Atvaino, šim videoklipam ir vecuma ierobežojums.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Lai skatītos videoklipus ar vecuma ierobežojumu, tev jāpiesakās YouTube, lai pārbaudītu savu vecumu.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Tu joprojām vari skatīties šo videoklipu, taču tev būs jāpiesakās un jāskatās to vietnē YouTube bez papildu privātuma, ko nodrošina Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Atvaino, šo video var atskaņot tikai vietnē YouTube.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Šī video veidotājs ir izvēlējies neļaut to skatīties citās vietnēs.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Joprojām vari to skatīties vietnē YouTube, taču bez papildu Duck Player privātuma.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube neļauj Duck Player ielādēt šo video", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube neļauj skatīties šo video ārpus YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Šo videoklipu joprojām vari skatīties vietnē YouTube, taču bez papildu Duck Player konfidencialitātes.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Atvainojiet, YouTube uzskata, ka esi robots", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube bloķē šī video ielādi. Ja tu izmanto VPN, mēģini to izslēgt un pārlādēt šo lapu.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Ja tas nedarbojas, joprojām vari skatīties šo video vietnē YouTube, taču bez papildu privātuma, ko nodrošina Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Tas var notikt, ja tu izmanto VPN. Mēģini izslēgt VPN vai mainīt servera atrašanās vietu un pārlādēt šo lapu.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Ja tas nedarbojas, tev būs jāpierakstās un jāskatās šis video vietnē YouTube bez papildu Duck Player privātuma.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player nodrošina netraucētu skatīšanās pieredzi bez personalizētām reklāmām un neļauj skatīšanās darbībām ietekmēt tavus YouTube ieteikumus." } diff --git a/special-pages/pages/duckplayer/public/locales/nb/duckplayer.json b/special-pages/pages/duckplayer/public/locales/nb/duckplayer.json index 4c1d826f66..3bede52c51 100644 --- a/special-pages/pages/duckplayer/public/locales/nb/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/nb/duckplayer.json @@ -32,6 +32,74 @@ "title" : "FEIL: Ugyldig video-ID", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player kan ikke laste denne videoen", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Denne videoen kan ikke vises utenfor YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Du kan fremdeles se videoen på YouTube, men uten det ekstra personvernet som Duck Player tilbyr.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Beklager, denne videoen er aldersbegrenset", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "For å se aldersbegrensede videoer må du logge inn på YouTube for å bekrefte alderen din.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Du kan fortsatt se denne videoen, men du må logge inn og se den på YouTube uten det ekstra personvernet som Duck Player tilbyr.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Beklager, denne videoen kan bare spilles av på YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Skaperen av denne videoen har valgt å ikke tillate at den vises på andre nettsteder.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Du kan fortsatt se den på YouTube, men uten det ekstra personvernet som Duck Player tilbyr.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube lar ikke Duck Player laste denne videoen", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube tillater ikke visning av denne videoen utenfor YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Du kan fremdeles se videoen på YouTube, men uten det ekstra personvernet som Duck Player tilbyr.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Beklager, YouTube tror du er en bot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube blokkerer denne videoen fra å lastes. Hvis du bruker en VPN, kan du prøve å slå den av og laste denne siden på nytt.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Hvis ikke det virker, kan du fremdeles se videoen på YouTube, bare uten det ekstra personvernet som Duck Player tilbyr.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Dette kan skje hvis du bruker en VPN. Prøv å slå av VPN eller bytte serverlokasjon, og last inn denne siden på nytt.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Hvis ikke det fungerer, må du logge inn og se videoen på YouTube uten det ekstra personvernet som Duck Player tilbyr.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player tilbyr en ren seeropplevelse uten tilpassede annonser og forhindrer at seeraktiviteten din påvirker YouTube-anbefalingene dine." } diff --git a/special-pages/pages/duckplayer/public/locales/nl/duckplayer.json b/special-pages/pages/duckplayer/public/locales/nl/duckplayer.json index a1be8669e2..4246c1cbb8 100644 --- a/special-pages/pages/duckplayer/public/locales/nl/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/nl/duckplayer.json @@ -32,6 +32,74 @@ "title" : "FOUT: ongeldige video-id", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player kan deze video niet laden.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Deze video kan alleen op YouTube worden bekeken.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Je kunt deze video nog steeds bekijken op YouTube, maar zonder de extra privacy van Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Sorry, deze video heeft een leeftijdsbeperking", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Om video's met leeftijdsbeperkingen te bekijken, moet je je aanmelden bij YouTube om je leeftijd te verifiëren.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Je kunt deze video nog steeds bekijken, maar je moet je aanmelden bij YouTube en hem daar bekijken zonder de extra privacy van Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Sorry, deze video kan alleen op YouTube worden afgespeeld", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "De maker van deze video staat niet toe dat deze video op andere sites wordt bekeken.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Je kunt deze video nog steeds op YouTube bekijken, maar zonder de extra privacy van Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube staat Duck Player niet toe om deze video te laden.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube staat niet toe dat je deze video buiten YouTube bekijkt.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Je kunt deze video nog steeds bekijken op YouTube, maar zonder de extra privacy van Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Sorry, YouTube denkt dat je een bot bent", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube blokkeert het laden van deze video. Als je een VPN gebruikt, probeer deze dan uit te schakelen en deze pagina opnieuw te laden.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Als dit niet werkt, kun je deze video nog steeds op YouTube bekijken, maar dan zonder de extra privacy van Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Dit kan gebeuren als je een VPN gebruikt. Schakel de VPN uit of wijzig de serverlocatie en laad deze pagina opnieuw.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Als dat niet werkt, moet je je aanmelden bij YouTube en deze video daar bekijken zonder de extra privacy van Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player biedt puur kijkplezier zonder gepersonaliseerde advertenties en voorkomt dat de dingen die je bekijkt je YouTube-aanbevelingen beïnvloeden." } diff --git a/special-pages/pages/duckplayer/public/locales/pl/duckplayer.json b/special-pages/pages/duckplayer/public/locales/pl/duckplayer.json index accd3fde5d..e40950aa6a 100644 --- a/special-pages/pages/duckplayer/public/locales/pl/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/pl/duckplayer.json @@ -32,6 +32,74 @@ "title" : "BŁĄD: nieprawidłowy identyfikator filmu", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player nie może załadować tego filmu", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Nie można oglądać tego filmu poza YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Nadal możesz oglądać ten film na YouTube, ale bez dodatkowej prywatności, jaką zapewnia Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Przepraszamy, ten film jest ograniczony wiekowo", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Aby oglądać filmy z ograniczeniami wiekowymi, musisz zalogować się na YouTube, aby potwierdzić swój wiek.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Nadal możesz obejrzeć ten film, ale musisz się zalogować i obejrzeć go na YouTube bez dodatkowej prywatności, jaką zapewnia Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Przepraszamy, ten film można odtwarzać tylko na YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Twórca tego filmu nie wyraził zgody na oglądanie go na innych stronach.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Nadal możesz oglądać go na YouTube, ale bez dodatkowej prywatności, jaką zapewnia Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube nie zezwala na załadowanie tego filmu przez Duck Player", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube nie zezwala na oglądanie tego filmu poza YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Nadal możesz oglądać ten film na YouTube, ale bez dodatkowej prywatności, jaką zapewnia Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Niestety serwis YouTube uważa, że jesteś botem", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube blokuje ładowanie tego filmu. Jeśli korzystasz z sieci VPN, spróbuj ją wyłączyć i ponownie załadować tę stronę.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Jeśli to nie pomoże, nadal możesz oglądać ten film na YouTube, jednak bez dodatkowej prywatności, jaką zapewnia Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Może się tak zdarzyć, jeśli korzystasz z sieci VPN. Spróbuj wyłączyć sieć VPN lub zmienić lokalizację serwera i odświeżyć tę stronę.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Jeśli to nie pomoże, możesz się zalogować i obejrzeć ten film na YouTube bez dodatkowej prywatności, jaką zapewnia Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player zapewnia czyste środowisko oglądania bez spersonalizowanych reklam i sprawia, że aktywność związana z oglądaniem filmów nie wpływa na rekomendacje YouTube'a." } diff --git a/special-pages/pages/duckplayer/public/locales/pt/duckplayer.json b/special-pages/pages/duckplayer/public/locales/pt/duckplayer.json index a5bfca1881..e164cbf863 100644 --- a/special-pages/pages/duckplayer/public/locales/pt/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/pt/duckplayer.json @@ -32,6 +32,74 @@ "title" : "ERRO: ID de vídeo inválido", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "O Duck Player não consegue carregar este vídeo", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Este vídeo não pode ser visualizado fora do YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Continuas a poder ver este vídeo no YouTube, mas sem a privacidade adicional do Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Desculpa, este vídeo tem restrição de idade", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Para assistir a vídeos com restrição de idade, precisas de iniciar sessão no YouTube para confirmar a tua idade.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Continuas a poder ver este vídeo, mas terás de iniciar sessão e vê-lo no YouTube sem a privacidade adicional do Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Desculpa, este vídeo só pode ser reproduzido no YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "O criador deste vídeo optou por não permitir que seja visto em outros sites.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Continuas a poder vê-lo no YouTube, mas sem a privacidade adicional do Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "O YouTube não permite que o Duck Player carregue este vídeo", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "O YouTube não permite que vejas este vídeo fora do YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Continuas a poder ver este vídeo no YouTube, mas sem a privacidade adicional do Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Desculpa, o YouTube acha que és um bot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "O YouTube está a bloquear o carregamento deste vídeo. Se estiveres a usar uma VPN, tenta desativá-la e recarregar esta página.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Se isto não funcionar, continuas a poder ver este vídeo no YouTube, mas sem a privacidade adicional do Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Isto pode acontecer se estiveres a usar uma VPN. Experimenta desativar a VPN ou mudar as localizações do servidor e recarregar esta página.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Se isso não funcionar, terás de iniciar sessão e ver este vídeo no YouTube sem a privacidade adicional do Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "O Duck Player oferece uma experiência de visualização limpa sem anúncios personalizados e evita que as atividades de visualização influenciem as recomendações do YouTube." } diff --git a/special-pages/pages/duckplayer/public/locales/ro/duckplayer.json b/special-pages/pages/duckplayer/public/locales/ro/duckplayer.json index bfafec70e0..d540031169 100644 --- a/special-pages/pages/duckplayer/public/locales/ro/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/ro/duckplayer.json @@ -32,6 +32,74 @@ "title" : "EROARE: ID video incorect", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player nu poate încărca acest videoclip.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Acest videoclip nu poate fi vizionat în afara YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Poți viziona în continuare acest videoclip pe YouTube, dar fără confidențialitatea suplimentară oferită de Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Ne pare rău, acest videoclip impune restricții de vârstă", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Pentru a viziona videoclipuri cu restricții de vârstă, trebuie să te conectezi la YouTube pentru a-ți verifica vârsta.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Poți viziona în continuare acest videoclip, dar va trebui să te conectezi și să-l vizionezi pe YouTube, fără confidențialitatea suplimentară oferită de Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Ne pare rău, acest videoclip poate fi redat doar pe YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Creatorul acestui videoclip a ales să nu permită vizionarea lui pe alte site-uri.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Îl poți viziona în continuare pe YouTube, dar fără confidențialitatea suplimentară oferită de Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube nu permite Duck Player să încarce acest videoclip", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube nu permite ca acest videoclip să fie vizionat în afara YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Poți viziona în continuare acest videoclip pe YouTube, dar fără confidențialitatea suplimentară oferită de Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Ne pare rău, YouTube crede că ești un robot.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube blochează încărcarea acestui videoclip. Dacă folosești un VPN, încearcă să-l dezactivezi și să reîncarci această pagină.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Dacă acest lucru nu funcționează, poți viziona în continuare acest videoclip pe YouTube, dar fără confidențialitatea suplimentară oferită de Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Acest lucru se poate întâmpla dacă folosești un VPN. Încearcă să dezactivezi VPN-ul, să schimbi locația serverului sau să reîncarci această pagină.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Dacă acest lucru nu funcționează, va trebui să te conectezi și să vizionezi acest videoclip pe YouTube fără confidențialitatea suplimentară oferită de Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player oferă o experiență de vizionare fără perturbări, fără reclame personalizate și împiedică activitatea de vizionare să îți influențeze recomandările YouTube." } diff --git a/special-pages/pages/duckplayer/public/locales/ru/duckplayer.json b/special-pages/pages/duckplayer/public/locales/ru/duckplayer.json index 4bf5cc0c1f..20a1a55325 100644 --- a/special-pages/pages/duckplayer/public/locales/ru/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/ru/duckplayer.json @@ -32,6 +32,74 @@ "title" : "ОШИБКА: Неверный идентификатор видео", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player не удается загрузить это видео", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Это видео нельзя смотреть за пределами YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Вы по-прежнему можете посмотреть этот ролик на YouTube, но уже без дополнительной защиты Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "К сожалению, это видео имеет возрастные ограничения.", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Чтобы смотреть видео с возрастными ограничениями, нужно подтвердить свой возраст, выполнив вход на YouTube.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Вы все равно можете просмотреть это видео, но на YouTube и только после входа в учетную запись, то есть без дополнительной защиты Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Извините, но это видео можно воспроизвести только в YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Автор этого видео не разрешил просмотр на других сайтах.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Вы по-прежнему можете посмотреть это видео на YouTube, но уже без дополнительной защиты Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube не позволяет проигрывателю Duck Player загрузить это видео", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube не позволяет смотреть это видео вне своей платформы.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Вы по-прежнему можете посмотреть этот ролик на YouTube, но уже без дополнительной защиты Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Извините, но YouTube считает вас ботом", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube блокирует загрузку этого видео. Если вы используете VPN, отключите ее и перезагрузите страницу.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Если это не даст результата, вы все равно сможете просмотреть это видео на YouTube, но без дополнительной защиты конфиденциальности, обеспечиваемой Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Подобное иногда случается при использовании VPN. Попробуйте отключить VPN или сменить геопозицию сервера и перезагрузить страницу.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Если и в таком случае не удается решить проблему, вам придется войти в учетную запись и посмотреть видео на YouTube без дополнительной защиты Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Проигрыватель Duck Player обеспечивает беспрепятственный просмотр без персонализированной рекламы и влияния просмотренных роликов на рекомендации в YouTube." } diff --git a/special-pages/pages/duckplayer/public/locales/sk/duckplayer.json b/special-pages/pages/duckplayer/public/locales/sk/duckplayer.json index 88a2cc3a57..da6e1794b2 100644 --- a/special-pages/pages/duckplayer/public/locales/sk/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/sk/duckplayer.json @@ -32,6 +32,74 @@ "title" : "CHYBA: Neplatný identifikátor videa", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player nemôže načítať toto video", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Toto video nie je možné pozerať mimo YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Toto video si môžeš pozrieť aj na YouTube, ale bez dodatočného súkromia Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Prepáč, toto video je vekovo obmedzené", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Na sledovanie videí s vekovým obmedzením sa musíš prihlásiť na YouTube a overiť svoj vek.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Stále si môžeš pozrieť toto video, ale budeš sa musieť prihlásiť a pozerať ho na YouTube bez dodatočnej ochrany súkromia Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Prepáč, toto video je možné prehrať iba na YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Autor tohto videa sa rozhodol nepovoliť jeho sledovanie na iných lokalitách.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Stále si ho môžeš pozrieť na YouTube, ale bez dodatočného súkromia Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube nedovolí Duck Playeru načítať toto video", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube nepovoľuje, aby sa toto video pozeralo mimo YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Toto video si môžeš pozrieť aj na YouTube, ale bez dodatočného súkromia Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Prepáč, YouTube si myslí, že si robot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube blokuje načítanie tohto videa. Ak používaš sieť VPN, skús ju vypnúť a znova načítať túto stránku.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Ak to nefunguje, môžeš si toto video pozrieť aj na YouTube, ale bez dodatočnej ochrany súkromia, ktorú poskytuje Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Toto sa môže stať, ak používaš VPN. Skús vypnúť sieť VPN alebo prepnúť umiestnenia servera a znova načítať túto stránku.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Ak to nefunguje, budeš sa musieť prihlásiť a pozrieť si toto video na YouTube bez dodatočnej ochrany súkromia, ktorú poskytuje Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player poskytuje čisté zobrazenie bez personalizovaných reklám a zabraňuje tomu, aby aktivita pri sledovaní ovplyvňovala vaše odporúčania v službe YouTube." } diff --git a/special-pages/pages/duckplayer/public/locales/sl/duckplayer.json b/special-pages/pages/duckplayer/public/locales/sl/duckplayer.json index 7d4a89155a..8ac63edbf8 100644 --- a/special-pages/pages/duckplayer/public/locales/sl/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/sl/duckplayer.json @@ -32,6 +32,74 @@ "title" : "NAPAKA: Neveljaven ID videoposnetka", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player ne more naložiti tega videa", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Tega videoposnetka si ni mogoče ogledati zunaj YouTuba.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Ta videoposnetek si lahko še vedno ogledate na YouTubu, vendar brez dodane zasebnosti predvajalnika Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Žal je ta videoposnetek starostno omejen", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Če si želite ogledati starostno omejene videoposnetke, se morate prijaviti v YouTube in potrditi svojo starost.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Ta videoposnetek si lahko še vedno ogledate, vendar se boste morali prijaviti in si ga ogledati v YouTubu brez dodane zasebnosti predvajalnika Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Žal je ta videoposnetek mogoče predvajati samo na YouTubu", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Ustvarjalec tega videoposnetka se je odločil, da ne dovoli njegovega ogleda na drugih spletnih mestih.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Še vedno si ga lahko ogledate na YouTubu, vendar brez dodane zasebnosti predvajalnika Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube ne dovoli predvajalniku Duck Player naložiti tega videa", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube ne dovoljuje, da si ta videoposnetek ogledate zunaj YouTuba.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Ta videoposnetek si lahko še vedno ogledate na YouTubu, vendar brez dodane zasebnosti predvajalnika Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "YouTube misli, da ste bot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube preprečuje nalaganje tega videoposnetka. Če uporabljate omrežje VPN, ga poskusite izklopiti in znova naložite to stran.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Če to ne deluje, si lahko ta videoposnetek še vedno ogledate na YouTubu, vendar brez dodane zasebnosti predvajalnika Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "To se lahko zgodi, če uporabljate omrežje VPN. Poskusite izklopiti omrežje VPN ali zamenjati lokacijo strežnika in znova naložite to stran.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Če to ne deluje, se boste morali prijaviti in si ogledati ta videoposnetek na YouTubu brez dodane zasebnosti predvajalnika Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Predvajalnik Duck Player zagotavlja čisto izkušnjo gledanja brez prilagojenih oglasov in preprečuje, da bi dejavnost gledanja vplivala na vaša priporočila v YouTubu." } diff --git a/special-pages/pages/duckplayer/public/locales/sv/duckplayer.json b/special-pages/pages/duckplayer/public/locales/sv/duckplayer.json index cb5b75e4ca..013a71642f 100644 --- a/special-pages/pages/duckplayer/public/locales/sv/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/sv/duckplayer.json @@ -32,6 +32,74 @@ "title" : "FEL: Ogiltigt video-ID", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player kan inte läsa in den här videon", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Den här videon kan inte ses utanför YouTube.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Du kan fortfarande se den här videon på YouTube, men utan den extra integriteten från Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Tyvärr, den här videon är åldersbegränsad", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "För att titta på åldersbegränsade videor måste du logga in på YouTube för att verifiera din ålder.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Du kan fortfarande titta på den här videon, men du måste logga in och se den på YouTube utan det extra integritetsskyddet från Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Tyvärr kan den här videon endast visas på YouTube", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Skaparen av den här videon har valt att inte tillåta att den visas på andra webbplatser.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Du kan fortfarande se den på YouTube, men utan den extra integriteten från Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube låter inte Duck Player läsa in den här videon", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube tillåter inte att den här videon ses utanför YouTube.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Du kan fortfarande se den här videon på YouTube, men utan den extra integriteten från Duck Player.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Tyvärr, YouTube tror att du är en bot", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube blockerar den här videon från att läsas in. Om du använder ett VPN kan du prova stänga av det och läsa in sidan igen.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Om det inte fungerar kan du fortfarande se den här videon på YouTube, men utan den extra integriteten från Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "Detta kan hända om du använder ett VPN-program. Prova att stänga av VPN-programmet eller byta serverplats och läsa in den här sidan igen.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Om det inte fungerar måste du logga in och titta på den här videon på YouTube utan det extra integritetsskyddet från Duck Player.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player ger en störningsfri visningsupplevelse utan personliga annonser och förhindrar att din tittaraktivitet påverkar YouTube-rekommendationer." } diff --git a/special-pages/pages/duckplayer/public/locales/tr/duckplayer.json b/special-pages/pages/duckplayer/public/locales/tr/duckplayer.json index c23cab2bbd..4a8f774358 100644 --- a/special-pages/pages/duckplayer/public/locales/tr/duckplayer.json +++ b/special-pages/pages/duckplayer/public/locales/tr/duckplayer.json @@ -32,6 +32,74 @@ "title" : "HATA: Geçersiz video kimliği", "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." }, + "unknownErrorHeading2" : { + "title" : "Duck Player bu videoyu yükleyemiyor", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "unknownErrorMessage2a" : { + "title" : "Bu video YouTube dışında izlenemez.", + "note" : "Explanation on why the error is happening." + }, + "unknownErrorMessage2b" : { + "title" : "Bu videoyu Duck Player'ın sunduğu ek gizlilik olmadan YouTube'da izleyebilirsiniz.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "ageRestrictedErrorHeading2" : { + "title" : "Üzgünüz, bu videoda yaş sınırlaması var", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "ageRestrictedErrorMessage2a" : { + "title" : "Yaş sınırlaması olan videoları izlemek için yaşınızı doğrulamak üzere YouTube'da oturum açmanız gerekir.", + "note" : "Explanation on why the error is happening." + }, + "ageRestrictedErrorMessage2b" : { + "title" : "Bu videoyu yine izleyebilirsiniz. Ancak Duck Player'ın ek gizliliği olmadan YouTube'da oturum açmanız ve izlemeniz gerekir.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "noEmbedErrorHeading2" : { + "title" : "Üzgünüz, bu video yalnızca YouTube'da oynatılabilir", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "noEmbedErrorMessage2a" : { + "title" : "Bu videoyu oluşturan kişi, videonun başka sitelerde izlenmesine izin vermemeyi seçti.", + "note" : "Explanation on why the error is happening." + }, + "noEmbedErrorMessage2b" : { + "title" : "Bu videoyu Duck Player'ın sunduğu ek gizlilik olmadan YouTube'da izleyebilirsiniz.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "blockedVideoErrorHeading" : { + "title" : "YouTube, Duck Player'ın bu videoyu yüklemesine izin vermiyor", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "blockedVideoErrorMessage1" : { + "title" : "YouTube, bu videonun YouTube dışında izlenmesine izin vermiyor.", + "note" : "Explanation on why the error is happening." + }, + "blockedVideoErrorMessage2" : { + "title" : "Bu videoyu Duck Player'ın sunduğu ek gizlilik olmadan YouTube'da izleyebilirsiniz.", + "note" : "A message explaining that the blocked video can be watched directly on YouTube." + }, + "signInRequiredErrorHeading2" : { + "title" : "Üzgünüz, YouTube sizin bir bot olduğunuzu sanıyor", + "note" : "Message shown when YouTube has blocked playback of a video" + }, + "signInRequiredErrorMessage1" : { + "title" : "YouTube bu videonun yüklenmesini engelliyor. VPN kullanıyorsanız, VPN'i kapatıp bu sayfayı yeniden yüklemeyi deneyin.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2" : { + "title" : "Bu işe yaramazsa, videoyu Duck Player'ın sunduğu ek gizlilik olmadan YouTube'da izleyebilirsiniz.", + "note" : "More troubleshooting tips for this specific error" + }, + "signInRequiredErrorMessage2a" : { + "title" : "VPN kullanıyorsanız bu durum meydana gelebilir. VPN'i kapatmayı veya sunucu konumlarını değiştirmeyi ve bu sayfayı yeniden yüklemeyi deneyin.", + "note" : "Explanation on why the error is happening and a suggestions on how to solve it." + }, + "signInRequiredErrorMessage2b" : { + "title" : "Bu işe yaramazsa, oturum açmanız ve bu videoyu Duck Player'ın sunduğu ek gizlilik olmadan YouTube'da izlemeniz gerekir.", + "note" : "More troubleshooting tips for this specific error" + }, "tooltipInfo" : { "title" : "Duck Player, kişiselleştirilmiş reklamlar olmadan temiz bir görüntüleme deneyimi sağlar ve görüntüleme etkinliğinin YouTube önerilerinizi etkilemesini önler." } diff --git a/special-pages/pages/duckplayer/src/index.js b/special-pages/pages/duckplayer/src/index.js index a9c40ae397..c677b427ea 100644 --- a/special-pages/pages/duckplayer/src/index.js +++ b/special-pages/pages/duckplayer/src/index.js @@ -91,6 +91,14 @@ export class DuckplayerPage { return this.messaging.subscribe('onUserValuesChanged', cb); } + /** + * This will be sent if the application fails to load. + * @param {{error: import('../types/duckplayer.ts').YouTubeError}} params + */ + reportYouTubeError(params) { + this.messaging.notify('reportYouTubeError', params); + } + /** * This will be sent if the application has loaded, but a client-side error * has occurred that cannot be recovered from diff --git a/special-pages/pages/duckplayer/types/duckplayer.ts b/special-pages/pages/duckplayer/types/duckplayer.ts index bb0d047c85..56a1321601 100644 --- a/special-pages/pages/duckplayer/types/duckplayer.ts +++ b/special-pages/pages/duckplayer/types/duckplayer.ts @@ -6,6 +6,7 @@ * @module Duckplayer Messages */ +export type YouTubeError = "age-restricted" | "sign-in-required" | "no-embed" | "unknown"; export type PrivatePlayerMode = | { enabled: unknown; @@ -26,6 +27,7 @@ export interface DuckplayerMessages { | OpenSettingsNotification | ReportInitExceptionNotification | ReportPageExceptionNotification + | ReportYouTubeErrorNotification | TelemetryEventNotification; requests: GetUserValuesRequest | InitialSetupRequest | SetUserValuesRequest; subscriptions: OnUserValuesChangedSubscription; @@ -62,6 +64,16 @@ export interface ReportPageExceptionNotification { export interface ReportPageExceptionNotify { message: string; } +/** + * Generated from @see "../messages/reportYouTubeError.notify.json" + */ +export interface ReportYouTubeErrorNotification { + method: "reportYouTubeError"; + params: ReportYouTubeErrorNotify; +} +export interface ReportYouTubeErrorNotify { + error: YouTubeError; +} /** * Generated from @see "../messages/telemetryEvent.notify.json" */ @@ -117,6 +129,31 @@ export interface DuckPlayerPageSettings { focusMode?: { state: "enabled" | "disabled"; }; + customError?: CustomErrorSettings & { + /** + * A selector that, when not empty, indicates a sign-in required error + */ + signInRequiredSelector?: string; + }; +} +/** + * Configures a custom error message for YouTube errors + */ +export interface CustomErrorSettings { + state: "enabled" | "disabled"; + /** + * Custom error settings + */ + settings?: { + /** + * A selector that, when not empty, indicates a sign-in required error + */ + signInRequiredSelector?: string; + /** + * A selector that, when not empty, indicates a general YouTube error + */ + youtubeErrorSelector?: string; + }; } /** * Generated from @see "../messages/setUserValues.request.json" diff --git a/special-pages/pages/duckplayer/unit-tests/embed-settings.mjs b/special-pages/pages/duckplayer/unit-tests/embed-settings.mjs index 36f6017b2f..fa9937af64 100644 --- a/special-pages/pages/duckplayer/unit-tests/embed-settings.mjs +++ b/special-pages/pages/duckplayer/unit-tests/embed-settings.mjs @@ -5,7 +5,7 @@ import { EmbedSettings } from '../app/embed-settings.js'; describe('creates embed url', () => { it('handles duck scheme', () => { const actual = EmbedSettings.fromHref('duck://player/123')?.toEmbedUrl(); - const expected = 'https://www.youtube-nocookie.com/embed/123?iv_load_policy=1&autoplay=1&rel=0&modestbranding=1'; + const expected = 'https://www.youtube-nocookie.com/embed/123?iv_load_policy=1&autoplay=1&rel=0&modestbranding=1&color=white'; deepEqual(actual, expected); }); it('handles duck scheme with timestamp', () => { @@ -15,6 +15,7 @@ describe('creates embed url', () => { autoplay: '1', rel: '0', modestbranding: '1', + color: 'white', start: '3723', }; if (!actual) throw new Error('unreachable'); @@ -28,6 +29,7 @@ describe('creates embed url', () => { autoplay: '1', rel: '0', modestbranding: '1', + color: 'white', start: '3723', }; if (!actual) throw new Error('unreachable'); @@ -41,6 +43,7 @@ describe('creates embed url', () => { autoplay: '1', rel: '0', modestbranding: '1', + color: 'white', start: '3723', }; if (!actual) throw new Error('unreachable'); @@ -54,6 +57,7 @@ describe('creates embed url', () => { autoplay: '1', rel: '0', modestbranding: '1', + color: 'white', }; if (!actual) throw new Error('unreachable'); const asParams = Object.fromEntries(new URL(actual).searchParams); @@ -67,6 +71,7 @@ describe('creates embed url', () => { autoplay: '1', rel: '0', modestbranding: '1', + color: 'white', muted: '1', }; if (!actual) throw new Error('unreachable'); @@ -80,6 +85,7 @@ describe('creates embed url', () => { iv_load_policy: '1', rel: '0', modestbranding: '1', + color: 'white', }; if (!actual) throw new Error('unreachable'); const asParams = Object.fromEntries(new URL(actual).searchParams); @@ -93,6 +99,7 @@ describe('creates embed url', () => { autoplay: '1', rel: '0', modestbranding: '1', + color: 'white', }; if (!actual) throw new Error('unreachable'); const asParams = Object.fromEntries(new URL(actual).searchParams); diff --git a/special-pages/pages/duckplayer/unit-tests/settings.mjs b/special-pages/pages/duckplayer/unit-tests/settings.mjs new file mode 100644 index 0000000000..0a8e9c231a --- /dev/null +++ b/special-pages/pages/duckplayer/unit-tests/settings.mjs @@ -0,0 +1,78 @@ +import { describe, it } from 'node:test'; +import { deepEqual } from 'node:assert/strict'; +import { Settings } from '../app/settings.js'; + +describe('parses settings', () => { + it('handles disabled custom error schema', () => { + const settings = new Settings({ + customError: { + state: 'disabled', + }, + }); + const expected = { + state: 'disabled', + }; + + deepEqual(settings.customError, expected); + }); + it('handles old custom error schema', () => { + const settings = new Settings({ + customError: { + state: 'enabled', + signInRequiredSelector: 'div', + }, + }); + const expected = { + state: 'enabled', + settings: { + signInRequiredSelector: 'div', + }, + }; + + deepEqual(settings.customError, expected); + }); + it('handles new custom error schema', () => { + const settings = new Settings({ + customError: { + state: 'enabled', + settings: { + signInRequiredSelector: 'div', + }, + }, + }); + const expected = { + state: 'enabled', + settings: { + signInRequiredSelector: 'div', + }, + }; + + deepEqual(settings.customError, expected); + }); + it('handles custom error enabled without settings', () => { + const settings = new Settings({ + customError: { + state: 'enabled', + }, + }); + const expected = { + state: 'enabled', + settings: {}, + }; + + deepEqual(settings.customError, expected); + }); + it('handles malformed custom error schema', () => { + const settings = new Settings({ + customError: { + // @ts-expect-error - Malformed object on purpose + status: 'enabled', + }, + }); + const expected = { + state: 'disabled', + }; + + deepEqual(settings.customError, expected); + }); +}); diff --git a/special-pages/pages/errorpage/readme.md b/special-pages/pages/errorpage/readme.md index d0cff014d4..3ca05aa6bb 100644 --- a/special-pages/pages/errorpage/readme.md +++ b/special-pages/pages/errorpage/readme.md @@ -20,8 +20,7 @@ The main HTML file is located at `src/index.html`. You can edit this file direct ### CSS The main stylesheet is located at `src/style.css`. You can edit this file directly -The build process will create a bundle and place it inside `Sources/ContentScopeScripts/dist/pages/onboarding` -on macOS - other platforms will be under `build//pages/errorpage` +The build process will create a bundle and place it under `build//pages/errorpage` Don't edit the generated files directly - any changes you make will not be reflected in the final build output. diff --git a/special-pages/pages/history/app/Settings.js b/special-pages/pages/history/app/Settings.js new file mode 100644 index 0000000000..62710cc942 --- /dev/null +++ b/special-pages/pages/history/app/Settings.js @@ -0,0 +1,55 @@ +export class Settings { + /** + * @param {object} params + * @param {{name: 'macos' | 'windows'}} [params.platform] + * @param {number} [params.typingDebounce=100] how long to debounce typing in the search field - default: 100ms + * @param {number} [params.urlDebounce=500] how long to debounce reflecting to the URL? - default: 500ms + */ + constructor({ platform = { name: 'macos' }, typingDebounce = 100, urlDebounce = 500 }) { + this.platform = platform; + this.typingDebounce = typingDebounce; + this.urlDebounce = urlDebounce; + } + + withPlatformName(name) { + /** @type {ImportMeta['platform'][]} */ + const valid = ['windows', 'macos']; + if (valid.includes(/** @type {any} */ (name))) { + return new Settings({ + ...this, + platform: { name }, + }); + } + return this; + } + + /** + * @param {null|undefined|number|string} value + */ + withDebounce(value) { + if (!value) return this; + const input = String(value).trim(); + if (input.match(/^\d+$/)) { + return new Settings({ + ...this, + typingDebounce: parseInt(input, 10), + }); + } + return this; + } + + /** + * @param {null|undefined|number|string} value + */ + withUrlDebounce(value) { + if (!value) return this; + const input = String(value).trim(); + if (input.match(/^\d+$/)) { + return new Settings({ + ...this, + urlDebounce: parseInt(input, 10), + }); + } + return this; + } +} diff --git a/special-pages/pages/history/app/components/App.jsx b/special-pages/pages/history/app/components/App.jsx new file mode 100644 index 0000000000..be4801e389 --- /dev/null +++ b/special-pages/pages/history/app/components/App.jsx @@ -0,0 +1,106 @@ +import { h } from 'preact'; +import cn from 'classnames'; +import styles from './App.module.css'; +import { useEnv } from '../../../../shared/components/EnvironmentProvider.js'; +import { Header } from './Header.js'; +import { ResultsContainer } from './Results.js'; +import { useEffect, useRef } from 'preact/hooks'; +import { Sidebar } from './Sidebar.js'; +import { useRowInteractions } from '../global/Providers/SelectionProvider.js'; +import { useQueryContext } from '../global/Providers/QueryProvider.js'; +import { useContextMenuForEntries } from '../global/hooks/useContextMenuForEntries.js'; +import { useAuxClickHandler } from '../global/hooks/useAuxClickHandler.js'; +import { useButtonClickHandler } from '../global/hooks/useButtonClickHandler.js'; +import { useLinkClickHandler } from '../global/hooks/useLinkClickHandler.js'; +import { useResetSelectionsOnQueryChange } from '../global/hooks/useResetSelectionsOnQueryChange.js'; +import { useSearchCommitForRange } from '../global/hooks/useSearchCommitForRange.js'; +import { useURLReflection } from '../global/hooks/useURLReflection.js'; +import { useSearchCommit } from '../global/hooks/useSearchCommit.js'; +import { useRangesData } from '../global/Providers/HistoryServiceProvider.js'; +import { usePlatformName } from '../types.js'; +import { useLayoutMode } from '../global/hooks/useLayoutMode.js'; +import { useClickAnywhereElse } from '../global/hooks/useClickAnywhereElse.jsx'; + +export function App() { + const platformName = usePlatformName(); + const mainRef = useRef(/** @type {HTMLElement|null} */ (null)); + const { isDarkMode } = useEnv(); + const ranges = useRangesData(); + const query = useQueryContext(); + const mode = useLayoutMode(); + + /** + * Handlers that are global in nature + */ + useResetSelectionsOnQueryChange(); + useLinkClickHandler(); + useButtonClickHandler(); + useContextMenuForEntries(); + useAuxClickHandler(); + useURLReflection(); + useSearchCommit(); + useSearchCommitForRange(); + const clickAnywhere = useClickAnywhereElse(); + + /** + * onClick can be passed directly to the main container, + * onKeyDown will be observed at the document level. + * todo: can this be resolved if the `main` element is given focus/tab-index? + */ + const { onClick, onKeyDown } = useRowInteractions(mainRef); + + useEffect(() => { + // whenever the query changes, scroll the main container back to the top + const unsubscribe = query.subscribe(() => { + mainRef.current?.scrollTo(0, 0); + }); + + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('keydown', onKeyDown); + unsubscribe(); + }; + }, [onKeyDown, query]); + + return ( +
    + +
    +
    +
    +
    + +
    +
    + ); +} + +export function AppLevelErrorBoundaryFallback({ children }) { + return ( +
    +

    {children}

    +
    + You can try to{' '} + +
    +
    + ); +} diff --git a/special-pages/pages/history/app/components/App.module.css b/special-pages/pages/history/app/components/App.module.css new file mode 100644 index 0000000000..65ca020330 --- /dev/null +++ b/special-pages/pages/history/app/components/App.module.css @@ -0,0 +1,131 @@ +@import url("../../../../shared/styles/variables.css"); +@import url("../../styles/base.css"); +@import url("../../styles/history-theme.css"); + +body { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + line-height: var(--body-line-height); + background-color: var(--history-background-color); + color: var(--history-text-normal); +} + +.layout { + --sidebar-width: 250px; + --main-padding-left: 48px; + --main-padding-right: 76px; + --scrollbar-width: 18px; + + &[data-layout-mode="reduced"] { + --sidebar-width: 230px; + --main-padding-left: 28px; + --main-padding-right: 36px; + } + + [data-platform="windows"] & { + --scrollbar-width: 15px + } + + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + grid-template-rows: max-content 1fr; + grid-template-areas: + 'aside header' + 'aside main'; + overflow: hidden; + height: 100vh; + background-color: var(--history-background-color); +} + +.header { + grid-area: header; + padding-left: var(--main-padding-left); + padding-right: var(--main-padding-right); + view-transition-name: header; + z-index: 1; + background-color: var(--history-background-color); +} + +.search { + justify-self: flex-end; +} + +.aside { + grid-area: aside; + padding: 10px 16px; + /* Nav child will handle this to accommodate a scrollbar */ + padding-right: 0; + border-right: 1px solid var(--history-surface-border-color); +} + +.main { + grid-area: main; + overflow: auto; + padding-left: var(--main-padding-left); + padding-right: var(--main-padding-right); + padding-top: 24px; +} + +/** + * The scrollbar is a custom scroller that is used in the main + * content area and the sidebar nav. + */ +.customScroller { + overflow-y: scroll; + padding-right: calc(var(--main-padding-right) - var(--scrollbar-width)); +} + +/** + * Windows has access to newer CSS features for styling the scrollbar + */ +[data-platform="windows"] { + .customScroller { + overflow-y: auto; + scrollbar-gutter: stable; + scrollbar-width: var(--scrollbar-width); + scrollbar-color: var(--history-scrollbar-controls-color) var(--history-background-color); + } +} + +/** + * macOS specific styling to handle dark mode - without this full + * override you cannot change the track color :( + */ +[data-platform="integration"], +[data-platform="macos"] { + .customScroller { + --webkit-thumb-color: rgba(136, 136, 136, 0.8); + + &::-webkit-scrollbar { + width: var(--scrollbar-width); + } + + &::-webkit-scrollbar-track { + background-color: var(--history-background-color) + } + + &::-webkit-scrollbar-thumb { + background-color: var(--webkit-thumb-color); + border-radius: calc(var(--scrollbar-width) / 2); + + /** faking some padding on the thumb */ + border: 4px solid var(--history-background-color); + } + + &::-webkit-scrollbar-button { + display: block; + background-color: var(--history-background-color); + + /** a small vertical gap to prevent it touching boundaries */ + height: 2px; + } + } +} + +.paddedError { + padding: 1rem; +} + +.paddedErrorRecovery { + margin-top: 1rem; +} diff --git a/special-pages/pages/history/app/components/Components.jsx b/special-pages/pages/history/app/components/Components.jsx new file mode 100644 index 0000000000..21e8e8d3ed --- /dev/null +++ b/special-pages/pages/history/app/components/Components.jsx @@ -0,0 +1,6 @@ +import { h } from 'preact'; +import styles from './Components.module.css'; + +export function Components() { + return
    Component list here!
    ; +} diff --git a/special-pages/pages/history/app/components/Components.module.css b/special-pages/pages/history/app/components/Components.module.css new file mode 100644 index 0000000000..2352c4102f --- /dev/null +++ b/special-pages/pages/history/app/components/Components.module.css @@ -0,0 +1,3 @@ +.main { + +} diff --git a/special-pages/pages/history/app/components/Empty.js b/special-pages/pages/history/app/components/Empty.js new file mode 100644 index 0000000000..8bcb8de9bf --- /dev/null +++ b/special-pages/pages/history/app/components/Empty.js @@ -0,0 +1,58 @@ +import { h } from 'preact'; +import { useTypedTranslation } from '../types.js'; +import cn from 'classnames'; +import styles from './VirtualizedList.module.css'; +import { useQueryContext } from '../global/Providers/QueryProvider.js'; +import { useResultsData } from '../global/Providers/HistoryServiceProvider.js'; +import { useComputed } from '@preact/signals'; + +/** + * Empty state component displayed when no results are available + * @param {object} props + * @param {object} props.title + * @param {object} props.text + */ +export function Empty({ title, text }) { + return ( +
    +
    + + +
    +

    {title}

    +

    {text}

    +
    + ); +} + +/** + * Use the application state to figure out which title+text to use. + */ +export function EmptyState() { + const { t } = useTypedTranslation(); + const results = useResultsData(); + const query = useQueryContext(); + const hasSearch = useComputed(() => query.value.term !== null && query.value.term.trim().length > 0); + + /** + * Compute the empty state title. this text needs to match the results + * it produces, not just the latest UI value. + */ + const text = useComputed(() => { + const termFromSearchBox = query.value.term; + if (!('term' in results.value.info.query)) return termFromSearchBox; + + // if we have results + a search term in the UI, choose which term to show + const termFromApiResponse = results.value.info.query.term; + if (termFromSearchBox === termFromApiResponse) { + return termFromSearchBox; + } + return termFromApiResponse; + }); + + if (hasSearch.value) { + return ; + } + + return ; +} diff --git a/special-pages/pages/history/app/components/Header.js b/special-pages/pages/history/app/components/Header.js new file mode 100644 index 0000000000..1149b9821c --- /dev/null +++ b/special-pages/pages/history/app/components/Header.js @@ -0,0 +1,93 @@ +import styles from './Header.module.css'; +import { h } from 'preact'; +import { useComputed } from '@preact/signals'; +import { SearchForm } from './SearchForm.js'; +import { Trash } from '../icons/Trash.js'; +import { useTypedTranslation } from '../types.js'; +import { useQueryContext } from '../global/Providers/QueryProvider.js'; +import { useSelected } from '../global/Providers/SelectionProvider.js'; +import { useHistoryServiceDispatch, useResultsData } from '../global/Providers/HistoryServiceProvider.js'; + +/** + */ +export function Header() { + const search = useQueryContext(); + const term = useComputed(() => search.value.term); + const range = useComputed(() => search.value.range); + const domain = useComputed(() => search.value.domain); + return ( +
    +
    + +
    +
    + +
    +
    + ); +} + +/** + * Renders the Controls component that displays a button for deletion functionality. + * + * @param {Object} props - Properties passed to the component. + * @param {import("@preact/signals").Signal} props.term + * @param {import("@preact/signals").Signal} props.range + * @param {import("@preact/signals").Signal} props.domain + */ +function Controls({ term, range, domain }) { + const { t } = useTypedTranslation(); + const results = useResultsData(); + const selected = useSelected(); + const dispatch = useHistoryServiceDispatch(); + + /** + * Aria labels + title text is derived from the current result set. + */ + const ariaDisabled = useComputed(() => results.value.items.length === 0); + const title = useComputed(() => (results.value.items.length === 0 ? t('delete_none') : '')); + + /** + * The button text should be 'delete all', unless there are row selections, then it's just 'delete' + */ + const buttonTxt = useComputed(() => { + const hasSelections = selected.value.size > 0; + if (hasSelections) return t('delete_some'); + return t('delete_all'); + }); + + /** + * Which action should the delete button take? + * + * - if there are selections, they should be deleted by indexes + * - if there's a range selected, that should be deleted + * - if there's a search term, that should be deleted + * - or fallback to deleting all + */ + function onClick() { + if (ariaDisabled.value === true) return; + if (selected.value.size > 0) { + return dispatch({ kind: 'delete-entries-by-index', value: [...selected.value] }); + } + if (range.value !== null) { + return dispatch({ kind: 'delete-range', value: range.value }); + } + if (term.value !== null && term.value !== '') { + return dispatch({ kind: 'delete-term', term: term.value }); + } + if (domain.value !== null) { + return dispatch({ kind: 'delete-domain', domain: domain.value }); + } + if (term.value !== null && term.value !== '') { + return dispatch({ kind: 'delete-term', term: term.value }); + } + dispatch({ kind: 'delete-all' }); + } + + return ( + + ); +} diff --git a/special-pages/pages/history/app/components/Header.module.css b/special-pages/pages/history/app/components/Header.module.css new file mode 100644 index 0000000000..243a0a5205 --- /dev/null +++ b/special-pages/pages/history/app/components/Header.module.css @@ -0,0 +1,114 @@ +.root { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + color: var(--history-text-normal); + padding: 16px 0; + border-bottom: 1px solid var(--history-surface-border-color); +} + +.controls { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.largeButton { + background: transparent; + display: flex; + align-items: center; + gap: 6px; + height: 28px; + border: none; + border-radius: 4px; + color: var(--history-text-normal); + padding-left: 8px; + padding-right: 8px; + + svg { + flex-shrink: 0; + fill-opacity: 0.84; + } + + &:hover { + background-color: var(--color-black-at-6); + [data-theme="dark"] & { + background-color: var(--color-white-at-6) + } + } + + &:active { + background-color: var(--color-black-at-12); + [data-theme="dark"] & { + background-color: var(--color-white-at-12) + } + } + + &[aria-disabled="true"] { + opacity: .3; + } +} + +.search { + max-width: 238px; + width: 100%; + flex-shrink: 1; + display: flex; +} +.form { + width: 100%; +} +.label { + color: inherit; + display: block; + position: relative; +} +.searchIcon { + position: absolute; + display: block; + width: 16px; + height: 16px; + top: 50%; + left: 9px; + transform: translateY(-50%); + color: black; + [data-theme="dark"] & { + color: white; + } +} +.searchInput { + width: 100%; + height: 28px; + border-radius: 6px; + border: 0.5px solid var(--history-surface-border-color); + /* these precise numbers help it match figma when overriding default UI */ + padding-left: 31px; + padding-right: 9px; + background: inherit; + color: inherit; + + [data-theme="dark"] & { + background: var(--color-white-at-6); + border-color: var(--color-white-at-9); + } + + &:focus { + outline: none; + box-shadow: 0px 0px 0px 2.5px rgba(87, 151, 237, 0.64), 0px 0px 0px 1px rgba(87, 151, 237, 0.64) inset, 0px 0.5px 0px -0.5px rgba(0, 0, 0, 0.10), 0px 1px 0px -0.5px rgba(0, 0, 0, 0.10); + } + + &::-webkit-search-cancel-button { + -webkit-appearance: none; + height: 13px; + width: 13px; + background-image: url("../../public/icons/clear.svg"); + background-repeat: no-repeat; + background-position: center center; + cursor: pointer; + } + + [data-theme="dark"] &::-webkit-search-cancel-button { + background-image: url("../../public/icons/clear-dark.svg"); + } +} \ No newline at end of file diff --git a/special-pages/pages/history/app/components/Item.js b/special-pages/pages/history/app/components/Item.js new file mode 100644 index 0000000000..7ad81274e6 --- /dev/null +++ b/special-pages/pages/history/app/components/Item.js @@ -0,0 +1,74 @@ +import { memo } from 'preact/compat'; +import cn from 'classnames'; +import { BOTH_KIND, END_KIND, TITLE_KIND } from '../utils.js'; +import { Fragment, h } from 'preact'; +import styles from './Item.module.css'; +import { Dots } from '../icons/dots.js'; +import { BTN_ACTION_ENTRIES_MENU, DDG_DEFAULT_ICON_SIZE } from '../constants.js'; +import { FaviconWithState } from '../../../../shared/components/FaviconWithState.js'; + +export const Item = memo( + /** + * Renders an individual item with specific styles and layout determined by props. + * + * @param {Object} props - An object containing the properties for the item. + * @param {string} props.id - A unique identifier for the item. + * @param {string} props.viewId - A unique identifier for the item, safe to use in CSS names + * @param {string} props.title - The text to be displayed for the item. + * @param {string} props.url - The text to be displayed for the item. + * @param {string} props.domain - The text to be displayed for the domain + * @param {number} props.kind - The kind or type of the item that determines its visual style. + * @param {string} [props.dateTimeOfDay] - the time of day, like 11.00am. + * @param {string} props.dateRelativeDay - the time of day, like 11.00am. + * @param {string|null} props.etldPlusOne + * @param {number} props.index - original index + * @param {boolean} props.selected - whether this item is selected + * @param {string|null|undefined} props.faviconSrc + * @param {number} props.faviconMax + */ + function Item(props) { + const { viewId, title, kind, etldPlusOne, faviconSrc, faviconMax, dateTimeOfDay, dateRelativeDay, index, selected } = props; + const hasFooterGap = kind === END_KIND || kind === BOTH_KIND; + const hasTitle = kind === TITLE_KIND || kind === BOTH_KIND; + + return ( + + {hasTitle && ( +
    + {dateRelativeDay} +
    + )} +
    +
    + +
    + + {title} + + + {props.domain} + + {dateTimeOfDay && {dateTimeOfDay}} + +
    +
    + ); + }, +); diff --git a/special-pages/pages/history/app/components/Item.module.css b/special-pages/pages/history/app/components/Item.module.css new file mode 100644 index 0000000000..e6dc0682c8 --- /dev/null +++ b/special-pages/pages/history/app/components/Item.module.css @@ -0,0 +1,166 @@ +.item { + +} + +.title { + --title-height: 32px; + height: var(--title-height); + width: 100%; + font-size: var(--title-3-em-font-size); + font-weight: var(--title-3-em-font-weight); + line-height: var(--title-3-em-line-height); + white-space: nowrap; + color: var(--history-text-normal); + display: flex; + align-items: center; + padding-left: 8px; + border-radius: 5px; + position: relative; +} + +.row { + --row-height: 28px; + height: var(--row-height); + display: flex; + gap: 8px; + align-items: center; + padding-left: 9px; + padding-right: 5px; + position: relative; +} + +.hover { + --row-bg: inherit; + --row-radius: 5px; + --row-color: var(--history-text-normal); + --dots-bg-hover: var(--color-black-at-9); + --dots-opacity: 0; + --time-opacity: 0.6; + --time-visibility: visible; + + background: var(--row-bg); + color: var(--row-color); + border-radius: var(--row-radius); + + &:hover { + --dots-opacity: visible; + --time-opacity: 0; + --time-visibility: hidden; + } + + &:hover:not([aria-selected="true"]) { + --row-bg: var(--color-black-at-6); + [data-theme="dark"] & { + --row-bg: var(--color-white-at-6); + } + } + + [data-theme="dark"] & { + --dots-bg-hover: var(--color-white-at-12); + } + + &[aria-selected="true"] { + --row-bg: #2565D9; + --row-color: var(--color-white-at-84); + --dots-bg-hover: var(--color-white-at-9); + } +} + +@supports selector(:has(*)) { + /** + * The following code handles handles the multi-row selections. It's responsible + * for ensuring only the first and last elements in a selection have rounded corners. + */ + [data-is-selected='true'] .row { + border-radius: 0; + } + + [data-is-selected='true']:first-of-type .row, + [data-is-selected='true']:not([data-is-selected='true'] + [data-is-selected='true']) .row + { + border-top-left-radius: var(--row-radius); + border-top-right-radius: var(--row-radius); + } + + [data-is-selected='true']:last-of-type .row, + [data-is-selected='true']:not(:has(+ [data-is-selected='true'])) .row + { + border-bottom-left-radius: var(--row-radius); + border-bottom-right-radius: var(--row-radius); + } +} + + +.favicon { + flex-shrink: 0; + min-width: 0; +} + +.entryLink { + font-size: var(--label-font-size); + font-weight: var(--label-font-weight); + line-height: var(--row-height); + white-space: nowrap; + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + text-decoration: none; + color: inherit; + pointer-events: none; +} + +.domain { + font-weight: 400; + display: block; + white-space: nowrap; + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + + [data-layout-mode="normal"] & { + flex-shrink: 0; + } + + &:before { + content: "- " + } +} + +.time { + margin-left: auto; + flex-shrink: 0; + opacity: var(--time-opacity); + visibility: var(--time-visibility); + font-feature-settings: "tnum"; + font-variant-numeric: tabular-nums; +} + +.dots { + position: absolute; + top: 50%; + transform: translateY(-50%); + right: 5px; + width: 24px; + height: 24px; + border-radius: 4px; + background: transparent; + border: 0; + z-index:1; + color: inherit; + opacity: var(--dots-opacity); + + svg { + width: 16px; + height: 16px; + } + + &:hover { + background: var(--dots-bg-hover); + } +} + +.last { + margin-bottom: 24px; +} \ No newline at end of file diff --git a/special-pages/pages/history/app/components/Results.js b/special-pages/pages/history/app/components/Results.js new file mode 100644 index 0000000000..92f1118e2c --- /dev/null +++ b/special-pages/pages/history/app/components/Results.js @@ -0,0 +1,92 @@ +import { h } from 'preact'; +import { DDG_DEFAULT_ICON_SIZE, OVERSCAN_AMOUNT } from '../constants.js'; +import { Item } from './Item.js'; +import styles from './VirtualizedList.module.css'; +import { VisibleItems } from './VirtualizedList.js'; +import { EmptyState } from './Empty.js'; +import { useSelected, useSelectionState } from '../global/Providers/SelectionProvider.js'; +import { useHistoryServiceDispatch, useResultsData } from '../global/Providers/HistoryServiceProvider.js'; +import { useCallback, useEffect } from 'preact/hooks'; + +/** + * Access global state and render the results + */ +export function ResultsContainer() { + const results = useResultsData(); + const selected = useSelected(); + const selectionState = useSelectionState(); + const dispatch = useHistoryServiceDispatch(); + + /** + * When the selection state changed in a way that might cause an element to be off-screen, re-focus it + */ + useEffect(() => { + return selectionState.subscribe(({ lastAction, focusedIndex }) => { + if (lastAction === 'move-selection' || lastAction === 'inc-or-dec-selected') { + const match = document.querySelector(`[aria-selected][data-index="${focusedIndex}"]`); + match?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + }); + }, [selectionState]); + + /** + * Let the history service know when it might want to load more + */ + const onChange = useCallback((end) => dispatch({ kind: 'request-more', end }), [dispatch]); + + return ; +} + +/** + * @param {object} props + * @param {import("@preact/signals").Signal} props.results + * @param {import("@preact/signals").Signal>} props.selected + * @param {(end: number) => void} props.onChange + */ +export function Results({ results, selected, onChange }) { + if (results.value.items.length === 0) { + return ; + } + + /** + * Sum all heights to determin the container height (so that the scroll bar is accurate) + */ + const totalHeight = results.value.heights.reduce((acc, item) => acc + item, 0); + + return ( +
      + { + const isSelected = selected.value.has(index); + const faviconMax = item.favicon?.maxAvailableSize ?? DDG_DEFAULT_ICON_SIZE; + const favoriteSrc = item.favicon?.src; + const viewId = results.value.viewIds[index]; + return ( +
    • + +
    • + ); + }} + /> +
    + ); +} diff --git a/special-pages/pages/history/app/components/SearchForm.js b/special-pages/pages/history/app/components/SearchForm.js new file mode 100644 index 0000000000..673be12e62 --- /dev/null +++ b/special-pages/pages/history/app/components/SearchForm.js @@ -0,0 +1,108 @@ +import { useComputed, useSignalEffect } from '@preact/signals'; +import { h } from 'preact'; +import { useQueryDispatch } from '../global/Providers/QueryProvider.js'; +import { SearchIcon } from '../icons/Search.js'; +import { usePlatformName, useTypedTranslation } from '../types.js'; +import styles from './Header.module.css'; + +const INPUT_FIELD_NAME = 'q'; + +/** + * @param {object} props + * @param {import("@preact/signals").Signal} props.term + * @param {import("@preact/signals").Signal} props.domain + */ +export function SearchForm({ term, domain }) { + const { t } = useTypedTranslation(); + const value = useComputed(() => term.value || domain.value || ''); + const dispatch = useQueryDispatch(); + const platformName = usePlatformName(); + useSearchShortcut(platformName); + + /** + * @param {InputEvent} inputEvent + */ + function input(inputEvent) { + invariant(inputEvent.target instanceof HTMLInputElement); + invariant(inputEvent.target.form instanceof HTMLFormElement); + const data = new FormData(inputEvent.target.form); + const term = data.get(INPUT_FIELD_NAME)?.toString(); + invariant(term !== undefined); + dispatch({ kind: 'search-by-term', value: term }); + } + + /** + * @param {SubmitEvent} submitEvent + */ + function submit(submitEvent) { + submitEvent.preventDefault(); + invariant(submitEvent.currentTarget instanceof HTMLFormElement); + const data = new FormData(submitEvent.currentTarget); + const term = data.get(INPUT_FIELD_NAME)?.toString(); + dispatch({ kind: 'search-by-term', value: term ?? '' }); + } + + return ( + + ); +} + +/** + * Listens for keyboard shortcuts to focus the search input. + * + * Handles platform-specific shortcuts for MacOS (Cmd+F) and Windows (Ctrl+F). + * If the shortcut is triggered, it will prevent the default action and focus + * on the first `input[type="search"]` element in the DOM, if available. + * + * @param {'macos' | 'windows'} platformName - Defines the current platform to handle the appropriate shortcut. + */ +function useSearchShortcut(platformName) { + useSignalEffect(() => { + const keydown = (e) => { + const isMacOS = platformName === 'macos'; + const isFindShortcutMacOS = isMacOS && e.metaKey && e.key === 'f'; + const isFindShortcutWindows = !isMacOS && e.ctrlKey && e.key === 'f'; + + if (isFindShortcutMacOS || isFindShortcutWindows) { + e.preventDefault(); + const searchInput = /** @type {HTMLInputElement|null} */ (document.querySelector(`input[type="search"]`)); + if (searchInput) { + searchInput.focus(); + } + } + }; + document.addEventListener('keydown', keydown); + return () => { + document.removeEventListener('keydown', keydown); + }; + }); +} + +/** + * @param {any} condition + * @param {string} [message] + * @return {asserts condition} + */ +export function invariant(condition, message) { + if (condition) return; + if (message) throw new Error('Invariant failed: ' + message); + throw new Error('Invariant failed'); +} diff --git a/special-pages/pages/history/app/components/Sidebar.js b/special-pages/pages/history/app/components/Sidebar.js new file mode 100644 index 0000000000..47f07229c8 --- /dev/null +++ b/special-pages/pages/history/app/components/Sidebar.js @@ -0,0 +1,193 @@ +import { h } from 'preact'; +import cn from 'classnames'; +import styles from './Sidebar.module.css'; +import AppStyles from './App.module.css'; +import { useComputed } from '@preact/signals'; +import { useTypedTranslation } from '../types.js'; +import { Trash } from '../icons/Trash.js'; +import { useTypedTranslationWith } from '../../../new-tab/app/types.js'; +import { useQueryContext, useQueryDispatch } from '../global/Providers/QueryProvider.js'; +import { useHistoryServiceDispatch } from '../global/Providers/HistoryServiceProvider.js'; + +/** + * @import json from "../strings.json" + * @typedef {import('../../types/history.js').Range} Range + * @typedef {import('../../types/history.js').RangeId} RangeId + */ + +/** @type {Record} */ +const iconMap = { + all: 'icons/all.svg', + today: 'icons/today.svg', + yesterday: 'icons/yesterday.svg', + monday: 'icons/day.svg', + tuesday: 'icons/day.svg', + wednesday: 'icons/day.svg', + thursday: 'icons/day.svg', + friday: 'icons/day.svg', + saturday: 'icons/day.svg', + sunday: 'icons/day.svg', + older: 'icons/older.svg', + sites: 'icons/sites.svg', +}; + +/** @type {Record string) => string>} */ +const titleMap = { + all: (t) => t('range_all'), + today: (t) => t('range_today'), + yesterday: (t) => t('range_yesterday'), + monday: (t) => t('range_monday'), + tuesday: (t) => t('range_tuesday'), + wednesday: (t) => t('range_wednesday'), + thursday: (t) => t('range_thursday'), + friday: (t) => t('range_friday'), + saturday: (t) => t('range_saturday'), + sunday: (t) => t('range_sunday'), + older: (t) => t('range_older'), + sites: (t) => t('range_sites'), +}; + +/** + * Renders a sidebar navigation component with links based on the provided ranges. + * + * @param {Object} props - The properties object. + * @param {import("@preact/signals").Signal} props.ranges - An array of range values used to generate navigation links. + */ +export function Sidebar({ ranges }) { + const { t } = useTypedTranslation(); + const search = useQueryContext(); + const current = useComputed(() => search.value.range); + const dispatch = useQueryDispatch(); + const historyServiceDispatch = useHistoryServiceDispatch(); + + /** + * @param {RangeId} range + */ + function onClick(range) { + if (range === 'all') { + dispatch({ kind: 'search-by-term', value: '' }); + } else if (range) { + dispatch({ kind: 'search-by-range', value: range }); + } + } + /** + * @param {RangeId} range + */ + function onDelete(range) { + historyServiceDispatch({ kind: 'delete-range', value: range }); + } + + return ( +
    +

    {t('page_title')}

    + +
    + ); +} + +/** + * A component that renders a list item with optional delete actions and a link. + * + * @param {Object} props + * @param {import('@preact/signals').ReadonlySignal} props.current The current selection with a value property. + * @param {RangeId} props.range The range represented by this item. + * @param {(range: RangeId) => void} props.onClick Callback function triggered when the range is clicked. + * @param {(range: RangeId) => void} props.onDelete Callback function triggered when the delete action is clicked. + * @param {number} props.count The count value associated with the ranges. + */ +function Item({ current, range, onClick, onDelete, count }) { + const { t } = useTypedTranslation(); + const { buttonLabel, linkLabel } = labels(range, t); + const classNames = useComputed(() => { + if (range === 'all' && current.value === null) { + return cn(styles.item, styles.active); + } + return cn(styles.item, current.value === range && styles.active, styles[`item_${range}`]); + }); + + return ( +
    + + +
    + ); +} + +/** + * The 'Delete' button for the 'all' range. This is a separate component because it contains + * logic that's only relevant to this row item. + * + * @param {Object} props - The properties passed to the component. + * @param {RangeId} props.range - The range value used for filtering and identification. + * @param {string} props.ariaLabel - The accessible label for the delete button. + * @param {(evt: RangeId) => void} props.onClick - The callback function triggered on button click. + * @param {number} props.count - A signal representing the count of items in the range. + */ +function DeleteAllButton({ range, onClick, ariaLabel, count }) { + const { t } = useTypedTranslationWith(/** @type {json} */ ({})); + + const ariaDisabled = count === 0 ? 'true' : 'false'; + const buttonTitle = count === 0 ? t('delete_none') : ''; + + return ( + + ); +} + +/** + * @param {RangeId} range + * @return {{linkLabel: string, buttonLabel: string}} + */ +function labels(range, t) { + switch (range) { + case 'all': + return { linkLabel: t('show_history_all'), buttonLabel: t('delete_history_all') }; + case 'today': + case 'yesterday': + case 'monday': + case 'tuesday': + case 'wednesday': + case 'thursday': + case 'friday': + case 'saturday': + case 'sites': + case 'sunday': + return { linkLabel: t('show_history_for', { range }), buttonLabel: t('delete_history_for', { range }) }; + case 'older': + return { linkLabel: t('show_history_older'), buttonLabel: t('delete_history_older') }; + } +} diff --git a/special-pages/pages/history/app/components/Sidebar.module.css b/special-pages/pages/history/app/components/Sidebar.module.css new file mode 100644 index 0000000000..6b15d6e30c --- /dev/null +++ b/special-pages/pages/history/app/components/Sidebar.module.css @@ -0,0 +1,161 @@ +.stack { + display: flex; + flex-direction: column; + height: 100%; + + >* { + min-width: 0 + } + + gap: 24px; +} + +.pageTitle { + color: var(--history-text-normal); + font-size: var(--title-font-size); + font-weight: var(--title-font-weight); + line-height: 24px; + padding: 10px 6px 10px 10px; +} + +.nav { + padding-right: 0; +} + +.item { + position: relative; + border-radius: 8px; + display: flex; + + &:hover, + &:focus-visible { + background: var(--color-black-at-6); + } + + [data-theme="dark"] & { + + &:hover, + &:focus-visible { + background: var(--color-white-at-6); + } + } + + &.item_sites { + margin-top: 16px; + + &::before { + content: ''; + position: absolute; + top: -8px; + left: 16px; + right: 16px; + border-top: 1px solid var(--color-black-at-6); + } + + [data-theme="dark"] &::before { + border-top: 1px solid var(--color-white-at-6); + } + } +} + +.item:hover .delete[aria-disabled="true"] { + opacity: 0.3; + cursor: default; +} + +.active { + background: var(--color-black-at-12); + + &:hover { + background: var(--color-black-at-18); + } + + [data-theme="dark"] & { + background: var(--color-white-at-9); + + &:hover { + background: var(--color-white-at-12); + } + } +} + + +.link { + font-size: var(--label-font-size); + font-weight: var(--label-font-weight); + line-height: var(--label-line-height); + color: var(--history-text-normal); + + height: 40px; + display: flex; + align-items: center; + border-radius: 8px; + padding-left: 16px; + text-decoration: none; + gap: 6px; + border: 0; + box-shadow: none; + background: transparent; + flex: 1; + white-space: normal; + text-align: left; + text-transform: capitalize; +} + +.delete { + height: 40px; + width: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + background: transparent; + border: none; + cursor: pointer; + opacity: 0; + color: var(--history-text-normal); + + &:not([aria-disabled="true"]) { + + &:hover, + &:focus-visible { + background: var(--color-black-at-9); + opacity: 1; + } + } + + &:active { + background: var(--color-black-at-12); + } + + [data-theme="dark"] & { + &:hover { + background: var(--color-white-at-9); + } + + &:active { + background: var(--color-white-at-12); + } + } + + .item:hover & { + opacity: 1; + } + + .link:focus-visible+& { + opacity: 1; + } + + svg path { + fill-opacity: 0.6; + } +} + +.icon { + width: 16px; + height: 16px; + display: block; + flex-shrink: 0; +} diff --git a/special-pages/pages/history/app/components/VirtualizedList.js b/special-pages/pages/history/app/components/VirtualizedList.js new file mode 100644 index 0000000000..3dad04a2c5 --- /dev/null +++ b/special-pages/pages/history/app/components/VirtualizedList.js @@ -0,0 +1,177 @@ +import { Fragment, h } from 'preact'; +import { memo } from 'preact/compat'; +import styles from './VirtualizedList.module.css'; +import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; + +/** + * @template T + * @typedef RenderProps + * @property {T} item + * @property {number} index + * @property {string} cssClassName + * @property {number} itemTopOffset + * @property {Record} style - inline styles to apply to your element + * + */ + +/** + * @template T + * @param {object} props + * @param {T[]} props.items + * @param {number[]} props.heights - a list of known heights for every item. This prevents needing to measure on the fly + * @param {number} props.overscan - how many items should be loaded before and after the current set on screen + * @param {string} props.scrollingElement - a CSS selector matching a parent element that will scroll + * @param {(arg: RenderProps) => import("preact").ComponentChild} props.renderItem - A function to render individual items. + * @param {(end: number)=>void} props.onChange - called when the end number is changed + */ +export function VirtualizedList({ items, heights, overscan, scrollingElement, onChange, renderItem }) { + const { start, end } = useVisibleRows(items, heights, scrollingElement, overscan); + const subset = items.slice(start, end + 1); + + /** + * Also publish the fact that the 'end' range changed - this is how 'fetch more' works + */ + useEffect(() => { + onChange?.(end); + }, [onChange, end]); + + return ( + + {subset.map((item, rowIndex) => { + const originalIndex = start + rowIndex; + const itemTopOffset = heights.slice(0, originalIndex).reduce((acc, item) => acc + item, 0); + return renderItem({ + item, + index: originalIndex, + cssClassName: styles.listItem, + itemTopOffset, + style: { + transform: `translateY(${itemTopOffset}px)`, + }, + }); + })} + + ); +} + +export const VisibleItems = memo(VirtualizedList); + +/** + * @param {Array} rows - The array of rows to be virtually rendered. Each row represents an item in the list. + * @param {number[]} heights - index lookup for known element heights + * @param {string} scrollerSelector - A CSS selector for tracking the scrollable area + * @param {number} overscan - how many items to fetch outside the window + * @return {Object} An object containing the calculated `start` and `end` indices of the visible rows. + */ +function useVisibleRows(rows, heights, scrollerSelector, overscan = 5) { + // set the start/end indexes of the elements + const [{ start, end }, setVisibleRange] = useState({ start: 0, end: 1 }); + + // hold a mutable value that we update on resize + const mainScrollerRef = useRef(/** @type {Element|null} */ (null)); + const scrollingSize = useRef(/** @type {number|null} */ (null)); + + /** + * When called, make the expensive calls to `getBoundingClientRect` to measure things + */ + function updateGlobals() { + if (!mainScrollerRef.current) return; + const rec = mainScrollerRef.current.getBoundingClientRect(); + scrollingSize.current = rec.height; + } + + /** + * decide which the start/end indexes should be, based on scroll position. + * NOTE: this is called on scroll, so must not incur expensive checks/measurements - math only! + */ + function setVisibleRowsForOffset() { + if (!mainScrollerRef.current) return console.warn('cannot access mainScroller ref'); + if (scrollingSize.current === null) return console.warn('need height'); + const scrollY = mainScrollerRef.current?.scrollTop ?? 0; + const next = calcVisibleRows(heights || [], scrollingSize.current, scrollY); + + const withOverScan = { + start: Math.max(next.startIndex - overscan, 0), + end: next.endIndex + overscan, + }; + + // don't set state if the offset didn't change + setVisibleRange((prev) => { + if (withOverScan.start !== prev.start || withOverScan.end !== prev.end) { + // todo: find a better place to emit this! + return { start: withOverScan.start, end: withOverScan.end }; + } + return prev; + }); + } + + useLayoutEffect(() => { + mainScrollerRef.current = document.querySelector(scrollerSelector) || document.documentElement; + if (!mainScrollerRef.current) console.warn('missing elements'); + + // always update globals first + updateGlobals(); + + // and set visible rows once the size is known + setVisibleRowsForOffset(); + + const controller = new AbortController(); + + // when the main area is scrolled, update the visible offset for the rows. + mainScrollerRef.current?.addEventListener('scroll', setVisibleRowsForOffset, { signal: controller.signal }); + + return () => { + controller.abort(); + }; + }, [rows, heights, scrollerSelector]); + + useEffect(() => { + let lastWindowHeight = window.innerHeight; + function handler() { + if (lastWindowHeight === window.innerHeight) return; + lastWindowHeight = window.innerHeight; + updateGlobals(); + setVisibleRowsForOffset(); + } + window.addEventListener('resize', handler); + return () => { + return window.removeEventListener('resize', handler); + }; + }, [heights, rows]); + + return { start, end }; +} + +/** + * @param {number[]} heights - an array of integers that represents a 1:1 mapping to `rows` - each value is pixels + * @param {number} space - the height in pixels that we have to fill + * @param {number} scrollOffset - the y offset in pixels representing scrolling + * @return {{startIndex: number, endIndex: number}} + */ +function calcVisibleRows(heights, space, scrollOffset) { + let startIndex = 0; + let endIndex = 0; + let currentHeight = 0; + + // Adjust startIndex for the scrollOffset + for (let i = 0; i < heights.length; i++) { + if (currentHeight + heights[i] > scrollOffset) { + startIndex = i; + break; + } + currentHeight += heights[i]; + } + + // Start calculating endIndex from the adjusted startIndex + currentHeight = 0; + for (let i = startIndex; i < heights.length; i++) { + if (currentHeight + heights[i] > space) { + endIndex = i; + break; + } + currentHeight += heights[i]; + endIndex = i; + } + + return { startIndex, endIndex }; +} diff --git a/special-pages/pages/history/app/components/VirtualizedList.module.css b/special-pages/pages/history/app/components/VirtualizedList.module.css new file mode 100644 index 0000000000..dbe4178e3d --- /dev/null +++ b/special-pages/pages/history/app/components/VirtualizedList.module.css @@ -0,0 +1,49 @@ +.container { + position: relative; +} + +.listItem { + display: block; + width: 100%; + position: absolute; + padding: 0; + margin: 0; +} + +.emptyState { + width: 100%; + height: 100%; + text-align: center; + color: var(--history-text-normal); + display: grid; + grid-template-rows: max-content max-content; + justify-items: center; +} + +.emptyStateOffset { + padding-top: 30vh; +} + +.icons { + width: 128px; + height: 96px; + position: relative; +} + +.forground { + position: absolute; + top: 50%; + left: 50%; + transform: translateY(-50%) translateX(-50%); +} + +.emptyTitle { + font-size: var(--title-3-em-font-size); + font-weight: var(--title-3-em-font-weight); + line-height: var(--title-3-em-line-height); + margin-top: 16px; +} +.emptyText { + margin-top: 8px; + color: var(--history-text-muted) +} diff --git a/special-pages/pages/history/app/constants.js b/special-pages/pages/history/app/constants.js new file mode 100644 index 0000000000..ec17fd80be --- /dev/null +++ b/special-pages/pages/history/app/constants.js @@ -0,0 +1,5 @@ +export const DDG_DEFAULT_ICON_SIZE = 32; +export const OVERSCAN_AMOUNT = 5; +export const BTN_ACTION_ENTRIES_MENU = 'entries_menu'; +export const BTN_ACTION_DELETE_RANGE = 'deleteRange'; +export const KNOWN_ACTIONS = /** @type {const} */ ([BTN_ACTION_ENTRIES_MENU, BTN_ACTION_DELETE_RANGE]); diff --git a/special-pages/pages/history/app/global/Providers/HistoryServiceProvider.js b/special-pages/pages/history/app/global/Providers/HistoryServiceProvider.js new file mode 100644 index 0000000000..2865c2426a --- /dev/null +++ b/special-pages/pages/history/app/global/Providers/HistoryServiceProvider.js @@ -0,0 +1,213 @@ +/* eslint-disable promise/prefer-await-to-then */ +import { createContext, h } from 'preact'; +import { paramsToQuery, toRange } from '../../history.service.js'; +import { useCallback, useContext } from 'preact/hooks'; +import { useQueryDispatch } from './QueryProvider.js'; +import { signal, useSignal, useSignalEffect } from '@preact/signals'; +import { generateHeights, generateViewIds } from '../../utils.js'; + +/** + * @typedef {import('../../../types/history.ts').HistoryQueryInfo} HistoryQueryInfo + * @typedef {import('../../../types/history.ts').HistoryQuery['source']} Source + * @typedef {{kind: 'search-commit', params: URLSearchParams, source: Source} + * | {kind: 'delete-range'; value: string } + * | {kind: 'delete-all'; } + * | {kind: 'delete-term'; term: string } + * | {kind: 'delete-domain'; domain: string } + * | {kind: 'delete-entries-by-index'; value: number[] } + * | {kind: 'open-url'; url: string, target: 'new-tab' | 'new-window' | 'same-tab' } + * | {kind: 'show-entries-menu'; indexes: number[] } + * | {kind: 'request-more'; end: number } + * } Action + */ + +/** + * @param {Action} action + */ +function defaultDispatch(action) { + console.log('would dispatch', action); +} +const HistoryServiceDispatchContext = createContext(defaultDispatch); + +/** + * @typedef {object} Results + * @property {import('../../../types/history.ts').HistoryItem[]} items + * @property {number[]} heights + * @property {string[]} viewIds + * @property {HistoryQueryInfo} info + */ +/** + * @typedef {import('../../../types/history.ts').Range} Range + * @import { ReadonlySignal } from '@preact/signals' + */ + +const ResultsContext = createContext( + /** @type {ReadonlySignal} */ ( + signal({ + items: [], + heights: [], + viewIds: [], + info: { finished: false, query: { term: '' } }, + }) + ), +); +const RangesContext = createContext(/** @type {ReadonlySignal} */ (signal([]))); + +/** + * Provides a context for the history service, allowing dependent components to access it. + * Everything that interacts with the service should be registered here + * + * @param {Object} props + * @param {import("../../history.service.js").HistoryService} props.service + * @param {import('../../history.service.js').InitialServiceData} props.initial - The initial state data for the history service. + * @param {import("preact").ComponentChild} props.children + */ +export function HistoryServiceProvider({ service, children, initial }) { + const queryDispatch = useQueryDispatch(); + const ranges = useSignal(initial.ranges.ranges); + const results = useSignal({ + info: initial.query.info, + items: initial.query.results, + heights: generateHeights(initial.query.results), + viewIds: generateViewIds(initial.query.results), + }); + + useSignalEffect(() => { + const unsub = service.onResults((data) => { + results.value = { + items: data.results, + info: data.info, + heights: generateHeights(data.results), + viewIds: generateViewIds(data.results), + }; + }); + + // Subscribe to changes in the 'ranges' data and reflect the updates into the UI + const unsubRanges = service.onRanges((data) => { + ranges.value = data.ranges; + }); + return () => { + unsub(); + unsubRanges(); + }; + }); + + /** + * @param {Action} action + */ + function dispatch(action) { + switch (action.kind) { + case 'search-commit': { + const asQuery = paramsToQuery(action.params, action.source); + service.trigger(asQuery); + break; + } + case 'delete-range': { + const range = toRange(action.value); + if (range) { + service + .deleteRange(range) + .then((resp) => { + if (resp.kind === 'delete') { + queryDispatch({ kind: 'reset' }); + service.refreshRanges(); + } + }) + .catch(console.error); + } + break; + } + case 'delete-domain': { + service + .deleteDomain(action.domain) + .then((resp) => { + if (resp.kind === 'delete') { + queryDispatch({ kind: 'reset' }); + service.refreshRanges(); + } + }) + .catch(console.error); + break; + } + case 'delete-entries-by-index': { + service + .entriesDelete(action.value) + .then((resp) => { + if (resp.kind === 'delete') { + service.refreshRanges(); + } + }) + .catch(console.error); + break; + } + case 'delete-all': { + service + .deleteRange('all') + .then((x) => { + if (x.kind === 'delete') { + service.refreshRanges(); + } + }) + .catch(console.error); + break; + } + case 'delete-term': { + service + .deleteTerm(action.term) + .then((resp) => { + if (resp.kind === 'delete') { + queryDispatch({ kind: 'reset' }); + service.refreshRanges(); + } + }) + .catch(console.error); + break; + } + case 'open-url': { + service.openUrl(action.url, action.target); + break; + } + case 'show-entries-menu': { + service + .entriesMenu(action.indexes) + .then((resp) => { + if (resp.kind === 'domain-search' && 'value' in resp) { + queryDispatch({ kind: 'search-by-domain', value: resp.value }); + } else if (resp.kind === 'delete') { + service.refreshRanges(); + } + }) + .catch(console.error); + break; + } + case 'request-more': { + service.requestMore(action.end); + break; + } + } + } + + const dispatcher = useCallback(dispatch, [service]); + + return ( + + + {children} + + + ); +} + +export function useHistoryServiceDispatch() { + return useContext(HistoryServiceDispatchContext); +} + +// Hook for consuming the context +export function useResultsData() { + return useContext(ResultsContext); +} + +// Hook for consuming the context +export function useRangesData() { + return useContext(RangesContext); +} diff --git a/special-pages/pages/history/app/global/Providers/QueryProvider.js b/special-pages/pages/history/app/global/Providers/QueryProvider.js new file mode 100644 index 0000000000..ec8a3bb15c --- /dev/null +++ b/special-pages/pages/history/app/global/Providers/QueryProvider.js @@ -0,0 +1,110 @@ +import { createContext, h } from 'preact'; +import { useCallback, useContext } from 'preact/hooks'; +import { signal, useSignal } from '@preact/signals'; + +/** + * @typedef {import('../../../types/history.ts').Range} Range + * @typedef {import('../../../types/history.ts').RangeId} RangeId + * @typedef {import('../../../types/history.ts').HistoryQuery['source']} Source + * @typedef {{ + * term: string | null, + * range: RangeId | null, + * domain: string | null, + * source: Source, + * }} QueryState - this is the value the entire application can read/observe + */ + +/** + * @typedef {{kind: 'reset'} + * | { kind: 'search-by-term', value: string } + * | { kind: 'search-by-domain', value: string } + * | { kind: 'search-by-range', value: string }} Action + */ + +const QueryContext = createContext( + /** @type {import('@preact/signals').ReadonlySignal} */ ( + signal({ + term: /** @type {string|null} */ (null), + range: /** @type {RangeId|null} */ (null), + domain: /** @type {string|null} */ (null), + source: /** @type {Source} */ ('initial'), + }) + ), +); + +const QueryDispatch = createContext( + /** @type {(a: Action) => void} */ ( + (_) => { + throw new Error('missing QueryDispatch'); + } + ), +); + +/** + * A provider for the global state related to the current query. It provides read-only access + * + * @param {Object} props - The props object for the component. + * @param {import('preact').ComponentChild} props.children - The child components wrapped within the provider. + * @param {import('../../../types/history.ts').QueryKind} [props.query=''] - The initial search term for the context. + */ +export function QueryProvider({ children, query = { term: '' } }) { + const initial = { + term: 'term' in query ? query.term : null, + range: 'range' in query ? query.range : null, + domain: 'domain' in query ? query.domain : null, + source: /** @type {Source} */ ('initial'), + }; + const queryState = useSignal(initial); + + /** + * All actions that can alter the query state come through here + * @param {Action} action + */ + function dispatch(action) { + queryState.value = (() => { + switch (action.kind) { + case 'reset': { + return { term: '', domain: null, range: null, source: /** @type {const} */ ('auto') }; + } + case 'search-by-domain': { + return { term: null, domain: action.value, range: null, source: /** @type {const} */ ('user') }; + } + case 'search-by-range': { + return { + term: null, + domain: null, + range: /** @type {RangeId} */ (action.value), + source: /** @type {const} */ ('user'), + }; + } + case 'search-by-term': { + return { term: action.value, domain: null, range: null, source: /** @type {const} */ ('user') }; + } + default: + return { term: '', domain: null, range: null, source: /** @type {const} */ ('auto') }; + } + })(); + } + + const dispatcher = useCallback(dispatch, [queryState]); + + return ( + + {children} + + ); +} + +/** + * A custom hook to access the SearchContext. + */ +export function useQueryContext() { + return useContext(QueryContext); +} + +/** + * A custom hook to access the SearchContext. + */ +export function useQueryDispatch() { + return useContext(QueryDispatch); +} diff --git a/special-pages/pages/history/app/global/Providers/SelectionProvider.js b/special-pages/pages/history/app/global/Providers/SelectionProvider.js new file mode 100644 index 0000000000..32b8215e7c --- /dev/null +++ b/special-pages/pages/history/app/global/Providers/SelectionProvider.js @@ -0,0 +1,208 @@ +import { createContext, h } from 'preact'; +import { useCallback, useContext } from 'preact/hooks'; +import { signal, useComputed } from '@preact/signals'; +import { usePlatformName } from '../../types.js'; +import { eventToIntention } from '../../utils.js'; +import { useHistoryServiceDispatch, useResultsData } from './HistoryServiceProvider.js'; +import { useSelectionStateApi } from '../hooks/useSelectionState.js'; + +/** + * @typedef {(s: (d: Set) => Set, reason: string) => void} UpdateSelected + * @typedef {import("../../utils.js").Intention} Intention + * @typedef {import('../hooks/useSelectionState.js').Action} Action + * @typedef {import('../hooks/useSelectionState.js').SelectionState} SelectionState + * @import { ReadonlySignal } from '@preact/signals' + */ + +const SelectionDispatchContext = createContext(/** @type {(a: Action) => void} */ ((_) => {})); +const SelectionStateContext = createContext(/** @type {ReadonlySignal} */ (signal({}))); + +/** + * Provides a context for the selections + state for managing updates (like keyboard+clicks) + * + * @param {Object} props - The properties object for the SelectionProvider component. + * @param {import("preact").ComponentChild} props.children - The child components that will consume the history service context. + */ +export function SelectionProvider({ children }) { + const { dispatch, state } = useSelectionStateApi(); + + return ( + + {children} + + ); +} + +export function useSelectionState() { + return useContext(SelectionStateContext); +} + +export function useSelected() { + const state = useContext(SelectionStateContext); + return useComputed(() => state.value.selected); +} + +export function useFocussedIndex() { + const state = useContext(SelectionStateContext); + return useComputed(() => state.value.focusedIndex); +} + +export function useSelectionDispatch() { + return useContext(SelectionDispatchContext); +} + +/** + * Handle onClick + keydown events to support most interactions with the list. + * @param {import('preact/hooks').MutableRef} mainRef + */ +export function useRowInteractions(mainRef) { + const platformName = usePlatformName(); + const dispatch = useSelectionDispatch(); + const selected = useSelected(); + const historyDispatch = useHistoryServiceDispatch(); + const results = useResultsData(); + const focusedIndex = useFocussedIndex(); + + /** + * @param {Intention} intention + * @param {{id: string; index: number}} selection + * @return {boolean} + */ + function handleRowClickIntentions(intention, selection) { + const { index } = selection; + switch (intention) { + case 'click': + dispatch({ kind: 'select-index', index, reason: intention }); + return true; + case 'ctrl+click': { + dispatch({ kind: 'toggle-index', index, reason: intention }); + return true; + } + case 'shift+click': { + dispatch({ kind: 'expand-selected-to-index', index, reason: intention }); + return true; + } + } + return false; + } + + function clickHandler(/** @type {MouseEvent} */ event) { + if (!(event.target instanceof Element)) return; + if (event.target.closest('button')) return; + if (event.target.closest('a')) return; + + const itemRow = /** @type {HTMLElement|null} */ (event.target.closest('[data-history-entry][data-index]')); + const intention = eventToIntention(event, platformName); + const selection = toRowSelection(itemRow); + if (selection) { + const handled = handleRowClickIntentions(intention, selection); + if (handled) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + } + } + + /** + * @param {Intention} intention + * @return {boolean} true if we handled this event + */ + function handleKeyIntention(intention) { + const total = results.value.items.length; + if (focusedIndex.value === null) return false; + + switch (intention) { + case 'shift+down': { + dispatch({ + kind: 'increment-selection', + direction: 'down', + total, + }); + return true; + } + case 'shift+up': { + dispatch({ + kind: 'increment-selection', + direction: 'up', + total, + }); + return true; + } + case 'up': + dispatch({ kind: 'move-selection', direction: 'up', total }); + return true; + case 'down': { + dispatch({ kind: 'move-selection', direction: 'down', total }); + return true; + } + case 'delete': { + if (selected.value.size === 0) break; + historyDispatch({ kind: 'delete-entries-by-index', value: [...selected.value] }); + break; + } + } + return false; + } + /** + * @param {Intention} intention + * @param {KeyboardEvent} event + */ + function handleGlobalKeyIntentions(intention, event) { + if (event.target !== document.body) return false; + switch (intention) { + case 'escape': { + dispatch({ + kind: 'reset', + reason: 'escape key pressed', + }); + return true; + } + } + return false; + } + + function keyHandler(/** @type {KeyboardEvent} */ event) { + const intention = eventToIntention(event, platformName); + if (intention === 'unknown') return; + if (focusedIndex.value === null) return; + let handled = false; + + /** + * If the target is body OR within the main scroller, handle the events such as selections + */ + if ( + event.target === document.body || + event.target === mainRef.current || + mainRef.current?.contains(/** @type {any} */ (event.target)) + ) { + handled = handleKeyIntention(intention); + } + + /** + * If it wasn't handled, try global things like `escape`? + */ + if (!handled) { + handled = handleGlobalKeyIntentions(intention, event); + } + + if (handled) event.preventDefault(); + } + + const onClick = useCallback(clickHandler, [selected, focusedIndex]); + const onKeyDown = useCallback(keyHandler, [selected, focusedIndex]); + + return { onClick, onKeyDown }; +} + +/** + * @param {null|HTMLElement} elem + * @returns {{id: string; index: number} | null} + */ +function toRowSelection(elem) { + if (elem === null) return null; + const { index, historyEntry } = elem.dataset; + if (typeof historyEntry !== 'string') return null; + if (typeof index !== 'string') return null; + if (!index.trim().match(/^\d+$/)) return null; + return { id: historyEntry, index: parseInt(index, 10) }; +} diff --git a/special-pages/pages/history/app/global/hooks/useAuxClickHandler.js b/special-pages/pages/history/app/global/hooks/useAuxClickHandler.js new file mode 100644 index 0000000000..1ee040ab9d --- /dev/null +++ b/special-pages/pages/history/app/global/hooks/useAuxClickHandler.js @@ -0,0 +1,29 @@ +import { usePlatformName } from '../../types.js'; +import { useEffect } from 'preact/hooks'; +import { eventToTarget } from '../../../../../shared/handlers.js'; +import { useHistoryServiceDispatch } from '../Providers/HistoryServiceProvider.js'; + +/** + * Support middle-button click + */ +export function useAuxClickHandler() { + const platformName = usePlatformName(); + const dispatch = useHistoryServiceDispatch(); + useEffect(() => { + const handleAuxClick = (event) => { + const row = /** @type {HTMLDivElement|null} */ (event.target.closest('[aria-selected]')); + const anchor = /** @type {HTMLAnchorElement|null} */ (row?.querySelector('a[href][data-url]')); + const url = anchor?.dataset.url; + if (anchor && url && event.button === 1) { + event.preventDefault(); + event.stopImmediatePropagation(); + const target = eventToTarget(event, platformName); + dispatch({ kind: 'open-url', url, target }); + } + }; + document.addEventListener('auxclick', handleAuxClick); + return () => { + document.removeEventListener('auxclick', handleAuxClick); + }; + }, [platformName, dispatch]); +} diff --git a/special-pages/pages/history/app/global/hooks/useButtonClickHandler.js b/special-pages/pages/history/app/global/hooks/useButtonClickHandler.js new file mode 100644 index 0000000000..7e2b707c9e --- /dev/null +++ b/special-pages/pages/history/app/global/hooks/useButtonClickHandler.js @@ -0,0 +1,75 @@ +import { useEffect } from 'preact/hooks'; +import { BTN_ACTION_ENTRIES_MENU, KNOWN_ACTIONS } from '../../constants.js'; +import { useHistoryServiceDispatch } from '../Providers/HistoryServiceProvider.js'; +import { useSelected } from '../Providers/SelectionProvider.js'; + +/** + * This function registers button click handlers that communicate with the history service. + * Depending on the `data-action` attribute of the clicked button, it triggers a specific action + * in the service, such as opening a menu, deleting a range, or deleting all entries. + * + * - "entries_menu": Triggers the `entriesMenu` method with the button value and dataset index. + */ +export function useButtonClickHandler() { + const historyServiceDispatch = useHistoryServiceDispatch(); + const selected = useSelected(); + useEffect(() => { + function clickHandler(/** @type {MouseEvent} */ event) { + if (!(event.target instanceof Element)) return; + + // was this a button click? + const btn = /** @type {HTMLButtonElement|null} */ (event.target.closest('button[data-action]')); + if (btn === null) return; + if (btn?.getAttribute('aria-disabled') === 'true') return; + + // if so, was it a known action? + const action = toKnownAction(btn); + if (action === null) return; + + // if we get this far, we're going to handle the event + event.stopImmediatePropagation(); + event.preventDefault(); + + switch (action) { + case BTN_ACTION_ENTRIES_MENU: { + const index = parseInt(btn.dataset.index ?? '-1', 10); + const withinSelection = selected.value.has(index); + if (withinSelection) { + historyServiceDispatch({ + kind: 'show-entries-menu', + indexes: [...selected.value], + }); + } else { + historyServiceDispatch({ + kind: 'show-entries-menu', + indexes: [Number(btn.dataset.index)], + }); + } + return; + } + default: + return null; + } + } + + document.addEventListener('click', clickHandler); + return () => { + document.removeEventListener('click', clickHandler); + }; + }, []); +} + +/** + * Converts an HTML button element with a `data-action` attribute + * into a known action type, based on the `KNOWN_ACTIONS` array. + * + * @param {HTMLButtonElement|null} elem - The button element to parse. + * @return {KNOWN_ACTIONS[number] | null} - The corresponding known action, or null if invalid. + */ +function toKnownAction(elem) { + if (!elem) return null; + const action = elem.dataset.action; + if (!action) return null; + if (KNOWN_ACTIONS.includes(/** @type {any} */ (action))) return /** @type {KNOWN_ACTIONS[number]} */ (action); + return null; +} diff --git a/special-pages/pages/history/app/global/hooks/useClickAnywhereElse.jsx b/special-pages/pages/history/app/global/hooks/useClickAnywhereElse.jsx new file mode 100644 index 0000000000..8fea30337c --- /dev/null +++ b/special-pages/pages/history/app/global/hooks/useClickAnywhereElse.jsx @@ -0,0 +1,18 @@ +import { useSelectionDispatch } from '../Providers/SelectionProvider.js'; +import { useCallback } from 'preact/hooks'; + +/** + * Custom hook that creates a callback function to handle click events occurring outside of specified elements. + * The callback dispatches a reset action when the click event target is not a button or an anchor element. + */ +export function useClickAnywhereElse() { + const dispatch = useSelectionDispatch(); + return useCallback( + (e) => { + if (e.target?.closest?.('button,a') === null) { + dispatch({ kind: 'reset', reason: 'click occurred outside of rows' }); + } + }, + [dispatch], + ); +} diff --git a/special-pages/pages/history/app/global/hooks/useContextMenuForEntries.js b/special-pages/pages/history/app/global/hooks/useContextMenuForEntries.js new file mode 100644 index 0000000000..f5da909ab3 --- /dev/null +++ b/special-pages/pages/history/app/global/hooks/useContextMenuForEntries.js @@ -0,0 +1,40 @@ +import { useEffect } from 'preact/hooks'; +import { useSelected } from '../Providers/SelectionProvider.js'; +import { useHistoryServiceDispatch } from '../Providers/HistoryServiceProvider.js'; + +/** + * Support for context menu on entries. This needs to be aware of + * selected regions so that it can either trigger a context menu + * for a group, or a single item + */ +export function useContextMenuForEntries() { + const selected = useSelected(); + const dispatch = useHistoryServiceDispatch(); + + useEffect(() => { + function contextMenu(event) { + const target = /** @type {HTMLElement|null} */ (event.target); + if (!(target instanceof HTMLElement)) return; + + // only act on history entries + const elem = target.closest('[data-history-entry]'); + if (!elem || !(elem instanceof HTMLElement)) return; + + event.preventDefault(); + event.stopImmediatePropagation(); + + const isSelected = elem.getAttribute('aria-selected') === 'true'; + if (isSelected) { + dispatch({ kind: 'show-entries-menu', indexes: [...selected.value] }); + } else { + dispatch({ kind: 'show-entries-menu', indexes: [Number(elem.dataset.index)] }); + } + } + + document.addEventListener('contextmenu', contextMenu); + + return () => { + document.removeEventListener('contextmenu', contextMenu); + }; + }, []); +} diff --git a/special-pages/pages/history/app/global/hooks/useLayoutMode.js b/special-pages/pages/history/app/global/hooks/useLayoutMode.js new file mode 100644 index 0000000000..d48a6d18fa --- /dev/null +++ b/special-pages/pages/history/app/global/hooks/useLayoutMode.js @@ -0,0 +1,33 @@ +import { useSignal } from '@preact/signals'; +import { useLayoutEffect } from 'preact/hooks'; + +/** + * This hook listens to changes in the viewport width and provides a + * reactive signal indicating whether the layout mode should be 'reduced' + * or 'normal'. + * + * - 'reduced': For narrow viewports (width <= 799px). + * - 'normal': For wider viewports (width > 799px). + * + * @returns {import('@preact/signals').ReadonlySignal<'reduced' | 'normal'>} + * A signal representing the current layout mode ('reduced' or 'normal'). + */ +export function useLayoutMode() { + const mode = useSignal(/** @type {'reduced' | 'normal'} */ (window.matchMedia('(max-width: 799px)').matches ? 'reduced' : 'normal')); + + useLayoutEffect(() => { + const mediaQuery = window.matchMedia('(max-width: 799px)'); + const handleChange = () => { + mode.value = mediaQuery.matches ? 'reduced' : 'normal'; + }; + + handleChange(); + mediaQuery.addEventListener('change', handleChange); + + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, []); + + return mode; +} diff --git a/special-pages/pages/history/app/global/hooks/useLinkClickHandler.js b/special-pages/pages/history/app/global/hooks/useLinkClickHandler.js new file mode 100644 index 0000000000..099f1803d8 --- /dev/null +++ b/special-pages/pages/history/app/global/hooks/useLinkClickHandler.js @@ -0,0 +1,71 @@ +import { useEffect } from 'preact/hooks'; +import { usePlatformName } from '../../types.js'; +import { eventToTarget } from '../../../../../shared/handlers.js'; +import { useHistoryServiceDispatch } from '../Providers/HistoryServiceProvider.js'; + +/** + * Registers click event handlers for anchor links (`` elements) having `href` and `data-url` attributes. + * Directs the `click` events with these links to interact with the provided history service. + * + * - Anchors with `data-url` attribute are intercepted, and their URLs are processed to determine + * the target action (`new-tab`, `same-tab`, or `new-window`) based on the click event details. + * - Prevents default navigation and propagation for handled events. + */ +export function useLinkClickHandler() { + const platformName = usePlatformName(); + const dispatch = useHistoryServiceDispatch(); + useEffect(() => { + /** + * Handles double-click events, and tries to open a link. + * + * @param {MouseEvent} event - The mouse event triggered by a click. + * @returns {void} - No return value. + */ + function dblClickHandler(event) { + const url = closestUrl(event); + if (url) { + event.preventDefault(); + event.stopImmediatePropagation(); + const target = eventToTarget(event, platformName); + dispatch({ kind: 'open-url', url, target }); + } + } + + /** + * Handles keydown events, specifically for Space or Enter keys, on anchor links. + * + * @param {KeyboardEvent} event - The keyboard event triggered by a keydown action. + * @returns {void} - No return value. + */ + function keydownHandler(event) { + if (event.key !== 'Enter' && event.key !== ' ') return; + const url = closestUrl(event); + if (url) { + event.preventDefault(); + event.stopImmediatePropagation(); + const target = eventToTarget(event, platformName); + dispatch({ kind: 'open-url', url, target }); + } + } + + document.addEventListener('keydown', keydownHandler); + document.addEventListener('dblclick', dblClickHandler); + + return () => { + document.removeEventListener('dblclick', dblClickHandler); + document.removeEventListener('keydown', keydownHandler); + }; + }, [platformName, dispatch]); +} + +/** + * @param {KeyboardEvent|MouseEvent} event + * @return {string|null} + */ +function closestUrl(event) { + if (!(event.target instanceof Element)) return null; + const row = /** @type {HTMLDivElement|null} */ (event.target.closest('[aria-selected]')); + const anchor = /** @type {HTMLAnchorElement|null} */ (row?.querySelector('a[href][data-url]')); + const url = anchor?.dataset.url; + return url || null; +} diff --git a/special-pages/pages/history/app/global/hooks/useResetSelectionsOnQueryChange.js b/special-pages/pages/history/app/global/hooks/useResetSelectionsOnQueryChange.js new file mode 100644 index 0000000000..156ec40daf --- /dev/null +++ b/special-pages/pages/history/app/global/hooks/useResetSelectionsOnQueryChange.js @@ -0,0 +1,40 @@ +import { useComputed, useSignalEffect } from '@preact/signals'; +import { useQueryContext } from '../Providers/QueryProvider.js'; +import { useResultsData } from '../Providers/HistoryServiceProvider.js'; +import { useSelectionDispatch } from '../Providers/SelectionProvider.js'; + +/** + * Subscribe to changes in the query, and reset selections when they change + */ +export function useResetSelectionsOnQueryChange() { + const dispatch = useSelectionDispatch(); + const query = useQueryContext(); + const results = useResultsData(); + const length = useComputed(() => results.value.items.length); + + useSignalEffect(() => { + let prevLength = 0; + const unsubs = [ + // when anything about the query changes, reset selections + query.subscribe(() => { + dispatch({ kind: 'reset', reason: 'query changed' }); + }), + // when the size of data is smaller than before, reset + length.subscribe((newLength) => { + if (newLength < prevLength) { + dispatch({ + kind: 'reset', + reason: `items length shrank from ${prevLength} to ${newLength}`, + }); + } + prevLength = newLength; + }), + ]; + + return () => { + for (const unsub of unsubs) { + unsub(); + } + }; + }); +} diff --git a/special-pages/pages/history/app/global/hooks/useSearchCommit.js b/special-pages/pages/history/app/global/hooks/useSearchCommit.js new file mode 100644 index 0000000000..d18fcf6d7a --- /dev/null +++ b/special-pages/pages/history/app/global/hooks/useSearchCommit.js @@ -0,0 +1,49 @@ +import { useHistoryServiceDispatch } from '../Providers/HistoryServiceProvider.js'; +import { useSettings } from '../../types.js'; +import { useSignalEffect } from '@preact/signals'; +import { useQueryContext } from '../Providers/QueryProvider.js'; + +/** + * Updates the URL with the latest search term (if present) and dispatches a custom search commit event. + * Utilizes a debounce mechanism to ensure the URL updates are not performed too often during typing. + * + * Workflow: + * - Listens for changes in the `derivedTerm` signal. + * - For the first signal emission (`counter === 0`), skips processing to avoid triggering on initial load. + * - Debounces subsequent changes using `settings.typingDebounce`. + * - If a non-null value is provided, constructs query parameters with the new term and dispatches + * an `EVENT_SEARCH_COMMIT` event with the updated parameters. + * - If the value is `null`, no action is taken. + */ +export function useSearchCommit() { + const dispatch = useHistoryServiceDispatch(); + const settings = useSettings(); + const query = useQueryContext(); + useSignalEffect(() => { + let timer; + let count = 0; + const unsubscribe = query.subscribe((next) => { + if (count === 0) return (count += 1); + clearTimeout(timer); + if (next.term !== null) { + const term = next.term; + const source = next.source; + timer = setTimeout(() => { + const params = new URLSearchParams(); + params.set('q', term); + dispatch({ kind: 'search-commit', params, source }); + }, settings.typingDebounce); + } + if (next.domain !== null) { + const params = new URLSearchParams(); + params.set('domain', next.domain); + dispatch({ kind: 'search-commit', params, source: next.source }); + } + return null; + }); + return () => { + unsubscribe(); + clearTimeout(timer); + }; + }); +} diff --git a/special-pages/pages/history/app/global/hooks/useSearchCommitForRange.js b/special-pages/pages/history/app/global/hooks/useSearchCommitForRange.js new file mode 100644 index 0000000000..aa20b5264c --- /dev/null +++ b/special-pages/pages/history/app/global/hooks/useSearchCommitForRange.js @@ -0,0 +1,40 @@ +import { useHistoryServiceDispatch } from '../Providers/HistoryServiceProvider.js'; +import { useQueryContext } from '../Providers/QueryProvider.js'; +import { useEffect } from 'preact/hooks'; + +/** + * Synchronizes the `derivedRange` signal with the browser's URL and issues a + * 'search-commit'. This allows any part of the application to change the range and + * have it synchronised to the URL + trigger a search + */ +export function useSearchCommitForRange() { + const dispatch = useHistoryServiceDispatch(); + const query = useQueryContext(); + + useEffect(() => { + let timer; + let counter = 0; + const sub = query.subscribe((nextQuery) => { + const { range } = nextQuery; + if (counter === 0) { + counter += 1; + return; + } + const url = new URL(window.location.href); + + url.searchParams.delete('q'); + url.searchParams.delete('range'); + + if (range !== null) { + url.searchParams.set('range', range); + window.history.replaceState(null, '', url.toString()); + dispatch({ kind: 'search-commit', params: new URLSearchParams(url.searchParams), source: 'user' }); + } + }); + + return () => { + sub(); + clearTimeout(timer); + }; + }, [query, dispatch]); +} diff --git a/special-pages/pages/history/app/global/hooks/useSelectionState.js b/special-pages/pages/history/app/global/hooks/useSelectionState.js new file mode 100644 index 0000000000..0b83987b57 --- /dev/null +++ b/special-pages/pages/history/app/global/hooks/useSelectionState.js @@ -0,0 +1,207 @@ +import { useCallback } from 'preact/hooks'; +import { useComputed, useSignal } from '@preact/signals'; +import { invariant } from '../../utils.js'; + +/** + * @typedef {(s: (d: Set) => Set, reason: string) => void} UpdateSelected + * @typedef {import("../../utils.js").Intention} Intention + * @typedef {{ + * focusedIndex: number|null; + * anchorIndex: number|null; + * lastShiftRange: { end: number|null; start: number|null }; + * lastAction: Action['kind'] | null; + * selected: Set; + * }} SelectionState + * @import { ReadonlySignal } from '@preact/signals' + */ + +/** + * @typedef {{kind: 'select-index', index: number, reason?: string} + * | {kind: 'toggle-index'; index: number; reason?: string} + * | {kind: 'expand-selected-to-index'; index: number; reason?: string} + * | {kind: 'inc-or-dec-selected'; nextIndex: number; reason?: string} + * | {kind: 'move-selection'; direction: 'up' | 'down'; total: number; reason?: string} + * | {kind: 'increment-selection'; direction: 'up' | 'down'; total: number; reason?: string} + * | {kind: 'reset'; reason?: string} + * } Action + */ + +/** @satisfies {SelectionState} */ +const defaultState = { + anchorIndex: /** @type {null|number} */ (null), + /** @type {{start: null|number; end: null|number}} */ + lastShiftRange: { + start: null, + end: null, + }, + focusedIndex: /** @type {null|number} */ (null), + selected: new Set(/** @type {number[]} */ ([])), + lastAction: /** @type {Action['kind']|null} */ (null), +}; + +/** + * @returns {{ + * selected: ReadonlySignal>; + * state: ReadonlySignal; + * dispatch: (action: Action) => void; + * }} + */ +export function useSelectionStateApi() { + const state = useSignal(defaultState); + const selected = useComputed(() => state.value.selected); + /** + * @param {Action} evt + */ + function dispatcher(evt) { + const next = reducer(state.value, evt); + next.lastAction = evt.kind; + state.value = next; + } + const dispatch = useCallback(dispatcher, [state, selected]); + return { selected, dispatch, state }; +} + +/** + * @param {SelectionState} prev + * @param {Action} evt + * @return {SelectionState} + */ +export function reducer(prev, evt) { + switch (evt.kind) { + case 'reset': { + return { + ...defaultState, + }; + } + case 'move-selection': { + const { focusedIndex } = prev; + invariant(focusedIndex !== null); + const delta = evt.direction === 'up' ? -1 : 1; + // either the last item, or current + 1 + const max = Math.min(evt.total - 1, focusedIndex + delta); + const newIndex = Math.max(0, max); + const newSelected = new Set([newIndex]); + return { + ...prev, + anchorIndex: newIndex, + focusedIndex: newIndex, + lastShiftRange: { start: null, end: null }, + selected: newSelected, + }; + } + case 'select-index': { + const newSelected = new Set([evt.index]); + return { + ...prev, + anchorIndex: evt.index, + focusedIndex: evt.index, + lastShiftRange: { start: null, end: null }, + selected: newSelected, + }; + } + case 'toggle-index': { + const newSelected = new Set(prev.selected); + if (newSelected.has(evt.index)) { + newSelected.delete(evt.index); + } else { + newSelected.add(evt.index); + } + return { + ...prev, + anchorIndex: evt.index, + lastShiftRange: { start: null, end: null }, + focusedIndex: evt.index, + selected: newSelected, + }; + } + case 'expand-selected-to-index': { + const { anchorIndex, lastShiftRange } = prev; + const newSelected = new Set(prev.selected); + + // If there was a previous shift selection, remove it first + if (lastShiftRange.start !== null && lastShiftRange.end !== null) { + for (let i = lastShiftRange.start; i <= lastShiftRange.end; i++) { + newSelected.delete(i); + } + } + + // Calculate new range bounds from the anchor point + const start = Math.min(anchorIndex ?? 0, evt.index); + const end = Math.max(anchorIndex ?? 0, evt.index); + + // Add all items in new range to selection + for (let i = start; i <= end; i++) { + newSelected.add(i); + } + + return { + ...prev, + lastShiftRange: { start, end }, + focusedIndex: evt.index, + selected: newSelected, + }; + } + case 'inc-or-dec-selected': { + const { anchorIndex, lastShiftRange } = prev; + // Handle shift+arrow selection + const newSelected = new Set(prev.selected); + + // Remove previous shift range + if (lastShiftRange.start !== null && lastShiftRange.end !== null) { + for (let i = lastShiftRange.start; i <= lastShiftRange.end; i++) { + newSelected.delete(i); + } + } + + // Calculate new range + const start = Math.min(anchorIndex ?? evt.nextIndex, evt.nextIndex); + const end = Math.max(anchorIndex ?? evt.nextIndex, evt.nextIndex); + + // Add new range + for (let i = start; i <= end; i++) { + newSelected.add(i); + } + return { + ...prev, + focusedIndex: evt.nextIndex, + lastShiftRange: { start, end }, + anchorIndex: anchorIndex === null ? evt.nextIndex : anchorIndex, + selected: newSelected, + }; + } + case 'increment-selection': { + const { focusedIndex, anchorIndex, lastShiftRange } = prev; + invariant(focusedIndex !== null); + const delta = evt.direction === 'up' ? -1 : 1; + const newIndex = Math.max(0, Math.min(evt.total - 1, focusedIndex + delta)); + + // Handle shift+arrow selection + const newSelected = new Set(prev.selected); + + // Remove previous shift range + if (lastShiftRange.start !== null && lastShiftRange.end !== null) { + for (let i = lastShiftRange.start; i <= lastShiftRange.end; i++) { + newSelected.delete(i); + } + } + + // Calculate new range + const start = Math.min(anchorIndex ?? newIndex, newIndex); + const end = Math.max(anchorIndex ?? newIndex, newIndex); + + // Add new range + for (let i = start; i <= end; i++) { + newSelected.add(i); + } + return { + ...prev, + focusedIndex: newIndex, + lastShiftRange: { start, end }, + anchorIndex: anchorIndex === null ? newIndex : anchorIndex, + selected: newSelected, + }; + } + default: + return prev; + } +} diff --git a/special-pages/pages/history/app/global/hooks/useURLReflection.js b/special-pages/pages/history/app/global/hooks/useURLReflection.js new file mode 100644 index 0000000000..d95e4c4471 --- /dev/null +++ b/special-pages/pages/history/app/global/hooks/useURLReflection.js @@ -0,0 +1,54 @@ +import { useSettings } from '../../types.js'; +import { useSignalEffect } from '@preact/signals'; +import { useQueryContext } from '../Providers/QueryProvider.js'; + +/** + * Updates the URL with the latest search term (if present) and dispatches a custom event with the updated query parameters. + * Debounces the updates based on the `settings.typingDebounce` value to avoid frequent URL state changes during typing. + * + * This hook uses a signal effect to listen for changes in the `derivedTerm` and updates the browser's URL accordingly, with debounce support. + * It dispatches an `EVENT_SEARCH_COMMIT` event to notify other components or parts of the application about the updated search parameters. + * + */ +export function useURLReflection() { + const settings = useSettings(); + const query = useQueryContext(); + useSignalEffect(() => { + let timer; + let count = 0; + const unsubscribe = query.subscribe((nextValue) => { + if (count === 0) return (count += 1); + clearTimeout(timer); + if (nextValue.term !== null) { + const term = nextValue.term; + timer = setTimeout(() => { + const url = new URL(window.location.href); + + url.searchParams.set('q', term); + url.searchParams.delete('range'); + url.searchParams.delete('domain'); + + if (term.trim() === '') { + url.searchParams.delete('q'); + } + + window.history.replaceState(null, '', url.toString()); + }, settings.urlDebounce); + } + if (nextValue.domain !== null) { + const url = new URL(window.location.href); + url.searchParams.set('domain', nextValue.domain); + url.searchParams.delete('q'); + url.searchParams.delete('range'); + + window.history.replaceState(null, '', url.toString()); + } + return null; + }); + + return () => { + unsubscribe(); + clearTimeout(timer); + }; + }); +} diff --git a/special-pages/pages/history/app/history.md b/special-pages/pages/history/app/history.md new file mode 100644 index 0000000000..0618279c5b --- /dev/null +++ b/special-pages/pages/history/app/history.md @@ -0,0 +1,312 @@ +--- +title: History View +--- + +# History view + +## Requests + +### `initialSetup` +{@link "History Messages".InitialSetupRequest} + +Configure initial history system settings. + +**Types:** +- Response: {@link "History Messages".InitialSetupResponse} + +```json +{ + "locale": "en", + "env": "production", + "platform": { + "name": "macos" + } +} +``` + +With {@link "History Messages".DefaultStyles} overrides + +```json +{ + "locale": "en", + "env": "production", + "platform": { + "name": "macos" + }, + "customizer": { + "defaultStyles": { + "lightBackgroundColor": "#E9EBEC", + "darkBackgroundColor": "#27282A" + } + } +} +``` + +### `getRanges` +{@link "History Messages".GetRangesRequest} + +Retrieves available time ranges for history filtering. + +**Types:** +- Response: {@link "History Messages".GetRangesResponse} + +```json +{ + "ranges": [ + { + "id": "today", + "count": 13 + }, + { + "id": "yesterday", + "count": 10 + }, + { + "id": "monday", + "count": 2 + }, + { + "id": "older", + "count": 120 + } + ] +} +``` + + +### `query` +{@link "History Messages".QueryRequest} + +Queries history items with filtering and pagination. + +**Types:** +- Parameters: {@link "History Messages".HistoryQuery} +- Response: {@link "History Messages".HistoryQueryResponse} + +params for a query: (note: can be an empty string!) + +```json +{ + "query": { + "term": "example.com" + }, + "offset": 0, + "limit": 50, + "source": "initial" +} +``` + +params for a range, note: the values here will match what you returned from `getRanges` + +```json +{ + "query": { + "range": "today" + }, + "offset": 0, + "limit": 50, + "source": "initial" +} +``` + +Response, note: always return the same query I sent: + +```json +{ + "info": { + "finished": false, + "query": { + "term": "example.com" + } + }, + "value": [ + { + "id": "12345", + "dateRelativeDay": "Today - Wednesday 15 January 2025", + "dateShort": "15 Jan 2025", + "dateTimeOfDay": "11:01", + "domain": "example.com", + "etldPlusOne": "example.com", + "title": "Example Website", + "url": "https://example.com/page", + "favicon": { + "src": "...", + "maxAvailableSize": 64 + } + } + ] +} +``` + +### `deleteRange` +- Sent to delete a range as displayed in the sidebar. +- Parameters: {@link "History Messages".DeleteRangeParams} +- If the user confirms, respond with `{ action: 'delete' }` +- otherwise `{ action: 'none' }` + - Response: {@link "History Messages".DeleteRangeResponse} + +**params** +```json +{ + "range": "today" +} +``` + +**response** +```json +{ + "action": "delete" +} +``` + +### `deleteDomain` +- Sent to delete a domain - which might be the etld+1 or domain. +- Parameters: {@link "History Messages".DeleteDomainParams} +- If the user confirms, respond with `{ action: 'delete' }` +- otherwise `{ action: 'none' }` + - Response: {@link "History Messages".DeleteDomainResponse} + +**params** +```json +{ + "domain": "youtube.com" +} +``` + +**response** +```json +{ + "action": "delete" +} +``` + +### `deleteTerm` +- Sent to delete a search term +- Parameters: {@link "History Messages".DeleteTermParams} +- If the user confirms, respond with `{ action: 'delete' }` +- otherwise `{ action: 'none' }` + - Response: {@link "History Messages".DeleteTermResponse} + +**params** +```json +{ + "term": "youtube" +} +``` + +**response** +```json +{ + "action": "delete" +} +``` + +**response, if deleted** +```json +{ + "action": "delete" +} +``` + +**response, otherwise** +```json +{ + "action": "none" +} +``` + +### `entries_menu` +{@link "History Messages".EntriesMenuRequest} + +Sent when a right-click is issued on a section title (or when the three-dots button is clicked) + +**Types:** +- Parameters: {@link "History Messages".EntriesMenuParams} +- Response: {@link "History Messages".EntriesMenuResponse} + +**params** +```json +{ + "ids": ["abc", "def"] +} +``` + +**response, if deleted** +```json +{ + "action": "delete" +} +``` + +**response, to trigger a domain search** +```json +{ + "action": "domain-search" +} +``` + +**response, otherwise** +```json +{ + "action": "none" +} +``` + +### `entries_delete` +{@link "History Messages".EntriesDeleteRequest} +{@link "History Messages".EntriesDeleteRequest} + +Sent when the delete key is pressed on an item, or a group of items + +**Types:** +- Parameters: {@link "History Messages".EntriesDeleteParams} +- Response: {@link "History Messages".EntriesDeleteResponse} + +Note: if a single `id` is sent, **no modal/confirmation should be shown** - but you must +still reply with an {@link "History Messages".ActionResponse} when the action was completed. + +If multiple `id`s are sent, then present a modal window for confirmation, eventually +responding to the message with {@link "History Messages".ActionResponse} + +## Notifications + +### `open` +- {@link "History Messages".OpenNotification} +- Sent when a user clicks a link, sends {@link "History Messages".OpenNotification} +- Target is one of {@link "History Messages".OpenTarget} + +example payload +```json +{ + "url": "https://example.com/path", + "target": "same-tab" +} +``` +```json +{ + "url": "https://example.com/path", + "target": "new-tab" +} +``` + +### `reportInitException` +{@link "History Messages".ReportInitExceptionNotification} + +Reports initialization errors in the history system. + +```json +{ + "message": "Failed to initialize history database" +} +``` + +### `reportPageException` +{@link "History Messages".ReportPageExceptionNotification} + +Reports errors during page history operations. + +```json +{ + "message": "Failed to load page history" +} +``` \ No newline at end of file diff --git a/special-pages/pages/history/app/history.range.service.js b/special-pages/pages/history/app/history.range.service.js new file mode 100644 index 0000000000..0883acff42 --- /dev/null +++ b/special-pages/pages/history/app/history.range.service.js @@ -0,0 +1,92 @@ +/** + * @typedef {import('../types/history.js').Range} Range + * @typedef {import('../types/history.js').RangeId} RangeId + * @typedef {{ranges: Range[]}} RangeData + * @typedef {{kind: 'none'} | { kind: 'domain-search'; value: string }} MenuContinuation + */ + +export class HistoryRangeService { + static REFRESH_EVENT = 'refresh'; + static DATA_EVENT = 'data'; + index = 0; + internal = new EventTarget(); + dataReadinessSignal = new EventTarget(); + + /** + * @type {RangeData|null} + */ + ranges = null; + + /** + * @param {import("../src/index.js").HistoryPage} history + */ + constructor(history) { + this.history = history; + + this.internal.addEventListener(HistoryRangeService.REFRESH_EVENT, () => { + // increment the counter + this.index++; + // and, store a local index, we can check it when the promise resolves + const index = this.index; + + this.fetcher().then((next) => { + /** + * First, reject overlapping promises + */ + const resolvedPromiseIsStale = this.index !== index; + if (resolvedPromiseIsStale) return console.log('❌ rejected stale result'); + this.accept(next); + }); + }); + } + + /** + * @param {RangeData} d + */ + accept(d) { + this.ranges = d; + this.dataReadinessSignal.dispatchEvent(new Event(HistoryRangeService.DATA_EVENT)); + } + + fetcher() { + console.log(`🦻 [getRanges]`); + return this.history.messaging.request('getRanges'); + } + + /** + * @returns {Promise} + */ + async getInitial() { + const rangesPromise = await this.fetcher(); + this.accept(rangesPromise); + return rangesPromise; + } + + refresh() { + this.internal.dispatchEvent(new Event(HistoryRangeService.REFRESH_EVENT)); + } + + /** + * @param {(data: RangeData) => void} cb + */ + onResults(cb) { + const controller = new AbortController(); + this.dataReadinessSignal.addEventListener( + HistoryRangeService.DATA_EVENT, + () => { + if (this.ranges === null) throw new Error('unreachable'); + cb(this.ranges); + }, + { signal: controller.signal }, + ); + return () => controller.abort(); + } + + /** + * @param {RangeId} range + */ + async deleteRange(range) { + console.log('📤 [deleteRange]: ', JSON.stringify({ range })); + return await this.history.messaging.request('deleteRange', { range }); + } +} diff --git a/special-pages/pages/history/app/history.service.js b/special-pages/pages/history/app/history.service.js new file mode 100644 index 0000000000..50c3814670 --- /dev/null +++ b/special-pages/pages/history/app/history.service.js @@ -0,0 +1,453 @@ +import { OVERSCAN_AMOUNT } from './constants.js'; +import { HistoryRangeService } from './history.range.service.js'; +import { viewTransition } from '../../new-tab/app/utils.js'; + +/** + * @import {ActionResponse} from "../types/history.js" + * @typedef {import('../types/history.js').Range} Range + * @typedef {import('../types/history.js').RangeId} RangeId + * @typedef {import('../types/history.js').HistoryQuery} HistoryQuery + * @typedef {import("../types/history.js").HistoryQueryInfo} HistoryQueryInfo + * @typedef {import("../types/history.js").QueryKind} QueryKind + * @typedef {import('./history.range.service.js').RangeData} RangeData + * @typedef {{info: HistoryQueryInfo; lastQueryParams: HistoryQuery|null; results: import('../types/history.js').HistoryItem[]}} QueryData + * @typedef {{query: QueryData; ranges: RangeData}} InitialServiceData + * @typedef {{kind: 'none'} | { kind: 'domain-search'; value: string }} MenuContinuation + */ + +/** + * @typedef {{kind: 'none'} |{ kind: ActionResponse } | {kind: 'domain-search'; value: string}} ServiceResult + */ + +export class HistoryService { + static CHUNK_SIZE = 150; + static QUERY_EVENT = 'query'; + static QUERY_MORE_EVENT = 'query-more'; + /** + * @return {QueryData} + */ + static defaultData() { + return { + lastQueryParams: null, + info: { + query: { term: '' }, + finished: true, + }, + results: [], + }; + } + + /** + * @type {QueryData} + */ + data = HistoryService.defaultData(); + + internal = new EventTarget(); + dataReadinessSignal = new EventTarget(); + + /** @type {HistoryQuery|null} */ + ongoing = null; + index = 0; + + /** + * @param {import("../src/index.js").HistoryPage} history + */ + constructor(history) { + this.history = history; + this.range = new HistoryRangeService(this.history); + + /** + * Conduct a query + */ + this.internal.addEventListener(HistoryService.QUERY_EVENT, (/** @type {CustomEvent} */ evt) => { + const { detail } = evt; + + // reject duplicates (eg: already fetching the same query) + if (eq(detail, this.ongoing)) return; + + // increment the counter + this.index++; + + // and, store a local index, we can check it when the promise resolves + const index = this.index; + + // store a snapshot of the ongoing query + this.ongoing = JSON.parse(JSON.stringify(detail)); + + this.queryFetcher(detail) + .then((next) => { + const old = this.data; + if (old === null) throw new Error('unreachable - typescript this.query must always be there?'); + + /** + * First, reject overlapping promises + */ + const resolvedPromiseIsStale = this.index !== index; + if (resolvedPromiseIsStale) return console.log('❌ rejected stale result'); + + /** + * concatenate results if this was a 'fetch more' request, or overwrite + */ + let valueToPublish; + if (queryEq(old.info.query, next.info.query) && next.lastQueryParams?.offset > 0) { + const results = old.results.concat(next.results); + valueToPublish = { info: next.info, results, lastQueryParams: next.lastQueryParams }; + } else { + valueToPublish = next; + } + + this.accept(valueToPublish); + }) + .catch((e) => { + console.error(e, detail); + }); + }); + + /** + * Allow consumers to request 'more' - we'll ignore when the list is 'finished', + * but otherwise will just increment the offset by the current length. + */ + this.internal.addEventListener(HistoryService.QUERY_MORE_EVENT, (/** @type {CustomEvent<{end: number}>} */ evt) => { + // console.log('🦻 [query-more]', evt.detail, this.query?.info); + if (!this.data) return; + /** + * 'end' is the index of the last seen element. We use that + the result set & OVERSCAN_AMOUNT + * whether to decide to fetch more data. + * + * Example: + * - if !finished (meaning the backend has more data) + * - and 'end' was 146 (meaning the 146th element was scrolled into view) + * - and memory.length was 150 (meaning we've got 150 items in memory) + * - and OVERSCAN_AMOUNT = 5 + * - that means we WOULD fetch more, because memory.length - end = 4, which is less than OVERSCAN_AMOUNT + * - but if 'end' was 140, we would NOT fetch. because memory.length - end = 10 which is not less than OVERSCAN_AMOUNT + * + */ + if (this.data.info.finished) return; + const { end } = evt.detail; + + const memory = this.data.results; + if (memory.length - end < OVERSCAN_AMOUNT) { + const lastquery = this.data.info.query; + /** @type {HistoryQuery} */ + const query = { + query: lastquery, + limit: HistoryService.CHUNK_SIZE, + offset: this.data.results.length, + source: 'user', + }; + this.internal.dispatchEvent(new CustomEvent(HistoryService.QUERY_EVENT, { detail: query })); + } + }); + } + + /** + * To 'accept' data is to store a local reference to it and treat it as 'latest' + * We also want to broadcast the fact that new data can be read. + * @param {QueryData} data + */ + accept(data) { + this.data = data; + this.ongoing = null; + this.dataReadinessSignal.dispatchEvent(new Event('data')); + } + + /** + * The single place for the query to be made + * @param {HistoryQuery} query + */ + queryFetcher(query) { + console.log(`🦻 [query] ${JSON.stringify(query.query)} offset: ${query.offset}, limit: ${query.limit} source: ${query.source}`); + // eslint-disable-next-line promise/prefer-await-to-then + return this.history.messaging.request('query', query).then((resp) => { + return { info: resp.info, results: resp.value, lastQueryParams: query }; + }); + } + + /** + * @param {HistoryQuery} initQuery + * @returns {Promise} + */ + async getInitial(initQuery) { + const queryPromise = this.queryFetcher(initQuery); + const rangesPromise = this.range.getInitial(); + const [query, ranges] = await Promise.all([queryPromise, rangesPromise]); + this.accept(query); + return { query, ranges }; + } + + /** + * Allow consumers to be notified when data has changed + * @param {(data: QueryData) => void} cb + */ + onResults(cb) { + const controller = new AbortController(); + this.dataReadinessSignal.addEventListener( + 'data', + () => { + if (this.data === null) throw new Error('unreachable'); + cb(this.data); + }, + { signal: controller.signal }, + ); + return () => controller.abort(); + } + + /** + * @param {(data: RangeData) => void} cb + */ + onRanges(cb) { + return this.range.onResults(cb); + } + + /** + * @param {HistoryQuery} query + */ + trigger(query) { + this.internal.dispatchEvent(new CustomEvent(HistoryService.QUERY_EVENT, { detail: query })); + } + + refreshRanges() { + this.range.refresh(); + } + + /** + * @param {number} end - the index of the last seen element + */ + requestMore(end) { + this.internal.dispatchEvent(new CustomEvent(HistoryService.QUERY_MORE_EVENT, { detail: { end } })); + } + + /** + * @param {string} url + * @param {import('../types/history.js').OpenTarget} target + */ + openUrl(url, target) { + this.history.messaging.notify('open', { url, target }); + } + + /** + * @param {number[]} indexes + * @return {Promise} + */ + async entriesMenu(indexes) { + const ids = this._collectIds(indexes); + console.trace('📤 [entries_menu]: ', JSON.stringify({ ids })); + const response = await this.history.messaging.request('entries_menu', { ids }); + if (response.action === 'none') { + return { kind: response.action }; + } + if (response.action === 'delete') { + this._postdelete(indexes); + return { kind: response.action }; + } + if (response.action === 'domain-search' && ids.length === 1 && indexes.length === 1) { + const target = this.data?.results[indexes[0]]; + const targetValue = target?.etldPlusOne || target?.domain; + if (targetValue) { + return { kind: response.action, value: targetValue }; + } else { + console.warn('missing target domain from current dataset?'); + return { kind: response.action }; + } + } + return { kind: response.action }; + } + + /** + * @param {number[]} indexes + * @return {Promise} + */ + async entriesDelete(indexes) { + const ids = this._collectIds(indexes); + console.log('📤 [entries_delete]: ', JSON.stringify({ ids })); + const response = await this.history.messaging.request('entries_delete', { ids }); + if (response.action === 'delete') { + viewTransition(() => { + this._postdelete(indexes); + }); + } + return { kind: response.action }; + } + + /** + * @param {number[]} indexes + * @return {string[]} + */ + _collectIds(indexes) { + const ids = []; + for (let i = 0; i < indexes.length; i++) { + const current = this.data?.results[indexes[i]]; + if (!current) throw new Error('unreachable'); + ids.push(current.id); + } + return ids; + } + + /** + * @param {(d: QueryData) => QueryData} updater + */ + update(updater) { + if (this.data === null) throw new Error('unreachable'); + this.accept(updater(this.data)); + } + + /** + * @param {number[]} indexes + */ + _postdelete(indexes) { + // if we get here, the entries were removed + this.update((old) => deleteByIndexes(old, indexes)); + } + + reset() { + this.update(() => { + /** @type {QueryData} */ + const query = { + lastQueryParams: null, + info: { + query: { term: '' }, + finished: true, + }, + results: [], + }; + return query; + }); + } + + /** + * @param {RangeId} range + * @return {Promise} + */ + async deleteRange(range) { + const resp = await this.range.deleteRange(range); + if (resp.action === 'delete' && range === 'all') { + this.reset(); + } + return { kind: resp.action }; + } + + /** + * @param {string} domain + * @return {Promise} + */ + async deleteDomain(domain) { + const resp = await this.history.messaging.request('deleteDomain', { domain }); + if (resp.action === 'delete') { + this.reset(); + } + return { kind: resp.action }; + } + + /** + * @param {string} term + * @return {Promise} + */ + async deleteTerm(term) { + console.log('📤 [deleteTerm]: ', JSON.stringify({ term })); + const resp = await this.history.messaging.request('deleteTerm', { term }); + if (resp.action === 'delete') { + this.reset(); + } + return { kind: resp.action }; + } +} + +/** + * @param {QueryData} old + * @param {number[]} indexes + * @return {QueryData} + */ +function deleteByIndexes(old, indexes) { + const inverted = indexes.sort((a, b) => b - a); + const removed = []; + const next = old.results.slice(); + + // remove items in reverse that that splice works multiple times + for (let i = 0; i < inverted.length; i++) { + removed.push(next.splice(inverted[i], 1)); + } + + /** @type {QueryData} */ + const nextStats = { ...old, results: next }; + return nextStats; +} + +/** + * @param {URLSearchParams} params + * @param {HistoryQuery['source']} source + * @return {HistoryQuery} + */ +export function paramsToQuery(params, source) { + /** @type {HistoryQuery['query'] | undefined} */ + let query; + const range = toRange(params.get('range')); + const domain = params.get('domain'); + + if (range === 'all') { + query = { term: '' }; + } else if (range) { + query = { range }; + } else if (domain) { + query = { domain }; + } else { + query = { term: params.get('q') || '' }; + } + + return { + query, + limit: HistoryService.CHUNK_SIZE, + offset: 0, + source, + }; +} + +/** + * @param {null|undefined|string} input + * @return {import('../types/history.js').RangeId|null} + */ +export function toRange(input) { + if (typeof input !== 'string') return null; + const valid = [ + 'all', + 'today', + 'yesterday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + 'recentlyOpened', + 'older', + 'sites', + ]; + return valid.includes(input) ? /** @type {import('../types/history.js').RangeId} */ (input) : null; +} + +/** + * @param {HistoryQuery} a + * @param {HistoryQuery|null} [b] + * @returns {boolean} + */ +function eq(a, b) { + if (!b) return false; + if (a.limit !== b.limit) return false; + if (a.offset !== b.offset) return false; + if (a.source !== b.source) return false; + return queryEq(a.query, b.query); +} + +/** + * @param {QueryKind} a + * @param {QueryKind|null} [b] + * @returns {boolean} + */ +function queryEq(a, b) { + if (!b) return false; + const k1 = Object.keys(a)[0]; + const k2 = Object.keys(b)[0]; + if (k1 === k2 && a[k1] === b[k2]) return true; + return false; +} diff --git a/special-pages/pages/history/app/icons/Cross.js b/special-pages/pages/history/app/icons/Cross.js new file mode 100644 index 0000000000..cb3d01a682 --- /dev/null +++ b/special-pages/pages/history/app/icons/Cross.js @@ -0,0 +1,13 @@ +import { h } from 'preact'; + +export function Cross() { + return ( + + + + ); +} diff --git a/special-pages/pages/history/app/icons/Fire.js b/special-pages/pages/history/app/icons/Fire.js new file mode 100644 index 0000000000..6cf96ae2e2 --- /dev/null +++ b/special-pages/pages/history/app/icons/Fire.js @@ -0,0 +1,13 @@ +import { h } from 'preact'; + +export function Fire() { + return ( + + + + ); +} diff --git a/special-pages/pages/history/app/icons/Search.js b/special-pages/pages/history/app/icons/Search.js new file mode 100644 index 0000000000..ac673ff6d6 --- /dev/null +++ b/special-pages/pages/history/app/icons/Search.js @@ -0,0 +1,13 @@ +import { h } from 'preact'; + +export function SearchIcon() { + return ( + + + + ); +} diff --git a/special-pages/pages/history/app/icons/Trash.js b/special-pages/pages/history/app/icons/Trash.js new file mode 100644 index 0000000000..e45ff8727b --- /dev/null +++ b/special-pages/pages/history/app/icons/Trash.js @@ -0,0 +1,25 @@ +import { h } from 'preact'; + +export function Trash() { + return ( + + + + + + ); +} diff --git a/special-pages/pages/history/app/icons/dots.js b/special-pages/pages/history/app/icons/dots.js new file mode 100644 index 0000000000..049249aefa --- /dev/null +++ b/special-pages/pages/history/app/icons/dots.js @@ -0,0 +1,23 @@ +import { h } from 'preact'; + +export function Dots() { + return ( + + + + + + ); +} diff --git a/special-pages/pages/history/app/index.js b/special-pages/pages/history/app/index.js new file mode 100644 index 0000000000..edb9c382f3 --- /dev/null +++ b/special-pages/pages/history/app/index.js @@ -0,0 +1,165 @@ +import { h, render } from 'preact'; +import { EnvironmentProvider, UpdateEnvironment } from '../../../shared/components/EnvironmentProvider.js'; + +import { App, AppLevelErrorBoundaryFallback } from './components/App.jsx'; +import { Components } from './components/Components.jsx'; + +import enStrings from '../public/locales/en/history.json'; +import { TranslationProvider } from '../../../shared/components/TranslationsProvider.js'; +import { callWithRetry } from '../../../shared/call-with-retry.js'; + +import { MessagingContext, SettingsContext } from './types.js'; +import { HistoryService, paramsToQuery } from './history.service.js'; +import { HistoryServiceProvider } from './global/Providers/HistoryServiceProvider.js'; +import { Settings } from './Settings.js'; +import { SelectionProvider } from './global/Providers/SelectionProvider.js'; +import { QueryProvider } from './global/Providers/QueryProvider.js'; +import { InlineErrorBoundary } from '../../../shared/components/InlineErrorBoundary.js'; + +/** + * @param {Element} root + * @param {import("../src/index.js").HistoryPage} messaging + * @param {import("../../../shared/environment").Environment} baseEnvironment + * @return {Promise} + */ +export async function init(root, messaging, baseEnvironment) { + const result = await callWithRetry(() => messaging.initialSetup()); + if ('error' in result) { + throw new Error(result.error); + } + + const init = result.value; + + // update the 'env' in case it was changed by native sides + const environment = baseEnvironment + .withEnv(init.env) + .withLocale(init.locale) + .withLocale(baseEnvironment.urlParams.get('locale')) + .withTextLength(baseEnvironment.urlParams.get('textLength')) + .withDisplay(baseEnvironment.urlParams.get('display')); + + // create app-specific settings + const settings = new Settings({}) + .withPlatformName(baseEnvironment.injectName) + .withPlatformName(init.platform?.name) + .withPlatformName(baseEnvironment.urlParams.get('platform')) + .withDebounce(baseEnvironment.urlParams.get('debounce')) + .withUrlDebounce(baseEnvironment.urlParams.get('urlDebounce')); + + if (!window.__playwright_01) { + console.log('initialSetup', init); + console.log('environment', environment); + console.log('settings', settings); + } + + /** + * @param {string} message + */ + const didCatchInit = (message) => { + messaging.reportInitException({ message }); + }; + + // apply default styles + applyDefaultStyles(init.customizer?.defaultStyles); + + const strings = await getStrings(environment); + const service = new HistoryService(messaging); + const query = paramsToQuery(environment.urlParams, 'initial'); + const initial = await fetchInitial(query, service, didCatchInit); + + if (environment.display === 'app') { + render( + { + return {message}; + }} + > + + + + + + + + + + + + + + + + + , + root, + ); + } else if (environment.display === 'components') { + render( + + + + + , + root, + ); + } +} + +/** + * This will apply default background colors as early as possible. + * + * @param {import("../types/history.ts").DefaultStyles | null | undefined} defaultStyles + */ +function applyDefaultStyles(defaultStyles) { + if (defaultStyles?.lightBackgroundColor) { + document.body.style.setProperty('--default-light-background-color', defaultStyles.lightBackgroundColor); + } + if (defaultStyles?.darkBackgroundColor) { + document.body.style.setProperty('--default-dark-background-color', defaultStyles.darkBackgroundColor); + } +} + +/** + * @param {import('../types/history.js').HistoryQuery} query + * @param {HistoryService} service + * @param {(message: string) => void} didCatch + * @returns {Promise} + */ +async function fetchInitial(query, service, didCatch) { + try { + return await service.getInitial(query); + } catch (e) { + console.error(e); + didCatch(e.message || String(e)); + return { + ranges: { + ranges: [{ id: 'all', count: 0 }], + }, + query: { + info: { query: { term: '' }, finished: true }, + results: [], + lastQueryParams: null, + }, + }; + } +} + +/** + * @param {import("../../../shared/environment").Environment} environment + */ +async function getStrings(environment) { + return environment.locale === 'en' + ? enStrings + : await fetch(`./locales/${environment.locale}/history.json`) + .then((x) => x.json()) + .catch((e) => { + console.error('Could not load locale', environment.locale, e); + return enStrings; + }); +} diff --git a/special-pages/pages/history/app/mocks/history.mocks.js b/special-pages/pages/history/app/mocks/history.mocks.js new file mode 100644 index 0000000000..4624afd4e0 --- /dev/null +++ b/special-pages/pages/history/app/mocks/history.mocks.js @@ -0,0 +1,127 @@ +import { HistoryService } from '../history.service.js'; + +/** + * @type {Record} + */ +export const historyMocks = { + few: { + info: { + finished: true, + query: { term: '' }, + }, + value: [ + { + id: 'history-id-01', + dateRelativeDay: 'Today', + dateShort: '15 Jan 2025', + dateTimeOfDay: '11:10', + title: 'Electric Callboy - Hypa Hypa (OFFICIAL VIDEO) - YouTube', + url: 'https://www.youtube.com/watch?v=75Mw8r5gW8E', + domain: 'www.youtube.com', + etldPlusOne: 'youtube.com', + favicon: { + src: './company-icons/fake.svg', + maxAvailableSize: 32, + }, + }, + { + id: 'history-id-02', + dateRelativeDay: 'Today', + dateShort: '15 Jan 2025', + dateTimeOfDay: '11:01', + title: 'Sonos continues to clean house with departure of chief commercial officer - The Verge', + url: 'https://www.theverge.com/2025/1/15/24344430/sonos-cco-deirdre-findlay-leaving', + domain: 'www.theverge.com', + etldPlusOne: 'theverge.com', + }, + { + id: 'history-id-03', + dateRelativeDay: 'Yesterday', + dateShort: '14 Jan 2025', + dateTimeOfDay: '16:45', + title: 'PreactJS/preact: Fast 3kB React alternative with the same API. Components & Virtual DOM. - GitHub', + url: 'https://github.com/preactjs/preact', + domain: 'github.com', + etldPlusOne: 'github.com', + }, + ], + }, +}; + +/** + * Generates a sample dataset with a specified number of entries, offset, and additional settings. + * + * @param {Object} options Configuration options for generating sample data. + * @param {number} options.count The number of sample entries to generate. + * @param {number} options.offset The starting index for the generated entries. + * @return {import("../../types/history").HistoryQueryResponse['value']} An object containing metadata (info) and an array of generated sample entries (value). + */ +export function generateSampleData({ count, offset }) { + const domains = ['youtube.com', 'theverge.com', 'github.com', 'reddit.com', 'wikipedia.org']; + const titles = [ + 'Electric Callboy - Hypa Hypa (OFFICIAL VIDEO) - YouTube', + 'Sonos continues to clean house - The Verge', + 'PreactJS/preact: Fast 3kB React alternative - GitHub', + 'A description of the vastly underrated art of sandwich making - Reddit', + 'JavaScript - Wikipedia, the free encyclopedia', + ]; + const urls = [ + 'https://www.youtube.com/watch?v=75Mw8r5gW8E', + 'https://www.theverge.com/2025/1/15/24344430/sonos-cco-deirdre-findlay-leaving', + 'https://github.com/preactjs/preact', + 'https://www.reddit.com/r/sandwiches/comments/art', + 'https://en.wikipedia.org/wiki/JavaScript', + ]; + const dates = ['15 Jan 2025', '14 Jan 2025', '13 Jan 2025', '12 Jan 2025', '11 Jan 2025']; + + const baseDate = new Date('2025-01-15T11:10:00'); // Base date for today + const value = []; + + for (let i = 0; i < count; i++) { + const id = i + offset; + const dateOffset = Math.floor(i / domains.length); // Move back a day after cycling through all domains + const date = new Date(baseDate); + date.setDate(date.getDate() - dateOffset); + + // Distribute attributes deterministically + const domainIndex = i % domains.length; + + value.push({ + id: `history-id-${String(id).padStart(2, '0')}`, + dateRelativeDay: dateOffset === 0 ? 'Today' : dateOffset === 1 ? 'Yesterday' : `${dateOffset} days ago`, + dateShort: dates[domainIndex], + dateTimeOfDay: date.toTimeString().split(' ')[0].slice(0, 5), // Format: "HH:MM" + domain: domains[domainIndex], + title: `(index:${id}) ` + titles[domainIndex], + url: urls[domainIndex], + etldPlusOne: domains[domainIndex], + }); + + // Adjust time for each entry so they're not identical + baseDate.setMinutes(baseDate.getMinutes() - 9); // Deduct time deterministically + } + + return value; +} + +/** + * @param {import("../../types/history").HistoryQueryResponse['value']} items + * @param {number} offset + * @param {number} chunkSize + * @return {import("../../types/history").HistoryQueryResponse} + */ +export function asResponse(items, offset, chunkSize = HistoryService.CHUNK_SIZE) { + // console.log(items.slice(offset, chunkSize)); + const sliced = items.slice(offset, offset + chunkSize); + const finished = sliced.length < chunkSize; + return { + value: sliced, + info: { + finished, + query: { term: '' }, + }, + }; +} + +// console.log(generateSampleData({ count: 10, offset: 0, term: '', finished: false })); +// console.log(generateSampleData({ count: 10, offset: 10, term: '', finished: true })); diff --git a/special-pages/pages/history/app/mocks/mock-transport.js b/special-pages/pages/history/app/mocks/mock-transport.js new file mode 100644 index 0000000000..d7b4d2948e --- /dev/null +++ b/special-pages/pages/history/app/mocks/mock-transport.js @@ -0,0 +1,296 @@ +import { TestTransportConfig } from '@duckduckgo/messaging'; +import { asResponse, generateSampleData, historyMocks } from './history.mocks.js'; + +const url = new URL(window.location.href); + +/** + * @typedef {import('@duckduckgo/messaging/lib/test-utils.mjs').SubscriptionEvent} SubscriptionEvent + */ + +/** + * @template T + * @param {T} value + * @return {T} + */ +function clone(value) { + return window.structuredClone?.(value) ?? JSON.parse(JSON.stringify(value)); +} + +export function mockTransport() { + /** @type {Mapvoid>} */ + const subscriptions = new Map(); + if ('__playwright_01' in window) { + window.__playwright_01.publishSubscriptionEvent = (/** @type {SubscriptionEvent} */ evt) => { + const matchingCallback = subscriptions.get(evt.subscriptionName); + if (!matchingCallback) return console.error('no matching callback for subscription', evt); + matchingCallback(evt.params); + }; + } + + let memory = clone(historyMocks.few).value; + /** @type {import('../../types/history.ts').GetRangesResponse} */ + const rangeMemory = { + ranges: [ + { id: 'all', count: 1 }, + { id: 'today', count: 1 }, + { id: 'yesterday', count: 1 }, + { id: 'tuesday', count: 1 }, + { id: 'monday', count: 1 }, + { id: 'sunday', count: 1 }, + { id: 'saturday', count: 1 }, + { id: 'friday', count: 1 }, + { id: 'older', count: 1 }, + { id: 'sites', count: 1 }, + ], + }; + + if (url.searchParams.has('history')) { + const key = url.searchParams.get('history'); + if (key && key in historyMocks) { + memory = clone(historyMocks[key]).value; + } else if (key?.match(/^\d+$/)) { + memory = generateSampleData({ count: parseInt(key), offset: 0 }); + } + } + // console.log(memory); + return new TestTransportConfig({ + notify(_msg) { + window.__playwright_01?.mocks?.outgoing?.push?.({ payload: clone(_msg) }); + /** @type {import('../../types/history.ts').HistoryMessages['notifications']} */ + const msg = /** @type {any} */ (_msg); + console.warn('unhandled notification', msg); + }, + subscribe(_msg, cb) { + const sub = /** @type {any} */ (_msg.subscriptionName); + + if ('__playwright_01' in window) { + window.__playwright_01?.mocks?.outgoing?.push?.({ payload: clone(_msg) }); + subscriptions.set(sub, cb); + return () => { + subscriptions.delete(sub); + }; + } + + console.warn('unhandled subscription', _msg); + + return () => {}; + }, + // eslint-ignore-next-line require-await + request(_msg) { + window.__playwright_01?.mocks?.outgoing?.push?.({ payload: clone(_msg) }); + /** @type {import('../../types/history.ts').HistoryMessages['requests']} */ + const msg = /** @type {any} */ (_msg); + + switch (msg.method) { + case 'query': { + return withLatency(queryResponseFrom(memory, msg)); + } + case 'entries_delete': { + // console.log('📤 [entries_delete]: ', JSON.stringify(msg.params)); + if (msg.params.ids.length > 1) { + // prettier-ignore + const lines = [ + `entries_delete: ${JSON.stringify(msg.params)}`, + `To simulate deleting these items, press confirm` + ].join('\n'); + if (confirm(lines)) { + return Promise.resolve({ action: 'delete' }); + } else { + return Promise.resolve({ action: 'none' }); + } + } + return Promise.resolve({ action: 'delete' }); + } + case 'entries_menu': { + // console.log('📤 [entries_menu]: ', JSON.stringify(msg.params)); + const isSingle = msg.params.ids.length === 1; + if (isSingle) { + if (url.searchParams.get('action') === 'domain-search') { + // prettier-ignore + const lines = [ + `entries_menu: ${JSON.stringify(msg.params.ids)}`, + `To simulate pressing 'show more from this url', press confirm` + ].join('\n'); + if (confirm(lines)) { + return Promise.resolve({ action: 'domain-search' }); + } else { + return Promise.resolve({ action: 'none' }); + } + } + } + // prettier-ignore + const lines = [ + `entries_menu: ${JSON.stringify(msg.params)}`, + `To simulate deleting these items, press confirm` + ].join('\n'); + if (confirm(lines)) { + return Promise.resolve({ action: 'delete' }); + } + return Promise.resolve({ action: 'none' }); + } + case 'deleteDomain': { + // console.log('📤 [deleteDomain]: ', JSON.stringify(msg.params)); + // prettier-ignore + const lines = [ + `deleteDomain: ${JSON.stringify(msg.params)}`, + `To simulate deleting this item, press confirm` + ].join('\n',); + if (confirm(lines)) { + return Promise.resolve({ action: 'delete' }); + } + return Promise.resolve({ action: 'none' }); + } + case 'deleteTerm': { + // console.log('📤 [deleteTerm]: ', JSON.stringify(msg.params)); + // prettier-ignore + const lines = [ + `deleteTerm: ${JSON.stringify(msg.params)}`, + `To simulate deleting this term, press confirm` + ].join('\n',); + if (confirm(lines)) { + return Promise.resolve({ action: 'delete' }); + } + return Promise.resolve({ action: 'none' }); + } + case 'initialSetup': { + /** @type {import('../../types/history.ts').InitialSetupResponse} */ + const initial = { + platform: { name: 'integration' }, + env: 'development', + locale: 'en', + customizer: { + defaultStyles: getDefaultStyles(), + }, + }; + + return Promise.resolve(initial); + } + + case 'deleteRange': { + // console.log('📤 [deleteRange]: ', JSON.stringify(msg.params)); + // prettier-ignore + const lines = [ + `deleteRange: ${JSON.stringify(msg.params)}`, + `To simulate deleting this item, press confirm` + ].join('\n',); + if (confirm(lines)) { + if (msg.params.range === 'all') memory = []; + rangeMemory.ranges = rangeMemory.ranges.filter((x) => { + return x.id !== msg.params.range; + }); + console.log(rangeMemory.ranges); + return Promise.resolve({ action: 'delete' }); + } + return Promise.resolve({ action: 'none' }); + } + case 'getRanges': { + if (url.searchParams.get('history') === '0') { + rangeMemory.ranges = rangeMemory.ranges.map((range) => { + return { ...range, count: 0 }; + }); + } + return Promise.resolve(rangeMemory); + } + default: { + return Promise.reject(new Error('unhandled request' + msg)); + } + } + }, + }); +} + +/** + * @returns {import("../../types/history").DefaultStyles | null} + */ +function getDefaultStyles() { + if (url.searchParams.get('defaultStyles') === 'visual-refresh') { + // https://app.asana.com/0/1201141132935289/1209349703167198/f + return { + lightBackgroundColor: '#E9EBEC', + darkBackgroundColor: '#27282A', + }; + } + return null; +} + +async function withLatency(value) { + let queryLatency = 50; + const fromParam = url.searchParams.get('query.latency'); + if (fromParam && fromParam.match(/^\d+$/)) { + queryLatency = parseInt(fromParam, 10); + } + + await new Promise((resolve) => setTimeout(resolve, queryLatency)); + + return value; +} + +/** + * @param {import("../../types/history").HistoryQueryResponse['value']} memory + * @param {import('../../types/history.ts').QueryRequest} msg + * @returns {import('../../types/history.ts').HistoryQueryResponse} + */ +function queryResponseFrom(memory, msg) { + // console.log('📤 [query]: ', JSON.stringify(msg.params)); + + if ('term' in msg.params.query) { + const { term } = msg.params.query; + if (term !== '') { + if (term === 'empty' || term.includes('"') || term.includes('<')) { + const response = asResponse([], msg.params.offset, msg.params.limit); + response.info.query = { term }; + return response; + } + if (term === 'empty') { + const response = asResponse([], msg.params.offset, msg.params.limit); + response.info.query = { term }; + return response; + } + if (term.trim().match(/^\d+$/)) { + const int = parseInt(term.trim(), 10); + /** @type {import("../../types/history").HistoryQueryResponse} */ + const response = asResponse(memory.slice(0, int), msg.params.offset, msg.params.limit); + response.value = response.value.map((item) => { + return { + ...item, + title: 't:' + term + ' ' + item.title, + }; + }); + response.info.query = { term }; + return response; + } + + /** @type {import("../../types/history").HistoryQueryResponse} */ + const response = asResponse(memory.slice(0, 10), msg.params.offset, msg.params.limit); + response.info.query = { term }; + return response; + } + } else if ('range' in msg.params.query) { + const response = asResponse(memory.slice(0, 10), msg.params.offset, msg.params.limit); + const range = msg.params.query.range; + response.value = response.value.map((item) => { + if (!('range' in msg.params.query)) return item; // unreachable + return { + ...item, + dateTimeOfDay: msg.params.query.range === 'sites' ? undefined : item.dateTimeOfDay, + title: 'range:' + range + ' ' + item.title, + }; + }); + response.info.query = msg.params.query; + return response; + } else if ('domain' in msg.params.query) { + const response = asResponse(memory.slice(0, 10), msg.params.offset, msg.params.limit); + const domain = msg.params.query.domain; + response.value = response.value.map((item) => { + return { + ...item, + title: 'domain:' + domain + ' ' + item.title, + }; + }); + response.info.query = msg.params.query; + return response; + } + + /** @type {import("../../types/history").HistoryQueryResponse} */ + return asResponse(memory, msg.params.offset, msg.params.limit); +} diff --git a/special-pages/pages/history/app/strings.json b/special-pages/pages/history/app/strings.json new file mode 100644 index 0000000000..823926070b --- /dev/null +++ b/special-pages/pages/history/app/strings.json @@ -0,0 +1,114 @@ +{ + "empty_title": { + "title": "Nothing to see here!", + "note": "Text shown where there are no remaining history entries" + }, + "empty_text": { + "title": "No browsing history yet.", + "note": "Placeholder text when there's no results to show" + }, + "no_results_title": { + "title": "No results found for {term}", + "note": "The placeholder {term} will be dynamically replaced with the search term entered by the user. For example, if the user searches for 'cats', the title will become 'No results found for cats'." + }, + "no_results_text": { + "title": "Try searching for a different URL or keywords.", + "note": "Placeholder text when a search gave no results." + }, + "delete_all": { + "title": "Delete All", + "note": "Text for a button that deletes all items or entries. An additional confirmation dialog will be presented." + }, + "delete_some": { + "title": "Delete", + "note": "Text for a button that deletes currently selected items" + }, + "delete_none": { + "title": "Nothing to delete", + "note": "Title/tooltip text on a button that does nothing when there is no browsing history to delete. It's additional information shown on hover." + }, + "page_title": { + "title": "History", + "note": "The main page title" + }, + "search": { + "title": "Search", + "note": "The placeholder text in a search input field." + }, + "show_history_all": { + "title": "Show all history", + "note": "Button text for an action that removes all filters and searches, and replaces the list with all history." + }, + "show_history_older": { + "title": "Show older history", + "note": "Button that shows older history entries" + }, + "show_history_for": { + "title": "Show history for {range}", + "note": "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Show history for Today'." + }, + "delete_history_all": { + "title": "Delete all history", + "note": "Button text for an action that removes all history entries." + }, + "delete_history_older": { + "title": "Delete older history", + "note": "Button that deletes older history entries." + }, + "delete_history_for": { + "title": "Delete history for {range}", + "note": "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Delete history for Today'." + }, + "search_your_history": { + "title": "Search your history", + "note": "Label text for screen readers. It's shown next to the search input field" + }, + "range_all": { + "title": "All", + "note": "Label on a button that shows all history entries" + }, + "range_today": { + "title": "Today", + "note": "Label on a button that shows history entries for today only" + }, + "range_yesterday": { + "title": "Yesterday", + "note": "Label on a button that shows history entries for yesterday only" + }, + "range_monday": { + "title": "Monday", + "note": "Label on a button that shows history entries for monday only" + }, + "range_tuesday": { + "title": "Tuesday", + "note": "Label on a button that shows history entries for tuesday only" + }, + "range_wednesday": { + "title": "Wednesday", + "note": "Label on a button that shows history entries for wednesday only" + }, + "range_thursday": { + "title": "Thursday", + "note": "Label on a button that shows history entries for thursday only" + }, + "range_friday": { + "title": "Friday", + "note": "Label on a button that shows history entries for friday only" + }, + "range_saturday": { + "title": "Saturday", + "note": "Label on a button that shows history entries for saturday only" + }, + "range_sunday": { + "title": "Sunday", + "note": "Label on a button that shows history entries for sunday only" + }, + "range_older": { + "title": "Older", + "note": "Label on a button that shows older history entries." + }, + "range_sites": { + "title": "Sites", + "note": "Label on a button that shows which sites have been visited" + } +} \ No newline at end of file diff --git a/special-pages/pages/history/app/types.js b/special-pages/pages/history/app/types.js new file mode 100644 index 0000000000..0011dec8c4 --- /dev/null +++ b/special-pages/pages/history/app/types.js @@ -0,0 +1,25 @@ +import { useContext } from 'preact/hooks'; +import { TranslationContext } from '../../../shared/components/TranslationsProvider.js'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import json from '../public/locales/en/history.json'; +import { createContext } from 'preact'; +import { Settings } from './Settings.js'; + +/** + * This is a wrapper to only allow keys from the default translation file + * @type {() => { t: (key: keyof json, replacements?: Record) => string }} + */ +export function useTypedTranslation() { + return { + t: useContext(TranslationContext).t, + }; +} + +export const MessagingContext = createContext(/** @type {import("../src/index.js").HistoryPage} */ ({})); +export const useMessaging = () => useContext(MessagingContext); +export const SettingsContext = createContext(new Settings({ platform: { name: 'macos' } })); +export const useSettings = () => useContext(SettingsContext); + +export function usePlatformName() { + return useContext(SettingsContext).platform.name; +} diff --git a/special-pages/pages/history/app/utils.js b/special-pages/pages/history/app/utils.js new file mode 100644 index 0000000000..08e5b8ead3 --- /dev/null +++ b/special-pages/pages/history/app/utils.js @@ -0,0 +1,95 @@ +export const ROW_SIZE = 28; +export const TITLE_SIZE = 32; +export const END_SIZE = 24; +export const TITLE_KIND = ROW_SIZE + TITLE_SIZE; +export const END_KIND = ROW_SIZE + END_SIZE; +export const BOTH_KIND = ROW_SIZE + TITLE_SIZE + END_SIZE; + +/** + * @param {import("../types/history").HistoryItem[]} rows + * @return {number[]} + */ +export function generateHeights(rows) { + const heights = new Array(rows.length); + for (let i = 0; i < rows.length; i++) { + const curr = rows[i]; + const prev = rows[i - 1]; + const next = rows[i + 1]; + const isStart = curr.dateRelativeDay !== prev?.dateRelativeDay; + const isEnd = curr.dateRelativeDay !== next?.dateRelativeDay; + + if (isStart && isEnd) { + heights[i] = TITLE_SIZE + ROW_SIZE + END_SIZE; + } else if (isStart) { + heights[i] = TITLE_SIZE + ROW_SIZE; + } else if (isEnd) { + heights[i] = ROW_SIZE + END_SIZE; + } else { + heights[i] = ROW_SIZE; + } + } + return heights; +} + +/** + * Convert the items 'id' field into something that is safe to use in + * things CSS view transition names. + * + * Note: I am not checking if the generated id starts with a number, + * because the names will always be prefixed in the component. + * + * @param {import("../types/history").HistoryItem[]} rows + * @return {string[]} + */ +export function generateViewIds(rows) { + return rows.map((row) => { + return btoa(row.id).replace(/=/g, ''); + }); +} + +/** + * @typedef {'ctrl+click' | 'shift+click' | 'click' | 'escape' | 'delete' | 'shift+up' | 'shift+down' | 'up' | 'down' | 'unknown'} Intention + */ + +/** + * @param {MouseEvent|KeyboardEvent} event + * @param {ImportMeta['platform']} platformName + * @return {Intention} + */ +export function eventToIntention(event, platformName) { + if (event instanceof MouseEvent) { + const isControlClick = platformName === 'macos' ? event.metaKey : event.ctrlKey; + if (isControlClick) { + return 'ctrl+click'; + } else if (event.shiftKey) { + return 'shift+click'; + } + return 'click'; + } else if (event instanceof KeyboardEvent) { + if (event.key === 'Escape') { + return 'escape'; + } else if (event.key === 'Delete' || event.key === 'Backspace') { + return 'delete'; + } else if (event.key === 'ArrowUp' && event.shiftKey) { + return 'shift+up'; + } else if (event.key === 'ArrowDown' && event.shiftKey) { + return 'shift+down'; + } else if (event.key === 'ArrowUp') { + return 'up'; + } else if (event.key === 'ArrowDown') { + return 'down'; + } + } + return 'unknown'; +} + +/** + * @param {any} condition + * @param {string} [message] + * @return {asserts condition} + */ +export function invariant(condition, message) { + if (condition) return; + if (message) throw new Error('Invariant failed: ' + message); + throw new Error('Invariant failed'); +} diff --git a/special-pages/pages/history/integration-tests/history-selections.spec.js b/special-pages/pages/history/integration-tests/history-selections.spec.js new file mode 100644 index 0000000000..57b016fb16 --- /dev/null +++ b/special-pages/pages/history/integration-tests/history-selections.spec.js @@ -0,0 +1,213 @@ +import { test } from '@playwright/test'; +import { HistoryTestPage } from './history.page.js'; + +test.describe('history selections', () => { + test('selects one item at a time', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.selectsRowIndex(1); + await hp.selectsRowIndex(2); + }); + test('resets selection with new query', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.types('example.com'); + await hp.rowIsNotSelected(0); + }); + test('adds to selection', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + + await hp.selectsRowIndex(0); + await hp.selectsRowWithCtrl(1); + + await hp.rowIsSelected(0); + await hp.rowIsSelected(1); + }); + test('removes from a selection', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + + await hp.selectsRowIndex(0); + await hp.selectsRowWithCtrl(1); + await hp.selectsRowWithCtrl(1); + await hp.rowIsSelected(0); + await hp.rowIsNotSelected(1); + }); + test('Expands a select with shift+click', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.selectsRowIndexWithShift(4); + await hp.rowIsSelected(0); + await hp.rowIsSelected(1); + await hp.rowIsSelected(2); + await hp.rowIsSelected(3); + await hp.rowIsSelected(4); + + // control + await hp.rowIsNotSelected(5); + }); + test('Anchors a selection with shift+click', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(4); + await hp.selectsRowIndexWithShift(6); + await hp.rowIsSelected(4); + await hp.rowIsSelected(5); + await hp.rowIsSelected(6); + + await hp.selectsRowIndexWithShift(0); + await hp.rowIsSelected(0); + await hp.rowIsSelected(1); + await hp.rowIsSelected(2); + await hp.rowIsSelected(3); + await hp.rowIsSelected(4); + + // control + await hp.rowIsNotSelected(5); + await hp.rowIsNotSelected(6); + }); + test('removes all selections when any item is deleted', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.menuForHistoryEntry(1, { action: 'delete' }); + await hp.rowIsNotSelected(0); + }); + test('issues context menu for selected group', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.selectsRowIndexWithShift(2); + await hp.rightClicksWithinSelection(0, hp.ids(3)); + }); + test('when multiple selected, issues context menu for a single row, when a non-selected item is the target', async ({ + page, + }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.selectsRowIndexWithShift(2); + + // control + await hp.rowIsSelected(0); + await hp.rowIsSelected(1); + await hp.rowIsSelected(2); + + // do the action, right-clicking an entry outside of the selection + await hp.menuForHistoryEntry(3, { action: 'delete' }); + + // double-check + await hp.rowIsNotSelected(0); + }); + test('expands selection up/down with shift+arrows', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await page.locator('main').press('Shift+ArrowDown'); + await page.locator('main').press('Shift+ArrowDown'); + + // control, making sure the element became selected + await hp.rowIsSelected(0); + await hp.rowIsSelected(1); + await hp.rowIsSelected(2); + + // now go bac kup + await page.locator('main').press('Shift+ArrowUp'); + await page.locator('main').press('Shift+ArrowUp'); + + // only the first should be selected now + await hp.rowIsSelected(0); + await hp.rowIsNotSelected(1); + await hp.rowIsNotSelected(2); + }); + test('`deleteAll` does nothing in the empty state', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(0); + await hp.openPage({}); + await hp.cannotDeleteAllWhenEmpty(); + }); + test('`deleteAll` button text changes to `delete` when selections are made', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.deletesSelection(); + }); + test('`delete` in header respects selection', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.clicksDeleteInHeader({ action: 'delete' }); + await hp.didDeleteSelection([0]); + }); + test('`delete` in header respects selection after search', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.types('2'); + await hp.waitForRowCount(2); + await hp.selectsRowIndex(0); + await hp.selectsRowIndexWithShift(1); + await hp.clicksDeleteInHeader({ action: 'delete' }); + await hp.didDeleteSelection([0, 1]); + }); + test('`deleteAll` during search (no selections)', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.didMakeNthQuery({ nth: 0, query: { term: '' }, source: 'initial' }); + + // do the search + await hp.types('example.com'); + await hp.didMakeNthQuery({ nth: 1, query: { term: 'example.com' } }); + + // delete for the given term + await hp.deletesAllForTerm('example.com', { action: 'delete' }); + + // should have reset the UI now + await hp.didMakeNthQuery({ nth: 2, query: { term: '' }, source: 'auto' }); + }); + test('removes all selections with ESC key', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.pressesEscape(); + await hp.rowIsNotSelected(0); + }); + test('deletes a single row item without confirmation', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.deletesWithKeyboard(hp.ids(1), { action: 'delete' }); + }); + test('does not delete item when backspace is pressed in search', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2); + await hp.openPage({}); + await hp.types('youtube.com'); + await hp.selectsRowIndex(0); + await page.locator('input[type="search"]').press('Delete'); + await page.waitForTimeout(50); + await hp.didNotDelete(); + }); + test('deletes multiple rows with confirmation', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.selectsRowIndexWithShift(2); + await hp.deletesWithKeyboard(hp.ids(3), { action: 'delete' }); + }); + test('3 dots menu on multiple history entries', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.selectsRowIndexWithShift(2); + await hp.menuForMultipleHistoryEntries(0, hp.ids(3), { action: 'delete' }); + }); + test('clicking outside of rows de-selects everything', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsRowIndex(0); + await hp.clicksOutsideOfRows(); + await hp.selectedRowCountIs(0); + }); +}); diff --git a/special-pages/pages/history/integration-tests/history.page.js b/special-pages/pages/history/integration-tests/history.page.js new file mode 100644 index 0000000000..3e12d6fd1c --- /dev/null +++ b/special-pages/pages/history/integration-tests/history.page.js @@ -0,0 +1,621 @@ +import { perPlatform } from 'injected/integration-test/type-helpers.mjs'; +import { Mocks } from '../../../shared/mocks.js'; +import { expect } from '@playwright/test'; +import { generateSampleData } from '../app/mocks/history.mocks.js'; + +/** + * @typedef {import('injected/integration-test/type-helpers.mjs').Build} Build + * @typedef {import('injected/integration-test/type-helpers.mjs').PlatformInfo} PlatformInfo + */ + +export class HistoryTestPage { + entries = 200; + /** + * Sets the number of entries stored in memory + * @param {number} count - The number of entries to set. + */ + withEntries(count) { + this.entries = count; + return this; + } + /** + * @param {import("@playwright/test").Page} page + * @param {Build} build + * @param {PlatformInfo} platform + */ + constructor(page, build, platform) { + this.page = page; + this.build = build; + this.platform = platform; + this.mocks = new Mocks(page, build, platform, { + context: 'specialPages', + featureName: 'history', + env: 'development', + }); + this.page.on('console', console.log); + if (this.platform.name === 'extension') throw new Error('unreachable - not supported in extension platform'); + this.mocks.defaultResponses({ + /** @type {import('../types/history.ts').InitialSetupResponse} */ + initialSetup: { + env: 'development', + locale: 'en', + platform: { + name: this.platform.name || 'windows', + }, + }, + }); + } + + /** + * Opens a page with optional parameters. + * This method ensures that mocks are installed and routes are set up before navigating to the page. + * @param {Object} [params] - Optional parameters for opening the page. + * @param {'debug' | 'production'} [params.mode] - Optional parameters for opening the page. + * @param {boolean} [params.willThrow] - Optional flag to simulate an exception + * @param {Record} [params.additional] - Optional map of key/values to add + */ + async openPage({ mode = 'debug', additional, willThrow = false } = {}) { + await this.mocks.install(); + const searchParams = new URLSearchParams({ mode, willThrow: String(willThrow) }); + for (const [key, value] of Object.entries(additional || {})) { + searchParams.set(key, value); + } + + searchParams.set('history', String(this.entries)); + + // eslint-disable-next-line no-undef + if (process.env.PAGE) { + await this.page.goto('/' + '?' + searchParams.toString()); + } else { + await this.page.goto('/history' + '?' + searchParams.toString()); + } + } + + /** + * @param {import("@playwright/test").Page} page + * @param {import("@playwright/test").TestInfo} testInfo + */ + static create(page, testInfo) { + // Read the configuration object to determine which platform we're testing against + const { platformInfo, build } = perPlatform(testInfo.project.use); + return new HistoryTestPage(page, build, platformInfo); + } + + async darkMode() { + await this.page.emulateMedia({ colorScheme: 'dark' }); + } + async lightMode() { + await this.page.emulateMedia({ colorScheme: 'light' }); + } + + /** + * @param {import('../types/history.ts').QueryKind} query + */ + async didMakeInitialQueries(query) { + const rangesCall = await this.mocks.waitForCallCount({ method: 'getRanges', count: 1 }); + const calls = await this.mocks.waitForCallCount({ method: 'query', count: 1 }); + expect(calls[0].payload.params).toStrictEqual(queryType({ query, limit: 150, offset: 0, source: 'initial' })); + expect(rangesCall[0].payload.params).toStrictEqual({}); + } + + /** + * @param {object} props + * @param {number} props.nth + * @param {import('../types/history.ts').QueryKind} props.query + * @param {import('../types/history.ts').HistoryQuery['source']} [props.source='user'] + */ + async didMakeNthQuery({ nth, query, source = 'user' }) { + const calls = await this.mocks.waitForCallCount({ method: 'query', count: nth + 1 }); + const params = calls[nth].payload.params; + + expect(params).toStrictEqual(queryType({ query, limit: 150, offset: 0, source })); + } + + /** + * @param {number} n + */ + async didMakeNQueries(n) { + const calls = await this.mocks.outgoing({ names: ['query'] }); + expect(calls).toHaveLength(n); + } + + /** + * @param {object} props + * @param {number} props.nth + * @param {import('../types/history.ts').QueryKind} props.query + * @param {number} props.offset + */ + async didMakeNthPagingQuery({ nth, query, offset }) { + const calls = await this.mocks.waitForCallCount({ method: 'query', count: nth + 1 }); + const params = calls[nth].payload.params; + + expect(params).toStrictEqual(queryType({ query, limit: 150, offset, source: 'user' })); + } + + async selectsToday() { + const { page } = this; + await page.getByLabel('Show history for today').click(); + } + + async selectsAll() { + const { page } = this; + await page.getByLabel('Show all history').click(); + } + + /** + * @param {string} term + */ + async types(term) { + const { page } = this; + await page.getByPlaceholder('Search').fill(term); + } + + async clearsInput() { + const { page } = this; + await page.getByPlaceholder('Search').fill(''); + } + + async scrollsToEnd() { + const { page } = this; + await page.getByRole('main').evaluate(() => { + const scrollableItem = document.querySelector('main'); + if (scrollableItem) { + scrollableItem.scrollTop = scrollableItem.scrollHeight; + } + }); + } + + async didResetScroll() { + const { page } = this; + const scrollPosition = await page.waitForFunction(() => { + const scrollableItem = document.querySelector('main'); + return scrollableItem ? scrollableItem.scrollTop === 0 : false; + }); + expect(scrollPosition).toBeTruthy(); + } + + async opensLinks() { + const row = this.main().locator('[aria-selected]').nth(0); + await row.dblclick(); + await row.dblclick({ modifiers: ['Meta'] }); + await row.dblclick({ modifiers: ['Shift'] }); + + await row.locator('a').click({ button: 'middle', force: true }); + await this._opensMainLink(); + } + async _opensMainLink() { + const calls = await this.mocks.waitForCallCount({ method: 'open', count: 3 }); + const url = 'https://www.youtube.com/watch?v=75Mw8r5gW8E'; + + expect(calls[0].payload.params).toStrictEqual({ + url, + target: 'same-tab', + }); + + expect(calls[1].payload.params).toStrictEqual({ + url, + target: 'new-tab', + }); + + expect(calls[2].payload.params).toStrictEqual({ + url, + target: 'new-window', + }); + + expect(calls[3].payload.params).toStrictEqual({ + url, + target: 'new-tab', + }); + } + + /** + * @param {import('../types/history.ts').DeleteRangeResponse} resp + */ + async deletesHistoryForToday(resp = { action: 'delete' }) { + const { page } = this; + await page.getByLabel('Show history for today').hover(); + this._withDialogHandling(resp); + await page.getByLabel('Delete history for today').click(); + const calls = await this.mocks.waitForCallCount({ method: 'deleteRange', count: 1 }); + expect(calls[0].payload.params).toStrictEqual({ range: 'today' }); + } + + async cannotDeleteAllFromSidebar() { + if (this.entries !== 0) throw new Error('this test requires 0 entries'); + + await this.sidebar().getByLabel('Show all history').hover(); + await this.sidebar().getByLabel('Delete all history').click({ force: true }); + + await this.page.waitForTimeout(50); + const count = await this.mocks.outgoing({ names: ['deleteRange'] }); + + expect(count).toHaveLength(0); + } + + /** + * @param {import("../types/history.js").RangeId} range + */ + async didDeleteRange(range) { + const calls = await this.mocks.waitForCallCount({ method: 'deleteRange', count: 1 }); + expect(calls[0].payload.params).toStrictEqual({ range }); + } + + /** + * @param {string} domain + */ + async didDeleteDomain(domain) { + const calls = await this.mocks.waitForCallCount({ method: 'deleteDomain', count: 1 }); + expect(calls[0].payload.params).toStrictEqual({ domain }); + } + + /** + * @param {import('../types/history.ts').DeleteRangeResponse} resp + */ + async deletesHistoryForYesterday(resp = { action: 'delete' }) { + const { page } = this; + await page.getByLabel('Show history for yesterday').hover(); + this._withDialogHandling(resp); + await page.getByLabel('Delete history for yesterday').click(); + } + + async sideBarItemWasRemoved(label) { + const { page } = this; + await expect(page.getByLabel(label)).not.toBeVisible({ timeout: 1000 }); + } + + async sidebarHasItem(label) { + const { page } = this; + await expect(page.getByLabel(label)).toBeVisible({ timeout: 1000 }); + } + + /** + * @param {import('../types/history.ts').DeleteRangeResponse} resp + */ + async deletesAllHistoryFromHeader(resp) { + const { page } = this; + this._withDialogHandling(resp); + await page.getByRole('button', { name: 'Delete All', exact: true }).click(); + const calls = await this.mocks.waitForCallCount({ method: 'deleteRange', count: 1 }); + expect(calls[0].payload.params).toStrictEqual({ range: 'all' }); + await this.hasEmptyState(); + } + + async hasEmptyState() { + const { page } = this; + await expect(page.getByRole('heading', { level: 2, name: 'Nothing to see here!' })).toBeVisible(); + await expect(page.getByText('No browsing history yet.')).toBeVisible(); + } + + async hasNoResultsState() { + const { page } = this; + await expect(page.getByRole('heading', { level: 2, name: 'No results found for "empty"' })).toBeVisible(); + await expect(page.getByText('Try searching for a different URL or keywords')).toBeVisible(); + } + + /** + * @param {import('../types/history.ts').DeleteRangeResponse} resp + */ + async clicksDeleteInHeader(resp) { + const { page } = this; + this._withDialogHandling(resp); + await page.locator('header').getByRole('button', { name: 'Delete', exact: true }).click(); + } + + /** + * @param {import('../types/history.ts').DeleteRangeResponse} resp + */ + async clicksDeleteAllInHeader(resp) { + const { page } = this; + this._withDialogHandling(resp); + await page.locator('header').getByRole('button', { name: 'Delete All', exact: true }).click(); + } + + /** + * @param {number} nth - row index + * @param {import('../types/history.ts').DeleteRangeResponse} resp + */ + async menuForHistoryEntry(nth, resp) { + const data = generateSampleData({ count: this.entries, offset: 0 }); + const nthItem = data[nth]; + await this.menuForMultipleHistoryEntries(nth, [nthItem.id], resp); + } + + /** + * @param {number} nth - row index to click the 3 dots on + * @param {string[]} ids - expected ids + * @param {import('../types/history.ts').DeleteRangeResponse} resp + */ + async menuForMultipleHistoryEntries(nth, ids, resp) { + const { page } = this; + + const cleanup = this._withDialogHandling(resp); + // console.log(data[0].title); + const data = generateSampleData({ count: this.entries, offset: 0 }); + const nthItem = data[nth]; + const row = this.main().locator(`[data-history-entry=${nthItem.id}]`); + await row.hover(); + await page.locator(`[data-action="entries_menu"][value=${nthItem.id}]`).click(); + + const calls = await this.mocks.waitForCallCount({ method: 'entries_menu', count: 1 }); + expect(calls[0].payload.params).toStrictEqual({ ids }); + cleanup(); + } + + /** + * @param {number} nth + */ + async selectsRowIndex(nth) { + const { page } = this; + const rows = page.locator('main').locator('[aria-selected]'); + const selected = page.locator('main').locator('[aria-selected="true"]'); + await rows.nth(nth).getByTestId('Item.domain').click(); + await expect(rows.nth(nth)).toHaveAttribute('aria-selected', 'true'); + await expect(selected).toHaveCount(1); + } + + /** + * @param {number} nth + */ + async hoversRowIndex(nth) { + const rows = this.page.locator('main').locator('[aria-selected]'); + await rows.nth(nth).hover(); + await rows.nth(nth).locator('[data-action="entries_menu"]').waitFor({ state: 'visible' }); + } + /** + * @param {number} nth + */ + async hoversRowIndexBtn(nth) { + const rows = this.page.locator('main').locator('[aria-selected]'); + await rows.nth(nth).locator('[data-action="entries_menu"]').hover(); + } + /** + * + */ + async hoversDeleteAllBtn() { + await this.page.getByRole('button', { name: 'Delete All', exact: true }).hover(); + } + + /** + * @param {number} nth + */ + async rowIsSelected(nth) { + const { page } = this; + const rows = page.locator('main').locator('[aria-selected]'); + await expect(rows.nth(nth)).toHaveAttribute('aria-selected', 'true'); + } + + /** + * @param {number} nth + */ + async rowIsNotSelected(nth) { + const { page } = this; + const rows = page.locator('main').locator('[aria-selected]'); + await expect(rows.nth(nth)).toHaveAttribute('aria-selected', 'false'); + } + + /** + * @param {number} nth + */ + async selectsRowWithCtrl(nth) { + const { page } = this; + const rows = page.locator('main').locator('[aria-selected]'); + await rows + .nth(nth) + .getByTestId('Item.domain') + .click({ modifiers: ['Meta'] }); + } + /** + * @param {number} nth + */ + async selectsRowIndexWithShift(nth) { + const { page } = this; + const rows = page.locator('main').locator('[aria-selected]'); + await rows + .nth(nth) + .getByTestId('Item.domain') + .click({ modifiers: ['Shift'] }); + } + + /** + * @param {number} count + */ + ids(count) { + return generateSampleData({ count: this.entries, offset: 0 }) + .slice(0, count) + .map((x) => x.id); + } + + /** + * @param {number} nth + * @param {string[]} expectedIds + */ + async rightClicksWithinSelection(nth, expectedIds) { + const { page } = this; + const rows = page.locator('main').locator('[aria-selected]'); + const selectedRow = rows.nth(nth); + await selectedRow.click({ button: 'right' }); + const calls = await this.mocks.waitForCallCount({ method: 'entries_menu', count: 1 }); + + expect(calls[0].payload.params).toStrictEqual({ ids: expectedIds }); + } + + async cannotDeleteAllWhenEmpty() { + const { page } = this; + await expect(page.getByRole('button', { name: 'Delete All', exact: true })).toHaveAttribute('aria-disabled', 'true'); + } + + async deletesSelection() { + const { page } = this; + await page.locator('header').getByRole('button', { name: 'Delete', exact: true }).click(); + } + + async deletesAll() { + const { page } = this; + await page.locator('header').getByRole('button', { name: 'Delete All', exact: true }).click(); + } + + /** + * @param {string} term + * @param {import('../types/history.ts').DeleteRangeResponse} resp + */ + async deletesAllForTerm(term, resp) { + this._withDialogHandling(resp); + await this.deletesAll(); + const calls = await this.mocks.waitForCallCount({ method: 'deleteTerm', count: 1 }); + expect(calls[0].payload.params).toStrictEqual({ term }); + } + + async pressesEscape() { + const { page } = this; + const main = page.locator('body'); + await main.press('Escape'); + } + + /** + * @param {import('../types/history.ts').DeleteRangeResponse} resp + */ + _withDialogHandling(resp) { + const { page } = this; + + const handler = (dialog) => { + if (resp.action === 'delete' || resp.action === 'domain-search') { + return dialog.accept(); + } else { + return dialog.dismiss(); + } + }; + page.on('dialog', handler); + return () => { + page.off('dialog', handler); + }; + } + + /** + * @param {string[]} ids + * @param {import('../types/history.ts').DeleteRangeResponse} resp + */ + async deletesWithKeyboard(ids, resp) { + const { page } = this; + this._withDialogHandling(resp); + + // Simulate pressing the 'Delete' key + await page.keyboard.press('Delete'); + + const calls = await this.mocks.waitForCallCount({ method: 'entries_delete', count: 1 }); + expect(calls[0].payload.params).toStrictEqual({ ids }); + + for (const id of ids) { + await expect(page.locator(`main [aria-selected] button[value=${id}]`)).not.toBeVisible(); + } + } + + /** + * @param {string} domain + */ + async inputContainsDomain(domain) { + const { page } = this; + await expect(page.getByPlaceholder('Search')).toHaveValue(domain); + } + + /** + * @param {string} term + */ + async didUpdateUrlWithQueryTerm(term) { + const { page } = this; + await page.waitForURL((url) => url.searchParams.get('q') === term); + } + + /** + * @param {number} count + */ + async hasRowCount(count) { + const { page } = this; + const rows = page.locator('main').locator('[aria-selected]'); + const rowCount = await rows.count(); + expect(rowCount).toBe(count); + } + + /** + * @param {number} count + */ + async waitForRowCount(count) { + const { page } = this; + await page.waitForFunction((count) => document.querySelector('main')?.querySelectorAll('[aria-selected]').length === count, count); + } + + sidebar() { + return this.page.locator('aside'); + } + + main() { + return this.page.locator('main'); + } + header() { + return this.page.locator('header'); + } + + async hoversRange(range) { + await this.page.getByLabel(`Show history for ${range}`).hover(); + } + async hoversRangeDelete(range) { + // await this.page.pause(); + await this.page.getByRole('button', { name: `Delete history for ${range}` }).hover(); + } + + /** + * @param {number[]} indexes + */ + async didDeleteSelection(indexes) { + const items = generateSampleData({ count: this.entries, offset: 0 }); + const ids = indexes.map((index) => items[index].id); + const calls = await this.mocks.waitForCallCount({ method: 'entries_delete', count: 1 }); + expect(calls[0].payload.params).toStrictEqual({ ids }); + } + + async didNotDelete() { + const calls = await this.mocks.outgoing({ names: ['entries_delete'] }); + expect(calls).toHaveLength(0); + } + + async submitSearchForm() { + await this.page.getByRole('searchbox', { name: 'Search your history' }).press('Enter'); + } + + async clicksOutsideOfRows() { + await this.page.getByRole('main').click({ position: { x: 0, y: 0 } }); + } + + async selectedRowCountIs(number) { + await expect(this.main().locator('[aria-selected="true"]')).toHaveCount(number); + } + + /** + * @param {object} params + * @param {string} params.hex + * @returns {Promise} + */ + async hasBackgroundColor({ hex }) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const rgb = `rgb(${[r, g, b].join(', ')})`; + await expect(this.page.locator('[data-layout-mode="normal"]')).toHaveCSS('background-color', rgb, { timeout: 50 }); + } + + async lastItemDividerHasColor({ rgb }) { + const lastItem = this.sidebar().locator('.Sidebar_item').last(); + const borderTopColor = await lastItem.evaluate((el) => { + const before = window.getComputedStyle(el, '::before'); + return before.borderTopColor; + }); + expect(borderTopColor).toBe(rgb); + } +} + +/** + * @param {import('../types/history.ts').HistoryQuery} q + * @return {import('../types/history.ts').HistoryQuery} + */ +function queryType(q) { + return q; +} diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js b/special-pages/pages/history/integration-tests/history.screenshots.spec.js new file mode 100644 index 0000000000..1e920056a0 --- /dev/null +++ b/special-pages/pages/history/integration-tests/history.screenshots.spec.js @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; +import { HistoryTestPage } from './history.page.js'; + +const maxDiffPixels = 20; + +test.describe('full history screenshots', { tag: ['@screenshots'] }, () => { + test.use({ viewport: { width: 1080, height: 500 } }); + test('empty state', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(0); + await hp.openPage(); + await hp.didMakeInitialQueries({ term: '' }); + await expect(page).toHaveScreenshot('full.empty.light.png', { maxDiffPixels }); + await hp.darkMode(); + await expect(page).toHaveScreenshot('full.empty.dark.png', { maxDiffPixels }); + }); + test('short list (3 items)', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(3); + await hp.openPage(); + await expect(page).toHaveScreenshot('full.short.light.png', { maxDiffPixels }); + await hp.darkMode(); + await expect(page).toHaveScreenshot('full.short.dark.png', { maxDiffPixels }); + }); +}); + +test.describe('history sidebar screenshots', { tag: ['@screenshots'] }, () => { + test.use({ viewport: { width: 1080, height: 400 } }); + test('sidebar active/hover', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(3); + await hp.openPage(); + await hp.selectsToday(); + await hp.hoversRange('yesterday'); + await expect(hp.sidebar()).toHaveScreenshot('sidebar.light.png', { maxDiffPixels }); + await hp.darkMode(); + await expect(hp.sidebar()).toHaveScreenshot('sidebar.dark.png', { maxDiffPixels }); + + await hp.lightMode(); + await hp.hoversRangeDelete('yesterday'); + await expect(hp.sidebar()).toHaveScreenshot('sidebar.delete.light.png', { maxDiffPixels }); + + await hp.darkMode(); + await expect(hp.sidebar()).toHaveScreenshot('sidebar.delete.dark.png', { maxDiffPixels }); + }); +}); + +test.describe('history item selections', { tag: ['@screenshots'] }, () => { + test.use({ viewport: { width: 1080, height: 400 } }); + test('main selecting', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(12); + await hp.openPage(); + await hp.didMakeNthQuery({ nth: 0, query: { term: '' }, source: 'initial' }); + await hp.selectsRowIndex(1); + await hp.selectsRowIndexWithShift(3); + await hp.hoversRowIndex(1); + await expect(hp.main()).toHaveScreenshot('main.select.light.png', { maxDiffPixels }); + await hp.darkMode(); + await expect(hp.main()).toHaveScreenshot('main.select.dark.png', { maxDiffPixels }); + }); + test('main hover', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(12); + await hp.openPage(); + await hp.didMakeNthQuery({ nth: 0, query: { term: '' }, source: 'initial' }); + await hp.hoversRowIndex(0); + await expect(hp.main()).toHaveScreenshot('main.hover.light.png', { maxDiffPixels }); + await hp.darkMode(); + await expect(hp.main()).toHaveScreenshot('main.hover.dark.png', { maxDiffPixels }); + }); + test('main selection + hover', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(12); + await hp.openPage(); + await hp.didMakeNthQuery({ nth: 0, query: { term: '' }, source: 'initial' }); + await hp.selectsRowIndex(1); + await hp.hoversRowIndexBtn(1); + await expect(hp.main()).toHaveScreenshot('main.select+hover.light.png', { maxDiffPixels }); + await hp.darkMode(); + await expect(hp.main()).toHaveScreenshot('main.select+hover.dark.png', { maxDiffPixels }); + }); +}); + +test.describe('history header', { tag: ['@screenshots'] }, () => { + test.use({ viewport: { width: 1080, height: 400 } }); + test('idle header', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(12); + await hp.openPage(); + await hp.didMakeNthQuery({ nth: 0, query: { term: '' }, source: 'initial' }); + await expect(hp.header()).toHaveScreenshot('header.idle.light.png', { maxDiffPixels }); + await hp.darkMode(); + await expect(hp.header()).toHaveScreenshot('header.idle.dark.png', { maxDiffPixels }); + }); + test('search', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(12); + await hp.openPage(); + await hp.didMakeNthQuery({ nth: 0, query: { term: '' }, source: 'initial' }); + await hp.types('example.com'); + await expect(hp.header()).toHaveScreenshot('header.search.light.png', { maxDiffPixels }); + await hp.darkMode(); + await expect(hp.header()).toHaveScreenshot('header.search.dark.png', { maxDiffPixels }); + }); + test('delete button', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(12); + await hp.openPage(); + await hp.didMakeNthQuery({ nth: 0, query: { term: '' }, source: 'initial' }); + await hp.hoversDeleteAllBtn(); + await expect(hp.header()).toHaveScreenshot('header.delete.light.png', { maxDiffPixels }); + await hp.darkMode(); + await expect(hp.header()).toHaveScreenshot('header.delete.dark.png', { maxDiffPixels }); + }); +}); diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-empty-dark-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-empty-dark-integration-darwin.png new file mode 100644 index 0000000000..8495afa3af Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-empty-dark-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-empty-light-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-empty-light-integration-darwin.png new file mode 100644 index 0000000000..894636d932 Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-empty-light-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-short-dark-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-short-dark-integration-darwin.png new file mode 100644 index 0000000000..851c14572a Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-short-dark-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-short-light-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-short-light-integration-darwin.png new file mode 100644 index 0000000000..9d8430594d Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/full-short-light-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-delete-dark-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-delete-dark-integration-darwin.png new file mode 100644 index 0000000000..bcc6dd5fd4 Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-delete-dark-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-delete-light-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-delete-light-integration-darwin.png new file mode 100644 index 0000000000..8ed79f6f40 Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-delete-light-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-idle-dark-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-idle-dark-integration-darwin.png new file mode 100644 index 0000000000..eb778ccb3b Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-idle-dark-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-idle-light-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-idle-light-integration-darwin.png new file mode 100644 index 0000000000..2ff1c00b2a Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-idle-light-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-search-dark-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-search-dark-integration-darwin.png new file mode 100644 index 0000000000..0234cbb3b9 Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-search-dark-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-search-light-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-search-light-integration-darwin.png new file mode 100644 index 0000000000..ad9a1ab89f Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/header-search-light-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-hover-dark-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-hover-dark-integration-darwin.png new file mode 100644 index 0000000000..1557141dbb Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-hover-dark-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-hover-light-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-hover-light-integration-darwin.png new file mode 100644 index 0000000000..0018a43e09 Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-hover-light-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-dark-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-dark-integration-darwin.png new file mode 100644 index 0000000000..14f0f12ea7 Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-dark-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-hover-dark-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-hover-dark-integration-darwin.png new file mode 100644 index 0000000000..9acbb4bdb9 Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-hover-dark-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-hover-light-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-hover-light-integration-darwin.png new file mode 100644 index 0000000000..072e20eb74 Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-hover-light-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-light-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-light-integration-darwin.png new file mode 100644 index 0000000000..38630711dc Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/main-select-light-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-dark-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-dark-integration-darwin.png new file mode 100644 index 0000000000..fb4dd88614 Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-dark-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-delete-dark-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-delete-dark-integration-darwin.png new file mode 100644 index 0000000000..fd0456532b Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-delete-dark-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-delete-light-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-delete-light-integration-darwin.png new file mode 100644 index 0000000000..e0911969c9 Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-delete-light-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-light-integration-darwin.png b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-light-integration-darwin.png new file mode 100644 index 0000000000..9e91605a3e Binary files /dev/null and b/special-pages/pages/history/integration-tests/history.screenshots.spec.js-snapshots/sidebar-light-integration-darwin.png differ diff --git a/special-pages/pages/history/integration-tests/history.spec.js b/special-pages/pages/history/integration-tests/history.spec.js new file mode 100644 index 0000000000..dfff3c4446 --- /dev/null +++ b/special-pages/pages/history/integration-tests/history.spec.js @@ -0,0 +1,266 @@ +import { test } from '@playwright/test'; +import { HistoryTestPage } from './history.page.js'; + +test.describe('history', () => { + test('has empty state', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(0); + await hp.openPage(); + await hp.didMakeInitialQueries({ term: '' }); + await hp.hasEmptyState(); + }); + test('has no-results state', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(0); + await hp.openPage(); + await hp.didMakeInitialQueries({ term: '' }); + await hp.hasEmptyState(); + await hp.types('empty'); + await hp.hasNoResultsState(); + }); + test('makes an initial empty query', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(100); + await hp.openPage(); + await hp.didMakeInitialQueries({ term: '' }); + }); + test('makes an initial query with term', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(100); + await hp.openPage({ additional: { q: 'youtube' } }); + await hp.didMakeInitialQueries({ term: 'youtube' }); + }); + test('makes an initial query with range', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(100); + await hp.openPage({ additional: { range: 'today' } }); + await hp.didMakeInitialQueries({ range: 'today' }); + }); + test('switches from initial search query to range', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(100); + await hp.openPage({ additional: { q: 'youtube' } }); + await hp.didMakeInitialQueries({ term: 'youtube' }); + await hp.selectsToday(); + await hp.didMakeNthQuery({ nth: 1, query: { range: 'today' } }); + }); + test('switches from initial range to term', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(100); + await hp.openPage({ additional: { range: 'today' } }); + await hp.didMakeInitialQueries({ range: 'today' }); + await hp.types('youtube'); + await hp.didMakeNthQuery({ nth: 1, query: { term: 'youtube' } }); + }); + test('makes query after clearing input and retyping', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(100); + await hp.openPage({ additional: { debounce: '10' } }); + + // page load was an empty query + await hp.didMakeInitialQueries({ term: '' }); + + // then a manual entry + await hp.types('youtube'); + await hp.didMakeNthQuery({ nth: 1, query: { term: 'youtube' } }); + + // ensure the URL `q` params gets cleaned + await hp.clearsInput(); + await page.waitForURL((url) => url.searchParams.get('q') === null, { timeout: 2000 }); + + // clearing the input + await hp.didMakeNthQuery({ nth: 2, query: { term: '' } }); + + // retyping a query + await hp.types('playwright'); + await hp.didMakeNthQuery({ nth: 3, query: { term: 'playwright' } }); + }); + test('performs paging', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(300); + await hp.openPage({}); + + // from page load, empty query + await hp.didMakeInitialQueries({ term: '' }); + + // scroll to end, triggering the next fetch + await hp.scrollsToEnd(); + + // make sure the offset is sent + await hp.didMakeNthPagingQuery({ nth: 1, query: { term: '' }, offset: 150 }); + + // now search for something that has many results + await hp.types('500'); + + // and assert we're back at the top of the container + await hp.didResetScroll(); + }); + test('selecting `all` resets to an empty query', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(300); + await hp.openPage({}); + + // start with a fresh range query + await hp.selectsToday(); + await hp.didMakeNthQuery({ nth: 1, query: { range: 'today' } }); + + // click 'all' (to reset) + await hp.selectsAll(); + + // ensure it's a full reset + await hp.didMakeNthQuery({ nth: 2, query: { term: '' } }); + }); + test('opening links', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(5); + await hp.openPage({}); + await hp.opensLinks(); + }); + test('cannot delete "all" when there are no history items', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(0); + await hp.openPage({}); + await hp.didMakeInitialQueries({ term: '' }); + await hp.cannotDeleteAllFromSidebar(); + }); + test('re-issues an empty query when there are no history items', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(0); + await hp.openPage({}); + await hp.didMakeInitialQueries({ term: '' }); + await hp.selectsAll(); + await hp.didMakeNthQuery({ nth: 1, query: { term: '' } }); + }); + test('deleting range from sidebar i tems + resetting the query state', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.didMakeInitialQueries({ term: '' }); + + await hp.selectsToday(); + await hp.didMakeNthQuery({ nth: 1, query: { range: 'today' } }); + + await hp.deletesHistoryForToday({ action: 'delete' }); + await hp.sideBarItemWasRemoved('Show history for today'); + + // makes a new query for default data + await hp.didMakeNthQuery({ nth: 2, query: { term: '' }, source: 'auto' }); + }); + test('deleting sidebar items, but dismissing modal', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.deletesHistoryForYesterday({ action: 'none' }); + await hp.sidebarHasItem('Show history for today'); + }); + test( + 'presses delete on range, but dismisses the modal', + { + annotation: { + type: 'issue', + description: 'https://app.asana.com/0/1201141132935289/1209501378934498', + }, + }, + async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + + // simulate a modal appearing, but the user dismissing it + await hp.deletesHistoryForYesterday({ action: 'none' }); + + // this timeout is needed to simulate the bug - a small delay after closing the modal + await page.waitForTimeout(100); + + // now check only the first query occurred (on page load) + await hp.didMakeNQueries(1); + }, + ); + test('deleting from the header', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.deletesAllHistoryFromHeader({ action: 'delete' }); + }); + test('deleting range from the header', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.selectsToday(); + await hp.clicksDeleteAllInHeader({ action: 'delete' }); + await hp.didDeleteRange('today'); + }); + + test('3 dots menu on history entry', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({}); + await hp.menuForHistoryEntry(0, { action: 'delete' }); + }); + test('accepts domain search as param', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({ additional: { domain: 'youtube.com', urlDebounce: 0 } }); + await hp.didMakeInitialQueries({ domain: 'youtube.com' }); + await hp.inputContainsDomain('youtube.com'); + + // now ensure it converts back to a query when typing + await hp.types('autotrader'); + await hp.didMakeNthQuery({ nth: 1, query: { term: 'autotrader' } }); + await hp.didUpdateUrlWithQueryTerm('autotrader'); + }); + test('accepts domain search in response to context menu', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({ additional: { action: 'domain-search' } }); + await hp.menuForHistoryEntry(0, { action: 'domain-search' }); + await hp.didMakeNthQuery({ nth: 1, query: { domain: 'youtube.com' } }); + }); + test('deleting domain-search from the header', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000); + await hp.openPage({ additional: { action: 'domain-search' } }); + await hp.menuForHistoryEntry(0, { action: 'domain-search' }); + await hp.didMakeNthQuery({ nth: 1, query: { domain: 'youtube.com' } }); + await hp.clicksDeleteAllInHeader({ action: 'delete' }); + await hp.didDeleteDomain('youtube.com'); + + // should have reset the UI now + await hp.didMakeNthQuery({ nth: 2, query: { term: '' }, source: 'auto' }); + }); + test('does not concatenate results if the query is not an addition', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(1); + await hp.openPage({}); + await hp.selectsAll(); + await page.waitForTimeout(100); + await hp.selectsAll(); + await page.waitForTimeout(100); + await hp.selectsAll(); + await page.waitForTimeout(100); + + // assert no additional rows are present + await hp.hasRowCount(1); + + // verify the queries still occurred, but they were not appended + await hp.didMakeNthQuery({ nth: 0, query: { term: '' }, source: 'initial' }); + await hp.didMakeNthQuery({ nth: 1, query: { term: '' } }); + await hp.didMakeNthQuery({ nth: 2, query: { term: '' } }); + await hp.didMakeNthQuery({ nth: 3, query: { term: '' } }); + }); + test('search after pressing submit', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(200); + await hp.openPage({}); + await hp.didMakeInitialQueries({ term: '' }); + await hp.types('café'); + await page.waitForURL((url) => url.searchParams.get('q') === 'café'); + await hp.didMakeNthQuery({ nth: 1, query: { term: 'café' } }); + await hp.submitSearchForm(); + await hp.didMakeNthQuery({ nth: 2, query: { term: 'café' } }); + }); + test.describe('default background colors', () => { + test('default background light', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(0); + await hp.openPage(); + await hp.hasEmptyState(); + await hp.hasBackgroundColor({ hex: '#fafafa' }); + }); + test('default background dark', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(0); + await hp.darkMode(); + await hp.openPage(); + await hp.hasEmptyState(); + await hp.hasBackgroundColor({ hex: '#333333' }); + }); + test('with overrides from initial setup (light)', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(0); + await hp.openPage({ additional: { defaultStyles: 'visual-refresh' } }); + await hp.hasEmptyState(); + await hp.hasBackgroundColor({ hex: '#E9EBEC' }); + }); + test('with overrides from initial setup (dark)', async ({ page }, workerInfo) => { + const hp = HistoryTestPage.create(page, workerInfo).withEntries(0); + await hp.darkMode(); + await hp.openPage({ additional: { defaultStyles: 'visual-refresh' } }); + await hp.hasEmptyState(); + await hp.hasBackgroundColor({ hex: '#27282A' }); + }); + }); +}); diff --git a/special-pages/pages/history/messages/deleteDomain.request.json b/special-pages/pages/history/messages/deleteDomain.request.json new file mode 100644 index 0000000000..975e3fdb62 --- /dev/null +++ b/special-pages/pages/history/messages/deleteDomain.request.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["domain"], + "title": "Delete Domain Params", + "properties": { + "domain": { + "type": "string" + } + } +} + diff --git a/special-pages/pages/history/messages/deleteDomain.response.json b/special-pages/pages/history/messages/deleteDomain.response.json new file mode 100644 index 0000000000..e106940995 --- /dev/null +++ b/special-pages/pages/history/messages/deleteDomain.response.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["action"], + "properties": { + "action": { + "$ref": "types/action-response.json" + } + } +} + diff --git a/special-pages/pages/history/messages/deleteRange.request.json b/special-pages/pages/history/messages/deleteRange.request.json new file mode 100644 index 0000000000..49eaa36ab7 --- /dev/null +++ b/special-pages/pages/history/messages/deleteRange.request.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["range"], + "title": "Delete Range Params", + "properties": { + "range": { + "$ref": "./types/range.json#/definitions/RangeId" + } + } +} + diff --git a/special-pages/pages/history/messages/deleteRange.response.json b/special-pages/pages/history/messages/deleteRange.response.json new file mode 100644 index 0000000000..e106940995 --- /dev/null +++ b/special-pages/pages/history/messages/deleteRange.response.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["action"], + "properties": { + "action": { + "$ref": "types/action-response.json" + } + } +} + diff --git a/special-pages/pages/history/messages/deleteTerm.request.json b/special-pages/pages/history/messages/deleteTerm.request.json new file mode 100644 index 0000000000..b757fe0354 --- /dev/null +++ b/special-pages/pages/history/messages/deleteTerm.request.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["term"], + "title": "Delete Term Params", + "properties": { + "term": { + "type": "string" + } + } +} + diff --git a/special-pages/pages/history/messages/deleteTerm.response.json b/special-pages/pages/history/messages/deleteTerm.response.json new file mode 100644 index 0000000000..e106940995 --- /dev/null +++ b/special-pages/pages/history/messages/deleteTerm.response.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["action"], + "properties": { + "action": { + "$ref": "types/action-response.json" + } + } +} + diff --git a/special-pages/pages/history/messages/entries_delete.request.json b/special-pages/pages/history/messages/entries_delete.request.json new file mode 100644 index 0000000000..7607ba4853 --- /dev/null +++ b/special-pages/pages/history/messages/entries_delete.request.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Entries Delete Params", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "items": { + "type": "string" + } + } + } +} + diff --git a/special-pages/pages/history/messages/entries_delete.response.json b/special-pages/pages/history/messages/entries_delete.response.json new file mode 100644 index 0000000000..e106940995 --- /dev/null +++ b/special-pages/pages/history/messages/entries_delete.response.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["action"], + "properties": { + "action": { + "$ref": "types/action-response.json" + } + } +} + diff --git a/special-pages/pages/history/messages/entries_menu.request.json b/special-pages/pages/history/messages/entries_menu.request.json new file mode 100644 index 0000000000..5b38b17633 --- /dev/null +++ b/special-pages/pages/history/messages/entries_menu.request.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Entries Menu Params", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "items": { + "type": "string" + } + } + } +} + diff --git a/special-pages/pages/history/messages/entries_menu.response.json b/special-pages/pages/history/messages/entries_menu.response.json new file mode 100644 index 0000000000..e106940995 --- /dev/null +++ b/special-pages/pages/history/messages/entries_menu.response.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["action"], + "properties": { + "action": { + "$ref": "types/action-response.json" + } + } +} + diff --git a/special-pages/pages/history/messages/getRanges.request.json b/special-pages/pages/history/messages/getRanges.request.json new file mode 100644 index 0000000000..65e2bc6661 --- /dev/null +++ b/special-pages/pages/history/messages/getRanges.request.json @@ -0,0 +1,4 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#" +} + diff --git a/special-pages/pages/history/messages/getRanges.response.json b/special-pages/pages/history/messages/getRanges.response.json new file mode 100644 index 0000000000..29ea6d75af --- /dev/null +++ b/special-pages/pages/history/messages/getRanges.response.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["ranges"], + "properties": { + "ranges": { + "type": "array", + "items": { + "$ref": "types/range.json" + } + } + } +} + diff --git a/special-pages/pages/new-tab/messages/stats_getConfig.request.json b/special-pages/pages/history/messages/initialSetup.request.json similarity index 100% rename from special-pages/pages/new-tab/messages/stats_getConfig.request.json rename to special-pages/pages/history/messages/initialSetup.request.json diff --git a/special-pages/pages/history/messages/initialSetup.response.json b/special-pages/pages/history/messages/initialSetup.response.json new file mode 100644 index 0000000000..7ae6a89333 --- /dev/null +++ b/special-pages/pages/history/messages/initialSetup.response.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["locale", "env", "platform"], + "properties": { + "locale": { + "type": "string" + }, + "env": { + "type": "string", + "enum": ["development", "production"] + }, + "platform": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "enum": ["macos", "windows", "android", "ios", "integration"] + } + } + }, + "customizer": { + "type": "object", + "properties": { + "defaultStyles": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "../../new-tab/messages/types/default-styles.json" + } + ] + } + } + } + } +} diff --git a/special-pages/pages/history/messages/open.notify.json b/special-pages/pages/history/messages/open.notify.json new file mode 100644 index 0000000000..3ad9cbaf13 --- /dev/null +++ b/special-pages/pages/history/messages/open.notify.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "History Open Action", + "type": "object", + "required": [ + "target", + "url" + ], + "properties": { + "url": { + "description": "The url to open", + "type": "string" + }, + "target": { + "$ref": "./types/open-target.json" + } + } +} diff --git a/special-pages/pages/history/messages/query.request.json b/special-pages/pages/history/messages/query.request.json new file mode 100644 index 0000000000..755b4a9538 --- /dev/null +++ b/special-pages/pages/history/messages/query.request.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "query", + "offset", + "limit", + "source" + ], + "title": "History Query", + "properties": { + "query": { + "$ref": "types/query.json" + }, + "offset": { + "description": "The starting point of records to query (zero-indexed); used for paging through large datasets", + "type": "number" + }, + "limit": { + "description": "Maximum number of records to return", + "type": "number" + }, + "source": { + "oneOf": [ + { + "const": "initial", + "title": "Initial Source", + "description": "Indicates the query was triggered before the UI was rendered" + }, + { + "const": "user", + "title": "User Source", + "description": "Indicates the query was following a user interaction" + }, + { + "const": "auto", + "title": "Auto Source", + "description": "Indicates the query was triggered automatically, for example in response to another action (like delete)" + } + ] + } + } +} + diff --git a/special-pages/pages/history/messages/query.response.json b/special-pages/pages/history/messages/query.response.json new file mode 100644 index 0000000000..30dc0b4720 --- /dev/null +++ b/special-pages/pages/history/messages/query.response.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "History Query Response", + "required": ["info", "value"], + "properties": { + "info": { + "type": "object", + "required": ["finished", "query"], + "title": "History Query Info", + "properties": { + "finished": { + "description": "Indicates whether there are more items outside of the current query", + "type": "boolean" + }, + "query": { + "$ref": "types/query.json" + } + } + }, + "value": { + "type": "array", + "items": { + "$ref": "./types/history-item.json" + } + } + } +} \ No newline at end of file diff --git a/special-pages/pages/history/messages/reportInitException.notify.json b/special-pages/pages/history/messages/reportInitException.notify.json new file mode 100644 index 0000000000..afd7d6bde2 --- /dev/null +++ b/special-pages/pages/history/messages/reportInitException.notify.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } +} diff --git a/special-pages/pages/history/messages/reportPageException.notify.json b/special-pages/pages/history/messages/reportPageException.notify.json new file mode 100644 index 0000000000..afd7d6bde2 --- /dev/null +++ b/special-pages/pages/history/messages/reportPageException.notify.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } +} diff --git a/special-pages/pages/history/messages/types/action-response.json b/special-pages/pages/history/messages/types/action-response.json new file mode 100644 index 0000000000..37496dfe5a --- /dev/null +++ b/special-pages/pages/history/messages/types/action-response.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Action Response", + "type": "string", + "oneOf": [ + { + "const": "delete", + "title": "Delete Action", + "description": "Confirms the user deleted this" + }, + { + "const": "none", + "title": "None Action", + "description": "The user cancelled the action, or did not agree to it" + }, + { + "const": "domain-search", + "title": "Domain Search Action", + "description": "The user asked to see more results from the domain" + } + ] +} diff --git a/special-pages/pages/history/messages/types/default-styles.json b/special-pages/pages/history/messages/types/default-styles.json new file mode 100644 index 0000000000..fdc89e98d2 --- /dev/null +++ b/special-pages/pages/history/messages/types/default-styles.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Default Styles", + "properties": { + "darkBackgroundColor": { + "description": "Optional default dark background color. Any HEX value is permitted", + "type": "string", + "examples": [ + "#000000", + "#333333" + ] + }, + "lightBackgroundColor": { + "description": "Optional default light background color. Any HEX value is permitted", + "type": "string", + "examples": [ + "#FFFFFF", + "#CCCCCC" + ] + } + } +} \ No newline at end of file diff --git a/special-pages/pages/history/messages/types/favicon.json b/special-pages/pages/history/messages/types/favicon.json new file mode 100644 index 0000000000..aad74689a2 --- /dev/null +++ b/special-pages/pages/history/messages/types/favicon.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Favicon", + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "required": ["src"], + "properties": { + "src": { + "type": "string" + }, + "maxAvailableSize": { + "type": "number" + } + } + } + ] +} diff --git a/special-pages/pages/history/messages/types/history-item.json b/special-pages/pages/history/messages/types/history-item.json new file mode 100644 index 0000000000..ce59b515a2 --- /dev/null +++ b/special-pages/pages/history/messages/types/history-item.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "id", + "dateRelativeDay", + "dateShort", + "domain", + "time", + "title", + "url" + ], + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the entry." + }, + "dateRelativeDay": { + "type": "string", + "description": "A relative day with a detailed date (e.g., 'Today - Wednesday 15 January 2025')." + }, + "dateShort": { + "type": "string", + "description": "A short date format (e.g., '15 Jan 2025')." + }, + "dateTimeOfDay": { + "type": "string", + "description": "The time of day in 24-hour format (e.g., '11:01')." + }, + "etldPlusOne": { + "type": "string", + "format": "hostname", + "description": "The eTLD+1 version of the domain, representing the domain and its top-level domain (e.g., 'example.com', 'localhost'). This differs from 'domain', which may include subdomains (e.g., 'www.youtube.com')." + }, + "domain": { + "type": "string", + "description": "The full domain to show beside the site title, eg: 'www.youtube.com'" + }, + "title": { + "type": "string", + "description": "Title of the page (e.g., 'YouTube')." + }, + "url": { + "type": "string", + "format": "uri", + "description": "A complete URL including query parameters." + }, + "favicon": { + "$ref": "./favicon.json" + } + } +} \ No newline at end of file diff --git a/special-pages/pages/history/messages/types/open-target.json b/special-pages/pages/history/messages/types/open-target.json new file mode 100644 index 0000000000..9b9db911ac --- /dev/null +++ b/special-pages/pages/history/messages/types/open-target.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Open Target", + "type": "string", + "enum": ["same-tab", "new-tab", "new-window"] +} diff --git a/special-pages/pages/history/messages/types/query.json b/special-pages/pages/history/messages/types/query.json new file mode 100644 index 0000000000..8bfa656354 --- /dev/null +++ b/special-pages/pages/history/messages/types/query.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "QueryKind", + "oneOf": [ + { + "type": "object", + "title": "Search Term", + "required": [ + "term" + ], + "properties": { + "term": { + "type": "string" + } + } + }, + { + "type": "object", + "title": "Domain Filter", + "required": [ + "domain" + ], + "properties": { + "domain": { + "type": "string" + } + } + }, + { + "type": "object", + "title": "Range Filter", + "required": [ + "range" + ], + "properties": { + "range": { + "$ref": "./range.json#/definitions/RangeId" + } + } + } + ] +} diff --git a/special-pages/pages/history/messages/types/range.json b/special-pages/pages/history/messages/types/range.json new file mode 100644 index 0000000000..2dd15c0ce1 --- /dev/null +++ b/special-pages/pages/history/messages/types/range.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Range", + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/RangeId" + }, + "count": { + "type": "number" + } + }, + "required": [ + "id", + "count" + ], + "definitions": { + "RangeId": { + "type": "string", + "title": "RangeId", + "enum": [ + "all", + "today", + "yesterday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + "older", + "sites" + ] + } + }, + "examples": [ + { + "id": "today", + "count": 10 + }, + { + "id": "monday", + "count": 5 + } + ] +} diff --git a/special-pages/pages/history/public/company-icons/fake.svg b/special-pages/pages/history/public/company-icons/fake.svg new file mode 100644 index 0000000000..5faf1bb8da --- /dev/null +++ b/special-pages/pages/history/public/company-icons/fake.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/special-pages/pages/history/public/company-icons/other-dark.svg b/special-pages/pages/history/public/company-icons/other-dark.svg new file mode 100644 index 0000000000..5f2007766b --- /dev/null +++ b/special-pages/pages/history/public/company-icons/other-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/special-pages/pages/history/public/company-icons/other.svg b/special-pages/pages/history/public/company-icons/other.svg new file mode 100644 index 0000000000..3287cf3ddc --- /dev/null +++ b/special-pages/pages/history/public/company-icons/other.svg @@ -0,0 +1,3 @@ + + + diff --git a/special-pages/pages/history/public/icons/all.svg b/special-pages/pages/history/public/icons/all.svg new file mode 100644 index 0000000000..7c63449d31 --- /dev/null +++ b/special-pages/pages/history/public/icons/all.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/special-pages/pages/history/public/icons/backdrop.svg b/special-pages/pages/history/public/icons/backdrop.svg new file mode 100644 index 0000000000..f46f5f26cd --- /dev/null +++ b/special-pages/pages/history/public/icons/backdrop.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/special-pages/pages/history/public/icons/clear-dark.svg b/special-pages/pages/history/public/icons/clear-dark.svg new file mode 100644 index 0000000000..b1f6704622 --- /dev/null +++ b/special-pages/pages/history/public/icons/clear-dark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/special-pages/pages/history/public/icons/clear.svg b/special-pages/pages/history/public/icons/clear.svg new file mode 100644 index 0000000000..2e8be1b5d3 --- /dev/null +++ b/special-pages/pages/history/public/icons/clear.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/special-pages/pages/history/public/icons/clock.svg b/special-pages/pages/history/public/icons/clock.svg new file mode 100644 index 0000000000..78e5f5ddf6 --- /dev/null +++ b/special-pages/pages/history/public/icons/clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/special-pages/pages/history/public/icons/closed.svg b/special-pages/pages/history/public/icons/closed.svg new file mode 100644 index 0000000000..fce262fff6 --- /dev/null +++ b/special-pages/pages/history/public/icons/closed.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/special-pages/pages/history/public/icons/day.svg b/special-pages/pages/history/public/icons/day.svg new file mode 100644 index 0000000000..0eb6fe2156 --- /dev/null +++ b/special-pages/pages/history/public/icons/day.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/special-pages/pages/history/public/icons/older.svg b/special-pages/pages/history/public/icons/older.svg new file mode 100644 index 0000000000..09af14cd50 --- /dev/null +++ b/special-pages/pages/history/public/icons/older.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/special-pages/pages/history/public/icons/sites.svg b/special-pages/pages/history/public/icons/sites.svg new file mode 100644 index 0000000000..27eb7ad66c --- /dev/null +++ b/special-pages/pages/history/public/icons/sites.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/special-pages/pages/history/public/icons/today.svg b/special-pages/pages/history/public/icons/today.svg new file mode 100644 index 0000000000..88cf6a3ff9 --- /dev/null +++ b/special-pages/pages/history/public/icons/today.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/special-pages/pages/history/public/icons/yesterday.svg b/special-pages/pages/history/public/icons/yesterday.svg new file mode 100644 index 0000000000..7215ee090f --- /dev/null +++ b/special-pages/pages/history/public/icons/yesterday.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/special-pages/pages/history/public/index.html b/special-pages/pages/history/public/index.html new file mode 100644 index 0000000000..c17fd166bf --- /dev/null +++ b/special-pages/pages/history/public/index.html @@ -0,0 +1,24 @@ + + + + + History + + + + + + +
    + + + diff --git a/special-pages/pages/history/public/locales/de/history.json b/special-pages/pages/history/public/locales/de/history.json new file mode 100644 index 0000000000..ab7c51cd8e --- /dev/null +++ b/special-pages/pages/history/public/locales/de/history.json @@ -0,0 +1,123 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "empty_title" : { + "title" : "Hier gibt's nichts zu sehen!", + "note" : "Text shown where there are no remaining history entries" + }, + "empty_text" : { + "title" : "Noch kein Browserverlauf.", + "note" : "Placeholder text when there's no results to show" + }, + "no_results_title" : { + "title" : "Keine Ergebnisse für {term} gefunden", + "note" : "The placeholder {term} will be dynamically replaced with the search term entered by the user. For example, if the user searches for 'cats', the title will become 'No results found for cats'." + }, + "no_results_text" : { + "title" : "Versuche es mit einer anderen URL oder anderen Stichwörtern.", + "note" : "Placeholder text when a search gave no results." + }, + "delete_all" : { + "title" : "Alle löschen", + "note" : "Text for a button that deletes all items or entries. An additional confirmation dialog will be presented." + }, + "delete_some" : { + "title" : "Löschen", + "note" : "Text for a button that deletes currently selected items" + }, + "delete_none" : { + "title" : "Nichts zu löschen", + "note" : "Title/tooltip text on a button that does nothing when there is no browsing history to delete. It's additional information shown on hover." + }, + "page_title" : { + "title" : "Verlauf", + "note" : "The main page title" + }, + "search" : { + "title" : "Suche", + "note" : "The placeholder text in a search input field." + }, + "show_history_all" : { + "title" : "Gesamten Verlauf anzeigen", + "note" : "Button text for an action that removes all filters and searches, and replaces the list with all history." + }, + "show_history_older" : { + "title" : "Älteren Verlauf anzeigen", + "note" : "Button that shows older history entries" + }, + "show_history_for" : { + "title" : "Verlauf von {range} anzeigen", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Show history for Today'." + }, + "delete_history_all" : { + "title" : "Gesamten Verlauf löschen", + "note" : "Button text for an action that removes all history entries." + }, + "delete_history_older" : { + "title" : "Älteren Verlauf löschen", + "note" : "Button that deletes older history entries." + }, + "delete_history_for" : { + "title" : "Verlauf von {range} löschen", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Delete history for Today'." + }, + "search_your_history" : { + "title" : "Durchsuche deinen Verlauf", + "note" : "Label text for screen readers. It's shown next to the search input field" + }, + "range_all" : { + "title" : "Alle", + "note" : "Label on a button that shows all history entries" + }, + "range_today" : { + "title" : "Heute", + "note" : "Label on a button that shows history entries for today only" + }, + "range_yesterday" : { + "title" : "Gestern", + "note" : "Label on a button that shows history entries for yesterday only" + }, + "range_monday" : { + "title" : "Montag", + "note" : "Label on a button that shows history entries for monday only" + }, + "range_tuesday" : { + "title" : "Dienstag", + "note" : "Label on a button that shows history entries for tuesday only" + }, + "range_wednesday" : { + "title" : "Mittwoch", + "note" : "Label on a button that shows history entries for wednesday only" + }, + "range_thursday" : { + "title" : "Donnerstag", + "note" : "Label on a button that shows history entries for thursday only" + }, + "range_friday" : { + "title" : "Freitag", + "note" : "Label on a button that shows history entries for friday only" + }, + "range_saturday" : { + "title" : "Samstag", + "note" : "Label on a button that shows history entries for saturday only" + }, + "range_sunday" : { + "title" : "Sonntag", + "note" : "Label on a button that shows history entries for sunday only" + }, + "range_older" : { + "title" : "Älter", + "note" : "Label on a button that shows older history entries." + }, + "range_sites" : { + "title" : "Websites", + "note" : "Label on a button that shows which sites have been visited" + } +} \ No newline at end of file diff --git a/special-pages/pages/history/public/locales/en/history.json b/special-pages/pages/history/public/locales/en/history.json new file mode 100644 index 0000000000..3250c691fd --- /dev/null +++ b/special-pages/pages/history/public/locales/en/history.json @@ -0,0 +1,124 @@ +{ + "smartling": { + "string_format": "icu", + "translate_paths": [ + { + "path": "*/title", + "key": "{*}/title", + "instruction": "*/note" + } + ] + }, + "empty_title": { + "title": "Nothing to see here!", + "note": "Text shown where there are no remaining history entries" + }, + "empty_text": { + "title": "No browsing history yet.", + "note": "Placeholder text when there's no results to show" + }, + "no_results_title": { + "title": "No results found for {term}", + "note": "The placeholder {term} will be dynamically replaced with the search term entered by the user. For example, if the user searches for 'cats', the title will become 'No results found for cats'." + }, + "no_results_text": { + "title": "Try searching for a different URL or keywords.", + "note": "Placeholder text when a search gave no results." + }, + "delete_all": { + "title": "Delete All", + "note": "Text for a button that deletes all items or entries. An additional confirmation dialog will be presented." + }, + "delete_some": { + "title": "Delete", + "note": "Text for a button that deletes currently selected items" + }, + "delete_none": { + "title": "Nothing to delete", + "note": "Title/tooltip text on a button that does nothing when there is no browsing history to delete. It's additional information shown on hover." + }, + "page_title": { + "title": "History", + "note": "The main page title" + }, + "search": { + "title": "Search", + "note": "The placeholder text in a search input field." + }, + "show_history_all": { + "title": "Show all history", + "note": "Button text for an action that removes all filters and searches, and replaces the list with all history." + }, + "show_history_older": { + "title": "Show older history", + "note": "Button that shows older history entries" + }, + "show_history_for": { + "title": "Show history for {range}", + "note": "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Show history for Today'." + }, + "delete_history_all": { + "title": "Delete all history", + "note": "Button text for an action that removes all history entries." + }, + "delete_history_older": { + "title": "Delete older history", + "note": "Button that deletes older history entries." + }, + "delete_history_for": { + "title": "Delete history for {range}", + "note": "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Delete history for Today'." + }, + "search_your_history": { + "title": "Search your history", + "note": "Label text for screen readers. It's shown next to the search input field" + }, + "range_all": { + "title": "All", + "note": "Label on a button that shows all history entries" + }, + "range_today": { + "title": "Today", + "note": "Label on a button that shows history entries for today only" + }, + "range_yesterday": { + "title": "Yesterday", + "note": "Label on a button that shows history entries for yesterday only" + }, + "range_monday": { + "title": "Monday", + "note": "Label on a button that shows history entries for monday only" + }, + "range_tuesday": { + "title": "Tuesday", + "note": "Label on a button that shows history entries for tuesday only" + }, + "range_wednesday": { + "title": "Wednesday", + "note": "Label on a button that shows history entries for wednesday only" + }, + "range_thursday": { + "title": "Thursday", + "note": "Label on a button that shows history entries for thursday only" + }, + "range_friday": { + "title": "Friday", + "note": "Label on a button that shows history entries for friday only" + }, + "range_saturday": { + "title": "Saturday", + "note": "Label on a button that shows history entries for saturday only" + }, + "range_sunday": { + "title": "Sunday", + "note": "Label on a button that shows history entries for sunday only" + }, + "range_older": { + "title": "Older", + "note": "Label on a button that shows older history entries." + }, + "range_sites": { + "title": "Sites", + "note": "Label on a button that shows which sites have been visited" + } +} \ No newline at end of file diff --git a/special-pages/pages/history/public/locales/es/history.json b/special-pages/pages/history/public/locales/es/history.json new file mode 100644 index 0000000000..d674579764 --- /dev/null +++ b/special-pages/pages/history/public/locales/es/history.json @@ -0,0 +1,123 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "empty_title" : { + "title" : "¡No hay nada que ver aquí!", + "note" : "Text shown where there are no remaining history entries" + }, + "empty_text" : { + "title" : "Todavía no hay historial de navegación.", + "note" : "Placeholder text when there's no results to show" + }, + "no_results_title" : { + "title" : "No se han encontrado resultados para {term}", + "note" : "The placeholder {term} will be dynamically replaced with the search term entered by the user. For example, if the user searches for 'cats', the title will become 'No results found for cats'." + }, + "no_results_text" : { + "title" : "Intenta buscar una URL diferente o palabras clave distintas.", + "note" : "Placeholder text when a search gave no results." + }, + "delete_all" : { + "title" : "Eliminar todo", + "note" : "Text for a button that deletes all items or entries. An additional confirmation dialog will be presented." + }, + "delete_some" : { + "title" : "Eliminar", + "note" : "Text for a button that deletes currently selected items" + }, + "delete_none" : { + "title" : "Nada que eliminar", + "note" : "Title/tooltip text on a button that does nothing when there is no browsing history to delete. It's additional information shown on hover." + }, + "page_title" : { + "title" : "Historial", + "note" : "The main page title" + }, + "search" : { + "title" : "Buscar", + "note" : "The placeholder text in a search input field." + }, + "show_history_all" : { + "title" : "Mostrar todo el historial", + "note" : "Button text for an action that removes all filters and searches, and replaces the list with all history." + }, + "show_history_older" : { + "title" : "Mostrar el historial más antiguo", + "note" : "Button that shows older history entries" + }, + "show_history_for" : { + "title" : "Mostrar historial para {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Show history for Today'." + }, + "delete_history_all" : { + "title" : "Borrar todo el historial", + "note" : "Button text for an action that removes all history entries." + }, + "delete_history_older" : { + "title" : "Borrar historial más antiguo", + "note" : "Button that deletes older history entries." + }, + "delete_history_for" : { + "title" : "Borrar historial para {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Delete history for Today'." + }, + "search_your_history" : { + "title" : "Buscar en tu historial", + "note" : "Label text for screen readers. It's shown next to the search input field" + }, + "range_all" : { + "title" : "Todo", + "note" : "Label on a button that shows all history entries" + }, + "range_today" : { + "title" : "Hoy", + "note" : "Label on a button that shows history entries for today only" + }, + "range_yesterday" : { + "title" : "Ayer", + "note" : "Label on a button that shows history entries for yesterday only" + }, + "range_monday" : { + "title" : "Lunes", + "note" : "Label on a button that shows history entries for monday only" + }, + "range_tuesday" : { + "title" : "Martes", + "note" : "Label on a button that shows history entries for tuesday only" + }, + "range_wednesday" : { + "title" : "Miércoles", + "note" : "Label on a button that shows history entries for wednesday only" + }, + "range_thursday" : { + "title" : "Jueves", + "note" : "Label on a button that shows history entries for thursday only" + }, + "range_friday" : { + "title" : "Viernes", + "note" : "Label on a button that shows history entries for friday only" + }, + "range_saturday" : { + "title" : "Sábado", + "note" : "Label on a button that shows history entries for saturday only" + }, + "range_sunday" : { + "title" : "Domingo", + "note" : "Label on a button that shows history entries for sunday only" + }, + "range_older" : { + "title" : "Más antiguo", + "note" : "Label on a button that shows older history entries." + }, + "range_sites" : { + "title" : "Sitios", + "note" : "Label on a button that shows which sites have been visited" + } +} \ No newline at end of file diff --git a/special-pages/pages/history/public/locales/fr/history.json b/special-pages/pages/history/public/locales/fr/history.json new file mode 100644 index 0000000000..0f537238af --- /dev/null +++ b/special-pages/pages/history/public/locales/fr/history.json @@ -0,0 +1,123 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "empty_title" : { + "title" : "Il n'y a rien à voir ici !", + "note" : "Text shown where there are no remaining history entries" + }, + "empty_text" : { + "title" : "Aucun historique de navigation pour le moment.", + "note" : "Placeholder text when there's no results to show" + }, + "no_results_title" : { + "title" : "Aucun résultat trouvé pour {term}", + "note" : "The placeholder {term} will be dynamically replaced with the search term entered by the user. For example, if the user searches for 'cats', the title will become 'No results found for cats'." + }, + "no_results_text" : { + "title" : "Essayez de rechercher une URL ou des mots-clés différents.", + "note" : "Placeholder text when a search gave no results." + }, + "delete_all" : { + "title" : "Tout supprimer", + "note" : "Text for a button that deletes all items or entries. An additional confirmation dialog will be presented." + }, + "delete_some" : { + "title" : "Supprimer", + "note" : "Text for a button that deletes currently selected items" + }, + "delete_none" : { + "title" : "Rien à supprimer", + "note" : "Title/tooltip text on a button that does nothing when there is no browsing history to delete. It's additional information shown on hover." + }, + "page_title" : { + "title" : "Historique", + "note" : "The main page title" + }, + "search" : { + "title" : "Rechercher", + "note" : "The placeholder text in a search input field." + }, + "show_history_all" : { + "title" : "Afficher tout l'historique", + "note" : "Button text for an action that removes all filters and searches, and replaces the list with all history." + }, + "show_history_older" : { + "title" : "Afficher l'historique plus ancien", + "note" : "Button that shows older history entries" + }, + "show_history_for" : { + "title" : "Afficher l'historique d'/de {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Show history for Today'." + }, + "delete_history_all" : { + "title" : "Supprimer tout l'historique", + "note" : "Button text for an action that removes all history entries." + }, + "delete_history_older" : { + "title" : "Supprimer l'historique plus ancien", + "note" : "Button that deletes older history entries." + }, + "delete_history_for" : { + "title" : "Supprimer l'historique d'/de {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Delete history for Today'." + }, + "search_your_history" : { + "title" : "Rechercher dans votre historique", + "note" : "Label text for screen readers. It's shown next to the search input field" + }, + "range_all" : { + "title" : "Tous", + "note" : "Label on a button that shows all history entries" + }, + "range_today" : { + "title" : "Auj.", + "note" : "Label on a button that shows history entries for today only" + }, + "range_yesterday" : { + "title" : "Hier", + "note" : "Label on a button that shows history entries for yesterday only" + }, + "range_monday" : { + "title" : "Lundi", + "note" : "Label on a button that shows history entries for monday only" + }, + "range_tuesday" : { + "title" : "Mardi", + "note" : "Label on a button that shows history entries for tuesday only" + }, + "range_wednesday" : { + "title" : "Mercredi", + "note" : "Label on a button that shows history entries for wednesday only" + }, + "range_thursday" : { + "title" : "Jeudi", + "note" : "Label on a button that shows history entries for thursday only" + }, + "range_friday" : { + "title" : "Vendredi", + "note" : "Label on a button that shows history entries for friday only" + }, + "range_saturday" : { + "title" : "Samedi", + "note" : "Label on a button that shows history entries for saturday only" + }, + "range_sunday" : { + "title" : "Dimanche", + "note" : "Label on a button that shows history entries for sunday only" + }, + "range_older" : { + "title" : "Anciennes", + "note" : "Label on a button that shows older history entries." + }, + "range_sites" : { + "title" : "Sites", + "note" : "Label on a button that shows which sites have been visited" + } +} \ No newline at end of file diff --git a/special-pages/pages/history/public/locales/it/history.json b/special-pages/pages/history/public/locales/it/history.json new file mode 100644 index 0000000000..fc0831e2e6 --- /dev/null +++ b/special-pages/pages/history/public/locales/it/history.json @@ -0,0 +1,123 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "empty_title" : { + "title" : "Non c'è niente da vedere qui!", + "note" : "Text shown where there are no remaining history entries" + }, + "empty_text" : { + "title" : "Ancora nessuna cronologia di navigazione.", + "note" : "Placeholder text when there's no results to show" + }, + "no_results_title" : { + "title" : "Nessun risultato trovato per {term}", + "note" : "The placeholder {term} will be dynamically replaced with the search term entered by the user. For example, if the user searches for 'cats', the title will become 'No results found for cats'." + }, + "no_results_text" : { + "title" : "Prova a cercare un URL o parole chiave diverse.", + "note" : "Placeholder text when a search gave no results." + }, + "delete_all" : { + "title" : "Elimina tutto", + "note" : "Text for a button that deletes all items or entries. An additional confirmation dialog will be presented." + }, + "delete_some" : { + "title" : "Elimina", + "note" : "Text for a button that deletes currently selected items" + }, + "delete_none" : { + "title" : "Nulla da eliminare", + "note" : "Title/tooltip text on a button that does nothing when there is no browsing history to delete. It's additional information shown on hover." + }, + "page_title" : { + "title" : "Cronologia", + "note" : "The main page title" + }, + "search" : { + "title" : "Ricerca", + "note" : "The placeholder text in a search input field." + }, + "show_history_all" : { + "title" : "Mostra tutta la cronologia", + "note" : "Button text for an action that removes all filters and searches, and replaces the list with all history." + }, + "show_history_older" : { + "title" : "Mostra cronologia precedente", + "note" : "Button that shows older history entries" + }, + "show_history_for" : { + "title" : "Mostra la cronologia per {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Show history for Today'." + }, + "delete_history_all" : { + "title" : "Elimina tutta la cronologia", + "note" : "Button text for an action that removes all history entries." + }, + "delete_history_older" : { + "title" : "Elimina la cronologia precedente", + "note" : "Button that deletes older history entries." + }, + "delete_history_for" : { + "title" : "Eliminare la cronologia di {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Delete history for Today'." + }, + "search_your_history" : { + "title" : "Cerca nella tua cronologia", + "note" : "Label text for screen readers. It's shown next to the search input field" + }, + "range_all" : { + "title" : "Tutto", + "note" : "Label on a button that shows all history entries" + }, + "range_today" : { + "title" : "Oggi", + "note" : "Label on a button that shows history entries for today only" + }, + "range_yesterday" : { + "title" : "Ieri", + "note" : "Label on a button that shows history entries for yesterday only" + }, + "range_monday" : { + "title" : "Lunedì", + "note" : "Label on a button that shows history entries for monday only" + }, + "range_tuesday" : { + "title" : "Martedì", + "note" : "Label on a button that shows history entries for tuesday only" + }, + "range_wednesday" : { + "title" : "Mercoledì", + "note" : "Label on a button that shows history entries for wednesday only" + }, + "range_thursday" : { + "title" : "Giovedì", + "note" : "Label on a button that shows history entries for thursday only" + }, + "range_friday" : { + "title" : "Venerdì", + "note" : "Label on a button that shows history entries for friday only" + }, + "range_saturday" : { + "title" : "Sabato", + "note" : "Label on a button that shows history entries for saturday only" + }, + "range_sunday" : { + "title" : "Domenica", + "note" : "Label on a button that shows history entries for sunday only" + }, + "range_older" : { + "title" : "Più vecchio", + "note" : "Label on a button that shows older history entries." + }, + "range_sites" : { + "title" : "Siti", + "note" : "Label on a button that shows which sites have been visited" + } +} \ No newline at end of file diff --git a/special-pages/pages/history/public/locales/nl/history.json b/special-pages/pages/history/public/locales/nl/history.json new file mode 100644 index 0000000000..fe9de50a62 --- /dev/null +++ b/special-pages/pages/history/public/locales/nl/history.json @@ -0,0 +1,123 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "empty_title" : { + "title" : "Er is hier niets te zien!", + "note" : "Text shown where there are no remaining history entries" + }, + "empty_text" : { + "title" : "Nog geen surfgeschiedenis.", + "note" : "Placeholder text when there's no results to show" + }, + "no_results_title" : { + "title" : "Geen resultaten gevonden voor {term}", + "note" : "The placeholder {term} will be dynamically replaced with the search term entered by the user. For example, if the user searches for 'cats', the title will become 'No results found for cats'." + }, + "no_results_text" : { + "title" : "Probeer te zoeken naar een andere URL of trefwoorden.", + "note" : "Placeholder text when a search gave no results." + }, + "delete_all" : { + "title" : "Alles verwijderen", + "note" : "Text for a button that deletes all items or entries. An additional confirmation dialog will be presented." + }, + "delete_some" : { + "title" : "Verwijderen", + "note" : "Text for a button that deletes currently selected items" + }, + "delete_none" : { + "title" : "Er is niets om te verwijderen", + "note" : "Title/tooltip text on a button that does nothing when there is no browsing history to delete. It's additional information shown on hover." + }, + "page_title" : { + "title" : "Geschiedenis", + "note" : "The main page title" + }, + "search" : { + "title" : "Zoeken", + "note" : "The placeholder text in a search input field." + }, + "show_history_all" : { + "title" : "Alle geschiedenis tonen", + "note" : "Button text for an action that removes all filters and searches, and replaces the list with all history." + }, + "show_history_older" : { + "title" : "Oudere geschiedenis tonen", + "note" : "Button that shows older history entries" + }, + "show_history_for" : { + "title" : "Geschiedenis weergeven voor {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Show history for Today'." + }, + "delete_history_all" : { + "title" : "Alle geschiedenis verwijderen", + "note" : "Button text for an action that removes all history entries." + }, + "delete_history_older" : { + "title" : "Oudere geschiedenis verwijderen", + "note" : "Button that deletes older history entries." + }, + "delete_history_for" : { + "title" : "Geschiedenis verwijderen voor {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Delete history for Today'." + }, + "search_your_history" : { + "title" : "Zoek in je geschiedenis", + "note" : "Label text for screen readers. It's shown next to the search input field" + }, + "range_all" : { + "title" : "Alle", + "note" : "Label on a button that shows all history entries" + }, + "range_today" : { + "title" : "Vandaag", + "note" : "Label on a button that shows history entries for today only" + }, + "range_yesterday" : { + "title" : "Gisteren", + "note" : "Label on a button that shows history entries for yesterday only" + }, + "range_monday" : { + "title" : "maandag", + "note" : "Label on a button that shows history entries for monday only" + }, + "range_tuesday" : { + "title" : "dinsdag", + "note" : "Label on a button that shows history entries for tuesday only" + }, + "range_wednesday" : { + "title" : "woensdag", + "note" : "Label on a button that shows history entries for wednesday only" + }, + "range_thursday" : { + "title" : "donderdag", + "note" : "Label on a button that shows history entries for thursday only" + }, + "range_friday" : { + "title" : "vrijdag", + "note" : "Label on a button that shows history entries for friday only" + }, + "range_saturday" : { + "title" : "zaterdag", + "note" : "Label on a button that shows history entries for saturday only" + }, + "range_sunday" : { + "title" : "zondag", + "note" : "Label on a button that shows history entries for sunday only" + }, + "range_older" : { + "title" : "Ouder", + "note" : "Label on a button that shows older history entries." + }, + "range_sites" : { + "title" : "Sites", + "note" : "Label on a button that shows which sites have been visited" + } +} \ No newline at end of file diff --git a/special-pages/pages/history/public/locales/pl/history.json b/special-pages/pages/history/public/locales/pl/history.json new file mode 100644 index 0000000000..bc97eb4321 --- /dev/null +++ b/special-pages/pages/history/public/locales/pl/history.json @@ -0,0 +1,123 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "empty_title" : { + "title" : "Tutaj nie ma nic do oglądania!", + "note" : "Text shown where there are no remaining history entries" + }, + "empty_text" : { + "title" : "Jeszcze nie ma historii przeglądania.", + "note" : "Placeholder text when there's no results to show" + }, + "no_results_title" : { + "title" : "Brak wyników dla „{term}”", + "note" : "The placeholder {term} will be dynamically replaced with the search term entered by the user. For example, if the user searches for 'cats', the title will become 'No results found for cats'." + }, + "no_results_text" : { + "title" : "Spróbuj wyszukać inny adres URL lub inne słowa kluczowe.", + "note" : "Placeholder text when a search gave no results." + }, + "delete_all" : { + "title" : "Usuń wszystko", + "note" : "Text for a button that deletes all items or entries. An additional confirmation dialog will be presented." + }, + "delete_some" : { + "title" : "Usuń", + "note" : "Text for a button that deletes currently selected items" + }, + "delete_none" : { + "title" : "Nie ma nic do usunięcia", + "note" : "Title/tooltip text on a button that does nothing when there is no browsing history to delete. It's additional information shown on hover." + }, + "page_title" : { + "title" : "Historia", + "note" : "The main page title" + }, + "search" : { + "title" : "Szukaj", + "note" : "The placeholder text in a search input field." + }, + "show_history_all" : { + "title" : "Pokaż całą historię", + "note" : "Button text for an action that removes all filters and searches, and replaces the list with all history." + }, + "show_history_older" : { + "title" : "Pokaż starszą historię", + "note" : "Button that shows older history entries" + }, + "show_history_for" : { + "title" : "Pokaż historię dotyczącą zakresu {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Show history for Today'." + }, + "delete_history_all" : { + "title" : "Usuń całą historię", + "note" : "Button text for an action that removes all history entries." + }, + "delete_history_older" : { + "title" : "Usuń starszą historię", + "note" : "Button that deletes older history entries." + }, + "delete_history_for" : { + "title" : "Usuń historię dotyczącą zakresu {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Delete history for Today'." + }, + "search_your_history" : { + "title" : "Przeszukaj historię", + "note" : "Label text for screen readers. It's shown next to the search input field" + }, + "range_all" : { + "title" : "Wszystko", + "note" : "Label on a button that shows all history entries" + }, + "range_today" : { + "title" : "Dziś", + "note" : "Label on a button that shows history entries for today only" + }, + "range_yesterday" : { + "title" : "Wczoraj", + "note" : "Label on a button that shows history entries for yesterday only" + }, + "range_monday" : { + "title" : "poniedziałek", + "note" : "Label on a button that shows history entries for monday only" + }, + "range_tuesday" : { + "title" : "Wtorek", + "note" : "Label on a button that shows history entries for tuesday only" + }, + "range_wednesday" : { + "title" : "Środa", + "note" : "Label on a button that shows history entries for wednesday only" + }, + "range_thursday" : { + "title" : "czwartek", + "note" : "Label on a button that shows history entries for thursday only" + }, + "range_friday" : { + "title" : "piątek", + "note" : "Label on a button that shows history entries for friday only" + }, + "range_saturday" : { + "title" : "Sobota", + "note" : "Label on a button that shows history entries for saturday only" + }, + "range_sunday" : { + "title" : "niedziela", + "note" : "Label on a button that shows history entries for sunday only" + }, + "range_older" : { + "title" : "Starsze", + "note" : "Label on a button that shows older history entries." + }, + "range_sites" : { + "title" : "Witryny", + "note" : "Label on a button that shows which sites have been visited" + } +} \ No newline at end of file diff --git a/special-pages/pages/history/public/locales/pt/history.json b/special-pages/pages/history/public/locales/pt/history.json new file mode 100644 index 0000000000..3dac530bd0 --- /dev/null +++ b/special-pages/pages/history/public/locales/pt/history.json @@ -0,0 +1,123 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "empty_title" : { + "title" : "Não há nada para ver aqui!", + "note" : "Text shown where there are no remaining history entries" + }, + "empty_text" : { + "title" : "Ainda não há histórico de navegação.", + "note" : "Placeholder text when there's no results to show" + }, + "no_results_title" : { + "title" : "Nenhum resultado encontrado para {term}", + "note" : "The placeholder {term} will be dynamically replaced with the search term entered by the user. For example, if the user searches for 'cats', the title will become 'No results found for cats'." + }, + "no_results_text" : { + "title" : "Experimenta pesquisar um URL ou palavras-chave diferentes.", + "note" : "Placeholder text when a search gave no results." + }, + "delete_all" : { + "title" : "Eliminar tudo", + "note" : "Text for a button that deletes all items or entries. An additional confirmation dialog will be presented." + }, + "delete_some" : { + "title" : "Eliminar", + "note" : "Text for a button that deletes currently selected items" + }, + "delete_none" : { + "title" : "Nada para apagar", + "note" : "Title/tooltip text on a button that does nothing when there is no browsing history to delete. It's additional information shown on hover." + }, + "page_title" : { + "title" : "Histórico", + "note" : "The main page title" + }, + "search" : { + "title" : "Pesquisar", + "note" : "The placeholder text in a search input field." + }, + "show_history_all" : { + "title" : "Mostrar todo o histórico", + "note" : "Button text for an action that removes all filters and searches, and replaces the list with all history." + }, + "show_history_older" : { + "title" : "Mostrar histórico mais antigo", + "note" : "Button that shows older history entries" + }, + "show_history_for" : { + "title" : "Mostrar histórico de {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Show history for Today'." + }, + "delete_history_all" : { + "title" : "Eliminar todo o histórico", + "note" : "Button text for an action that removes all history entries." + }, + "delete_history_older" : { + "title" : "Eliminar histórico mais antigo", + "note" : "Button that deletes older history entries." + }, + "delete_history_for" : { + "title" : "Eliminar histórico de {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Delete history for Today'." + }, + "search_your_history" : { + "title" : "Pesquisa no teu histórico", + "note" : "Label text for screen readers. It's shown next to the search input field" + }, + "range_all" : { + "title" : "Todos", + "note" : "Label on a button that shows all history entries" + }, + "range_today" : { + "title" : "Hoje", + "note" : "Label on a button that shows history entries for today only" + }, + "range_yesterday" : { + "title" : "Ontem", + "note" : "Label on a button that shows history entries for yesterday only" + }, + "range_monday" : { + "title" : "Segunda-feira", + "note" : "Label on a button that shows history entries for monday only" + }, + "range_tuesday" : { + "title" : "Terça-feira", + "note" : "Label on a button that shows history entries for tuesday only" + }, + "range_wednesday" : { + "title" : "Quarta-feira", + "note" : "Label on a button that shows history entries for wednesday only" + }, + "range_thursday" : { + "title" : "Quinta-feira", + "note" : "Label on a button that shows history entries for thursday only" + }, + "range_friday" : { + "title" : "Sexta-feira", + "note" : "Label on a button that shows history entries for friday only" + }, + "range_saturday" : { + "title" : "Sábado", + "note" : "Label on a button that shows history entries for saturday only" + }, + "range_sunday" : { + "title" : "Domingo", + "note" : "Label on a button that shows history entries for sunday only" + }, + "range_older" : { + "title" : "Mais antigas", + "note" : "Label on a button that shows older history entries." + }, + "range_sites" : { + "title" : "Sites", + "note" : "Label on a button that shows which sites have been visited" + } +} \ No newline at end of file diff --git a/special-pages/pages/history/public/locales/ru/history.json b/special-pages/pages/history/public/locales/ru/history.json new file mode 100644 index 0000000000..e7cba7de92 --- /dev/null +++ b/special-pages/pages/history/public/locales/ru/history.json @@ -0,0 +1,123 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "empty_title" : { + "title" : "Здесь ничего нет!", + "note" : "Text shown where there are no remaining history entries" + }, + "empty_text" : { + "title" : "Истории посещений пока нет.", + "note" : "Placeholder text when there's no results to show" + }, + "no_results_title" : { + "title" : "По запросу «{term}» ничего не найдено.", + "note" : "The placeholder {term} will be dynamically replaced with the search term entered by the user. For example, if the user searches for 'cats', the title will become 'No results found for cats'." + }, + "no_results_text" : { + "title" : "Попробуйте ввести другой адрес или ключевые слова.", + "note" : "Placeholder text when a search gave no results." + }, + "delete_all" : { + "title" : "Удалить все", + "note" : "Text for a button that deletes all items or entries. An additional confirmation dialog will be presented." + }, + "delete_some" : { + "title" : "Удалить", + "note" : "Text for a button that deletes currently selected items" + }, + "delete_none" : { + "title" : "Нечего удалять", + "note" : "Title/tooltip text on a button that does nothing when there is no browsing history to delete. It's additional information shown on hover." + }, + "page_title" : { + "title" : "История", + "note" : "The main page title" + }, + "search" : { + "title" : "Поиск", + "note" : "The placeholder text in a search input field." + }, + "show_history_all" : { + "title" : "Отобразить всю историю", + "note" : "Button text for an action that removes all filters and searches, and replaces the list with all history." + }, + "show_history_older" : { + "title" : "Показать более раннюю историю", + "note" : "Button that shows older history entries" + }, + "show_history_for" : { + "title" : "Показать историю за {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Show history for Today'." + }, + "delete_history_all" : { + "title" : "Удалить всю историю", + "note" : "Button text for an action that removes all history entries." + }, + "delete_history_older" : { + "title" : "Удалить старую историю", + "note" : "Button that deletes older history entries." + }, + "delete_history_for" : { + "title" : "Удалить историю за {range}", + "note" : "The placeholder {range} in the title will be dynamically replaced with specific date ranges such as 'Today', 'Yesterday', or days of the week like 'Monday'. For example, if the range is set to 'Today', the title will become 'Delete history for Today'." + }, + "search_your_history" : { + "title" : "Поиск по истории", + "note" : "Label text for screen readers. It's shown next to the search input field" + }, + "range_all" : { + "title" : "Всё", + "note" : "Label on a button that shows all history entries" + }, + "range_today" : { + "title" : "Сегодня", + "note" : "Label on a button that shows history entries for today only" + }, + "range_yesterday" : { + "title" : "Вчера", + "note" : "Label on a button that shows history entries for yesterday only" + }, + "range_monday" : { + "title" : "понедельник", + "note" : "Label on a button that shows history entries for monday only" + }, + "range_tuesday" : { + "title" : "вторник", + "note" : "Label on a button that shows history entries for tuesday only" + }, + "range_wednesday" : { + "title" : "среду", + "note" : "Label on a button that shows history entries for wednesday only" + }, + "range_thursday" : { + "title" : "Четверг", + "note" : "Label on a button that shows history entries for thursday only" + }, + "range_friday" : { + "title" : "пятницу", + "note" : "Label on a button that shows history entries for friday only" + }, + "range_saturday" : { + "title" : "субботу", + "note" : "Label on a button that shows history entries for saturday only" + }, + "range_sunday" : { + "title" : "Воскресенье", + "note" : "Label on a button that shows history entries for sunday only" + }, + "range_older" : { + "title" : "Более старые записи", + "note" : "Label on a button that shows older history entries." + }, + "range_sites" : { + "title" : "Сайты", + "note" : "Label on a button that shows which sites have been visited" + } +} \ No newline at end of file diff --git a/special-pages/pages/history/readme.md b/special-pages/pages/history/readme.md new file mode 100644 index 0000000000..b364795f34 --- /dev/null +++ b/special-pages/pages/history/readme.md @@ -0,0 +1,4 @@ +### Integration + +Serve the entire folder found under `build//pages/history`w + diff --git a/special-pages/pages/history/src/index.js b/special-pages/pages/history/src/index.js new file mode 100644 index 0000000000..2311a594e6 --- /dev/null +++ b/special-pages/pages/history/src/index.js @@ -0,0 +1,106 @@ +/** + * Special Page example. Used as a template for new special pages. + * + * @module History Page + */ + +import 'preact/devtools'; +import { createTypedMessages } from '@duckduckgo/messaging'; +import { Environment } from '../../../shared/environment.js'; +import { createSpecialPageMessaging } from '../../../shared/create-special-page-messaging.js'; +import { init } from '../app/index.js'; +import '../../../shared/live-reload.js'; +import { mockTransport } from '../app/mocks/mock-transport.js'; +import { Fragment, h, render } from 'preact'; + +export class HistoryPage { + /** + * @param {import("@duckduckgo/messaging").Messaging} messaging + */ + constructor(messaging) { + this.messaging = createTypedMessages(this, messaging); + } + + /** + * Sends an initial message to the native layer. This is the opportunity for the native layer + * to provide the initial state of the application or any configuration, for example: + * + * ```json + * { + * "env": "development", + * "locale": "en" + * } + * ``` + * + * @returns {Promise} + */ + initialSetup() { + return this.messaging.request('initialSetup'); + } + + /** + * This will be sent if the application has loaded, but a client-side error + * has occurred that cannot be recovered from + * @param {{message: string}} params + */ + reportPageException(params) { + this.messaging.notify('reportPageException', params); + } + + /** + * This will be sent if the application fails to load. + * @param {{message: string}} params + */ + reportInitException(params) { + this.messaging.notify('reportInitException', params); + } +} + +const baseEnvironment = new Environment().withInjectName(import.meta.injectName).withEnv(import.meta.env); + +const messaging = createSpecialPageMessaging({ + injectName: baseEnvironment.injectName, + env: baseEnvironment.env, + pageName: /** @type {string} */ (import.meta.pageName), + mockTransport: () => { + // only in integration environments + if (baseEnvironment.injectName !== 'integration') return null; + let mock = null; + // eslint-disable-next-line no-labels,no-unused-labels + $INTEGRATION: mock = mockTransport(); + return mock; + }, +}); + +const historyPage = new HistoryPage(messaging); + +/** + * Grab the root element from the index.html file - bail early if it's absent + */ +const root = document.querySelector('#app'); +if (!root) { + document.documentElement.dataset.fatalError = 'true'; + render('Fatal: #app missing', document.body); + throw new Error('Missing #app'); +} + +init(root, historyPage, baseEnvironment).catch((e) => { + console.error(e); + const msg = typeof e?.message === 'string' ? e.message : 'unknown init error'; + historyPage.reportInitException({ message: msg }); + document.documentElement.dataset.fatalError = 'true'; + const element = ( + +
    +

    + A fatal error occurred: +

    +
    +
    +                    {JSON.stringify({ message: e.message }, null, 2)}
    +                
    +
    +
    + ); + render(element, document.body); +}); diff --git a/special-pages/pages/history/src/inline.js b/special-pages/pages/history/src/inline.js new file mode 100644 index 0000000000..434c4066cf --- /dev/null +++ b/special-pages/pages/history/src/inline.js @@ -0,0 +1,22 @@ +/** + * This script is designed to be run before the application loads, use it to set values + * that might be needed in CSS or JS + */ + +const param = new URLSearchParams(window.location.search).get('platform'); + +if (isAllowed(param)) { + document.documentElement.dataset.platform = String(param); +} else { + document.documentElement.dataset.platform = import.meta.injectName; +} + +/** + * @param {any} input + * @returns {input is ImportMeta['injectName']} + */ +function isAllowed(input) { + /** @type {ImportMeta['injectName'][]} */ + const allowed = ['windows', 'apple', 'integration']; + return allowed.includes(input); +} diff --git a/special-pages/pages/history/styles/base.css b/special-pages/pages/history/styles/base.css new file mode 100644 index 0000000000..f850543ab7 --- /dev/null +++ b/special-pages/pages/history/styles/base.css @@ -0,0 +1,82 @@ +*, *:after, *:before { + box-sizing: border-box +} +html[data-reduced-motion=true] * { + animation: none!important; + transition: none!important; +} +*, *::before, *::after { + box-sizing: border-box; +} +* { + margin: 0; +} +body { + -webkit-font-smoothing: antialiased; + + font-family: system-ui; + margin: 0; + + height: 100vh; + width: 100%; + overflow-x: hidden; + + /* Make it feel more like something native */ + user-select: none; + -webkit-user-select: none; + cursor: default; +} +/** Allow debugging error messages to be selected for debugging **/ +:root[data-fatal-error] body { + user-select: auto; + -webkit-user-select: auto; +} +img, picture, video, canvas, svg { + display: block; + max-width: 100%; +} +input, button, textarea, select { + font: inherit; +} +p, h1, h2, h3, h4, h5, h6 { + overflow-wrap: break-word; +} +#app, #__next { + isolation: isolate; +} + +h1, +h2, +h3, +h4 { + margin: 0; +} +button { + font-family: system-ui, sans-serif; +} +ul { + margin: 0; + padding: 0; +} + +li { + list-style: none; + margin: 0; + padding: 0; +} + +button { + text-wrap: nowrap; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} diff --git a/special-pages/pages/history/styles/history-theme.css b/special-pages/pages/history/styles/history-theme.css new file mode 100644 index 0000000000..05ab00bc15 --- /dev/null +++ b/special-pages/pages/history/styles/history-theme.css @@ -0,0 +1,70 @@ +:root { + /* H1 title */ + --title-font-size: 20px; + --title-font-weight: 600; + --title-line-height: 18px; + + /* Mac/System/Body */ + --body-font-size: 13px; + --body-font-weight: 400; + --body-line-height: 16px; + + /* Mac/System/Label */ + --label-font-size: 13px; + --label-font-weight: 510; + --label-line-height: 13px; + + /* title 3 em */ + --title-3-em-font-size: 15px; + --title-3-em-font-weight: 590; + --title-3-em-line-height: 20px; +} + +body { + --default-light-background-color: var(--color-gray-0); + --default-dark-background-color: var(--color-gray-85); +} + +/* This comes from the application settings */ +:root:has([data-platform="windows"]) { + /* H1 title */ + --title-font-size: 24px; + --title-font-weight: 600; + --title-line-height: 32px; + + /* Windows/System/Body */ + --body-font-size: 14px; + --body-font-weight: 400; + --body-line-height: 20px; + + /* Windows/Title 3 (Emphasis); */ + --title-3-em-font-size: 16px; + --title-3-em-font-weight: 600; + --title-3-em-line-height: 20px; + + /* Windows/System/Label */ + --label-font-size: 14px; + --label-font-weight: 400; + --label-line-height: normal; +} + +[data-theme=light] { + --history-background-color: var(--default-light-background-color); + --history-surface-background-color: var(--color-white-at-30); + --history-surface-border-color: var(--color-black-at-9); + --history-scrollbar-controls-color: var(--color-black-at-18); + --history-text-normal: var(--color-black-at-84); + --history-text-invert: var(--color-white-at-84); + --history-text-muted: var(--color-black-at-60); +} + +[data-theme=dark] { + --history-background-color: var(--default-dark-background-color); + --history-surface-background-color: var(--color-black-at-18); + --history-surface-border-color: var(--color-white-at-12); + --history-scrollbar-controls-color: var(--color-white-at-18); + --history-surface-color: var(--color-white-at-12); + --history-text-normal: var(--color-white-at-84); + --history-text-invert: var(--color-black-at-84); + --history-text-muted: var(--color-white-at-60); +} diff --git a/special-pages/pages/history/types/history.ts b/special-pages/pages/history/types/history.ts new file mode 100644 index 0000000000..967558d6bf --- /dev/null +++ b/special-pages/pages/history/types/history.ts @@ -0,0 +1,295 @@ +/** + * These types are auto-generated from schema files. + * scripts/build-types.mjs is responsible for type generation. + * **DO NOT** edit this file directly as your changes will be lost. + * + * @module History Messages + */ + +export type OpenTarget = "same-tab" | "new-tab" | "new-window"; +export type ActionResponse = (DeleteAction | NoneAction | DomainSearchAction) & string; +/** + * Confirms the user deleted this + */ +export type DeleteAction = "delete"; +/** + * The user cancelled the action, or did not agree to it + */ +export type NoneAction = "none"; +/** + * The user asked to see more results from the domain + */ +export type DomainSearchAction = "domain-search"; +export type RangeId = + | "all" + | "today" + | "yesterday" + | "monday" + | "tuesday" + | "wednesday" + | "thursday" + | "friday" + | "saturday" + | "sunday" + | "older" + | "sites"; +export type QueryKind = SearchTerm | DomainFilter | RangeFilter; +/** + * Indicates the query was triggered before the UI was rendered + */ +export type InitialSource = "initial"; +/** + * Indicates the query was following a user interaction + */ +export type UserSource = "user"; +/** + * Indicates the query was triggered automatically, for example in response to another action (like delete) + */ +export type AutoSource = "auto"; +export type Favicon = null | { + src: string; + maxAvailableSize?: number; +}; + +/** + * Requests, Notifications and Subscriptions from the History feature + */ +export interface HistoryMessages { + notifications: OpenNotification | ReportInitExceptionNotification | ReportPageExceptionNotification; + requests: + | DeleteDomainRequest + | DeleteRangeRequest + | DeleteTermRequest + | EntriesDeleteRequest + | EntriesMenuRequest + | GetRangesRequest + | InitialSetupRequest + | QueryRequest; +} +/** + * Generated from @see "../messages/open.notify.json" + */ +export interface OpenNotification { + method: "open"; + params: HistoryOpenAction; +} +export interface HistoryOpenAction { + /** + * The url to open + */ + url: string; + target: OpenTarget; +} +/** + * Generated from @see "../messages/reportInitException.notify.json" + */ +export interface ReportInitExceptionNotification { + method: "reportInitException"; + params: ReportInitExceptionNotify; +} +export interface ReportInitExceptionNotify { + message: string; +} +/** + * Generated from @see "../messages/reportPageException.notify.json" + */ +export interface ReportPageExceptionNotification { + method: "reportPageException"; + params: ReportPageExceptionNotify; +} +export interface ReportPageExceptionNotify { + message: string; +} +/** + * Generated from @see "../messages/deleteDomain.request.json" + */ +export interface DeleteDomainRequest { + method: "deleteDomain"; + params: DeleteDomainParams; + result: DeleteDomainResponse; +} +export interface DeleteDomainParams { + domain: string; +} +export interface DeleteDomainResponse { + action: ActionResponse; +} +/** + * Generated from @see "../messages/deleteRange.request.json" + */ +export interface DeleteRangeRequest { + method: "deleteRange"; + params: DeleteRangeParams; + result: DeleteRangeResponse; +} +export interface DeleteRangeParams { + range: RangeId; +} +export interface DeleteRangeResponse { + action: ActionResponse; +} +/** + * Generated from @see "../messages/deleteTerm.request.json" + */ +export interface DeleteTermRequest { + method: "deleteTerm"; + params: DeleteTermParams; + result: DeleteTermResponse; +} +export interface DeleteTermParams { + term: string; +} +export interface DeleteTermResponse { + action: ActionResponse; +} +/** + * Generated from @see "../messages/entries_delete.request.json" + */ +export interface EntriesDeleteRequest { + method: "entries_delete"; + params: EntriesDeleteParams; + result: EntriesDeleteResponse; +} +export interface EntriesDeleteParams { + ids: string[]; +} +export interface EntriesDeleteResponse { + action: ActionResponse; +} +/** + * Generated from @see "../messages/entries_menu.request.json" + */ +export interface EntriesMenuRequest { + method: "entries_menu"; + params: EntriesMenuParams; + result: EntriesMenuResponse; +} +export interface EntriesMenuParams { + ids: string[]; +} +export interface EntriesMenuResponse { + action: ActionResponse; +} +/** + * Generated from @see "../messages/getRanges.request.json" + */ +export interface GetRangesRequest { + method: "getRanges"; + result: GetRangesResponse; +} +export interface GetRangesResponse { + ranges: Range[]; +} +export interface Range { + id: RangeId; + count: number; +} +/** + * Generated from @see "../messages/initialSetup.request.json" + */ +export interface InitialSetupRequest { + method: "initialSetup"; + result: InitialSetupResponse; +} +export interface InitialSetupResponse { + locale: string; + env: "development" | "production"; + platform: { + name: "macos" | "windows" | "android" | "ios" | "integration"; + }; + customizer?: { + defaultStyles?: null | DefaultStyles; + }; +} +export interface DefaultStyles { + /** + * Optional default dark background color. Any HEX value is permitted + */ + darkBackgroundColor?: string; + /** + * Optional default light background color. Any HEX value is permitted + */ + lightBackgroundColor?: string; +} +/** + * Generated from @see "../messages/query.request.json" + */ +export interface QueryRequest { + method: "query"; + params: HistoryQuery; + result: HistoryQueryResponse; +} +export interface HistoryQuery { + query: QueryKind; + /** + * The starting point of records to query (zero-indexed); used for paging through large datasets + */ + offset: number; + /** + * Maximum number of records to return + */ + limit: number; + source: InitialSource | UserSource | AutoSource; +} +export interface SearchTerm { + term: string; +} +export interface DomainFilter { + domain: string; +} +export interface RangeFilter { + range: RangeId; +} +export interface HistoryQueryResponse { + info: HistoryQueryInfo; + value: HistoryItem[]; +} +export interface HistoryQueryInfo { + /** + * Indicates whether there are more items outside of the current query + */ + finished: boolean; + query: QueryKind; +} +export interface HistoryItem { + /** + * A unique identifier for the entry. + */ + id: string; + /** + * A relative day with a detailed date (e.g., 'Today - Wednesday 15 January 2025'). + */ + dateRelativeDay: string; + /** + * A short date format (e.g., '15 Jan 2025'). + */ + dateShort: string; + /** + * The time of day in 24-hour format (e.g., '11:01'). + */ + dateTimeOfDay?: string; + /** + * The eTLD+1 version of the domain, representing the domain and its top-level domain (e.g., 'example.com', 'localhost'). This differs from 'domain', which may include subdomains (e.g., 'www.youtube.com'). + */ + etldPlusOne?: string; + /** + * The full domain to show beside the site title, eg: 'www.youtube.com' + */ + domain: string; + /** + * Title of the page (e.g., 'YouTube'). + */ + title: string; + /** + * A complete URL including query parameters. + */ + url: string; + favicon?: Favicon; +} + +declare module "../src/index.js" { + export interface HistoryPage { + notify: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['notify'], + request: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['request'] + } +} \ No newline at end of file diff --git a/special-pages/pages/history/unit-tests/useSelectionState.spec.js b/special-pages/pages/history/unit-tests/useSelectionState.spec.js new file mode 100644 index 0000000000..d4cce35cf6 --- /dev/null +++ b/special-pages/pages/history/unit-tests/useSelectionState.spec.js @@ -0,0 +1,168 @@ +import { test } from 'node:test'; +import { deepEqual } from 'node:assert/strict'; +import { reducer } from '../app/global/hooks/useSelectionState.js'; + +/** + * @typedef {import('../app/global/hooks/useSelectionState.js').Action} Action + * @typedef {import('../app/global/hooks/useSelectionState.js').SelectionState} SelectionState + */ + +/** @type {(a: Action) => Action} */ +const create = (a) => a; + +/** + * @satisfies {SelectionState} + */ +const defaultState = { + anchorIndex: null, + focusedIndex: null, + lastShiftRange: { start: null, end: null }, + selected: new Set(), + lastAction: null, +}; + +test.describe('reducer function', () => { + test('should handle "reset" action', () => { + const prevState = { + ...defaultState, + anchorIndex: 5, + focusedIndex: 5, + selected: new Set([5]), + }; + const action = create({ kind: 'reset' }); + + const result = reducer(prevState, action); + deepEqual(result, { + ...defaultState, + lastAction: null, + }); + }); + + test('should handle "move-selection" action (direction: up)', () => { + const prevState = { + ...defaultState, + focusedIndex: 3, + }; + const action = create({ kind: 'move-selection', direction: 'up', total: 10 }); + + const result = reducer(prevState, action); + deepEqual(result, { + anchorIndex: 2, + focusedIndex: 2, + lastShiftRange: { start: null, end: null }, + selected: new Set([2]), + lastAction: null, + }); + }); + + test('should handle "move-selection" action (direction: down)', () => { + const prevState = { + ...defaultState, + focusedIndex: 3, + }; + const action = create({ kind: 'move-selection', direction: 'down', total: 10 }); + + const result = reducer(prevState, action); + deepEqual(result, { + anchorIndex: 4, + focusedIndex: 4, + lastShiftRange: { start: null, end: null }, + selected: new Set([4]), + lastAction: null, + }); + }); + + test('should handle "select-index" action', () => { + const prevState = { ...defaultState }; + const action = create({ kind: 'select-index', index: 2 }); + + const result = reducer(prevState, action); + deepEqual(result, { + anchorIndex: 2, + focusedIndex: 2, + lastShiftRange: { start: null, end: null }, + selected: new Set([2]), + lastAction: null, + }); + }); + + test('should handle "toggle-index" action (add to selection)', () => { + const prevState = { ...defaultState, selected: new Set([1]) }; + const action = create({ kind: 'toggle-index', index: 2 }); + + const result = reducer(prevState, action); + deepEqual(result, { + anchorIndex: 2, + focusedIndex: 2, + lastShiftRange: { start: null, end: null }, + selected: new Set([1, 2]), + lastAction: null, + }); + }); + + test('should handle "toggle-index" action (remove from selection)', () => { + const prevState = { ...defaultState, selected: new Set([1, 2]) }; + const action = create({ kind: 'toggle-index', index: 2 }); + + const result = reducer(prevState, action); + deepEqual(result, { + anchorIndex: 2, + focusedIndex: 2, + lastShiftRange: { start: null, end: null }, + selected: new Set([1]), + lastAction: null, + }); + }); + + test('should handle "expand-selected-to-index" action', () => { + const prevState = { ...defaultState, anchorIndex: 1, selected: new Set([1]) }; + const action = create({ kind: 'expand-selected-to-index', index: 4 }); + + const result = reducer(prevState, action); + deepEqual(result, { + ...prevState, + lastShiftRange: { start: 1, end: 4 }, + focusedIndex: 4, + selected: new Set([1, 2, 3, 4]), + lastAction: null, + }); + }); + + test('should handle "increment-selection" action (direction: up)', () => { + const prevState = { + ...defaultState, + focusedIndex: 3, + anchorIndex: 3, + selected: new Set([3]), + }; + const action = create({ kind: 'increment-selection', direction: 'up', total: 10 }); + + const result = reducer(prevState, action); + deepEqual(result, { + anchorIndex: 3, + focusedIndex: 2, + lastShiftRange: { start: 2, end: 3 }, + selected: new Set([2, 3]), + lastAction: null, + }); + }); + + test('should handle "increment-selection" action (direction: down)', () => { + const prevState = { + ...defaultState, + focusedIndex: 2, + anchorIndex: 2, + selected: new Set([2]), + }; + const action = create({ kind: 'increment-selection', direction: 'down', total: 10 }); + + const result = reducer(prevState, action); + deepEqual(result, { + anchorIndex: 2, + focusedIndex: 3, + lastShiftRange: { start: 2, end: 3 }, + selected: new Set([2, 3]), + lastAction: null, + }); + }); +}); diff --git a/special-pages/pages/new-tab/app/activity/ActivityProvider.js b/special-pages/pages/new-tab/app/activity/ActivityProvider.js new file mode 100644 index 0000000000..9eb2268ac0 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/ActivityProvider.js @@ -0,0 +1,71 @@ +import { createContext, h } from 'preact'; +import { useEffect, useReducer, useRef } from 'preact/hooks'; +import { useMessaging } from '../types.js'; +import { reducer, useInitialData } from '../service.hooks.js'; +import { useBatchedActivityApi } from '../settings.provider.js'; +import { BatchedActivityService } from './batched-activity.service.js'; + +/** + * @typedef {import('../../types/new-tab.js').ActivityData} ActivityData + * @typedef {import('../../types/new-tab').TrackingStatus} TrackingStatus + * @typedef {import('../../types/new-tab').HistoryEntry} HistoryEntry + * @typedef {import('../../types/new-tab').DomainActivity} DomainActivity + * @typedef {import('../service.hooks.js').State} State + * @typedef {import('../service.hooks.js').Events} Events + */ + +/** + * These are the values exposed to consumers. + */ +export const ActivityContext = createContext({ + /** @type {State} */ + state: { status: 'idle', data: null, config: null }, +}); + +export const ActivityServiceContext = createContext(/** @type {BatchedActivityService|null} */ ({})); + +/** + * A data provider that will use `ActivityService` to fetch initial data only + * + * @param {Object} props + * @param {import("preact").ComponentChild} props.children + */ +export function ActivityProvider(props) { + const initial = /** @type {State} */ ({ + status: 'idle', + data: null, + config: null, + }); + + const [state, dispatch] = useReducer(reducer, initial); + const batched = useBatchedActivityApi(); + + // create an instance of `ActivityService` for the lifespan of this component. + const service = useService(batched); + + // get initial data + useInitialData({ dispatch, service }); + + return ( + + {props.children} + + ); +} + +/** + * @param {boolean} useBatched + * @return {import("preact").RefObject} + */ +export function useService(useBatched) { + const service = useRef(/** @type {BatchedActivityService|null} */ (null)); + const ntp = useMessaging(); + useEffect(() => { + const stats = new BatchedActivityService(ntp, useBatched); + service.current = stats; + return () => { + stats.destroy(); + }; + }, [ntp, useBatched]); + return service; +} diff --git a/special-pages/pages/new-tab/app/activity/NormalizeDataProvider.js b/special-pages/pages/new-tab/app/activity/NormalizeDataProvider.js new file mode 100644 index 0000000000..80c516c43e --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/NormalizeDataProvider.js @@ -0,0 +1,285 @@ +import { createContext, h } from 'preact'; +import { useCallback, useContext, useEffect } from 'preact/hooks'; +import { eventToTarget } from '../utils.js'; +import { useBatchedActivityApi, usePlatformName } from '../settings.provider.js'; +import { ACTION_ADD_FAVORITE, ACTION_REMOVE, ACTION_REMOVE_FAVORITE } from './constants.js'; +import { batch, signal, useSignal } from '@preact/signals'; +import { DDG_DEFAULT_ICON_SIZE } from '../favorites/constants.js'; +import { ActivityContext, ActivityServiceContext } from './ActivityProvider.js'; +import { ActivityInteractionsContext } from '../burning/ActivityInteractionsContext.js'; +import { ACTION_BURN } from '../burning/BurnProvider.js'; + +/** + * @typedef {import('../../types/new-tab.js').ActivityData} ActivityData + * @typedef {import('../../types/new-tab').TrackingStatus} TrackingStatus + * @typedef {import('../../types/new-tab').HistoryEntry} HistoryEntry + * @typedef {import('../../types/new-tab').DomainActivity} DomainActivity + * @typedef {import('../service.hooks.js').State} State + * @typedef {import('../service.hooks.js').Events} Events + */ + +/** + * @typedef Item + * @property {string} props.title + * @property {string} props.url + * @property {string|null|undefined} props.favoriteSrc + * @property {number} props.faviconMax + * @property {string} props.etldPlusOne + * @property {boolean} props.trackersFound + */ + +/** + * @typedef NormalizedActivity + * @property {Record} items + * @property {Record} history + * @property {Record} trackingStatus + * @property {Record} favorites + * @property {string[]} urls + * @property {number} totalTrackers + */ + +/** + * @param {NormalizedActivity} prev + * @param {import("./batched-activity.service.js").Incoming} incoming + * @return {NormalizedActivity} + */ +export function normalizeData(prev, incoming) { + /** @type {NormalizedActivity} */ + const output = { + favorites: {}, + items: {}, + history: {}, + trackingStatus: {}, + urls: [], + totalTrackers: incoming.totalTrackers, + }; + + if (shallowDiffers(prev.urls, incoming.urls)) { + output.urls = [...incoming.urls]; + } else { + output.urls = prev.urls; + } + + for (const item of incoming.activity) { + const id = item.url; + + output.favorites[id] = item.favorite; + + /** @type {Item} */ + const next = { + etldPlusOne: item.etldPlusOne, + title: item.title, + url: id, + faviconMax: item.favicon?.maxAvailableSize ?? DDG_DEFAULT_ICON_SIZE, + favoriteSrc: item.favicon?.src, + trackersFound: item.trackersFound, + }; + const differs = shallowDiffers(next, prev.items[id] || {}); + output.items[id] = differs ? next : prev.items[id] || {}; + + const historyDiff = shallowDiffers(item.history, prev.history[id] || []); + output.history[id] = historyDiff ? [...item.history] : prev.history[id] || []; + + const prevItem = prev.trackingStatus[id] || { + totalCount: 0, + trackerCompanies: [], + }; + const trackersDiffer = shallowDiffers(item.trackingStatus.trackerCompanies, prevItem.trackerCompanies); + if (prevItem.totalCount !== item.trackingStatus.totalCount || trackersDiffer) { + const next = { + totalCount: item.trackingStatus.totalCount, + trackerCompanies: [...item.trackingStatus.trackerCompanies], + }; + output.trackingStatus[id] = next; + } else { + output.trackingStatus[id] = prevItem; + } + } + return output; +} + +/** + * @param {string[]} prev + * @param {string[]} data + * @return {string[]} + */ +function normalizeKeys(prev, data) { + const next = shallowDiffers(prev, data) ? [...data] : prev; + return next; +} + +/** + * Check if two objects have a different shape + * @param {object} a + * @param {object} b + * @returns {boolean} + */ +export function shallowDiffers(a, b) { + for (const i in a) if (i !== '__source' && !(i in b)) return true; + for (const i in b) if (i !== '__source' && a[i] !== b[i]) return true; + return false; +} + +export const NormalizedDataContext = createContext({ + activity: signal(/** @type {NormalizedActivity} */ ({})), + keys: signal(/** @type {string[]} */ ([])), +}); + +export function SignalStateProvider({ children }) { + const { state } = useContext(ActivityContext); + const batched = useBatchedActivityApi(); + const platformName = usePlatformName(); + const service = /** @type {import("./batched-activity.service.js").BatchedActivityService} */ (useContext(ActivityServiceContext)); + if (state.status !== 'ready') throw new Error('must have ready status here'); + if (!service) throw new Error('must have service here'); + + /** + * @param {MouseEvent} event + */ + function didClick_(event) { + const target = /** @type {HTMLElement|null} */ (event.target); + if (!target) return; + if (!service) return; + const anchor = /** @type {HTMLAnchorElement|null} */ (target.closest('a[href][data-url]')); + const button = /** @type {HTMLButtonElement|null} */ (target.closest('button[value][data-action]')); + if (anchor) { + const url = anchor.dataset.url; + if (!url) return; + event.preventDefault(); + event.stopImmediatePropagation(); + const openTarget = eventToTarget(event, platformName); + service.openUrl(url, openTarget); + } else if (button) { + event.preventDefault(); + event.stopImmediatePropagation(); + + const action = button.dataset.action; + const value = button.value; + + if (!action) return console.warn('expected clicked button to have data-action=""'); + if (typeof value !== 'string') return console.warn('expected clicked button to have a value'); + + if (action === ACTION_ADD_FAVORITE) { + service.addFavorite(button.value); + } else if (action === ACTION_REMOVE_FAVORITE) { + service.removeFavorite(button.value); + } else if (action === ACTION_BURN) { + // burning will be captured elsewhere + console.warn('Should not get here... Burning should be captured elsewhere?'); + } else if (action === ACTION_REMOVE) { + service.remove(button.value); + } else { + console.warn('unhandled action:', action); + } + } + } + + const didClick = useCallback(didClick_, [service, batched]); + const firstUrls = state.data.activity.map((x) => x.url); + const keys = useSignal(normalizeKeys([], firstUrls)); + + const activity = useSignal( + normalizeData( + { + items: {}, + history: {}, + trackingStatus: {}, + favorites: {}, + urls: [], + totalTrackers: 0, + }, + { activity: state.data.activity, urls: state.data.urls, totalTrackers: state.data.totalTrackers }, + ), + ); + + /** + * @param {string[]} nextVisibleRange + */ + function setVisibleRange(nextVisibleRange) { + keys.value = normalizeKeys(keys.value, nextVisibleRange); + } + + function fillHoles() { + const visible = keys.value; + const data = Object.keys(activity.value.items); + const missing = visible.filter((x) => !data.includes(x)); + service.next(missing); + } + + function showNextChunk() { + if (service.isFetchingNext) return; + if (!batched) return; + const visibleLength = keys.value.length; + const end = visibleLength + service.CHUNK_SIZE; + const nextVisibleRange = activity.value.urls.slice(0, end); + setVisibleRange(nextVisibleRange); + fillHoles(); + } + + useEffect(() => { + if (!service) return console.warn('could not access service'); + const src = /** @type {import("./batched-activity.service.js").BatchedActivityService} */ (service); + const unsub = src.onData((evt) => { + batch(() => { + activity.value = normalizeData(activity.value, { + activity: evt.data.activity, + urls: evt.data.urls, + totalTrackers: evt.data.totalTrackers, + }); + const visible = keys.value; + const all = activity.value.urls; + + // prettier-ignore + const nextVisibleRange = batched + ? all.slice(0, Math.max(service.INITIAL, Math.max(service.INITIAL, visible.length))) + : all + + setVisibleRange(nextVisibleRange); + fillHoles(); + }); + }); + + return () => { + unsub(); + }; + }, [service, batched, activity, keys]); + + useEffect(() => { + window.addEventListener('activity.next', showNextChunk); + return () => { + window.removeEventListener('activity.next', showNextChunk); + }; + }, []); + + useEffect(() => { + const handler = () => { + if (document.visibilityState === 'visible') { + if (batched) { + const visible = keys.value; + service.triggerDataFetch(visible); + } else { + service.triggerDataFetch(); + } + } + }; + + // eslint-disable-next-line no-labels,no-unused-labels + $INTEGRATION: (() => { + // export the event in tests + if (window.__playwright_01) { + /** @type {any} */ (window).__trigger_document_visibilty__ = handler; + } + })(); + + document.addEventListener('visibilitychange', handler); + return () => { + document.removeEventListener('visibilitychange', handler); + }; + }, [batched]); + + return ( + + {children} + + ); +} diff --git a/special-pages/pages/new-tab/app/activity/activity.md b/special-pages/pages/new-tab/app/activity/activity.md new file mode 100644 index 0000000000..366d6ddad1 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/activity.md @@ -0,0 +1,181 @@ +--- +title: Activity +--- + +## Setup + +- Widget ID: `"activity"` +- Add it to the `widgets` + `widgetConfigs` fields on [initialSetup](../new-tab.md) +- Example: + +```json +{ + "...": "...", + "widgets": [ + {"...": "..."}, + {"id": "activity"} + ], + "widgetConfigs": [ + {"...": "..."}, + {"id": "activity", "visibility": "visible" } + ] +} +``` + +## Requests: +### `activity_getData` +- {@link "NewTab Messages".ActivityGetDataRequest} +- Used to fetch the initial data (during the first render) +- returns {@link "NewTab Messages".ActivityData} + +```json +{ + "activity": [ + { + "favicon": "", + "url": "https://www.youtube.com", + "title": "youtube.com", + "etldPlusOne": "youtube.com", + "favorite": true, + "trackersFound": true, + "trackingStatus": { + "trackerCompanies": [{ "displayName": "Adobe Analytics" }], + "totalCount": 0 + }, + "history": [ + { + "title": "Electric Callboy - Hypa Hypa (OFFICIAL VIDEO) - YouTube", + "url": "https://youtube.com/watch?v=abc", + "relativeTime": "Just now" + } + ] + } + ] +} +``` + +Notes: + - on `macOS`, `history.title` should be a path-like string to match current implementations + - `etldPlusOne` will be used for fallback favicons/colors, so the logic should match the NTP + +### `activity_getUrls` +- {@link "NewTab Messages".ActivityGetUrlsRequest} +- Used to fetch the initial config data (eg: expanded vs collapsed) +- returns {@link "NewTab Messages".UrlInfo} + +```json +{ + "urls": ["..."], + "totalTrackersBlocked": 123 +} +``` + +### `activity_getDataForUrls` +- {@link "NewTab Messages".ActivityGetDataForUrlsRequest} +- Used to confirm the burn action - native side may or may not show a modal +- sends {@link "NewTab Messages".DataForUrlsParams} +- returns {@link "NewTab Messages".ActivityData} +- Note: This response is the same format as `activity_getData`, where DomainActivity items are delivered under `.activity` + +```json +{ + "activity": [ + {"...": "..."} + ] +} +``` + + +### `activity_confirmBurn` +- {@link "NewTab Messages".ActivityConfirmBurnRequest} +- Used to confirm the burn action - native side may or may not show a modal +- sends {@link "NewTab Messages".ConfirmBurnParams} +- returns {@link "NewTab Messages".ConfirmBurnResponse} + +Sends +```json +{ "url": "..." } +``` + +Response: +```json +{ "action": "burn" } +``` + +Response (do nothing) +```json +{ "action": "none" } +``` + +If `{ "action": "burn" }` is returned, the burn animation will play, and will follow +by sending the notification `activity_burnAnimationComplete` + +## Subscriptions: +### `activity_onDataUpdate` +- {@link "NewTab Messages".ActivityOnDataUpdateSubscription} +- The activity data used in the feed. +- returns {@link "NewTab Messages".ActivityData} + +### `activity_onDataPatch` +- {@link "NewTab Messages".ActivityOnDataPatchSubscription} +- The activity data used in the feed. +- returns {@link "NewTab Messages".UrlInfo} + optional {@link "NewTab Messages".PatchData} + +```json +{ + "urls": ["..."], + "totalTrackersBlocked": 123 +} +``` +```json +{ + "urls": ["..."], + "totalTrackersBlocked": 123, + "patch": { + "...": "..." + } +} +``` + +## Notifications: + +### `activity_addFavorite` +- {@link "NewTab Messages".ActivityAddFavoriteNotification} +- Sent when the user clicks the favorite icon +- sends {@link "NewTab Messages".ActivityAddFavoriteNotify} +- example payload: `{ "url": "..." }` + +### `activity_removeFavorite` +- {@link "NewTab Messages".ActivityRemoveFavoriteNotification} +- Sent when the user clicks the favorite icon, if already a favorite +- sends {@link "NewTab Messages".ActivityRemoveFavoriteNotify} +- example payload: `{ "url": "..." }` + +### `activity_removeItem` +- {@link "NewTab Messages".ActivityRemoveItemNotification} +- (windows only) Sent when the user clicks the cross icon +- sends {@link "NewTab Messages".ActivityRemoveItemNotify} +- example payload: `{ "url": "..." }` + +### `activity_open` +- {@link "NewTab Messages".ActivityOpenNotification} +- Sent when a user clicks a link, sends {@link "NewTab Messages".ActivityOpenAction} + +example payload (with id): +```json +{ + "url": "https://example.com/path", + "target": "same-tab" +} +``` + +example payload without id (for example, on history items) +```json +{ + "url": "https://example.com/path", + "target": "same-tab" +} +``` + +### `activity_burnAnimationComplete` +- Sent when the burn animation completes \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/activity/batched-activity.service.js b/special-pages/pages/new-tab/app/activity/batched-activity.service.js new file mode 100644 index 0000000000..f7bd346ffc --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/batched-activity.service.js @@ -0,0 +1,225 @@ +/** + * @typedef {import("../../types/new-tab.js").ActivityData} ActivityData + * @typedef {import("../../types/new-tab.js").UrlInfo} UrlInfo + * @typedef {import("../../types/new-tab.js").PatchData} PatchData + * @typedef {import('../../types/new-tab.js').DomainActivity} DomainActivity + * @typedef {import('../service.js').InvocationSource} InvocationSource + */ +import { Service } from '../service.js'; + +/** + * @typedef {{ activity: DomainActivity[], urls: string[], totalTrackers: number }} Incoming + */ + +export class BatchedActivityService { + INITIAL = 5; + CHUNK_SIZE = 10; + isFetchingNext = false; + /** + * @param {import("../../src/index.js").NewTabPage} ntp - The internal data feed, expected to have a `subscribe` method. + * @param {boolean} batched + * @internal + */ + constructor(ntp, batched = false) { + this.ntp = ntp; + this.batched = batched; + + /** @type {Service} */ + this.dataService = new Service({ + initial: async (params) => { + if (this.batched) { + if (params && Array.isArray(params.urls) && this.dataService.data?.urls) { + const data = await this.ntp.messaging.request('activity_getDataForUrls', { + urls: params.urls, + }); + return { + activity: data.activity, + totalTrackers: this.dataService.data.totalTrackers, + urls: this.dataService.data.urls, + }; + } else { + const urlsResponse = await this.ntp.messaging.request('activity_getUrls'); + const data = await this.ntp.messaging.request('activity_getDataForUrls', { + urls: urlsResponse.urls.slice(0, this.INITIAL), + }); + return { activity: data.activity, urls: urlsResponse.urls, totalTrackers: urlsResponse.totalTrackersBlocked }; + } + } else { + const data = await this.ntp.messaging.request('activity_getData'); + return { + activity: data.activity, + urls: data.activity.map((x) => x.url), + totalTrackers: data.activity.reduce((acc, item) => acc + item.trackingStatus.totalCount, 0), + }; + } + }, + subscribe: (cb) => { + const sub1 = ntp.messaging.subscribe('activity_onDataUpdate', (params) => { + cb({ + activity: params.activity, + urls: params.activity.map((x) => x.url), + totalTrackers: params.activity.reduce((acc, item) => acc + item.trackingStatus.totalCount, 0), + }); + }); + const sub2 = ntp.messaging.subscribe('activity_onDataPatch', (params) => { + const totalTrackers = params.totalTrackersBlocked; + if ('patch' in params && params.patch !== null) { + cb({ activity: [/** @type {DomainActivity} */ (params.patch)], urls: params.urls, totalTrackers }); + } else { + cb({ activity: [], urls: params.urls, totalTrackers }); + } + }); + return () => { + sub1(); + sub2(); + }; + }, + }).withUpdater((old, next, source) => { + if (source === 'manual') { + return next; + } + if (this.batched) { + return { + activity: old.activity.concat(next.activity), + urls: next.urls, + totalTrackers: next.totalTrackers, + }; + } + return next; + }); + + /** @type {EventTarget|null} */ + this.burns = new EventTarget(); + this.burnUnsub = this.ntp.messaging.subscribe('activity_onBurnComplete', () => { + this.burns?.dispatchEvent(new CustomEvent('activity_onBurnComplete')); + }); + } + + name() { + return 'BatchedActivity'; + } + + /** + * @returns {Promise} + * @internal + */ + async getInitial() { + return await this.dataService.fetchInitial(); + } + + /** + * @internal + */ + destroy() { + this.dataService.destroy(); + this.burnUnsub(); + this.burns = null; + } + + /** + * @param {string[]} urls + */ + next(urls) { + if (urls.length === 0) return; + this.isFetchingNext = true; + this.dataService.triggerFetch({ urls }); + } + + /** + * @param {(evt: {data: Incoming, source: InvocationSource}) => void} cb + * @internal + */ + onData(cb) { + return this.dataService.onData((data) => { + this.isFetchingNext = false; + cb(data); + }); + } + + /** + * @param {string[]} [urls] - optional subset to refresh + */ + triggerDataFetch(urls) { + if (urls) { + this.dataService.triggerFetch({ urls }); + } else { + this.dataService.triggerFetch(); + } + } + /** + * @param {string} url + */ + addFavorite(url) { + this.dataService.update((old) => { + return { + ...old, + activity: old.activity.map((item) => { + if (item.url === url) return { ...item, favorite: true }; + return item; + }), + }; + }); + this.ntp.messaging.notify('activity_addFavorite', { url }); + } + /** + * @param {string} url + */ + removeFavorite(url) { + this.dataService.update((old) => { + return { + ...old, + activity: old.activity.map((item) => { + if (item.url === url) return { ...item, favorite: false }; + return item; + }), + }; + }); + this.ntp.messaging.notify('activity_removeFavorite', { url }); + } + /** + * @param {string} url + * @return {Promise} + */ + confirmBurn(url) { + return this.ntp.messaging.request('activity_confirmBurn', { url }); + } + /** + * @param {string} url + */ + remove(url) { + this.dataService.update((old) => { + return { + ...old, + activity: old.activity.filter((item) => { + return item.url !== url; + }), + urls: old.urls.filter((x) => x !== url), + }; + }); + this.ntp.messaging.notify('activity_removeItem', { url }); + } + /** + * @param {string} url + * @param {import('../../types/new-tab.js').OpenTarget} target + */ + openUrl(url, target) { + this.ntp.messaging.notify('activity_open', { url, target }); + } + + onBurnComplete(cb) { + if (!this.burns) throw new Error('unreachable'); + this.burns.addEventListener('activity_onBurnComplete', cb); + return () => { + if (!this.burns) throw new Error('unreachable'); + this.burns.removeEventListener('activity_onBurnComplete', cb); + }; + } + + enableBroadcast() { + this.dataService.enableBroadcast(); + this.dataService.flush(); + } + disableBroadcast() { + this.dataService.disableBroadcast(); + } +} diff --git a/special-pages/pages/new-tab/app/activity/components/Activity.examples.js b/special-pages/pages/new-tab/app/activity/components/Activity.examples.js new file mode 100644 index 0000000000..d2c4791524 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/components/Activity.examples.js @@ -0,0 +1,74 @@ +import { h } from 'preact'; +import { Activity, ActivityBody } from './Activity.js'; +import { signal } from '@preact/signals'; +import { generateSampleData } from '../mocks/activity.mock-transport.js'; +import { normalizeData, NormalizedDataContext } from '../NormalizeDataProvider.js'; + +/** @type {Record import("preact").ComponentChild}>} */ + +export const activityExamples = { + 'activity.empty': { + factory: () => { + return ; + }, + }, + 'activity.few': { + factory: () => ( + + + + + + ), + }, + 'activity.noTrackers': { + factory: () => ( + + + + + + ), + }, + 'activity.noActivity.someTrackers': { + factory: () => ( + + + + + + ), + }, +}; + +/** + * Creates a context provider for normalized data that includes sample activity data, URLs, and tracking status. + * + * @param {object} props + * @param {import("preact").ComponentChild} props.children The child nodes to render within the context provider. + * @param {number} props.size The number of sample data entries to generate for the mock data. + */ +function Mock({ children, size }) { + const mocks = generateSampleData(size); + const items = normalizeData( + { + items: {}, + history: {}, + trackingStatus: {}, + favorites: {}, + urls: [], + totalTrackers: 0, + }, + { activity: mocks, urls: mocks.map((x) => x.url), totalTrackers: 0 }, + ); + return ( + + {children} + + ); +} diff --git a/special-pages/pages/new-tab/app/activity/components/Activity.js b/special-pages/pages/new-tab/app/activity/components/Activity.js new file mode 100644 index 0000000000..060090dfd4 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/components/Activity.js @@ -0,0 +1,293 @@ +import { h } from 'preact'; +import styles from './Activity.module.css'; +import { useContext, useEffect, useRef } from 'preact/hooks'; +import { memo } from 'preact/compat'; +import { ActivityContext, ActivityServiceContext } from '../ActivityProvider.js'; +import { useTypedTranslationWith } from '../../types.js'; +import { useOnMiddleClick } from '../../utils.js'; +import { useAdBlocking, useBatchedActivityApi, usePlatformName } from '../../settings.provider.js'; +import { CompanyIcon } from '../../components/CompanyIcon.js'; +import { Trans } from '../../../../../shared/components/TranslationsProvider.js'; +import { ActivityItem } from './ActivityItem.js'; +import { ActivityBurningSignalContext, BurnProvider } from '../../burning/BurnProvider.js'; +import { useEnv } from '../../../../../shared/components/EnvironmentProvider.js'; +import { useComputed } from '@preact/signals'; +import { ActivityItemAnimationWrapper } from './ActivityItemAnimationWrapper.js'; +import { useDocumentVisibility } from '../../../../../shared/components/DocumentVisibility.js'; +import { HistoryItems } from './HistoryItems.js'; +import { NormalizedDataContext, SignalStateProvider } from '../NormalizeDataProvider.js'; +import { ActivityInteractionsContext } from '../../burning/ActivityInteractionsContext.js'; +import { ProtectionsEmpty } from '../../protections/components/Protections.js'; + +/** + * @import enStrings from "../strings.json" + * @typedef {import('../../../types/new-tab').Expansion} Expansion + */ + +/** + * Renders the Activity component with associated heading and body, managing interactivity and state. + * + * @param {Object} props - Object containing all properties required by the Activity component. + * @param {number} props.itemCount - Object representing the count of items in the activity. + * @param {boolean} props.batched - Boolean indicating whether the activity uses batched loading. + * @param {import("preact").ComponentChild} [props.children] + */ +export function Activity({ itemCount, batched, children }) { + return ( +
    + {itemCount === 0 && } + {itemCount > 0 && children} + {batched && itemCount > 0 && } +
    + ); +} + +export function ActivityEmptyState() { + const { t } = useTypedTranslationWith(/** @type {import("../strings.json")} */ ({})); + return ( + +

    {t('activity_empty')}

    +
    + ); +} + +/** + * @param {object} props + * @param {boolean} props.canBurn + * @param {DocumentVisibilityState} props.visibility + */ +export function ActivityBody({ canBurn, visibility }) { + const { isReducedMotion } = useEnv(); + const { keys } = useContext(NormalizedDataContext); + const { burning, exiting } = useContext(ActivityBurningSignalContext); + const busy = useComputed(() => burning.value.length > 0 || exiting.value.length > 0); + + // see: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/examples/accordion/ + const { didClick } = useContext(ActivityInteractionsContext); + + const ref = useRef(null); + useOnMiddleClick(ref, didClick); + + return ( +
      + {keys.value.map((id, _index) => { + if (canBurn && !isReducedMotion) return ; + return ; + })} +
    + ); +} + +function Loader() { + const loaderRef = useRef(/** @type {HTMLDivElement|null} */ (null)); + + useEffect(() => { + const observer = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting) { + window.dispatchEvent(new Event('activity.next')); + } + }); + + if (loaderRef.current) { + observer.observe(loaderRef.current); + } + + return () => { + if (loaderRef.current) { + observer.unobserve(loaderRef.current); + } + }; + }, []); + + return ( +
    + Loading... +
    + ); +} + +const BurnableItem = memo( + /** + * @param {object} props + * @param {string} props.id + * @param {'visible' | 'hidden'} props.documentVisibility + */ + function BurnableItem({ id, documentVisibility }) { + const { activity } = useContext(NormalizedDataContext); + const item = useComputed(() => activity.value.items[id]); + if (!item.value) { + return null; + } + return ( + + + + + + + ); + }, +); + +const RemovableItem = memo( + /** + * @param {object} props + * @param {string} props.id + * @param {boolean} props.canBurn + * @param {"visible" | "hidden"} props.documentVisibility + */ + function RemovableItem({ id, canBurn, documentVisibility }) { + const { activity } = useContext(NormalizedDataContext); + const item = useComputed(() => activity.value.items[id]); + if (!item.value) { + return ( + + ); + } + return ( + + + + + ); + }, +); + +const DDG_MAX_TRACKER_ICONS = 3; +/** + * @param {object} props + * @param {string} props.id + * @param {boolean} props.trackersFound + */ +function TrackerStatus({ id, trackersFound }) { + const { t } = useTypedTranslationWith(/** @type {enStrings} */ ({})); + const { activity } = useContext(NormalizedDataContext); + const status = useComputed(() => activity.value.trackingStatus[id]); + const other = status.value.trackerCompanies.slice(DDG_MAX_TRACKER_ICONS - 1); + const companyIconsMax = other.length === 0 ? DDG_MAX_TRACKER_ICONS : DDG_MAX_TRACKER_ICONS - 1; + const adBlocking = useAdBlocking(); + + const icons = status.value.trackerCompanies.slice(0, companyIconsMax).map((item, _index) => { + return ; + }); + + let otherIcon = null; + if (other.length > 0) { + const title = other.map((item) => item.displayName).join('\n'); + otherIcon = ( + + +{other.length} + + ); + } + + if (status.value.totalCount === 0) { + let text; + if (trackersFound) { + text = adBlocking ? t('activity_no_adsAndTrackers_blocked') : t('activity_no_trackers_blocked'); + } else { + text = adBlocking ? t('activity_no_adsAndTrackers') : t('activity_no_trackers'); + } + return ( +

    + {text} +

    + ); + } + + return ( +
    +
    + {icons} + {otherIcon} +
    +
    + {adBlocking ? ( + + ) : ( + + )} +
    +
    + ); +} + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + */ +export function ActivityConfigured({ children }) { + const batched = useBatchedActivityApi(); + + const { activity } = useContext(NormalizedDataContext); + + const itemCount = useComputed(() => { + return Object.keys(activity.value.items).length; + }); + + return ( + + {children} + + ); +} + +/** + * Use this when you want to render the UI from a context where + * the service is available + initial data is ready + * + * for example: + * + * ```jsx + * + * + * + * ``` + * @param {object} props + * @param {boolean} props.showBurnAnimation + */ +export function ActivityConsumer({ showBurnAnimation }) { + const { state } = useContext(ActivityContext); + const service = useContext(ActivityServiceContext); + const platformName = usePlatformName(); + const visibility = useDocumentVisibility(); + if (service && state.status === 'ready') { + if (platformName === 'windows') { + return ( + + + + + + ); + } + return ( + + + + + + + + ); + } + return null; +} diff --git a/special-pages/pages/new-tab/app/activity/components/Activity.module.css b/special-pages/pages/new-tab/app/activity/components/Activity.module.css new file mode 100644 index 0000000000..efe08990e0 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/components/Activity.module.css @@ -0,0 +1,296 @@ +.root { + display: grid; +} + +.activity { + --favicon-width: 32px; + --heading-gap: 8px; + + + overflow: hidden; + width: calc(100% + 12px); + margin-left: -6px; + + &:not(:empty) { + margin-top: 24px; + } +} + +.block { + margin-top: 24px; +} + +.loader { + height: 10px; + border: 1px dotted black; + border-radius: 5px; + opacity: 0; +} + +.anim { + position: relative; + overflow: hidden; + border-radius: var(--border-radius-lg); + + [data-lottie-player] { + width: 100%; + height: auto; + object-fit: cover; + position: absolute; + bottom: -50%; + left: 0; + } +} + +.item { + padding-top: 8px; + padding-bottom: 8px; + padding-left: 6px; + padding-right: 6px; +} + +.burning { + > *:not([data-lottie-player]) { + transition: opacity .2s; + transition-delay: .3s; + opacity: 0; + } +} + +.heading { + display: flex; + gap: var(--heading-gap); + width: 100%; +} + +.favicon { + width: 32px; + height: 32px; + /* adding a margin to prevent needing an extra dom node for spacing */ + margin: 3px; + display: block; + backdrop-filter: blur(24px); + border-radius: var(--border-radius-sm); + flex-shrink: 0; + text-decoration: none; + position: relative; + background: var(--color-black-at-12); + transition: transform .2s; + + border: 0.5px solid rgba(0, 0, 0, 0.09); + background: rgba(255, 255, 255, 0.30); + box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.12), 0px 0px 1.5px 0px rgba(0, 0, 0, 0.16); + + [data-theme="dark"] & { + border: 0.5px solid rgba(255, 255, 255, 0.09); + background: rgba(0, 0, 0, 0.18); + box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.12), 0px 0px 1.5px 0px rgba(0, 0, 0, 0.16); + backdrop-filter: blur(24px); + } + + > *:first-child { + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); + } +} + +.title { + font-size: var(--title-3-em-font-size); + font-weight: var(--title-3-em-font-weight); + text-decoration: none; + color: var(--ntp-text-normal); + height: 35px; + display: flex; + align-items: center; + line-height: 1; + + /* Note: This is not a 1:1 value from figma, I reduced it for perfect visual alignment */ + gap: 4px; + min-width: 0; + + &:hover, &:focus-visible { + color: var(--ntp-color-primary); + .favicon { + transform: scale(1.08) + } + } +} + +.controls { + display: flex; + margin-left: auto; + flex-shrink: 0; + position: relative; + gap: 4px; + top: 4px; +} + +.icon { + width: 24px; + height: 24px; + position: relative; + border: none; + background: transparent; + padding: 0; + margin: 0; + color: var(--ntp-text-normal); + svg { + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); + } +} + +.controlIcon { + border-radius: 50%; + background-color: var(--color-black-at-3); + &:hover { + background-color: var(--color-black-at-6); + } + + [data-theme="dark"] & { + background-color: var(--color-white-at-6); + } + [data-theme="dark"] &:hover { + background-color: var(--color-white-at-9); + } + svg { + fill-opacity: 0.6; + } +} + +.disableWhenBusy { + [data-busy="true"] & { + cursor: not-allowed; + } +} + +.body { + padding-left: calc(var(--favicon-width) + var(--heading-gap)); + padding-right: calc(var(--favicon-width) + var(--heading-gap) * 2); + position: relative; +} + +.otherIcon { + width: 16px; + height: 16px; + border-radius: 50%; + font-weight: bold; + font-size: 0.5rem; + line-height: 16px; + color: var(--color-black-at-60); + background: var(--color-black-at-6); + text-align: center; + + [data-theme="dark"] & { + color: var(--color-white-at-50); + background: var(--color-white-at-9); + } +} + +.companiesIconRow { + display: flex; + align-items: center; + gap: 6px; + padding-left: 1px; /* visual alignment */ +} + +.companiesIcons { + display: flex; + gap: 3px; + > * { + flex-shrink: 0; + min-width: 0; + } +} +.companiesText {} + +.history { + margin-top: 10px; +} +.historyItem { + display: flex; + align-items: center; + width: 100%; + height: 16px; + + .historyItem { + margin-top: 5px; + } +} +.historyLink { + font-size: var(--small-label-font-size); + font-weight: var(--small-label-font-weight); + line-height: var(--small-label-line-height); + color: var(--ntp-text-normal); + text-decoration: none; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover, &:focus-visible { + color: var(--ntp-color-primary) + } + + &:hover .time { + text-decoration: none; + display: inline-block; + } +} + +.time { + flex-shrink: 0; + margin-left: 8px; + color: var(--ntp-text-muted); + opacity: 0.6; + font-size: var(--small-label-font-size); + font-weight: var(--small-label-font-weight); + line-height: var(--small-label-line-height); +} + +.historyBtn { + width: 16px; + height: 16px; + flex-shrink: 0; + border: 0; + border-radius: 4px; + position: relative; + text-align: center; + padding: 0; + margin: 0; + margin-left: 8px; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-black-at-60); + + &:hover { + background-color: var(--color-black-at-6); + } + + [data-theme="dark"] & { + color: var(--color-white-at-60); + &:hover { + background-color: var(--color-white-at-6); + } + } + + svg { + display: inline-block; + width: 16px; + height: 16px; + position: relative; + top: 1px; + transform: rotate(0); + } + + &[data-action="hide"] { + svg { + transform: rotate(180deg) + } + } +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/activity/components/ActivityItem.js b/special-pages/pages/new-tab/app/activity/components/ActivityItem.js new file mode 100644 index 0000000000..34ba648d16 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/components/ActivityItem.js @@ -0,0 +1,104 @@ +import { h } from 'preact'; +import { useTypedTranslationWith } from '../../types.js'; +import cn from 'classnames'; +import styles from './Activity.module.css'; +import { FaviconWithState } from '../../../../../shared/components/FaviconWithState.js'; +import { ACTION_ADD_FAVORITE, ACTION_REMOVE, ACTION_REMOVE_FAVORITE } from '../constants.js'; +import { Star, StarFilled } from '../../components/icons/Star.js'; +import { Fire } from '../../components/icons/Fire.js'; +import { Cross } from '../../components/Icons.js'; +import { useContext } from 'preact/hooks'; +import { memo } from 'preact/compat'; +import { useComputed } from '@preact/signals'; +import { NormalizedDataContext } from '../NormalizeDataProvider.js'; +import { ACTION_BURN } from '../../burning/BurnProvider.js'; +import { DDG_FALLBACK_ICON, DDG_FALLBACK_ICON_DARK } from '../../favorites/constants.js'; + +export const ActivityItem = memo( + /** + * @param {object} props + * @param {boolean} props.canBurn + * @param {"visible"|"hidden"} props.documentVisibility + * @param {import("preact").ComponentChild} props.children + * @param {string} props.title + * @param {string} props.url + * @param {string|null|undefined} props.favoriteSrc + * @param {number} props.faviconMax + * @param {string} props.etldPlusOne + */ + function ActivityItem({ canBurn, documentVisibility, title, url, favoriteSrc, faviconMax, etldPlusOne, children }) { + return ( +
  • + +
    {children}
    +
  • + ); + }, +); + +/** + * Renders a set of control buttons that handle actions related to favorites and burn/removal features. + * + * @import enStrings from "../strings.json" + * @param {Object} props - The input properties for the control buttons. + * @param {boolean} props.canBurn - Indicates whether the burn action is allowed. + * @param {string} props.url - The unique URL identifier for the associated item. + * @param {string} props.title - The title or domain name displayed in the button tooltips. + */ +function Controls({ canBurn, url, title }) { + const { t } = useTypedTranslationWith(/** @type {enStrings} */ ({})); + const { activity } = useContext(NormalizedDataContext); + const favorite = useComputed(() => activity.value.favorites[url]); + + // prettier-ignore + const favoriteTitle = favorite.value + ? t('activity_favoriteRemove', { domain: title }) + : t('activity_favoriteAdd', { domain: title }); + + // prettier-ignore + const secondaryTitle = canBurn + ? t('activity_burn', { domain: title }) + : t('activity_itemRemove', { domain: title }); + return ( +
    + + +
    + ); +} diff --git a/special-pages/pages/new-tab/app/activity/components/ActivityItemAnimationWrapper.js b/special-pages/pages/new-tab/app/activity/components/ActivityItemAnimationWrapper.js new file mode 100644 index 0000000000..5293ad9c48 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/components/ActivityItemAnimationWrapper.js @@ -0,0 +1,83 @@ +import { useContext, useEffect, useLayoutEffect, useRef } from 'preact/hooks'; +import { ActivityBurningSignalContext } from '../../burning/BurnProvider.js'; +import { useComputed } from '@preact/signals'; +import cn from 'classnames'; +import styles from './Activity.module.css'; +import { lazy, Suspense } from 'preact/compat'; +import { h } from 'preact'; + +// eslint-disable-next-line promise/prefer-await-to-then +const BurnAnimationLazy = lazy(() => import('../../burning/BurnAnimationLottieWeb.js').then((x) => x.BurnAnimation)); + +/** + * A wrapper component that provides animation effects for activity items. It handles + * animations for items entering and exiting, as well as animations for a "burning" state + * based on the provided context signals. + * + * @param {Object} props + * @param {import("preact").ComponentChild} props.children The child components or elements to be rendered inside the wrapper. + * @param {string} props.url The unique URL associated with the activity item used for identifying its state in the context. + */ +export function ActivityItemAnimationWrapper({ children, url }) { + const ref = useRef(/** @type {HTMLDivElement|null} */ (null)); + const { exiting, burning, showBurnAnimation, doneBurning } = useContext(ActivityBurningSignalContext); + const isBurning = useComputed(() => burning.value.some((x) => x === url)); + const isExiting = useComputed(() => exiting.value.some((x) => x === url)); + + useLayoutEffect(() => { + let canceled = false; + let sent = false; + if (isBurning.value && ref.current) { + const element = ref.current; + element.style.height = element.scrollHeight + 'px'; + } else if (isExiting.value && ref.current) { + const element = ref.current; + const anim = element.animate([{ height: element.style.height }, { height: '0px' }], { + duration: 200, + iterations: 1, + fill: 'both', + easing: 'ease-in-out', + }); + const handler = (_) => { + if (canceled) return; + if (sent) return; + sent = true; + anim.removeEventListener('finish', handler); + window.dispatchEvent( + new CustomEvent('done-exiting', { + detail: { + url, + reason: 'animation completed', + }, + }), + ); + }; + anim.addEventListener('finish', handler, { once: true }); + document.addEventListener('visibilitychange', handler, { once: true }); + return () => { + anim.removeEventListener('finish', handler); + document.removeEventListener('visibilitychange', handler); + }; + } + return () => { + canceled = true; + }; + }, [isBurning.value, isExiting.value, url]); + + return ( +
    + {!isExiting.value && children} + {!isExiting.value && isBurning.value && showBurnAnimation && ( + + + + )} + {!isExiting.value && isBurning.value && !showBurnAnimation && } +
    + ); +} + +function NullBurner({ url, doneBurning }) { + useEffect(() => doneBurning(url), [url]); + return null; +} diff --git a/special-pages/pages/new-tab/app/activity/components/HistoryItems.js b/special-pages/pages/new-tab/app/activity/components/HistoryItems.js new file mode 100644 index 0000000000..74af1d4cc1 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/components/HistoryItems.js @@ -0,0 +1,84 @@ +import { useTypedTranslationWith } from '../../types.js'; +import { useContext, useState } from 'preact/hooks'; +import { NormalizedDataContext } from '../NormalizeDataProvider.js'; +import { useComputed } from '@preact/signals'; +import styles from './Activity.module.css'; +import { ChevronSmall } from '../../components/Icons.js'; +import { h } from 'preact'; + +/** + * @import enStrings from "../strings.json" + * @import { Expansion, HistoryEntry } from "../../../types/new-tab" + */ + +export const MIN_SHOW_AMOUNT = 2; +export const MAX_SHOW_AMOUNT = 10; + +/** + * @param {object} props + * @param {string} props.id + */ +export function HistoryItems({ id }) { + const { activity } = useContext(NormalizedDataContext); + const history = useComputed(() => activity.value.history[id]); + const [expansion, setExpansion] = useState(/** @type {Expansion} */ ('collapsed')); + const max = Math.min(history.value.length, MAX_SHOW_AMOUNT); + const min = Math.min(MIN_SHOW_AMOUNT, max); + const current = expansion === 'collapsed' ? min : max; + + function onClick(event) { + const btn = event.target?.closest('button[data-action]'); + if (btn?.dataset.action === 'hide') { + setExpansion('collapsed'); + } else if (btn?.dataset.action === 'show') { + setExpansion('expanded'); + } + } + + return ( +
      + {history.value.slice(0, current).map((item, index) => { + const isLast = index === current - 1; + return ; + })} +
    + ); +} + +/** + * Renders a history item with relevant details such as title, time, and optional show/hide button. + * + * @param {object} props + * @param {HistoryEntry} props.item - The history item object containing details like title, URL, and relative time. + * @param {boolean} props.isLast - Indicates if the current item is the last item in the history list. + * @param {number} props.current - The current number of visible history items. + * @param {number} props.min - The minimum number of visible history items. + * @param {number} props.max - The maximum number of visible history items. + */ +function HistoryItem({ item, isLast, current, min, max }) { + const { t } = useTypedTranslationWith(/** @type {enStrings} */ ({})); + + const hasMore = current < max; + const hasLess = current > min; + const hiddenCount = max - current; + const showButton = hasMore || hasLess; + + // prettier-ignore + const buttonLabel = hasMore && isLast + ? t('activity_show_more_history', { count: String(hiddenCount) }) + : t('activity_show_less_history'); + + return ( +
  • + + {item.title} + + {item.relativeTime} + {isLast && showButton && ( + + )} +
  • + ); +} diff --git a/special-pages/pages/new-tab/app/activity/constants.js b/special-pages/pages/new-tab/app/activity/constants.js new file mode 100644 index 0000000000..b2f54329c9 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/constants.js @@ -0,0 +1,6 @@ +/** + * @module Privacy Stats Constants + */ +export const ACTION_ADD_FAVORITE = 'add-favorite'; +export const ACTION_REMOVE_FAVORITE = 'remove-favorite'; +export const ACTION_REMOVE = 'remove'; diff --git a/special-pages/pages/new-tab/app/activity/integration-tests/activity.page.js b/special-pages/pages/new-tab/app/activity/integration-tests/activity.page.js new file mode 100644 index 0000000000..2daa6d595b --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/integration-tests/activity.page.js @@ -0,0 +1,444 @@ +import { activityMocks } from '../mocks/activity.mocks.js'; +import { expect, test } from '@playwright/test'; +import { generateSampleData } from '../mocks/activity.mock-transport.js'; + +/** + * @typedef {import('../../../types/new-tab.js').NewTabMessages['subscriptions']['subscriptionEvent']} SubscriptionEventNames + */ + +/** + * @param {SubscriptionEventNames} n + */ +const sub = (n) => n; + +export class ActivityPage { + entries = 200; + /** + * Sets the number of entries and returns the current instance for chaining. + * @param {number} count - The number of entries to set. + */ + withEntries(count) { + this.entries = count; + return this; + } + /** + * @param {import("@playwright/test").Page} page + * @param {import("../../../integration-tests/new-tab.page.js").NewtabPage} ntp + */ + constructor(page, ntp) { + this.page = page; + this.ntp = ntp; + } + + async receive() { + /** @type {import("../../../types/new-tab.js").ActivityData} */ + const next = activityMocks.few; + await this.ntp.mocks.simulateSubscriptionMessage('stats_onDataUpdate', next); + } + + /** + * @param {import("../../../types/new-tab.js").ActivityData} data + */ + async receiveData(data) { + await this.ntp.mocks.simulateSubscriptionMessage('stats_onDataUpdate', data); + } + + context() { + return this.page.locator('[data-entry-point="protections"] [data-testid="Activity"]'); + } + + rows() { + return this.context().getByTestId('ActivityItem'); + } + + /** + * @param {number} count + */ + async hasRows(count) { + await expect(this.rows()).toHaveCount(count); + } + + /** + * @param {string} heading + */ + async hasHeading(heading) { + await expect(this.context().getByRole('heading')).toContainText(heading); + } + + async showMoreSecondary() { + await this.context().getByLabel('Show More', { exact: true }).click(); + } + + async showLessSecondary() { + await this.context().getByLabel('Show Less', { exact: true }).click(); + } + + async didRender() { + await this.context().waitFor(); + } + + async ready() { + await this.ntp.mocks.waitForCallCount({ method: 'activity_getData', count: 1 }); + } + + async cannotExpandListWhenEmpty() { + const { page } = this; + + // control: ensure it can be collapsed first + await page.getByLabel('Hide recent activity').waitFor(); + + // now deliver new data + await this.ntp.mocks.simulateSubscriptionMessage('activity_onDataUpdate', { activity: [] }); + + // and assert the collapse button is now absent + await expect(page.getByLabel('Hide recent activity')).not.toBeVisible(); + } + + async canCollapseList() { + const { page } = this; + await page.getByLabel('Hide recent activity').click(); + await page.getByLabel('Show recent activity').click(); + } + + async collapsesList() { + const { page } = this; + await page.getByLabel('Hide recent activity').click(); + } + async expandsList() { + const { page } = this; + await page.getByLabel('Show recent activity').click(); + } + + async addsFavorite() { + await this.context().getByRole('button', { name: 'Add example.com to favorites' }).click(); + const result = await this.ntp.mocks.waitForCallCount({ method: 'activity_addFavorite', count: 1 }); + expect(result[0].payload).toStrictEqual({ + context: 'specialPages', + featureName: 'newTabPage', + method: 'activity_addFavorite', + params: { + url: 'https://example.com', + }, + }); + } + async removesFavorite() { + await this.context().getByRole('button', { name: 'Remove youtube.com from favorites' }).click(); + const result = await this.ntp.mocks.waitForCallCount({ method: 'activity_removeFavorite', count: 1 }); + expect(result[0].payload).toStrictEqual({ + context: 'specialPages', + featureName: 'newTabPage', + method: 'activity_removeFavorite', + params: { + url: 'https://fireproof.youtube.com', + }, + }); + } + async burnsItem() { + const { page } = this; + /** @type {import('../../../types/new-tab.js').ConfirmBurnResponse} */ + const response = { + action: 'burn', + }; + + await page.evaluate((response) => { + window.__playwright_01.mockResponses = { + ...window.__playwright_01.mockResponses, + activity_confirmBurn: /** @type {any} */ (response), + }; + }, response); + + // control: ensure we have 5 first + await expect(this.context().getByTestId('ActivityItem')).toHaveCount(5); + + await test.step('burn 1 item in the list', async () => { + await this.context().getByRole('button', { name: 'Clear browsing history and data for example.com' }).click(); + }); + + await test.step('assert the activity_confirmBurn was sent', async () => { + const result = await this.ntp.mocks.waitForCallCount({ method: 'activity_confirmBurn', count: 1 }); + expect(result[0].payload).toMatchObject({ + context: 'specialPages', + featureName: 'newTabPage', + method: 'activity_confirmBurn', + params: { + url: 'https://example.com', + }, + }); + }); + + // simulate a small delay from native + await page.waitForTimeout(50); + + // deliver both updates from native + const nextData = activityMocks.few.activity.filter((x) => x.url !== 'https://example.com'); + await this.ntp.mocks.simulateSubscriptionMessage(sub('activity_onDataUpdate'), { activity: nextData }); + await this.ntp.mocks.simulateSubscriptionMessage(sub('activity_onBurnComplete'), {}); + + // now assert only 4 items are there + await expect(this.context().getByTestId('ActivityItem')).toHaveCount(4); + } + + async removesItem() { + await this.context().getByRole('button', { name: 'Remove example.com from history' }).click(); + const result = await this.ntp.mocks.waitForCallCount({ method: 'activity_removeItem', count: 1 }); + expect(result[0].payload).toStrictEqual({ + context: 'specialPages', + featureName: 'newTabPage', + method: 'activity_removeItem', + params: { + url: 'https://example.com', + }, + }); + } + async opensLinkFromTitle() { + const { page } = this; + await page.getByText('example.com').click(); + await page.getByText('example.com').click({ modifiers: ['Meta'] }); + await page.getByText('example.com').click({ modifiers: ['Shift'] }); + await page.getByText('example.com').click({ button: 'middle' }); + await this._opensMainLink(); + } + async _opensMainLink() { + const calls = await this.ntp.mocks.waitForCallCount({ method: 'activity_open', count: 3 }); + const url = 'https://example.com'; + + expect(calls[0].payload.params).toStrictEqual({ + url, + target: 'same-tab', + }); + + expect(calls[1].payload.params).toStrictEqual({ + url, + target: 'new-tab', + }); + + expect(calls[2].payload.params).toStrictEqual({ + url, + target: 'new-window', + }); + + expect(calls[3].payload.params).toStrictEqual({ + url, + target: 'new-tab', + }); + } + + async opensLinkFromHistory() { + const { page } = this; + await page.getByRole('link', { name: '/kitchen/sinks' }).click(); + await page.getByRole('link', { name: '/kitchen/sinks' }).click({ modifiers: ['Meta'] }); + await page.getByRole('link', { name: '/kitchen/sinks' }).click({ modifiers: ['Shift'] }); + const calls = await this.ntp.mocks.waitForCallCount({ method: 'activity_open', count: 3 }); + expect(calls[0].payload.params).toStrictEqual({ + url: 'https://example.com/kitchen/sinks', + target: 'same-tab', + }); + expect(calls[1].payload.params).toStrictEqual({ + url: 'https://example.com/kitchen/sinks', + target: 'new-tab', + }); + expect(calls[2].payload.params).toStrictEqual({ + url: 'https://example.com/kitchen/sinks', + target: 'new-window', + }); + } + + /** + * Simulates the subscription message with a new list of activity data. + * + * @param {number} count - The number of items to generate in the new list. + */ + async acceptsNewList(count) { + const next = generateSampleData(count); + await this.ntp.mocks.simulateSubscriptionMessage('activity_onDataUpdate', { activity: next }); + } + + async acceptsUpdatedFavorite() { + const { page } = this; + const initial = structuredClone(activityMocks.few); + initial.activity[0].favorite = true; + + // control: ensure the first item is not already favorited + await page.getByRole('button', { name: 'Add example.com to favorites' }).waitFor(); + + await this.ntp.mocks.simulateSubscriptionMessage('activity_onDataUpdate', initial); + + // assertion: make sure it can be removed + await page.getByRole('button', { name: 'Remove example.com from favorites' }).waitFor(); + } + + async acceptsUpdatedHistoryPaths() { + // control: should only be 2 hidden (4 total) + await this.context().getByLabel('Show 2 more').click(); + const initial = structuredClone(activityMocks.few); + + /** @type {import('../../../types/new-tab.js').HistoryEntry[]} */ + const newItems = [ + { url: 'https://example.com/a', relativeTime: 'Just now', title: '/a' }, + { url: 'https://example.com/b', relativeTime: 'Just now', title: '/b' }, + { url: 'https://example.com/c', relativeTime: 'Just now', title: '/c' }, + { url: 'https://example.com/d', relativeTime: 'Just now', title: '/d' }, + { url: 'https://example.com/e', relativeTime: 'Just now', title: '/e' }, + { url: 'https://example.com/f', relativeTime: 'Just now', title: '/f' }, + { url: 'https://example.com/g', relativeTime: 'Just now', title: '/g' }, + ]; + + initial.activity[0].history.push(...newItems); + + // now we simulate sending 6 additional items, making 10 in total + await this.ntp.mocks.simulateSubscriptionMessage('activity_onDataUpdate', { activity: [initial.activity[0]] }); + + // ensure we can re-hide the items + await this.context().getByLabel('Hide additional').click(); + + // if the update was accepted, there should be 2 showing + 8 more to show (from the 10 above) + await this.context().getByLabel('Show 8 more').click(); + } + async listsAtMost3TrackerCompanies() { + const { page } = this; + await page.pause(); + } + + async showsTrackersOnlyTrackerStates() { + await expect(this.context().getByTestId('ActivityItem').nth(0)).toMatchAriaSnapshot(` + - listitem: + - link "example.com" + - button "Add example.com to favorites": + - img + - button "Clear browsing history and data for example.com": + - img + - text: +1 56 tracking attempts blocked + - list: + - listitem: + - link "/bathrooms/toilets" + - text: Just now + - listitem: + - link "/kitchen/sinks" + - text: 50 mins ago + `); + + await expect(this.context().getByTestId('ActivityItem').nth(3)).toMatchAriaSnapshot(` + - listitem: + - link "twitter.com" + - button "Add twitter.com to favorites": + - img + - button "Clear browsing history and data for twitter.com": + - img + - paragraph: No trackers blocked + - list: + - listitem: + - link "Trending Topics" + - text: 2 days ago + `); + + await expect(this.context().getByTestId('ActivityItem').nth(4)).toMatchAriaSnapshot(` + - listitem: + - link "app.linkedin.com" + - button "Add app.linkedin.com to favorites": + - img + - button "Clear browsing history and data for app.linkedin.com": + - img + - paragraph: No trackers found + - list: + - listitem: + - link "Profile Page" + - text: 2 hrs ago + `); + } + + async showsAdsAndTrackersTrackerStates() { + await expect(this.context().getByTestId('ActivityItem').nth(0)).toMatchAriaSnapshot(` + - listitem: + - link "example.com" + - button "Add example.com to favorites": + - img + - button "Clear browsing history and data for example.com": + - img + - text: +1 56 ads + tracking attempts blocked + - list: + - listitem: + - link "/bathrooms/toilets" + - text: Just now + - listitem: + - link "/kitchen/sinks" + - text: 50 mins ago + `); + + await expect(this.context().getByTestId('ActivityItem').nth(3)).toMatchAriaSnapshot(` + - listitem: + - link "twitter.com" + - button "Add twitter.com to favorites": + - img + - button "Clear browsing history and data for twitter.com": + - img + - paragraph: No ads + tracking attempts blocked + - list: + - listitem: + - link "Trending Topics" + - text: 2 days ago + `); + + await expect(this.context().getByTestId('ActivityItem').nth(4)).toMatchAriaSnapshot(` + - listitem: + - link "app.linkedin.com" + - button "Add app.linkedin.com to favorites": + - img + - button "Clear browsing history and data for app.linkedin.com": + - img + - paragraph: No ads + tracking attempts found + - list: + - listitem: + - link "Profile Page" + - text: 2 hrs ago + `); + } + + async hasEmptyTrackersOnlyTitle() { + const { page } = this; + await expect(page.getByTestId('ActivityHeading')).toMatchAriaSnapshot(` + - img "Privacy Shield" + - heading "No recent browsing activity" [level=2] + - paragraph: Recently visited sites will appear here. Keep browsing to see how many trackers we block. + `); + } + + async hasEmptyAdsAndTrackersTitle() { + const { page } = this; + await expect(page.getByTestId('ActivityHeading')).toMatchAriaSnapshot(` + - img "Privacy Shield" + - heading "No recent browsing activity" [level=2] + - paragraph: Recently visited sites will appear here. Keep browsing to see how many ads and trackers we block. + `); + } + + async hasPopulatedTrackersOnlyTitle() { + const { page } = this; + await expect(page.getByTestId('ActivityHeading')).toMatchAriaSnapshot(` + - img "Privacy Shield" + - heading "0 tracking attempts blocked" [level=2] + - button "Hide recent activity" [expanded] [pressed]: + - img + - paragraph: Past 7 days + `); + } + + async hasPopulatedAdsAndTrackersTitle() { + const { page } = this; + await expect(page.getByTestId('ActivityHeading')).toMatchAriaSnapshot(` + - img "Privacy Shield" + - heading "0 advertising & tracking attempts blocked" [level=2] + - button "Hide recent activity" [expanded] [pressed]: + - img + - paragraph: Past 7 days + `); + } + + async hasTrackingInfoWithoutButtons() { + const { page } = this; + await expect(page.getByTestId('ActivityHeading')).toMatchAriaSnapshot(` + - img "Privacy Shield" + - heading "56 tracking attempts blocked" [level=2] + - paragraph: Past 7 days + `); + } +} diff --git a/special-pages/pages/new-tab/app/activity/integration-tests/activity.spec.js b/special-pages/pages/new-tab/app/activity/integration-tests/activity.spec.js new file mode 100644 index 0000000000..f4aad82f5b --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/integration-tests/activity.spec.js @@ -0,0 +1,230 @@ +import { test } from '@playwright/test'; +import { NewtabPage } from '../../../integration-tests/new-tab.page.js'; +import { ActivityPage } from './activity.page.js'; +import { BatchingPage } from './batching.page.js'; + +const defaultPageParams = { + 'protections.feed': 'activity', +}; + +test.describe('activity widget', () => { + test('Accepts update (pushing items to front)', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const widget = new ActivityPage(page, ntp).withEntries(5); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams, activity: widget.entries } }); + await widget.didRender(); + await widget.acceptsNewList(6); + await widget.hasRows(6); + }); + test('Accepts update (subscription)', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams } }); + await ap.didRender(); + await ap.acceptsUpdatedFavorite(); + await ap.acceptsUpdatedHistoryPaths(); + }); + test('can expand with entries', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams, activity: 'onlyTopLevel' } }); + await ap.canCollapseList(); + }); + test('favorite item', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams } }); + await ap.didRender(); + await ap.addsFavorite(); + }); + test('remove favorite item', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams } }); + await ap.didRender(); + await ap.removesFavorite(); + }); + test('burns item', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { 'protections.feed': 'activity' } }); + await ap.didRender(); + await ap.burnsItem(); + }); + test('removes item (windows)', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams }, platformName: 'windows' }); + await ap.didRender(); + await ap.removesItem(); + }); + test('opening links from title', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams } }); + await ap.didRender(); + await ap.opensLinkFromTitle(); + }); + test('opening links from history', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams } }); + await ap.didRender(); + await ap.opensLinkFromHistory(); + }); + test('listing tracker companies', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams } }); + await ap.didRender(); + await ap.listsAtMost3TrackerCompanies(); + }); + test('tracker states', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams } }); + await ap.didRender(); + await ap.showsTrackersOnlyTrackerStates(); + await ntp.openPage({ additional: { ...defaultPageParams, adBlocking: 'enabled' } }); + await ap.didRender(); + await ap.showsAdsAndTrackersTrackerStates(); + }); + test('after rendering and navigating to a new tab, data is re-requested on return', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams } }); + await ap.didRender(); + + // Open a new tab and navigate it to about:blank + await ntp.mocks.waitForCallCount({ method: 'activity_getData', count: 1 }); + const newTab = await page.context().newPage(); + await newTab.goto('about:blank'); + + // Bring the first tab back into focus + await page.bringToFront(); + + await page.evaluate(() => { + // @ts-expect-error - testing only property + const fn = window.__trigger_document_visibilty__; + fn?.(); + }); + await ntp.mocks.waitForCallCount({ method: 'activity_getData', count: 2 }); + }); + test.describe('batched API', () => { + test('control: un-batched fetches all', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const ap = new ActivityPage(page, ntp); + await ntp.reducedMotion(); + await ntp.openPage({ + additional: { + ...defaultPageParams, + 'activity.api': 'NOT BATCHED', + platform: 'macos', + activity: '20', // 20 items to show by default + }, + }); + await ap.hasRows(20); + }); + test('fetches the minimal amount initially, and then chunks', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const widget = new ActivityPage(page, ntp).withEntries(200); + const batching = new BatchingPage(page, ntp, widget); + await ntp.reducedMotion(); + await ntp.openPage({ + additional: { ...defaultPageParams, 'activity.api': 'batched', platform: 'windows', activity: widget.entries }, + }); + await batching.fetchedRows(5); + await widget.hasRows(5); + await batching.triggerNext(); + await widget.hasRows(15); + await page.waitForTimeout(500); + await batching.triggerNext(); + await page.waitForTimeout(500); + await widget.hasRows(25); + }); + test('patching in place', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const widget = new ActivityPage(page, ntp); + const batching = new BatchingPage(page, ntp, widget); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { ...defaultPageParams, 'activity.api': 'batched', platform: 'windows', activity: '200' } }); + await widget.hasRows(5); + await batching.acceptsUpdate(0); + }); + test('patching removes an item', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + await ntp.reducedMotion(); + + const widget = new ActivityPage(page, ntp).withEntries(5); + const batching = new BatchingPage(page, ntp, widget); + + await ntp.openPage({ + additional: { ...defaultPageParams, 'activity.api': 'batched', platform: 'windows', activity: widget.entries }, + }); + + await widget.hasRows(5); + await batching.itemRemovedViaPatch(0); + await widget.hasRows(4); + }); + test('items are fetched to replace patched removals', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + await ntp.reducedMotion(); + + // 6 entries, 1 more than default to show + const widget = new ActivityPage(page, ntp).withEntries(6); + const batching = new BatchingPage(page, ntp, widget); + + await ntp.openPage({ + additional: { ...defaultPageParams, 'activity.api': 'batched', platform: 'windows', activity: widget.entries }, + }); + + await batching.fillsHoleWhenItemRemoved(); + }); + test('items are reordered on patch', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + await ntp.reducedMotion(); + + // 6 entries, 1 more than default to show + const widget = new ActivityPage(page, ntp).withEntries(6); + const batching = new BatchingPage(page, ntp, widget); + + await ntp.openPage({ + additional: { ...defaultPageParams, 'activity.api': 'batched', platform: 'windows', activity: widget.entries }, + }); + + await batching.itemsReorder(); + }); + test('resets on collapse', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + await ntp.reducedMotion(); + + // 20 entries, plenty to be triggered + const widget = new ActivityPage(page, ntp).withEntries(20); + const batching = new BatchingPage(page, ntp, widget); + + await ntp.openPage({ + additional: { ...defaultPageParams, 'activity.api': 'batched', platform: 'windows', activity: widget.entries }, + }); + + await batching.fetchedRows(5); + await widget.hasRows(5); + await batching.triggerNext(); + await widget.hasRows(15); + await widget.collapsesList(); + await widget.expandsList(); + await widget.hasRows(5); + }); + }); +}); diff --git a/special-pages/pages/new-tab/app/activity/integration-tests/batching.page.js b/special-pages/pages/new-tab/app/activity/integration-tests/batching.page.js new file mode 100644 index 0000000000..fc1e4e3a18 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/integration-tests/batching.page.js @@ -0,0 +1,224 @@ +import { generateSampleData } from '../mocks/activity.mock-transport.js'; +import { expect } from '@playwright/test'; + +/** + * @typedef {import('../../../types/new-tab.js').NewTabMessages['subscriptions']['subscriptionEvent']} SubscriptionEventNames + * @typedef {import('../../../types/new-tab.js').ActivityOnDataPatchSubscription['params']} PatchParams + */ + +/** + * @param {SubscriptionEventNames} n + */ +const sub = (n) => n; + +export class BatchingPage { + /** + * @param {import('@playwright/test').Page} page + * @param {import('../../../integration-tests/new-tab.page.js').NewtabPage} ntp + * @param {import("./activity.page.js").ActivityPage} ap + */ + constructor(page, ntp, ap) { + this.page = page; + this.ntp = ntp; + this.ap = ap; + } + + async fetchedRows(count) { + const data = generateSampleData(200); + const ids = data.slice(0, count).map((x) => x.url); + + await this.ntp.mocks.waitForCallCount({ method: 'activity_getUrls', count: 1 }); + const calls2 = await this.ntp.mocks.waitForCallCount({ method: 'activity_getDataForUrls', count: 1 }); + expect(calls2[0].payload.params.urls).toStrictEqual(ids); + } + + async triggerNext() { + const { page } = this; + + await page.evaluate(() => { + const scrollableItem = document.querySelector('[data-main-scroller]'); + if (scrollableItem) { + scrollableItem.scrollTop = scrollableItem.scrollHeight; + } + }); + } + + async acceptsUpdate(nth) { + const data = generateSampleData(200); + const original = structuredClone(data[nth]); + const clone1 = structuredClone(data[nth]); + clone1.history.push({ url: 'https://update.', title: 'test update 1', relativeTime: 'Just now' }); + + /** @type {import('../../../types/new-tab.js').ActivityOnDataPatchSubscription['params']} */ + const patch = { + patch: clone1, + urls: data.map((x) => x.url), + totalTrackersBlocked: 0, + }; + + const locator = this.ap.context().getByTestId('ActivityItem').nth(nth); + + // deliver the update + await this.ntp.mocks.simulateSubscriptionMessage(sub('activity_onDataPatch'), patch); + + // this item should now have an expander + await locator.getByLabel('Show 1 more').click(); + + // now assert the history item is shown + await expect(locator).toMatchAriaSnapshot(` + - list: + - listitem: + - link "(h) 0.0 - ARizGXo5MduB" + - text: 5 minutes ago + - listitem: + - link "(h) 0.1 - BSj0HYp6NevC" + - text: 3 weeks ago + - listitem: + - link "test update 1" + - text: Just now + - button "Hide additional": + - img`); + + /** @type {import('../../../types/new-tab.js').ActivityOnDataPatchSubscription['params']} */ + const patch2 = { + patch: original, + urls: data.map((x) => x.url), + totalTrackersBlocked: 0, + }; + + // deliver the update + await this.ntp.mocks.simulateSubscriptionMessage(sub('activity_onDataPatch'), patch2); + + // now assert only 2 history items show + await expect(locator).toMatchAriaSnapshot(` + - list: + - listitem: + - link "(h) 0.0 - ARizGXo5MduB" + - text: 5 minutes ago + - listitem: + - link "(h) 0.1 - BSj0HYp6NevC" + - text: 3 weeks ago`); + } + + /** + * Removes an item from a generated sample data array at the specified index and sends updated data + * through a simulated subscription message. + * + * @param {number} index - The index of the item to remove from the sample data array. + */ + async itemRemovedViaPatch(index) { + const data = generateSampleData(this.ap.entries); + data.splice(index, 1); + const update = toPatch(data); + await this.ntp.mocks.simulateSubscriptionMessage(sub('activity_onDataPatch'), update); + } + + /** + * Simulates removing all items + * @param {object} params + * @param {number} params.index - The index of the item to remove from the sample data array. + * @param {number} params.nextTrackerCount + */ + async removesItem({ index, nextTrackerCount }) { + const { page } = this; + await page.locator('button[data-action="remove"]').nth(index).click(); + + const update = toPatch([]); + update.totalTrackersBlocked = nextTrackerCount; + await this.ntp.mocks.simulateSubscriptionMessage(sub('activity_onDataPatch'), update); + } + + async fillsHoleWhenItemRemoved() { + if (this.ap.entries !== 6) throw new Error('this scenario expects 6 initial items'); + + // control, these calls already happened on page load + await this.ntp.mocks.waitForCallCount({ method: 'activity_getUrls', count: 1 }); + const first = await this.ntp.mocks.waitForCallCount({ method: 'activity_getDataForUrls', count: 1 }); + + expect(first[0].payload.params).toStrictEqual({ + urls: [ + 'https://0.ARizGXo5Md.com', + 'https://1.BSj0HYp6Ne.com', + 'https://2.CTk1IZq7Of.com', + 'https://3.DUl2Jar8Pg.com', + 'https://4.EVm3Kbs9Qh.com', + ], + }); + + // now remove the first item via subscription + await this.itemRemovedViaPatch(0); + + // now there must have been 2 calls + const [, second] = await this.ntp.mocks.waitForCallCount({ method: 'activity_getDataForUrls', count: 2 }); + + expect(second.payload.params).toStrictEqual({ + urls: ['https://5.FWn4LctARi.com'], + }); + } + + async itemsReorder() { + if (this.ap.entries !== 6) throw new Error('this scenario expects 6 initial items'); + + await this.ap.hasRows(5); + + // control: this is the initial state + await expect(this.ap.context()).toMatchAriaSnapshot(` + - list: + - listitem: + - link "0 ARizGXo5Md" + - listitem: + - link "1 BSj0HYp6Ne" + - listitem: + - link "2 CTk1IZq7Of" + - listitem: + - link "3 DUl2Jar8Pg" + - listitem: + - link "4 EVm3Kbs9Qh" + `); + + const data = generateSampleData(this.ap.entries); + const first = data[0]; + const last = data[data.length - 1]; + + data[0] = last; + data[data.length - 1] = first; + + const patch = toPatch(data); + await this.ntp.mocks.simulateSubscriptionMessage(sub('activity_onDataPatch'), patch); + + // Ensure the + await expect(this.ap.context()).toMatchAriaSnapshot(` + - list: + - listitem: + - link "5 FWn4LctARi" + - listitem: + - link "1 BSj0HYp6Ne" + - listitem: + - link "2 CTk1IZq7Of" + - listitem: + - link "3 DUl2Jar8Pg" + - listitem: + - link "4 EVm3Kbs9Qh" + `); + } + + async displaysTrackerCount() { + if (this.ap.entries !== 6) throw new Error('this scenario expects 6 initial items'); + + const { totalTrackersBlocked } = toPatch(generateSampleData(this.ap.entries)); + + // ensure the total is shown, even though some item will not have been fetched + await expect(this.ap.context().getByRole('heading')).toContainText(`${totalTrackersBlocked} tracking attempts blocked`); + } +} + +/** + * @param {import('../../../types/new-tab.ts').DomainActivity[]} entries + * @return {PatchParams} + */ +function toPatch(entries) { + return { + urls: entries.map((x) => x.url), + totalTrackersBlocked: entries.reduce((acc, item) => acc + item.trackingStatus.totalCount, 0), + }; +} diff --git a/special-pages/pages/new-tab/app/activity/mocks/activity.mock-transport.js b/special-pages/pages/new-tab/app/activity/mocks/activity.mock-transport.js new file mode 100644 index 0000000000..2bf90c018f --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/mocks/activity.mock-transport.js @@ -0,0 +1,347 @@ +import { TestTransportConfig } from '@duckduckgo/messaging'; +import { activityMocks } from './activity.mocks.js'; + +const url = typeof window !== 'undefined' ? new URL(window.location.href) : new URL('https://example.com'); + +/** + * @typedef {import('../../../types/new-tab.js').ActivityOnDataPatchSubscription['params']} PatchParams + */ + +/** + * @template T + * @param {T} value + * @return {T} + */ +function clone(value) { + return window.structuredClone?.(value) ?? JSON.parse(JSON.stringify(value)); +} + +export function activityMockTransport() { + /** @type {import('../../../types/new-tab.ts').ActivityData} */ + let dataset = clone(activityMocks.few); + + if (url.searchParams.has('activity')) { + const key = url.searchParams.get('activity'); + if (key && key in activityMocks) { + dataset = clone(activityMocks[key]); + } else if (key?.match(/^\d+$/)) { + dataset = getJsonSync(parseInt(key)); + } + } + + const subs = new Map(); + + return new TestTransportConfig({ + notify(_msg) { + /** @type {import('../../../types/new-tab.ts').NewTabMessages['notifications']} */ + const msg = /** @type {any} */ (_msg); + switch (msg.method) { + case 'activity_removeItem': { + // grab the tracker count of the current dataset before we alter it + const oldCount = dataset.activity.reduce((acc, item) => acc + item.trackingStatus.totalCount, 0); + + // now filter the items + dataset.activity = dataset.activity.filter((x) => x.url !== msg.params.url); + + // create the patch dataset, and use the original tracker count + const patchParams = toPatch(dataset.activity); + patchParams.totalTrackersBlocked = oldCount; + + // simulate the native side pushing the fresh data back into the page. + setTimeout(() => { + const cb = subs.get('activity_onDataPatch'); + cb(patchParams); + }, 0); + break; + } + default: { + console.warn('unhandled notification', msg); + } + } + }, + subscribe(_msg, cb) { + /** @type {import('../../../types/new-tab.ts').NewTabMessages['subscriptions']['subscriptionEvent']} */ + const sub = /** @type {any} */ (_msg.subscriptionName); + if (sub === 'activity_onBurnComplete') { + subs.set('activity_onBurnComplete', cb); + return () => { + subs.delete('activity_onBurnComplete'); + }; + } + if (sub === 'activity_onDataUpdate') { + subs.set('activity_onDataUpdate', cb); + } + if (sub === 'activity_onDataPatch') { + subs.set('activity_onDataPatch', cb); + } + if (sub === 'activity_onDataUpdate' && url.searchParams.has('flood')) { + let count = 0; + const int = setInterval(() => { + if (count === 10) return clearInterval(int); + dataset.activity.push({ + url: `https://${count}.example.com`, + etldPlusOne: 'example.com', + favicon: null, + history: [], + favorite: false, + trackersFound: false, + trackingStatus: { trackerCompanies: [], totalCount: 0 }, + title: 'example.com', + }); + count += 1; + console.log('sent', dataset); + cb(dataset); + }, 1000); + return () => {}; + } + if (sub === 'activity_onDataUpdate' && url.searchParams.has('nested')) { + let count = 0; + const int = setInterval(() => { + if (count === 10) return clearInterval(int); + dataset.activity[1].history.push({ + url: `https://${count}.example.com`, + title: 'example.com', + relativeTime: 'just now', + }); + // next.activity[0].trackingStatus.trackerCompanies.push({ + // displayName: `${count}.example.com`, + // }); + // next.activity[0].trackingStatus.trackerCompanies.push({ + // displayName: `${count}.example.com`, + // }); + // next.activity[0].trackingStatus.totalCount += 1; + count += 1; + cb(dataset); + }, 500); + return () => {}; + } + + if (sub === 'activity_onDataPatch') { + /** @type {any} */ (window).af = { + gen(count) { + return generateSampleData(count); + }, + patchAddBack(count) { + const len = dataset.activity.length; + const all = generateSampleData(200); + const newItems = all.slice(len, len + count); + dataset.activity.push(...newItems); + const patch = toPatch(dataset.activity); + cb(patch); + }, + patchAddFront(count) { + const len = dataset.activity.length; + const all = generateSampleData(200); + const newItems = all.slice(len, len + count); + dataset.activity = [...newItems, ...dataset.activity]; + const patch = toPatch(dataset.activity); + cb(patch); + }, + patchRemove(nth) { + dataset.activity.splice(nth, 1); + const patch = toPatch(dataset.activity); + cb(patch); + }, + patchRemoveCount(count) { + dataset.activity.splice(dataset.activity.length - count, count); + const patch = toPatch(dataset.activity); + cb(patch); + }, + addHistoryEntry(nth) { + const item = dataset.activity[nth]; + item.history.push({ + title: 'pushed history entry', + url: item.url + '/h1', + relativeTime: 'Just now', + }); + // dataset.activity.splice(dataset.activity.length - count, count); + const patch = toPatchItem(dataset.activity, nth); + cb(patch); + }, + addTrackingCompany(nth) { + const item = dataset.activity[nth]; + item.trackingStatus.trackerCompanies.push({ + displayName: 'Bytedance', + }); + // dataset.activity.splice(dataset.activity.length - count, count); + const patch = toPatchItem(dataset.activity, nth); + cb(patch); + }, + increaseTrackerCount(nth) { + const item = dataset.activity[nth]; + item.trackingStatus.totalCount += 1; + // dataset.activity.splice(dataset.activity.length - count, count); + const patch = toPatchItem(dataset.activity, nth); + cb(patch); + }, + }; + return () => {}; + } + + console.warn('unhandled sub', sub); + return () => {}; + }, + // eslint-ignore-next-line require-await + request(_msg) { + /** @type {import('../../../types/new-tab.ts').NewTabMessages['requests']} */ + const msg = /** @type {any} */ (_msg); + console.log(msg); + switch (msg.method) { + case 'activity_confirmBurn': { + const url = msg.params.url; + /** @type {import('../../../types/new-tab.ts').ConfirmBurnResponse} */ + let response = { action: 'burn' }; + + /** + * When not in automated tests, use a confirmation window to mimic the native modal + */ + if (!window.__playwright_01) { + const fireproof = url.startsWith('https://fireproof.'); + if (fireproof) { + if (!confirm('are you sure?')) { + response = { action: 'none' }; + } + } + } + + if (response.action === 'burn' && !window.__playwright_01) { + setTimeout(() => { + const cb = subs.get('activity_onDataUpdate'); + console.log('will send updated data after 500ms', url); + const next = activityMocks.few.activity.filter((x) => x.url !== url); + cb?.({ activity: next }); + }, 500); + setTimeout(() => { + const cb = subs.get('activity_onBurnComplete'); + console.log('will send updated data after 600ms', url); + cb?.(); + }, 600); + } + + return Promise.resolve(response); + } + case 'activity_getUrls': { + /** @type {import('../../../types/new-tab.ts').ActivityGetUrlsRequest['result']} */ + const next = toPatch(dataset.activity); + return Promise.resolve(next); + } + case 'activity_getDataForUrls': { + /** @type {import('../../../types/new-tab.ts').ActivityGetDataForUrlsRequest['result']} */ + const next = { + activity: dataset.activity.filter((x) => msg.params.urls.includes(x.url)), + }; + return Promise.resolve(next); + } + case 'activity_getData': + return Promise.resolve(dataset); + default: { + return Promise.reject(new Error('unhandled request' + msg)); + } + } + }, + }); +} + +/** + * @returns {import('../../../types/new-tab.ts').ActivityData} + */ +function getJsonSync(count = 200) { + return { activity: generateSampleData(count) }; +} + +/** + * @param {import('../../../types/new-tab.ts').DomainActivity[]} entries + * @return {PatchParams} + */ +function toPatch(entries) { + return { + urls: entries.map((x) => x.url), + totalTrackersBlocked: entries.reduce((acc, item) => acc + item.trackingStatus.totalCount, 0), + }; +} +/** + * @param {import('../../../types/new-tab.ts').DomainActivity[]} entries + * @param {number} nth + * @return {PatchParams} + */ +function toPatchItem(entries, nth) { + return { + urls: entries.map((x) => x.url), + totalTrackersBlocked: entries.reduce((acc, item) => acc + item.trackingStatus.totalCount, 0), + patch: entries[nth], + }; +} + +/** + * @param {number} count + * @returns {import('../../../types/new-tab.ts').DomainActivity[]} + */ +export function generateSampleData(count) { + // Deterministic string generator based on an index + const generateString = (index, length = 10) => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; // Define a string of all possible characters (alphanumeric) + let result = ''; // Initialize an empty string to store the result + const seed = index; // Use index as a seed for pseudo-randomness + for (let i = 0; i < length; i++) { + // Loop for the specified length of the string + result += chars.charAt((seed + i * 17) % chars.length); // Add a character at a calculated position within the chars string + } + return result; // Return the generated string + }; + + const generateHistoryItem = (parentIndex, historyIndex) => ({ + title: `(h) ${parentIndex}.${historyIndex} - ${generateString(historyIndex, 12)}`, + url: `https://${parentIndex}.${generateString(parentIndex)}.com/a`, + relativeTime: historyIndex === 0 ? '5 minutes ago' : '3 weeks ago', + }); + + const generateTrackerCompanies = (index) => { + // Using 20 common companies identified as trackers + const companies = [ + { displayName: 'Google' }, + { displayName: 'Facebook' }, + { displayName: 'Amazon' }, + { displayName: 'Microsoft' }, + { displayName: 'Apple' }, + { displayName: 'Twitter' }, + { displayName: 'Adobe' }, + { displayName: 'Oracle' }, + { displayName: 'TikTok' }, + { displayName: 'LinkedIn' }, + { displayName: 'Spotify' }, + { displayName: 'Snapchat' }, + { displayName: 'Yahoo' }, + { displayName: 'Cloudflare' }, + { displayName: 'Dropbox' }, + { displayName: 'Reddit' }, + { displayName: 'Pinterest' }, + { displayName: 'Salesforce' }, + { displayName: 'IBM' }, + { displayName: 'Tencent' }, + ]; + const itemCount = index % 6; // Wrap from 0 to 5 + return companies.slice(0, itemCount); + }; + + const data = []; + for (let i = 0; i < count; i++) { + const generatedString = generateString(i); + const trackerCompanies = generateTrackerCompanies(i); + data.push({ + title: `${i} ${generatedString}`, + url: `https://${i}.${generatedString}.com`, + etldPlusOne: `${generatedString}.com`, + trackersFound: true, + history: [generateHistoryItem(i, 0), generateHistoryItem(i, 1)], + favorite: false, + favicon: null, + trackingStatus: { + trackerCompanies, + totalCount: trackerCompanies.length === 0 ? 0 : Math.round(trackerCompanies.length * 1.5), + }, + }); + } + return data; +} + +// console.log(JSON.stringify(generateSampleData(10), null, 2)); diff --git a/special-pages/pages/new-tab/app/activity/mocks/activity.mocks.js b/special-pages/pages/new-tab/app/activity/mocks/activity.mocks.js new file mode 100644 index 0000000000..27f41cec49 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/mocks/activity.mocks.js @@ -0,0 +1,202 @@ +/** + * @import { ActivityData } from "../../../types/new-tab"; + * @type {Record} + */ +export const activityMocks = { + empty: { + activity: [], + }, + onlyTopLevel: { + activity: [ + { + favicon: { src: 'selco-icon.png' }, + url: 'https://example.com', + title: 'example.com', + etldPlusOne: 'example.com', + favorite: false, + trackersFound: false, + trackingStatus: { + trackerCompanies: [], + totalCount: 0, + }, + history: [], + }, + ], + }, + longTitle: { + activity: [ + { + favicon: { src: 'selco-icon.png' }, + url: 'https://deploy-preview-1468--content-scope-scripts.netlify.app', + title: 'deploy-preview-1468--content-scope-scripts.netlify.app', + etldPlusOne: 'deploy-preview-1468--content-scope-scripts.netlify.app', + favorite: false, + trackersFound: false, + trackingStatus: { + trackerCompanies: [], + totalCount: 0, + }, + history: [], + }, + ], + }, + longEntry: { + activity: [ + { + favicon: { src: 'selco-icon.png' }, + url: 'https://example.app', + title: 'example.app', + etldPlusOne: 'example.app', + favorite: false, + trackersFound: false, + trackingStatus: { + trackerCompanies: [], + totalCount: 0, + }, + history: [ + { + title: '/products/bathroom/toilets-and-bidets/wall-mounted/modern-collection/ceramic-toilet-bowl?color=white&size=standard&material=porcelain&inStock=true&freeShipping=true', + url: 'https://example.com/products/bathroom', + relativeTime: 'Just now', + }, + ], + }, + ], + }, + singleWithTrackers: { + activity: [ + { + favicon: { src: 'selco-icon.png' }, + url: 'https://example.com', + title: 'example.com', + etldPlusOne: 'example.com', + favorite: false, + trackersFound: true, + trackingStatus: { + trackerCompanies: [{ displayName: 'Google' }, { displayName: 'Facebook' }, { displayName: 'Amazon' }], + totalCount: 56, + }, + history: [], + }, + ], + }, + few: { + activity: [ + { + favicon: { src: 'selco-icon.png' }, + url: 'https://example.com', + title: 'example.com', + etldPlusOne: 'example.com', + favorite: false, + trackersFound: true, + trackingStatus: { + trackerCompanies: [{ displayName: 'Google' }, { displayName: 'Facebook' }, { displayName: 'Amazon' }], + totalCount: 56, + }, + history: [ + { + title: '/bathrooms/toilets', + url: 'https://example.com/bathrooms/toilets', + relativeTime: 'Just now', + }, + { + title: '/kitchen/sinks', + url: 'https://example.com/kitchen/sinks', + relativeTime: '50 mins ago', + }, + { + title: '/gardening/tools', + url: 'https://example.com/gardening/tools', + relativeTime: '18 hrs ago', + }, + { + title: '/lighting/fixtures', + url: 'https://example.com/lighting/fixtures', + relativeTime: '1 day ago', + }, + ], + }, + { + favicon: { src: 'youtube-icon.png' }, + url: 'https://fireproof.youtube.com', + title: 'youtube.com', + etldPlusOne: 'youtube.com', + favorite: true, + trackersFound: true, + trackingStatus: { + trackerCompanies: [ + { displayName: 'Google' }, + { displayName: 'Facebook' }, + { displayName: 'Amazon' }, + { displayName: 'Twitter' }, + ], + totalCount: 89, + }, + history: [ + { + title: 'Great Video on YouTube', + url: 'https://youtube.com/watch?v=123', + relativeTime: '3 days ago', + }, + ], + }, + { + favicon: { src: 'amazon-icon.png' }, + url: 'https://amazon.com', + title: 'amazon.com', + etldPlusOne: 'amazon.com', + favorite: false, + trackersFound: true, + trackingStatus: { + trackerCompanies: [{ displayName: 'Adobe Analytics' }, { displayName: 'Facebook' }], + totalCount: 12, + }, + history: [ + { + title: 'Electronics Store', + url: 'https://amazon.com/electronics', + relativeTime: '1 day ago', + }, + ], + }, + { + favicon: { src: 'twitter-icon.png' }, + url: 'https://twitter.com', + title: 'twitter.com', + etldPlusOne: 'twitter.com', + favorite: false, + trackersFound: true, + trackingStatus: { + trackerCompanies: [], + totalCount: 0, + }, + history: [ + { + title: 'Trending Topics', + url: 'https://twitter.com/explore', + relativeTime: '2 days ago', + }, + ], + }, + { + favicon: { src: 'linkedin-icon.png' }, + url: 'https://linkedin.com', + title: 'app.linkedin.com', + etldPlusOne: 'linkedin.com', + favorite: false, + trackersFound: false, + trackingStatus: { + trackerCompanies: [], + totalCount: 0, + }, + history: [ + { + title: 'Profile Page', + url: 'https://linkedin.com/in/user-profile', + relativeTime: '2 hrs ago', + }, + ], + }, + ], + }, +}; diff --git a/special-pages/pages/new-tab/app/activity/strings.json b/special-pages/pages/new-tab/app/activity/strings.json new file mode 100644 index 0000000000..c8a78a5d81 --- /dev/null +++ b/special-pages/pages/new-tab/app/activity/strings.json @@ -0,0 +1,66 @@ +{ + "activity_noRecent_title": { + "title": "No recent browsing activity", + "note": "Placeholder to indicate that no browsing activity was seen in the last 7 days" + }, + "activity_empty": { + "title": "Recently visited sites will appear here. Keep browsing to see how many trackers we block.", + "note": "Shown in the place a list of browsing history entries will be displayed." + }, + "activity_no_trackers": { + "title": "No trackers found", + "note": "Placeholder message indicating that no trackers are detected" + }, + "activity_no_trackers_blocked": { + "title": "No trackers blocked", + "note": "Placeholder message indicating that no trackers are blocked" + }, + "activity_countBlockedPlural": { + "title": "{count} tracking attempts blocked", + "note": "The main headline indicating that more than 1 attempt has been blocked. Eg: '2 tracking attempts blocked'" + }, + "activity_noRecentAdsAndTrackers_subtitle": { + "title": "Recently visited sites will appear here. Keep browsing to see how many ads and trackers we block.", + "note": "Shown in the place a list of browsing history entries will be displayed." + }, + "activity_no_adsAndTrackers": { + "title": "No ads + tracking attempts found", + "note": "Placeholder message indicating that no ads and trackers are detected" + }, + "activity_no_adsAndTrackers_blocked": { + "title": "No ads + tracking attempts blocked", + "note": "Placeholder message indicating that no ads and trackers are blocked" + }, + "activity_countBlockedAdsAndTrackersPlural": { + "title": "{count} ads + tracking attempts blocked", + "note": "The main headline indicating that more than 1 attempt has been blocked. Eg: '2 ads + tracking attempts blocked'" + }, + "activity_favoriteAdd": { + "title": "Add {domain} to favorites", + "note": "Button label, allows the user to add the specified domain to their favorites" + }, + "activity_favoriteRemove": { + "title": "Remove {domain} from favorites", + "note": "Button label, allows the user to remove the specified domain from their favorites" + }, + "activity_itemRemove": { + "title": "Remove {domain} from history", + "note": "Button label for clearing browsing history for a given domain." + }, + "activity_burn": { + "title": "Clear browsing history and data for {domain}", + "note": "Button label for clearing browsing history and data exclusively for the specified domain" + }, + "activity_menuTitle": { + "title": "Recent Activity", + "note": "Used as a label in a customization menu" + }, + "activity_show_more_history": { + "title": "Show {count} more", + "note": "Button label that expands the list of browsing history with the specified count of additional items. Example: 'Show 5 more'" + }, + "activity_show_less_history": { + "title": "Hide additional", + "note": "Button label that hides the expanded browsing history items." + } +} diff --git a/special-pages/pages/new-tab/app/burning/ActivityInteractionsContext.js b/special-pages/pages/new-tab/app/burning/ActivityInteractionsContext.js new file mode 100644 index 0000000000..40d804b3f6 --- /dev/null +++ b/special-pages/pages/new-tab/app/burning/ActivityInteractionsContext.js @@ -0,0 +1,8 @@ +import { createContext } from 'preact'; + +export const ActivityInteractionsContext = createContext({ + /** + * @type {(evt: MouseEvent) => void} _event + */ + didClick(_event) {}, +}); diff --git a/special-pages/pages/new-tab/app/burning/BurnAnimationLottieWeb.js b/special-pages/pages/new-tab/app/burning/BurnAnimationLottieWeb.js new file mode 100644 index 0000000000..ffc4c3ca14 --- /dev/null +++ b/special-pages/pages/new-tab/app/burning/BurnAnimationLottieWeb.js @@ -0,0 +1,58 @@ +import { h } from 'preact'; +import lottie from 'lottie-web'; +import { useContext, useEffect, useRef } from 'preact/hooks'; +import { ActivityBurningSignalContext } from './BurnProvider.js'; + +/** + * BurnAnimation is a React component that renders a Lottie animation and dispatches custom events when the animation stops or the component unmounts. + * + * @param {Object} props The properties object. + * @param {string} props.url The URL associated with the animation, used to identify or provide additional context in the dispatched events. + * @param {(url: string) => void} props.doneBurning + */ +export function BurnAnimation({ url, doneBurning }) { + const ref = useRef(/** @type {Lottie} */ null); + const json = useContext(ActivityBurningSignalContext); + useEffect(() => { + if (!ref.current) return; + let finished = false; + let timer = null; + + const publish = (_reason) => { + if (finished) return; + doneBurning(url); + finished = true; + clearTimeout(timer); + }; + + timer = setTimeout(() => { + publish('timeout occurred'); + }, 1200); + + const animationHandler = () => publish('timeout occurred'); + const hasJson = json.animation.value.state === 'ready' && json.animation.value.data; + + /** @type {import('lottie-web').AnimationItem | null} */ + let animation = lottie.loadAnimation({ + container: ref.current, + renderer: 'svg', + loop: false, + autoplay: true, + animationData: hasJson || undefined, + path: hasJson === undefined ? 'burn.json' : undefined, + }); + + animation.addEventListener('complete', animationHandler); + + return () => { + clearTimeout(timer); + // animation?.removeEventListener('complete', animationHandler); + animation = null; + + if (!finished) { + publish('unmount occurred'); + } + }; + }, [url, json, doneBurning]); + return
    ; +} diff --git a/special-pages/pages/new-tab/app/burning/BurnProvider.js b/special-pages/pages/new-tab/app/burning/BurnProvider.js new file mode 100644 index 0000000000..9c272b251a --- /dev/null +++ b/special-pages/pages/new-tab/app/burning/BurnProvider.js @@ -0,0 +1,243 @@ +import { h, createContext } from 'preact'; +import { useCallback, useContext } from 'preact/hooks'; +import { batch, signal, useSignal, useSignalEffect } from '@preact/signals'; +import { useEnv } from '../../../../shared/components/EnvironmentProvider.js'; +import { ActivityInteractionsContext } from './ActivityInteractionsContext.js'; + +export const ACTION_BURN = 'burn'; + +export const ActivityBurningSignalContext = createContext({ + /** @type {import("@preact/signals").Signal} */ + burning: signal([]), + /** @type {import("@preact/signals").Signal} */ + exiting: signal([]), + /** @type {import("@preact/signals").Signal<{state: 'loading' | 'ready' | 'error', data: null | Record}>} */ + animation: signal({ state: 'loading', data: null }), + /** @type {boolean} */ + showBurnAnimation: true, + /** @type {(url: string) => void} */ + doneBurning: (_url) => {}, +}); + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + * @param {{confirmBurn: (url: string) => Promise<{action: 'burn' | 'none'}>; disableBroadcast: () => void; enableBroadcast: () => void }} props.service + * @param {boolean} [props.showBurnAnimation] - defaults to true to match original implementation + * + */ +export function BurnProvider({ children, service, showBurnAnimation = true }) { + const burning = useSignal(/** @type {string[]} */ ([])); + const exiting = useSignal(/** @type {string[]} */ ([])); + const animation = useSignal({ state: /** @type {'loading' | 'ready' | 'error'} */ ('loading'), data: null }); + const { didClick: originalDidClick } = useContext(ActivityInteractionsContext); + const { isReducedMotion } = useEnv(); + + async function didClick(e) { + const button = /** @type {HTMLButtonElement|null} */ (e.target?.closest(`button[value][data-action="${ACTION_BURN}"]`)); + if (!button) return originalDidClick(e); + if (!service) throw new Error('unreachable'); + + e.preventDefault(); + e.stopImmediatePropagation(); + + if (burning.value.length > 0 || exiting.value.length > 0) return; + + const value = button.value; + const response = await service?.confirmBurn(value); + if (response && response.action === 'none') return; + + // stop the service broadcasting any updates for a moment + service.disableBroadcast(); + + // mark this item as burning - this will prevent further events until we're done + burning.value = burning.value.concat(value); + + // wait for a signal from the FE that we can continue + const feSignals = any(reducedMotion(isReducedMotion), animationExit(), didChangeDocumentVisibility()); + + // the signal from native that burning was complete + const nativeSignal = didCompleteNatively(service); + + // at least 1 FE signal + 1 native signal is required to continue + const required = all(feSignals, nativeSignal); + + // but don't wait any longer than 3 seconds + const withTimer = any(required, timer(3000)); + + // exec the chain + await toPromise(withTimer); + + // when we get here, clear out all state + batch(() => { + exiting.value = []; + burning.value = []; + }); + + // and re-enable the data broadcasting + service?.enableBroadcast(); + } + + useSignalEffect(() => { + let cancelled = false; + async function fetchAnimation() { + const resp = await fetch('burn.json'); + if (!resp.ok) { + animation.value = { state: /** @type {const} */ ('error'), data: null }; + return; + } + const json = await resp.json(); + if (!cancelled) animation.value = { state: 'ready', data: json }; + } + fetchAnimation() + // eslint-disable-next-line promise/prefer-await-to-then + .catch((_) => { + animation.value = { state: /** @type {const} */ ('error'), data: null }; + }); + return () => { + cancelled = true; + }; + }); + + /** @type {(url: string) => void} */ + const doneBurning = useCallback( + (url) => { + if (url) { + batch(() => { + burning.value = burning.value.filter((x) => x !== url); + exiting.value = exiting.value.concat(url); + }); + } + }, + [burning, exiting], + ); + + return ( + + {children} + + ); +} + +function toPromise(fn) { + return new Promise((resolve) => { + const cleanup = fn({ + next: (v) => { + resolve(v); + cleanup(); + }, + }); + }); +} + +function reducedMotion(isReducedMotion) { + return (subject) => { + if (isReducedMotion) { + subject.next(); + } + }; +} + +function animationExit() { + return (subject) => { + const handler = () => { + subject.next(); + }; + window.addEventListener('done-exiting', handler, { once: true }); + return () => { + window.removeEventListener('done-exiting', handler); + }; + }; +} + +function timer(ms) { + return (subject) => { + const int = setTimeout(() => { + return subject.next(); + }, ms); + return () => { + clearTimeout(int); + }; + }; +} + +function didCompleteNatively(service) { + return (subject) => { + const unsub = service?.onBurnComplete(() => { + subject.next(); + }); + return () => { + unsub(); + }; + }; +} + +function didChangeDocumentVisibility() { + return (subject) => { + const handler = () => { + return subject.next(document.visibilityState); + }; + document.addEventListener('visibilitychange', handler, { once: true }); + return () => { + window.removeEventListener('visibilitychange', handler); + }; + }; +} + +function any(...fns) { + return (subject) => { + const jobs = fns.map((factory) => { + const subject = { + /** @type {any} */ + next: undefined, + }; + const promise = new Promise((resolve) => (subject.next = resolve)); + const cleanup = factory(subject); + return { + promise, + cleanup, + }; + }); + + Promise.any(jobs.map((x) => x.promise)) + // eslint-disable-next-line promise/prefer-await-to-then + .then((d) => subject.next(d)) + // eslint-disable-next-line promise/prefer-await-to-then + .catch(console.error); + + return () => { + for (const job of jobs) { + job.cleanup?.(); + } + }; + }; +} + +function all(...fns) { + return (subject) => { + const jobs = fns.map((factory) => { + const subject = { + /** @type {any} */ + next: undefined, + }; + const promise = new Promise((resolve) => (subject.next = resolve)); + const cleanup = factory(subject); + return { + promise, + cleanup, + }; + }); + + Promise.all(jobs.map((x) => x.promise)) + // eslint-disable-next-line promise/prefer-await-to-then + .then((d) => subject.next(d)) + // eslint-disable-next-line promise/prefer-await-to-then + .catch(console.error); + + return () => { + for (const job of jobs) { + job.cleanup?.(); + } + }; + }; +} diff --git a/special-pages/pages/new-tab/app/components/App.js b/special-pages/pages/new-tab/app/components/App.js index 4f7877bba2..3b03421fbe 100644 --- a/special-pages/pages/new-tab/app/components/App.js +++ b/special-pages/pages/new-tab/app/components/App.js @@ -4,7 +4,7 @@ import styles from './App.module.css'; import { useCustomizerDrawerSettings, usePlatformName } from '../settings.provider.js'; import { WidgetList } from '../widget-list/WidgetList.js'; import { useGlobalDropzone } from '../dropzone.js'; -import { Customizer, CustomizerButton, CustomizerMenuPositionedFixed, useContextMenu } from '../customizer/components/Customizer.js'; +import { CustomizerButton, CustomizerMenuPositionedFixed, useContextMenu } from '../customizer/components/CustomizerMenu.js'; import { useDrawer, useDrawerControls } from './Drawer.js'; import { CustomizerDrawer } from '../customizer/components/CustomizerDrawer.js'; import { BackgroundConsumer } from './BackgroundProvider.js'; @@ -23,8 +23,6 @@ export function App() { const platformName = usePlatformName(); const customizerDrawer = useCustomizerDrawerSettings(); - const customizerKind = customizerDrawer.state === 'enabled' ? 'drawer' : 'menu'; - useGlobalDropzone(); useContextMenu(); @@ -41,6 +39,7 @@ export function App() { } = useDrawer(customizerDrawer.autoOpen ? 'visible' : 'hidden'); const tabIndex = useComputed(() => (hidden.value ? -1 : 0)); + const isOpen = useComputed(() => hidden.value === false); const { toggle } = useDrawerControls(); const { main, browser } = useContext(CustomizerThemesContext); @@ -54,42 +53,40 @@ export function App() { + +
    - {customizerKind === 'menu' && } - {customizerKind === 'drawer' && ( - - )} + - - {customizerKind === 'drawer' && ( - - )} +
    + ); diff --git a/special-pages/pages/new-tab/app/components/App.module.css b/special-pages/pages/new-tab/app/components/App.module.css index ed1e36174c..ca5fd28776 100644 --- a/special-pages/pages/new-tab/app/components/App.module.css +++ b/special-pages/pages/new-tab/app/components/App.module.css @@ -15,6 +15,8 @@ body { padding-bottom: var(--sp-16); margin-left: auto; margin-right: auto; + /* prevent the scrollbar affecting the width */ + padding-left: calc(100vw - 100%); } body:has([data-reset-layout="true"]) .tube { @@ -30,6 +32,10 @@ body[data-animate-background="true"] { max-width: calc(504 * var(--px-in-rem)); } +:global([data-entry-point="omnibar"]) { + max-width: calc(620 * var(--px-in-rem)); +} + :global(.vertical-space) { padding-top: 1rem; padding-bottom: 1rem; @@ -52,11 +58,16 @@ body[data-animate-background="true"] { color: var(--ntp-text-normal); } +.themeContext { + color: var(--ntp-text-normal); +} + .mainLayout { - padding-right: 0; - transition: padding-right .3s; + will-change: transform; + transition: transform .3s; + [data-drawer-visibility='visible'] & { - padding-right: var(--ntp-combined-width); + transform: translateX(calc(0px - var(--ntp-combined-width) / 2)); } } @@ -110,6 +121,7 @@ body[data-animate-background="true"] { right: 0; top: 0; z-index: 1; + will-change: transform; transform: translateX(100%); transition: transform .3s; diff --git a/special-pages/pages/new-tab/app/components/BackgroundProvider.js b/special-pages/pages/new-tab/app/components/BackgroundProvider.js index 815590cda5..2fb39792ad 100644 --- a/special-pages/pages/new-tab/app/components/BackgroundProvider.js +++ b/special-pages/pages/new-tab/app/components/BackgroundProvider.js @@ -1,11 +1,11 @@ import { Fragment, h } from 'preact'; -import cn from 'classnames'; import styles from './BackgroundReceiver.module.css'; import { values } from '../customizer/values.js'; -import { useContext, useState } from 'preact/hooks'; +import { useContext, useEffect, useState } from 'preact/hooks'; import { CustomizerContext } from '../customizer/CustomizerProvider.js'; import { detectThemeFromHex } from '../customizer/utils.js'; import { useSignalEffect } from '@preact/signals'; +import { memo } from 'preact/compat'; /** * @import { BackgroundVariant, BrowserTheme } from "../../types/new-tab" @@ -80,10 +80,11 @@ export function BackgroundConsumer({ browser }) { } if (background.kind === 'userImage') { const isDark = background.value.colorScheme === 'dark'; - nextBodyBackground = isDark ? 'var(--default-dark-bg)' : 'var(--default-light-bg)'; + nextBodyBackground = isDark ? 'var(--default-dark-background-color)' : 'var(--default-light-background-color)'; } if (background.kind === 'default') { - nextBodyBackground = browser.value === 'dark' ? 'var(--default-dark-bg)' : 'var(--default-light-bg)'; + nextBodyBackground = + browser.value === 'dark' ? 'var(--default-dark-background-color)' : 'var(--default-light-background-color)'; } document.body.style.setProperty('background-color', nextBodyBackground); @@ -110,7 +111,7 @@ export function BackgroundConsumer({ browser }) { const gradient = values.gradients[background.value]; return ( - +
    } + */ +const states = { + idle: 'idle', + loadingFirst: 'loadingFirst', + loading: 'loading', + fading: 'fading', + settled: 'settled', +}; + /** * @param {object} props * @param {string} props.src */ -function ImageCrossFade({ src }) { - /** - * Proxy the image source, so that we can keep the old - * image around whilst the new one is loading. - */ - const [stable, setStable] = useState(src); - /** - * Trigger the animation: - * - * NOTE: this animation is deliberately NOT done purely with CSS-triggered state. - * Whilst debugging in WebKit, I found the technique below to be 100% reliable - * in terms of fading a new image over the top of an existing one. - * - * If you find a better way, please test in webkit-based browsers - */ - return ( - - - { - const elem = /** @type {HTMLImageElement} */ (e.target); - - // HACK: This is what I needed to force, to get 100% predictability. 🤷 - elem.style.opacity = '0'; - - const anim = elem.animate([{ opacity: '0' }, { opacity: '1' }], { - duration: 250, - iterations: 1, - easing: 'ease-in-out', - fill: 'both', - }); - - // when the fade completes, we want to reset the stable `src`. - // This allows the image underneath to be updated but also allows us to un-mount the fader on top. - anim.onfinish = () => { - setStable(src); - }; - }} - /> - - ); +function ImageCrossFade_({ src }) { + const [state, setState] = useState({ + /** @type {ImgState} */ + value: states.idle, + current: src, + next: src, + }); + + useEffect(() => { + /** @type {HTMLImageElement|undefined} */ + let img = new Image(); + let cancelled = false; + + // Mark the component as being in a 'loading' state, without + // explicit changes to any DOM + setState((prev) => { + // prettier-ignore + const nextState = prev.value === states.idle + ? states.loadingFirst + : states.loading + return { ...prev, value: nextState }; + }); + + /** @type {(()=>void)|undefined} */ + let handler = () => { + if (cancelled) return; + setState((prev) => { + // when coming from a 'loading' states, we can fade + if (prev.value === states.loading) { + return { ...prev, value: states.fading, next: src }; + } + return prev; + }); + }; + + // trigger the load in memory, not on screen + img.addEventListener('load', handler); + img.src = src; + + return () => { + cancelled = true; + if (img && handler) { + img.removeEventListener('load', handler); + img = undefined; + handler = undefined; + } + }; + }, [src]); + + switch (state.value) { + case states.settled: + case states.loadingFirst: + return ; + case states.loading: + case states.fading: + return ( + + + { + const elem = /** @type {HTMLImageElement} */ (e.target); + + // HACK: This is what I needed to force, to get 100% predictability. 🤷 + elem.style.opacity = '0'; + + const anim = elem.animate([{ opacity: '0' }, { opacity: '1' }], { + duration: 250, + iterations: 1, + fill: 'both', + }); + + // when the fade completes, we want to reset the stable `src`. + // This allows the image underneath to be updated but also allows us to un-mount the fader on top. + anim.onfinish = () => { + setState((prev) => { + return { ...prev, value: states.settled, current: prev.next, next: prev.next }; + }); + }; + }} + /> + + ); + default: + return null; + } } + +const ImageCrossFade = memo(ImageCrossFade_); diff --git a/special-pages/pages/new-tab/app/components/BackgroundReceiver.module.css b/special-pages/pages/new-tab/app/components/BackgroundReceiver.module.css index 2cdfd69182..7754f3d4a6 100644 --- a/special-pages/pages/new-tab/app/components/BackgroundReceiver.module.css +++ b/special-pages/pages/new-tab/app/components/BackgroundReceiver.module.css @@ -7,14 +7,20 @@ object-fit: cover; pointer-events: none; - &[data-animate="true"] { - transition: background .3s; + &[data-state="loadingFirst"] { + animation-name: fade-in; + animation-fill-mode: both; + animation-duration: .25s; + animation-iteration-count: 1; } } -.under { - opacity: 1; -} -.over { - opacity: 0; +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } } + diff --git a/special-pages/pages/new-tab/app/components/CompanyIcon.js b/special-pages/pages/new-tab/app/components/CompanyIcon.js new file mode 100644 index 0000000000..86d0977b4e --- /dev/null +++ b/special-pages/pages/new-tab/app/components/CompanyIcon.js @@ -0,0 +1,194 @@ +import styles from './CompanyIcon.module.css'; +import { DDG_STATS_OTHER_COMPANY_IDENTIFIER } from '../privacy-stats/constants.js'; +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +const mappings = { + 'google-analytics-google': 'google-analytics', + 'google-ads-google': 'google-ads', +}; + +export const CompanyIcon = memo( + /** + * @param {object} props + * @param {string} props.displayName + */ + function CompanyIcon({ displayName }) { + const icon = displayName.toLowerCase().split('.')[0]; + const cleaned = icon.replace(/[^a-z ]/g, '').replace(/ /g, '-'); + const id = cleaned in mappings ? mappings[cleaned] : cleaned; + const firstChar = id[0]; + + if (icon === DDG_STATS_OTHER_COMPANY_IDENTIFIER) { + return ( + + + + ); + } + + // prettier-ignore + const src = names.has(id) + ? `./company-icons/${id}.svg` + : `./company-icons/${firstChar}.svg`; + + return ( + + {''} + + ); + }, +); + +function Other() { + return ( + + + + ); +} + +/** + * A static list of names representing the icons in `./public/company-icons` + * We don't want the bundler to crawl through that folder, or include the SVGs in JS, + * so this static list have to do for now. Perhaps it can be generated later. + * + * @type {Set} + */ +const names = new Set([ + '33across', + 'a', + 'acuityads', + 'adform', + 'adjust', + 'adobe', + 'akamai', + 'amazon', + 'amplitude', + 'appsflyer', + 'automattic', + 'b', + 'beeswax', + 'bidtellect', + 'branch-metrics', + 'braze', + 'bugsnag', + 'bytedance', + 'c', + 'chartbeat', + 'cloudflare', + 'cognitiv', + 'comscore', + 'crimtan-holdings', + 'criteo', + 'd', + 'deepintent', + 'e', + 'exoclick', + 'eyeota', + 'f', + 'facebook', + 'g', + 'google', + 'google-ads', + 'google-analytics', + 'gumgum', + 'h', + 'hotjar', + 'i', + 'id5', + 'improve-digital', + 'index-exchange', + 'inmar', + 'instagram', + 'intent-iq', + 'iponweb', + 'j', + 'k', + 'kargo', + 'kochava', + 'l', + 'line', + 'linkedin', + 'liveintent', + 'liveramp', + 'loopme-ltd', + 'lotame-solutions', + 'm', + 'magnite', + 'mediamath', + 'medianet-advertising', + 'mediavine', + 'merkle', + 'microsoft', + 'mixpanel', + 'n', + 'narrative', + 'nativo', + 'neustar', + 'new-relic', + 'o', + 'onetrust', + 'openjs-foundation', + 'openx', + 'opera-software', + 'oracle', + 'other', + 'other-dark', + 'outbrain', + 'p', + 'pinterest', + 'prospect-one', + 'pubmatic', + 'pulsepoint', + 'q', + 'quantcast', + 'r', + 'rhythmone', + 'roku', + 'rtb-house', + 'rubicon', + 's', + 'salesforce', + 'semasio', + 'sharethrough', + 'simplifi-holdings', + 'smaato', + 'snap', + 'sonobi', + 'sovrn-holdings', + 'spotx', + 'supership', + 'synacor', + 't', + 'taboola', + 'tapad', + 'teads', + 'the-nielsen-company', + 'the-trade-desk', + 'triplelift', + 'twitter', + 'u', + 'unruly-group', + 'urban-airship', + 'v', + 'verizon-media', + 'w', + 'warnermedia', + 'wpp', + 'x', + 'xaxis', + 'y', + 'yahoo-japan', + 'yandex', + 'yieldmo', + 'youtube', + 'z', + 'zeotap', + 'zeta-global', +]); diff --git a/special-pages/pages/new-tab/app/components/CompanyIcon.module.css b/special-pages/pages/new-tab/app/components/CompanyIcon.module.css new file mode 100644 index 0000000000..fe54c8cdea --- /dev/null +++ b/special-pages/pages/new-tab/app/components/CompanyIcon.module.css @@ -0,0 +1,29 @@ +.icon { + display: block; + width: 1rem; + height: 1rem; + border-radius: 50%; + flex-shrink: 0; + + img, svg { + display: block; + font-size: 0; + width: 1rem; + height: 1rem; + } + + &:has([data-errored=true]) { + outline: 1px solid var(--ntp-surface-border-color); + [data-theme=dark] & { + outline-color: var(--color-white-at-9); + } + } +} + +.companyImgIcon { + opacity: 0; +} + +.companyImgIcon[data-loaded=true] { + opacity: 1; +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/components/Components.jsx b/special-pages/pages/new-tab/app/components/Components.jsx index 053f2fb10d..01dfc30750 100644 --- a/special-pages/pages/new-tab/app/components/Components.jsx +++ b/special-pages/pages/new-tab/app/components/Components.jsx @@ -120,7 +120,7 @@ function Stage({ entries }) { function Isolated({ entries, e2e }) { if (e2e) { return ( -
    +
    {entries.map(([id, item]) => { return {item.factory()}; })} @@ -128,7 +128,7 @@ function Isolated({ entries, e2e }) { ); } return ( -
    +
    {entries.map(([id, item], index) => { return
    {item.factory()}
    ; })} @@ -140,7 +140,7 @@ function DebugBar({ entries, id, ids }) { return (
    - {ids.length > 0 && } + {ids.length > 0 && }
    @@ -171,11 +171,16 @@ function TextLength() { function Isolate() { const next = new URL(url); next.searchParams.set('isolate', 'true'); + const prod = new URL('/build/pages/new-tab', 'https://content-scope-scripts.netlify.app'); + prod.search = url.search; return ( ); } @@ -225,14 +230,17 @@ function ExampleSelector({ entries, id }) { ); } +export function TubeGrid({ children }) { + return
    {children}
    ; +} + /** * Allows users to select an example from a list and update the URL accordingly. * * @param {Object} options - The options object. * @param {Array} options.entries - The list of examples to choose from, each represented as an array with an id. - * @param {string} options.id - The current selected example id. */ -function Append({ entries, id }) { +function Append({ entries }) { function onReset() { const url = new URL(window.location.href); url.searchParams.delete('id'); diff --git a/special-pages/pages/new-tab/app/components/Components.module.css b/special-pages/pages/new-tab/app/components/Components.module.css index e51b6f2105..e83e96b63b 100644 --- a/special-pages/pages/new-tab/app/components/Components.module.css +++ b/special-pages/pages/new-tab/app/components/Components.module.css @@ -33,6 +33,25 @@ body[data-display="components"] { grid-row-gap: 2rem; } +body:has(.tubeGrid):has([data-isolated=true]) { + .componentList { + max-width: none; + padding: var(--sp-16); + + } +} + +.tubeGrid { + display: grid; + grid-template-columns: repeat(auto-fit, 540px); + grid-column-gap: 12px; + align-items: flex-start; + + + .tubeGrid { + margin-top: 12px; + } +} + .itemInfo { display: flex; flex-direction: column; diff --git a/special-pages/pages/new-tab/app/components/Drawer.js b/special-pages/pages/new-tab/app/components/Drawer.js index 413e39cb09..b346fd8b07 100644 --- a/special-pages/pages/new-tab/app/components/Drawer.js +++ b/special-pages/pages/new-tab/app/components/Drawer.js @@ -157,6 +157,24 @@ function _close() { window.dispatchEvent(new CustomEvent(CLOSE_DRAWER_EVENT)); } +/** + * Hook for listening to drawer events + * @param {object} callbacks + * @param {() => void} [callbacks.onOpen] - Called when drawer opens + * @param {() => void} [callbacks.onClose] - Called when drawer closes + * @param {() => void} [callbacks.onToggle] - Called when drawer toggles + * @param {any[]} [deps] - Dependency array controlling when listeners are re-registered + */ +export function useDrawerEventListeners({ onOpen, onClose, onToggle }, deps = []) { + useEffect(() => { + const controller = new AbortController(); + if (onOpen) window.addEventListener(OPEN_DRAWER_EVENT, onOpen, { signal: controller.signal }); + if (onClose) window.addEventListener(CLOSE_DRAWER_EVENT, onClose, { signal: controller.signal }); + if (onToggle) window.addEventListener(TOGGLE_DRAWER_EVENT, onToggle, { signal: controller.signal }); + return () => controller.abort(); + }, deps); +} + /** * familiar React-style API */ diff --git a/special-pages/pages/new-tab/app/components/Examples.jsx b/special-pages/pages/new-tab/app/components/Examples.jsx index 67fd1d0d01..724a125cb5 100644 --- a/special-pages/pages/new-tab/app/components/Examples.jsx +++ b/special-pages/pages/new-tab/app/components/Examples.jsx @@ -5,6 +5,9 @@ import { nextStepsExamples, otherNextStepsExamples } from '../next-steps/compone import { otherPrivacyStatsExamples, privacyStatsExamples } from '../privacy-stats/components/PrivacyStats.examples.js'; import { otherRMFExamples, RMFExamples } from '../remote-messaging-framework/components/RMF.examples.js'; import { updateNotificationExamples } from '../update-notification/components/UpdateNotification.examples.js'; +import { activityExamples } from '../activity/components/Activity.examples.js'; +import { protectionsHeadingExamples } from '../protections/components/ProtectionsHeading.examples.js'; +import { subscriptionWinBackBannerExamples } from '../subscription-winback-banner/components/SubscriptionWinBackBanner.examples.js'; /** @type {Record import("preact").ComponentChild}>} */ export const mainExamples = { @@ -13,6 +16,7 @@ export const mainExamples = { ...nextStepsExamples, ...privacyStatsExamples, ...RMFExamples, + ...subscriptionWinBackBannerExamples, }; export const otherExamples = { @@ -21,4 +25,6 @@ export const otherExamples = { ...otherRMFExamples, ...customizerExamples, ...updateNotificationExamples, + ...activityExamples, + ...protectionsHeadingExamples, }; diff --git a/special-pages/pages/new-tab/app/components/Icons.js b/special-pages/pages/new-tab/app/components/Icons.js index 546cdacc67..791d1bd889 100644 --- a/special-pages/pages/new-tab/app/components/Icons.js +++ b/special-pages/pages/new-tab/app/components/Icons.js @@ -1,28 +1,27 @@ import { h } from 'preact'; import styles from './Icons.module.css'; -export function ChevronButton() { +export function Chevron() { return ( - - + ); } -export function Chevron() { +export function ChevronSmall() { return ( - + ); @@ -164,3 +163,441 @@ export function BackChevron() { ); } + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Glyphs/16px/Find-Search-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function SearchIcon(props) { + return ( + + + + + + + + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Color/16px/Search-Find-Color-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function SearchColorIcon(props) { + return ( + + + + + + + + + + + + + + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Color/16px/Search-Find-Color-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function SearchOnDarkColorIcon(props) { + return ( + + + + + + + + + + + + + + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Glyphs/16px/Ai-Chat-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function AiChatIcon(props) { + return ( + + + + + + + + + + + + ); +} + +/** + * https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Color/16px/Ai-Chat-Gradient-Color-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function AiChatColorIcon(props) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** + * https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Color/16px/Ai-Chat-OnDark-Color-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function AiChatOnDarkColorIcon(props) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Glyphs/16px/ArrowRight-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function ArrowRightIcon(props) { + return ( + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Glyphs/16px/Globe-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function GlobeIcon(props) { + return ( + + + + + + + + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Glyphs/16px/History-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function HistoryIcon(props) { + return ( + + + + + + + + + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Glyphs/16px/Favorite-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function FavoriteIcon(props) { + return ( + + + + + + + + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Glyphs/16px/Bookmark-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function BookmarkIcon(props) { + return ( + + + + + + + + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Glyphs/16px/Browser-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function BrowserIcon(props) { + return ( + + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Glyphs/16px/Tab-Desktop-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function TabDesktopIcon(props) { + return ( + + + + ); +} + +/** + * @param {import('preact').JSX.SVGAttributes} props + */ +export function LogoStacked(props) { + return ( + + + + + + + + + + + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Glyphs/16px/Arrow-Indent-Centerd-16.svg + * @param {import('preact').JSX.SVGAttributes} props + */ +export function ArrowIndentCenteredIcon(props) { + return ( + + + + ); +} + +/** + * From https://dub.duckduckgo.com/duckduckgo/Icons/blob/Main/Glyphs/16px/Close-Small-16.svg. + * @param {import('preact').JSX.SVGAttributes} props + */ +export function CloseSmallIcon(props) { + return ( + + + + ); +} diff --git a/special-pages/pages/new-tab/app/components/Popover.js b/special-pages/pages/new-tab/app/components/Popover.js new file mode 100644 index 0000000000..184137228c --- /dev/null +++ b/special-pages/pages/new-tab/app/components/Popover.js @@ -0,0 +1,60 @@ +import { h } from 'preact'; +import { useEffect, useId, useRef } from 'preact/hooks'; +import { useTypedTranslationWith } from '../types.js'; +import { Cross } from './Icons.js'; +import styles from './Popover.module.css'; + +/** + * @typedef {import('../strings.json')} Strings + */ + +/** + * @param {object} props + * @param {string} props.title + * @param {string} [props.badge] + * @param {() => void} props.onClose + * @param {import('preact').ComponentChildren} props.children + */ +export function Popover({ title, badge, onClose, children }) { + const { t } = useTypedTranslationWith(/** @type {Strings} */ ({})); + const titleId = useId(); + const descriptionId = useId(); + const popoverRef = useRef(/** @type {HTMLDivElement|null} */ (null)); + + useEffect(() => { + popoverRef.current?.focus(); + + /** @type {(event: KeyboardEvent) => void} */ + const handleEscapeKey = (event) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscapeKey); + return () => document.removeEventListener('keydown', handleEscapeKey); + }, [onClose]); + + return ( + + ); +} diff --git a/special-pages/pages/new-tab/app/components/Popover.module.css b/special-pages/pages/new-tab/app/components/Popover.module.css new file mode 100644 index 0000000000..4227427006 --- /dev/null +++ b/special-pages/pages/new-tab/app/components/Popover.module.css @@ -0,0 +1,95 @@ +.popover { + align-items: center; + display: flex; + filter: drop-shadow(0 0 calc(0.5px) rgba(0, 0, 0, 0.75)) + drop-shadow(0 0 calc(1px) rgba(0, 0, 0, 0.25)) + drop-shadow(0 calc(8px) calc(16px) rgba(0, 0, 0, 0.20)); + flex-direction: row; + left: calc(100% + 7px); + outline: none; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 390px; + z-index: 1000; +} + +.content { + background: var(--ntp-surface-tertiary); + border-radius: 16px; + flex: auto; + padding: var(--sp-4) var(--sp-11) var(--sp-4) var(--sp-5); + position: relative; +} + +.closeButton { + background: none; + border-radius: 100%; + border: none; + color: var(--ntp-icons-primary); + cursor: pointer; + padding: 0; + position: absolute; + right: var(--sp-4); + top: var(--sp-4); + + &:hover { + background: rgba(0, 0, 0, 0.06); /* @todo: dark mode? */ + } + + &:active { + background: rgba(0, 0, 0, 0.12); /* @todo: dark mode? */ + } + + &:focus-visible { + box-shadow: var(--focus-ring); + } +} + +.heading { + display: flex; + flex-direction: column; + gap: var(--sp-1); +} + +.badge { + background: var(--color-yellow-60); + border-radius: var(--border-radius-xs); + color: var(--color-black); + font-size: 11.5px; + font-weight: 600; + line-height: var(--sp-4); + padding: 0 var(--sp-2); + text-transform: uppercase; + width: fit-content; +} + +.title { + font-size: var(--title-3-em-font-size); + font-weight: var(--title-3-em-font-weight); + line-height: var(--title-3-em-line-height); +} + +.description { + button { + background: none; + border: none; + color: var(--ntp-accent-primary); + cursor: pointer; + padding: 0; + transform: translateZ(0); /* Fix shadow rendering issue on .popover in WebKit */ + + &:hover { + color: var(--ntp-accent-secondary); + } + + &:active { + color: var(--ntp-accent-tertiary); + } + } +} + +.arrow { + color: var(--ntp-surface-tertiary); + margin-right: -1px; +} diff --git a/special-pages/pages/new-tab/app/components/ShowHide.module.css b/special-pages/pages/new-tab/app/components/ShowHide.module.css index b4426b35bc..ba818a9033 100644 --- a/special-pages/pages/new-tab/app/components/ShowHide.module.css +++ b/special-pages/pages/new-tab/app/components/ShowHide.module.css @@ -1,6 +1,4 @@ .button { - opacity: 0; - transition: opacity 0.3s; cursor: pointer; background: none; border: none; @@ -9,108 +7,126 @@ justify-content: center; align-items: center; color: var(--ntp-text-normal); - height: var(--ntp-gap); - width: 100%; - border-radius: var(--border-radius-sm); - - &.round { - height: 2rem; - width: 2rem; - border-radius: 50%; - padding-inline: 0; - background-color: transparent; - color: var(--ntp-text-muted); - - .iconBlock { - backdrop-filter: unset; - background-color: transparent; - box-shadow: none; - transition: all 0.3s ease-in; + > * { + pointer-events: none; + } - [data-theme=dark] & { - box-shadow: none; - background-color: transparent; - } - } + svg { + transition: transform .3s; + } - &:hover { - .iconBlock { - background-color: var(--color-black-at-6); + &[aria-pressed=false] svg { + transform: rotate(-180deg); + } - [data-theme=dark] & { - background-color: var(--color-white-at-12); - } - } - } + &:focus-visible { + box-shadow: var(--focus-ring-thin); + } +} + +.iconBlock { + backdrop-filter: blur(48px); + background-color: var(--ntp-surface-background-color); + border-radius: 50%; + height: 1.5rem; + width: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0px 2px 4px 0px var(--color-black-at-12), 0px 0px 3px 0px var(--color-black-at-18); + color: var(--ntp-text-muted); - &:focus-visible { - box-shadow: var(--focus-ring); - } + [data-theme="dark"] & { + box-shadow: 0px 2px 4px 0px var(--color-white-at-6), 0px 0px 3px 0px var(--color-white-at-9); } +} - &.withText { - border: 1px solid var(--color-black-at-9); +.round { + height: 2rem; + width: 2rem; + border-radius: 50%; + padding-inline: 0; + background-color: transparent; + color: var(--ntp-text-muted); - svg { - margin-right: var(--sp-2); - } + .iconBlock { + backdrop-filter: unset; + background-color: transparent; + box-shadow: none; + transition: all 0.3s ease-in; - &:hover { - background-color: var(--color-black-at-9); + [data-theme=dark] & { + box-shadow: none; + background-color: transparent; } + } - &:active { - background-color: var(--color-black-at-12); + &:hover { + .iconBlock { + background-color: var(--color-black-at-6); + + [data-theme=dark] & { + background-color: var(--color-white-at-12); + } } } - >* { - pointer-events: none; + &:focus-visible { + box-shadow: var(--focus-ring); } +} + +.pill { + height: calc(26 * var(--px-in-rem)); + border-radius: 9999px; + padding-left: 8px; + padding-right: 11px; + font-size: var(--callout-font-size); + font-weight: var(--callout-font-weight); + line-height: var(--callout-line-height); + border: 1px solid var(--ntp-surface-border-color); + color: var(--ntp-text-muted); +} + +.fill { + backdrop-filter: blur(48px); + background-color: var(--ntp-surface-background-color); +} + +.hover { + transition: background-color .2s; svg { - transition: transform .3s; - } - - .iconBlock { - backdrop-filter: blur(48px); - background-color: var(--ntp-surface-background-color); - border-radius: 50%; - height: 1.5rem; - width: 1.5rem; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0px 2px 4px 0px var(--color-black-at-12), 0px 0px 3px 0px var(--color-black-at-18); - color: var(--ntp-text-muted); - - - [data-theme="dark"] & { - box-shadow: 0px 2px 4px 0px var(--color-white-at-6), 0px 0px 3px 0px var(--color-white-at-9); - } + margin-right: calc(6 * var(--px-in-rem)); } - &[aria-pressed=true] svg { - transform: rotate(-180deg); + &:hover { + background-color: var(--color-black-at-6); } - &:focus-visible { - opacity: 1; - box-shadow: var(--focus-ring-thin); + &:active { + background-color: var(--color-black-at-12); } [data-theme=dark] & { - &.withText { - border-color: var(--color-white-at-9); + border-color: var(--color-white-at-9); - &:hover { - background-color: var(--color-white-at-9); - } + &:hover { + border-color: var(--color-white-at-18); + background-color: var(--color-white-at-6); + } - &:active { - background-color: var(--color-white-at-12); - } + &:active { + background-color: var(--color-white-at-12); } } +} + +.bar { + padding-top: 11px; + padding-bottom: 11px; + display: flex; + justify-content: center; + font-size: 12px; } \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/components/ShowHideButton.jsx b/special-pages/pages/new-tab/app/components/ShowHideButton.jsx index d8581d695e..ae9ca70c2c 100644 --- a/special-pages/pages/new-tab/app/components/ShowHideButton.jsx +++ b/special-pages/pages/new-tab/app/components/ShowHideButton.jsx @@ -1,36 +1,63 @@ import styles from './ShowHide.module.css'; import cn from 'classnames'; import { Chevron } from './Icons.js'; -import { Fragment, h } from 'preact'; +import { h } from 'preact'; /** * Function to handle showing or hiding content based on certain conditions. * * @param {Object} props - Input parameters for controlling the behavior of the ShowHide functionality. + * @param {string} props.label + * @param {() => void} props.onClick + * @param {import("preact").ComponentProps<'button'>} [props.buttonAttrs] + */ +export function ShowHideButtonCircle({ label, onClick, buttonAttrs = {} }) { + return ( + + ); +} + +/** + * Use this version for a small pill version with text and option aria-label + * @param {object} props * @param {string} props.text + * @param {string|undefined} props.label * @param {() => void} props.onClick - * @param {'none'|'round'} [props.shape] - when "none", is a full width btn w/ icon inside (used for below Favorites and NextSteps), Round is the PrivacyStats heading button - * @param {boolean} [props.showText] - btn w/ icon and text (used to expand PrivacyStats list), should be used with shape="none" + * @param {boolean} [props.fill=true] * @param {import("preact").ComponentProps<'button'>} [props.buttonAttrs] */ -export function ShowHideButton({ text, onClick, buttonAttrs = {}, shape = 'none', showText = false }) { +export function ShowHideButtonPill({ label, onClick, text, fill = true, buttonAttrs = {} }) { + // if a different label was given, make the main text aria-hidden=true + const btnText = label ? : text; + return ( ); } + +/** + * A container you can place a into. + * Consumers can use the `data-show-hide` to show/hide the bar + * @param {object} props + * @param {import("preact").ComponentChild} props.children + */ +export function ShowHideBar({ children }) { + return ( +
    + {children} +
    + ); +} diff --git a/special-pages/pages/new-tab/app/components/icons/Fire.js b/special-pages/pages/new-tab/app/components/icons/Fire.js new file mode 100644 index 0000000000..ead40bf6af --- /dev/null +++ b/special-pages/pages/new-tab/app/components/icons/Fire.js @@ -0,0 +1,12 @@ +import { h } from 'preact'; + +export function Fire() { + return ( + + + + ); +} diff --git a/special-pages/pages/new-tab/app/components/icons/Star.js b/special-pages/pages/new-tab/app/components/icons/Star.js new file mode 100644 index 0000000000..e3c1d90f5a --- /dev/null +++ b/special-pages/pages/new-tab/app/components/icons/Star.js @@ -0,0 +1,34 @@ +import { h } from 'preact'; + +export function Star() { + return ( + + + + + + + + + + + ); +} + +export function StarFilled() { + return ( + + + + ); +} diff --git a/special-pages/pages/new-tab/app/customizer/CustomizerProvider.js b/special-pages/pages/new-tab/app/customizer/CustomizerProvider.js index e007818fbb..58ebffe721 100644 --- a/special-pages/pages/new-tab/app/customizer/CustomizerProvider.js +++ b/special-pages/pages/new-tab/app/customizer/CustomizerProvider.js @@ -1,17 +1,27 @@ import { createContext, h } from 'preact'; import { useCallback } from 'preact/hooks'; -import { effect, signal, useSignal } from '@preact/signals'; +import { signal, useSignal, useSignalEffect } from '@preact/signals'; import { useThemes } from './themes.js'; +import { applyDefaultStyles } from './utils.js'; /** * @typedef {import('../../types/new-tab.js').CustomizerData} CustomizerData * @typedef {import('../../types/new-tab.js').BackgroundData} BackgroundData * @typedef {import('../../types/new-tab.js').ThemeData} ThemeData * @typedef {import('../../types/new-tab.js').UserImageData} UserImageData + * @typedef {import('../../types/new-tab.js').UserImageContextMenu} UserImageContextMenu * @typedef {import('../service.hooks.js').State} State * @typedef {import('../service.hooks.js').Events} Events */ +/** + * @typedef {{ + * title: string, + * icon: import('preact').ComponentChild, + * onClick: () => void, + * }} SettingsLinkData + */ + /** * These are the values exposed to consumers. */ @@ -31,16 +41,20 @@ export const CustomizerContext = createContext({ theme: 'system', }), /** @type {(bg: BackgroundData) => void} */ - select: (bg) => {}, + select: (_) => {}, upload: () => {}, /** * @type {(theme: ThemeData) => void} */ - setTheme: (theme) => {}, + setTheme: (_) => {}, /** * @type {(id: string) => void} */ - deleteImage: (id) => {}, + deleteImage: (_) => {}, + /** + * @param {UserImageContextMenu} _params + */ + customizerContextMenu: (_params) => {}, }); /** @@ -57,7 +71,7 @@ export function CustomizerProvider({ service, initialData, children }) { const data = useSignal(initialData); const { main, browser } = useThemes(data); - effect(() => { + useSignalEffect(() => { const unsub = service.onBackground((evt) => { data.value = { ...data.value, background: evt.data.background }; }); @@ -79,6 +93,18 @@ export function CustomizerProvider({ service, initialData, children }) { }; }); + useSignalEffect(() => { + const unsub = service.onTheme((evt) => { + if (evt.source === 'subscription') { + applyDefaultStyles(evt.data.defaultStyles); + } + }); + + return () => { + unsub(); + }; + }); + /** @type {(bg: BackgroundData) => void} */ const select = useCallback( (bg) => { @@ -105,8 +131,11 @@ export function CustomizerProvider({ service, initialData, children }) { [service], ); + /** @type {(p: UserImageContextMenu) => void} */ + const customizerContextMenu = useCallback((params) => service.contextMenu(params), [service]); + return ( - + {children} ); diff --git a/special-pages/pages/new-tab/app/customizer/components/BackgroundSection.js b/special-pages/pages/new-tab/app/customizer/components/BackgroundSection.js index 39dbff5451..c13ad19637 100644 --- a/special-pages/pages/new-tab/app/customizer/components/BackgroundSection.js +++ b/special-pages/pages/new-tab/app/customizer/components/BackgroundSection.js @@ -93,6 +93,7 @@ function DefaultPanel({ checked, onClick }) { aria-labelledby={id} role="radio" onClick={onClick} + tabindex={checked ? -1 : 0} > {checked && } @@ -117,6 +118,7 @@ function ColorPanel(props) { data-color-mode={props.color.colorScheme} onClick={props.onClick} aria-checked={props.checked} + tabindex={props.checked ? -1 : 0} aria-labelledby={id} role="radio" style={{ background: props.color.hex }} @@ -144,6 +146,7 @@ function GradientPanel(props) { class={cn(styles.bgPanel, styles.dynamicIconColor)} data-color-mode={props.gradient.colorScheme} aria-checked={props.checked} + tabindex={props.checked ? -1 : 0} aria-labelledby={id} style={{ background: `url(${props.gradient.path})`, diff --git a/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.js b/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.js index c77d58bcfc..a2a4ada4bf 100644 --- a/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.js +++ b/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.js @@ -26,12 +26,12 @@ export function BrowserThemeSection(props) { role="radio" type="button" aria-checked={current.value === 'light'} - tabindex={0} + tabindex={current.value === 'light' ? -1 : 0} onClick={() => props.setTheme({ theme: 'light' })} > {t('customizer_browser_theme_label', { type: 'light' })} - {t('customizer_browser_theme_light')} + {t('customizer_browser_theme_light')}
  • - {t('customizer_browser_theme_dark')} + {t('customizer_browser_theme_dark')}
  • - {t('customizer_browser_theme_system')} + {t('customizer_browser_theme_system')}
  • ); diff --git a/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.module.css b/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.module.css index d20cecd96f..6d6fa84fe0 100644 --- a/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.module.css +++ b/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.module.css @@ -5,9 +5,15 @@ --chip-size-half: calc(var(--chip-size) / 2); } .themeItem { - display: grid; - justify-items: center; - grid-row-gap: 4px; + width: 42px; + + span { + margin-top: 6px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + display: block; + } } .themeButton { display: block; diff --git a/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js b/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js index 06e7272660..af028ea753 100644 --- a/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js +++ b/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js @@ -1,12 +1,53 @@ import { h } from 'preact'; import { noop } from '../../utils.js'; -import { CustomizerButton } from './Customizer.js'; -import { EmbeddedVisibilityMenu, VisibilityMenu } from './VisibilityMenu.js'; +import { CustomizerButton } from './CustomizerMenu.js'; +import { EmbeddedVisibilityMenu } from './VisibilityMenu.js'; import { BackgroundSection } from './BackgroundSection.js'; import { ColorSelection } from './ColorSelection.js'; import { GradientSelection } from './GradientSelection.js'; import { useSignal } from '@preact/signals'; import { ImageSelection } from './ImageSelection.js'; +import { ArrowIndentCenteredIcon, DuckFoot, SearchIcon, Shield } from '../../components/Icons.js'; + +/** @type {import('./CustomizerMenu.js').VisibilityRowData[]} */ +const ROWS = [ + { + id: 'omnibar', + title: 'Search', + icon: , + toggle: noop('toggle search'), + visibility: 'visible', + index: 1, + enabled: true, + }, + { + id: 'omnibar-toggleAi', + title: 'Duck.ai', + icon: , + toggle: noop('toggle Duck.ai'), + visibility: 'visible', + index: 1.1, + enabled: true, + }, + { + id: 'favorites', + title: 'Favorites', + icon: , + toggle: noop('toggle favorites'), + visibility: 'hidden', + index: 0, + enabled: true, + }, + { + id: 'privacyStats', + title: 'Privacy Stats', + icon: , + toggle: noop('toggle favorites'), + visibility: 'visible', + index: 1, + enabled: true, + }, +]; /** @type {Record import("preact").ComponentChild}>} */ export const customizerExamples = { @@ -55,6 +96,7 @@ export const customizerExamples = { back={noop('back')} onUpload={noop('onUpload')} deleteImage={noop('deleteImage')} + customizerContextMenu={noop('customizerContextMenu')} /> ); }} @@ -65,49 +107,32 @@ export const customizerExamples = { 'customizer-menu': { factory: () => ( - +
    - +
    + +
    +
    + ), + }, + 'customizer-menu-disabled-item': { + factory: () => ( + +
    { + if (row.id === 'omnibar') { + /** @type {import('./CustomizerMenu.js').VisibilityRowData}} */ + return { ...row, visibility: 'hidden' }; + } + if (row.id === 'omnibar-toggleAi') { + /** @type {import('./CustomizerMenu.js').VisibilityRowData}} */ + return { ...row, enabled: false, visibility: 'hidden' }; + } + return row; + })} />
    diff --git a/special-pages/pages/new-tab/app/customizer/components/Customizer.js b/special-pages/pages/new-tab/app/customizer/components/Customizer.js deleted file mode 100644 index 42626b7e8a..0000000000 --- a/special-pages/pages/new-tab/app/customizer/components/Customizer.js +++ /dev/null @@ -1,220 +0,0 @@ -import { h } from 'preact'; -import { useEffect, useRef, useState, useCallback, useId } from 'preact/hooks'; -import styles from './Customizer.module.css'; -import { CustomizeIcon } from '../../components/Icons.js'; -import cn from 'classnames'; -import { useMessaging, useTypedTranslation } from '../../types.js'; -import { VisibilityMenu, VisibilityMenuPopover } from './VisibilityMenu.js'; - -/** - * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem } from '../../../types/new-tab.js' - */ - -/** - * Represents the NTP customizer. For now it's just the ability to toggle sections. - */ -export function Customizer() { - const { setIsOpen, buttonRef, dropdownRef, isOpen } = useDropdown(); - const [rowData, setRowData] = useState(/** @type {VisibilityRowData[]} */ ([])); - - /** - * Dispatch an event every time the customizer is opened - this - * allows widgets to register themselves and provide titles/icons etc. - */ - const toggleMenu = useCallback(() => { - if (isOpen) return setIsOpen(false); - setRowData(getItems()); - setIsOpen(true); - }, [isOpen]); - - useEffect(() => { - if (!isOpen) return; - function handler() { - setRowData(getItems()); - } - window.addEventListener(Customizer.UPDATE_EVENT, handler); - return () => { - window.removeEventListener(Customizer.UPDATE_EVENT, handler); - }; - }, [isOpen]); - - const MENU_ID = useId(); - const BUTTON_ID = useId(); - - return ( -
    - -
    - - - -
    -
    - ); -} - -Customizer.OPEN_EVENT = 'ntp-customizer-open'; -Customizer.UPDATE_EVENT = 'ntp-customizer-update'; - -export function getItems() { - /** @type {VisibilityRowData[]} */ - const next = []; - const detail = { - register: (/** @type {VisibilityRowData} */ incoming) => { - next.push(incoming); - }, - }; - const event = new CustomEvent(Customizer.OPEN_EVENT, { detail }); - window.dispatchEvent(event); - next.sort((a, b) => a.index - b.index); - return next; -} - -/** - * Forward the contextmenu event - */ -export function useContextMenu() { - const messaging = useMessaging(); - useEffect(() => { - function handler(e) { - e.preventDefault(); - e.stopImmediatePropagation(); - const items = getItems(); - /** @type {VisibilityMenuItem[]} */ - const simplified = items - .filter((x) => x.id !== 'debug') - .map((item) => { - return { - id: item.id, - title: item.title, - }; - }); - messaging.contextMenu({ visibilityMenuItems: simplified }); - } - document.body.addEventListener('contextmenu', handler); - return () => { - document.body.removeEventListener('contextmenu', handler); - }; - }, [messaging]); -} - -/** - * @param {object} props - * @param {string} [props.menuId] - * @param {string} [props.buttonId] - * @param {boolean} props.isOpen - * @param {() => void} [props.toggleMenu] - * @param {import("preact").Ref} [props.buttonRef] - */ -export function CustomizerButton({ menuId, buttonId, isOpen, toggleMenu, buttonRef }) { - const { t } = useTypedTranslation(); - return ( - - ); -} - -export function CustomizerMenuPositionedFixed({ children }) { - return
    {children}
    ; -} - -function useDropdown() { - /** @type {import("preact").Ref} */ - const dropdownRef = useRef(null); - /** @type {import("preact").Ref} */ - const buttonRef = useRef(null); - - const [isOpen, setIsOpen] = useState(false); - - /** - * Event handlers when it's open - */ - useEffect(() => { - if (!isOpen) return; - const handleFocusOutside = (event) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target) && !buttonRef.current?.contains(event.target)) { - setIsOpen(false); - } - }; - const handleClickOutside = (event) => { - if (dropdownRef.current && !dropdownRef.current.contains?.(event.target)) { - setIsOpen(false); - } - }; - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - setIsOpen(false); - buttonRef.current?.focus?.(); - } - }; - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleKeyDown); - document.addEventListener('focusin', handleFocusOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('focusin', handleFocusOutside); - }; - }, [isOpen]); - - return { dropdownRef, buttonRef, isOpen, setIsOpen }; -} - -export class VisibilityRowState { - checked; - /** - * @param {object} params - * @param {boolean} params.checked - whether this item should appear 'checked' - */ - constructor({ checked }) { - this.checked = checked; - } -} - -export class VisibilityRowData { - /** - * @param {object} params - * @param {string} params.id - a unique id - * @param {string} params.title - the title as it should appear in the menu - * @param {'shield' | 'star'} params.icon - known icon name, maps to an SVG - * @param {(id: string) => void} params.toggle - toggle function for this item - * @param {number} params.index - position in the menu - * @param {WidgetVisibility} params.visibility - known icon name, maps to an SVG - */ - constructor({ id, title, icon, toggle, visibility, index }) { - this.id = id; - this.title = title; - this.icon = icon; - this.toggle = toggle; - this.index = index; - this.visibility = visibility; - } -} - -/** - * Call this to opt-in to the visibility menu - * @param {VisibilityRowData} row - */ -export function useCustomizer({ title, id, icon, toggle, visibility, index }) { - useEffect(() => { - const handler = (/** @type {CustomEvent} */ e) => { - e.detail.register({ title, id, icon, toggle, visibility, index }); - }; - window.addEventListener(Customizer.OPEN_EVENT, handler); - return () => window.removeEventListener(Customizer.OPEN_EVENT, handler); - }, [title, id, icon, toggle, visibility, index]); - - useEffect(() => { - window.dispatchEvent(new Event(Customizer.UPDATE_EVENT)); - }, [visibility]); -} diff --git a/special-pages/pages/new-tab/app/customizer/components/Customizer.module.css b/special-pages/pages/new-tab/app/customizer/components/Customizer.module.css index e4f64cfc34..eb45e1ce03 100644 --- a/special-pages/pages/new-tab/app/customizer/components/Customizer.module.css +++ b/special-pages/pages/new-tab/app/customizer/components/Customizer.module.css @@ -1,26 +1,12 @@ -.root { - position: relative -} - .lowerRightFixed { - position: fixed; + position: absolute; bottom: 1rem; right: 1rem; } -.dropdownMenu { - display: none; - position: absolute; - right: 0; - bottom: calc(100% + 10px); -} - -.show { - display: block; -} - /** todo: is this a re-usable button, yet? */ .customizeButton { + backdrop-filter: blur(48px); background-color: var(--ntp-surface-background-color); border: 1px solid var(--ntp-surface-border-color); border-radius: var(--border-radius-sm); @@ -56,6 +42,16 @@ border-color: var(--color-white-at-50); } } + + &[data-kind="drawer"][aria-expanded="true"] { + visibility: hidden; + } + + @media screen and (max-width: 800px) { + span { + display: none; + } + } } diff --git a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawer.js b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawer.js index 7c27b2ad9b..d645feed8a 100644 --- a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawer.js +++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawer.js @@ -13,6 +13,15 @@ export function CustomizerDrawer({ displayChildren }) { } function CustomizerConsumer() { - const { data, select, upload, setTheme, deleteImage } = useContext(CustomizerContext); - return ; + const { data, select, upload, setTheme, deleteImage, customizerContextMenu } = useContext(CustomizerContext); + return ( + + ); } diff --git a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js index 4bdebf3af9..9ede2ad7fe 100644 --- a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js +++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js @@ -13,10 +13,12 @@ import { BorderedSection, CustomizerSection } from './CustomizerSection.js'; import { SettingsLink } from './SettingsLink.js'; import { DismissButton } from '../../components/DismissButton.jsx'; import { InlineErrorBoundary } from '../../InlineErrorBoundary.js'; -import { useTypedTranslationWith } from '../../types.js'; +import { useMessaging, useTypedTranslationWith } from '../../types.js'; +import { Open } from '../../components/icons/Open.js'; /** - * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData, BackgroundData } from '../../../types/new-tab.js' + * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData, BackgroundData, UserImageContextMenu } from '../../../types/new-tab.js' + * @import { SettingsLinkData } from '../CustomizerProvider'; * @import enStrings from '../strings.json'; */ @@ -27,10 +29,12 @@ import { useTypedTranslationWith } from '../../types.js'; * @param {() => void} props.onUpload * @param {(theme: import('../../../types/new-tab').ThemeData) => void} props.setTheme * @param {(id: string) => void} props.deleteImage + * @param {(p: UserImageContextMenu) => void} props.customizerContextMenu */ -export function CustomizerDrawerInner({ data, select, onUpload, setTheme, deleteImage }) { +export function CustomizerDrawerInner({ data, select, onUpload, setTheme, deleteImage, customizerContextMenu }) { const { close } = useDrawerControls(); const { t } = useTypedTranslationWith(/** @type {enStrings} */ ({})); + const messaging = useMessaging(); return (
    @@ -64,7 +68,11 @@ export function CustomizerDrawerInner({ data, select, onUpload, setTheme, delete - + } + onClick={() => messaging.open({ target: 'settings' })} + />
    )} @@ -73,7 +81,14 @@ export function CustomizerDrawerInner({ data, select, onUpload, setTheme, delete {id === 'color' && } {id === 'gradient' && } {id === 'image' && ( - + )} )} diff --git a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css index e8e7135aef..1467f541b8 100644 --- a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css +++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css @@ -18,6 +18,11 @@ display: flex; justify-content: space-between; align-items: center; + gap: 6px; + + > * { + overflow: hidden; + } } .internal { @@ -30,6 +35,7 @@ width: 24px; height: 24px; position: relative; + flex-shrink: 0; color: var(--color-black-at-84); background-color: var(--color-black-at-9); @@ -63,7 +69,7 @@ opacity: .8; } &:focus-visible { - outline: 1px solid var(--ntp-focus-outline-color) + box-shadow: var(--focus-ring); } } .section { @@ -91,7 +97,6 @@ .bgListItem { display: grid; grid-row-gap: 4px; - white-space: nowrap; position: relative; &:hover { @@ -171,12 +176,10 @@ @keyframes fade-in { 0% { - opacity: 0; visibility: hidden; } 100% { - opacity: 1; visibility: visible; } } @@ -252,6 +255,7 @@ align-items: center; text-decoration: none; color: var(--ntp-color-primary); + margin-bottom: var(--sp-3); &:focus { outline: none; @@ -259,4 +263,4 @@ &:focus-visible { text-decoration: underline; } -} \ No newline at end of file +} diff --git a/special-pages/pages/new-tab/app/customizer/components/CustomizerMenu.js b/special-pages/pages/new-tab/app/customizer/components/CustomizerMenu.js new file mode 100644 index 0000000000..9f4d618081 --- /dev/null +++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerMenu.js @@ -0,0 +1,119 @@ +import { h } from 'preact'; +import { useEffect } from 'preact/hooks'; +import styles from './Customizer.module.css'; +import { CustomizeIcon } from '../../components/Icons.js'; +import { useMessaging, useTypedTranslation } from '../../types.js'; + +/** + * @import { WidgetVisibility, VisibilityMenuItem } from '../../../types/new-tab.js' + */ + +/** + * @typedef {object} VisibilityRowData + * @property {string} id - a unique id + * @property {boolean} enabled - whether this row can be interacted with + * @property {string} title - the title as it should appear in the menu + * @property {import('preact').ComponentChild} icon - icon to display in the menu + * @property {(id: string) => void} toggle - toggle function for this item + * @property {number} index - position in the menu + * @property {WidgetVisibility} visibility - known icon name, maps to an SVG + */ + +export const OPEN_EVENT = 'ntp-customizer-open'; +export const UPDATE_EVENT = 'ntp-customizer-update'; + +export function getItems() { + /** @type {VisibilityRowData[]} */ + const next = []; + const detail = { + register: (/** @type {VisibilityRowData} */ incoming) => { + next.push(incoming); + }, + }; + const event = new CustomEvent(OPEN_EVENT, { detail }); + window.dispatchEvent(event); + next.sort((a, b) => a.index - b.index); + return next; +} + +/** + * Forward the contextmenu event + */ +export function useContextMenu() { + const messaging = useMessaging(); + useEffect(() => { + function handler(e) { + e.preventDefault(); + e.stopImmediatePropagation(); + const items = getItems(); + /** @type {VisibilityMenuItem[]} */ + const simplified = items + .filter((x) => !x.id.startsWith('_')) + .map((item) => { + return { + id: item.id, + title: item.title, + }; + }); + messaging.contextMenu({ visibilityMenuItems: simplified }); + } + document.body.addEventListener('contextmenu', handler); + return () => { + document.body.removeEventListener('contextmenu', handler); + }; + }, [messaging]); +} + +/** + * @param {object} props + * @param {string} [props.menuId] + * @param {string} [props.buttonId] + * @param {import("@preact/signals").Signal|boolean} props.isOpen + * @param {() => void} [props.toggleMenu] + * @param {import("preact").Ref} [props.buttonRef] + * @param {"menu" | "drawer"} props.kind + */ +export function CustomizerButton({ menuId, buttonId, isOpen, toggleMenu, buttonRef, kind }) { + const { t } = useTypedTranslation(); + return ( + + ); +} + +export function CustomizerMenuPositionedFixed({ children }) { + return
    {children}
    ; +} + +/** + * Call this to opt-in to the visibility menu + * @param {VisibilityRowData} row + */ +export function useCustomizer({ title, id, icon, toggle, visibility, index, enabled }) { + useEffect(() => { + const handler = (/** @type {CustomEvent<{register: (d: VisibilityRowData) => void}>} */ e) => { + e.detail.register({ title, id, icon, toggle, visibility, index, enabled }); + }; + window.addEventListener(OPEN_EVENT, handler); + return () => window.removeEventListener(OPEN_EVENT, handler); + }, [title, id, icon, toggle, visibility, index, enabled]); + + useEffect(() => { + window.dispatchEvent(new Event(UPDATE_EVENT)); + return () => { + window.dispatchEvent(new Event(UPDATE_EVENT)); + }; + }, [visibility]); +} diff --git a/special-pages/pages/new-tab/app/customizer/components/ImageSelection.js b/special-pages/pages/new-tab/app/customizer/components/ImageSelection.js index 90cf7c473d..0400da5d08 100644 --- a/special-pages/pages/new-tab/app/customizer/components/ImageSelection.js +++ b/special-pages/pages/new-tab/app/customizer/components/ImageSelection.js @@ -12,7 +12,7 @@ import { useTypedTranslationWith } from '../../types.js'; /** * @import enStrings from '../strings.json'; - * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData, BackgroundData, PredefinedGradient } from '../../../types/new-tab.js' + * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData, BackgroundData, PredefinedGradient, UserImageContextMenu } from '../../../types/new-tab.js' */ /** @@ -22,8 +22,9 @@ import { useTypedTranslationWith } from '../../types.js'; * @param {() => void} props.back * @param {() => void} props.onUpload * @param {(id: string) => void} props.deleteImage + * @param {(p: UserImageContextMenu) => void} props.customizerContextMenu */ -export function ImageSelection({ data, select, back, onUpload, deleteImage }) { +export function ImageSelection({ data, select, back, onUpload, deleteImage, customizerContextMenu }) { const { t } = useTypedTranslationWith(/** @type {enStrings} */ ({})); function onClick(event) { let target = /** @type {HTMLElement|null} */ (event.target); @@ -38,8 +39,19 @@ export function ImageSelection({ data, select, back, onUpload, deleteImage }) { select({ background: { kind: 'userImage', value: match } }); } + function onContextMenu(event) { + const target = /** @type {HTMLElement|null} */ (event.target); + if (!(target instanceof HTMLElement)) return; + const id = target.closest('button')?.dataset.id; + if (typeof id === 'string') { + event.preventDefault(); + event.stopImmediatePropagation(); + customizerContextMenu({ id, target: 'userImage' }); + } + } + return ( -
    +