diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index ebbd3aeeb..4c32f7309 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -21,4 +21,4 @@ Provide steps or a code snippet that reproduces the bug. ### Version: -Run `python -m camoufox version` in your terminal and paste the output here. \ No newline at end of file +Run `python -m camoufox version` (or `npx camoufox version` for the npm package) and paste the output here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/camoufox-detected.md b/.github/ISSUE_TEMPLATE/camoufox-detected.md index 346da578f..cd4f8f033 100644 --- a/.github/ISSUE_TEMPLATE/camoufox-detected.md +++ b/.github/ISSUE_TEMPLATE/camoufox-detected.md @@ -41,4 +41,4 @@ These questions will help me diagnose the issue: ### Version: -Run `python -m camoufox version` in your terminal and paste the output here. \ No newline at end of file +Run `python -m camoufox version` (or `npx camoufox version` for the npm package) and paste the output here. \ No newline at end of file diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 000000000..b5c8ea356 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,87 @@ +name: Publish to npm + +# The npm twin of publish-pypi.yml: dispatched by hand, checks, builds, verifies +# the tarball, publishes. The package version is typescript/package.json's and +# must equal pythonlib's -- scripts/check-pack.mjs refuses to go on otherwise. +# +# Authentication is npm trusted publishing (OIDC): no token is stored anywhere. +# npm accepts this workflow's identity because the package's settings on +# npmjs.com name this repository and this file (publish-npm.yml). Renaming the +# file, or publishing from a fork, is rejected by the registry. The first +# version of a new package has to be published by hand, since the trusted +# publisher is configured on a package that already exists. + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Run every check and `npm publish --dry-run`, upload nothing" + type: boolean + default: false + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # trusted publishing, and the provenance attestation + defaults: + run: + working-directory: typescript + env: + CAMOUFOX_FPGEN_DATA: ${{ github.workspace }}/.fpgen + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install pythonlib + # tests/golden-setup.ts records the golden fixtures from it, and the + # build copies its data files into the package. + working-directory: . + run: | + python3 -m venv .venv + .venv/bin/pip install -r ci/requirements.txt -e pythonlib + .venv/bin/python scripts/pin-fpgen-model.py + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: typescript/package.json + + - name: Set up Node + uses: actions/setup-node@v6 + with: + # Trusted publishing needs npm >= 11.5.1, which Node 24 ships. + node-version: "24" + registry-url: https://registry.npmjs.org + cache: pnpm + cache-dependency-path: typescript/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Type check and lint + run: | + pnpm typecheck + pnpm check + + - name: Test + run: pnpm test + + - name: Build package + run: pnpm build + + - name: Check package + run: node scripts/check-pack.mjs + + - name: Publish to npm + run: npm publish --access public ${{ inputs.dry_run && '--dry-run' || '' }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3b9c2f9b1..a53fabe6f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -150,22 +150,48 @@ jobs: # Only a change that can alter the binary justifies compiling one. A # pull request that touches pythonlib/ or ci/ is a driver change: it # still gets the full browser suite, but against the published build its - # users are running, which takes a minute instead of seventy. + # users are running, which takes a minute instead of seventy -- as long + # as that build matches this tree's browser sources (see below). run: | if [ "${{ github.event_name }}" != "pull_request" ]; then echo "browser_changed=true" >> "$GITHUB_OUTPUT" echo "::notice::Not a pull request -- building, which also refreshes the shared ccache." exit 0 fi + sources='^(patches/|additions/|settings/|assets/|upstream\.sh|Makefile|scripts/)' base="${{ github.event.pull_request.base.sha }}" changed=$(git diff --name-only "$base"...HEAD || echo "") echo "changed files:"; echo "$changed" | sed 's/^/ /' - if echo "$changed" | grep -qE '^(patches/|additions/|settings/|assets/|upstream\.sh|Makefile|scripts/)'; then + if echo "$changed" | grep -qE "$sources"; then echo "browser_changed=true" >> "$GITHUB_OUTPUT" echo "::notice::Browser sources changed -- rebuilding from source." + exit 0 + fi + # The pull request leaves the browser alone -- but the published + # release is only the right browser to test it on if it was built from + # the SAME browser sources as this tree. The patch guards and suites + # come from this checkout, so when the base branch has moved past the + # release (a merged browser change that is not published yet), testing + # the release pairs new guards with an old browser, and every guard for + # the unreleased change fails on a pull request that never touched it. + # Compare against the tag the release was cut from; if they differ, + # build -- which restores the base branch's cached browser when its + # compiled half matches, so it costs minutes, not the full build. + . ./upstream.sh + tag="v${version}-${release}" + if ! git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo "browser_changed=true" >> "$GITHUB_OUTPUT" + echo "::notice::No release tag $tag -- the release this tree targets is not published; building." + exit 0 + fi + ahead=$(git diff --name-only "$tag" HEAD | grep -E "$sources" || true) + if [ -n "$ahead" ]; then + echo "browser sources that differ from $tag:"; echo "$ahead" | sed 's/^/ /' + echo "browser_changed=true" >> "$GITHUB_OUTPUT" + echo "::notice::The base branch's browser sources are ahead of the published $tag ($(echo "$ahead" | wc -l) files) -- building (a cache hit when the base branch already built it)." else echo "browser_changed=false" >> "$GITHUB_OUTPUT" - echo "::notice::No browser sources changed -- testing against the published release." + echo "::notice::No browser sources differ from the published $tag -- testing against it." fi - name: May the stealth check run? @@ -307,6 +333,73 @@ jobs: include-hidden-files: true if-no-files-found: warn + # --------------------------------------------------------------------------- + typescript: + name: typescript + # Tier 1, beside pythonlib. The npm package's type check, lint and vitest + # suite, including the golden tests that hold it to pythonlib's output byte + # for byte -- so a pythonlib change that typescript/ does not mirror fails + # here, in a minute, not after the build. + needs: [resolve, static] + runs-on: ubuntu-24.04 + permissions: + contents: read + env: + # The fpgen model the TS port downloads (sha256-pinned by + # scripts/data/fpgen-model.json). Kept in the workspace so it can be cached. + CAMOUFOX_FPGEN_DATA: ${{ github.workspace }}/.ci-work/fpgen + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: actions/setup-python@v5 + with: + # Not PYTHON_VERSION: the golden fixtures are regenerated from this + # interpreter, and pycompat.ts's pySum() reproduces sum() as 3.14 + # computes it (3.12/3.13 round mixed int/float sums differently in + # the last bit). + python-version: "3.14" + - run: | + # A venv at the repo root: tests/golden-setup.ts records the golden + # fixtures from its pythonlib before the suite runs. + python3 -m venv .venv + .venv/bin/pip install -r ci/requirements.txt -e pythonlib + # fpgen downloads its model on first import with TLS verification + # OFF and no checksum, and its release picker can only ever reach the + # April-2025 model. Install the pinned one first: see + # scripts/pin-fpgen-model.py. + .venv/bin/python scripts/pin-fpgen-model.py + - name: Test prerequisites + # Everything tests/prereq.ts may ask for. In CI a missing prerequisite + # FAILS its tests rather than skipping them, so this list is the job's + # contract. xvfb: the virtual-display lifecycle. + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends xvfb + - uses: pnpm/action-setup@v4 + with: + package_json_file: typescript/package.json + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: typescript/pnpm-lock.yaml + - uses: actions/cache@v4 + with: + path: .ci-work/fpgen + key: fpgen-model-${{ hashFiles('scripts/data/fpgen-model.json') }} + - name: Run + env: + GITHUB_TOKEN: ${{ github.token }} + run: .venv/bin/python -m ci.run_typescript + - uses: actions/upload-artifact@v4 + if: always() + with: + name: results-typescript + path: .ci-work/results/ + include-hidden-files: true + if-no-files-found: warn + # --------------------------------------------------------------------------- build: name: Build (linux x86_64) @@ -595,7 +688,11 @@ jobs: run: | set -euo pipefail python -m camoufox fetch - install_dir="$(python -m camoufox path)" + # The ACTIVE build's directory, resolved the way the launcher resolves + # it. `camoufox path` prints the cache root, and multiversion installs + # each build under browsers/// -- so reading the + # binary from the root failed every driver-only run since #772. + install_dir="$(python -c 'from camoufox.pkgman import camoufox_path; print(camoufox_path(download_if_missing=False))')" echo "install dir: $install_dir" # Which browser did we actually get? This path does not build, it # downloads the current release -- correct for a driver change, since @@ -833,6 +930,67 @@ jobs: include-hidden-files: true if-no-files-found: ignore + # --------------------------------------------------------------------------- + typescript-browser: + name: typescript (browser) + # Tier 3a. Launches the browser under test through the TS API -- headless, + # persistent context, and launchServer -- and checks that a page sees the + # same identity pythonlib's launch of it shows. + needs: [resolve, build, fetch-browser, typescript] + if: >- + always() && needs.typescript.result == 'success' && + (needs.build.result == 'success' || needs.fetch-browser.result == 'success') + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + env: + CAMOUFOX_FPGEN_DATA: ${{ github.workspace }}/.ci-work/fpgen + # The browser under test is the build job's unpackaged dist/bin, which + # packages en-US only: scripts/package.py adds the langpacks, and CI never + # runs it. A de-DE/fr-FR identity therefore presents en-US here though a + # packaged release presents de-DE. The e2e locale assertions skip on that + # (named) gap; everything else in the page-vs-config checks still runs. + # Remove this once the test artifact carries the langpacks. + CAMOUFOX_TEST_ALLOW_MISSING: packaged-locales + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: ./.github/actions/prepare-browser + with: + python-version: ${{ env.PYTHON_VERSION }} + - run: | + pip install -e pythonlib + # fpgen downloads its model on first import with TLS verification + # OFF and no checksum, and its release picker can only ever reach the + # April-2025 model. Install the pinned one first: see + # scripts/pin-fpgen-model.py. + python3 scripts/pin-fpgen-model.py + - uses: pnpm/action-setup@v4 + with: + package_json_file: typescript/package.json + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: typescript/pnpm-lock.yaml + - uses: actions/cache@v4 + with: + path: .ci-work/fpgen + key: fpgen-model-${{ hashFiles('scripts/data/fpgen-model.json') }} + - name: Run + env: + GITHUB_TOKEN: ${{ github.token }} + run: xvfb-run -a python3 -m ci.run_typescript --browser "$CAMOUFOX_BINARY" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: results-typescript-browser + path: .ci-work/results/ + include-hidden-files: true + if-no-files-found: warn + # --------------------------------------------------------------------------- growth: name: Memory growth (scheduled) @@ -927,8 +1085,8 @@ jobs: # --------------------------------------------------------------------------- summary: name: Summary - needs: [resolve, static, pythonlib, build, fetch-browser, playwright, - patch-guards, build-tester, native, sundial] + needs: [resolve, static, pythonlib, typescript, build, fetch-browser, playwright, + patch-guards, build-tester, native, typescript-browser, sundial] if: always() && needs.resolve.result == 'success' runs-on: ubuntu-24.04 permissions: @@ -968,7 +1126,7 @@ jobs: # summarize report "produced no result file" and fail the gate on # every pull request that did not touch the browser. Which is most of # them, and exactly the cheap path this pipeline advertises. - required="pythonlib native_rules patch_guards skiplist_audit build_tester playwright native_browser" + required="pythonlib typescript typescript_browser native_rules patch_guards skiplist_audit build_tester playwright native_browser" if [ "${{ needs.resolve.outputs.browser_changed }}" = "true" ]; then required="$required build" fi @@ -1033,8 +1191,8 @@ jobs: # that is not `success`, including `skipped`, fails the gate: a suite that # did not run has not passed, and silently skipping one is the cheapest way # to a green tick. - needs: [resolve, static, pythonlib, build, fetch-browser, playwright, - patch-guards, build-tester, native, sundial, summary] + needs: [resolve, static, pythonlib, typescript, build, fetch-browser, playwright, + patch-guards, build-tester, native, typescript-browser, sundial, summary] if: always() runs-on: ubuntu-24.04 permissions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5ead3133..20f9c5c61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ The engineering rules in [`AGENTS.md`](AGENTS.md) apply to every change, whether ## Testing Requirements -**CI runs everything, on every pull request.** [`.github/workflows/tests.yml`](.github/workflows/tests.yml) builds the browser from your branch when you touch browser sources (and tests against the published release when you do not), then runs the Python package tests, the patch guards, build-tester, the upstream Playwright suite, the leak suite and the stealth check. Branch protection requires exactly one check, **`All tests passed`**, which is green only when every applicable suite is. +**CI runs everything, on every pull request.** [`.github/workflows/tests.yml`](.github/workflows/tests.yml) builds the browser from your branch when you touch browser sources (and tests against the published release when you do not), then runs the Python and TypeScript package tests, the patch guards, build-tester, the upstream Playwright suite, the leak suite and the stealth check. Branch protection requires exactly one check, **`All tests passed`**, which is green only when every applicable suite is. So there is nothing to attach to the pull request by hand. The old process โ€” run the suites locally, screenshot the output, paste it in โ€” was unenforceable: nothing checked that the browser in the screenshot was built from the branch under review. If you want a report in the description anyway, CI leaves one as a comment on the pull request. @@ -50,6 +50,7 @@ python3 -m ci.run_build_tester --binary /path/to/camoufox-bin python3 -m ci.run_playwright --binary /path/to/camoufox-bin # or --shard 3/6 python3 -m ci.run_skiplist_audit --binary /path/to/camoufox-bin python3 -m ci.run_pythonlib # pythonlib/ +python3 -m ci.run_typescript # typescript/ python3 -m pytest ci/tests -q # the pipeline's own tests ``` diff --git a/README.md b/README.md index 080ee0df1..f73c2c3b5 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Processing tens of billions of requests per month for thousands of customers, Byteful powers browser-based AI agents, automation systems, and data workflows. It is a member of the Internet Watch Foundation and the Ethical Web Data Collection Initiative. -Get 10% off Byteful Residential Proxies with the code: CAMOUFOX10 +Get 10% off Byteful Residential Bandwidth with the code: CAMOUFOX10 @@ -338,7 +338,7 @@ Camoufox is a Firefox fork engineered for web scraping and AI agents. It is head * **Built for AI agents** ๐Ÿค– * Minimal, debloated Firefox - fast to launch, cheap to run - * Drop-in Playwright compatibility via Python interface + * Drop-in Playwright compatibility from Python and JavaScript/TypeScript * Invisible to anti-bot systems so you can run your agent cluster locally or in the cloud without being flagged - **Undetectable by design** ๐ŸŽญ @@ -362,7 +362,7 @@ Camoufox is a Firefox fork engineered for web scraping and AI agents. It is head * Optional instant animations (`instantAnimations`), so Playwright never waits on one ๐Ÿ’จ - Debloated & optimized for memory efficiency โšก -- [PyPI package](https://pypi.org/project/camoufox/) for updates & auto fingerprint injection ๐Ÿ“ฆ +- [PyPI](https://pypi.org/project/camoufox/) and npm packages for updates & auto fingerprint injection ๐Ÿ“ฆ - Stays up to date with the latest Firefox version ๐Ÿ•“ --- @@ -371,7 +371,7 @@ Camoufox is a Firefox fork engineered for web scraping and AI agents. It is head In Camoufox, data is intercepted at the C++ implementation level, making the changes undetectable through JavaScript inspection. -To spoof individual fingerprint properties, pass a JSON containing properties to spoof to the [Python interface](pythonlib/): +To spoof individual fingerprint properties, pass a JSON containing properties to spoof to the [Python](pythonlib/) or [TypeScript](typescript/) interface: ```py >>> with Camoufox(config={"property": "value"}) as browser: @@ -383,11 +383,11 @@ Config data not set by the user is populated from [fpgen](https://github.com/scr --- -## Python Usage +## Usage Camoufox is compatible with your existing Playwright code. You only have to change your browser initialization. -**Sync API** +**Python, sync API** ```python from camoufox.sync_api import Camoufox @@ -397,7 +397,7 @@ with Camoufox() as browser: page.goto("https://example.com") ``` -**Async API** +**Python, async API** ```python from camoufox.async_api import AsyncCamoufox @@ -407,7 +407,18 @@ async with AsyncCamoufox() as browser: await page.goto("https://example.com") ``` -[[Installation & usage](https://camoufox.com/python/)] +**JavaScript / TypeScript** + +```javascript +import { Camoufox } from "@camoufox/camoufox"; + +const browser = await Camoufox({ headless: true }); +const page = await browser.newPage(); +await page.goto("https://example.com"); +await browser.close(); +``` + +[[Python installation & usage](https://camoufox.com/python/)] ยท [[TypeScript package](typescript/README.md)] --- @@ -472,7 +483,7 @@ Below is a list of patches and features implemented in Camoufox. - Addons are automatically pinned to the toolbar - Fixes DNS leaks with uBO prefetching -### Python Interface +### Python & TypeScript Interfaces - Automatically generates & injects unique device characteristics into Camoufox based on their real-world distribution - WebGL fingerprint injection & rotation @@ -568,7 +579,7 @@ Additionally, Juggler sends its inputs directly through the Firefox's original u

Build System

> [!WARNING] -> The content below is intended for those interested in building & debugging Camoufox. For usage instructions, see [pythonlib](pythonlib/). +> The content below is intended for those interested in building & debugging Camoufox. For usage instructions, see [pythonlib](pythonlib/) or [typescript](typescript/). ### Overview @@ -793,7 +804,7 @@ flowchart TD ## Licensing - **The browser** (`patches/`, `additions/`, `settings/`, and the build system) is [MPL-2.0](LICENSE), the licence of the Firefox source it modifies. The vendored Cursory trajectories are LGPLv3-or-later (`additions/juggler/input/cursory/NOTICE`). -- **The Python launcher** is MIT ([`pythonlib/LICENSE`](pythonlib/LICENSE)), as it has always been declared on PyPI. +- **The launchers** are MIT: the Python package ([`pythonlib/LICENSE`](pythonlib/LICENSE)) and the TypeScript package ([`typescript/LICENSE`](typescript/LICENSE)). The TypeScript package also contains ports of fpgen, CPython's `random` and NumPy's random generators; their notices are in [`typescript/THIRD_PARTY_NOTICES.md`](typescript/THIRD_PARTY_NOTICES.md). --- diff --git a/ci/README.md b/ci/README.md index 269de0d0c..1d4f17bb7 100644 --- a/ci/README.md +++ b/ci/README.md @@ -7,9 +7,11 @@ pass", not two. ``` resolve โ”€โ”€ static โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ lint, tribal rules, skiplist, self-tests (seconds) + โ”œโ”€ typescript โ”€โ”€โ”€โ”€โ”€โ”€ type check, lint, vitest, golden parity โ””โ”€ pythonlib โ”€โ”€โ”€โ”€โ”€โ”€โ”€ the package's own tests (a minute) โ””โ”€ build or fetch โ”€โ”ฌโ”€ patch guards โ”€โ”€โ”€โ”€โ”€โ”€โ”€ one per spoofing patch, + skiplist audit โ”œโ”€ build-tester โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 8 fingerprint profiles + โ”œโ”€ typescript-browser โ”€ the npm launcher end to end โ””โ”€ once guards and build-tester pass: โ”œโ”€ playwright ร— 6 shards (conformance + our own) โ”œโ”€ native โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ leaks, contexts, crash recovery @@ -537,17 +539,23 @@ Each tier gates the next, so a two-second lint failure never reaches the build: ``` 0 static lint, self-tests, settled decisions seconds -1 unit pythonlib ~1 min +1 unit pythonlib, typescript ~1 min 2 browser build (patches/additions/settings/assets/upstream.sh/Makefile/scripts changed) - fetch (anything else -- driver changes test against the published release) -3a smoke patch guards, skiplist audit, build-tester ~15 min + fetch (anything else, when the published release has this tree's browser sources) +3a smoke patch guards, skiplist audit, build-tester, + typescript-browser ~15 min 3b full Playwright x6, leaks, stealth ~40 min 4 gate the required check ``` -**Driver-only pull requests never build.** There is nothing new to compile, so -`fetch-browser` downloads the published release and the browser suites run -against the build users are actually on โ€” a minute instead of seventy. +**Driver-only pull requests test the published release, when it matches.** +There is nothing new to compile, so `fetch-browser` downloads the published +release and the browser suites run against the build users are actually on โ€” a +minute instead of seventy. That is only right while the release was built from +this tree's browser sources: once a browser change has merged but not been +released, the guards in the checkout would judge an older browser. So the scope +step compares the browser sources against the release tag, and when they +differ it builds instead, which restores the base branch's cached browser. **Changing Juggler's JavaScript does not rebuild the browser.** Measured on a real build: ccache reported a **98.63%** hit rate, so almost none of those 24 @@ -622,6 +630,8 @@ way, so a tagged build gets the same hardening. python3 -m ci.run_prepare # make setup-minimal, dir, mozbootstrap python3 -m ci.run_build python3 -m ci.run_pythonlib # no browser needed +python3 -m ci.run_typescript # no browser needed +python3 -m ci.run_typescript --browser path/to/camoufox-bin python3 -m ci.run_patch_guards --binary path/to/camoufox-bin python3 -m ci.run_build_tester --binary path/to/camoufox-bin python3 -m ci.run_skiplist_audit --binary path/to/camoufox-bin diff --git a/ci/run_typescript.py b/ci/run_typescript.py new file mode 100644 index 000000000..8b7d0e999 --- /dev/null +++ b/ci/run_typescript.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""typescript gate: the npm package's own checks and test suite. + +Two modes, two gates: + + typescript (default) type check, lint, and the vitest suite. No + browser. Includes the golden tests that hold the TS + launcher to byte-for-byte parity with pythonlib, so a + pythonlib change that is not mirrored in typescript/ + fails here, in tier 1, rather than after the build. + typescript_browser (--browser BINARY) the opt-in end-to-end suite: launches + the browser under test through the TS API and compares + what a page sees with what pythonlib's launch of the same + identity shows. + +Run: + python3 -m ci.run_typescript + python3 -m ci.run_typescript --browser path/to/camoufox-bin +""" + +from __future__ import annotations + +import argparse +import os +import sys +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Dict, List, Optional + +from . import results as evidence +from ._util import EVIDENCE_DIR, REPO_ROOT, WORK_DIR, run + +TYPESCRIPT = REPO_ROOT / "typescript" + + +def parse_vitest_junit(path: Path) -> Dict[str, str]: + """vitest junit XML -> {"tests/file.test.ts::suite > test": outcome}. + + vitest puts the test file in `classname` and the describe path in `name`, + which already make a stable identity; pytest's dotted-module trimming in + ci/_pytest.py would mangle a `.test.ts` path. + """ + if not path.exists(): + return {} + outcomes: Dict[str, str] = {} + for case in ET.parse(path).getroot().iter("testcase"): + tid = f"{case.get('classname', '')}::{case.get('name', '')}" + if case.find("error") is not None: + outcome = evidence.ERROR + elif case.find("failure") is not None: + outcome = evidence.FAIL + elif case.find("skipped") is not None: + outcome = evidence.SKIP + else: + outcome = evidence.PASS + if outcomes.get(tid) == evidence.PASS: + continue + outcomes[tid] = outcome + return outcomes + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--evidence-dir", type=Path, default=EVIDENCE_DIR) + parser.add_argument("--browser", type=Path, help="camoufox-bin for the end-to-end suite") + parser.add_argument("--python", type=Path, default=Path(sys.executable), + help="interpreter with pythonlib installed: the golden fixtures are " + "recorded from it, and the e2e suite compares against it") + parser.add_argument("--timeout", type=int, default=1800) + args = parser.parse_args(argv) + + gate = "typescript_browser" if args.browser else "typescript" + result = evidence.GateResult(gate=gate) + if not (TYPESCRIPT / "package.json").is_file(): + result.note("typescript/package.json does not exist") + result.finish(evidence.ERROR).save(args.evidence_dir) + return 1 + + env = dict(os.environ) + # tests/golden-setup.ts records the golden fixtures from this interpreter. + env["CAMOUFOX_PYTHON"] = str(args.python.absolute()) + if args.browser: + env.update( + CAMOUFOX_E2E="1", + CAMOUFOX_EXECUTABLE=str(args.browser.resolve()), + CAMOUFOX_E2E_PYTHON=str(args.python.absolute()), + ) + + # Every test input must be something a checkout gets. A git-ignored file + # under src/, tests/ or scripts/ exists on the machine that made it and + # nowhere else, so the suite passes there and fails in CI -- which is how + # tests/fixtures/launch/ once went missing from a branch (an unanchored + # `launch` rule in the root .gitignore). + ignored = run( + ["git", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", + "--", "typescript/src", "typescript/tests", "typescript/scripts"], + cwd=REPO_ROOT, + ) + # The goldens tests/golden-setup.ts records are ignored on purpose, by + # typescript/tests/fixtures/.gitignore; anything another rule hides is stray. + candidates = [p for p in ignored.stdout.split() if "__pycache__" not in p] + rules = run(["git", "check-ignore", "--verbose", "--no-index", *candidates], cwd=REPO_ROOT) \ + if candidates else None + generated = { + line.split("\t", 1)[1] for line in (rules.stdout.splitlines() if rules else []) + if line.startswith("typescript/tests/fixtures/.gitignore:") + } + stray = [p for p in candidates if p not in generated] + for path in stray: + result.note(f"git-ignored test input: {path}") + result.record("no test input is git-ignored", evidence.FAIL if stray else evidence.PASS) + + install = run(["pnpm", "install", "--frozen-lockfile"], cwd=TYPESCRIPT, env=env, + timeout=600, tee=True, capture=False) + if not install.ok: + result.note(f"pnpm install exited {install.code}") + result.finish(evidence.ERROR).save(args.evidence_dir) + return 1 + + # Static checks are recorded as tests of their own, so the summary names + # which one failed instead of reporting a bare non-zero exit. + if not args.browser: + for script in ("typecheck", "check"): + proc = run(["pnpm", script], cwd=TYPESCRIPT, env=env, timeout=600, tee=True, capture=False) + result.record(f"pnpm {script}", evidence.PASS if proc.ok else evidence.FAIL) + + # The tarball a user would install: builds, ships every data file, installs + # and imports in an empty project, and its CLI starts. publish-npm.yml runs + # the same check before uploading; running it here means a packaging mistake + # is caught on the pull request that makes it, not on release day. + if not args.browser: + build = run(["pnpm", "build"], cwd=TYPESCRIPT, env=env, timeout=600, tee=True, capture=False) + pack = build.ok and run(["node", "scripts/check-pack.mjs"], cwd=TYPESCRIPT, env=env, + timeout=900, tee=True, capture=False).ok + result.record("npm package (scripts/check-pack.mjs)", evidence.PASS if pack else evidence.FAIL) + + junit = WORK_DIR / f"junit-{gate}.xml" + junit.parent.mkdir(parents=True, exist_ok=True) + # The browser gate runs the e2e file alone: the unit suite already ran in + # tier 1, and running it again here would need that job's prerequisites. + files = ["tests/e2e.test.ts"] if args.browser else [] + proc = run( + ["pnpm", "exec", "vitest", "run", "--config", "tests/vitest.config.ts", + "--reporter=default", "--reporter=junit", f"--outputFile.junit={junit}", *files], + cwd=TYPESCRIPT, env=env, timeout=args.timeout, tee=True, capture=False, + ) + outcomes = parse_vitest_junit(junit) + if not outcomes: + result.note(f"vitest exited {proc.code} with no junit output; the suite did not run") + result.finish(evidence.ERROR).save(args.evidence_dir) + return 1 + for tid, outcome in outcomes.items(): + result.record(tid, outcome) + + tally = result.tally() + result.artifacts.append(junit.name) + result.metrics["exit_code"] = proc.code + result.note( + f"{tally.get('pass', 0)} passed, {tally.get('fail', 0)} failed, " + f"{tally.get('error', 0)} errored, {tally.get('skip', 0)} skipped " + f"({tally.get('total', 0)} collected)" + ) + failing = tally.get("fail", 0) + tally.get("error", 0) + e2e_passed = sum(1 for t, o in result.tests.items() if "e2e" in t and o == evidence.PASS) + result.metrics["e2e_passed"] = e2e_passed + if args.browser and e2e_passed == 0: + # An e2e run where every browser test skipped proved nothing. + result.note("no end-to-end test ran; CAMOUFOX_E2E did not take effect") + failing += 1 + status = evidence.PASS if failing == 0 else evidence.FAIL + result.finish(status).save(args.evidence_dir) + return 0 if status == evidence.PASS else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/summarize.py b/ci/summarize.py index 9bb282714..2e3a68a8a 100644 --- a/ci/summarize.py +++ b/ci/summarize.py @@ -52,8 +52,8 @@ # Presented in this order; anything unexpected is appended. _ORDER = [ - "native_rules", "pythonlib", "patches_apply", "build", "patch_guards", - "skiplist_audit", "native_browser", "build_tester", "playwright", "sundial", + "native_rules", "pythonlib", "typescript", "patches_apply", "build", "patch_guards", + "skiplist_audit", "native_browser", "build_tester", "playwright", "typescript_browser", "sundial", ] diff --git a/ci/tests/test_ci.py b/ci/tests/test_ci.py index 91e2f7d68..7e7fcf67b 100644 --- a/ci/tests/test_ci.py +++ b/ci/tests/test_ci.py @@ -952,7 +952,7 @@ def test_required_suites_are_names_a_runner_actually_writes(): producible = { "build", "build_tester", "patch_guards", "pythonlib", "sundial", "native", "native_rules", "native_browser", "native_growth", - "playwright", "skiplist_audit", + "playwright", "skiplist_audit", "typescript", "typescript_browser", } unknown = required - producible assert not unknown, ( @@ -2260,7 +2260,7 @@ def test_the_native_inputs_cover_everything_that_can_change_the_binary(): from ci.browser_inputs import BROWSER_DIRS, BROWSER_FILES text = WORKFLOW.read_text(encoding="utf-8") - scope = re.search(r"grep -qE '\^\(([^)]*)\)'", text) + scope = re.search(r"sources='\^\(([^)]*)\)'", text) assert scope, "the browser_changed grep is gone or was reshaped" considered = { part.replace("\\", "").rstrip("/") for part in scope.group(1).split("|") if part @@ -2623,3 +2623,89 @@ def test_build_tester_accepts_every_core_count_pythonlib_presents(): f"build-tester's plausibleHWC rejects core counts pythonlib presents: {missing}. " "Add them to the list in build-tester/src/lib/checks/extended.ts." ) + + +# --------------------------------------------------------------------------- +# build-or-fetch: the published release is used only when it matches the tree +# --------------------------------------------------------------------------- + + +def _scope(repo: pathlib.Path, base: str) -> tuple[str, str]: + """Run the workflow's own "Does this change the browser?" step in `repo`.""" + import subprocess + + text = WORKFLOW.read_text(encoding="utf-8") + start = text.index(" run: |\n if [ \"${{ github.event_name }}\" != \"pull_request\" ]") + end = text.index(" - name: May the stealth check run?", start) + lines = text[start:end].splitlines()[1:] + block = "\n".join(line[10:] if line.startswith(" " * 10) else line.strip() for line in lines) + block = block.replace("${{ github.event_name }}", "pull_request") + block = block.replace("${{ github.event.pull_request.base.sha }}", base) + out = repo / "out.txt" + proc = subprocess.run( + ["bash", "-e", "-c", block], cwd=repo, capture_output=True, text=True, + env={**os.environ, "GITHUB_OUTPUT": str(out)}, + ) + assert proc.returncode == 0, proc.stderr + return out.read_text().strip(), proc.stdout + + +def _git(repo: pathlib.Path, *args: str) -> str: + import subprocess + + return subprocess.run( + ["git", "-c", "user.email=ci@test", "-c", "user.name=ci", *args], + cwd=repo, check=True, capture_output=True, text=True, + ).stdout.strip() + + +@pytest.fixture +def release_repo(tmp_path): + """A repo whose v1.0-beta.1 tag is the published release, and a main after it.""" + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-q", "-b", "main") + (repo / "upstream.sh").write_text("version=1.0\nrelease=beta.1\n") + (repo / "patches").mkdir() + (repo / "patches" / "a.patch").write_text("a\n") + (repo / "typescript").mkdir() + (repo / "typescript" / "x.ts").write_text("x\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-qm", "release") + _git(repo, "tag", "v1.0-beta.1") + return repo + + +def test_driver_pr_on_the_released_sources_tests_the_release(release_repo): + base = _git(release_repo, "rev-parse", "HEAD") + (release_repo / "typescript" / "x.ts").write_text("y\n") + _git(release_repo, "commit", "-qam", "driver change") + assert _scope(release_repo, base)[0] == "browser_changed=false" + + +def test_driver_pr_on_unreleased_browser_sources_builds(release_repo): + """#785: main had merged browser patches (#779) that no release carried yet, + so a TS-only pull request fetched beta.31 and ran #779's patch guards on it.""" + (release_repo / "patches" / "a.patch").write_text("merged but unreleased\n") + _git(release_repo, "commit", "-qam", "browser change merged to main") + base = _git(release_repo, "rev-parse", "HEAD") + (release_repo / "typescript" / "x.ts").write_text("y\n") + _git(release_repo, "commit", "-qam", "driver change") + result, log = _scope(release_repo, base) + assert result == "browser_changed=true" + assert "patches/a.patch" in log + + +def test_browser_pr_builds(release_repo): + base = _git(release_repo, "rev-parse", "HEAD") + (release_repo / "patches" / "a.patch").write_text("b\n") + _git(release_repo, "commit", "-qam", "browser change") + assert _scope(release_repo, base)[0] == "browser_changed=true" + + +def test_unpublished_release_tag_builds(release_repo): + _git(release_repo, "tag", "-d", "v1.0-beta.1") + base = _git(release_repo, "rev-parse", "HEAD") + (release_repo / "typescript" / "x.ts").write_text("y\n") + _git(release_repo, "commit", "-qam", "driver change") + assert _scope(release_repo, base)[0] == "browser_changed=true" diff --git a/native-tests/test_crash_recovery.py b/native-tests/test_crash_recovery.py index a44dd3a88..e5ea3dd85 100644 --- a/native-tests/test_crash_recovery.py +++ b/native-tests/test_crash_recovery.py @@ -637,6 +637,12 @@ async def test_the_parent_stays_flat_across_content_crashes(binary, psutil_mod, manager, browser = await open_browser(binary) parents = wait_for_process(psutil_mod, BROWSER) parent = parents[0] + # The baseline follows one clean context. The parent's first context costs + # it 150-200 MB with no crash at all, and a baseline taken before that + # counted the warm-up as crash growth: 330-370 MB of the 400 MB allowance + # locally, and over it on a CI runner. + context, _ = await a_page(browser) + await close_bounded(context, 30, "the warm-up context") baseline = parent.memory_info().rss for _ in range(3): diff --git a/native-tests/test_tribal_rules.py b/native-tests/test_tribal_rules.py index 2d216a784..4fad86f54 100644 --- a/native-tests/test_tribal_rules.py +++ b/native-tests/test_tribal_rules.py @@ -577,10 +577,10 @@ def test_no_canvas_seed_is_declared_or_sent(): for entry in json.loads((REPO_ROOT / "settings" / "properties.json").read_text()) } assert not {k for k in declared if k.startswith("canvas:")}, explain("canvas-is-not-noised") - fingerprints = REPO_ROOT / "pythonlib" / "camoufox" / "fingerprints.py" - assert "setCanvasSeed" not in fingerprints.read_text(encoding="utf-8"), ( - "fingerprints.py still calls setCanvasSeed" + explain("canvas-is-not-noised") - ) + for launcher in ("pythonlib/camoufox/fingerprints.py", "typescript/src/fingerprints.ts"): + assert "setCanvasSeed" not in (REPO_ROOT / launcher).read_text(encoding="utf-8"), ( + f"{launcher} still calls setCanvasSeed" + explain("canvas-is-not-noised") + ) def test_the_canvas_check_hashes_pixels_rather_than_a_data_url_prefix(): diff --git a/pythonlib/README.md b/pythonlib/README.md index d66e9b2ec..0659c4f03 100644 --- a/pythonlib/README.md +++ b/pythonlib/README.md @@ -238,7 +238,7 @@ Display the Python package version, active browser version, channel, and update ```bash > camoufox version Python Packages - Camoufox v0.5.6 + Camoufox v0.5.7 fpgen v1.3.0 Playwright v1.62.0 Browser diff --git a/pythonlib/camoufox/essential-fonts.json b/pythonlib/camoufox/essential-fonts.json new file mode 100644 index 000000000..ec1e8230c --- /dev/null +++ b/pythonlib/camoufox/essential-fonts.json @@ -0,0 +1,5 @@ +{ + "win": ["Arial", "Arial Black", "Bahnschrift", "Calibri", "Calibri Light", "Cambria", "Cambria Math", "Candara", "Candara Light", "Comic Sans MS", "Consolas", "Constantia", "Corbel", "Corbel Light", "Courier", "Courier New", "Ebrima", "Franklin Gothic Medium", "Gabriola", "Gadugi", "Georgia", "Helvetica", "Impact", "Ink Free", "Javanese Text", "Leelawadee UI", "Leelawadee UI Semilight", "Lucida Console", "Lucida Sans Unicode", "MS Gothic", "MS PGothic", "MS Sans Serif", "MS Serif", "MS UI Gothic", "MV Boli", "Malgun Gothic", "Malgun Gothic Semilight", "Marlett", "Microsoft Himalaya", "Microsoft JhengHei", "Microsoft JhengHei Light", "Microsoft JhengHei UI", "Microsoft JhengHei UI Light", "Microsoft New Tai Lue", "Microsoft PhagsPa", "Microsoft Sans Serif", "Microsoft Tai Le", "Microsoft YaHei", "Microsoft YaHei Light", "Microsoft YaHei UI", "Microsoft YaHei UI Light", "Microsoft Yi Baiti", "MingLiU-ExtB", "MingLiU_HKSCS-ExtB", "MingLiU_MSCS-ExtB", "Mongolian Baiti", "Myanmar Text", "NSimSun", "Nirmala Text", "Nirmala Text Semilight", "Nirmala UI", "Nirmala UI Semilight", "PMingLiU-ExtB", "Palatino Linotype", "Roman", "Sans Serif Collection", "Segoe Fluent Icons", "Segoe MDL2 Assets", "Segoe Print", "Segoe Script", "Segoe UI", "Segoe UI Black", "Segoe UI Emoji", "Segoe UI Historic", "Segoe UI Light", "Segoe UI Semibold", "Segoe UI Semilight", "Segoe UI Symbol", "Segoe UI Variable", "Segoe UI Variable Display", "Segoe UI Variable Small", "Segoe UI Variable Text", "SimSun", "SimSun-ExtB", "Sitka Banner", "Sitka Display", "Sitka Heading", "Sitka Small", "Sitka Subheading", "Sitka Text", "Small Fonts", "Sylfaen", "Symbol", "Tahoma", "Times", "Times New Roman", "Trebuchet MS", "Twemoji Mozilla", "Verdana", "Webdings", "Wingdings", "Yu Gothic", "Yu Gothic Light", "Yu Gothic Medium", "Yu Gothic UI", "Yu Gothic UI Light", "Yu Gothic UI Semibold", "Yu Gothic UI Semilight", "ๅฎ‹ไฝ“", "ๅพฎ่ปŸๆญฃ้ป‘้ซ”", "ๅพฎ่ปŸๆญฃ้ป‘้ซ” Light", "ๅพฎ่ฝฏ้›…้ป‘", "ๅพฎ่ฝฏ้›…้ป‘ Light", "ๆ–ฐๅฎ‹ไฝ“", "ๆ–ฐ็ดฐๆ˜Ž้ซ”-ExtB", "ๆธธใ‚ดใ‚ทใƒƒใ‚ฏ", "ๆธธใ‚ดใ‚ทใƒƒใ‚ฏ Light", "ๆธธใ‚ดใ‚ทใƒƒใ‚ฏ Medium", "็ดฐๆ˜Ž้ซ”-ExtB", "็ดฐๆ˜Ž้ซ”_HKSCS-ExtB", "็ดฐๆ˜Ž้ซ”_MSCS-ExtB", "๋ง‘์€ ๊ณ ๋”•", "๋ง‘์€ ๊ณ ๋”• Semilight", "๏ผญ๏ผณ ใ‚ดใ‚ทใƒƒใ‚ฏ", "๏ผญ๏ผณ ๏ผฐใ‚ดใ‚ทใƒƒใ‚ฏ"], + "mac": ["Academy Engraved LET", "Al Bayan", "Al Nile", "Al Tarikh", "American Typewriter", "American Typewriter Semibold", "Andale Mono", "Apple Braille", "Apple Chancery", "Apple Color Emoji", "Apple SD Gothic Neo", "Apple SD Gothic Neo ExtraBold", "Apple Symbols", "AppleGothic", "AppleMyungjo", "Arial", "Arial Black", "Arial Hebrew", "Arial Hebrew Scholar", "Arial Narrow", "Arial Rounded MT Bold", "Arial Unicode MS", "Athelas", "Avenir", "Avenir Black", "Avenir Black Oblique", "Avenir Book", "Avenir Heavy", "Avenir Light", "Avenir Medium", "Avenir Next", "Avenir Next Demi Bold", "Avenir Next Heavy", "Avenir Next Medium", "Avenir Next Ultra Light", "Ayuthaya", "Baghdad", "Bangla MN", "Bangla Sangam MN", "Baskerville", "Beirut", "Big Caslon", "Bodoni 72", "Bodoni 72 Oldstyle", "Bodoni 72 Smallcaps", "Bodoni Ornaments", "Bradley Hand", "Brush Script MT", "Chalkboard", "Chalkboard SE", "Chalkduster", "Charter", "Charter Black", "Cochin", "Comic Sans MS", "Copperplate", "Corsiva Hebrew", "Courier", "Courier New", "DIN Alternate", "DIN Condensed", "Damascus", "DecoType Naskh", "Devanagari MT", "Devanagari Sangam MN", "Didot", "Diwan Kufi", "Diwan Thuluth", "Euphemia UCAS", "Farah", "Farisi", "Futura", "Futura Bold", "GB18030 Bitmap", "Galvji", "Geeza Pro", "Geneva", "Georgia", "Gill Sans", "Grantha Sangam MN", "Gujarati MT", "Gujarati Sangam MN", "Gurmukhi MN", "Gurmukhi MT", "Gurmukhi Sangam MN", "Heiti SC", "Heiti TC", "Helvetica", "Helvetica Neue", "Hiragino Kaku Gothic Pro", "Hiragino Kaku Gothic Std", "Hiragino Kaku Gothic StdN", "Hiragino Maru Gothic Pro", "Hiragino Maru Gothic ProN", "Hiragino Maru Gothic ProN W4", "Hiragino Mincho Pro", "Hiragino Mincho ProN", "Hiragino Mincho ProN W3", "Hiragino Mincho ProN W6", "Hiragino Sans", "Hiragino Sans GB", "Hiragino Sans GB W3", "Hiragino Sans GB W6", "Hiragino Sans W0", "Hiragino Sans W1", "Hiragino Sans W2", "Hiragino Sans W3", "Hiragino Sans W4", "Hiragino Sans W5", "Hiragino Sans W6", "Hiragino Sans W7", "Hiragino Sans W8", "Hiragino Sans W9", "Hoefler Text", "Hoefler Text Ornaments", "ITF Devanagari", "ITF Devanagari Marathi", "Impact", "InaiMathi", "InaiMathi Bold", "Iowan Old Style", "Kailasa", "Kannada MN", "Kannada Sangam MN", "Khmer MN", "Khmer Sangam MN", "Kohinoor Bangla", "Kohinoor Devanagari", "Kohinoor Devanagari Medium", "Kohinoor Gujarati", "Kohinoor Telugu", "Kokonor", "Krungthep", "KufiStandardGK", "Lao MN", "Lao Sangam MN", "Lucida Grande", "Luminari", "Malayalam MN", "Malayalam Sangam MN", "Marker Felt", "Menlo", "Microsoft Sans Serif", "Mishafi", "Mishafi Gold", "Monaco", "Mshtakan", "MuktaMahee Bold", "MuktaMahee ExtraBold", "MuktaMahee ExtraLight", "MuktaMahee Light", "MuktaMahee Medium", "MuktaMahee Regular", "MuktaMahee SemiBold", "Muna", "Myanmar MN", "Myanmar Sangam MN", "Nadeem", "New Peninim MT", "Noteworthy", "Noto Nastaliq Urdu", "Noto Sans Adlam", "Noto Sans Armenian", "Noto Sans Armenian Blk", "Noto Sans Armenian ExtBd", "Noto Sans Armenian ExtLt", "Noto Sans Armenian Light", "Noto Sans Armenian Med", "Noto Sans Armenian SemBd", "Noto Sans Armenian Thin", "Noto Sans Avestan", "Noto Sans Bamum", "Noto Sans Bassa Vah", "Noto Sans Batak", "Noto Sans Bhaiksuki", "Noto Sans Buginese", "Noto Sans Buhid", "Noto Sans Canadian Aboriginal Regular", "Noto Sans Carian", "Noto Sans CaucAlban", "Noto Sans Chakma", "Noto Sans Cham", "Noto Sans Coptic", "Noto Sans Cuneiform", "Noto Sans Cypriot", "Noto Sans Duployan", "Noto Sans EgyptHiero", "Noto Sans Elbasan", "Noto Sans Glagolitic", "Noto Sans Gothic", "Noto Sans Gunjala Gondi", "Noto Sans HanifiRohg", "Noto Sans Hanunoo", "Noto Sans Hatran", "Noto Sans ImpAramaic", "Noto Sans InsPahlavi", "Noto Sans InsParthi", "Noto Sans Javanese", "Noto Sans Kaithi", "Noto Sans Kannada", "Noto Sans Kannada Black", "Noto Sans Kannada ExtraBold", "Noto Sans Kannada ExtraLight", "Noto Sans Kannada Light", "Noto Sans Kannada Medium", "Noto Sans Kannada SemiBold", "Noto Sans Kannada Thin", "Noto Sans Kayah Li", "Noto Sans Kharoshthi", "Noto Sans Khojki", "Noto Sans Khudawadi", "Noto Sans Lepcha", "Noto Sans Limbu", "Noto Sans Linear A", "Noto Sans Linear B", "Noto Sans Lisu", "Noto Sans Lycian", "Noto Sans Lydian", "Noto Sans Mahajani", "Noto Sans Mandaic", "Noto Sans Manichaean", "Noto Sans Marchen", "Noto Sans Masaram Gondi", "Noto Sans Mende Kikakui", "Noto Sans Meroitic", "Noto Sans Miao", "Noto Sans Modi", "Noto Sans Mongolian", "Noto Sans Mro", "Noto Sans Multani", "Noto Sans Myanmar", "Noto Sans Myanmar Blk", "Noto Sans Myanmar ExtBd", "Noto Sans Myanmar ExtLt", "Noto Sans Myanmar Light", "Noto Sans Myanmar Med", "Noto Sans Myanmar SemBd", "Noto Sans Myanmar Thin", "Noto Sans NKo", "Noto Sans Nabataean", "Noto Sans Newa", "Noto Sans Ol Chiki", "Noto Sans Old Italic", "Noto Sans Old Permic", "Noto Sans Old Turkic", "Noto Sans OldHung", "Noto Sans OldNorArab", "Noto Sans OldSouArab", "Noto Sans Oriya", "Noto Sans Osage", "Noto Sans Osmanya", "Noto Sans Pahawh Hmong", "Noto Sans Palmyrene", "Noto Sans PhagsPa", "Noto Sans Phoenician", "Noto Sans PsaPahlavi", "Noto Sans Rejang", "Noto Sans Samaritan", "Noto Sans Saurashtra", "Noto Sans Sharada", "Noto Sans Siddham", "Noto Sans SoraSomp", "Noto Sans Sundanese", "Noto Sans Syloti Nagri", "Noto Sans Syriac", "Noto Sans Tagalog", "Noto Sans Tagbanwa", "Noto Sans Tai Le", "Noto Sans Tai Tham", "Noto Sans Tai Viet", "Noto Sans Takri", "Noto Sans Thaana", "Noto Sans Tifinagh", "Noto Sans Tirhuta", "Noto Sans Ugaritic", "Noto Sans Vai", "Noto Sans Wancho", "Noto Sans Yi", "Noto Sans Zawgyi", "Noto Sans Zawgyi Blk", "Noto Sans Zawgyi ExtBd", "Noto Sans Zawgyi ExtLt", "Noto Sans Zawgyi Light", "Noto Sans Zawgyi Med", "Noto Sans Zawgyi SemBd", "Noto Sans Zawgyi Thin", "Noto Serif Ahom", "Noto Serif Balinese", "Noto Serif Hmong Nyiakeng", "Noto Serif Myanmar", "Noto Serif Myanmar Blk", "Noto Serif Myanmar ExtBd", "Noto Serif Myanmar ExtLt", "Noto Serif Myanmar Light", "Noto Serif Myanmar Med", "Noto Serif Myanmar SemBd", "Noto Serif Myanmar Thin", "Noto Serif Yezidi", "Optima", "Oriya MN", "Oriya Sangam MN", "PT Mono", "PT Sans", "PT Sans Caption", "PT Sans Narrow", "PT Serif", "PT Serif Caption", "Palatino", "Papyrus", "Party LET", "Phosphate", "PingFang HK", "PingFang SC", "PingFang TC", "Plantagenet Cherokee", "Raanana", "Rockwell", "STIX Two Math", "STIX Two Math Regular", "STIX Two Text", "STIX Two Text Regular", "STIXGeneral", "STIXIntegralsD", "STIXIntegralsSm", "STIXIntegralsUp", "STIXIntegralsUpD", "STIXIntegralsUpSm", "STIXNonUnicode", "STIXSizeFiveSym", "STIXSizeFourSym", "STIXSizeOneSym", "STIXSizeThreeSym", "STIXSizeTwoSym", "STIXVariants", "STSong", "Sana", "Sathu", "Savoye LET", "Shree Devanagari 714", "SignPainter-HouseScript", "Silom", "Sinhala MN", "Sinhala Sangam MN", "Skia", "Snell Roundhand", "Songti SC", "Songti TC", "Sukhumvit Set", "Superclarendon", "Symbol", "System Font", "Tahoma", "Tamil MN", "Tamil Sangam MN", "Telugu MN", "Telugu Sangam MN", "Thonburi", "Times", "Times New Roman", "Trattatello", "Trebuchet MS", "Verdana", "Waseem", "Webdings", "Wingdings", "Wingdings 2", "Wingdings 3", "Zapf Dingbats", "Zapfino"], + "lin": ["AR PL UKai CN", "AR PL UKai HK", "AR PL UKai TW", "AR PL UKai TW MBE", "AR PL UMing CN", "AR PL UMing HK", "AR PL UMing TW", "AR PL UMing TW MBE", "Arial", "Arial Narrow", "Avant Garde", "Bookman Old Style", "C059", "Calibri", "Cambria", "Century Schoolbook", "Courier", "Courier New", "D050000L", "DejaVu Sans", "DejaVu Sans Mono", "DejaVu Serif", "Droid Sans Fallback", "Helvetica", "Helvetica Narrow", "Liberation Mono", "Liberation Sans", "Liberation Sans Narrow", "Liberation Serif", "Nimbus Mono PS", "Nimbus Roman", "Nimbus Sans", "Nimbus Sans Narrow", "Noto Color Emoji", "Noto Kufi Arabic", "Noto Looped Lao", "Noto Looped Lao Bold", "Noto Looped Lao Regular", "Noto Looped Thai", "Noto Looped Thai Bold", "Noto Looped Thai Regular", "Noto Mono", "Noto Music", "Noto Naskh Arabic", "Noto Nastaliq Urdu", "Noto Rashi Hebrew", "Noto Sans", "Noto Sans Adlam", "Noto Sans Adlam Unjoined", "Noto Sans AnatoHiero", "Noto Sans Anatolian Hieroglyphs", "Noto Sans Arabic", "Noto Sans Armenian", "Noto Sans Avestan", "Noto Sans Balinese", "Noto Sans Bamum", "Noto Sans Bassa Vah", "Noto Sans Batak", "Noto Sans Bengali", "Noto Sans Bhaiksuki", "Noto Sans Brahmi", "Noto Sans Buginese", "Noto Sans Buhid", "Noto Sans CJK HK", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans CJK SC", "Noto Sans CJK TC", "Noto Sans CanAborig", "Noto Sans Canadian Aboriginal", "Noto Sans Carian", "Noto Sans CaucAlban", "Noto Sans Caucasian Albanian", "Noto Sans Chakma", "Noto Sans Cham", "Noto Sans Cherokee", "Noto Sans Coptic", "Noto Sans Cuneiform", "Noto Sans Cypriot", "Noto Sans Deseret", "Noto Sans Devanagari", "Noto Sans Display", "Noto Sans Duployan", "Noto Sans EgyptHiero", "Noto Sans Egyptian Hieroglyphs", "Noto Sans Elbasan", "Noto Sans Elymaic", "Noto Sans Ethiopic", "Noto Sans Georgian", "Noto Sans Glagolitic", "Noto Sans Gothic", "Noto Sans Grantha", "Noto Sans Gujarati", "Noto Sans Gunjala Gondi", "Noto Sans Gurmukhi", "Noto Sans Hanifi Rohingya", "Noto Sans Hanunoo", "Noto Sans Hatran", "Noto Sans Hebrew", "Noto Sans ImpAramaic", "Noto Sans Imperial Aramaic", "Noto Sans Indic Siyaq Numbers", "Noto Sans InsPahlavi", "Noto Sans InsParthi", "Noto Sans Inscriptional Pahlavi", "Noto Sans Inscriptional Parthian", "Noto Sans Javanese", "Noto Sans Kaithi", "Noto Sans Kannada", "Noto Sans Kayah Li", "Noto Sans Kharoshthi", "Noto Sans Khmer", "Noto Sans Khojki", "Noto Sans Khudawadi", "Noto Sans Lao", "Noto Sans Lepcha", "Noto Sans Limbu", "Noto Sans Linear A", "Noto Sans Linear B", "Noto Sans Lisu", "Noto Sans Lycian", "Noto Sans Lydian", "Noto Sans Mahajani", "Noto Sans Malayalam", "Noto Sans Mandaic", "Noto Sans Manichaean", "Noto Sans Marchen", "Noto Sans Masaram Gondi", "Noto Sans Math", "Noto Sans Mayan Numerals", "Noto Sans Medefaidrin", "Noto Sans Meetei Mayek", "Noto Sans Mende Kikakui", "Noto Sans Meroitic", "Noto Sans Miao", "Noto Sans Modi", "Noto Sans Mongolian", "Noto Sans Mono", "Noto Sans Mono CJK HK", "Noto Sans Mono CJK JP", "Noto Sans Mono CJK KR", "Noto Sans Mono CJK SC", "Noto Sans Mono CJK TC", "Noto Sans Mro", "Noto Sans Multani", "Noto Sans Myanmar", "Noto Sans NKo", "Noto Sans Nabataean", "Noto Sans New Tai Lue", "Noto Sans Newa", "Noto Sans Nushu", "Noto Sans Ogham", "Noto Sans Ol Chiki", "Noto Sans Old Hungarian", "Noto Sans Old Italic", "Noto Sans Old North Arabian", "Noto Sans Old Permic", "Noto Sans Old Persian", "Noto Sans Old Sogdian", "Noto Sans Old South Arabian", "Noto Sans Old Turkic", "Noto Sans OldHung", "Noto Sans OldNorArab", "Noto Sans OldSouArab", "Noto Sans Oriya", "Noto Sans Osage", "Noto Sans Osmanya", "Noto Sans Pahawh Hmong", "Noto Sans Palmyrene", "Noto Sans Pau Cin Hau", "Noto Sans PhagsPa", "Noto Sans Phoenician", "Noto Sans PsaPahlavi", "Noto Sans Psalter Pahlavi", "Noto Sans Rejang", "Noto Sans Runic", "Noto Sans Samaritan", "Noto Sans Saurashtra", "Noto Sans Sharada", "Noto Sans Shavian", "Noto Sans Siddham", "Noto Sans SignWrit", "Noto Sans SignWriting", "Noto Sans Sinhala", "Noto Sans Sogdian", "Noto Sans Sora Sompeng", "Noto Sans Soyombo", "Noto Sans Sundanese", "Noto Sans Syloti Nagri", "Noto Sans Symbols", "Noto Sans Symbols2", "Noto Sans Syriac", "Noto Sans Tagalog", "Noto Sans Tagbanwa", "Noto Sans Tai Le", "Noto Sans Tai Tham", "Noto Sans Tai Viet", "Noto Sans Takri", "Noto Sans Tamil", "Noto Sans Tamil Supplement", "Noto Sans Telugu", "Noto Sans Thaana", "Noto Sans Thai", "Noto Sans Tifinagh", "Noto Sans Tifinagh APT", "Noto Sans Tifinagh Adrar", "Noto Sans Tifinagh Agraw Imazighen", "Noto Sans Tifinagh Ahaggar", "Noto Sans Tifinagh Air", "Noto Sans Tifinagh Azawagh", "Noto Sans Tifinagh Ghat", "Noto Sans Tifinagh Hawad", "Noto Sans Tifinagh Rhissa Ixa", "Noto Sans Tifinagh SIL", "Noto Sans Tifinagh Tawellemmet", "Noto Sans Tirhuta", "Noto Sans Ugaritic", "Noto Sans Vai", "Noto Sans Wancho", "Noto Sans Warang Citi", "Noto Sans Yi", "Noto Sans Zanabazar", "Noto Sans Zanabazar Square", "Noto Serif", "Noto Serif Ahom", "Noto Serif Armenian", "Noto Serif Balinese", "Noto Serif Bengali", "Noto Serif CJK HK", "Noto Serif CJK JP", "Noto Serif CJK KR", "Noto Serif CJK SC", "Noto Serif CJK TC", "Noto Serif Devanagari", "Noto Serif Display", "Noto Serif Dogra", "Noto Serif Ethiopic", "Noto Serif Georgian", "Noto Serif Grantha", "Noto Serif Gujarati", "Noto Serif Gurmukhi", "Noto Serif Hebrew", "Noto Serif Hmong Nyiakeng", "Noto Serif Kannada", "Noto Serif Khmer", "Noto Serif Khojki", "Noto Serif Lao", "Noto Serif Malayalam", "Noto Serif Myanmar", "Noto Serif Sinhala", "Noto Serif Tamil", "Noto Serif Tamil Slanted", "Noto Serif Tangut", "Noto Serif Telugu", "Noto Serif Thai", "Noto Serif Tibetan", "Noto Serif Yezidi", "Noto Traditional Nushu", "OpenSymbol", "P052", "Palatino", "Palatino Linotype", "Standard Symbols PS", "Symbol", "Times", "Times New Roman", "URW Bookman", "URW Gothic", "Ubuntu", "Ubuntu Mono", "Ubuntu Sans", "Ubuntu Sans Mono", "Z003", "Zapf Chancery"] +} diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index 698d190a7..b2b4f891a 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -130,177 +130,20 @@ def _load_os_fonts() -> Dict[str, List[str]]: # the stock CJK families; macOS Sonoma; Ubuntu and its Mint variant), # intersected with fonts.json so only names the bundle can # render are listed (Sonoma's PingFang / Kefa / Hiragino families are in the real -# base but not bundled, so they are absent here). Regenerate together with -# fonts.json: `python3 scripts/gen-fonts-json.py --print-bases`. +# base but not bundled, so they are absent here). They live in essential-fonts.json, +# which the TypeScript launcher reads too; regenerate it together with fonts.json: +# `python3 scripts/gen-fonts-json.py --print-bases`. # # Windows: the seven GDI-substitution names (Courier, Helvetica, MS Sans Serif, # MS Serif, Roman, Small Fonts, Times) and the six Light/Semilight names have no # file of their own; bundle/fontconfig/windows/fonts.conf rewrites each to its # bundled target unconditionally, so they MUST stay in this always-reported set # (an identity that did not report Helvetica would still render it otherwise). -_ESSENTIAL_FONTS_MACOS = [ - 'Academy Engraved LET', 'Al Bayan', 'Al Nile', 'Al Tarikh', 'American Typewriter', 'American Typewriter Semibold', - 'Andale Mono', 'Apple Braille', 'Apple Chancery', 'Apple Color Emoji', 'Apple SD Gothic Neo', - 'Apple SD Gothic Neo ExtraBold', 'Apple Symbols', 'AppleGothic', 'AppleMyungjo', 'Arial', - 'Arial Black', 'Arial Hebrew', 'Arial Hebrew Scholar', 'Arial Narrow', 'Arial Rounded MT Bold', - 'Arial Unicode MS', 'Athelas', 'Avenir', 'Avenir Black', 'Avenir Black Oblique', 'Avenir Book', - 'Avenir Heavy', 'Avenir Light', 'Avenir Medium', 'Avenir Next', 'Avenir Next Demi Bold', - 'Avenir Next Heavy', 'Avenir Next Medium', 'Avenir Next Ultra Light', 'Ayuthaya', 'Baghdad', - 'Bangla MN', 'Bangla Sangam MN', 'Baskerville', 'Beirut', 'Big Caslon', 'Bodoni 72', 'Bodoni 72 Oldstyle', - 'Bodoni 72 Smallcaps', 'Bodoni Ornaments', 'Bradley Hand', 'Brush Script MT', 'Chalkboard', - 'Chalkboard SE', 'Chalkduster', 'Charter', 'Charter Black', 'Cochin', 'Comic Sans MS', - 'Copperplate', 'Corsiva Hebrew', 'Courier', 'Courier New', 'DIN Alternate', 'DIN Condensed', - 'Damascus', 'DecoType Naskh', 'Devanagari MT', 'Devanagari Sangam MN', 'Didot', 'Diwan Kufi', - 'Diwan Thuluth', 'Euphemia UCAS', 'Farah', 'Farisi', 'Futura', 'Futura Bold', 'GB18030 Bitmap', - 'Galvji', 'Geeza Pro', 'Geneva', 'Georgia', 'Gill Sans', 'Grantha Sangam MN', 'Gujarati MT', - 'Gujarati Sangam MN', 'Gurmukhi MN', 'Gurmukhi MT', 'Gurmukhi Sangam MN', 'Heiti SC', 'Heiti TC', - 'Helvetica', 'Helvetica Neue', 'Hiragino Kaku Gothic Pro', 'Hiragino Kaku Gothic Std', - 'Hiragino Kaku Gothic StdN', 'Hiragino Maru Gothic Pro', 'Hiragino Maru Gothic ProN', 'Hiragino Maru Gothic ProN W4', - 'Hiragino Mincho Pro', 'Hiragino Mincho ProN', 'Hiragino Mincho ProN W3', 'Hiragino Mincho ProN W6', - 'Hiragino Sans', 'Hiragino Sans GB', 'Hiragino Sans GB W3', 'Hiragino Sans GB W6', 'Hiragino Sans W0', - 'Hiragino Sans W1', 'Hiragino Sans W2', 'Hiragino Sans W3', 'Hiragino Sans W4', 'Hiragino Sans W5', - 'Hiragino Sans W6', 'Hiragino Sans W7', 'Hiragino Sans W8', 'Hiragino Sans W9', 'Hoefler Text', - 'Hoefler Text Ornaments', 'ITF Devanagari', 'ITF Devanagari Marathi', 'Impact', 'InaiMathi', - 'InaiMathi Bold', 'Iowan Old Style', 'Kailasa', 'Kannada MN', 'Kannada Sangam MN', 'Khmer MN', - 'Khmer Sangam MN', 'Kohinoor Bangla', 'Kohinoor Devanagari', 'Kohinoor Devanagari Medium', - 'Kohinoor Gujarati', 'Kohinoor Telugu', 'Kokonor', 'Krungthep', 'KufiStandardGK', 'Lao MN', - 'Lao Sangam MN', 'Lucida Grande', 'Luminari', 'Malayalam MN', 'Malayalam Sangam MN', 'Marker Felt', - 'Menlo', 'Microsoft Sans Serif', 'Mishafi', 'Mishafi Gold', 'Monaco', 'Mshtakan', 'MuktaMahee Bold', - 'MuktaMahee ExtraBold', 'MuktaMahee ExtraLight', 'MuktaMahee Light', 'MuktaMahee Medium', - 'MuktaMahee Regular', 'MuktaMahee SemiBold', 'Muna', 'Myanmar MN', 'Myanmar Sangam MN', - 'Nadeem', 'New Peninim MT', 'Noteworthy', 'Noto Nastaliq Urdu', 'Noto Sans Adlam', 'Noto Sans Armenian', - 'Noto Sans Armenian Blk', 'Noto Sans Armenian ExtBd', 'Noto Sans Armenian ExtLt', 'Noto Sans Armenian Light', - 'Noto Sans Armenian Med', 'Noto Sans Armenian SemBd', 'Noto Sans Armenian Thin', 'Noto Sans Avestan', - 'Noto Sans Bamum', 'Noto Sans Bassa Vah', 'Noto Sans Batak', 'Noto Sans Bhaiksuki', 'Noto Sans Buginese', - 'Noto Sans Buhid', 'Noto Sans Canadian Aboriginal Regular', 'Noto Sans Carian', 'Noto Sans CaucAlban', - 'Noto Sans Chakma', 'Noto Sans Cham', 'Noto Sans Coptic', 'Noto Sans Cuneiform', 'Noto Sans Cypriot', - 'Noto Sans Duployan', 'Noto Sans EgyptHiero', 'Noto Sans Elbasan', 'Noto Sans Glagolitic', - 'Noto Sans Gothic', 'Noto Sans Gunjala Gondi', 'Noto Sans HanifiRohg', 'Noto Sans Hanunoo', - 'Noto Sans Hatran', 'Noto Sans ImpAramaic', 'Noto Sans InsPahlavi', 'Noto Sans InsParthi', - 'Noto Sans Javanese', 'Noto Sans Kaithi', 'Noto Sans Kannada', 'Noto Sans Kannada Black', - 'Noto Sans Kannada ExtraBold', 'Noto Sans Kannada ExtraLight', 'Noto Sans Kannada Light', - 'Noto Sans Kannada Medium', 'Noto Sans Kannada SemiBold', 'Noto Sans Kannada Thin', 'Noto Sans Kayah Li', - 'Noto Sans Kharoshthi', 'Noto Sans Khojki', 'Noto Sans Khudawadi', 'Noto Sans Lepcha', - 'Noto Sans Limbu', 'Noto Sans Linear A', 'Noto Sans Linear B', 'Noto Sans Lisu', 'Noto Sans Lycian', - 'Noto Sans Lydian', 'Noto Sans Mahajani', 'Noto Sans Mandaic', 'Noto Sans Manichaean', - 'Noto Sans Marchen', 'Noto Sans Masaram Gondi', 'Noto Sans Mende Kikakui', 'Noto Sans Meroitic', - 'Noto Sans Miao', 'Noto Sans Modi', 'Noto Sans Mongolian', 'Noto Sans Mro', 'Noto Sans Multani', - 'Noto Sans Myanmar', 'Noto Sans Myanmar Blk', 'Noto Sans Myanmar ExtBd', 'Noto Sans Myanmar ExtLt', - 'Noto Sans Myanmar Light', 'Noto Sans Myanmar Med', 'Noto Sans Myanmar SemBd', 'Noto Sans Myanmar Thin', - 'Noto Sans NKo', 'Noto Sans Nabataean', 'Noto Sans Newa', 'Noto Sans Ol Chiki', 'Noto Sans Old Italic', - 'Noto Sans Old Permic', 'Noto Sans Old Turkic', 'Noto Sans OldHung', 'Noto Sans OldNorArab', - 'Noto Sans OldSouArab', 'Noto Sans Oriya', 'Noto Sans Osage', 'Noto Sans Osmanya', 'Noto Sans Pahawh Hmong', - 'Noto Sans Palmyrene', 'Noto Sans PhagsPa', 'Noto Sans Phoenician', 'Noto Sans PsaPahlavi', - 'Noto Sans Rejang', 'Noto Sans Samaritan', 'Noto Sans Saurashtra', 'Noto Sans Sharada', - 'Noto Sans Siddham', 'Noto Sans SoraSomp', 'Noto Sans Sundanese', 'Noto Sans Syloti Nagri', - 'Noto Sans Syriac', 'Noto Sans Tagalog', 'Noto Sans Tagbanwa', 'Noto Sans Tai Le', 'Noto Sans Tai Tham', - 'Noto Sans Tai Viet', 'Noto Sans Takri', 'Noto Sans Thaana', 'Noto Sans Tifinagh', 'Noto Sans Tirhuta', - 'Noto Sans Ugaritic', 'Noto Sans Vai', 'Noto Sans Wancho', 'Noto Sans Yi', 'Noto Sans Zawgyi', - 'Noto Sans Zawgyi Blk', 'Noto Sans Zawgyi ExtBd', 'Noto Sans Zawgyi ExtLt', 'Noto Sans Zawgyi Light', - 'Noto Sans Zawgyi Med', 'Noto Sans Zawgyi SemBd', 'Noto Sans Zawgyi Thin', 'Noto Serif Ahom', - 'Noto Serif Balinese', 'Noto Serif Hmong Nyiakeng', 'Noto Serif Myanmar', 'Noto Serif Myanmar Blk', - 'Noto Serif Myanmar ExtBd', 'Noto Serif Myanmar ExtLt', 'Noto Serif Myanmar Light', 'Noto Serif Myanmar Med', - 'Noto Serif Myanmar SemBd', 'Noto Serif Myanmar Thin', 'Noto Serif Yezidi', 'Optima', 'Oriya MN', - 'Oriya Sangam MN', 'PT Mono', 'PT Sans', 'PT Sans Caption', 'PT Sans Narrow', 'PT Serif', - 'PT Serif Caption', 'Palatino', 'Papyrus', 'Party LET', 'Phosphate', 'PingFang HK', 'PingFang SC', - 'PingFang TC', 'Plantagenet Cherokee', 'Raanana', 'Rockwell', 'STIX Two Math', 'STIX Two Math Regular', - 'STIX Two Text', 'STIX Two Text Regular', 'STIXGeneral', 'STIXIntegralsD', 'STIXIntegralsSm', - 'STIXIntegralsUp', 'STIXIntegralsUpD', 'STIXIntegralsUpSm', 'STIXNonUnicode', 'STIXSizeFiveSym', - 'STIXSizeFourSym', 'STIXSizeOneSym', 'STIXSizeThreeSym', 'STIXSizeTwoSym', 'STIXVariants', - 'STSong', 'Sana', 'Sathu', 'Savoye LET', 'Shree Devanagari 714', 'SignPainter-HouseScript', - 'Silom', 'Sinhala MN', 'Sinhala Sangam MN', 'Skia', 'Snell Roundhand', 'Songti SC', 'Songti TC', - 'Sukhumvit Set', 'Superclarendon', 'Symbol', 'System Font', 'Tahoma', 'Tamil MN', 'Tamil Sangam MN', - 'Telugu MN', 'Telugu Sangam MN', 'Thonburi', 'Times', 'Times New Roman', 'Trattatello', - 'Trebuchet MS', 'Verdana', 'Waseem', 'Webdings', 'Wingdings', 'Wingdings 2', 'Wingdings 3', - 'Zapf Dingbats', 'Zapfino', -] -_ESSENTIAL_FONTS_WINDOWS = [ - 'Arial', 'Arial Black', 'Bahnschrift', 'Calibri', 'Calibri Light', 'Cambria', 'Cambria Math', - 'Candara', 'Candara Light', 'Comic Sans MS', 'Consolas', 'Constantia', 'Corbel', 'Corbel Light', - 'Courier', 'Courier New', 'Ebrima', 'Franklin Gothic Medium', 'Gabriola', 'Gadugi', 'Georgia', - 'Helvetica', 'Impact', 'Ink Free', 'Javanese Text', 'Leelawadee UI', 'Leelawadee UI Semilight', - 'Lucida Console', 'Lucida Sans Unicode', 'MS Gothic', 'MS PGothic', 'MS Sans Serif', 'MS Serif', - 'MS UI Gothic', 'MV Boli', 'Malgun Gothic', 'Malgun Gothic Semilight', 'Marlett', 'Microsoft Himalaya', - 'Microsoft JhengHei', 'Microsoft JhengHei Light', 'Microsoft JhengHei UI', 'Microsoft JhengHei UI Light', - 'Microsoft New Tai Lue', 'Microsoft PhagsPa', 'Microsoft Sans Serif', 'Microsoft Tai Le', - 'Microsoft YaHei', 'Microsoft YaHei Light', 'Microsoft YaHei UI', 'Microsoft YaHei UI Light', - 'Microsoft Yi Baiti', 'MingLiU-ExtB', 'MingLiU_HKSCS-ExtB', 'MingLiU_MSCS-ExtB', 'Mongolian Baiti', - 'Myanmar Text', 'NSimSun', 'Nirmala Text', 'Nirmala Text Semilight', 'Nirmala UI', 'Nirmala UI Semilight', - 'PMingLiU-ExtB', 'Palatino Linotype', 'Roman', 'Sans Serif Collection', 'Segoe Fluent Icons', - 'Segoe MDL2 Assets', 'Segoe Print', 'Segoe Script', 'Segoe UI', 'Segoe UI Black', 'Segoe UI Emoji', - 'Segoe UI Historic', 'Segoe UI Light', 'Segoe UI Semibold', 'Segoe UI Semilight', 'Segoe UI Symbol', - 'Segoe UI Variable', 'Segoe UI Variable Display', 'Segoe UI Variable Small', 'Segoe UI Variable Text', - 'SimSun', 'SimSun-ExtB', 'Sitka Banner', 'Sitka Display', 'Sitka Heading', 'Sitka Small', - 'Sitka Subheading', 'Sitka Text', 'Small Fonts', 'Sylfaen', 'Symbol', 'Tahoma', 'Times', - 'Times New Roman', 'Trebuchet MS', 'Twemoji Mozilla', 'Verdana', 'Webdings', 'Wingdings', - 'Yu Gothic', 'Yu Gothic Light', 'Yu Gothic Medium', 'Yu Gothic UI', 'Yu Gothic UI Light', - 'Yu Gothic UI Semibold', 'Yu Gothic UI Semilight', 'ๅฎ‹ไฝ“', 'ๅพฎ่ปŸๆญฃ้ป‘้ซ”', 'ๅพฎ่ปŸๆญฃ้ป‘้ซ” Light', 'ๅพฎ่ฝฏ้›…้ป‘', - 'ๅพฎ่ฝฏ้›…้ป‘ Light', 'ๆ–ฐๅฎ‹ไฝ“', 'ๆ–ฐ็ดฐๆ˜Ž้ซ”-ExtB', 'ๆธธใ‚ดใ‚ทใƒƒใ‚ฏ', 'ๆธธใ‚ดใ‚ทใƒƒใ‚ฏ Light', 'ๆธธใ‚ดใ‚ทใƒƒใ‚ฏ Medium', '็ดฐๆ˜Ž้ซ”-ExtB', '็ดฐๆ˜Ž้ซ”_HKSCS-ExtB', - '็ดฐๆ˜Ž้ซ”_MSCS-ExtB', '๋ง‘์€ ๊ณ ๋”•', '๋ง‘์€ ๊ณ ๋”• Semilight', '๏ผญ๏ผณ ใ‚ดใ‚ทใƒƒใ‚ฏ', '๏ผญ๏ผณ ๏ผฐใ‚ดใ‚ทใƒƒใ‚ฏ', -] -_ESSENTIAL_FONTS_LINUX = [ - 'AR PL UKai CN', 'AR PL UKai HK', 'AR PL UKai TW', 'AR PL UKai TW MBE', 'AR PL UMing CN', - 'AR PL UMing HK', 'AR PL UMing TW', 'AR PL UMing TW MBE', 'Arial', 'Arial Narrow', 'Avant Garde', - 'Bookman Old Style', 'C059', 'Calibri', 'Cambria', 'Century Schoolbook', 'Courier', 'Courier New', - 'D050000L', 'DejaVu Sans', 'DejaVu Sans Mono', 'DejaVu Serif', 'Droid Sans Fallback', 'Helvetica', - 'Helvetica Narrow', 'Liberation Mono', 'Liberation Sans', 'Liberation Sans Narrow', 'Liberation Serif', - 'Nimbus Mono PS', 'Nimbus Roman', 'Nimbus Sans', 'Nimbus Sans Narrow', 'Noto Color Emoji', - 'Noto Kufi Arabic', 'Noto Looped Lao', 'Noto Looped Lao Bold', 'Noto Looped Lao Regular', - 'Noto Looped Thai', 'Noto Looped Thai Bold', 'Noto Looped Thai Regular', 'Noto Mono', 'Noto Music', - 'Noto Naskh Arabic', 'Noto Nastaliq Urdu', 'Noto Rashi Hebrew', 'Noto Sans', 'Noto Sans Adlam', - 'Noto Sans Adlam Unjoined', 'Noto Sans AnatoHiero', 'Noto Sans Anatolian Hieroglyphs', - 'Noto Sans Arabic', 'Noto Sans Armenian', 'Noto Sans Avestan', 'Noto Sans Balinese', 'Noto Sans Bamum', - 'Noto Sans Bassa Vah', 'Noto Sans Batak', 'Noto Sans Bengali', 'Noto Sans Bhaiksuki', 'Noto Sans Brahmi', - 'Noto Sans Buginese', 'Noto Sans Buhid', 'Noto Sans CJK HK', 'Noto Sans CJK JP', 'Noto Sans CJK KR', - 'Noto Sans CJK SC', 'Noto Sans CJK TC', 'Noto Sans CanAborig', 'Noto Sans Canadian Aboriginal', - 'Noto Sans Carian', 'Noto Sans CaucAlban', 'Noto Sans Caucasian Albanian', 'Noto Sans Chakma', - 'Noto Sans Cham', 'Noto Sans Cherokee', 'Noto Sans Coptic', 'Noto Sans Cuneiform', 'Noto Sans Cypriot', - 'Noto Sans Deseret', 'Noto Sans Devanagari', 'Noto Sans Display', 'Noto Sans Duployan', - 'Noto Sans EgyptHiero', 'Noto Sans Egyptian Hieroglyphs', 'Noto Sans Elbasan', 'Noto Sans Elymaic', - 'Noto Sans Ethiopic', 'Noto Sans Georgian', 'Noto Sans Glagolitic', 'Noto Sans Gothic', - 'Noto Sans Grantha', 'Noto Sans Gujarati', 'Noto Sans Gunjala Gondi', 'Noto Sans Gurmukhi', - 'Noto Sans Hanifi Rohingya', 'Noto Sans Hanunoo', 'Noto Sans Hatran', 'Noto Sans Hebrew', - 'Noto Sans ImpAramaic', 'Noto Sans Imperial Aramaic', 'Noto Sans Indic Siyaq Numbers', - 'Noto Sans InsPahlavi', 'Noto Sans InsParthi', 'Noto Sans Inscriptional Pahlavi', 'Noto Sans Inscriptional Parthian', - 'Noto Sans Javanese', 'Noto Sans Kaithi', 'Noto Sans Kannada', 'Noto Sans Kayah Li', 'Noto Sans Kharoshthi', - 'Noto Sans Khmer', 'Noto Sans Khojki', 'Noto Sans Khudawadi', 'Noto Sans Lao', 'Noto Sans Lepcha', - 'Noto Sans Limbu', 'Noto Sans Linear A', 'Noto Sans Linear B', 'Noto Sans Lisu', 'Noto Sans Lycian', - 'Noto Sans Lydian', 'Noto Sans Mahajani', 'Noto Sans Malayalam', 'Noto Sans Mandaic', 'Noto Sans Manichaean', - 'Noto Sans Marchen', 'Noto Sans Masaram Gondi', 'Noto Sans Math', 'Noto Sans Mayan Numerals', - 'Noto Sans Medefaidrin', 'Noto Sans Meetei Mayek', 'Noto Sans Mende Kikakui', 'Noto Sans Meroitic', - 'Noto Sans Miao', 'Noto Sans Modi', 'Noto Sans Mongolian', 'Noto Sans Mono', 'Noto Sans Mono CJK HK', - 'Noto Sans Mono CJK JP', 'Noto Sans Mono CJK KR', 'Noto Sans Mono CJK SC', 'Noto Sans Mono CJK TC', - 'Noto Sans Mro', 'Noto Sans Multani', 'Noto Sans Myanmar', 'Noto Sans NKo', 'Noto Sans Nabataean', - 'Noto Sans New Tai Lue', 'Noto Sans Newa', 'Noto Sans Nushu', 'Noto Sans Ogham', 'Noto Sans Ol Chiki', - 'Noto Sans Old Hungarian', 'Noto Sans Old Italic', 'Noto Sans Old North Arabian', 'Noto Sans Old Permic', - 'Noto Sans Old Persian', 'Noto Sans Old Sogdian', 'Noto Sans Old South Arabian', 'Noto Sans Old Turkic', - 'Noto Sans OldHung', 'Noto Sans OldNorArab', 'Noto Sans OldSouArab', 'Noto Sans Oriya', - 'Noto Sans Osage', 'Noto Sans Osmanya', 'Noto Sans Pahawh Hmong', 'Noto Sans Palmyrene', - 'Noto Sans Pau Cin Hau', 'Noto Sans PhagsPa', 'Noto Sans Phoenician', 'Noto Sans PsaPahlavi', - 'Noto Sans Psalter Pahlavi', 'Noto Sans Rejang', 'Noto Sans Runic', 'Noto Sans Samaritan', - 'Noto Sans Saurashtra', 'Noto Sans Sharada', 'Noto Sans Shavian', 'Noto Sans Siddham', - 'Noto Sans SignWrit', 'Noto Sans SignWriting', 'Noto Sans Sinhala', 'Noto Sans Sogdian', - 'Noto Sans Sora Sompeng', 'Noto Sans Soyombo', 'Noto Sans Sundanese', 'Noto Sans Syloti Nagri', - 'Noto Sans Symbols', 'Noto Sans Symbols2', 'Noto Sans Syriac', 'Noto Sans Tagalog', 'Noto Sans Tagbanwa', - 'Noto Sans Tai Le', 'Noto Sans Tai Tham', 'Noto Sans Tai Viet', 'Noto Sans Takri', 'Noto Sans Tamil', - 'Noto Sans Tamil Supplement', 'Noto Sans Telugu', 'Noto Sans Thaana', 'Noto Sans Thai', - 'Noto Sans Tifinagh', 'Noto Sans Tifinagh APT', 'Noto Sans Tifinagh Adrar', 'Noto Sans Tifinagh Agraw Imazighen', - 'Noto Sans Tifinagh Ahaggar', 'Noto Sans Tifinagh Air', 'Noto Sans Tifinagh Azawagh', 'Noto Sans Tifinagh Ghat', - 'Noto Sans Tifinagh Hawad', 'Noto Sans Tifinagh Rhissa Ixa', 'Noto Sans Tifinagh SIL', - 'Noto Sans Tifinagh Tawellemmet', 'Noto Sans Tirhuta', 'Noto Sans Ugaritic', 'Noto Sans Vai', - 'Noto Sans Wancho', 'Noto Sans Warang Citi', 'Noto Sans Yi', 'Noto Sans Zanabazar', 'Noto Sans Zanabazar Square', - 'Noto Serif', 'Noto Serif Ahom', 'Noto Serif Armenian', 'Noto Serif Balinese', 'Noto Serif Bengali', - 'Noto Serif CJK HK', 'Noto Serif CJK JP', 'Noto Serif CJK KR', 'Noto Serif CJK SC', 'Noto Serif CJK TC', - 'Noto Serif Devanagari', 'Noto Serif Display', 'Noto Serif Dogra', 'Noto Serif Ethiopic', - 'Noto Serif Georgian', 'Noto Serif Grantha', 'Noto Serif Gujarati', 'Noto Serif Gurmukhi', - 'Noto Serif Hebrew', 'Noto Serif Hmong Nyiakeng', 'Noto Serif Kannada', 'Noto Serif Khmer', - 'Noto Serif Khojki', 'Noto Serif Lao', 'Noto Serif Malayalam', 'Noto Serif Myanmar', 'Noto Serif Sinhala', - 'Noto Serif Tamil', 'Noto Serif Tamil Slanted', 'Noto Serif Tangut', 'Noto Serif Telugu', - 'Noto Serif Thai', 'Noto Serif Tibetan', 'Noto Serif Yezidi', 'Noto Traditional Nushu', - 'OpenSymbol', 'P052', 'Palatino', 'Palatino Linotype', 'Standard Symbols PS', 'Symbol', - 'Times', 'Times New Roman', 'URW Bookman', 'URW Gothic', 'Ubuntu', 'Ubuntu Mono', 'Ubuntu Sans', - 'Ubuntu Sans Mono', 'Z003', 'Zapf Chancery', -] +with open(os.path.join(os.path.dirname(__file__), 'essential-fonts.json'), 'rb') as _f: + _ESSENTIAL = json.loads(_f.read()) +_ESSENTIAL_FONTS_WINDOWS: List[str] = _ESSENTIAL['win'] +_ESSENTIAL_FONTS_MACOS: List[str] = _ESSENTIAL['mac'] +_ESSENTIAL_FONTS_LINUX: List[str] = _ESSENTIAL['lin'] # OS-version variants of the base, drawn ALL-OR-NOTHING on top of the essential # core with the real-world share of that version (the manifest's base weights). A diff --git a/pythonlib/pyproject.toml b/pythonlib/pyproject.toml index fa4a272f4..6fb29c7c3 100644 --- a/pythonlib/pyproject.toml +++ b/pythonlib/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "camoufox" -version = "0.5.6" +version = "0.5.7" description = "Wrapper around Playwright to help launch Camoufox" authors = ["daijro "] license = "MIT" diff --git a/scripts/gen-fonts-json.py b/scripts/gen-fonts-json.py index 06a984cb3..fbeb1f863 100755 --- a/scripts/gen-fonts-json.py +++ b/scripts/gen-fonts-json.py @@ -35,9 +35,9 @@ The manifest lists, per OS, the base font sets of each OS version (with their real-world share) and the optional additions (Office, LibreOffice, developer and web fonts) with their install probability, per OS. --print-bases prints -the OS base lists (intersected with the result) as Python literals for the -_ESSENTIAL_FONTS_* constants in pythonlib/camoufox/fingerprints.py, which must -be kept in step with this file. +the OS base lists (intersected with the result) and writes the essential floor +to pythonlib/camoufox/essential-fonts.json, which both launchers read and which +must be kept in step with this file. """ import argparse import json @@ -321,6 +321,7 @@ def main(): # onto every identity -- which is what made a macOS 26 identity keep # claiming 131 Sonoma-only families -- and it must still carry the alias # names fonts.conf rewrites unconditionally, which always render. + essential = {} for os_key in OSDIRS: bases = list(bases_out[os_key].values()) if not bases: @@ -333,7 +334,18 @@ def main(): suffix = {'win': 'WINDOWS', 'mac': 'MACOS', 'lin': 'LINUX'}[os_key] print(f'# _ESSENTIAL_FONTS_{suffix}: {len(common)} families ' f'(intersection of {len(bases)} base(s) + aliases)') - print(json.dumps(sorted(common), ensure_ascii=False)) + essential[os_key] = sorted(common) + path = os.path.join(REPO, 'pythonlib', 'camoufox', 'essential-fonts.json') + # Both launchers read every OS's list at import, so a file missing one + # breaks `import camoufox`. Keep the old file rather than write that. + missing = [k for k in OSDIRS if k not in essential] + if missing: + sys.exit(f'not writing {path}: no OS bases for {", ".join(missing)} ' + f'in the manifest') + with open(path, 'w', encoding='utf-8') as f: + f.write('{\n' + ',\n'.join(f' {json.dumps(k)}: {json.dumps(v, ensure_ascii=False)}' + for k, v in essential.items()) + '\n}\n') + print(f'wrote {path}') if __name__ == '__main__': diff --git a/scripts/verify-fonts.py b/scripts/verify-fonts.py index 150da0279..5cf74925c 100755 --- a/scripts/verify-fonts.py +++ b/scripts/verify-fonts.py @@ -70,6 +70,11 @@ def load_constants(): name = node.targets[0].id if 'FONTS' in name and name.startswith('_'): consts[name] = ast.literal_eval(node.value) + # The OS bases live in essential-fonts.json, shared with the TS launcher. + with open(os.path.join(REPO, 'pythonlib', 'camoufox', 'essential-fonts.json'), encoding='utf-8') as f: + for key, fonts in json.load(f).items(): + suffix = {'win': 'WINDOWS', 'mac': 'MACOS', 'lin': 'LINUX'}[key] + consts[f'_ESSENTIAL_FONTS_{suffix}'] = fonts return consts diff --git a/typescript/.gitignore b/typescript/.gitignore new file mode 100644 index 000000000..f1e068e97 --- /dev/null +++ b/typescript/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +yarn.lock +.yarn +.npmrc +dist/ +*.mmdb diff --git a/typescript/LICENSE b/typescript/LICENSE new file mode 100644 index 000000000..be612e85c --- /dev/null +++ b/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 daijro and the Camoufox contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/typescript/README.md b/typescript/README.md new file mode 100644 index 000000000..12ff9e194 --- /dev/null +++ b/typescript/README.md @@ -0,0 +1,180 @@ +# camoufox (TypeScript) + +This is the JavaScript/TypeScript client for Camoufox. It is a port of the +Python wrapper in [`../pythonlib`](../pythonlib) โ€” it does **not** shell out +to Python. + +The two launchers are twins: they read the same `properties.json`, write the +same chunked `CAMOU_CONFIG`, share the same browser install directory, and +ship the same fingerprint presets, font/voice lists and GeoIP configuration. +They draw the same identities too: fingerprints and WebGL devices come from a +TypeScript port of [fpgen](https://github.com/scrapfly/fingerprint-generator) +using the same pinned model, and the per-identity draws (fonts, voices, GPU, +media devices, noise seeds) use a bit-exact port of CPython's `random`, so a +pinned identity presents identically from either language. + +## Installation + +```bash +npm install @camoufox/camoufox playwright-core +# then download the browser +npx camoufox fetch +``` + +`playwright-core` is a peer dependency โ€” bring your own version (`<1.63`, +the same ceiling as the Python package). Node 22.15 or newer is required. + +## Usage + +```javascript +import { Camoufox } from "@camoufox/camoufox"; + +const browser = await Camoufox({ + // any Camoufox option, plus any Playwright Firefox launch option + headless: true, + os: "windows", + geoip: true, +}); + +const page = await browser.newPage(); // a Playwright Page +await page.goto("https://example.com"); +await browser.close(); +``` + +### Persistent profiles + +```javascript +const context = await Camoufox({ user_data_dir: "./profiles/alice" }); +const page = await context.newPage(); +``` + +### Per-context identities + +`NewContext()` gives each context its own fingerprint โ€” real preset or +fpgen-synthesised โ€” with its own audio noise seed. The +values are applied through `addInitScript`, so the setters self-destruct before +any page script runs. + +```javascript +import { Camoufox, NewContext } from "@camoufox/camoufox"; + +const browser = await Camoufox({ headless: true }); +const context = await NewContext(browser, { + os: "macos", + proxy: { server: "http://proxy:8080", username: "u", password: "p" }, +}); +``` + +When a `proxy` is given and no `webrtc_ip`/`timezoneId` is, both are resolved +from the proxy's exit IP. If that lookup fails, `NewContext()` throws +`InvalidIP` rather than open a context that would show the host's values. + +### Server mode + +```javascript +import { launchServer } from "@camoufox/camoufox"; + +const server = await launchServer({ headless: true, port: 9222 }); +console.log(server.wsEndpoint()); +``` + +Persistent contexts are not servable โ€” Playwright's `launchServer` can only +expose a pre-launched `Browser`. + +### Building launch options yourself + +```javascript +import { launchOptions } from "@camoufox/camoufox"; +import { firefox } from "playwright-core"; + +const browser = await firefox.launch(await launchOptions({ os: "linux" })); +``` + +## Options + +Every option from the Python `launch_options()` is supported, with the same +snake_case names: `os`, `config`, `block_images`, `block_webrtc`, +`block_webgl`, `disable_coop`, `webgl_config`, `geoip`, `geoip_db`, `humanize`, +`locale`, `addons`, `fonts`, `custom_fonts_only`, `exclude_addons`, `screen`, +`window`, `fingerprint`, `fingerprint_preset`, `ff_version`, `headless`, +`main_world_eval`, `allow_addon_new_tab`, `executable_path`, `browser`, +`firefox_user_prefs`, `proxy`, `enable_cache`, `args`, `env`, +`i_know_what_im_doing`, `debug`, `virtual_display`, `pin_cpu_cores`. Anything else is passed +straight through to Playwright. + +The returned launch options use Playwright's camelCase keys +(`executablePath`, `firefoxUserPrefs`) rather than Python's snake_case. As in +Python, `headless: "virtual"` is handled by `Camoufox()`, `NewBrowser()` and +`launchServer()`, not by `launchOptions()`. + +## CLI + +``` +camoufox sync # refresh the version catalogue +camoufox fetch [version] # install the active or a specific version +camoufox set [specifier] # pin a version or channel; no specifier opens a picker +camoufox set --geoip # pick a GeoIP source +camoufox list [installed|all] # list versions +camoufox remove [version] # remove one version, or everything (--select to pick) +camoufox active # print the active version +camoufox path # print the install directory +camoufox version # version / storage info +camoufox test [url] # open the Playwright inspector +camoufox server # launch a Playwright server +``` + +The commands and pickers match the Python CLI. The one exception is `gui`, a +PySide6 desktop app that only the Python package provides. + +## Development + +The tests need a Python with pythonlib next to them, at the repo root (or +point `CAMOUFOX_PYTHON` at one): + +```bash +python3.14 -m venv .venv # repo root +.venv/bin/pip install -r ci/requirements.txt -e pythonlib +.venv/bin/python scripts/pin-fpgen-model.py +cd typescript +pnpm install +pnpm build # tsc -> dist/, then copy pythonlib's data files into dist/data-files +pnpm test # records the golden fixtures from pythonlib, then vitest +pnpm check # biome lint + format +pnpm typecheck # tsc --noEmit +``` + +The data files (presets, fonts, voices, territoryInfo.xml, ...) are read from +`pythonlib/camoufox/`, the only copy in the repo; `DATA_FILES` in +`src/paths.ts` lists them, and the build copies them into the package. + +### Parity with pythonlib + +The golden tests are what keep the two launchers twins. Before the suite runs, +`tests/golden-setup.ts` runs the scripts under `scripts/golden/`, which put the +Python code through hundreds of fixed inputs and record its output in +`tests/fixtures/` (git-ignored); the TS tests must reproduce it exactly -- +`launch_options()` byte for byte, including the `CAMOU_CONFIG` blob. A +pythonlib change that is not mirrored here fails `pnpm test`. Use Python 3.14: +`pySum()` follows its `sum()`, and on 3.12/3.13 that one test skips. + +The end-to-end suite launches a real browser through both launchers and compares +what a page sees: + +```bash +CAMOUFOX_E2E=1 CAMOUFOX_EXECUTABLE=/path/to/camoufox-bin pnpm test tests/e2e.test.ts +``` + +## Releasing + +`.github/workflows/publish-npm.yml` is dispatched by hand, like the PyPI +workflow. It type-checks, lints, tests, builds, runs `scripts/check-pack.mjs` +(the version must equal pythonlib's; every data file must be in the tarball; +the tarball must install and import in an empty project), then publishes with +npm trusted publishing -- no token is stored. + +## Licence + +MIT, like the Python package ([`LICENSE`](LICENSE)); the browser itself is +MPL-2.0. The package contains ports of fpgen (Apache-2.0), CPython's `random` +and NumPy's pairwise summation; their notices are in +[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md), which ships with it. diff --git a/typescript/THIRD_PARTY_NOTICES.md b/typescript/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..458f3d770 --- /dev/null +++ b/typescript/THIRD_PARTY_NOTICES.md @@ -0,0 +1,158 @@ +# Third-party notices + +The Camoufox TypeScript launcher is MIT-licensed (see `LICENSE`). It contains +ports of the third-party code below, each under its own licence. + +## fpgen โ€” `src/fpgen/` + +A TypeScript port of [fpgen](https://github.com/scrapfly/fingerprint-generator) +1.3.0, licensed under the Apache License 2.0. See `src/fpgen/NOTICE`, which +ships with the package. + +## CPython `random` โ€” `src/pyrandom.ts` + +A port of CPython's `random.Random`. The MT19937 core follows +`Modules/_randommodule.c`: + +``` +Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura, +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. The names of its contributors may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +``` + +The methods follow `Lib/random.py`, which is distributed under the Python +Software Foundation License: + +``` +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001-2024 Python Software Foundation; All Rights Reserved" +are retained in Python alone or in any derivative version prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. +``` + +## NumPy โ€” `src/locales.ts` + +A port of NumPy's pairwise float64 summation (`np.sum`): + +``` +Copyright (c) 2005-2025, NumPy Developers. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the NumPy Developers nor the names of any + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +The PCG64 bit generator it drives: + +``` +PCG Random Number Generation for C. + +Copyright 2014 Melissa O'Neill + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` diff --git a/typescript/biome.json b/typescript/biome.json new file mode 100644 index 000000000..fba97bb11 --- /dev/null +++ b/typescript/biome.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": ["src/**/*.ts", "tests/*.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noDocumentCookie": "off", + "noExplicitAny": "off", + "noImplicitAnyLet": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/typescript/package.json b/typescript/package.json new file mode 100644 index 000000000..4cac49d81 --- /dev/null +++ b/typescript/package.json @@ -0,0 +1,88 @@ +{ + "name": "@camoufox/camoufox", + "version": "0.5.7", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "rimraf dist && tsc && pnpm copy-files", + "copy-files": "node scripts/copy-files.mjs", + "test": "vitest run --config tests/vitest.config.ts", + "test:watch": "vitest --config tests/vitest.config.ts", + "check": "biome check", + "typecheck": "tsc --noEmit", + "prepare": "pnpm build", + "check:dist": "node scripts/check-dist-fresh.mjs", + "check:pack": "node scripts/check-pack.mjs" + }, + "bin": { + "camoufox": "dist/__main__.js" + }, + "files": [ + "dist", + "THIRD_PARTY_NOTICES.md" + ], + "type": "module", + "keywords": [ + "camoufox", + "firefox", + "playwright", + "fingerprint", + "anti-detect", + "scraping" + ], + "author": "", + "repository": { + "type": "git", + "url": "git+https://github.com/daijro/camoufox.git", + "directory": "typescript" + }, + "bugs": { + "url": "https://github.com/daijro/camoufox/issues" + }, + "engines": { + "node": ">= 22.15" + }, + "license": "MIT", + "description": "Camoufox: TypeScript launcher for the Camoufox anti-detect Firefox browser. A port of the Python wrapper.", + "//playwright-core": "Range mirrors pythonlib/pyproject.toml's `playwright = \"<1.63\"`: every Playwright minor is free to change Juggler, so the ceiling is bumped deliberately, with a run of `make tests`. The dev pin (1.62.0) is the version the Python venv resolves, so the goldens compare like with like.", + "packageManager": "pnpm@10.33.4", + "dependencies": { + "adm-zip": "^0.5.16", + "cli-progress": "^3.12.0", + "commander": "^14.0.0", + "impit": "^0.14.1", + "language-tags": "^2.0.1", + "maxmind": "^5.0.0", + "pretty-bytes": "^7.1.0", + "ua-parser-js": "^2.0.2", + "xml2js": "^0.6.2", + "yaml": "^2.9.1" + }, + "devDependencies": { + "@biomejs/biome": "2.4.10", + "@types/adm-zip": "^0.5.7", + "@types/cli-progress": "^3.11.6", + "@types/language-tags": "^1.0.4", + "@types/node": "^24.0.0", + "@types/xml2js": "^0.4.14", + "playwright-core": "1.62.0", + "rimraf": "^6.0.1", + "typescript": "^5.8.3", + "vitest": "^4.0.0" + }, + "peerDependencies": { + "playwright-core": "<1.63" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.4", + "onFail": "warn" + } + }, + "//engines": "22.15 for node:zlib zstd, which the fpgen model archive needs.", + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml new file mode 100644 index 000000000..8cad8bc1c --- /dev/null +++ b/typescript/pnpm-lock.yaml @@ -0,0 +1,1267 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + adm-zip: + specifier: ^0.5.16 + version: 0.5.18 + cli-progress: + specifier: ^3.12.0 + version: 3.12.0 + commander: + specifier: ^14.0.0 + version: 14.0.3 + impit: + specifier: ^0.14.1 + version: 0.14.3 + language-tags: + specifier: ^2.0.1 + version: 2.1.0 + maxmind: + specifier: ^5.0.0 + version: 5.0.7 + pretty-bytes: + specifier: ^7.1.0 + version: 7.1.1 + ua-parser-js: + specifier: ^2.0.2 + version: 2.0.10 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + yaml: + specifier: ^2.9.1 + version: 2.9.1 + devDependencies: + '@biomejs/biome': + specifier: 2.4.10 + version: 2.4.10 + '@types/adm-zip': + specifier: ^0.5.7 + version: 0.5.8 + '@types/cli-progress': + specifier: ^3.11.6 + version: 3.11.6 + '@types/language-tags': + specifier: ^1.0.4 + version: 1.0.4 + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 + playwright-core: + specifier: 1.62.0 + version: 1.62.0 + rimraf: + specifier: ^6.0.1 + version: 6.1.3 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^4.0.0 + version: 4.1.10(@types/node@24.13.3)(vite@8.2.0(@types/node@24.13.3)(yaml@2.9.1)) + +packages: + + '@biomejs/biome@2.4.10': + resolution: {integrity: sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.10': + resolution: {integrity: sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.10': + resolution: {integrity: sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.10': + resolution: {integrity: sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.10': + resolution: {integrity: sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.10': + resolution: {integrity: sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.10': + resolution: {integrity: sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.10': + resolution: {integrity: sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.10': + resolution: {integrity: sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@emnapi/core@2.0.0-alpha.3': + resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} + + '@emnapi/runtime@2.0.0-alpha.3': + resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} + + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@rolldown/binding-android-arm64@1.2.1': + resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.1': + resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.1': + resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.1': + resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.1': + resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.1': + resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.1': + resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.1': + resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.2.1': + resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.1': + resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/adm-zip@0.5.8': + resolution: {integrity: sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/cli-progress@3.11.6': + resolution: {integrity: sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/language-tags@1.0.4': + resolution: {integrity: sha512-20PQbifv3v/djCT+KlXybv0KqO5ofoR1qD1wkinN59kfggTPVTWGmPFgL/1yWuDyRcsQP/POvkqK+fnl5nOwTg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + adm-zip@0.5.18: + resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} + engines: {node: '>=12.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + cli-progress@3.12.0: + resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==} + engines: {node: '>=4'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + impit-darwin-arm64@0.14.3: + resolution: {integrity: sha512-kMoQB+CR+a954pnhe4kf1O2RyJCsqGRE95jIXfgqNOiIMS5O1z0cOMzG/sohJk/nodZVSJ5VdWEV4BKhRWPFSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + impit-darwin-x64@0.14.3: + resolution: {integrity: sha512-ft9+kjz1pR8H5xbbf5U1EgwaaIea5iXHxBf2Q3NoinPpiV7KgyzYSr83pCSrAY6evWk+smG9shHgzhQtgbj1Rg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + impit-linux-arm64-gnu@0.14.3: + resolution: {integrity: sha512-+mwta93S6Ndfpca3DDblvrqV1g8Bg6gZFOlEiJHfGJZpiUIQE+A/Pjg2e+inV+WrTtlnLsvff8lgwFvkIGnRug==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + impit-linux-arm64-musl@0.14.3: + resolution: {integrity: sha512-wYhfH1J8laWkpSN2SAFCKx9npY4vXrk23ex+tOzTGf2xBBqIhpMUff2wa6dOFb0or6EhZHBssoRf0HK3h/htXQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + impit-linux-x64-gnu@0.14.3: + resolution: {integrity: sha512-hAYnutJDGQO5bPvTPKBBFkHgbB7QL7QLDse8c4s21VSzC/EqGliSS3UqR5ZUUwaFdm3t8DJsXOr+gboAUwDR7w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + impit-linux-x64-musl@0.14.3: + resolution: {integrity: sha512-wXBeHiqCjuyqxg4u5eY4OBNVzsS4Karz/ms/0mjB8aqVZLNER1NaUTeE4J3eZCdytFaJRaAeQvKTH8HfjpPsQg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + impit-win32-arm64-msvc@0.14.3: + resolution: {integrity: sha512-XiF8MYpm4tF8TMbtsWrcEDDs6NxRNeirfiHM/AyKcr2jiN34hZwbZDM0EseD30PDwb2Pny2JWB+zTxpE1P5CZg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + impit-win32-x64-msvc@0.14.3: + resolution: {integrity: sha512-HIaLSpU5SGVx3UDH/R3ZaGMURVgyRVjmalqXj65ABxVUqfXiBo1t5ZObDPPjqvLT3klPnrya1+xF1DQ4dfT+og==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + impit@0.14.3: + resolution: {integrity: sha512-SbCLoeW0YDRox5kQoy71jtpjZE+BwjWO411OdgfbLMbNmSX79s5Y0Y2vCE7AO6zQSq9F4Fu8SJTCKnp8rXQzpg==} + engines: {node: '>= 20'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@2.1.0: + resolution: {integrity: sha512-D4CgpyCt+61f6z2jHjJS1OmZPviAWM57iJ9OKdFFWSNgS7Udj9QVWqyGs/cveVNF57XpZmhSvMdVIV5mjLA7Vg==} + engines: {node: '>=22'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + maxmind@5.0.7: + resolution: {integrity: sha512-+w637dwfv01MKjkrp4sKDBTEKHLPvWLYb647QTjiz3wG/teSemqudIKNShaS6eqZ7ffxC9oZlQQgIqY0rGojog==} + engines: {node: '>=12', npm: '>=6'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mmdb-lib@3.0.3: + resolution: {integrity: sha512-xQPoBXcNjjHiOvOraFBKtA++uNWF6aCVHL9dRKFXEov8eI3QJwtgiw3qApsonFT5SpoqsEVISUTg3HIDs2DiXw==} + engines: {node: '>=10', npm: '>=6'} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + pretty-bytes@7.1.1: + resolution: {integrity: sha512-X+vn9z8nOFZQlxOLmfJ0iKDdMD7jYTsTW12OAlCpdoE3Igik6L37pugIZi+N3usuyp5McfgKPWi12q3zvHLeGQ==} + engines: {node: '>=20'} + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rolldown@1.2.1: + resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + tiny-lru@13.0.0: + resolution: {integrity: sha512-xDHxKKS1FdF0Tv2P+QT7IeSEg74K/8cEDzbv3Tv6UyHHUgBOjOiQiBp818MGj66dhurQus/IBcoAbwIKtSGc6Q==} + engines: {node: '>=14'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + + ua-parser-js@2.0.10: + resolution: {integrity: sha512-t+3Ktbq0Ies2vaSezfOaWiolH4OigQIO1dk+1xDpOydB1COVPocVYOrEV5rqZ0kFY9XYG1v9LutCyMgYBpABcw==} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@biomejs/biome@2.4.10': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.10 + '@biomejs/cli-darwin-x64': 2.4.10 + '@biomejs/cli-linux-arm64': 2.4.10 + '@biomejs/cli-linux-arm64-musl': 2.4.10 + '@biomejs/cli-linux-x64': 2.4.10 + '@biomejs/cli-linux-x64-musl': 2.4.10 + '@biomejs/cli-win32-arm64': 2.4.10 + '@biomejs/cli-win32-x64': 2.4.10 + + '@biomejs/cli-darwin-arm64@2.4.10': + optional: true + + '@biomejs/cli-darwin-x64@2.4.10': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.10': + optional: true + + '@biomejs/cli-linux-arm64@2.4.10': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.10': + optional: true + + '@biomejs/cli-linux-x64@2.4.10': + optional: true + + '@biomejs/cli-win32-arm64@2.4.10': + optional: true + + '@biomejs/cli-win32-x64@2.4.10': + optional: true + + '@emnapi/core@2.0.0-alpha.3': + dependencies: + '@emnapi/wasi-threads': 2.0.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@2.0.0-alpha.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@2.0.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.142.0': {} + + '@rolldown/binding-android-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-x64@1.2.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.2.1': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/adm-zip@0.5.8': + dependencies: + '@types/node': 24.13.3 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/cli-progress@3.11.6': + dependencies: + '@types/node': 24.13.3 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/language-tags@1.0.4': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 24.13.3 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3)(yaml@2.9.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.0(@types/node@24.13.3)(yaml@2.9.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + adm-zip@0.5.18: {} + + ansi-regex@5.0.1: {} + + assertion-error@2.0.1: {} + + balanced-match@4.0.4: {} + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + chai@6.2.2: {} + + cli-progress@3.12.0: + dependencies: + string-width: 4.2.3 + + commander@14.0.3: {} + + convert-source-map@2.0.0: {} + + detect-europe-js@0.1.2: {} + + detect-libc@2.1.2: {} + + emoji-regex@8.0.0: {} + + es-module-lexer@2.3.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + impit-darwin-arm64@0.14.3: + optional: true + + impit-darwin-x64@0.14.3: + optional: true + + impit-linux-arm64-gnu@0.14.3: + optional: true + + impit-linux-arm64-musl@0.14.3: + optional: true + + impit-linux-x64-gnu@0.14.3: + optional: true + + impit-linux-x64-musl@0.14.3: + optional: true + + impit-win32-arm64-msvc@0.14.3: + optional: true + + impit-win32-x64-msvc@0.14.3: + optional: true + + impit@0.14.3: + optionalDependencies: + impit-darwin-arm64: 0.14.3 + impit-darwin-x64: 0.14.3 + impit-linux-arm64-gnu: 0.14.3 + impit-linux-arm64-musl: 0.14.3 + impit-linux-x64-gnu: 0.14.3 + impit-linux-x64-musl: 0.14.3 + impit-win32-arm64-msvc: 0.14.3 + impit-win32-x64-msvc: 0.14.3 + + is-fullwidth-code-point@3.0.0: {} + + is-standalone-pwa@0.1.1: {} + + language-subtag-registry@0.3.23: {} + + language-tags@2.1.0: + dependencies: + language-subtag-registry: 0.3.23 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@11.5.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + maxmind@5.0.7: + dependencies: + mmdb-lib: 3.0.3 + tiny-lru: 13.0.0 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minipass@7.1.3: {} + + mmdb-lib@3.0.3: {} + + nanoid@3.3.16: {} + + obug@2.1.4: {} + + package-json-from-dist@1.0.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.62.0: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-bytes@7.1.1: {} + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rolldown@1.2.1: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.1 + '@rolldown/binding-darwin-arm64': 1.2.1 + '@rolldown/binding-darwin-x64': 1.2.1 + '@rolldown/binding-freebsd-x64': 1.2.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 + '@rolldown/binding-linux-arm64-gnu': 1.2.1 + '@rolldown/binding-linux-arm64-musl': 1.2.1 + '@rolldown/binding-linux-ppc64-gnu': 1.2.1 + '@rolldown/binding-linux-s390x-gnu': 1.2.1 + '@rolldown/binding-linux-x64-gnu': 1.2.1 + '@rolldown/binding-linux-x64-musl': 1.2.1 + '@rolldown/binding-openharmony-arm64': 1.2.1 + '@rolldown/binding-wasm32-wasi': 1.2.1 + '@rolldown/binding-win32-arm64-msvc': 1.2.1 + '@rolldown/binding-win32-x64-msvc': 1.2.1 + + sax@1.6.1: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + tiny-lru@13.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + ua-is-frozen@0.1.2: {} + + ua-parser-js@2.0.10: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 + + undici-types@7.18.2: {} + + vite@8.2.0(@types/node@24.13.3)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + yaml: 2.9.1 + + vitest@4.1.10(@types/node@24.13.3)(vite@8.2.0(@types/node@24.13.3)(yaml@2.9.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(yaml@2.9.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@24.13.3)(yaml@2.9.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml2js@0.6.2: + dependencies: + sax: 1.6.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + yaml@2.9.1: {} diff --git a/typescript/pnpm-workspace.yaml b/typescript/pnpm-workspace.yaml new file mode 100644 index 000000000..39d709825 --- /dev/null +++ b/typescript/pnpm-workspace.yaml @@ -0,0 +1,20 @@ +# 24h quarantine on anything freshly published, as supply-chain cover. +minimumReleaseAge: 1440 +# captchakraken is exempt because it is OURS -- JWriter20/CaptchaKraken, the +# same hands that publish this package. The window exists to put distance +# between a compromised third-party release and our lockfile; it buys nothing +# against a repo we control, and it does cost something: the camoufox bridge and +# the solver ship in step, so a same-day captchakraken release is the normal +# case here, not a red flag. Scoped to this one name, never widened. +minimumReleaseAgeExclude: + - captchakraken + +onlyBuiltDependencies: + - esbuild + - playwright-core + +nodeLinker: hoisted +linkWorkspacePackages: true +preferWorkspacePackages: true +publicHoistPattern: + - "*" diff --git a/typescript/renovate.json b/typescript/renovate.json new file mode 100644 index 000000000..8605bee78 --- /dev/null +++ b/typescript/renovate.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "dependencyDashboard": false, + "minimumReleaseAge": "1 day", + "internalChecksFilter": "strict" +} diff --git a/typescript/scripts/check-dist-fresh.mjs b/typescript/scripts/check-dist-fresh.mjs new file mode 100644 index 000000000..5667c5f6a --- /dev/null +++ b/typescript/scripts/check-dist-fresh.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Fail if `dist/` is missing or older than `src/`. + * + * `src/captcha.ts` was added and then sat uncompiled: `dist/captcha.js` did not + * exist at all, so `import { solveCaptcha } from "camoufox"` resolved to a build + * that had never heard of it. Nothing complained โ€” `dist/` is gitignored, the + * package still imported, and only the missing export gave it away at runtime, + * as `CaptchaSolverUnavailable`, which reads like a missing optional dependency + * rather than a stale build. + * + * `prepare` now rebuilds on install, which covers the common path. This is the + * guard for the rest: run it in CI or a pre-commit hook and a stale build is a + * red check instead of a confusing runtime error. + */ +import { readdirSync, statSync, existsSync } from "node:fs"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const src = join(root, "src"); +const dist = join(root, "dist"); + +if (!existsSync(dist)) { + console.error("dist/ does not exist โ€” run `pnpm build`."); + process.exit(1); +} + +/** Every file under `dir`, recursively. */ +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else out.push(full); + } + return out; +} + +const newestDist = Math.max(...walk(dist).map((f) => statSync(f).mtimeMs)); + +// Sources newer than the newest build output. Anything here is not in dist. +const stale = walk(src) + .filter((f) => statSync(f).mtimeMs > newestDist) + .map((f) => relative(root, f)); + +// A .ts source with no corresponding .js in dist was never compiled at all โ€” +// the captcha.ts case, and the one that actually bit. +const missing = walk(src) + .filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts")) + .filter((f) => !existsSync(join(dist, relative(src, f).replace(/\.ts$/, ".js")))) + .map((f) => relative(root, f)); + +if (missing.length || stale.length) { + if (missing.length) { + console.error("Never compiled into dist/:"); + for (const f of missing) console.error(` ${f}`); + } + if (stale.length) { + console.error("Newer than dist/:"); + for (const f of stale) console.error(` ${f}`); + } + console.error("\nRun `pnpm build`."); + process.exit(1); +} + +console.log("dist/ is up to date with src/"); diff --git a/typescript/scripts/check-pack.mjs b/typescript/scripts/check-pack.mjs new file mode 100644 index 000000000..99b824a4b --- /dev/null +++ b/typescript/scripts/check-pack.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * The npm twin of `twine check`: prove the tarball `npm publish` would upload is + * a working package before it goes anywhere. + * + * 1. its version equals pythonlib's (the two launchers ship in lockstep, and + * a user comparing `camoufox version` across them should see one number); + * 2. it carries every file src/ reads at runtime -- the DATA_FILES it takes + * from pythonlib and any non-TS file under src/ -- since a file missing + * only shows up on a user's machine, as an ENOENT from inside dist/; + * 3. installed into an empty project, it imports and exposes its entry + * points, and its CLI starts. + * + * Run after `pnpm build`: node scripts/check-pack.mjs + */ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +const problems = []; + +// 1. version lockstep with pythonlib +const pyproject = readFileSync(join(root, "..", "pythonlib", "pyproject.toml"), "utf8"); +const pyVersion = pyproject.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; +if (pyVersion !== pkg.version) { + problems.push(`package.json version ${pkg.version} != pythonlib ${pyVersion}`); +} +if (pkg.private) problems.push("package.json is private: npm will refuse to publish it"); + +// 2. every runtime data file is in the tarball +// npm 10 still runs `prepare` (the build) on pack despite --ignore-scripts, and +// its banner lands on stdout ahead of the JSON. +const packOut = execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: root, + encoding: "utf8", +}); +const packed = JSON.parse(packOut.slice(packOut.search(/^\[/m)))[0]; +const inTarball = new Set(packed.files.map((f) => f.path)); +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)], + ); +} +const src = join(root, "src"); +for (const file of walk(src)) { + const rel = relative(src, file); + if (/\.ts$/.test(rel)) { + const js = `dist/${rel.replace(/\.ts$/, ".js")}`; + if (!rel.endsWith(".d.ts") && !inTarball.has(js)) problems.push(`missing ${js}`); + } else if (!inTarball.has(`dist/${rel}`)) { + problems.push(`missing dist/${rel} (a non-TS file under src/ that copy-files does not ship)`); + } +} +const { DATA_FILES } = await import(join(root, "dist", "paths.js")); +for (const name of DATA_FILES) { + if (!inTarball.has(`dist/data-files/${name}`)) problems.push(`missing dist/data-files/${name}`); +} +console.log(`${packed.filename}: ${packed.entryCount} files, ${(packed.size / 1e6).toFixed(1)} MB packed`); + +// 3. installs and imports in a clean project +if (problems.length === 0) { + const tmp = mkdtempSync(join(tmpdir(), "camoufox-pack-")); + try { + const tgz = execFileSync("npm", ["pack", "--ignore-scripts", "--pack-destination", tmp], { + cwd: root, + encoding: "utf8", + }) + .trim() + .split("\n") + .pop(); + writeFileSync(join(tmp, "package.json"), '{"name":"pack-check","private":true,"type":"module"}'); + execFileSync("npm", ["install", "--no-audit", "--no-fund", join(tmp, tgz), `playwright-core@${pkg.peerDependencies["playwright-core"]}`], { + cwd: tmp, + stdio: "inherit", + }); + const probe = ` + const m = await import(${JSON.stringify(pkg.name)}); + for (const name of ["Camoufox", "NewBrowser", "launchOptions"]) { + if (typeof m[name] !== "function") throw new Error("missing export " + name); + } + console.log("exports:", Object.keys(m).length); + `; + execFileSync("node", ["--input-type=module", "-e", probe], { cwd: tmp, stdio: "inherit" }); + for (const bin of Object.keys(pkg.bin ?? {})) { + execFileSync("npx", ["--no-install", bin, "--help"], { cwd: tmp, stdio: "ignore" }); + } + } catch (err) { + problems.push(`clean install failed: ${err.message}`); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +if (problems.length) { + console.error(problems.map((p) => ` - ${p}`).join("\n")); + process.exit(1); +} +console.log("pack check OK"); diff --git a/typescript/scripts/copy-files.mjs b/typescript/scripts/copy-files.mjs new file mode 100644 index 000000000..731f00b5f --- /dev/null +++ b/typescript/scripts/copy-files.mjs @@ -0,0 +1,25 @@ +// Ship every non-TS file the package needs at runtime into dist/: +// DATA_FILES (src/paths.ts) from pythonlib/camoufox/ -> dist/data-files/ +// (presets, fonts, voices, territoryInfo.xml, repos.yml, warnings.yml, ...) +// src/fpgen/NOTICE -> dist/fpgen/NOTICE (Apache-2.0 attribution for the port) +// Runs after tsc, so the list comes from the compiled paths.js. +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const PYTHONLIB = path.resolve(ROOT, "..", "pythonlib", "camoufox"); +const DATA = path.join(ROOT, "dist", "data-files"); + +fs.rmSync(DATA, { recursive: true, force: true }); +const { DATA_FILES } = await import(path.join(ROOT, "dist", "paths.js")); +fs.mkdirSync(DATA, { recursive: true }); +for (const name of DATA_FILES) { + fs.copyFileSync(path.join(PYTHONLIB, name), path.join(DATA, name)); +} +console.log(`copied ${DATA_FILES.length} data files from pythonlib -> dist/data-files`); + +const notice = path.join(ROOT, "dist", "fpgen", "NOTICE"); +fs.mkdirSync(path.dirname(notice), { recursive: true }); +fs.copyFileSync(path.join(ROOT, "src", "fpgen", "NOTICE"), notice); +console.log("copied src/fpgen/NOTICE -> dist/fpgen/NOTICE"); diff --git a/typescript/scripts/e2e/python_probe.py b/typescript/scripts/e2e/python_probe.py new file mode 100644 index 000000000..25a661f36 --- /dev/null +++ b/typescript/scripts/e2e/python_probe.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Launch the Python camoufox with a fixed identity and run the shared page probe. + +Used by tests/e2e.test.ts to compare the TypeScript launcher against Python on +the same binary and the same identity: + + echo '{"mode": "headless", "url": "...", "kwargs": {...}}' | \ + .venv/bin/python typescript/scripts/e2e/python_probe.py + +Prints one JSON object: {"probe": , "config": }. +`mode` is "config-only" (launch_options() alone), "headless" (Camoufox(...) -> new_page), +"persistent" (Camoufox(persistent_context=True, user_data_dir=...)), "virtual" +(Camoufox(headless="virtual")) or "context" +(Camoufox(...) -> NewContext(browser, preset=req["preset"]) -> new_page). + +stdout carries the JSON and nothing else: pythonlib prints to stdout (e.g. +"Skipping unknown patch" when the binary predates a config key), so everything +the launch prints is sent to stderr instead. +""" + +import contextlib +import json +import sys +import tempfile +import warnings +from pathlib import Path + +from camoufox.sync_api import Camoufox, NewContext +from camoufox.utils import launch_options + +PROBE = (Path(__file__).resolve().parent.parent.parent / 'tests' / 'fixtures' / 'e2e' / 'probe.js').read_text() + + +def config_of(options): + env = options['env'] + chunks = sorted((int(k.rsplit('_', 1)[1]), v) for k, v in env.items() if k.startswith('CAMOU_CONFIG_')) + return json.loads(''.join(v for _, v in chunks)) + + +def main(): + req = json.loads(sys.stdin.read()) + with contextlib.redirect_stdout(sys.stderr): + out = run(req) + json.dump(out, sys.stdout) + + +def run(req): + kwargs = req['kwargs'] + warnings.simplefilter('ignore') + config = config_of(launch_options(**kwargs)) + if req['mode'] == 'config-only': + return {'config': config} + if req['mode'] == 'context': + with Camoufox(**kwargs) as browser: + context = NewContext(browser, preset=req['preset']) + page = context.new_page() + page.goto(req['url']) + return {'probe': page.evaluate(PROBE), 'config': config} + with tempfile.TemporaryDirectory() as profile: + extra = {'persistent_context': True, 'user_data_dir': profile} if req['mode'] == 'persistent' else {} + if req['mode'] == 'virtual': + extra = {'headless': 'virtual'} + kwargs = {k: v for k, v in kwargs.items() if k != 'headless'} + with Camoufox(**kwargs, **extra) as browser: + page = browser.new_page() + page.goto(req['url']) + if req['mode'] == 'virtual': + # As the TS side does: Firefox defers enumerateDevices() until + # the document has focus, which a headful window on a bare Xvfb + # only sometimes gets. Wait for it, as a user's window has it. + for _ in range(50): + page.bring_to_front() + if page.evaluate('document.hasFocus()'): + break + page.wait_for_timeout(200) + else: + raise RuntimeError('the headful page never got focus on Xvfb') + probe = page.evaluate(PROBE) + return {'probe': probe, 'config': config} + + +if __name__ == '__main__': + main() diff --git a/typescript/scripts/golden/fpgen_golden.py b/typescript/scripts/golden/fpgen_golden.py new file mode 100644 index 000000000..a9bf3b787 --- /dev/null +++ b/typescript/scripts/golden/fpgen_golden.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""Golden fixtures for the TypeScript fpgen port (typescript/src/fpgen/). + +Run from the repo root with the worktree venv, which has fpgen and the PINNED +model installed (scripts/pin-fpgen-model.py): + + .venv/bin/python typescript/scripts/golden/fpgen_golden.py + +Writes typescript/tests/fixtures/fpgen/*.json. Everything except stats.json is +deterministic -- network structure, value lookups, beam-search distributions, +condition -> evidence, query() -- and the TS tests compare against it exactly. +stats.json holds marginal counts from real Python draws; the TS test compares +its own draws against them statistically, because Python samples with +`random.random()` and cannot be matched draw-for-draw. + +The CASES tables below are mirrored by name in typescript/tests/fpgen*.test.ts; +predicates are named because a lambda cannot be serialised. +""" + +import hashlib +import json +import os +import random +import re +import struct +import sys +import time +from collections import Counter +from multiprocessing import Pool +from pathlib import Path + +HERE = Path(__file__).resolve().parent +TS_ROOT = HERE.parent.parent +REPO = TS_ROOT.parent +OUT = TS_ROOT / 'tests' / 'fixtures' / 'fpgen' +PIN = json.loads((REPO / 'scripts' / 'data' / 'fpgen-model.json').read_text()) + +STATS_N = int(os.environ.get('FPGEN_GOLDEN_STATS_N', '3000')) + + +def _guard_model(): + """Refuse to run against anything but the pinned model, and keep fpgen from + 'refreshing' it: fpgen re-downloads (unverified, wrong release) when its + files look older than five weeks, so bump their mtime before importing.""" + import importlib.util + + spec = importlib.util.find_spec('fpgen') + if spec is None or not spec.origin: + sys.exit('fpgen is not installed in this interpreter') + data = Path(spec.origin).parent / 'data' + stamp = data / '.pinned-model' + if not stamp.exists() or stamp.read_text().strip() != PIN['sha256']: + sys.exit(f'fpgen model in {data} is not pinned to {PIN["tag"]}; ' + 'run scripts/pin-fpgen-model.py first') + now = time.time() + for f in PIN['files']: + os.utime(data / f, (now, now)) + + +_guard_model() + +import fpgen # noqa: E402 +import fpgen.bayesian_network as bn # noqa: E402 +from fpgen import Generator, query, trace # noqa: E402 +from fpgen.unpacker import VALUE_PAIRS, base85_to_int, lookup_value_list # noqa: E402 +from fpgen.utils import NETWORK, build_evidence # noqa: E402 + +# Count beam prunings so the fixture can prove the exact-match traces cover them. +_PRUNES = [0] +_nlargest = bn.heapq.nlargest + + +def _counting_nlargest(*a, **k): + _PRUNES[0] += 1 + return _nlargest(*a, **k) + + +bn.heapq.nlargest = _counting_nlargest + +# Predicates, by name. The TS test defines the same names. +PREDICATES = { + 'screen_width_1280_1920': lambda w: isinstance(w, int) and 1280 <= w <= 1920, + 'os_linux_or_windows': lambda v: v in {'linux', 'windows'}, + 'hc_at_least_8': lambda v: isinstance(v, int) and v >= 8, + 'ua_rv146': lambda v: 'rv:146' in v, + 'never': lambda v: False, +} + + +class Pred: + def __init__(self, name): + self.name = name + + +class Alt: + """A tuple of alternatives (a Set in TS).""" + + def __init__(self, *values): + self.values = values + + +def to_py(cond): + if isinstance(cond, Pred): + return PREDICATES[cond.name] + if isinstance(cond, Alt): + return tuple(to_py(v) for v in cond.values) + if isinstance(cond, dict): + return {k: to_py(v) for k, v in cond.items()} + return cond + + +def to_fixture(cond): + if isinstance(cond, Pred): + return {'$pred': cond.name} + if isinstance(cond, Alt): + return {'$alt': [to_fixture(v) for v in cond.values]} + if isinstance(cond, dict): + return {k: to_fixture(v) for k, v in cond.items()} + return cond + + +def chrome_ua(): + uas = query('navigator.userAgent') + return next(u for u in uas if 'Chrome/' in u and 'Firefox' not in u) + + +CONDITION_CASES = [ + ('empty', {}), + ('firefox', {'browser': 'Firefox'}), + ('firefox_windows', {'browser': 'Firefox', 'os': 'Windows'}), + ('firefox_linux', {'browser': 'Firefox', 'os': 'Linux'}), + ('firefox_macos', {'browser': 'Firefox', 'os': 'macOS'}), + ('casefolded_keys_values', {'Browser': 'firefox', 'OS': 'WINDOWS'}), + ('nested_dict', {'browser': 'Firefox', 'screen': {'width': 1920, 'height': 1080}}), + ('dotted_key', {'browser': 'Firefox', 'screen.width': 2560}), + ('alt_os', {'browser': 'Firefox', 'os': Alt('Linux', 'Windows')}), + ('alt_nested', {'browser': 'Firefox', 'screen.width': Alt(1920, 2560)}), + ('pred_screen', {'browser': 'Firefox', 'os': 'Windows', + 'screen.width': Pred('screen_width_1280_1920')}), + ('pred_os', {'browser': 'Firefox', 'os': Pred('os_linux_or_windows')}), + ('pred_hc', {'browser': 'Firefox', 'navigator.hardwareConcurrency': Pred('hc_at_least_8')}), + ('pred_ua', {'navigator.userAgent': Pred('ua_rv146')}), + ('list_value', {'navigator.languages': ['en-US', 'en']}), + ('nested_string', {'browser': 'Firefox', 'gpu': {'vendor': 'Apple'}}), + ('device_memory', {'navigator.deviceMemory': 8}), + ('hc_int', {'browser': 'Firefox', 'os': 'Linux', 'navigator.hardwareConcurrency': 16}), +] + +ERROR_CASES = [ + ('bad_value', {'os': 'Plan9'}, True), + ('bad_node', {'nosuch.node': 1}, True), + ('bad_root_node', {'nosuch': 1}, True), + ('empty_key', {'': 1}, True), + ('bad_nested_value', {'screen.width': 12345}, True), + ('bad_nested_path', {'screen.nosuchfield': 1}, True), + ('pred_never', {'os': Pred('never')}, True), + ('pred_never_nested', {'screen.width': Pred('never')}, True), + ('restrictive', {'browser': 'Firefox', 'navigator.userAgent': '$CHROME_UA'}, True), + # Not strict: fpgen drops the FIRST key and does not re-validate. + ('restrictive_relaxed', {'browser': 'Firefox', 'navigator.userAgent': '$CHROME_UA'}, False), +] + +TRACE_TARGETS = [ + 'os', 'navigator.userAgent', 'screen', 'navigator.hardwareConcurrency', 'gpu', 'gpuInfo', +] +# Deep nodes with big supports, traced for a few cases only to keep the fixture small. +EXTRA_TRACE_TARGETS = ['allFonts', 'window'] +EXTRA_TRACE_CASES = {'empty', 'firefox_linux', 'list_value', 'pred_hc'} + + +def subst(cond, ua): + if cond == '$CHROME_UA': + return ua + if isinstance(cond, dict): + return {k: subst(v, ua) for k, v in cond.items()} + return cond + + +def evidence_fixture(evidence): + return [[k, sorted(v, key=base85_to_int)] for k, v in evidence.items()] + + +def dist_fixture(dist): + return [[k, p] for k, p in dist.items()] + + +def float_hex(x): + return struct.pack('>d', float(x)).hex() + + +def cpt_canonical(o): + if isinstance(o, dict): + return '{' + ','.join(json.dumps(k) + ':' + cpt_canonical(v) for k, v in o.items()) + '}' + return float_hex(o) + + +def sha(s): + return hashlib.sha256(s.encode('utf-8')).hexdigest() + + +def write(name, data): + OUT.mkdir(parents=True, exist_ok=True) + path = OUT / name + path.write_text(json.dumps(data, separators=(',', ':'), ensure_ascii=False) + '\n') + print(f'{path.relative_to(REPO)}: {path.stat().st_size} bytes', file=sys.stderr) + + +def all_ids(): + ids = set() + for node in NETWORK.nodes_in_sampling_order: + ids.update(node.possible_values) + return sorted(ids, key=base85_to_int) + + +def gen_structure(): + nodes = [] + for node in NETWORK.nodes_in_sampling_order: + nodes.append({ + 'name': node.name, + 'parentNames': node.parent_names, + 'possibleValues': node.possible_values, + 'cptSha256': sha(cpt_canonical(node.probabilities)), + 'ancestors': sorted(NETWORK.get_all_ancestors(node.name)), + }) + ids = all_ids() + return { + 'pin': PIN['sha256'], + 'fpgenVersion': '1.3.0', + 'nodeNames': list(NETWORK.node_names), + 'nodes': nodes, + 'valuePairs': { + 'count': len(VALUE_PAIRS), + 'sha256': sha(''.join(f'{int(o, 16)}:{n};' for o, n in VALUE_PAIRS)), + }, + 'base85': { + 'count': len(ids), + 'sha256': sha(''.join(f'{i}={base85_to_int(i)};' for i in ids)), + 'samples': {i: base85_to_int(i) for i in ids[:: max(1, len(ids) // 200)]}, + }, + } + + +def gen_values(): + ids = all_ids() + rng = random.Random(1234) + sample = sorted(rng.sample(ids, 300), key=base85_to_int) + texts = lookup_value_list(sample) + samples = [] + for i, t in zip(sample, texts): + entry = {'id': i, 'length': len(t.encode()), 'sha256': sha(t)} + if len(t) <= 160: + entry['text'] = t + samples.append(entry) + # Digest of every referenced value, in id order, in chunks. + h = hashlib.sha256() + for n in range(0, len(ids), 500): + for t in lookup_value_list(ids[n:n + 500]): + h.update(sha(t).encode()) + return {'samples': samples, 'allIdsDigest': h.hexdigest(), 'idCount': len(ids)} + + +def gen_conditions(ua): + out = [] + for name, cond in CONDITION_CASES: + evidence = {} + build_evidence(to_py(cond), evidence) + traces = {} + before = _PRUNES[0] + targets = TRACE_TARGETS + (EXTRA_TRACE_TARGETS if name in EXTRA_TRACE_CASES else []) + for target in targets: + traces[target] = dist_fixture(NETWORK.trace(target, evidence)) + out.append({ + 'name': name, + 'conditions': to_fixture(cond), + 'evidence': evidence_fixture(evidence), + 'traces': traces, + 'pruned': _PRUNES[0] - before, + }) + errors = [] + for name, cond, strict in ERROR_CASES: + cond = subst(cond, ua) + evidence = {} + entry = {'name': name, 'conditions': to_fixture(cond), 'strict': strict} + try: + build_evidence(to_py(cond), evidence, strict=strict) + entry['evidence'] = evidence_fixture(evidence) + except Exception as exc: # noqa: BLE001 -- recording the class is the point + entry['error'] = type(exc).__name__ + entry['message'] = str(exc) + errors.append(entry) + return {'cases': out, 'errors': errors} + + +def trace_results(res): + if isinstance(res, list): + return [{'value': r.value, 'probability': r.probability} for r in res] + return {k: trace_results(v) for k, v in res.items()} + + +def gen_api(): + """Public-API outputs: trace(), Generator.trace, query(), deterministic generate().""" + traces = [] + for name, target, cond, kw in [ + ('os_firefox', 'os', {'browser': 'Firefox'}, {}), + ('hc_firefox_linux', 'navigator.hardwareConcurrency', + {'browser': 'Firefox', 'os': 'Linux'}, {}), + ('screen_firefox_macos', 'screen', {'browser': 'Firefox', 'os': 'macOS'}, {}), + ('inside_node', 'screen.width', {'browser': 'Firefox', 'os': 'Linux'}, {}), + ('two_targets_nested', ['navigator.platform', 'navigator.oscpu'], {'browser': 'Firefox'}, {}), + ('two_targets_flat', ['navigator.platform', 'navigator.oscpu'], {'browser': 'Firefox'}, + {'flatten': True}), + ('prefix_target', 'headers.sec-fetch', {'browser': 'Firefox'}, {}), + ('pred', 'os', {'browser': 'Firefox', 'os': Pred('os_linux_or_windows')}, {}), + ]: + traces.append({ + 'name': name, 'target': target, 'conditions': to_fixture(cond), 'options': kw, + 'result': trace_results(trace(target, to_py(cond), **kw)), + }) + gen_trace = trace_results(Generator(browser='Firefox', os='Windows').trace('navigator.platform')) + + queries = [] + for target, kw in [ + ('os', {}), ('os', {'sort': True}), + ('navigator.hardwareConcurrency', {}), ('navigator.hardwareConcurrency', {'sort': True}), + ('navigator.deviceMemory', {'sort': True}), + ('screen', {'sort': True}), ('screen', {'sort': True, 'flatten': True}), + ('screen.width', {'sort': True}), + ('window', {'sort': True}), + ('windowComponents', {}), + ('navigator', {'sort': True}), ('navigator', {'sort': True, 'flatten': True}), + ('matchmedia', {'sort': True}), + ('headers', {'sort': True, 'flatten': True}), + ('permissions.geolocation', {}), + # The only node whose merged lists mix ints and integral floats (grouping by type). + ('audio.values', {'sort': True}), + ]: + queries.append({'target': target, 'options': kw, 'result': query(target, **kw)}) + query_errors = [] + for target in ['nosuch', 'screen.nosuchfield', 'navigator.nosuch', 'matchMedia']: + try: + query(target) + query_errors.append({'target': target, 'error': None}) + except Exception as exc: # noqa: BLE001 + query_errors.append({'target': target, 'error': type(exc).__name__, 'message': str(exc)}) + + # generate() outputs that do not depend on the draw (checked by repetition). + deterministic = [] + for name, cond, kw in [ + ('appName', {'browser': 'Firefox'}, {'target': 'navigator.appName'}), + ('two', {'browser': 'Firefox'}, {'target': ['navigator.appCodeName', 'navigator.productSub']}), + ('platform_win', {'browser': 'Firefox', 'os': 'Windows'}, {'target': 'navigator.platform'}), + ('platform_mac', {'browser': 'Firefox', 'os': 'macOS'}, {'target': 'navigator.platform'}), + ('casefold_target', {'browser': 'Firefox', 'os': 'Windows'}, {'target': 'NAVIGATOR.PLATFORM'}), + ('flat_target', {'browser': 'Firefox', 'os': 'Windows'}, + {'target': ['navigator.appName', 'navigator.platform'], 'flatten': True}), + ]: + outs = [Generator().generate(to_py(cond), **kw) for _ in range(20)] + if any(o != outs[0] for o in outs): + sys.exit(f'deterministic case {name} is not deterministic') + deterministic.append({'name': name, 'conditions': cond, 'options': kw, 'result': outs[0]}) + + g = Generator(browser='Firefox') + shapes = { + 'topLevelKeys': sorted(g.generate().keys()), + 'navigatorKeys': sorted(g.generate(target='navigator').keys()), + 'flatKeysSample': sorted(k for k in g.generate(flatten=True) if k.startswith('navigator.')), + } + return {'traces': traces, 'generatorTrace': gen_trace, 'queries': queries, + 'queryErrors': query_errors, 'deterministic': deterministic, 'shapes': shapes} + + +# ---- statistics ----------------------------------------------------------- + +STATS_SCENARIOS = [ + ('firefox_any', {'browser': 'Firefox'}), + ('firefox_windows', {'browser': 'Firefox', 'os': 'Windows'}), + ('firefox_linux', {'browser': 'Firefox', 'os': 'Linux'}), + ('firefox_macos', {'browser': 'Firefox', 'os': 'macOS'}), + ('firefox_windows_screen_bound', {'browser': 'Firefox', 'os': 'Windows', + 'screen.width': Pred('screen_width_1280_1920')}), +] + + +def fields(fp): + """The marginals compared. Mirrored by fields() in fpgen-stats.test.ts.""" + ua = fp['navigator']['userAgent'] + m = re.search(r'\(([^)]*)\)', ua) + platform = m.group(1).split('; rv:')[0] if m else '' + rv = re.search(r'rv:(\d+)', ua) + return { + 'os': fp['os'], + 'uaPlatform': platform, + 'firefoxMajor': rv.group(1) if rv else '', + 'screen': f"{fp['screen']['width']}x{fp['screen']['height']}", + 'hardwareConcurrency': str(fp['navigator']['hardwareConcurrency']), + 'gpuVendor': fp['gpu']['vendor'], + } + + +_WORKER_GEN = None + + +def _draw(args): + global _WORKER_GEN + scenario, n, seed = args + random.seed(seed) + if _WORKER_GEN is None: + _WORKER_GEN = Generator() + cond = to_py(dict(STATS_SCENARIOS)[scenario]) + counts = {} + for _ in range(n): + for k, v in fields(_WORKER_GEN.generate(cond)).items(): + counts.setdefault(k, Counter())[v] += 1 + return scenario, counts + + +def gen_stats(): + chunk = 100 + jobs = [] + for s, (name, _) in enumerate(STATS_SCENARIOS): + for c in range(STATS_N // chunk): + jobs.append((name, chunk, s * 100000 + c)) + merged = {name: {} for name, _ in STATS_SCENARIOS} + with Pool(max(1, (os.cpu_count() or 2) - 1)) as pool: + for scenario, counts in pool.imap_unordered(_draw, jobs): + for k, c in counts.items(): + merged[scenario].setdefault(k, Counter()).update(c) + return { + 'n': STATS_N, + 'scenarios': [ + {'name': name, 'conditions': to_fixture(cond), + 'counts': {k: dict(sorted(v.items())) for k, v in merged[name].items()}} + for name, cond in STATS_SCENARIOS + ], + } + + +def main(): + only = set(sys.argv[1:]) + + def want(x): + return not only or x in only + + t = time.time() + if want('structure'): + write('structure.json', gen_structure()) + if want('values'): + write('values.json', gen_values()) + if want('conditions'): + write('conditions.json', gen_conditions(chrome_ua())) + if want('api'): + write('api.json', gen_api()) + if want('stats'): + write('stats.json', gen_stats()) + print(f'done in {time.time() - t:.1f}s (fpgen {fpgen.__name__})', file=sys.stderr) + + +if __name__ == '__main__': + main() diff --git a/typescript/scripts/golden/identity_golden.py b/typescript/scripts/golden/identity_golden.py new file mode 100644 index 000000000..00cdc8a30 --- /dev/null +++ b/typescript/scripts/golden/identity_golden.py @@ -0,0 +1,728 @@ +#!/usr/bin/env python3 +"""Record the Python identity layer's outputs as goldens for the TypeScript port. + + .venv/bin/python typescript/scripts/golden/identity_golden.py + +Writes typescript/tests/fixtures/identity/*.json (and one .json.gz holding full +fpgen fingerprints). tests/identity-golden.test.ts replays every case through +the TypeScript functions and requires the identical result. + +Everything recorded is a pure function of its recorded inputs: seeded draws +take their seed from the case, the unseeded module-level `random` is re-seeded +per case (the TS side seeds its `pyRandom` the same way), and the host probes +fix_hardware_concurrency reads are patched. Large outputs (font / voice lists, +WebGL parameter blobs, preset configs) are recorded as a short hash: + + sha256(orjson.dumps(canon(value), OPT_SORT_KEYS)).hexdigest()[:20] + +where canon() turns integral floats below 2**53 into ints and ints from 2**53 +up into floats (JavaScript cannot tell 1.0 from 1 once parsed, nor keep an int +past 2**53 exact, so the TS side hashes the same canonical form). +""" + +import copy +import gzip +import hashlib +import json +import math +import random +import sys +import zlib +from pathlib import Path +from unittest import mock + +import numpy as np +import orjson + +HERE = Path(__file__).resolve().parent +TS_ROOT = HERE.parent.parent +FIXTURES = TS_ROOT / 'tests' / 'fixtures' / 'identity' +sys.path.insert(0, str(HERE)) + +import pyrandom_cases # noqa: E402 + +from camoufox import coherence # noqa: E402 +from camoufox import cpu_affinity # noqa: E402 +from camoufox import fingerprints as fp # noqa: E402 +from camoufox import webgl # noqa: E402 + +OS_NAMES = ('windows', 'macos', 'linux') +OS_KEYS = ('win', 'mac', 'lin') + + +def canon(o): + if isinstance(o, bool) or o is None: + return o + if isinstance(o, float) and o.is_integer() and abs(o) < 2**53: + return int(o) + if isinstance(o, int) and abs(o) >= 2**53: + return float(o) # a JavaScript number past 2**53 is a float either way + if isinstance(o, dict): + return {str(k): canon(v) for k, v in o.items()} + if isinstance(o, (list, tuple)): + return [canon(v) for v in o] + return o + + +def h(o) -> str: + return hashlib.sha256(orjson.dumps(canon(o), option=orjson.OPT_SORT_KEYS)).hexdigest()[:20] + + +def write(name, data, compress=True): + """Write a fixture; the large ones gzipped (mtime 0, so reruns are byte-stable).""" + FIXTURES.mkdir(parents=True, exist_ok=True) + text = json.dumps(data, ensure_ascii=False, separators=(',', ':')) + '\n' + path = FIXTURES / (name + '.gz' if compress else name) + if compress: + path.write_bytes(gzip.compress(text.encode('utf-8'), compresslevel=9, mtime=0)) + else: + path.write_text(text, encoding='utf-8') + print(f'{path.relative_to(TS_ROOT)}: {path.stat().st_size // 1024} KiB') + + +def hashed_lists(config): + """A config with its (long) font and voice lists replaced by their hashes.""" + out = dict(config) + for key in ('fonts', 'voices'): + if key in out: + out[key] = {'hash': h(out[key]), 'len': len(out[key])} + return out + + +def err(fn): + try: + return {'ok': fn()} + except Exception as exc: # noqa: BLE001 + return {'error': type(exc).__name__, 'message': str(exc)} + + +# --------------------------------------------------------------------------- +# pyrandom + numpy + python-compat primitives +# --------------------------------------------------------------------------- + + +def record_pyrandom(): + write('pyrandom.json', {'cases': pyrandom_cases.cases()}) + + +def record_numpy(): + """np.sum over a float64 array, which the locale selector normalises by.""" + rng = random.Random(20260924) + sums = [] + for n in (0, 1, 2, 5, 7, 8, 9, 15, 16, 17, 31, 64, 100, 127, 128, 129, 200, 255, 256, 257, 600): + vals = [rng.uniform(-1, 1) * 10 ** rng.randint(-8, 8) for _ in range(n)] + sums.append({'values': vals, 'sum': float(np.array(vals, dtype=np.float64).sum())}) + write('numpy.json', {'sums': sums}) + + +SALT_OBJECTS = [ + {}, + {'a': 1, 'b': 2}, + {'b': 2, 'a': 1}, + {'nested': {'z': [1, 2.5, None, True, False], 'a': 'x'}, 'k': -0.0}, + {'floats': [1.0, 0.1, 1e16, 1e15, 1.5e-7, 1e-5, 1e-6, 3.4028234663852886e38, -2.5e-310, 123456.789, 1e21]}, + {'big': 18446744073709551615, 'neg': -9223372036854775808, 'safe': 9007199254740993}, + {'unicode': 'รฉ๐Ÿ˜€โ€จ\x00\x1f"\\/', 'รฉ': 1, '๐Ÿ˜€': 2, '๏ฟฟ': 3, 'Z': 4, 'a': 5}, + {'navigator.userAgent': 'Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0', + 'screen.width': 1920, 'screen.height': 1080, 'window.devicePixelRatio': 1.0}, + ['list', 'at', 'top', 1, 2.0], + 'a plain string', + 12345, + 1.0, + None, +] + + +def record_pycompat(): + rng = random.Random(1717) + floats = [0.0, -0.0, 1.0, -1.0, 0.1, 0.5, 1e16, 1e15, 9999999999999998.0, 1.0000000000000002e16, + 123456789012345678.0, 1.5e-7, 1e-5, 1.5e-5, 9.99e-6, 1e-4, 1e-6, 0.000123, 1e21, 1e22, 1e100, + 3.4028234663852886e38, 5e-324, 1.7976931348623157e308, 12345.678, 9007199254740992.0, + 9223372034707292000.0, 1.8446744073709552e19, 2.5, 100.0, 1e-7, 1.234e-300] + for _ in range(300): + floats.append(rng.uniform(-1, 1) * 10 ** rng.randint(-30, 30)) + for _ in range(60): + floats.append(float(rng.randint(-10**18, 10**18))) + float_cases = [[x, orjson.dumps(x).decode(), repr(x)] for x in floats] + + sums = [] + for _ in range(150): + n = rng.randint(0, 25) + items = [] + for _ in range(n): + kind = rng.random() + if kind < 0.25: + items.append(rng.randint(-100, 100)) + elif kind < 0.6: + items.append(rng.uniform(-1, 1) * 10 ** rng.randint(-20, 20)) + else: + items.append(rng.random()) + result = sum(items) + sums.append({ + 'items': [{'i': x} if isinstance(x, int) else {'f': x} for x in items], + 'result': {'i': result} if isinstance(result, int) else {'f': result}, + }) + float_sums = [] + for _ in range(150): + vals = [rng.random() * rng.choice([1, 1e-3, 1e10, 1e-17, 3]) for _ in range(rng.randint(1, 40))] + float_sums.append({'values': vals, 'sum': sum(vals)}) + + crc = [[s, zlib.crc32(s.encode('utf-8'))] for s in ('', 'a', 'hello world', 'รฉ๐Ÿ˜€', 'x' * 1000)] + + salts = [{'value': o, 'salt': str(fp.identity_salt(o))} for o in SALT_OBJECTS] + salts.append({'screen': [1, 2, 3, 4], 'salt': str(fp.identity_salt(fp.Screen(1, 2, 3, 4)))}) + salts.append({'screen': [None, 1920, None, 1080], 'salt': str(fp.identity_salt(fp.Screen(None, 1920, None, 1080)))}) + + seeds = [] + configs = [ + {}, + {'navigator.userAgent': 'x', 'navigator.platform': 'Win32', 'screen.width': 1920, + 'screen.height': 1080, 'navigator.hardwareConcurrency': 8}, + {'navigator.userAgent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0', + 'navigator.platform': 'MacIntel', 'screen.width': 1512.5, 'screen.height': None, + 'navigator.hardwareConcurrency': True}, + {'navigator.userAgent': 'รฉ๐Ÿ˜€', 'screen.width': 0, 'screen.height': ''}, + {'navigator.platform': 1e-5, 'screen.width': 1e16, 'navigator.hardwareConcurrency': -3}, + ] + for c in configs: + for salt in (0, 1, 2**31, 2**64 - 1, 12345678901234567890): + seeds.append({'config': c, 'salt': str(salt), 'seed': fp.identity_seed(c, salt)}) + + reprs = [[s, repr(s)] for s in ('', 'abc', "it's", 'say "hi"', 'both \' and "', 'tab\there', 'nl\n', + 'back\\slash', '\x00\x7f', 'รฉ๐Ÿ˜€', 'โ€‹', 'โ€จ', 'Apple M1, or similar')] + write('pycompat.json', {'floats': float_cases, 'sums': sums, 'floatSums': float_sums, 'crc32': crc, + 'salts': salts, 'seeds': seeds, 'reprs': reprs, + 'python': list(sys.version_info[:2])}) + + +def record_fpgen_salts(): + """Full fpgen fingerprints (one per OS, plus one carrying an int > 2**53).""" + found = {} + tries = 0 + while len(found) < 4 and tries < 200: + tries += 1 + os_name = OS_NAMES[tries % 3] + f = fp.generate_fingerprint(os=os_name) + big = isinstance(((f.get('webgl2') or {}).get('params') or {}).get('37137', {}).get('value'), int) + if os_name not in found: + found[os_name] = f + elif big and 'bigint' not in found: + found['bigint'] = f + cases = [{'label': k, 'fingerprint': v, 'salt': str(fp.identity_salt(v))} for k, v in found.items()] + FIXTURES.mkdir(parents=True, exist_ok=True) + path = FIXTURES / 'fpgen-salts.json.gz' + path.write_bytes(gzip.compress(json.dumps(cases, ensure_ascii=False).encode('utf-8'), mtime=0)) + print(f'{path.relative_to(TS_ROOT)}: {path.stat().st_size // 1024} KiB') + + +# --------------------------------------------------------------------------- +# per-identity draws +# --------------------------------------------------------------------------- + + +def record_fonts(): + cases = [] + for os_name in OS_NAMES + ('plan9',): + for seed in range(150): + locale = None if seed < 100 else ('zh-TW' if seed < 125 else 'en-US') + fonts = fp._generate_random_font_subset(os_name, seed=seed, locale=locale) + case = {'os': os_name, 'seed': seed, 'locale': locale, 'hash': h(fonts), 'len': len(fonts)} + if seed < 2: + case['fonts'] = fonts + cases.append(case) + for seed in (2**32 - 1, 2**40 + 3): + fonts = fp._generate_random_font_subset(os_name, seed=seed) + cases.append({'os': os_name, 'seed': str(seed), 'locale': None, 'hash': h(fonts), 'len': len(fonts)}) + fonts = fp._generate_random_font_subset(os_name, seed=0, native=True) + cases.append({'os': os_name, 'seed': 0, 'native': True, 'locale': None, 'hash': h(fonts), + 'len': len(fonts), 'fonts': fonts}) + write('fonts.json', {'cases': cases}) + + +VOICE_LOCALES = (None, 'en-US', 'en-GB', 'de-DE', 'fr-FR', 'zh-TW', 'ja-JP', 'pt-BR', 'es', 'xx-YY') + + +def manifest_voice_entries(node): + """Every "Name:lang:type" entry in a voice manifest, in document order.""" + if isinstance(node, dict): + return [e for value in node.values() for e in manifest_voice_entries(value)] + if isinstance(node, list): + return [e for item in node + for e in ([item] if isinstance(item, str) and item.count(':') >= 2 + else manifest_voice_entries(item))] + return [] + + +def record_voices(): + cases = [] + for os_name in OS_NAMES + ('plan9',): + for locale in VOICE_LOCALES: + for seed in range(20): + voices = fp._generate_random_voice_subset(os_name, locale, seed=seed) + case = {'os': os_name, 'locale': locale, 'seed': seed, 'hash': h(voices), 'len': len(voices)} + if seed == 0 and locale in (None, 'de-DE'): + case['voices'] = voices + cases.append(case) + uris = [] + uri_hashes = {} + for os_key, manifest in fp._load_voice_manifests().items(): + entries = manifest_voice_entries(manifest) + per = [] + for entry in entries: + name, lang, _ = entry.rsplit(':', 2) + per.append(fp._voice_uri(os_key, name, lang)) + uri_hashes[os_key] = h(per) + for entry in entries[:: max(1, len(entries) // 15)]: + name, lang, _ = entry.rsplit(':', 2) + uris.append([os_key, name, lang, fp._voice_uri(os_key, name, lang)]) + for os_key in ('mac', 'win', 'lin', 'xx'): + for name, lang in (('Albert', 'en-US'), ('Eddy', 'de-DE'), ('Zoรซ รœnรฏcode', 'fr-FR'), + ('Some Voice (Enhanced)', 'en-GB'), ('ๆ—ฅๆœฌ่ชž', 'ja-JP'), ('.Dots.', 'en')): + uris.append([os_key, name, lang, fp._voice_uri(os_key, name, lang)]) + normalized = [] + for fname in ('fingerprint-presets.json', 'fingerprint-presets-v150.json'): + presets = json.loads((Path(fp.__file__).parent / fname).read_text())['presets'] + for os_name, entries in presets.items(): + for i, preset in enumerate(entries): + if preset.get('speechVoices'): + out = fp._normalize_preset_voices(preset['speechVoices'], os_name) + normalized.append({'file': fname, 'os': os_name, 'index': i, 'hash': h(out), 'len': len(out)}) + extra = ['Albert:en-US:local', 'bad', 'x:y', ':en:local', 'Name::local', 'A:b:c:remote', + {'name': 'Obj', 'lang': 'en', 'voiceUri': 'u', 'isDefault': False, 'isLocalService': True}] + normalized.append({'extra': extra, 'os': 'macos', 'out': fp._normalize_preset_voices(extra, 'macos')}) + write('voices.json', {'cases': cases, 'uris': uris, 'uriHashes': uri_hashes, 'normalized': normalized}) + + +def record_media(): + cases = [] + for os_key in OS_KEYS + ('xx',): + for seed in range(150): + out = fp.draw_media_devices(os_key, seed) + case = {'os': os_key, 'seed': seed, 'hash': h(out)} + if seed < 4: + case['out'] = out + cases.append(case) + defaults = [] + for i in range(60): + plat = ('Win32', 'MacIntel', 'Linux x86_64', '', None)[i % 5] + config = {'navigator.userAgent': f'ua{i}', 'screen.width': 1920} + if plat is not None: + config['navigator.platform'] = plat + salt = (0, 7, 2**64 - 1)[i % 3] + before = copy.deepcopy(config) + fp.set_media_devices_defaults(config, salt) + defaults.append({'config': before, 'salt': str(salt), 'hash': h(config)}) + preset_mix = {'mediaDevices:webcams': 5, 'navigator.platform': 'Win32'} + before = copy.deepcopy(preset_mix) + fp.set_media_devices_defaults(preset_mix) + defaults.append({'config': before, 'salt': '0', 'hash': h(preset_mix)}) + write('media.json', {'cases': cases, 'defaults': defaults}) + + +def exact(o) -> str: + """sha256 of the exact orjson bytes: key order, int vs float and all.""" + return hashlib.sha256(orjson.dumps(o)).hexdigest()[:20] + + +WEBGL_SCREENS = [(1024, 600), (800, 480), (1366, 768), (1280, 800), (1920, 1080), (2560, 1440), (None, None)] + + +def record_webgl(): + gpus = {os_key: [[r.value['vendor'], r.value['renderer']] for r in webgl._trace('gpu', os_key)] + for os_key in OS_KEYS} + + for_screen = [] + for os_key in OS_KEYS: + for w, hh in WEBGL_SCREENS: + for seed in range(60): + out = webgl.sample_webgl_for_screen(os_key, w, hh, seed=seed) + for_screen.append({'os': os_key, 'w': w, 'h': hh, 'seed': str(seed), + 'renderer': out['webGl:renderer'], 'hash': exact(out)}) + for seed in (2**32 - 1, 2**40, 2**64 + 1, 12345678901234567890123): + out = webgl.sample_webgl_for_screen(os_key, 1920, 1080, seed=seed) + for_screen.append({'os': os_key, 'w': 1920, 'h': 1080, 'seed': str(seed), + 'renderer': out['webGl:renderer'], 'hash': exact(out)}) + + # Every GPU fpgen records for each OS, and every bundled preset's GPU. + for_gpu = [] + targets = {(os_key, v, r) for os_key in OS_KEYS for v, r in gpus[os_key]} + for fname in ('fingerprint-presets.json', 'fingerprint-presets-v150.json'): + presets = json.loads((Path(fp.__file__).parent / fname).read_text())['presets'] + for os_name, entries in presets.items(): + os_key = {'windows': 'win', 'macos': 'mac', 'linux': 'lin'}[os_name] + for preset in entries: + targets.add((os_key, preset['webgl']['unmaskedVendor'], preset['webgl']['unmaskedRenderer'])) + for os_key, v, r in sorted(targets): + for seed in range(5): + for_gpu.append({'os': os_key, 'vendor': v, 'renderer': r, 'seed': seed, + **err(lambda: exact(webgl.webgl_for_gpu(os_key, v, r, seed=seed)))}) + for os_key, v, r in (('win', 'Nope', 'Nope GPU'), ('lin', 'Apple', 'Apple M1, or similar')): + for_gpu.append({'os': os_key, 'vendor': v, 'renderer': r, 'seed': 0, + **err(lambda: webgl.webgl_for_gpu(os_key, v, r, seed=0))}) + errors = { + 'unknownOs': err(lambda: webgl.sample_webgl_for_screen('bsd', 1920, 1080, seed=0)), + } + + recorded = { + 'vendor': 'v', 'renderer': 'r', 'contextAttributes': {'alpha': True}, 'params': {'3379': {'value': 1.0}}, + 'shaderPrecisionFormats': [{'shaderType': 35633, 'precisionType': 36336, + 'shaderPrecisionFormat': {'rangeMin': 127, 'rangeMax': 127, 'precision': 23}}], + 'supportedExtensions': ['ANGLE_instanced_arrays', 'WEBGL_multi_draw', 'OVR_multiview2', + 'WEBGL_compressed_texture_etc1'], + } + recorded2 = {**recorded, 'supportedExtensions': ['EXT_texture_norm16', 'WEBGL_clip_cull_distance', + 'OVR_multiview2', 'EXT_color_buffer_float']} + converted = [{'os': os_key, 'webgl2': w2, 'hash': exact(webgl.to_config(recorded, w2, os_key))} + for os_key in OS_KEYS for w2 in (recorded2, [])] + + write('webgl.json', {'gpus': gpus, 'forScreen': for_screen, 'forGpu': for_gpu, 'errors': errors, + 'recorded': recorded, 'recorded2': recorded2, 'converted': converted}) + + +# --------------------------------------------------------------------------- +# geometry fixes + coherence +# --------------------------------------------------------------------------- + + +def maybe(rng, value, p_missing=0.15, p_none=0.03): + roll = rng.random() + if roll < p_missing: + return '__missing__' + if roll < p_missing + p_none: + return None + return value + + +def random_geometry(rng, p_none=0.03): + sw = rng.choice([800, 1024, 1280, 1366, 1440, 1536, 1600, 1920, 2560, 3440, 3840, 736, 1080]) + sh = rng.choice([480, 600, 720, 768, 800, 864, 900, 1024, 1080, 1440, 1600, 2160, 414, 1920]) + aw = sw - rng.choice([0, 0, 0, 40, 60, -30]) + ah = sh - rng.choice([0, 0, 25, 27, 40, 48, -20, 900]) + ow = rng.choice([sw, sw - 100, sw + 200, 1280, 800, 0]) + oh = rng.choice([sh, ah, sh - 40, sh + 100, 720, 1000, 0]) + iw = ow - rng.choice([0, 16, -20, 200, 0]) + ih = oh - rng.choice([0, 74, 86, 90, 120, -10, 2000]) + sx = rng.choice([0, 0, 8, -8, 60, 250, -200, 5000]) + sy = rng.choice([0, 0, 20, 281, -30, 900]) + c = {} + for key, value in (('screen.width', sw), ('screen.height', sh), ('screen.availWidth', aw), + ('screen.availHeight', ah), ('window.outerWidth', ow), ('window.outerHeight', oh), + ('window.innerWidth', iw), ('window.innerHeight', ih), ('window.screenX', sx), + ('window.screenY', sy)): + v = maybe(rng, value, p_none=p_none) + if v != '__missing__': + c[key] = v + return c + + +UAS = [ + 'Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0', + 'Mozilla/5.0 (X11; Linux i686; rv:152.0) Gecko/20100101 Firefox/152.0', + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0', + 'Mozilla/5.0 (Android 16; Mobile; rv:152.0) Gecko/152.0 Firefox/152.0', + 'Mozilla/5.0 (X11; Linux aarch64; rv:152.0) Gecko/20100101 Firefox/152.0', + '', +] +PLATFORMS = ['Linux x86_64', 'Linux armv81', 'Linux i686', 'Win32', 'MacIntel', 'Linux aarch64', ''] + + +def record_geometry(): + rng = random.Random(4242) + cases = [] + for i in range(500): + c = random_geometry(rng) + target = OS_KEYS[i % 3] + cap_w = rng.choice([None, 1366, 1920, 1280, 2560, 0]) + cap_h = rng.choice([None, 768, 1080, 720, 1440, 0]) + out = {} + for name, fn in ( + ('fixScreenNoTaskbar', lambda d: fp.fix_screen_no_taskbar(d, target)), + ('clampWindowDimensions', fp.clamp_window_dimensions), + ('clampScreenToDisplay', lambda d: fp.clamp_screen_to_display(d, cap_w, cap_h)), + ('clampWindowPosition', fp.clamp_window_position), + ('raiseScreenToModernFloor', fp.raise_screen_to_modern_floor), + ('repairScreenOrientation', coherence.repair_screen_orientation), + ): + d = copy.deepcopy(c) + ret = fn(d) + out[name] = {'config': d, 'ret': ret} if i < 15 else {'hash': h(d), 'keys': h(list(d)), 'ret': ret} + + # the launch_options order + d = copy.deepcopy(c) + if coherence.screen_is_implausible(d): + coherence.repair_screen_orientation(d) + fp.raise_screen_to_modern_floor(d) + fp.raise_screen_to_modern_floor(d) + fp.clamp_screen_to_display(d, cap_w, cap_h) + fp.fix_screen_no_taskbar(d, target) + fp.clamp_window_dimensions(d) + fp.clamp_window_position(d) + out['pipeline'] = {'config': d} if i < 15 else {'hash': h(d), 'keys': h(list(d))} + cases.append({'input': c, 'os': target, 'capW': cap_w, 'capH': cap_h, 'out': out}) + + arch = [] + for ua in UAS: + for plat in PLATFORMS + [None]: + for oscpu in ('Linux armv81', 'Linux x86_64', None): + for target in OS_KEYS: + c = {'navigator.userAgent': ua} + if plat is not None: + c['navigator.platform'] = plat + if oscpu is not None: + c['navigator.oscpu'] = oscpu + d = copy.deepcopy(c) + fp.fix_navigator_arch(d, target) + arch.append([ua, plat, oscpu, target, d.get('navigator.platform'), d.get('navigator.oscpu')]) + + hc = [] + for host in (None, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 16, 22, 24, 26, 32, 64, 128): + for supported in (True, False): + for can_pin in (None, True, False): + for drawn in (None, 1, 2, 3, 4, 5, 7, 8, 9, 11, 13, 15, 16, 18, 24, 32, 48, True, '8', 8.5): + c = {} if drawn is None else {'navigator.hardwareConcurrency': drawn} + d = copy.deepcopy(c) + with mock.patch.object(fp, 'host_cpu_count', lambda n=host: n), \ + mock.patch.object(cpu_affinity, 'supported', lambda s=supported: s): + fp.fix_hardware_concurrency(d, can_pin=can_pin) + hc.append([drawn, host, supported, can_pin, d.get('navigator.hardwareConcurrency', '__missing__')]) + write('geometry.json', {'cases': cases, 'arch': arch, 'hardwareConcurrency': hc}) + + +def fpgen_renderers(): + """Every renderer fpgen records from Firefox, once each, in trace order.""" + renderers = [r.value['renderer'] for os_key in OS_KEYS for r in webgl._trace('gpu', os_key)] + return list(dict.fromkeys(renderers)) + + +EXTRA_RENDERERS = [ + 'ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar', + 'NVIDIA GeForce GTX 980/PCIe/SSE2', 'GeForce GTX 980, or similar', + 'ANGLE (AMD, Radeon HD 3200 Graphics Direct3D11 vs_5_0 ps_5_0), or similar', + 'Radeon HD 3200 Graphics, or similar', 'ANGLE (Samsung Xclipse 920) on Vulkan', + 'ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar', 'Apple M1, or similar', + 'llvmpipe, or similar', 'ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)', + 'ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)', 'Generic Renderer', + 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0)', 'Intel(R) HD Graphics 400, or similar', + 'Radeon R9 200 Series, or similar', 'NVIDIA GeForce GTX 480/PCI/SSE2', 'GeForce 8800 GTX', '', 'Mozilla', +] + + +def record_coherence(): + renderers = fpgen_renderers() + EXTRA_RENDERERS + rng = random.Random(9001) + fits = [[r, os_key, coherence.gpu_fits_os(r, os_key)] for r in renderers + [None] for os_key in OS_KEYS + ('bsd',)] + gpu = [] + for r in renderers + [None]: + for w, hh in ((1024, 600), (800, 480), (1024, 768), (1366, 768), (1920, 1080), (None, None), (0, 600)): + gpu.append([r, w, hh, fp.gpu_screen_is_plausible(r, w, hh), fp.is_software_renderer(r), + fp._renderer_bucket(r) if r is not None else None]) + + cases = [] + for i in range(900): + c = random_geometry(rng, p_none=0) if rng.random() < 0.7 else {} + fields = { + 'webGl:renderer': rng.choice(renderers + [None]), + 'navigator.hardwareConcurrency': rng.choice([2, 4, 6, 8, 9, 10, 11, 12, 13, 16, 24, 33, 64, True, '8']), + 'screen.colorDepth': rng.choice([24, 30, 32, 16, 48, True, '24']), + 'navigator.maxTouchPoints': rng.choice([0, 1, 2, 5, 10, 11, 40, 256, -1, 1.5, True, '5']), + 'window.devicePixelRatio': rng.choice([1, 1.25, 1.5, 1.75, 2, 2.5, 3, 1.125, 1.1, 1.818181818181818, + 1.09, 3.5, 0.9, 2.25, 1.625, 2.75, '2']), + 'screen.pixelDepth': rng.choice([24, 30, 32]), + 'navigator.userAgent': rng.choice(UAS), + 'navigator.platform': rng.choice(PLATFORMS), + 'navigator.oscpu': rng.choice(PLATFORMS), + } + for key, value in fields.items(): + if rng.random() < 0.6: + c[key] = value + target = rng.choice(OS_KEYS + ('bsd',)) + v = [list(x) for x in coherence.validate(c, target)] + applied = copy.deepcopy(c) + left = [list(x) for x in coherence.apply(applied, target)] + dropped_cfg = copy.deepcopy(c) + dropped = [list(x) for x in coherence.drop_incoherent_source_values(dropped_cfg, target)] + case = {'input': c, 'os': target, 'rules': [x[0] for x in v], 'validate': h(v), + 'apply': [x[0] for x in left], 'applyHash': h(left), 'applied': h(applied), + 'dropped': dropped, 'droppedConfig': h(dropped_cfg), + 'implausible': coherence.screen_is_implausible(c)} + if i < 40: + case.update(validateFull=v, appliedFull=applied) + cases.append(case) + write('coherence.json', {'fits': fits, 'gpuScreen': gpu, 'cases': cases}) + + +# --------------------------------------------------------------------------- +# fpgen -> config, presets +# --------------------------------------------------------------------------- + + +def trimmed(f): + return {k: f[k] for k in ('navigator', 'screen', 'window', 'headers') if k in f} + + +def record_from_fpgen_inputs(): + """from_fpgen goldens over stored inputs (generated once, trimmed).""" + inputs = [] + for os_name in OS_NAMES: + for _ in range(25): + inputs.append(trimmed(fp.generate_fingerprint(os=os_name))) + # Hand-made edge cases: windowed screenX, negatives, header lists, empties. + inputs.append({'navigator': {'userAgent': 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0', + 'platform': 'Linux x86_64', 'hardwareConcurrency': 0, 'maxTouchPoints': 0, + 'oscpu': ''}, + 'screen': {'width': 1920, 'height': 1080, 'availHeight': 1040, 'availLeft': -5, 'availTop': 0}, + 'window': {'screenX': 300, 'outerHeight': 900, 'outerWidth': 1200}, + 'headers': {'accept-encoding': ['gzip, deflate, br', 'gzip'], 'accept-language': ['en']}}) + inputs.append({'screen': {'availHeight': 700}, 'window': {'screenX': -120, 'outerHeight': 900}, + 'headers': {'accept-encoding': ['gzip, deflate, br, zstd']}}) + inputs.append({'window': {'screenX': 51}, 'screen': {}}) + inputs.append({'window': {'screenX': 50, 'screenY': 3}}) + inputs.append({'navigator': {'userAgent': 'Firefox/115.0 rv:115.0 1115.0 115.01 Firefox/99.0'}}) + inputs.append({}) + cases = [] + for i, f in enumerate(inputs): + for ffv in (None, '152'): + random.seed(1000 + i) + config = fp.from_fpgen(copy.deepcopy(f), ffv) + cases.append({'input': i, 'ffVersion': ffv, 'moduleSeed': 1000 + i, 'config': config, + 'configKeys': list(config.keys()), + 'identitySeed': fp.identity_seed(config, 12345678901234567890)}) + windows = [] + for i, f in enumerate(inputs[:20]): + for w, hh in ((1280, 720), (800, 600), (1920, 1080)): + d = copy.deepcopy(f) + fp.handle_window_size(d, w, hh) + windows.append({'input': i, 'w': w, 'h': hh, 'out': d}) + screens = [] + for bounds in ((None, None, None, None), (100, 2000, None, None), (None, 1920, None, 1080), (1366, 1366, 768, 768)): + s = fp.Screen(*bounds) + conds = s.as_conditions() + probes = [800, 1366, 1920, 2560, 1080.0, '1920', None] + screens.append({'bounds': list(bounds), 'keys': sorted(conds), + 'width': [conds['screen.width'](p) for p in probes] if 'screen.width' in conds else None, + 'height': [conds['screen.height'](p) for p in probes] if 'screen.height' in conds else None}) + write('from-fpgen.json', {'inputs': inputs, 'cases': cases, 'windowSize': windows, 'screens': screens}) + + +def record_presets(): + base = Path(fp.__file__).parent + cases = [] + full = [] + for fname, ffv in (('fingerprint-presets.json', None), ('fingerprint-presets-v150.json', '152')): + presets = json.loads((base / fname).read_text())['presets'] + for os_name, entries in presets.items(): + for i, preset in enumerate(entries): + for salt in (0, 12345678901234567890): + random.seed(i * 7 + salt % 1000) + config = fp.from_preset(copy.deepcopy(preset), ffv, salt=salt) + key = {'macos': 'mac', 'windows': 'win', 'linux': 'lin'}[os_name] + cases.append({'file': fname, 'os': os_name, 'index': i, 'ffVersion': ffv, 'salt': str(salt), + 'moduleSeed': i * 7 + salt % 1000, 'hash': h(config), + 'keys': h(list(config.keys())), + 'validate': [x.rule for x in coherence.validate(config, key)]}) + if i < 2 and salt == 0: + full.append({'file': fname, 'os': os_name, 'index': i, 'config': hashed_lists(config)}) + # Synthetic presets: derived oscpu / appVersion, fallbacks. + synthetic = [ + {'navigator': {'platform': 'Linux x86_64', 'userAgent': UAS[0]}}, + {'navigator': {'platform': 'Win32', 'userAgent': UAS[3], 'appVersion': '5.0 (Windows NT 10.0; Win64; x64)'}}, + {'navigator': {'platform': 'iPhone', 'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Gecko/20100101'}}, + {'navigator': {'platform': 'Win32', 'userAgent': 'not a user agent', 'maxTouchPoints': 0}}, + {'navigator': {'platform': 'MacIntel', 'hardwareConcurrency': 0}, 'screen': {'width': 1440, 'colorDepth': 30}, + 'webgl': {'unmaskedVendor': 'Apple', 'unmaskedRenderer': 'Apple M1, or similar'}, 'timezone': 'Europe/Paris'}, + {}, + ] + synth = [] + for i, preset in enumerate(synthetic): + for ffv in (None, '152'): + random.seed(5000 + i) + config = fp.from_preset(copy.deepcopy(preset), ffv, salt=99) + synth.append({'preset': preset, 'ffVersion': ffv, 'moduleSeed': 5000 + i, 'config': hashed_lists(config)}) + rand = [] + for os_arg in (None, 'windows', 'mac', 'lin', ['linux', 'macos'], 'bsd'): + for ffv in (None, '152', '148', 'abc'): + for seed in range(10): + random.seed(seed) + preset = fp.get_random_preset(os=os_arg, ff_version=ffv) + rand.append({'os': os_arg, 'ffVersion': ffv, 'moduleSeed': seed, + 'hash': None if preset is None else h(preset)}) + app_versions = [] + uas = set(UAS) + for fname in ('fingerprint-presets.json', 'fingerprint-presets-v150.json'): + for entries in json.loads((base / fname).read_text())['presets'].values(): + for preset in entries: + ua = (preset.get('navigator') or {}).get('userAgent') + if ua: + uas.add(ua) + uas |= {'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0', + 'Mozilla/5.0 (X11; Linux x86_64)', 'Mozilla/5.0 (rv:1.0)', 'Mozilla/5.0 ()', None} + for ua in sorted(uas, key=lambda x: x or ''): + app_versions.append([ua, fp._app_version_from_user_agent(ua)]) + write('presets.json', {'cases': cases, 'full': full, 'synthetic': synth, 'random': rand, + 'appVersions': app_versions, + 'presetsFile': {str(v): Path(fp._select_presets_file(v)).name + for v in (None, '148', '149', '150.0.2', 'abc', 152, '', ' 150')}}) + + +def record_constants(): + write('constants.json', compress=False, data={ + 'fpgenData': fp.FPGEN_DATA, + 'essentialMacos': fp._ESSENTIAL_FONTS_MACOS, + 'essentialWindows': fp._ESSENTIAL_FONTS_WINDOWS, + 'essentialLinux': fp._ESSENTIAL_FONTS_LINUX, + 'markers': {'macos': fp._MACOS_MARKER_FONTS, 'windows': fp._WINDOWS_MARKER_FONTS, + 'linux': fp._LINUX_MARKER_FONTS}, + 'windows11Markers': sorted(fp.WINDOWS_11_MARKER_FONTS), + 'plausibleCoreCounts': list(fp.PLAUSIBLE_CORE_COUNTS), + 'modernScreenFloor': list(fp.MODERN_SCREEN_FLOOR), + 'appleSiliconCores': sorted(coherence.APPLE_SILICON_CORES), + 'plausibleDpr': {k: list(v) for k, v in coherence.PLAUSIBLE_DPR.items()}, + 'plausibleColorDepth': sorted(coherence.PLAUSIBLE_COLOR_DEPTH), + 'maxTouchPoints': coherence.MAX_PLAUSIBLE_TOUCH_POINTS, + 'browserChromeHeight': coherence.BROWSER_CHROME_HEIGHT, + 'rules': [r.name for r in coherence.RULES], + 'macNovelty': sorted(fp._MAC_NOVELTY_VOICES), + 'macEloquence': sorted(fp._MAC_ELOQUENCE_VOICES), + 'presetsV150MinFf': fp.PRESETS_V150_MIN_FF, + }) + + +def record_init_script(): + cases = [ + {}, + {'audioFingerprintSeed': 123, + 'navigatorPlatform': 'Win32', 'navigatorOscpu': 'Windows NT 10.0; Win64; x64', + 'navigatorUserAgent': UAS[3], 'hardwareConcurrency': 8, 'webglVendor': 'Google Inc. (Intel)', + 'webglRenderer': 'ANGLE (Intel, "quoted" รฉ)', 'screenWidth': 1920, 'screenHeight': 1080, + 'screenColorDepth': 24, 'timezone': 'Europe/Paris', 'fontList': ['Arial', 'ๅพฎ่ฝฏ้›…้ป‘', 'Segoe UI'], + 'speechVoices': [{'name': 'Microsoft David'}, 'Plain Name'], 'webrtcIP': '1.2.3.4'}, + {'screenWidth': 1920, 'screenHeight': None, 'screenColorDepth': 24, 'webrtcIP': '', 'fontList': [], + 'speechVoices': [], 'timezone': ''}, + {'navigatorUserAgent': 'emoji ๐Ÿ˜€ โ€จ \x7f tab\t', 'hardwareConcurrency': None}, + {'webrtcIP': '2001:db8::7'}, + ] + out = [{'values': v, 'script': fp._build_init_script(v)} for v in cases] + write('init-script.json', {'cases': out}, compress=False) + + +def main(): + record_constants() + record_pyrandom() + record_numpy() + record_pycompat() + record_fpgen_salts() + record_fonts() + record_voices() + record_media() + record_webgl() + record_geometry() + record_coherence() + record_from_fpgen_inputs() + record_presets() + record_init_script() + + +if __name__ == '__main__': + main() diff --git a/typescript/scripts/golden/launch_golden.py b/typescript/scripts/golden/launch_golden.py new file mode 100644 index 000000000..c81c6a6b2 --- /dev/null +++ b/typescript/scripts/golden/launch_golden.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +"""Record Python launch_options() outputs as goldens for the TypeScript port. + + .venv/bin/python typescript/scripts/golden/launch_golden.py + +Writes typescript/tests/fixtures/launch/*.json, one file per scenario, and +typescript/tests/fixtures/launch/inputs.json (the fixed fingerprints / presets +the scenarios are built from). tests/launch-golden.test.ts replays every +scenario through the TypeScript launchOptions() and requires the same result. + +Every source of nondeterminism is pinned, and every host probe is replaced, so +the goldens are a function of the code alone: + + * identity salt: scenarios pass a fixed fingerprint / preset dict (salt is a + hash of it); the few that exercise the generate / random-preset paths patch + the fresh salt, generate_fingerprint and get_random_preset. + * host: XDG_CACHE_HOME points the camoufox cache at a scratch dir; the stock + profile disk capacity, the host OS key, the host CPU count, the monitor + probe and the public-IP lookup are patched; the GeoIP reader is a fake + maxminddb module over a fixed table; numpy's weighted locale choice uses a + fixed uniform draw (same algorithm as numpy, so the TS port can mirror it). + * paths: the browser bundle is tests/fixtures/launch/bundle*, the cache the + scratch dir; both are written back as //... placeholders. + +Recorded on Linux: OS_NAME affects the chunk size and the fontconfig, and the +TS test only replays on Linux. +""" + +import copy +import io +import json +import os +import re +import sys +import tempfile +import types +import warnings +from contextlib import redirect_stdout +from pathlib import Path + +HERE = Path(__file__).resolve().parent +TS_ROOT = HERE.parent.parent +FIXTURES = TS_ROOT / 'tests' / 'fixtures' / 'launch' + +# Must precede every camoufox import: INSTALL_DIR is computed at import time. +SCRATCH = Path(tempfile.mkdtemp(prefix='camoufox-golden-')) +os.environ['XDG_CACHE_HOME'] = str(SCRATCH / 'xdg-cache') +CACHE = SCRATCH / 'xdg-cache' / 'camoufox' +HOME = SCRATCH / 'home' +HOME.mkdir(parents=True) + +# ---------------------------------------------------------------- fake GeoIP +GEO_TABLE = { + '8.8.8.8': {'country_code': 'US', 'longitude': -97.822, 'latitude': 37.751, 'timezone': 'America/Chicago'}, + '81.2.69.160': {'country_code': 'GB', 'longitude': -0.0931, 'latitude': 51.5142, 'timezone': 'Europe/London'}, + '2a01:4f8::1': {'country_code': 'DE', 'longitude': 9.491, 'latitude': 51.2993, 'timezone': 'Europe/Berlin'}, + '203.0.113.7': {'country_code': 'JP', 'longitude': 139.6899, 'latitude': 35.6893, 'timezone': 'Asia/Tokyo'}, +} +_fake_mmdb = types.ModuleType('maxminddb') + + +class _FakeReader: + def __init__(self, path): + self.path = path + + def get(self, ip): + return copy.deepcopy(GEO_TABLE.get(ip)) + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +_fake_mmdb.open_database = lambda path: _FakeReader(path) +sys.modules['maxminddb'] = _fake_mmdb + +import numpy as np # noqa: E402 +import orjson # noqa: E402 + +import camoufox.fingerprints as fingerprints # noqa: E402 +from camoufox import geolocation, locales, utils # noqa: E402 +from camoufox.addons import DefaultAddons # noqa: E402 +from camoufox.fingerprints import Screen # noqa: E402 +from camoufox.utils import launch_options # noqa: E402 + +assert str(utils.INSTALL_DIR) == str(CACHE), (utils.INSTALL_DIR, CACHE) + +# The mmdb files only have to exist and be fresh; the fake reader answers. +for name in ('maxmind geolite2-ipv4.mmdb', 'maxmind geolite2-ipv6.mmdb'): + p = geolocation.MMDB_DIR / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b'') + +# The default addon, already "downloaded", so nothing is fetched. +(CACHE / 'addons' / 'UBO').mkdir(parents=True) +(CACHE / 'addons' / 'UBO' / 'manifest.json').write_text('{}') + +# ------------------------------------------------------------ host probes +HOST = { + 'cpu_count': 16, + 'disk_capacity_kb': 250_000_000, + 'host_os_key': 'lin', + 'display': [1600, 900], + 'public_ip': '81.2.69.160', + 'locale_uniform': 0.5, + 'fresh_salt': 1234567890123456789, +} + +fingerprints.host_cpu_count = lambda: HOST['cpu_count'] +utils._stock_profile_disk_capacity_kb = lambda: HOST['disk_capacity_kb'] +utils._host_os_key = lambda: HOST['host_os_key'] +def _display(): + from camoufox.display import DisplaySize + + return DisplaySize(*HOST['display']) + + +utils.largest_display = _display +utils.public_ip = lambda proxy=None: HOST['public_ip'] + +_real_salt = utils.identity_salt +utils.identity_salt = lambda pinned=None: _real_salt(pinned) if pinned is not None else HOST['fresh_salt'] + + +def _fixed_choice(a, p=None): + # numpy.random.RandomState.choice(a, p=p), with the uniform draw pinned. + cdf = np.cumsum(p) + cdf /= cdf[-1] + return a[int(cdf.searchsorted(HOST['locale_uniform'], side='right'))] + + +locales.np.random.choice = _fixed_choice + +# ------------------------------------------------------------------ inputs +FPGEN_KEYS = ('navigator', 'screen', 'window', 'headers') + + +def js_equivalent(value): + """What the value is once it has been through JSON in JavaScript: an + integral float becomes an int, and a dict's array-index keys move to the + front in ascending order (JS object key order).""" + if isinstance(value, float) and value.is_integer() and abs(value) < 2**53: + return int(value) + if isinstance(value, dict): + idx = sorted((k for k in value if isinstance(k, str) and _is_index(k)), key=int) + rest = [k for k in value if k not in idx] + return {k: js_equivalent(value[k]) for k in idx + rest} + if isinstance(value, (list, tuple)): + return [js_equivalent(v) for v in value] + return value + + +def _is_index(k): + return k.isdigit() and (k == '0' or not k.startswith('0')) and int(k) < 2**32 - 1 + + +def make_fingerprint(os_name): + fp = fingerprints._generator().generate(browser='Firefox', os=fingerprints._FPGEN_OS[os_name]) + fp = {k: fp[k] for k in FPGEN_KEYS if k in fp} + # handle_screenXY draws screenY with randrange outside [-50, 50] + fp.setdefault('window', {})['screenX'] = 0 + return js_equivalent(fp) + + +def load_inputs(): + path = FIXTURES / 'inputs.json' + if path.exists() and '--regen-inputs' not in sys.argv: + return json.loads(path.read_text()) + presets = orjson.loads((Path(fingerprints.__file__).parent / 'fingerprint-presets-v150.json').read_bytes())['presets'] + from camoufox.webgl import firefox_gpus + + pairs = {os_key: sorted(firefox_gpus(os_key)) for os_key in ('win', 'mac', 'lin')} + known = {x for v in pairs.values() for x in v} + + def pick(os_key): + for p in presets[os_key]: + gl = p.get('webgl') or {} + if (gl.get('unmaskedVendor'), gl.get('unmaskedRenderer')) in known: + return p + raise SystemExit(f'no preset for {os_key} with a recorded GPU') + + inputs = { + 'fingerprints': {o: make_fingerprint(o) for o in ('linux', 'windows', 'macos')}, + 'presets': { + 'windows': pick('windows'), + 'macos': pick('macos'), + 'linux': pick('linux'), + # A GPU fpgen has never seen, which launch_options must refuse. + 'windows_unknown_gpu': {**pick('windows'), 'webgl': { + 'unmaskedVendor': 'Google Inc. (Acme)', + 'unmaskedRenderer': 'ANGLE (Acme, Acme GPU 9000 Direct3D11 vs_5_0 ps_5_0)'}}, + }, + 'webgl_pairs': {k: [list(x) for x in v[:2]] for k, v in pairs.items()}, + } + inputs = js_equivalent(inputs) + path.write_text(json.dumps(inputs, indent=1, ensure_ascii=False) + '\n') + return inputs + + +INPUTS = load_inputs() + +# ---------------------------------------------------------------- scenarios +BUNDLE = FIXTURES / 'bundle' +BUNDLE_OLD = FIXTURES / 'bundle-old' +ADDON = FIXTURES / 'addons' / 'example-addon' + +PLACEHOLDERS = [ + (str(BUNDLE_OLD), ''), + (str(BUNDLE), ''), + (str(ADDON), ''), + (str(CACHE), ''), + (str(HOME), ''), +] + + +def fill(value): + """Scenario kwargs -> real values.""" + if isinstance(value, str): + for real, ph in PLACEHOLDERS: + value = value.replace(ph, real) + return value + if isinstance(value, dict): + return {k: fill(v) for k, v in value.items()} + if isinstance(value, list): + return [fill(v) for v in value] + return value + + +def mask(value): + if isinstance(value, str): + for real, ph in PLACEHOLDERS: + value = value.replace(real, ph) + # A FallbackWarning's report block names the host and the runtime + # (python/node), so neither launcher can reproduce the other's. + value = re.sub(r'(and include:\n\n)( .*(\n|$))+', r'\1', value) + # The name hashes the fonts.conf content, which embeds the checkout path; + # the TS test masks it the same way and checks the hash itself. + return re.sub(r'fonts-[0-9a-f]{12}\.conf', 'fonts-.conf', value) + if isinstance(value, dict): + return {mask(k): mask(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [mask(v) for v in value] + return value + + +BASE = {'executable_path': '/camoufox-bin', 'env': {'HOME': ''}} +FP = INPUTS['fingerprints'] +PR = INPUTS['presets'] + +S = {} + + +def scenario(name, _special=None, **kwargs): + assert name not in S, name + S[name] = {'kwargs': {**BASE, **kwargs}, 'special': _special or {}} + + +# fpgen fingerprints, one per target OS +for _os in ('linux', 'windows', 'macos'): + scenario(f'fpgen_{_os}', fingerprint=FP[_os], os=_os) +scenario('fpgen_linux_ikwid', fingerprint=FP['linux'], os='linux', i_know_what_im_doing=True) +scenario('fpgen_no_os', fingerprint=FP['windows']) +scenario('fpgen_os_list', fingerprint=FP['macos'], os=['macos', 'windows']) + +# presets +for _os in ('windows', 'macos', 'linux'): + scenario(f'preset_{_os}', fingerprint_preset=PR[_os]) +scenario('preset_windows_unknown_gpu', fingerprint_preset=PR['windows_unknown_gpu']) +scenario('preset_random', {'random_preset': 'macos'}, fingerprint_preset=True, os='macos') + +# generation path (generate_fingerprint patched to a fixed fingerprint) +scenario('generate_default', {'generate': 'linux'}) +scenario('generate_window', {'generate': 'windows'}, window=[1280, 720], os='windows') +scenario('generate_screen', {'generate': 'macos'}, screen={'max_width': 1920, 'max_height': 1080}, os='macos') +scenario('generate_headful_display', {'generate': 'linux'}, headless=False, + env={'HOME': '', 'DISPLAY': ':0'}) +scenario('generate_headless_display', {'generate': 'linux'}, headless=True, + env={'HOME': '', 'DISPLAY': ':0'}) + +# headless / display handling on a fixed identity +scenario('headful_display_clamp', fingerprint=FP['windows'], os='windows', headless=False, + env={'HOME': '', 'DISPLAY': ':0'}) +scenario('headless_true', fingerprint=FP['linux'], os='linux', headless=True) +scenario('virtual_display', fingerprint=FP['linux'], os='linux', virtual_display=':99', + env={'HOME': '', 'WAYLAND_DISPLAY': 'wayland-0', 'GDK_BACKEND': 'wayland', 'KEEP': '1'}) + +# locale +scenario('locale_full', fingerprint=FP['linux'], os='linux', locale='fr-FR') +scenario('locale_script', fingerprint=FP['linux'], os='linux', locale='zh-Hans-CN') +scenario('locale_language_only', fingerprint=FP['windows'], os='windows', locale='de') +scenario('locale_region_only', fingerprint=FP['windows'], os='windows', locale='CA') +scenario('locale_list', fingerprint=FP['macos'], os='macos', locale=['en-US', 'fr-FR', 'de', 'en-US']) +scenario('locale_string_list', fingerprint=FP['macos'], os='macos', locale='ja-JP, en') +scenario('locale_invalid', fingerprint=FP['linux'], os='linux', locale='xx-invalid-tag-!!') + +# geoip / proxy +scenario('geoip_ipv4', fingerprint=FP['windows'], os='windows', geoip='8.8.8.8') +scenario('geoip_ipv6', fingerprint=FP['windows'], os='windows', geoip='2a01:4f8::1') +scenario('geoip_true_proxy', fingerprint=FP['linux'], os='linux', geoip=True, + proxy={'server': 'http://proxy.example:8080', 'username': 'u', 'password': 'p'}) +scenario('geoip_true_no_proxy', fingerprint=FP['linux'], os='linux', geoip=True) +scenario('geoip_block_webrtc', fingerprint=FP['macos'], os='macos', geoip='203.0.113.7', block_webrtc=True) +scenario('geoip_with_locale', fingerprint=FP['macos'], os='macos', geoip='8.8.8.8', locale='es-MX') +scenario('geoip_manual_timezone', fingerprint=FP['macos'], os='macos', geoip='8.8.8.8', + config={'timezone': 'Europe/Paris', 'locale:language': 'fr', 'locale:region': 'FR'}) +scenario('geoip_db_named', fingerprint=FP['linux'], os='linux', geoip='8.8.8.8', geoip_db='MaxMind GeoLite2') +scenario('geoip_unknown_ip', fingerprint=FP['linux'], os='linux', geoip='192.0.2.1') +scenario('geoip_invalid_ip', fingerprint=FP['linux'], os='linux', geoip='not-an-ip') +scenario('proxy_without_geoip', fingerprint=FP['linux'], os='linux', proxy={'server': 'http://proxy.example:8080'}) +scenario('proxy_localhost', fingerprint=FP['linux'], os='linux', proxy={'server': 'http://localhost:8080'}) +scenario('proxy_manual_geolocation', fingerprint=FP['linux'], os='linux', proxy={'server': 'socks5://1.2.3.4:1080'}, + config={'geolocation:latitude': 10.5, 'geolocation:longitude': 20.25}) + +# humanize +scenario('humanize_true', fingerprint=FP['linux'], os='linux', humanize=True) +scenario('humanize_float', fingerprint=FP['linux'], os='linux', humanize=1.5) +scenario('humanize_int', fingerprint=FP['linux'], os='linux', humanize=2) +scenario('humanize_false', fingerprint=FP['linux'], os='linux', humanize=False) + +# block_* and friends +scenario('block_all', fingerprint=FP['windows'], os='windows', block_images=True, block_webrtc=True, + block_webgl=True, disable_coop=True) +scenario('block_all_ikwid', fingerprint=FP['windows'], os='windows', block_images=True, block_webrtc=True, + block_webgl=True, disable_coop=True, i_know_what_im_doing=True) +scenario('allow_webgl_false', fingerprint=FP['linux'], os='linux', allow_webgl=False) +scenario('flags', fingerprint=FP['macos'], os='macos', main_world_eval=True, allow_addon_new_tab=True, + enable_cache=True, args=['--foo', '--bar=1'], firefox_user_prefs={'my.pref': 'x', 'ui.useOverlayScrollbars': 0, + 'intl.locale.requested': 'de-DE'}) +scenario('ff_version', fingerprint=FP['windows'], os='windows', ff_version=140) +scenario('pin_cpu_cores', fingerprint=FP['linux'], os='linux', pin_cpu_cores=True) + +# addons +scenario('addons_custom', fingerprint=FP['linux'], os='linux', addons=['']) +scenario('addons_exclude_default', fingerprint=FP['linux'], os='linux', exclude_addons=['UBO']) +scenario('addons_only_custom', fingerprint=FP['linux'], os='linux', addons=[''], exclude_addons=['UBO']) +scenario('addons_invalid', fingerprint=FP['linux'], os='linux', addons=['/fonts']) + +# webgl +scenario('webgl_config_windows', fingerprint=FP['windows'], os='windows', webgl_config=INPUTS['webgl_pairs']['win'][0]) +scenario('webgl_config_macos', fingerprint=FP['macos'], os='macos', webgl_config=INPUTS['webgl_pairs']['mac'][0]) +scenario('webgl_config_no_os', fingerprint=FP['linux'], webgl_config=INPUTS['webgl_pairs']['lin'][0]) +scenario('webgl_config_unknown', fingerprint=FP['linux'], os='linux', webgl_config=['Nope', 'Nope GPU']) + +# fonts / voices +scenario('fonts_custom', fingerprint=FP['linux'], os='linux', fonts=['Arial', 'Helvetica', 'Comic Sans MS']) +scenario('fonts_custom_only', fingerprint=FP['windows'], os='windows', fonts=['Arial'], custom_fonts_only=True) +scenario('fonts_custom_only_missing', fingerprint=FP['windows'], os='windows', custom_fonts_only=True) +scenario('fonts_config', fingerprint=FP['macos'], os='macos', config={'fonts': ['Helvetica', 'Menlo']}) +scenario('fonts_config_empty', fingerprint=FP['macos'], os='macos', config={'fonts': []}) +scenario('voices_config', fingerprint=FP['macos'], os='macos', config={'voices': [ + {'lang': 'en-US', 'name': 'Samantha', 'voiceUri': 'com.apple.voice.compact.en-US.Samantha', 'isDefault': True, + 'isLocalService': True}]}) +scenario('voices_config_bad', fingerprint=FP['macos'], os='macos', config={'voices': ['Samantha:en-US:local']}) +scenario('voices_config_missing_field', fingerprint=FP['macos'], os='macos', config={'voices': [{'lang': 'en-US'}]}) + +# config overrides and the warnings they emit +scenario('config_navigator', fingerprint=FP['windows'], os='windows', + config={'navigator.userAgent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0', + 'navigator.platform': 'Win32', 'navigator.hardwareConcurrency': 6}) +scenario('config_ua_only', {'generate': 'macos'}, config={ + 'navigator.userAgent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0'}) +scenario('config_ua_ikwid', {'generate': 'linux'}, i_know_what_im_doing=True, config={ + 'navigator.userAgent': 'Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0'}) +scenario('config_header_ua', fingerprint=FP['linux'], os='linux', config={'headers.User-Agent': 'x'}) +scenario('config_locale_keys', fingerprint=FP['linux'], os='linux', + config={'locale:language': 'pt', 'locale:region': 'BR', 'headers.Accept-Language': 'pt-BR'}) +scenario('config_screen', fingerprint=FP['macos'], os='macos', + config={'screen.width': 1440, 'screen.height': 900, 'window.outerWidth': 1200}) +scenario('config_touch', fingerprint=FP['windows'], os='windows', config={'navigator.maxTouchPoints': 5}) +scenario('config_dnt_gpc', fingerprint=FP['windows'], os='windows', + config={'navigator.doNotTrack': '1', 'navigator.globalPrivacyControl': True}) +scenario('config_accept_encoding', fingerprint=FP['linux'], os='linux', config={'headers.Accept-Encoding': 'gzip'}) +scenario('config_seeds', fingerprint=FP['linux'], os='linux', config={'audio:seed': 42}) +scenario('config_instant_animations', fingerprint=FP['linux'], os='linux', config={'instantAnimations': True}) +scenario('config_media_devices', fingerprint=FP['linux'], os='linux', config={'mediaDevices:micros': 0}) +scenario('config_webgl_pair', fingerprint=FP['linux'], os='linux', + config={'webGl:vendor': INPUTS['webgl_pairs']['lin'][1][0], 'webGl:renderer': INPUTS['webgl_pairs']['lin'][1][1]}) +scenario('config_webgl_unknown_pair', fingerprint=FP['linux'], os='linux', + config={'webGl:vendor': 'Nope', 'webGl:renderer': 'Nope GPU'}) +scenario('config_unknown_key', fingerprint=FP['linux'], os='linux', + config={'not.a.real.property': {'a': [1, 'b', None, True]}, 'also.unknown': 'x'}) +scenario('config_bad_type', fingerprint=FP['linux'], os='linux', config={'navigator.buildID': 5}) +scenario('config_float_int', fingerprint=FP['linux'], os='linux', config={'screen.width': 1920.0, + 'window.devicePixelRatio': 2}) +scenario('config_non_ascii', fingerprint=FP['linux'], os='linux', config={'timezone': 'America/Sรฃo_Paulo'}, + firefox_user_prefs={'my.unicode.pref': 'hรฉllo โœ“ \U0001F600'}) + +# errors / validation +scenario('invalid_os_case', fingerprint=FP['linux'], os='Linux') +scenario('invalid_os_name', fingerprint=FP['linux'], os='beos') +scenario('non_firefox_fingerprint', fingerprint={**FP['linux'], 'navigator': { + **FP['linux']['navigator'], 'userAgent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'}}) + +# persistent context / passthrough Playwright options +scenario('persistent_passthrough', fingerprint=FP['linux'], os='linux', user_data_dir='/profile', + slow_mo=50, timeout=12345) + +# executable_path handling +scenario('executable_old_build', fingerprint=FP['linux'], os='linux', executable_path='/camoufox-bin') +scenario('executable_from_env', {'process_env': {'CAMOUFOX_EXECUTABLE_PATH': '/camoufox-bin'}}, + fingerprint=FP['linux'], os='linux', executable_path=None) + +# chunking: a config bigger than one env var chunk +scenario('config_large', fingerprint=FP['linux'], os='linux', i_know_what_im_doing=True, + config={'fonts': [f'Font Family {i:05d}' for i in range(2600)]}) + + +# ------------------------------------------------------------------ running +def run(name, spec): + kwargs = fill(copy.deepcopy(spec['kwargs'])) + special = spec['special'] + calls = [] + restore = [] + + if kwargs.get('executable_path') is None: + kwargs.pop('executable_path', None) + if 'screen' in kwargs: + kwargs['screen'] = Screen(**kwargs['screen']) + if 'exclude_addons' in kwargs: + kwargs['exclude_addons'] = [DefaultAddons[x] for x in kwargs['exclude_addons']] + if 'webgl_config' in kwargs: + kwargs['webgl_config'] = tuple(kwargs['webgl_config']) + if 'window' in kwargs: + kwargs['window'] = tuple(kwargs['window']) + + if 'generate' in special: + base_fp = FP[special['generate']] + + def fake_generate(window=None, screen=None, os=None, **conditions): + calls.append({'fn': 'generate_fingerprint', 'window': list(window) if window else None, + 'screen': None if screen is None else {k: getattr(screen, k) for k in ( + 'min_width', 'max_width', 'min_height', 'max_height')}, + 'os': os}) + fp = copy.deepcopy(base_fp) + if window: + fingerprints.handle_window_size(fp, *window) + return fp + + restore.append(('generate_fingerprint', utils.generate_fingerprint)) + utils.generate_fingerprint = fake_generate + if 'random_preset' in special: + preset = PR[special['random_preset']] + + def fake_random_preset(os=None, ff_version=None): + calls.append({'fn': 'get_random_preset', 'os': os, 'ff_version': ff_version}) + return copy.deepcopy(preset) + + restore.append(('get_random_preset', utils.get_random_preset)) + utils.get_random_preset = fake_random_preset + env_backup = {} + for k, v in special.get('process_env', {}).items(): + env_backup[k] = os.environ.get(k) + os.environ[k] = fill(v) + + out = io.StringIO() + record = {'name': name, 'kwargs': spec['kwargs'], 'special': special} + try: + with warnings.catch_warnings(record=True) as caught, redirect_stdout(out): + warnings.simplefilter('always') + try: + result = launch_options(**kwargs) + except Exception as exc: # recorded, not raised + record['error'] = {'type': type(exc).__name__, 'message': mask(str(exc))} + result = None + record['warnings'] = [{'category': w.category.__name__, 'message': mask(str(w.message))} for w in caught] + finally: + for attr, value in restore: + setattr(utils, attr, value) + for k, v in env_backup.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + record['stdout'] = mask(out.getvalue()) + record['calls'] = mask(calls) + if result is not None: + record['result'] = describe(result) + return record + + +def describe(result): + env = dict(result['env']) + config_chunks = sorted(((int(k.rsplit('_', 1)[1]), k) for k in env if k.startswith('CAMOU_CONFIG_'))) + pref_chunks = sorted(((int(k.rsplit('_', 1)[1]), k) for k in env if k.startswith('CAMOU_PREFS_'))) + config_blob = ''.join(env[k] for _, k in config_chunks) + prefs_blob = ''.join(env[k] for _, k in pref_chunks) + fontconfig = None + if 'FONTCONFIG_FILE' in env: + fontconfig = {'path': mask(env['FONTCONFIG_FILE']), 'content': mask(Path(env['FONTCONFIG_FILE']).read_text())} + other_env = {k: v for k, v in env.items() if not k.startswith(('CAMOU_CONFIG_', 'CAMOU_PREFS_'))} + rest = {k: v for k, v in result.items() if k not in ('env',)} + config = orjson.loads(config_blob) + js_blob = mask(orjson.dumps(js_equivalent(config)).decode()) + return { + 'options': mask(js_equivalent(rest)), + 'env': mask(other_env), + 'config_chunks': [len(env[k]) for _, k in config_chunks], + 'prefs_chunks': [len(env[k]) for _, k in pref_chunks], + # The config as JavaScript serializes the same data (see js_equivalent); + # the raw Python blob too, only when it differs from that. + 'config_blob_js': js_blob, + **({'config_blob_raw': mask(config_blob)} if mask(config_blob) != js_blob else {}), + 'prefs_blob': prefs_blob, + 'fontconfig': fontconfig, + } + + +def main(): + only = [a for a in sys.argv[1:] if not a.startswith('--')] + for old in FIXTURES.glob('scenario-*.json'): + if not only: + old.unlink() + summary = {'host': HOST, 'geo_table': GEO_TABLE, 'scenarios': []} + for name, spec in S.items(): + if only and name not in only: + continue + rec = run(name, spec) + (FIXTURES / f'scenario-{name}.json').write_text(json.dumps(rec, indent=1, ensure_ascii=False) + '\n') + summary['scenarios'].append(name) + status = rec.get('error', {}).get('type', 'ok') + print(f'{name:32s} {status:24s} warnings={len(rec["warnings"])}', file=sys.stderr) + if not only: + (FIXTURES / 'host.json').write_text(json.dumps(summary, indent=1) + '\n') + + +if __name__ == '__main__': + main() diff --git a/typescript/scripts/golden/pyrandom_cases.py b/typescript/scripts/golden/pyrandom_cases.py new file mode 100644 index 000000000..d654d0a31 --- /dev/null +++ b/typescript/scripts/golden/pyrandom_cases.py @@ -0,0 +1,63 @@ +"""random.Random sequences for tests/pyrandom.test.ts (stdlib only). + +Imported by identity_golden.py, and runnable on its own under any CPython to +check that the recorded sequences do not depend on the Python version: + + python3.10 typescript/scripts/golden/pyrandom_cases.py > /tmp/310.json + python3.12 typescript/scripts/golden/pyrandom_cases.py > /tmp/312.json + .venv/bin/python typescript/scripts/golden/pyrandom_cases.py > /tmp/314.json +""" + +import json +import sys +from random import Random + +SEEDS = [0, 1, 2, 7, 42, 99, 12345, 2**31 - 1, 2**32 - 1, 2**32, 2**32 + 1, 2**53 + 3, + 2**64 - 1, 2**64 + 5, 123456789012345678901234567890, -7, -(2**40)] +STR_SEEDS = ['', 'hello', 'camoufox', 'hรฉllo wรถrld ๐Ÿ˜€'] + + +def seed_json(seed): + return {'int': str(seed)} if isinstance(seed, int) else {'str': seed} + + +def cases(): + out = [] + for seed in SEEDS + STR_SEEDS: + r = Random(seed) + case = {'seed': seed_json(seed)} + case['random'] = [r.random() for _ in range(8)] + case['getrandbits'] = [[k, str(r.getrandbits(k))] for k in (1, 2, 5, 8, 16, 31, 32, 33, 40, 53, 64, 65, 100, 0)] + case['randbelow'] = [[n, r._randbelow(n)] for n in (1, 2, 3, 10, 100, 1000, 2**31, 2**32 - 1, 2**32 + 7, 10**15)] + case['randrange'] = [ + [[10], r.randrange(10)], + [[5, 15], r.randrange(5, 15)], + [[-20, -3], r.randrange(-20, -3)], + [[0, 100, 7], r.randrange(0, 100, 7)], + [[100, 0, -3], r.randrange(100, 0, -3)], + ] + case['randint'] = [[a, b, r.randint(a, b)] for a, b in ((1, 6), (0, 0), (-5, 5), (1, 4294967295), (0, 2**40))] + pop = list('abcdefghijklmnopqrstuvwxyz') + case['choice'] = [r.choice(pop) for _ in range(5)] + case['choices'] = { + 'plain': r.choices(pop, k=6), + 'weights': r.choices(pop[:5], weights=[0.1, 0.5, 2.0, 1.25, 0.15], k=6), + 'cum_weights': r.choices(pop[:4], cum_weights=[1, 3, 6, 10], k=6), + } + shuffled = list(range(20)) + r.shuffle(shuffled) + case['shuffle'] = shuffled + # sample: the pool branch (n <= setsize) and the set branch (n > setsize) + case['sample'] = [ + [n, k, r.sample(range(n), k)] + for n, k in ((5, 5), (10, 3), (21, 6), (22, 6), (30, 5), (40, 10), (100, 4), (100, 6), + (500, 30), (1000, 7), (60, 50), (1, 0), (300, 300)) + ] + case['uniform'] = [r.uniform(-3.5, 10.25) for _ in range(3)] + case['after'] = r.random() + out.append(case) + return out + + +if __name__ == '__main__': + json.dump({'python': sys.version.split()[0], 'cases': cases()}, sys.stdout, sort_keys=True) diff --git a/typescript/src/__main__.ts b/typescript/src/__main__.ts new file mode 100644 index 000000000..80e8340a0 --- /dev/null +++ b/typescript/src/__main__.ts @@ -0,0 +1,1345 @@ +#!/usr/bin/env node +/** + * The `camoufox` CLI. + * + * TypeScript twin of pythonlib/camoufox/__main__.py. Every command is present, + * with the same arguments, output and config/cache files. `gui` drives a + * PySide6 desktop app with no Node equivalent, so here it only says so. The + * interactive pickers (`set`, `set --geoip`, `remove --select`) use a numbered + * prompt in place of inquirer's arrow-key list. + */ +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import * as readline from "node:readline"; +import { Argument, Command, Option } from "commander"; +import { LIBRARY_VERSION } from "./__version__.js"; +import { DefaultAddons, maybeDownloadAddons } from "./addons.js"; +import { FileNotFoundError } from "./exceptions.js"; +import { + allowGeoip, + downloadMmdb, + GEOIP_DIR, + type GeoIPRepo, + getMmdbPath, + loadGeoipConfig, + saveGeoipConfig, +} from "./geolocation.js"; +import { + BROWSERS_DIR, + type CachedVersion, + COMPAT_FLAG, + CONFIG_FILE, + findInstall, + getDefaultChannel, + type InstalledVersion, + installedLabel, + latestPerBuild, + listInstalled, + loadConfig, + loadRepoCache, + printTree, + REPO_CACHE_FILE, + type RepoCache, + removeVersion, + saveConfig, + saveRepoCache, +} from "./multiversion.js"; +import { + AvailableVersion, + CamoufoxFetcher, + formatAssetDate, + INSTALL_DIR, + installedVerStr, + listAvailableVersions, + loadYaml, + RepoConfig, + rprint, + Version, +} from "./pkgman.js"; + +// -------------------------------------------------------------------------- +// click-style output + prompts +// -------------------------------------------------------------------------- + +const ANSI: Record = { + red: "31", + green: "32", + yellow: "33", + blue: "34", + cyan: "36", + bright_black: "90", +}; + +function useColor(): boolean { + return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; +} + +/** click.style */ +function style( + text: string, + { fg, bold, dim }: { fg?: string | null; bold?: boolean; dim?: boolean } = {}, +): string { + if (!useColor()) return text; + const codes: string[] = []; + if (bold) codes.push("1"); + if (dim) codes.push("2"); + if (fg && ANSI[fg]) codes.push(ANSI[fg]); + return codes.length ? `\x1b[${codes.join(";")}m${text}\x1b[0m` : text; +} + +/** click.secho */ +function secho( + text: string, + opts: { fg?: string | null; bold?: boolean; nl?: boolean } = {}, +): void { + process.stdout.write(style(text, opts) + (opts.nl === false ? "" : "\n")); +} + +/** click.echo */ +function echo(text = "", nl = true): void { + process.stdout.write(text + (nl ? "\n" : "")); +} + +let rl: readline.Interface | null = null; +const pendingLines: string[] = []; +const lineWaiters: Array<(line: string | null) => void> = []; +let stdinClosed = false; + +function readLine(): Promise { + if (!rl) { + rl = readline.createInterface({ input: process.stdin }); + rl.on("line", (line) => { + const waiter = lineWaiters.shift(); + if (waiter) waiter(line); + else pendingLines.push(line); + }); + rl.on("close", () => { + stdinClosed = true; + for (const waiter of lineWaiters.splice(0)) waiter(null); + }); + } + const queued = pendingLines.shift(); + if (queued !== undefined) return Promise.resolve(queued); + if (stdinClosed) return Promise.resolve(null); + return new Promise((resolve) => lineWaiters.push(resolve)); +} + +function closePrompts(): void { + rl?.close(); + rl = null; +} + +/** click.confirm (default No). EOF answers No. */ +async function confirm(message: string): Promise { + for (;;) { + process.stdout.write(`${message} [y/N]: `); + const answer = await readLine(); + if (answer === null) { + echo(); + return false; + } + const value = answer.trim().toLowerCase(); + if (value === "") return false; + if (value === "y" || value === "yes") return true; + if (value === "n" || value === "no") return false; + echo("Error: invalid input"); + } +} + +/** + * Generic selection, standing in for the Python twin's inquirer list. + * Returns the selected value, or null on cancel/EOF. + */ +async function select( + choices: Array<[string, T]>, + message: string, +): Promise { + echo(`${style("[?]", { fg: "yellow" })} ${message}:`); + choices.forEach(([label], i) => { + echo(` ${style(String(i + 1).padStart(2), { fg: "cyan" })}) ${label}`); + }); + for (;;) { + process.stdout.write(`Choice [1-${choices.length}]: `); + const answer = await readLine(); + if (answer === null || answer.trim() === "") { + if (answer === null) echo(); + return null; + } + const n = Number.parseInt(answer.trim(), 10); + if (Number.isInteger(n) && n >= 1 && n <= choices.length) { + return choices[n - 1][1]; + } + echo(`Error: enter a number between 1 and ${choices.length}`); + } +} + +// -------------------------------------------------------------------------- +// helpers +// -------------------------------------------------------------------------- + +/** Find an installed version by channel path, build, or full version string. */ +function findInstalled(specifier: string): InstalledVersion | null { + const spec = specifier.toLowerCase(); + const installed = listInstalled(); + const parts = spec.split("/"); + + for (const v of installed) { + if ( + v.channelPath.toLowerCase() === spec || + v.relativePath.toLowerCase() === spec || + v.version.build.toLowerCase() === spec || + v.version.fullString.toLowerCase() === spec + ) { + return v; + } + // Match repo/version without channel, for example official/134.0.2-beta.20 + if (parts.length === 2) { + const [repo, ver] = parts; + if (v.repoName === repo && v.version.fullString.toLowerCase() === ver) { + return v; + } + } + } + + // Match repo/channel, for example official/stable gets the latest installed + // for that channel + if (parts.length === 2) { + const [repo, ctype] = parts; + if (ctype === "stable" || ctype === "prerelease") { + const isPre = ctype === "prerelease"; + for (const v of installed) { + if (v.repoName === repo && v.isPrerelease === isPre) return v; + } + } + } + + return null; +} + +/** Name of the active GeoIP source. */ +function getGeoIPSourceName(): string { + try { + return loadGeoipConfig().name ?? "Default"; + } catch { + return "Default"; + } +} + +/** + * Sync available versions from the remote repositories. Returns true on success. + */ +async function doSync(spoofOs?: string, spoofArch?: string): Promise { + rprint("Syncing repositories...", "yellow"); + + const cache: { + repos: Array<{ name: string; repo: string; versions: CachedVersion[] }>; + spoof_os: string | null; + spoof_arch: string | null; + } = { + repos: [], + spoof_os: spoofOs ?? null, + spoof_arch: spoofArch ?? null, + }; + + for (const repoConfig of RepoConfig.loadRepos()) { + rprint(` ${repoConfig.name}...`, "cyan", false); + try { + const versions = await listAvailableVersions( + repoConfig, + true, + spoofOs, + spoofArch, + ); + cache.repos.push({ + name: repoConfig.name, + repo: repoConfig.repo, + versions: versions.map( + (v) => + ({ + version: v.version.version ?? null, + build: v.version.build, + url: v.url, + is_prerelease: v.isPrerelease, + asset_id: v.assetId ?? null, + asset_size: v.assetSize ?? null, + asset_updated_at: v.assetUpdatedAt ?? null, + sha256: v.sha256 ?? null, + created_at: v.assetCreatedAt ?? null, + }) as unknown as CachedVersion, + ), + }); + rprint(` ${versions.length} versions`, "green"); + } catch (e) { + rprint(` Error: ${errorMessage(e)}`, "red"); + } + } + + saveRepoCache(cache as RepoCache); + const total = cache.repos.reduce((sum, r) => sum + r.versions.length, 0); + const platformStr = spoofOs ? ` (${spoofOs}/${spoofArch})` : ""; + rprint( + `\nSynced ${total} versions from ${cache.repos.length} repos${platformStr}.`, + "green", + ); + + return true; +} + +function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +/** Ensure a repo cache exists. Returns true if synced. */ +function ensureSynced(): boolean { + if (!fs.existsSync(REPO_CACHE_FILE)) { + rprint("No repo cache found. Run 'camoufox sync' first.", "red"); + return false; + } + return true; +} + +type RepoBlock = { name: string; versions?: CachedVersion[] }; + +/** Cache block for a repo by name. */ +function repoData(cache: RepoCache, repoName: string): RepoBlock | null { + return ( + (cache.repos ?? []).find( + (r) => r.name.toLowerCase() === repoName.toLowerCase(), + ) ?? null + ); +} + +/** + * Resolve a version-build or version-build-sha8 spec against cache entries. + * Returns [asset, sha] for a specific date, [latest, null] to follow the + * latest, or [null, null] when the spec is unknown. + */ +function resolveSpec( + repo: RepoBlock, + spec: string, +): [CachedVersion | null, string | null] { + const versions = repo.versions ?? []; + for (const v of versions) { + const sha = v.sha256 || ""; + if (sha && spec === `${v.version}-${v.build}-${sha.slice(0, 8)}`) { + return [v, sha]; + } + } + for (const v of latestPerBuild(versions)) { + if (spec === `${v.version}-${v.build}`) return [v, null]; + } + return [null, null]; +} + +/** Cache entry a pin resolves to: the specific sha asset or the build's latest. */ +function pinTarget( + repo: RepoBlock, + pinned: string, + pinnedSha?: string | null, +): CachedVersion | null { + const versions = repo.versions ?? []; + if (pinnedSha) { + return versions.find((v) => v.sha256 === pinnedSha) ?? null; + } + return ( + latestPerBuild(versions).find( + (v) => `${v.version}-${v.build}` === pinned, + ) ?? null + ); +} + +/** Set to track a channel (fetches the latest on fetch). */ +function setChannel(repoName: string, channelType: string): void { + const config = loadConfig(); + config.channel = `${repoName}/${channelType}`; + delete config.pinned; + delete config.pinned_sha; + + // Check if the latest for this channel is already installed + const isPre = channelType === "prerelease"; + const cache = loadRepoCache(); + for (const repo of cache.repos ?? []) { + if (repo.name.toLowerCase() !== repoName.toLowerCase()) continue; + const candidates = (repo.versions ?? []).filter( + (v) => (v.is_prerelease ?? false) === isPre, + ); + if (candidates.length) { + const latestBuild = candidates[0].build; + for (const inst of listInstalled()) { + if ( + inst.version.build === latestBuild && + inst.repoName === repoName.toLowerCase() + ) { + config.active_version = inst.relativePath; + saveConfig(config); + secho(`Channel: ${repoName.toLowerCase()}/${channelType}`, { + fg: "cyan", + bold: true, + }); + secho(`Using latest: ${inst.channelPath} (installed)`, { + fg: "green", + }); + return; + } + } + } + break; + } + + delete config.active_version; + saveConfig(config); + secho(`Channel: ${repoName.toLowerCase()}/${channelType}`, { + fg: "cyan", + bold: true, + }); + secho("Run 'camoufox fetch' to install latest.", { fg: "yellow" }); +} + +/** Pin a version-build, optionally to a specific dated asset by sha. */ +function setPinned( + repoName: string, + channelType: string, + verData: CachedVersion, + inst: InstalledVersion | null, + sha?: string | null, +): void { + const config = loadConfig(); + config.channel = `${repoName.toLowerCase()}/${channelType}`; + config.pinned = `${verData.version}-${verData.build}`; + if (sha) { + config.pinned_sha = sha; + } else { + delete config.pinned_sha; + } + const tag = sha ? ` (${sha.slice(0, 8)})` : ""; + const display = `${repoName.toLowerCase()}/${channelType}/${verData.version}-${verData.build}${tag}`; + if (inst) { + config.active_version = inst.relativePath; + saveConfig(config); + secho(`Pinned: ${display} (installed)`, { fg: "green" }); + } else { + delete config.active_version; + saveConfig(config); + secho(`Pinned: ${display}`, { fg: "cyan", bold: true }); + secho("Run 'camoufox fetch' to install.", { fg: "yellow" }); + } +} + +/** + * Checks & updates Camoufox. + */ +class CamoufoxUpdate extends CamoufoxFetcher { + currentVerStr: string | null = null; + + async init(): Promise { + await super.init(); + try { + this.currentVerStr = installedVerStr(); + } catch (error) { + if (!(error instanceof FileNotFoundError)) throw error; + this.currentVerStr = null; + } + return this; + } + + isUpdatedNeeded(): boolean { + return this.currentVerStr === null || this.currentVerStr !== this.verstr; + } + + async update(replace = false, iKnowWhatImDoing = false): Promise { + if (!this.isUpdatedNeeded() && !replace) { + rprint("Camoufox binaries up to date!", "green"); + rprint(`Current version: v${this.currentVerStr}`, "green"); + return; + } + + if (this.isPrerelease && !iKnowWhatImDoing) { + rprint(`Warning: v${this.verstr} is a prerelease version!`, "yellow"); + if (!(await confirm("Continue with prerelease installation?"))) { + rprint("Installation cancelled.", "red"); + return; + } + } + + const action = this.currentVerStr ? "Installing" : "Fetching"; + rprint(`${action} Camoufox v${this.verstr}...`, "yellow"); + await this.install(replace); + } +} + +// -------------------------------------------------------------------------- +// commands +// -------------------------------------------------------------------------- + +const program = new Command(); +program.name("camoufox").version(LIBRARY_VERSION); + +program + .command("sync") + .description("Sync available versions from remote repositories") + .addOption( + new Option("--spoof-os ", "Spoof OS (auto = native)").choices([ + "auto", + "mac", + "win", + "lin", + ]), + ) + .addOption( + new Option( + "--spoof-arch ", + "Spoof architecture (auto = native)", + ).choices(["auto", "x86_64", "i686", "arm64"]), + ) + .action(async ({ spoofOs, spoofArch }) => { + await doSync( + spoofOs === "auto" ? undefined : spoofOs, + spoofArch === "auto" ? undefined : spoofArch, + ); + }); + +program + .command("fetch") + .description("Install the active version, or a specific version") + .argument("[version]", "e.g. official/135.0-beta.25") + .action(async (version?: string) => { + // Clean up an incompatible old data directory + if ( + fs.existsSync(INSTALL_DIR) && + fs.readdirSync(INSTALL_DIR).length > 0 && + !fs.existsSync(COMPAT_FLAG) + ) { + rprint("Cleaning old data...", "yellow"); + fs.rmSync(INSTALL_DIR, { recursive: true, force: true }); + } + + await doSync(); + + const cache = loadRepoCache(); + const config = loadConfig(); + + let repoName: string | null = null; + let repo: RepoBlock | null = null; + let verData: CachedVersion | null = null; + + if (version) { + const parts = version.toLowerCase().split("/"); + let spec: string; + if (parts.length === 1) { + repoName = RepoConfig.getDefaultName(); + spec = parts[0]; + } else if (parts.length === 2 || parts.length === 3) { + repoName = parts[0]; + spec = parts[parts.length - 1]; + } else { + rprint( + "Format: version-build, repo/version-build, or repo/channel/version-build", + "red", + ); + return; + } + repo = repoData(cache, repoName); + if (repo) [verData] = resolveSpec(repo, spec.replace(/^v+/, "")); + } else if (config.pinned) { + const channel = config.channel ?? ""; + repoName = channel.includes("/") ? channel.split("/")[0] : channel; + repo = repoData(cache, repoName); + if (repo) verData = pinTarget(repo, config.pinned, config.pinned_sha); + } else { + const channel = config.channel || getDefaultChannel(); + const slash = channel.indexOf("/"); + const name = slash === -1 ? channel : channel.slice(0, slash); + const ctype = slash === -1 ? "stable" : channel.slice(slash + 1); + repoName = name; + repo = repoData(cache, repoName); + if (repo) { + const isPre = ctype === "prerelease"; + const latest = latestPerBuild(repo.versions ?? []).filter( + (v) => (v.is_prerelease ?? false) === isPre, + ); + verData = latest[0] ?? null; + } + } + + if (!verData || !repo) { + rprint( + `Version '${version || repoName}' not found in cache. Run 'camoufox sync'.`, + "red", + ); + return; + } + + const selected = new AvailableVersion({ + version: new Version(verData.build, verData.version), + url: verData.url, + isPrerelease: verData.is_prerelease ?? false, + sha256: verData.sha256 ?? undefined, + assetCreatedAt: verData.created_at ?? undefined, + }); + const repoConfig = RepoConfig.findByName(repo.name); + try { + const updater = await new CamoufoxUpdate(repoConfig, selected).init(); + await updater.update(); + } catch (e) { + const msg = errorMessage(e); + if (msg.includes("404") || msg.includes("Not Found")) { + rprint("Release not found (404). Asset may have been removed.", "red"); + rprint("Run 'camoufox sync' to refresh available versions.", "yellow"); + } else { + rprint(`Error: ${msg}`, "red"); + } + return; + } + if (allowGeoip()) { + await downloadMmdb(); + } + await maybeDownloadAddons({ ...DefaultAddons }); + // TS addition: Python's fpgen package ships its model with the wheel; + // the TS port fetches the pinned model into the cache, so do it here + // rather than on the first launch. + const { ensureModel } = await import("./fpgen/index.js"); + await ensureModel(); + }); + +program + .command("set") + .description( + "Set the active Camoufox version to use & fetch.\n" + + "By default, this opens an interactive selector for versions and settings.\n" + + "You can also pass a specifier to activate directly:\n" + + "Pin version:\n" + + " camoufox set official/stable/134.0.2-beta.20\n" + + "Automatically find latest in a channel source:\n" + + " camoufox set official/stable", + ) + .argument("[specifier]") + .option("--geoip", "Select GeoIP source instead") + .action(async (specifier: string | undefined, { geoip }) => { + if (geoip) { + await selectGeoIPSource(); + return; + } + + if (specifier) { + const parts = specifier.toLowerCase().split("/"); + + // 2-part sets a channel like official/stable + if (parts.length === 2) { + const [repoName, ctype] = parts; + if (ctype !== "stable" && ctype !== "prerelease") { + rprint( + `Unknown channel type '${ctype}'. Use 'stable' or 'prerelease'.`, + "red", + ); + return; + } + setChannel(repoName, ctype); + return; + } + + // 1-part pins in the default repo, 3-part names the repo and channel + let repoName: string; + let spec: string; + if (parts.length === 1) { + repoName = RepoConfig.getDefaultName(); + spec = parts[0]; + } else if (parts.length === 3) { + const ctype = parts[1]; + [repoName, , spec] = parts; + if (ctype !== "stable" && ctype !== "prerelease") { + rprint( + `Unknown channel type '${ctype}'. Use 'stable' or 'prerelease'.`, + "red", + ); + return; + } + } else { + rprint(`Invalid specifier '${specifier}'.`, "red"); + rprint( + "Use: version-build, repo/channel, or repo/channel/version-build", + "yellow", + ); + return; + } + + if (!ensureSynced()) return; + const repo = repoData(loadRepoCache(), repoName); + if (!repo) { + rprint( + `Repo '${repoName.toLowerCase()}' not in cache. Run 'camoufox sync'.`, + "red", + ); + return; + } + const [verData, sha] = resolveSpec(repo, spec.replace(/^v+/, "")); + if (!verData) { + rprint( + `Version '${spec}' not found in ${repoName.toLowerCase()}.`, + "red", + ); + return; + } + const ctype = verData.is_prerelease ? "prerelease" : "stable"; + const vb = `${verData.version}-${verData.build}`; + const count = (repo.versions ?? []).filter( + (x) => `${x.version}-${x.build}` === vb, + ).length; + const inst = findInstall(vb, verData.sha256, listInstalled(), count); + setPinned(repo.name, ctype, verData, inst, sha); + return; + } + + if (!ensureSynced()) return; + await interactiveSet(); + }); + +async function interactiveSet(): Promise { + const cache = loadRepoCache(); + const installedList = listInstalled(); + + if (!cache.repos?.length) { + rprint("No versions in cache. Run 'camoufox sync' first.", "red"); + return; + } + + const channels: Array<[string, string, CachedVersion]> = []; + for (const repo of cache.repos) { + const versions = repo.versions ?? []; + const stable = latestPerBuild(versions.filter((v) => !v.is_prerelease)); + const prereleases = latestPerBuild(versions.filter((v) => v.is_prerelease)); + if (stable.length) channels.push([repo.name, "stable", stable[0]]); + if (prereleases.length) { + channels.push([repo.name, "prerelease", prereleases[0]]); + } + } + + const config = loadConfig(); + const channel = config.channel || getDefaultChannel(); + const pinned = config.pinned; + const pinnedSha = config.pinned_sha; + + if (pinned) { + secho(`Pinned: ${channel.toLowerCase()}/${pinned}`, { fg: "cyan" }); + } else { + secho(`Channel: ${channel.toLowerCase()}`, { fg: "cyan" }); + } + echo(); + + // Full dated lists so the pin picker can show every date, not just the latest + const channelVersions: Array<[string, string, CachedVersion[]]> = []; + for (const repo of cache.repos) { + const versions = repo.versions ?? []; + const stable = versions.filter((v) => !v.is_prerelease); + const prereleases = versions.filter((v) => v.is_prerelease); + if (stable.length) channelVersions.push([repo.name, "stable", stable]); + if (prereleases.length) { + channelVersions.push([repo.name, "prerelease", prereleases]); + } + } + + type Action = "channel" | "exit" | ["pin", string, string, CachedVersion[]]; + + for (;;) { + const choices: Array<[string, Action]> = [["Set channel", "channel"]]; + for (const [name, ctype, versions] of channelVersions) { + const label = `Pin version: ${style(`${name.toLowerCase()}/${ctype}`, { fg: "cyan", bold: true })}`; + choices.push([label, ["pin", name, ctype, versions]]); + } + choices.push([style("Exit", { fg: "bright_black" }), "exit"]); + + const action = await select(choices, "Select"); + if (action === null || action === "exit") return; + + if (action === "channel") { + const chChoices: Array<[string, [string, string] | null]> = []; + for (const [name, ctype, latest] of channels) { + const verStr = `v${latest.version}-${latest.build}`; + const isCurrent = channel === `${name}/${ctype}`; + let label = `${name.toLowerCase()}/${ctype} (latest: ${verStr})`; + if (isCurrent) { + label = `${style(label, { fg: "green", bold: true })} (current)`; + } + chChoices.push([label, [name, ctype]]); + } + chChoices.push([style("Back", { fg: "bright_black" }), null]); + + const chAnswer = await select(chChoices, "Set channel"); + if (!chAnswer) continue; + setChannel(chAnswer[0], chAnswer[1]); + return; + } + + const [, rname, ctype, versions] = action; + const vbCounts = new Map(); + for (const x of versions) { + const key = `${x.version}-${x.build}`; + vbCounts.set(key, (vbCounts.get(key) ?? 0) + 1); + } + + const vChoices: Array<[string, CachedVersion | null]> = []; + for (const v of versions) { + const vb = `${v.version}-${v.build}`; + const sha = v.sha256 || ""; + const date = formatAssetDate(v.created_at ?? undefined); + const inst = findInstall( + vb, + v.sha256 ?? undefined, + installedList, + vbCounts.get(vb) ?? 0, + ); + const isPinned = pinned === vb && (pinnedSha ?? null) === (sha || null); + let color: string | null; + let bold: boolean; + let status: string; + if (isPinned) { + [color, bold, status] = ["green", true, "(pinned)"]; + } else if (inst) { + [color, bold, status] = [null, false, "(installed)"]; + } else { + [color, bold, status] = ["bright_black", false, ""]; + } + const cells = [date, sha ? `(${sha.slice(0, 8)})` : "", status].filter( + Boolean, + ); + const meta = cells.length ? ` ${cells.join(" ")}` : ""; + vChoices.push([style(`v${vb}`, { fg: color, bold }) + meta, v]); + } + vChoices.push([style("Back", { fg: "bright_black" }), null]); + + const verData = await select( + vChoices, + `Pin version (${rname.toLowerCase()}/${ctype})`, + ); + if (!verData) continue; + + const vb = `${verData.version}-${verData.build}`; + const inst = findInstall( + vb, + verData.sha256 ?? undefined, + installedList, + vbCounts.get(vb) ?? 0, + ); + setPinned(rname, ctype, verData, inst, verData.sha256); + return; + } +} + +/** Interactive selection of the GeoIP source. */ +async function selectGeoIPSource(): Promise { + const repos: GeoIPRepo[] = loadYaml("repos.yml").geoip ?? []; + if (!repos.length) { + rprint("No GeoIP sources configured.", "red"); + return; + } + + let current = ""; + try { + current = loadGeoipConfig().name ?? ""; + } catch { + // unreadable config: nothing is marked active + } + const choices: Array<[string, GeoIPRepo]> = repos.map((r) => [ + r.name + (r.name === current ? " [active]" : ""), + r, + ]); + + const selected = await select(choices, "Select GeoIP source"); + if (!selected) return; + + saveGeoipConfig(selected); + rprint(`GeoIP source: ${selected.name}`, "green"); +} + +program + .command("list") + .description( + "List Camoufox versions\n\n" + + "installed Show installed versions (default)\n" + + "all Show all available versions from synced repos", + ) + .addArgument( + new Argument("[mode]").choices(["installed", "all"]).default("installed"), + ) + .option("--path", "Show full paths") + .action((mode: string, { path: showPaths }: { path?: boolean }) => { + if (mode === "all") { + listAll(Boolean(showPaths)); + } else { + listInstalledVersions(Boolean(showPaths)); + } + }); + +function listInstalledVersions(showPaths: boolean): void { + printTree(true, showPaths); + + echo(); + secho("geoip/", { fg: "cyan", bold: true, nl: false }); + if (showPaths && fs.existsSync(GEOIP_DIR)) { + secho(` -> ${GEOIP_DIR}`, { fg: "bright_black" }); + } else { + echo(); + } + + if (fs.existsSync(GEOIP_DIR)) { + const mmdb = getMmdbPath(); + if (fs.existsSync(mmdb)) { + echo(` โ””โ”€โ”€ ${path.basename(mmdb)} `, false); + secho(`(${getGeoIPSourceName()})`, { fg: "green" }); + } else { + rprint(" โ””โ”€โ”€ Not downloaded", "yellow"); + } + } else { + rprint(" โ””โ”€โ”€ Not configured", "yellow"); + } +} + +function listAll(showPaths: boolean): void { + if (!ensureSynced()) return; + + const cache = loadRepoCache(); + const installed = new Map(listInstalled().map((v) => [v.version.build, v])); + + rprint("Available versions:\n", "yellow"); + + for (const repo of cache.repos ?? []) { + const versions = latestPerBuild(repo.versions ?? []); + + secho(`${repo.name}/`, { fg: "cyan", bold: true }); + + versions.forEach((v, i) => { + const fullVer = `${v.version}-${v.build}`; + const inst = installed.get(v.build); + const isLast = i === versions.length - 1; + const prefix = isLast ? "โ””โ”€โ”€ " : "โ”œโ”€โ”€ "; + const active = Boolean(inst?.isActive); + + echo(` ${prefix}`, false); + secho(`v${fullVer}`, { + fg: active ? "green" : null, + bold: active, + nl: false, + }); + + if (v.is_prerelease) { + secho(" (prerelease)", { fg: "yellow", nl: false }); + } else { + secho(" (stable)", { fg: "blue", nl: false }); + } + + if (inst) { + if (inst.isActive) { + secho(" (installed, active)", { fg: "green", bold: true, nl: false }); + } else { + secho(" (installed)", { fg: "green", nl: false }); + } + if (showPaths) { + secho(` -> ${inst.path}`, { fg: "bright_black", nl: false }); + } + } + + echo(); + }); + + echo(); + } +} + +program + .command("remove") + .description( + "Remove downloaded data. By default, this removes everything.\n" + + "Pass --select to pick a browser version to remove.", + ) + .argument("[version_path]") + .option("--select", "Interactively select a version to remove") + .option("-y, --yes", "Skip confirmation prompts") + .action( + async ( + versionPath: string | undefined, + { select: doSelect, yes }: { select?: boolean; yes?: boolean }, + ) => { + // Select mode: interactively pick a single version + if (doSelect) { + const installed = listInstalled(); + if (!installed.length) { + rprint("No browser versions installed.", "yellow"); + return; + } + const choices: Array<[string, InstalledVersion]> = installed.map( + (v) => { + const tag = installedLabel(v); + const suffix = tag ? ` (${tag})` : ""; + return [ + v.channelPath + suffix + (v.isActive ? " [active]" : ""), + v, + ]; + }, + ); + const target = await select(choices, "Select version to remove"); + if (!target) { + rprint("Cancelled.", "yellow"); + return; + } + if (yes || (await confirm(`Remove ${target.channelPath}?`))) { + removeVersion(target.path); + rprint(`Removed ${target.channelPath}`, "green"); + } + return; + } + + // Specific version: remove just that one + if (versionPath) { + const target = findInstalled(versionPath); + if (!target) { + rprint(`Version '${versionPath}' not found.`, "red"); + return; + } + if (yes || (await confirm(`Remove ${target.channelPath}?`))) { + removeVersion(target.path); + rprint(`Removed ${target.channelPath}`, "green"); + } + return; + } + + // Default: remove everything + if ( + !fs.existsSync(INSTALL_DIR) || + fs.readdirSync(INSTALL_DIR).length === 0 + ) { + rprint("Nothing to remove.", "yellow"); + return; + } + + if ( + yes || + (await confirm(`Remove the camoufox data directory (${INSTALL_DIR})?`)) + ) { + fs.rmSync(INSTALL_DIR, { recursive: true, force: true }); + rprint("Removed camoufox data directory.", "green"); + } + }, + ); + +program + .command("test") + .description("Open the Playwright inspector") + .argument("[url]") + .option("--executable-path ", "Path to the Camoufox executable") + .action(async (url: string | undefined, { executablePath }) => { + const { Camoufox } = await import("./sync_api.js"); + const browser = await Camoufox({ + headless: false, + env: process.env as Record, + config: { showcursor: false }, + executable_path: executablePath, + }); + try { + const page = await browser.newPage(); + if (url) { + await page.goto(url); + } + await page.pause(); + } finally { + await browser.close(); + } + }); + +program + .command("server") + .description("Launch a Playwright server") + .action(async () => { + // The Python twin hands launch options to launchServer.js, which logs + // exactly this; the server runs until the process is told to stop. + const { launchServer } = await import("./server.js"); + const started = performance.now(); + console.info("Launching server..."); + const server = await launchServer(); + console.log( + `Server launched: ${(performance.now() - started).toFixed(3)}ms`, + ); + console.log("Websocket endpoint:\x1b[93m", server.wsEndpoint(), "\x1b[0m"); + + await new Promise((resolve) => { + const stop = () => resolve(); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + }); + await server.close(); + }); + +program + .command("gui") + .description("Launch the Camoufox Manager GUI (requires PySide6)") + .option("--debug", "Enable debug options in the GUI.") + .action(() => { + rprint( + "The GUI is a PySide6 app and is only available from the Python package: pip install 'camoufox[gui]'", + "red", + ); + }); + +// -------------------------------------------------------------------------- +// version +// -------------------------------------------------------------------------- + +/** Version of an installed npm package, or null. */ +function pkgVersion(name: string): string | null { + try { + const require = createRequire(import.meta.url); + const pkg = JSON.parse( + fs.readFileSync(require.resolve(`${name}/package.json`), "utf-8"), + ); + return typeof pkg.version === "string" ? pkg.version : null; + } catch { + return null; + } +} + +/** The TypeScript fpgen port's version, when it exposes one. */ +async function fpgenVersion(): Promise { + const specifier = "./fpgen/index.js"; + try { + const mod: Record = await import(specifier); + for (const key of ["VERSION", "version", "__version__", "FPGEN_VERSION"]) { + if (typeof mod[key] === "string") return mod[key] as string; + } + return null; + } catch { + return null; + } +} + +class VersionInfo { + rows: Array< + [string, string, { fg?: string; bold?: boolean; dim?: boolean }] + > = []; + + row(label: string, value: string, fg: string | "dim" = "green"): void { + this.rows.push([ + style(` ${label}`, { dim: true }), + value, + fg === "dim" ? { dim: true } : { fg }, + ]); + } + + header(title: string): void { + this.rows.push([style(title, { bold: true }), "", {}]); + } + + pkg(label: string, version: string | null): void { + if (version) this.row(label, `v${version}`); + else this.row(label, "?", "dim"); + } + + async packages(): Promise { + this.header("Packages"); + this.pkg("Camoufox", LIBRARY_VERSION); + this.pkg("fpgen", await fpgenVersion()); + this.pkg("Playwright", pkgVersion("playwright-core")); + } + + browser(): void { + this.header("Browser"); + + const config = loadConfig(); + const pinned = config.pinned; + const channel = config.channel || getDefaultChannel(); + + // Active: what was set (channel or pinned version) + if (pinned) { + this.row("Active", `${channel.toLowerCase()}/${pinned}`); + } else { + this.row("Active", channel.toLowerCase()); + } + + // Find the active installed version + const activeV = listInstalled().find((v) => v.isActive) ?? null; + + if (activeV) { + this.row("Current browser", `v${activeV.version.fullString}`); + } else { + this.row("Current browser", "Not installed", "dim"); + } + + if (activeV?.createdAt) { + this.row("Build date", formatAssetDate(activeV.createdAt), "dim"); + } + if (activeV?.sha256) { + this.row("SHA256", activeV.sha256.slice(0, 12), "dim"); + } + + if (activeV) { + this.row("Installed", "Yes", "green"); + } else { + this.row("Installed", "No", "red"); + } + + // Check if the installed version is the latest in its own channel + if (activeV) { + const ctype = activeV.isPrerelease ? "prerelease" : "stable"; + const repoCh = `${activeV.repoName}/${ctype}`; + let isLatest = false; + for (const repo of loadRepoCache().repos ?? []) { + if (repo.name.toLowerCase() !== activeV.repoName.toLowerCase()) { + continue; + } + const candidates = (repo.versions ?? []).filter( + (v) => (v.is_prerelease ?? false) === activeV.isPrerelease, + ); + if ( + candidates.length && + activeV.version.build === candidates[0].build + ) { + isLatest = true; + } + break; + } + this.row( + `Latest in ${repoCh}?`, + isLatest ? "Yes" : "No", + isLatest ? "green" : "red", + ); + } + + // Last repo sync time from the cache file mtime + if (fs.existsSync(REPO_CACHE_FILE)) { + this.row( + "Last Sync", + localTimestamp(fs.statSync(REPO_CACHE_FILE).mtime), + "dim", + ); + } else { + this.row("Last Sync", "Never", "red"); + } + } + + geoip(): void { + this.header("GeoIP"); + if (!allowGeoip()) { + this.row("Status", "Not supported (install camoufox[geoip])", "dim"); + return; + } + const mmdbPath = getMmdbPath(); + if (fs.existsSync(mmdbPath)) { + this.row("Database", loadGeoipConfig().name ?? "Unknown"); + this.row("Updated", localTimestamp(fs.statSync(mmdbPath).mtime), "dim"); + } else { + this.row("Database", "Not installed", "dim"); + } + } + + storage(): void { + this.header("Storage"); + this.row("Install path", INSTALL_DIR, "cyan"); + this.row("Browser(s) directory size", dirSize(BROWSERS_DIR), "dim"); + if (allowGeoip()) { + this.row("GeoIP database size", dirSize(GEOIP_DIR), "dim"); + } + this.row("Config file", CONFIG_FILE, "cyan"); + this.row("Repo cache", REPO_CACHE_FILE, "cyan"); + } + + async printAll(): Promise { + await this.packages(); + this.browser(); + this.geoip(); + this.storage(); + // rich's Table.grid(padding=(0, 2)): the first column padded to its + // widest cell, then two spaces. + // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI codes to measure + const visible = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "").length; + const width = Math.max(...this.rows.map(([label]) => visible(label))); + for (const [label, value, st] of this.rows) { + const pad = " ".repeat(width - visible(label)); + echo(value ? `${label}${pad} ${style(value, st)}` : label); + } + } +} + +/** "%Y-%m-%d %H:%M" in local time. */ +function localTimestamp(d: Date): string { + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +function dirSize(dir: string): string { + if (!fs.existsSync(dir)) return "Nothing here"; + let total = 0; + const walk = (current: string) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.isFile()) total += fs.statSync(full).size; + } + }; + walk(dir); + let size = total; + for (const unit of ["B", "KB", "MB"]) { + if (size < 1024) { + return unit === "B" ? `${size} B` : `${size.toFixed(1)} ${unit}`; + } + size /= 1024; + } + return `${size.toFixed(1)} GB`; +} + +program + .command("version") + .description("Display version, package, browser, and storage info") + .action(async () => { + await new VersionInfo().printAll(); + }); + +program + .command("active") + .description("Print the current active version") + .action(() => { + const config = loadConfig(); + const pinned = config.pinned; + const channel = config.channel || getDefaultChannel(); + const installed = listInstalled(); + + const label = (v: InstalledVersion) => { + const sha8 = (v.sha256 ?? "").slice(0, 8); + return sha8 ? `${v.channelPath} (${sha8})` : v.channelPath; + }; + + if (pinned) { + const pinnedSha = config.pinned_sha; + const target = pinnedSha + ? (installed.find((v) => v.sha256 === pinnedSha) ?? null) + : findInstalled(`${channel.toLowerCase()}/${pinned}`); + if (target) { + echo(label(target)); + } else { + echo(`${channel.toLowerCase()}/${pinned} `, false); + rprint("(not fetched)", "yellow"); + } + return; + } + + for (const v of installed) { + if (v.isActive) { + echo(label(v)); + return; + } + } + echo(`${channel.toLowerCase()} `, false); + rprint("(not fetched)", "yellow"); + }); + +program + .command("path") + .description("Print the install directory path") + .action(() => { + echo(INSTALL_DIR); + }); + +program + .parseAsync(process.argv) + .catch((error) => { + rprint(`Error: ${errorMessage(error)}`, "red"); + process.exitCode = 1; + }) + .finally(closePrompts); diff --git a/typescript/src/__version__.ts b/typescript/src/__version__.ts new file mode 100644 index 000000000..5e95b88ef --- /dev/null +++ b/typescript/src/__version__.ts @@ -0,0 +1,40 @@ +/** + * Camoufox version constants. + * + * TypeScript twin of pythonlib/camoufox/__version__.py. + */ + +// biome-ignore lint/complexity/noStaticOnlyClass: mirrors the Python twin's CONSTRAINTS class so both launchers read the same +export class CONSTRAINTS { + /** + * The minimum and maximum supported versions of the Camoufox browser. + */ + static readonly MIN_VERSION: string = "alpha.1"; + static readonly MAX_VERSION: string = "1"; + + /** + * The browser floor is conditional on the resolved Playwright, not fixed. + * + * Each entry is [playwrightVersion, requiredBrowserBuild]: from that + * Playwright on, the browser must be at least that build. 1.61 began + * sending viewport isMobile/screenSize in Browser.setDefaultViewport and + * Page.setViewportSize; beta.30 is the first build whose Protocol.js schema + * accepts them. Below that pairing every newContext() dies with + * "Protocol error (Browser.setDefaultViewport)". Measured: 1.60 works on + * beta.29 and beta.30; 1.61 and 1.62 fail on beta.29 and pass on beta.30. + * + * A flat MIN_VERSION cannot express this. It only knows about the browser, + * so to stay safe it has to assume the worst Playwright and force *every* + * user to re-download -- including the majority on <1.61, who are in no + * danger -- and it leaves the library unusable until the matching browser + * release is published. Keyed on Playwright, only the users who would + * actually break get moved. + */ + static readonly PLAYWRIGHT_BROWSER_FLOORS: ReadonlyArray< + readonly [readonly number[], string] + > = [[[1, 61], "beta.30"]]; +} + +/** Version of this launcher library. Kept in step with package.json and + * pythonlib's pyproject.toml. */ +export const LIBRARY_VERSION = "0.5.7"; diff --git a/typescript/src/addons.ts b/typescript/src/addons.ts new file mode 100644 index 000000000..7fa6bc321 --- /dev/null +++ b/typescript/src/addons.ts @@ -0,0 +1,109 @@ +/** + * Default Firefox addon download/extraction. + * + * TypeScript twin of pythonlib/camoufox/addons.py. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { InvalidAddonPath } from "./exceptions.js"; +import { INSTALL_DIR, unzip, webdl } from "./pkgman.js"; + +/** + * Default addons to be downloaded. + */ +export const DefaultAddons = { + UBO: "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi", +} as const; + +export type DefaultAddon = keyof typeof DefaultAddons; + +// Addons are stored in a shared folder, not per-browser version +export const ADDONS_DIR: string = path.join(INSTALL_DIR, "addons"); + +/** + * Confirms that the addon paths are valid. + */ +export function confirmPaths(paths: string[]): void { + for (const addonPath of paths) { + if (!fs.existsSync(addonPath) || !fs.statSync(addonPath).isDirectory()) { + throw new InvalidAddonPath(addonPath); + } + if (!fs.existsSync(path.join(addonPath, "manifest.json"))) { + throw new InvalidAddonPath( + "manifest.json is missing. Addon path must be a path to an extracted addon.", + ); + } + } +} + +/** + * Adds default addons, minus any specified in excludeList, to addonsList. + */ +export async function addDefaultAddons( + addonsList: string[], + excludeList: DefaultAddon[] = [], +): Promise { + const addons: Record = {}; + for (const [name, url] of Object.entries(DefaultAddons)) { + if (!excludeList.includes(name as DefaultAddon)) { + addons[name] = url; + } + } + await maybeDownloadAddons(addons, addonsList); +} + +/** + * Downloads and extracts an addon from a given URL to a specified path. + */ +export async function downloadAndExtract( + url: string, + extractPath: string, + name: string, +): Promise { + const buffer = await webdl(url, `Downloading addon (${name})`, false); + unzip(buffer, extractPath, `Extracting addon (${name})`, false); +} + +/** + * Returns a path to the addon in the shared addons folder. + */ +export function getAddonPath(addonName: string): string { + return path.join(ADDONS_DIR, addonName); +} + +/** Seams the Python tests reach with monkeypatch. */ +export const addonsDeps = { + downloadAndExtract: (url: string, extractPath: string, name: string) => + downloadAndExtract(url, extractPath, name), +}; + +/** + * Downloads and extracts addons from a given map into the given list. + * Skips downloading if the addon is already downloaded. + */ +export async function maybeDownloadAddons( + addons: Record, + addonsList?: string[], +): Promise { + for (const [addonName, url] of Object.entries(addons)) { + const addonPath = getAddonPath(addonName); + + // Check if the addon is already extracted. A bare directory is not + // enough: a failed download leaves an empty dir behind, so require the + // manifest that confirmPaths() looks for. + if (fs.existsSync(path.join(addonPath, "manifest.json"))) { + addonsList?.push(addonPath); + continue; + } + + try { + fs.mkdirSync(addonPath, { recursive: true }); + await addonsDeps.downloadAndExtract(url, addonPath, addonName); + addonsList?.push(addonPath); + } catch (e) { + // Drop the partial directory so the next run re-downloads. + fs.rmSync(addonPath, { recursive: true, force: true }); + console.log(`Failed to download and extract ${addonName}: ${e}`); + } + } +} diff --git a/typescript/src/async_api.ts b/typescript/src/async_api.ts new file mode 100644 index 000000000..41d7d72bf --- /dev/null +++ b/typescript/src/async_api.ts @@ -0,0 +1,15 @@ +/** + * TypeScript twin of pythonlib/camoufox/async_api.py. + * + * playwright-core has a single, promise-based API, so the async entry points + * are the same functions as sync_api.ts under Python's async names. The + * behaviours async_api.py adds over sync_api.py (the per-driver pin lock) live + * there already. + */ +export { + Camoufox as AsyncCamoufox, + NewBrowser as AsyncNewBrowser, + type NewBrowserOptions, + NewContext as AsyncNewContext, + type NewContextOptions, +} from "./sync_api.js"; diff --git a/typescript/src/coherence.ts b/typescript/src/coherence.ts new file mode 100644 index 000000000..b8aab43fd --- /dev/null +++ b/typescript/src/coherence.ts @@ -0,0 +1,382 @@ +/** + * Whole-identity coherence: the checks that look at more than one field. + * + * TypeScript twin of pythonlib/camoufox/coherence.py -- see there for the + * measurements behind each rule. Camoufox assembles an identity from several + * independently sampled pools, so a combination no machine has ever had can + * be built out of individually plausible parts; every identity passes through + * here, whatever it was built from. + * + * `validate()` reports what is still broken; `apply()` repairs what it can. + */ +import { isPyInt, num, pyRepr, pyStr, pyTruthy } from "./pycompat.js"; + +type Config = Record; + +/** Core counts Apple Silicon actually ships. */ +export const APPLE_SILICON_CORES: ReadonlySet = new Set([ + 8, 10, 11, 12, 14, 16, 20, 24, 28, 32, +]); + +/** + * devicePixelRatio by platform, ascending as in coherence.py: the + * nearest-step repair keeps the first of two equally near steps, so a tie + * goes to the lower one. + */ +export const PLAUSIBLE_DPR: Readonly> = { + win: [1, 1.25, 1.5, 1.75, 2, 2.5, 3], + mac: [1, 2], + lin: [1, 1.25, 1.5, 1.75, 2], +}; + +/** colorDepth: Firefox reports 24, or 30 on a deep-colour display. */ +export const PLAUSIBLE_COLOR_DEPTH: ReadonlySet = new Set([24, 30]); + +/** maxTouchPoints: consumer digitisers top out at 10 contacts. */ +export const MAX_PLAUSIBLE_TOUCH_POINTS = 10; + +/** The browser's own chrome height, in CSS pixels (a property of the binary). */ +export const BROWSER_CHROME_HEIGHT = 86; + +/** GPU strings that are not possible on macOS. */ +const NOT_A_MAC_GPU = [ + "ANGLE", + "Intel(R) HD Graphics 400", + "Radeon R9 200 Series", + "llvmpipe", +]; + +export interface Violation { + rule: string; + detail: string; +} + +export interface Rule { + name: string; + /** Returns a description of the breakage, or null when the identity holds. */ + check: (config: Config, targetOs: string) => string | null; + /** Repairs the identity in place. null where no correct value is determined. */ + repair: ((config: Config, targetOs: string) => void) | null; +} + +function isNone(value: unknown): boolean { + return value === null || value === undefined; +} + +function renderer(config: Config): string { + const r = config["webGl:renderer"]; + return pyTruthy(r) ? pyStr(r) : ""; +} + +function isAppleSilicon(config: Config): boolean { + return renderer(config).includes("Apple M"); +} + +function checkAppleSiliconCores( + config: Config, + _targetOs: string, +): string | null { + if (!isAppleSilicon(config)) return null; + const cores = config["navigator.hardwareConcurrency"]; + if (isPyInt(cores) && !APPLE_SILICON_CORES.has(Number(cores))) { + return `${pyRepr(renderer(config))} with hardwareConcurrency ${pyStr(cores)}; Apple Silicon starts at 8`; + } + return null; +} + +function repairAppleSiliconCores(config: Config, _targetOs: string): void { + const cores = config["navigator.hardwareConcurrency"]; + if (!isPyInt(cores)) return; + const n = Number(cores); + const sorted = [...APPLE_SILICON_CORES].sort((a, b) => a - b); + config["navigator.hardwareConcurrency"] = + sorted.find((c) => c >= n) ?? Math.max(...sorted); +} + +/** + * Whether this renderer string is one the OS can report. Used both to check + * a finished identity and to filter the WebGL pool before sampling. + */ +export function gpuFitsOs( + rendererString: string | null | undefined, + targetOs: string, +): boolean { + const r = pyTruthy(rendererString) ? pyStr(rendererString) : ""; + if (!r) return true; + if (targetOs === "mac") return !NOT_A_MAC_GPU.some((bad) => r.includes(bad)); + if (targetOs === "win") return r.startsWith("ANGLE"); + if (targetOs === "lin") return !r.includes("ANGLE") && !r.includes("Apple M"); + return true; +} + +function checkGpuMatchesOs(config: Config, targetOs: string): string | null { + const r = renderer(config); + if (!r || gpuFitsOs(r, targetOs)) return null; + if (targetOs === "mac") + return `macOS identity with ${pyRepr(r)}, which no Mac reports`; + if (targetOs === "win") { + return `Windows identity with ${pyRepr(r)}; Firefox on Windows renders through ANGLE`; + } + return `Linux identity with ${pyRepr(r)}`; +} + +function inColorDepths(depth: unknown): boolean { + return typeof depth !== "boolean" && PLAUSIBLE_COLOR_DEPTH.has(num(depth)); +} + +function checkColorDepth(config: Config, targetOs: string): string | null { + const depth = config["screen.colorDepth"]; + if (isNone(depth)) return null; + if (!inColorDepths(depth)) + return `screen.colorDepth ${pyStr(depth)}; Firefox reports 24 or 30`; + if (targetOs === "mac" && isAppleSilicon(config) && num(depth) !== 30) { + return `Apple Silicon Mac with colorDepth ${pyStr(depth)}; deep colour is the macOS default`; + } + return null; +} + +function repairColorDepth(config: Config, targetOs: string): void { + const depth = config["screen.colorDepth"]; + if (!isNone(depth) && !inColorDepths(depth)) config["screen.colorDepth"] = 24; + if (targetOs === "mac" && isAppleSilicon(config)) + config["screen.colorDepth"] = 30; + // pixelDepth is the same number in every browser that reports both. + if ("screen.pixelDepth" in config || "screen.colorDepth" in config) { + config["screen.pixelDepth"] = + "screen.colorDepth" in config ? config["screen.colorDepth"] : 24; + } +} + +function checkTouchPoints(config: Config, targetOs: string): string | null { + const touch = config["navigator.maxTouchPoints"]; + if (isNone(touch)) return null; + if ( + !isPyInt(touch) || + Number(touch) < 0 || + Number(touch) > MAX_PLAUSIBLE_TOUCH_POINTS + ) { + return `navigator.maxTouchPoints ${pyStr(touch)}; a digitiser reports at most ${MAX_PLAUSIBLE_TOUCH_POINTS}`; + } + if (targetOs === "mac" && pyTruthy(touch)) { + return `macOS identity with maxTouchPoints ${pyStr(touch)}; no Mac has a touchscreen`; + } + return null; +} + +function repairTouchPoints(config: Config, targetOs: string): void { + const touch = config["navigator.maxTouchPoints"]; + const tooMany = isPyInt(touch) && Number(touch) > MAX_PLAUSIBLE_TOUCH_POINTS; + if (targetOs === "mac" || tooMany || (!isNone(touch) && !isPyInt(touch))) { + config["navigator.maxTouchPoints"] = 0; + } +} + +function checkDevicePixelRatio( + config: Config, + targetOs: string, +): string | null { + const dpr = config["window.devicePixelRatio"]; + if (isNone(dpr)) return null; + const allowed = PLAUSIBLE_DPR[targetOs]; + if (allowed?.length && !allowed.includes(Number(num(dpr)))) { + return `window.devicePixelRatio ${pyStr(dpr)} is not a display mode ${targetOs} offers`; + } + return null; +} + +function repairDevicePixelRatio(config: Config, targetOs: string): void { + const dpr = config["window.devicePixelRatio"]; + const allowed = PLAUSIBLE_DPR[targetOs]; + if (isNone(dpr) || !allowed?.length) return; + // Nearest real scaling step; min() keeps the first of equal distances. + const x = Number(num(dpr)); + let best = allowed[0]; + for (const v of allowed.slice(1)) { + if (Math.abs(v - x) < Math.abs(best - x)) best = v; + } + config["window.devicePixelRatio"] = best; +} + +function checkWindowChrome(config: Config, _targetOs: string): string | null { + const inner = config["window.innerHeight"]; + const outer = config["window.outerHeight"]; + if (!pyTruthy(inner) || !pyTruthy(outer)) return null; + const chrome = num(outer) - num(inner); + if (chrome < BROWSER_CHROME_HEIGHT) { + return ( + `window.outerHeight ${pyStr(outer)} - innerHeight ${pyStr(inner)} = ${pyStr(chrome)}, less than the ` + + `${BROWSER_CHROME_HEIGHT}px of chrome the window actually has; the bottom ` + + `${pyStr(BROWSER_CHROME_HEIGHT - chrome)}px of the claimed viewport cannot receive input` + ); + } + return null; +} + +function repairWindowChrome(config: Config, _targetOs: string): void { + const inner = config["window.innerHeight"]; + const outer = config["window.outerHeight"]; + if (!pyTruthy(inner) || !pyTruthy(outer)) return; + const avail = pyTruthy(config["screen.availHeight"]) + ? config["screen.availHeight"] + : config["screen.height"]; + // Prefer growing the window, which keeps the viewport the identity drew. + if (!pyTruthy(avail) || num(inner) + BROWSER_CHROME_HEIGHT <= num(avail)) { + config["window.outerHeight"] = num(inner) + BROWSER_CHROME_HEIGHT; + return; + } + // No room on the claimed screen: shrink the viewport instead. + config["window.innerHeight"] = Math.max( + num(outer) - BROWSER_CHROME_HEIGHT, + 1, + ); +} + +function checkScreenShape(config: Config, _targetOs: string): string | null { + const width = config["screen.width"]; + const height = config["screen.height"]; + if (!pyTruthy(width) || !pyTruthy(height)) return null; + if (num(height) > num(width)) { + return `portrait screen ${pyStr(width)}x${pyStr(height)}; desktop panels are landscape`; + } + if (num(width) < 1024) { + return `screen ${pyStr(width)}x${pyStr(height)} is smaller than any current desktop panel`; + } + return null; +} + +function checkAvailBounds(config: Config, _targetOs: string): string | null { + const width = config["screen.width"]; + const height = config["screen.height"]; + const availW = config["screen.availWidth"]; + const availH = config["screen.availHeight"]; + if (pyTruthy(width) && pyTruthy(availW) && num(availW) > num(width)) { + return `screen.availWidth ${pyStr(availW)} exceeds screen.width ${pyStr(width)}`; + } + if (pyTruthy(height) && pyTruthy(availH) && num(availH) > num(height)) { + return `screen.availHeight ${pyStr(availH)} exceeds screen.height ${pyStr(height)}`; + } + return null; +} + +function repairAvailBounds(config: Config, _targetOs: string): void { + const width = config["screen.width"]; + const height = config["screen.height"]; + const availW = + "screen.availWidth" in config ? config["screen.availWidth"] : 0; + const availH = + "screen.availHeight" in config ? config["screen.availHeight"] : 0; + if (pyTruthy(width) && num(availW) > num(width)) + config["screen.availWidth"] = width; + if (pyTruthy(height) && num(availH) > num(height)) + config["screen.availHeight"] = height; +} + +function checkArchAgreement(config: Config, _targetOs: string): string | null { + const str = (key: string) => + pyTruthy(config[key]) ? pyStr(config[key]) : ""; + const ua = str("navigator.userAgent"); + const platform = str("navigator.platform"); + const oscpu = str("navigator.oscpu"); + if (!ua) return null; + if ( + ua.includes("x86_64") && + (platform.includes("armv") || oscpu.includes("armv")) + ) { + return `user agent claims x86_64 while platform/oscpu say ${pyRepr(platform)}/${pyRepr(oscpu)}`; + } + return null; +} + +export const RULES: readonly Rule[] = [ + { + name: "apple-silicon-cores", + check: checkAppleSiliconCores, + repair: repairAppleSiliconCores, + }, + { name: "gpu-matches-os", check: checkGpuMatchesOs, repair: null }, + { name: "color-depth", check: checkColorDepth, repair: repairColorDepth }, + { name: "touch-points", check: checkTouchPoints, repair: repairTouchPoints }, + { + name: "device-pixel-ratio", + check: checkDevicePixelRatio, + repair: repairDevicePixelRatio, + }, + { + name: "window-chrome", + check: checkWindowChrome, + repair: repairWindowChrome, + }, + { name: "screen-shape", check: checkScreenShape, repair: null }, + { name: "avail-bounds", check: checkAvailBounds, repair: repairAvailBounds }, + { name: "arch-agreement", check: checkArchAgreement, repair: null }, +]; + +/** Whether the screen is one no desktop reports (portrait, or tiny). */ +export function screenIsImplausible(config: Config): boolean { + return checkScreenShape(config, "") !== null; +} + +/** + * Turn a portrait screen landscape, keeping the panel's own dimensions. Run + * before the window clamps, which then bound the window to the new screen. + */ +export function repairScreenOrientation(config: Config): boolean { + const width = config["screen.width"]; + const height = config["screen.height"]; + if (!pyTruthy(width) || !pyTruthy(height) || num(height) <= num(width)) + return false; + config["screen.width"] = height; + config["screen.height"] = width; + const availW = config["screen.availWidth"]; + const availH = config["screen.availHeight"]; + if (pyTruthy(availW) && pyTruthy(availH)) { + config["screen.availWidth"] = availH; + config["screen.availHeight"] = availW; + } + return true; +} + +/** + * Discard values a source supplied that this identity cannot keep (a preset's + * GPU pair its OS cannot report), so the normal WebGL sampling draws a + * coherent one instead. Only values another pool can replace are dropped. + */ +export function dropIncoherentSourceValues( + config: Config, + targetOs: string, +): Violation[] { + const dropped: Violation[] = []; + const r = renderer(config); + if (r && !gpuFitsOs(r, targetOs)) { + delete config["webGl:renderer"]; + delete config["webGl:vendor"]; + dropped.push({ + rule: "gpu-matches-os", + detail: `dropped ${pyRepr(r)} for a ${targetOs} identity`, + }); + } + return dropped; +} + +/** Every invariant this identity breaks. Empty means coherent. */ +export function validate(config: Config, targetOs: string): Violation[] { + const violations: Violation[] = []; + for (const rule of RULES) { + const detail = rule.check(config, targetOs); + if (detail) violations.push({ rule: rule.name, detail }); + } + return violations; +} + +/** + * Repair what is determined, and report what is left. A rule with no repair + * cannot be corrected without inventing a machine, so it is returned for the + * caller to decide about. + */ +export function apply(config: Config, targetOs: string): Violation[] { + for (const rule of RULES) { + if (rule.repair && rule.check(config, targetOs)) + rule.repair(config, targetOs); + } + return validate(config, targetOs); +} diff --git a/typescript/src/cpu_affinity.ts b/typescript/src/cpu_affinity.ts new file mode 100644 index 000000000..8b6bfee98 --- /dev/null +++ b/typescript/src/cpu_affinity.ts @@ -0,0 +1,235 @@ +/** + * Pin the browser to as many CPU cores as the identity reports. + * + * TypeScript twin of pythonlib/camoufox/cpu_affinity.py. + * + * navigator.hardwareConcurrency is spoofed by the browser, but the number of + * cores a page can *measure* (timing N parallel workers) is the number the OS + * lets the browser run on. Reporting the fingerprint's value and pinning the + * browser's CPU affinity to that many cores makes the two agree, so the drawn + * value survives instead of being replaced by the host count. + * + * Python pins the Playwright driver (a separate Node process) right before the + * launch; here the driver IS this process -- playwright-core spawns the browser + * from the main thread -- so the pin is applied to this process's main thread + * and lifted again afterwards. Child processes inherit the affinity mask on + * Linux and Windows, so the browser and every content/GPU process it spawns + * run on the pinned set. macOS has no process affinity API, so nothing can be + * pinned there and the launcher falls back to reporting the host's (snapped) + * count. + * + * Node has no sched_setaffinity binding, so Linux goes through util-linux's + * `taskset` (which, like os.sched_setaffinity(pid), sets the thread whose TID + * is `pid` -- the main thread) and Windows through PowerShell's + * Process.ProcessorAffinity. + */ +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; + +function run(command: string, args: string[]): string | null { + try { + return execFileSync(command, args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 10_000, + windowsHide: true, + }); + } catch { + return null; + } +} + +let tasksetAvailable: boolean | undefined; + +/** Whether this host can constrain a process to a subset of its cores. */ +export function supported(): boolean { + if (process.platform === "linux") { + // Python checks hasattr(os, 'sched_setaffinity'), which is always true on + // Linux. The equivalent capability here is the taskset binary. + tasksetAvailable ??= run("taskset", ["-V"]) !== null; + return tasksetAvailable; + } + return process.platform === "win32"; +} + +/** Parse a Linux cpu list ("0-3,8,10-11") into sorted core numbers. */ +export function parseCpuList(list: string): number[] { + const cores = new Set(); + for (const part of list.trim().split(",")) { + if (!part) continue; + const [lo, hi] = part.split("-").map((n) => Number.parseInt(n, 10)); + if (Number.isNaN(lo)) continue; + const top = hi === undefined || Number.isNaN(hi) ? lo : hi; + for (let c = lo; c <= top; c++) { + cores.add(c); + } + } + return [...cores].sort((a, b) => a - b); +} + +function linuxGetAffinity(pid: number): number[] | null { + try { + const status = fs.readFileSync(`/proc/${pid}/status`, "utf-8"); + const match = status.match(/^Cpus_allowed_list:\s*(.+)$/m); + if (match) { + const cores = parseCpuList(match[1]); + if (cores.length) return cores; + } + } catch { + // fall through + } + return null; +} + +function linuxSetAffinity(pid: number, cores: Iterable): boolean { + const list = [...cores].sort((a, b) => a - b).join(","); + return run("taskset", ["-p", "-c", list, String(pid)]) !== null; +} + +/** The cores this process may run on, in order. */ +export function hostCores(): number[] | null { + if (process.platform === "linux") { + const cores = linuxGetAffinity(process.pid); + if (cores) return cores; + } + if (process.platform === "win32") { + const mask = winGetMask(process.pid); + if (mask) return maskToCores(mask); + } + const n = os.cpus().length; + return n ? Array.from({ length: n }, (_, i) => i) : null; +} + +let hostCountSnapshot: number | undefined; + +/** + * How many cores the host lets this process use, as read before this process + * first pinned itself. playwright-core spawns the browser from this process, + * so pin() narrows this process's own mask during a launch; a live reading + * taken then by a concurrent launch (hostCores(), or Node's + * availableParallelism()) would see the pinned count and pass it off as the + * host's. Python pins a separate driver process and never has this problem. + */ +export function hostCoreCount(): number { + hostCountSnapshot ??= + hostCores()?.length || os.availableParallelism() || os.cpus().length; + return hostCountSnapshot; +} + +/** + * `count` adjacent cores from a random starting point (wrapping). Always + * taking the first `count` stacked every browser on one host onto cores + * 0..count-1, so concurrent browsers measured far less parallelism than they + * report; adjacent cores keep the SMT topology a real machine of that size + * would have. + */ +export function pick(cores: readonly number[], count: number): number[] { + const start = Math.floor(Math.random() * cores.length); + return [...cores.slice(start), ...cores.slice(0, start)] + .slice(0, count) + .sort((a, b) => a - b); +} + +/** + * Restrict `pid` to `count` of its cores. Returns the previous set so it can + * be handed back to `restore()`, or null if nothing was changed. + * + * The caller must not pin the same process for two launches at once: the + * browser inherits whatever mask the driver has when it is spawned. + */ +export function pin(pid: number, count: number): number[] | null { + if (count < 1 || !supported()) return null; + if (pid === process.pid) hostCoreCount(); // read the host before narrowing it + if (process.platform === "linux") { + const before = linuxGetAffinity(pid); + if (!before || count >= before.length) return null; + return linuxSetAffinity(pid, pick(before, count)) ? before : null; + } + if (process.platform === "win32") { + const beforeMask = winGetMask(pid); + if (!beforeMask) return null; + const before = maskToCores(beforeMask); + if (count >= before.length) return null; + return winSetMask(pid, coresToMask(pick(before, count))) ? before : null; + } + return null; +} + +/** Give `pid` back the cores it had before `pin()`. */ +export function restore(pid: number, previous: readonly number[] | null): void { + if (!previous?.length) return; + if (process.platform === "linux") { + linuxSetAffinity(pid, previous); + } else if (process.platform === "win32") { + winSetMask(pid, coresToMask(previous)); + } +} + +// -- Windows --------------------------------------------------------------- + +export function maskToCores(mask: bigint): number[] { + const cores: number[] = []; + for (let i = 0; mask >> BigInt(i) > 0n; i++) { + if ((mask >> BigInt(i)) & 1n) cores.push(i); + } + return cores; +} + +export function coresToMask(cores: Iterable): bigint { + let mask = 0n; + for (const c of cores) mask |= 1n << BigInt(c); + return mask; +} + +function winGetMask(pid: number): bigint { + const out = run("powershell", [ + "-NoProfile", + "-Command", + `[int64](Get-Process -Id ${pid}).ProcessorAffinity`, + ]); + if (!out) return 0n; + try { + return BigInt(out.trim()); + } catch { + return 0n; + } +} + +function winSetMask(pid: number, mask: bigint): boolean { + return ( + run("powershell", [ + "-NoProfile", + "-Command", + `(Get-Process -Id ${pid}).ProcessorAffinity = [IntPtr][int64]${mask}`, + ]) !== null + ); +} + +// -- launch serialisation -------------------------------------------------- + +let pinChain: Promise = Promise.resolve(); +let everPinned = false; + +/** + * Run `fn` while holding the process-wide pin lock. The browser inherits the + * driver's mask at spawn, so two concurrent launches must not interleave + * pin/restore: the second pin would land on the first browser, and the first + * restore would leave the driver pinned. (Python: async_api._pin_lock.) + */ +export function withPinLock(fn: () => Promise): Promise { + everPinned = true; + const result = pinChain.then(fn, fn); + pinChain = result.catch(() => undefined); + return result; +} + +/** + * Run an UNPINNED launch. It spawns a browser too, which inherits whatever + * mask this process has at that moment, so once any launch in this process + * has pinned, it waits for the lock rather than spawning into another + * launch's pin. Until then (pin_cpu_cores is opt-in) it runs straight away. + */ +export function withUnpinnedLaunch(fn: () => Promise): Promise { + return everPinned ? withPinLock(fn) : fn(); +} diff --git a/typescript/src/display.ts b/typescript/src/display.ts new file mode 100644 index 000000000..dbbe75286 --- /dev/null +++ b/typescript/src/display.ts @@ -0,0 +1,151 @@ +/** + * Host display geometry, in the units Firefox lays its windows out in. + * + * TypeScript twin of pythonlib/camoufox/display.py. + * + * Firefox sizes windows in **CSS pixels**. Python uses `screeninfo`, which + * marks the process per-monitor DPI aware and therefore reports **physical** + * pixels; where Windows display scaling is enabled the two differ by the scale + * factor (a 1920x1080 panel at 150% is only 1280x720 CSS px), so deriving a + * window size from the physical numbers opens it partly off-screen + * (daijro/camoufox#425). + * + * There is no dependency-free `screeninfo` equivalent on npm, so the probes + * below shell out per platform and are deliberately best-effort: any failure + * returns null, exactly as the Python twin does when enumeration fails, and + * the caller simply skips the screen constraint. + * + * X11 (xrandr) and macOS already report CSS pixels; only Windows needs the + * scale correction, and there the DPI-aware value is what PowerShell reports. + */ +import { execFileSync } from "node:child_process"; +import { OS_NAME } from "./pkgman.js"; + +/** Size of a monitor in CSS pixels. */ +export interface DisplaySize { + width: number; + height: number; +} + +/** + * Whether the host has a desktop session for Camoufox's window to open on. + * + * DISPLAY / WAYLAND_DISPLAY only ever exist on Linux, so they cannot be the + * sole probe: keying off DISPLAY alone skips the screen constraints entirely on + * Windows and macOS, where a session is always present. + */ +export function hasDisplay( + env: Record, +): boolean { + if (OS_NAME !== "lin") { + return true; + } + return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY); +} + +function run(command: string, args: string[]): string | null { + try { + return execFileSync(command, args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, + }); + } catch { + return null; + } +} + +/** Every connected monitor's resolution, or [] when it can't be probed. */ +function enumerateMonitors(): DisplaySize[] { + if (OS_NAME === "lin") return enumerateLinux(); + if (OS_NAME === "mac") return enumerateMac(); + return enumerateWindows(); +} + +function enumerateLinux(): DisplaySize[] { + // `xrandr --current` avoids a mode probe and is safe to call repeatedly. + // Connected outputs carry a "x++" geometry token. + const out = run("xrandr", ["--current"]); + if (!out) return []; + const monitors: DisplaySize[] = []; + for (const line of out.split("\n")) { + if (!/\bconnected\b/.test(line)) continue; + const match = line.match(/\b(\d+)x(\d+)\+\d+\+\d+/); + if (!match) continue; + monitors.push({ + width: Number.parseInt(match[1], 10), + height: Number.parseInt(match[2], 10), + }); + } + return monitors; +} + +function enumerateMac(): DisplaySize[] { + const out = run("system_profiler", ["-json", "SPDisplaysDataType"]); + if (!out) return []; + try { + const data = JSON.parse(out); + const monitors: DisplaySize[] = []; + for (const gpu of data.SPDisplaysDataType ?? []) { + for (const display of gpu.spdisplays_ndrvs ?? []) { + // e.g. "2560 x 1440" or "2560 x 1440 @ 60.00Hz" + const raw: string = + display._spdisplays_resolution ?? display.spdisplays_resolution ?? ""; + const match = raw.match(/(\d+)\s*x\s*(\d+)/); + if (!match) continue; + monitors.push({ + width: Number.parseInt(match[1], 10), + height: Number.parseInt(match[2], 10), + }); + } + } + return monitors; + } catch { + return []; + } +} + +function enumerateWindows(): DisplaySize[] { + // Screen.AllScreens reports DPI-*unaware* bounds for a non-manifested + // process, which is exactly the CSS-pixel figure Firefox lays out in -- + // so unlike the Python twin no scale-factor correction is needed here. + const script = + "Add-Type -AssemblyName System.Windows.Forms; " + + "[System.Windows.Forms.Screen]::AllScreens | " + + 'ForEach-Object { "$($_.Bounds.Width)x$($_.Bounds.Height)" }'; + const out = run("powershell", ["-NoProfile", "-Command", script]); + if (!out) return []; + const monitors: DisplaySize[] = []; + for (const line of out.split("\n")) { + const match = line.trim().match(/^(\d+)x(\d+)$/); + if (!match) continue; + monitors.push({ + width: Number.parseInt(match[1], 10), + height: Number.parseInt(match[2], 10), + }); + } + return monitors; +} + +/** + * Size of the roomiest attached monitor in CSS pixels, or null when the display + * cannot be probed (no monitors, or enumeration failed). + */ +export function largestDisplay(): DisplaySize | null { + let monitors: DisplaySize[]; + try { + monitors = enumerateMonitors(); + } catch { + return null; + } + if (!monitors.length) return null; + + // max() keeps the FIRST of equally large monitors, as Python's max() does. + const monitor = monitors.reduce((prev, curr) => + curr.width * curr.height > prev.width * prev.height ? curr : prev, + ); + return { + width: Math.max(1, Math.trunc(monitor.width)), + height: Math.max(1, Math.trunc(monitor.height)), + }; +} diff --git a/typescript/src/exceptions.ts b/typescript/src/exceptions.ts new file mode 100644 index 000000000..69ed4c0bc --- /dev/null +++ b/typescript/src/exceptions.ts @@ -0,0 +1,194 @@ +/** + * TypeScript twin of python/src/exceptions.py. + * + * The Python hierarchy leans on builtin bases (FileNotFoundError, ValueError, + * ImportError) that have no JS analogue; those become plain Error subclasses + * here. Every relationship that callers actually catch on -- LocaleError, + * VirtualDisplayError -- is preserved. + */ + +/** The Python twin relies on the builtin FileNotFoundError; JS has no such + * class, so version-lookup misses raise this instead. */ +export class FileNotFoundError extends Error { + constructor(message?: string) { + super(message ?? "File couldn't be found."); + this.name = "FileNotFoundError"; + } +} + +export class UnsupportedVersion extends Error { + constructor(message?: string) { + super(message ?? "The Camoufox executable is outdated."); + this.name = "UnsupportedVersion"; + } +} + +export class MissingRelease extends Error { + constructor(message?: string) { + super(message ?? "A required GitHub release asset is missing."); + this.name = "MissingRelease"; + } +} + +/** Raised when a downloaded asset does not match its expected sha256 digest. */ +export class CorruptedDownload extends Error { + constructor(message?: string) { + super( + message ?? + "A downloaded asset does not match its expected sha256 digest.", + ); + this.name = "CorruptedDownload"; + } +} + +export class UnsupportedArchitecture extends Error { + constructor(message?: string) { + super(message ?? "The architecture is not supported."); + this.name = "UnsupportedArchitecture"; + } +} + +export class UnsupportedOS extends Error { + constructor(message?: string) { + super(message ?? "The OS is not supported."); + this.name = "UnsupportedOS"; + } +} + +export class InvalidPropertyType extends Error { + constructor(message?: string) { + super(message ?? "The property type is invalid."); + this.name = "InvalidPropertyType"; + } +} + +export class InvalidAddonPath extends FileNotFoundError { + constructor(message?: string) { + super(message ?? "The addon path is invalid."); + this.name = "InvalidAddonPath"; + } +} + +export class LocaleError extends Error { + constructor(message?: string) { + super(message ?? "The locale is invalid."); + this.name = "LocaleError"; + } +} + +export class InvalidIP extends Error { + constructor(message?: string, options?: ErrorOptions) { + super(message ?? "An IP address is invalid.", options); + this.name = "InvalidIP"; + } +} + +export class InvalidProxy extends Error { + constructor(message?: string, options?: ErrorOptions) { + super(message ?? "A proxy is invalid.", options); + this.name = "InvalidProxy"; + } +} + +export class UnknownIPLocation extends LocaleError { + constructor(message?: string) { + super(message ?? "The location of an IP is unknown."); + this.name = "UnknownIPLocation"; + } +} + +export class InvalidLocale extends LocaleError { + constructor(message?: string) { + super(message ?? "The locale input is invalid."); + this.name = "InvalidLocale"; + } + + static invalidInput(locale: string): InvalidLocale { + return new InvalidLocale( + `Invalid locale: '${locale}'. Must be either a region, language, language-region, or language-script-region.`, + ); + } +} + +export class UnknownTerritory extends InvalidLocale { + constructor(message?: string) { + super(message ?? "The territory is unknown."); + this.name = "UnknownTerritory"; + } +} + +export class UnknownLanguage extends InvalidLocale { + constructor(message?: string) { + super(message ?? "The language is unknown."); + this.name = "UnknownLanguage"; + } +} + +export class NotInstalledGeoIPExtra extends Error { + constructor(message?: string) { + super(message ?? "The GeoIP database reader is not available."); + this.name = "NotInstalledGeoIPExtra"; + } +} + +export class NonFirefoxFingerprint extends Error { + constructor(message?: string) { + super(message ?? "A passed fingerprint is not a Firefox fingerprint."); + this.name = "NonFirefoxFingerprint"; + } +} + +export class InvalidOS extends Error { + constructor(message?: string) { + super(message ?? "The target OS is invalid."); + this.name = "InvalidOS"; + } +} + +export class VirtualDisplayError extends Error { + constructor(message?: string) { + super(message ?? "There is an error with the virtual display."); + this.name = "VirtualDisplayError"; + } +} + +export class CannotFindXvfb extends VirtualDisplayError { + constructor(message?: string) { + super(message ?? "Xvfb cannot be found."); + this.name = "CannotFindXvfb"; + } +} + +export class CannotExecuteXvfb extends VirtualDisplayError { + constructor(message?: string) { + super(message ?? "Xvfb cannot be executed."); + this.name = "CannotExecuteXvfb"; + } +} + +export class VirtualDisplayNotSupported extends VirtualDisplayError { + constructor(message?: string) { + super( + message ?? "The user tried to use a virtual display on a non-Linux OS.", + ); + this.name = "VirtualDisplayNotSupported"; + } +} + +export class CamoufoxNotInstalled extends FileNotFoundError { + constructor(message?: string) { + super(message ?? "Camoufox is not installed."); + this.name = "CamoufoxNotInstalled"; + } +} + +/** Raised when Camoufox's required runtime directory cannot be prepared. */ +export class ProfileDirectoryError extends Error { + constructor(message?: string, options?: ErrorOptions) { + super( + message ?? "Camoufox's runtime directory could not be prepared.", + options, + ); + this.name = "ProfileDirectoryError"; + } +} diff --git a/typescript/src/fingerprints.ts b/typescript/src/fingerprints.ts new file mode 100644 index 000000000..75b6dd9c1 --- /dev/null +++ b/typescript/src/fingerprints.ts @@ -0,0 +1,2076 @@ +/** + * The identity layer: fpgen fingerprints and real presets turned into a + * Camoufox config, the per-identity draws (fonts, voices, media devices, GPU) + * and the geometry / arch corrections applied on top of both. + * + * TypeScript twin of pythonlib/camoufox/fingerprints.py. Every seeded draw is + * bit-for-bit the Python one -- `random.Random(seed)` is ./pyrandom.ts, and + * identitySalt() hashes the same orjson bytes -- so one config and salt + * present one identity whichever launcher built it. Unseeded Python draws + * (`random.randint`, `random.choice`) go through the shared `pyRandom` + * instance, the twin of Python's module-level generator. + */ +import { createHash, randomBytes } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + supported as cpuAffinitySupported, + hostCoreCount, +} from "./cpu_affinity.js"; +import { Generator, InvalidConstraints } from "./fpgen/index.js"; +import { validateIP, validIPv4 } from "./ip.js"; +import { normalizeLocale } from "./locale.js"; +import { LOCAL_DATA } from "./pkgman.js"; +import { + comparePyStr, + crc32, + isPyError, + KeyError, + num, + orjsonDumps, + pyStr, + pySum, + pySumFloats, + pyTruthy, + ValueError, +} from "./pycompat.js"; +import { PyRandom, pyRandom } from "./pyrandom.js"; +import { FallbackWarning } from "./warnings.js"; +import { sampleWebglForScreen, type TargetOS } from "./webgl.js"; + +export type { TargetOS } from "./webgl.js"; + +type Config = Record; + +export const SUPPORTED_OS = ["linux", "macos", "windows"] as const; +export type SupportedOS = (typeof SUPPORTED_OS)[number]; + +/** + * The fpgen -> Camoufox config mapping (pythonlib/camoufox/fpgen.yml; the + * golden fixtures assert the two stay equal). + */ +export const FPGEN_DATA: Readonly>> = { + navigator: { + userAgent: "navigator.userAgent", + appVersion: "navigator.appVersion", + oscpu: "navigator.oscpu", + platform: "navigator.platform", + hardwareConcurrency: "navigator.hardwareConcurrency", + maxTouchPoints: "navigator.maxTouchPoints", + }, + screen: { + availLeft: "screen.availLeft", + availTop: "screen.availTop", + availWidth: "screen.availWidth", + availHeight: "screen.availHeight", + height: "screen.height", + width: "screen.width", + colorDepth: "screen.colorDepth", + pixelDepth: "screen.pixelDepth", + }, + window: { + outerHeight: "window.outerHeight", + outerWidth: "window.outerWidth", + screenX: "window.screenX", + screenY: "window.screenY", + }, + headers: { + "accept-encoding": "headers.Accept-Encoding", + }, +}; + +// fpgen's OS names, from Camoufox's. +export const FPGEN_OS: Readonly> = { + lin: "Linux", + linux: "Linux", + mac: "macOS", + macos: "macOS", + win: "Windows", + windows: "Windows", +}; + +// fpgen unpacks its model on first use, so the generator is built on demand. +let FP_GENERATOR: Generator | null = null; + +function generator(): Generator { + FP_GENERATOR ??= new Generator(); + return FP_GENERATOR; +} + +/** Python dict.get(key, default): the default only when the key is absent. */ +function get(config: Config, key: string, dflt: any = undefined): any { + return Object.hasOwn(config, key) ? config[key] : dflt; +} + +function isNone(value: unknown): boolean { + return value === null || value === undefined; +} + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +export interface ScreenBounds { + minWidth?: number | null; + maxWidth?: number | null; + minHeight?: number | null; + maxHeight?: number | null; +} + +/** + * A bound on the screen a generated fingerprint may claim (the twin of the + * Python dataclass that replaced browserforge's Screen). + */ +export class Screen { + minWidth: number | null; + maxWidth: number | null; + minHeight: number | null; + maxHeight: number | null; + + constructor({ + minWidth = null, + maxWidth = null, + minHeight = null, + maxHeight = null, + }: ScreenBounds = {}) { + this.minWidth = minWidth; + this.maxWidth = maxWidth; + this.minHeight = minHeight; + this.maxHeight = maxHeight; + } + + /** The bound as fpgen conditions: a predicate per field. */ + asConditions(): Record boolean> { + const conditions: Record boolean> = {}; + const { + minWidth: loW, + maxWidth: hiW, + minHeight: loH, + maxHeight: hiH, + } = this; + const within = (v: unknown, lo: number | null, hi: number | null) => + typeof v === "number" && + Number.isInteger(v) && + (lo === null || v >= lo) && + (hi === null || v <= hi); + if (loW !== null || hiW !== null) + conditions["screen.width"] = (w) => within(w, loW, hiW); + if (loH !== null || hiH !== null) + conditions["screen.height"] = (h) => within(h, loH, hiH); + return conditions; + } + + /** dataclasses.asdict(), with the Python field names (for identitySalt). */ + toPyDict(): Record { + return { + min_width: this.minWidth, + max_width: this.maxWidth, + min_height: this.minHeight, + max_height: this.maxHeight, + }; + } +} + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +export interface Preset { + navigator?: Record; + screen?: Record; + webgl?: Record; + timezone?: string; + fonts?: string[]; + speechVoices?: Array; + [key: string]: any; +} + +export interface PresetBundle { + presets?: Partial>; + [key: string]: any; +} + +export const PRESETS_FILE = path.join(LOCAL_DATA, "fingerprint-presets.json"); +export const PRESETS_V150_FILE = path.join( + LOCAL_DATA, + "fingerprint-presets-v150.json", +); +/** Firefox major version at which the v150 preset bundle becomes preferred. */ +export const PRESETS_V150_MIN_FF = 149; +const PRESETS_CACHE = new Map(); + +// --------------------------------------------------------------------------- +// Fonts +// --------------------------------------------------------------------------- + +// CreepJS OS marker fonts used for OS detection (see fingerprints.py). +export const MACOS_MARKER_FONTS: readonly string[] = ["Helvetica Neue"]; +export const LINUX_MARKER_FONTS: readonly string[] = [ + "Noto Sans", + "Noto Serif", + "DejaVu Sans Mono", + "Arimo", + "Cousine", + "Tinos", + "Twemoji Mozilla", +]; +export const WINDOWS_MARKER_FONTS: readonly string[] = [ + "Segoe UI", + "Tahoma", + "Cambria Math", + "Nirmala UI", +]; + +/** Add any missing marker fonts to the font list (in place). */ +function ensureMarkerFonts(fonts: string[], markers: readonly string[]): void { + const existing = new Set(fonts); + for (const m of markers) { + if (!existing.has(m)) fonts.push(m); + } +} + +function readJson(file: string): T { + return JSON.parse(fs.readFileSync(path.join(LOCAL_DATA, file), "utf-8")) as T; +} + +let osFontsCache: Record | null = null; + +/** The full OS font lists (fonts.json). */ +function loadOsFonts(): Record { + osFontsCache ??= readJson>("fonts.json"); + return osFontsCache; +} + +// The OS BASE font sets that must always be reported: every family a real +// machine of that OS ships by default, intersected with fonts.json (see +// fingerprints.py for the sources). essential-fonts.json is the file +// pythonlib reads, so the two launchers cannot disagree on them. +const ESSENTIAL_FONTS = readJson>( + "essential-fonts.json", +); +export const ESSENTIAL_FONTS_MACOS: readonly string[] = ESSENTIAL_FONTS.mac; +export const ESSENTIAL_FONTS_WINDOWS: readonly string[] = ESSENTIAL_FONTS.win; +export const ESSENTIAL_FONTS_LINUX: readonly string[] = ESSENTIAL_FONTS.lin; + +/** + * OS-version variant of the Windows base: drawn with probability 1 since + * Windows 10 was dropped as a target (2026-09-22). Format: [probability, fonts]. + */ +const BASE_VARIANT_FONTS_MACOS: readonly [number, readonly string[]] = [ + 0.0, + [], +]; +const BASE_VARIANT_FONTS_WINDOWS: readonly [number, readonly string[]] = [ + 1.0, + [ + "Sans Serif Collection", + "Segoe Fluent Icons", + "Segoe UI Variable", + "Segoe UI Variable Display", + "Segoe UI Variable Small", + "Segoe UI Variable Text", + ], +]; +const BASE_VARIANT_FONTS_LINUX: readonly [number, readonly string[]] = [ + 0.0, + [], +]; + +/** + * Fonts only a Windows 11 base has: a Windows identity whose font list + * contains them presents Windows 11, and the rest of the identity must agree. + */ +export const WINDOWS_11_MARKER_FONTS: ReadonlySet = new Set( + BASE_VARIANT_FONTS_WINDOWS[1], +); + +/** + * The entropy that makes identitySeed() belong to ONE identity. + * + * Pass whatever the caller pinned the identity with -- an fpgen fingerprint, + * a preset, the caller's own config -- for a salt that is stable across + * launches of that identity; pass nothing for a fresh, random one. The salt + * is the first 8 bytes (big-endian) of the SHA-256 of the orjson + * serialization with sorted keys, exactly as Python computes it, so a pinned + * identity draws the same fonts, voices, GPU and noise seeds in both + * launchers. (Integral floats and integers above 2**53 only hash like + * Python's when they arrive as PyFloat / bigint -- see pycompat.parsePyJson.) + */ +export function identitySalt(pinned?: unknown): bigint { + if (pinned === null || pinned === undefined) { + return randomBytes(8).readBigUInt64BE(0); + } + const blob = Buffer.from(orjsonDumps(pinned), "utf-8"); + return createHash("sha256").update(blob).digest().readBigUInt64BE(0); +} + +/** + * A seed for the per-identity draws: a pure function of the presented + * identity (UA, platform, screen, cores) and its salt -- zlib.crc32 of the + * values' Python str() joined by "|". + */ +export function identitySeed( + config: Config, + salt: number | bigint = 0, +): number { + const parts = [ + pyStr(get(config, "navigator.userAgent", "")), + pyStr(get(config, "navigator.platform", "")), + pyStr(get(config, "screen.width", "")), + pyStr(get(config, "screen.height", "")), + pyStr(get(config, "navigator.hardwareConcurrency", "")), + // not the GPU: it is sampled after the font draw in launchOptions + pyStr(salt), + ]; + return crc32(parts.join("|")); +} + +/** + * The audio noise seed launchOptions derives from identitySeed() (utils.py: + * `(ident * 2654435761 + 97) & 0xFFFFFFFF or 1`), computed without losing + * precision past 2**53. + */ +export function audioSeedFromIdentity(ident: number): number { + return Number((BigInt(ident) * 2654435761n + 97n) & 0xffffffffn) || 1; +} + +/** A seeded generator for a draw, or a fresh OS-seeded one when unseeded. */ +function rng(seed: number | bigint | null | undefined): PyRandom { + return new PyRandom(seed ?? null); +} + +export interface FontUnit { + id: string; + kind: "bundle" | "alacarte" | string; + prob?: number; + fonts: string[]; + requiresLocale?: string; + sizes?: Array<{ n: number; w: number }>; +} + +export interface FontBase { + id: string; + weight?: number; + fonts: string[]; +} + +let fontGroupsCache: Record | null = null; + +/** The addition units per OS (font-groups.json), each with its own probability. */ +function loadFontGroups(): Record { + if (!fontGroupsCache) { + try { + fontGroupsCache = + readJson>("font-groups.json"); + } catch (e) { + if (!isPyError(e, "OSError", "ValueError")) throw e; + FallbackWarning.warn( + "Reading font-groups.json", + "an OS-version base with no font additions", + e, + ); + fontGroupsCache = {}; + } + } + return fontGroupsCache; +} + +let fontBasesCache: Record | null = null; + +/** The OS-version bases per OS (font-bases.json), drawn entire by weight. */ +function loadFontBases(): Record { + if (!fontBasesCache) { + try { + fontBasesCache = readJson>("font-bases.json"); + } catch (e) { + if (!isPyError(e, "OSError", "ValueError")) throw e; + FallbackWarning.warn( + "Reading font-bases.json", + "only the always-present core fonts as its OS base", + e, + ); + fontBasesCache = {}; + } + } + return fontBasesCache; +} + +/** Draw one OS-version base by its real-world weight. */ +function pickBase(osKey: string, r: PyRandom): string[] { + const bases = loadFontBases()[osKey] ?? []; + if (!bases.length) return []; + const roll = r.random(); + let cumulative = 0.0; + for (const base of bases) { + cumulative += base.weight ?? 0.0; + if (roll < cumulative) return [...base.fonts]; + } + return [...bases[bases.length - 1].fonts]; +} + +function localeMatches( + locale: string | null | undefined, + required: string, +): boolean { + return (locale ?? "").toLowerCase().startsWith(required.toLowerCase()); +} + +/** The additions this machine has, each unit judged on its own probability. */ +function drawUnits( + osKey: string, + r: PyRandom, + exclude: Set, + locale?: string | null, +): string[] { + const out: string[] = []; + for (const unit of loadFontGroups()[osKey] ?? []) { + const required = unit.requiresLocale; + if (required && !localeMatches(locale, required)) continue; + if (r.random() >= (unit.prob ?? 0.0)) continue; + let members = unit.fonts.filter((f) => !exclude.has(f)); + if (!members.length) continue; + if (unit.kind === "alacarte") { + const sizes = unit.sizes?.length + ? unit.sizes + : [{ n: members.length, w: 1.0 }]; + const roll = r.random() * pySumFloats(sizes.map((s) => s.w)); + let cumulative = 0.0; + let count = sizes[sizes.length - 1].n; + for (const size of sizes) { + cumulative += size.w; + if (roll < cumulative) { + count = size.n; + break; + } + } + count = Math.max(1, Math.min(count, members.length)); + members = r.sample(members, count); + } + out.push(...members); + } + return out; +} + +/** Whether the host itself ships the OS-version font variant (native identities only). */ +function hostHasVariantFonts(targetOs: string): boolean { + if (targetOs !== "windows") return false; + const fontsDir = path.join(process.env.WINDIR || "C:\\Windows", "Fonts"); + // SegUIVar.ttf is Segoe UI Variable: on every Windows 11 and on no Windows 10. + return fs.existsSync(path.join(fontsDir, "SegUIVar.ttf")); +} + +const OS_TO_KEY: Readonly> = { + macos: "mac", + windows: "win", + linux: "lin", +}; + +function osKeyOf(targetOs: string): TargetOS { + return OS_TO_KEY[targetOs] ?? "mac"; +} + +/** + * The font list of one plausible machine of the given OS + * (`_generate_random_font_subset`): one OS-version base by weight, never + * subsetted, then each addition unit at its own measured probability, then + * the marker fonts. + * + * @param locale gates the units the manifest marks `requiresLocale`. + * @param native the identity is the host's own OS (macOS / Windows), where the + * real system fonts are used: only the OS base is claimed. + */ +export function generateRandomFontSubset( + targetOs: string, + seed?: number | bigint | null, + native = false, + locale?: string | null, +): string[] { + const r = rng(seed); + const osFontsData = loadOsFonts(); + const osKey = osKeyOf(targetOs); + const fullList = osFontsData[osKey] ?? osFontsData.mac ?? []; + + let essential: Set; + let markers: readonly string[]; + let variantFonts: readonly string[]; + if (targetOs === "windows") { + essential = new Set(ESSENTIAL_FONTS_WINDOWS); + markers = WINDOWS_MARKER_FONTS; + variantFonts = BASE_VARIANT_FONTS_WINDOWS[1]; + } else if (targetOs === "linux") { + essential = new Set(ESSENTIAL_FONTS_LINUX); + markers = LINUX_MARKER_FONTS; + variantFonts = BASE_VARIANT_FONTS_LINUX[1]; + } else { + essential = new Set(ESSENTIAL_FONTS_MACOS); + markers = MACOS_MARKER_FONTS; + variantFonts = BASE_VARIANT_FONTS_MACOS[1]; + } + + if (native) { + let result = fullList.filter((f) => essential.has(f)); + const full = new Set(fullList); + result.push( + ...[...essential].filter((f) => !full.has(f)).sort(comparePyStr), + ); + if (variantFonts.length && !hostHasVariantFonts(targetOs)) { + const absent = new Set(variantFonts); + result = result.filter((f) => !absent.has(f)); + } + return result; + } + + let base = pickBase(osKey, r); + if (!base.length) base = fullList.filter((f) => essential.has(f)); + const result = [...base]; + const chosen = new Set(result); + + // The guaranteed floor underneath whichever base was drawn. + for (const font of fullList) { + if (essential.has(font) && !chosen.has(font)) { + result.push(font); + chosen.add(font); + } + } + + // Every addition unit on its own real-world probability, atomically. + for (const font of drawUnits(osKey, r, chosen, locale)) { + if (!chosen.has(font)) { + result.push(font); + chosen.add(font); + } + } + + ensureMarkerFonts(result, markers); + return result; +} + +// --------------------------------------------------------------------------- +// Voices +// --------------------------------------------------------------------------- + +export interface VoiceObject { + name: string; + lang: string; + voiceUri: string; + isDefault: boolean; + isLocalService: boolean; +} + +// Real Firefox speechSynthesis URI prefixes per backend. +const VOICE_URI_PREFIX: Readonly> = { + mac: "urn:moz-tts:osx:", + win: "urn:moz-tts:sapi:", + lin: "urn:moz-tts:speechd:", +}; + +export const MAC_NOVELTY_VOICES: ReadonlySet = new Set([ + "Albert", + "Bad News", + "Bahh", + "Bells", + "Boing", + "Bubbles", + "Cellos", + "Wobble", + "Good News", + "Jester", + "Organ", + "Superstar", + "Trinoids", + "Whisper", + "Zarvox", + "Fred", + "Junior", + "Kathy", + "Ralph", + "Bruce", + "Vicki", + "Victoria", + "Agnes", + "Princess", + "Hysterical", + "Pipe Organ", + "Deranged", + // not a novelty voice, but the same MacinTalk identifier family + "Alex", +]); +export const MAC_ELOQUENCE_VOICES: ReadonlySet = new Set([ + "Eddy", + "Flo", + "Grandma", + "Grandpa", + "Reed", + "Rocko", + "Sandy", + "Shelley", +]); + +let voiceUrisCache: Record> | null = null; + +/** Real voiceURI per "Name|lang" as a stock browser reports it (voice-uris.json). */ +function loadVoiceUris(): Record> { + if (!voiceUrisCache) { + try { + voiceUrisCache = + readJson>>("voice-uris.json"); + } catch { + voiceUrisCache = {}; + } + } + return voiceUrisCache; +} + +let voiceManifestsCache: Record | null = null; + +/** The per-OS installed-voice model (voice-manifests.json). */ +function loadVoiceManifests(): Record { + voiceManifestsCache ??= readJson>("voice-manifests.json"); + return voiceManifestsCache; +} + +/** Stable dotted slug for mac/win URIs (shape-plausible, not catalog-exact). */ +function voiceUriSlug(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, ".") + .replace(/^\.|\.$/g, ""); +} + +/** A voiceUri matching what real Firefox emits for the OS backend. */ +export function voiceUri(osKey: string, name: string, lang: string): string { + if (osKey === "lin") { + // SpeechDispatcherService.cpp: NS_EscapeURL(name, OnlyNonASCII|Spaces) + let escaped = ""; + for (const ch of name) { + if (ch === " ") escaped += "%20"; + else if ((ch.codePointAt(0) as number) <= 0x7f) escaped += ch; + else { + for (const b of Buffer.from(ch, "utf-8")) { + escaped += `%${b.toString(16).toUpperCase().padStart(2, "0")}`; + } + } + } + return `${VOICE_URI_PREFIX.lin}${escaped}?${lang}`; + } + if (osKey === "win") { + // SapiService.cpp: the name and lang, verbatim. + return `${VOICE_URI_PREFIX.win}${name}?${lang}`; + } + if (osKey === "mac") { + const uri = loadVoiceUris().mac?.[`${name}|${lang}`]; + if (uri) return uri; + const asciiName = name.normalize("NFKD").replace(/[^A-Za-z0-9]/g, ""); + if (MAC_NOVELTY_VOICES.has(name)) { + return `${VOICE_URI_PREFIX.mac}com.apple.speech.synthesis.voice.${asciiName}`; + } + if (MAC_ELOQUENCE_VOICES.has(name)) { + return `${VOICE_URI_PREFIX.mac}com.apple.eloquence.${lang}.${asciiName}`; + } + return `${VOICE_URI_PREFIX.mac}com.apple.voice.compact.${lang}.${asciiName}`; + } + return `${VOICE_URI_PREFIX[osKey] ?? ""}${voiceUriSlug(name)}`; +} + +/** "Name:lang:type" -> [name, lang, type] (str.rsplit(':', 2)). */ +function splitVoiceEntry(entry: string): [string, string, string] { + const last = entry.lastIndexOf(":"); + const langsep = last < 0 ? -1 : entry.lastIndexOf(":", last - 1); + if (last < 0 || langsep < 0) { + throw new ValueError( + `not enough values to unpack (voice entry ${JSON.stringify(entry)})`, + ); + } + return [ + entry.slice(0, langsep), + entry.slice(langsep + 1, last), + entry.slice(last + 1), + ]; +} + +function weightedPick>( + r: PyRandom, + items: T[], + wkey = "w", +): T { + const total = pySumFloats(items.map((i) => Number(i[wkey] ?? 0))); + let x = r.random() * total; + for (const i of items) { + x -= Number(i[wkey] ?? 0); + if (x <= 0) return i; + } + return items[items.length - 1]; +} + +function weightedSample( + r: PyRandom, + items: T[], + k: number, + weight: (x: T) => number, +): T[] { + const pool = [...items]; + const out: T[] = []; + while (pool.length && out.length < k) { + const total = pySumFloats(pool.map(weight)); + let x = r.random() * total; + let picked = false; + for (const item of pool) { + x -= weight(item); + if (x <= 0) { + out.push(item); + pool.splice(pool.indexOf(item), 1); + picked = true; + break; + } + } + if (!picked) out.push(pool.pop() as T); + } + return out; +} + +function resolveDisplayPack( + packs: Record, + fallback: string, + locale?: string | null, +): string { + const keys = Object.keys(packs); + if (locale) { + if (Object.hasOwn(packs, locale)) return locale; + const lang = locale.split("-")[0].toLowerCase(); + for (const key of keys) { + if (key.split("-")[0].toLowerCase() === lang) return key; + } + } + return Object.hasOwn(packs, fallback) ? fallback : keys[0]; +} + +function codePointGreater(a: string, b: string): boolean { + return comparePyStr(a, b) > 0; +} + +/** + * The speech voice list for the given OS as MaskConfig voice objects + * (`_generate_random_voice_subset`), following the measured per-OS model in + * voice-manifests.json. Seeded by the identity so it always reports the same + * list. No voice is marked default: stock Firefox marks none. + */ +export function generateRandomVoiceSubset( + targetOs: string, + locale?: string | null, + seed?: number | bigint | null, +): VoiceObject[] { + const r = rng(seed); + const osKey = osKeyOf(targetOs); + const manifests = loadVoiceManifests(); + const manifest = pyTruthy(manifests[osKey]) + ? manifests[osKey] + : manifests.mac; + if (manifest === undefined) throw new KeyError("'mac'"); + + const out: string[] = []; + const seen = new Set(); + const add = (entries: string[] | null | undefined) => { + for (const e of entries ?? []) { + if (!seen.has(e)) { + seen.add(e); + out.push(e); + } + } + }; + + const legacy: string[] = []; + const packs: Record = pyTruthy(manifest.langPacks) + ? manifest.langPacks + : {}; + + const takePack = (pack: Record) => { + add(pack.oneCore); + if ( + pyTruthy(pack.desktop) && + r.random() < Number(pyTruthy(pack.desktopProb) ? pack.desktopProb : 0) + ) { + for (const e of pack.desktop as string[]) { + if (!legacy.includes(e)) legacy.push(e); + } + } + }; + + add(manifest.base); + const chosen = new Set(); + if (Object.keys(packs).length) { + const key = resolveDisplayPack( + packs, + pyTruthy(manifest.fallbackLocale) ? manifest.fallbackLocale : "en-US", + locale, + ); + chosen.add(key); + takePack(packs[key]); + } + + for (const addition of manifest.additions ?? []) { + if (pyTruthy(addition.deferred)) continue; + const req = addition.requiresLocale; + if (pyTruthy(req) && !localeMatches(locale, req)) continue; + if (r.random() >= Number(pyTruthy(addition.prob) ? addition.prob : 0)) + continue; + const kind = addition.kind; + if (kind === "bundle") { + add(addition.voices); + } else if (kind === "alacarte") { + const sizes = pyTruthy(addition.sizes) + ? addition.sizes + : [{ n: 1, w: 1 }]; + const k = Math.trunc(Number(weightedPick(r, sizes).n)); + for (const e of weightedSample( + r, + addition.voices ?? [], + k, + () => 1.0, + )) { + if (!seen.has(e)) { + seen.add(e); + // a downloaded voice sits in its alphabetical place + const el = e.toLowerCase(); + let idx = out.findIndex((v) => codePointGreater(v.toLowerCase(), el)); + if (idx < 0) idx = out.length; + out.splice(idx, 0, e); + } + } + } else if (kind === "groups" && Object.keys(packs).length) { + const eligible = ((addition.groups ?? []) as string[]).filter( + (g) => Object.hasOwn(packs, g) && !chosen.has(g), + ); + if (!eligible.length) continue; + const k = pyTruthy(addition.sizes) + ? Math.trunc(Number(weightedPick(r, addition.sizes).n)) + : eligible.length; + const weight = (g: string) => + Number(pyTruthy(packs[g].weight) ? packs[g].weight : 0.01); + for (const g of weightedSample(r, eligible, k, weight)) { + chosen.add(g); + takePack(packs[g]); + } + } + } + + const selected = [...out, ...legacy].map(splitVoiceEntry); + return selected.map(([name, lang, vtype]) => ({ + name, + lang, + voiceUri: voiceUri(osKey, name, lang), + isDefault: false, + isLocalService: vtype === "local", + })); +} + +/** + * Coerce a preset's `speechVoices` into MaskConfig voice objects: presets + * store "Name:lang:type" strings, which MaskConfig drops. Objects pass through. + */ +export function normalizePresetVoices( + voices: Array, + targetOs: string, +): VoiceObject[] { + const osKey = osKeyOf(targetOs); + const result: VoiceObject[] = []; + for (const entry of voices) { + if (entry !== null && typeof entry === "object") { + result.push(entry); + continue; + } + const last = entry.lastIndexOf(":"); + if (last < 0) continue; + const vtype = entry.slice(last + 1); + const before = entry.slice(0, last); + const langsep = before.lastIndexOf(":"); + if (langsep < 0) continue; + const lang = before.slice(langsep + 1); + const name = before.slice(0, langsep); + if (!name || !lang) continue; + result.push({ + name, + lang, + voiceUri: voiceUri(osKey, name, lang), + isDefault: false, + isLocalService: vtype === "local", + }); + } + if (result.length && !result.some((v) => v.isDefault)) + result[0].isDefault = true; + return result; +} + +// --------------------------------------------------------------------------- +// Hardware concurrency and navigator arch +// --------------------------------------------------------------------------- + +/** + * Logical CPUs this process may run on (affinity aware), as they were before + * any launch pinned it: see cpu_affinity.hostCoreCount(). + */ +export function hostCpuCount(): number | null { + return hostCoreCount() || null; +} + +/** + * Core counts real desktop machines ship with, from the recorded fingerprint + * corpus. 2 is excluded: no Apple Silicon part has 2 cores, and 85% of macOS + * identities draw an Apple GPU (see fingerprints.py). + */ +export const PLAUSIBLE_CORE_COUNTS: readonly number[] = [ + 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28, 32, +]; + +export interface HostCpu { + /** Logical CPUs of the host (default: hostCpuCount()). */ + cpuCount?: number | null; + /** Whether the host can pin processes to cores (default: cpu_affinity.supported()). */ + canPinHost?: boolean; +} + +/** + * navigator.hardwareConcurrency = the host's parallelism, snapped DOWN into + * PLAUSIBLE_CORE_COUNTS -- or the fingerprint's own value (snapped, capped by + * the host) when the browser can be pinned to that many cores. + * + * @param canPin false when nothing will pin the browser (launchServer, or + * launchOptions used directly); undefined/null: whatever the host supports. + * @param host overrides for the host probes (tests). + */ +export function fixHardwareConcurrency( + config: Config, + canPin?: boolean | null, + host: HostCpu = {}, +): void { + const n = "cpuCount" in host ? host.cpuCount : hostCpuCount(); + if (!n) return; + const pinnable = + (host.canPinHost ?? cpuAffinitySupported()) && canPin !== false; + + const cap = Math.trunc(n); + const hostAllowed = PLAUSIBLE_CORE_COUNTS.filter((c) => c <= cap); + const hostValue = hostAllowed.length + ? hostAllowed[hostAllowed.length - 1] + : PLAUSIBLE_CORE_COUNTS[0]; + + let drawn = config["navigator.hardwareConcurrency"]; + if (typeof drawn === "boolean") drawn = Number(drawn); // a Python bool is an int + if ( + pinnable && + typeof drawn === "number" && + Number.isInteger(drawn) && + drawn >= 1 + ) { + const target = Math.min(drawn, cap); + const allowed = PLAUSIBLE_CORE_COUNTS.filter((c) => c <= target); + config["navigator.hardwareConcurrency"] = allowed.length + ? allowed[allowed.length - 1] + : PLAUSIBLE_CORE_COUNTS[0]; + return; + } + config["navigator.hardwareConcurrency"] = hostValue; +} + +/** Force navigator.platform AND navigator.oscpu to match the UA's arch (Linux). */ +export function fixNavigatorArch(config: Config, targetOs: string): void { + if (targetOs !== "lin") return; + const ua = config["navigator.userAgent"]; + if (!pyTruthy(ua)) return; + let target = ""; + if (ua.includes("Linux x86_64")) target = "Linux x86_64"; + else if (ua.includes("Linux i686")) target = "Linux i686"; + if (!target) return; + if (config["navigator.platform"] !== target) + config["navigator.platform"] = target; + if (config["navigator.oscpu"] !== target) config["navigator.oscpu"] = target; +} + +// --------------------------------------------------------------------------- +// Screen / window geometry +// --------------------------------------------------------------------------- + +/** + * Ensure screen.availHeight < screen.height (CreepJS's noTaskbar flag), and + * clamp the window's outer/inner height to the new avail. + */ +export function fixScreenNoTaskbar(config: Config, targetOs: string): void { + const sw = config["screen.width"]; + const sh = config["screen.height"]; + const ah = config["screen.availHeight"]; + if (!(pyTruthy(sw) && pyTruthy(sh) && num(ah) === num(sh) && !isNone(ah))) + return; + const taskbar = targetOs === "win" ? 40 : targetOs === "mac" ? 25 : 27; + const newAvail = num(sh) - taskbar; + config["screen.availHeight"] = newAvail; + const oh = config["window.outerHeight"]; + if (pyTruthy(oh) && num(oh) > newAvail) { + const ih = config["window.innerHeight"]; + const chrome = pyTruthy(ih) ? num(oh) - num(ih) : 0; + config["window.outerHeight"] = newAvail; + if (pyTruthy(ih)) config["window.innerHeight"] = newAvail - chrome; + } +} + +/** Enforce inner <= outer <= avail <= screen on both axes. */ +export function clampWindowDimensions(config: Config): void { + for (const axis of ["Width", "Height"]) { + const screen = config[`screen.${axis.toLowerCase()}`]; + const avail = config[`screen.avail${axis}`]; + const outer = config[`window.outer${axis}`]; + const inner = config[`window.inner${axis}`]; + + // avail must not exceed screen + if (pyTruthy(screen) && pyTruthy(avail) && num(avail) > num(screen)) { + config[`screen.avail${axis}`] = screen; + } + const availClamped = get(config, `screen.avail${axis}`, screen); + + // outer must not exceed avail (or screen if avail is unknown) + const outerCap = !isNone(availClamped) ? availClamped : screen; + if (pyTruthy(outer) && pyTruthy(outerCap) && num(outer) > num(outerCap)) { + const chrome = pyTruthy(inner) ? Math.max(0, num(outer) - num(inner)) : 0; + config[`window.outer${axis}`] = outerCap; + if (pyTruthy(inner)) + config[`window.inner${axis}`] = Math.max(1, num(outerCap) - chrome); + } + + // inner must not exceed outer + const outerClamped = get(config, `window.outer${axis}`, outer); + const innerNow = config[`window.inner${axis}`]; + if ( + pyTruthy(innerNow) && + pyTruthy(outerClamped) && + num(innerNow) > num(outerClamped) + ) { + config[`window.inner${axis}`] = outerClamped; + } + } +} + +/** + * Shrink screen.width/height down to the bounds of the real display, keeping + * the taskbar delta. Run clampWindowDimensions afterwards. + */ +export function clampScreenToDisplay( + config: Config, + maxWidth: number | null | undefined, + maxHeight: number | null | undefined, +): void { + for (const [axis, cap] of [ + ["width", maxWidth], + ["height", maxHeight], + ] as const) { + const screen = config[`screen.${axis}`]; + if (!(pyTruthy(screen) && pyTruthy(cap)) || num(screen) <= (cap as number)) + continue; + const availKey = + axis === "width" ? "screen.availWidth" : "screen.availHeight"; + const avail = config[availKey]; + config[`screen.${axis}`] = cap; + if (pyTruthy(avail)) { + config[availKey] = Math.max( + 1, + (cap as number) - Math.max(0, num(screen) - num(avail)), + ); + } + } +} + +/** Keep the window box inside the screen: 0 <= screenX/Y <= screen - outer. */ +export function clampWindowPosition(config: Config): void { + for (const [axis, posKey] of [ + ["Width", "window.screenX"], + ["Height", "window.screenY"], + ] as const) { + const screen = config[`screen.${axis.toLowerCase()}`]; + const outer = config[`window.outer${axis}`]; + const pos = config[posKey]; + if (isNone(pos) || !(pyTruthy(screen) && pyTruthy(outer))) continue; + config[posKey] = Math.max(0, Math.min(num(pos), num(screen) - num(outer))); + } +} + +/** The smallest screen mainstream hardware still ships. */ +export const MODERN_SCREEN_FLOOR: readonly [number, number] = [1366, 768]; + +/** + * Lift netbook-era screen geometry to the modern floor, keeping the + * screen-to-avail gaps. Call BEFORE clampScreenToDisplay. + */ +export function raiseScreenToModernFloor(config: Config): void { + const [minW, minH] = MODERN_SCREEN_FLOOR; + const sw = config["screen.width"]; + const sh = config["screen.height"]; + if (!(pyTruthy(sw) && pyTruthy(sh)) || (num(sw) >= minW && num(sh) >= minH)) + return; + + const aw = config["screen.availWidth"]; + const ah = config["screen.availHeight"]; + const gapW = pyTruthy(aw) ? num(sw) - num(aw) : null; + const gapH = pyTruthy(ah) ? num(sh) - num(ah) : null; + + const newW = Math.max(num(sw), minW); + const newH = Math.max(num(sh), minH); + config["screen.width"] = newW; + config["screen.height"] = newH; + if (gapW !== null) + config["screen.availWidth"] = Math.max(1, newW - Math.max(0, gapW)); + if (gapH !== null) + config["screen.availHeight"] = Math.max(1, newH - Math.max(0, gapH)); +} + +// --------------------------------------------------------------------------- +// Media devices +// --------------------------------------------------------------------------- + +let mediaDevicesCache: Record | null = null; + +/** Per-OS catalogue of common sound cards / headsets / displays / cameras. */ +function loadMediaDevices(): Record { + mediaDevicesCache ??= readJson>("media-devices.json"); + return mediaDevicesCache; +} + +function weightedChoice>( + r: PyRandom, + items: T[], +): T { + const w = (item: T) => (Object.hasOwn(item, "w") ? item.w : 1); + const total = Number(pySum(items.map(w))); + let x = r.random() * total; + for (const item of items) { + x -= w(item); + if (x < 0) return item; + } + return items[items.length - 1]; +} + +// Share of machines with no microphone at all, and with a built-in camera. +const MEDIA_P_NO_MIC: Readonly> = { + win: 0.08, + mac: 0.0, + lin: 0.2, +}; +const MEDIA_P_BUILTIN_CAM: Readonly> = { + win: 0.78, + mac: 0.0, + lin: 0.45, +}; + +/** + * Draw one machine's media devices for `osKey` ('win'|'mac'|'lin'): the + * mediaDevices:* config keys, counts plus aligned label and group lists. + */ +export function drawMediaDevices( + osKey: string, + seed: number | bigint | null | undefined, +): Config { + const r = rng(seed); + const catalogue = loadMediaDevices(); + const cat = pyTruthy(catalogue[osKey]) ? catalogue[osKey] : catalogue.win; + const mics: Array<[string, string]> = []; + const outs: Array<[string, string]> = []; + const cams: Array<[string, string]> = []; + let counter = 0; + const group = () => { + counter += 1; + return `hw-${counter}`; + }; + const add = (item: Record, grp: string) => { + for (const m of item.mics ?? []) mics.push([m, grp]); + for (const o of item.outs ?? []) outs.push([o, grp]); + if (pyTruthy(item.cam)) cams.push([item.cam, group()]); + }; + + // 1. the machine's own sound card (+ built-in camera on macOS models) + const card = weightedChoice(r, cat.cards); + const noMic = r.random() < (MEDIA_P_NO_MIC[osKey] ?? 0.0); + const cardGrp = group(); + add(noMic ? { ...card, mics: [] } : card, cardGrp); + // 2. a built-in laptop camera (Windows/Linux); rare on a mic-less tower + const pCam = MEDIA_P_BUILTIN_CAM[osKey] ?? 0.0; + if (r.random() < (noMic ? pCam * 0.3 : pCam)) { + const builtin = (cat.cameras as any[]).filter((c) => !pyTruthy(c.mic)); + if (builtin.length) cams.push([weightedChoice(r, builtin).cam, group()]); + } + // 3. a headset / USB microphone + if (r.random() < (cat.p_headset ?? 0.0)) + add(weightedChoice(r, cat.headsets), group()); + // 4. display audio (HDMI/DP) + if (r.random() < (cat.p_display ?? 0.0)) + add(weightedChoice(r, cat.displays), group()); + // 5. an external webcam, usually with its own microphone + if (r.random() < (cat.p_extra_camera ?? 0.0)) { + const withMic = (cat.cameras as any[]).filter((c) => pyTruthy(c.mic)); + const external = withMic.length ? withMic : cat.cameras; + const cam = weightedChoice(r, external); + const grp = group(); + cams.push([cam.cam, grp]); + if (pyTruthy(cam.mic)) mics.push([cam.mic, grp]); + } + // 6. PulseAudio exposes a monitor source per output as a capture device + if (pyTruthy(cat.monitor_sources)) { + for (const [label, grp] of [...outs]) + mics.push([`Monitor of ${label}`, grp]); + } + + return { + "mediaDevices:enabled": true, + "mediaDevices:micros": mics.length, + "mediaDevices:webcams": cams.length, + "mediaDevices:speakers": outs.length, + "mediaDevices:microphoneLabels": mics.map(([m]) => m), + "mediaDevices:microphoneGroups": mics.map(([, g]) => g), + "mediaDevices:webcamLabels": cams.map(([c]) => c), + "mediaDevices:webcamGroups": cams.map(([, g]) => g), + "mediaDevices:speakerLabels": outs.map(([o]) => o), + "mediaDevices:speakerGroups": outs.map(([, g]) => g), + }; +} + +/** + * Give the identity a plausible set of media devices, drawn from the common + * desktop population for its OS and seeded by the identity. Nothing is drawn + * when the caller already set any mediaDevices: key. + */ +export function setMediaDevicesDefaults( + config: Config, + salt: number | bigint = 0, +): void { + if (Object.keys(config).some((k) => k.startsWith("mediaDevices:"))) return; + const plat = pyStr(get(config, "navigator.platform", "")); + const osKey = plat.startsWith("Win") + ? "win" + : plat.startsWith("Mac") + ? "mac" + : "lin"; + Object.assign(config, drawMediaDevices(osKey, identitySeed(config, salt))); +} + +// --------------------------------------------------------------------------- +// WebGL <-> screen coherence (#729) +// --------------------------------------------------------------------------- + +/** Software rasterizers: never preferred, never screen-constrained. */ +const SOFTWARE_RENDERERS: readonly string[] = [ + "llvmpipe", + "Microsoft Basic Render Driver", + "SwiftShader", + "Generic Renderer", +]; + +/** Gecko renderer buckets that are discrete GPUs (no netbook shipped one). */ +const DISCRETE_GPU_BUCKETS: ReadonlySet = new Set([ + "GeForce 8800 GTX", + "GeForce GTX 480", + "GeForce GTX 980", + "Radeon R9 200 Series", +]); + +/** Netbook panels topped out at 1024x600; an area, not a per-axis floor. */ +const NETBOOK_MAX_PIXELS = 1024 * 600; + +const ANGLE_D3D_RE = /^ANGLE \([^,]*, (.*?) Direct3D.*\)$/s; +const ANGLE_VULKAN_RE = /^ANGLE \((.*)\) on Vulkan$/s; +const PCIE_SSE2_RE = /^(.*)\/PCIe?\/SSE2$/s; + +/** Reduce a reported renderer to Gecko's sanitized device bucket. */ +export function rendererBucket(renderer: string): string { + let core = renderer.endsWith(", or similar") + ? renderer.slice(0, -", or similar".length) + : renderer; + let match = ANGLE_D3D_RE.exec(core) ?? ANGLE_VULKAN_RE.exec(core); + if (match) core = match[1]; + match = PCIE_SSE2_RE.exec(core); + if (match) core = match[1]; + return core.startsWith("NVIDIA ") ? core.slice("NVIDIA ".length) : core; +} + +/** Whether `renderer` is a software rasterizer rather than real hardware. */ +export function isSoftwareRenderer( + renderer: string | null | undefined, +): boolean { + return ( + !!renderer && SOFTWARE_RENDERERS.some((name) => renderer.includes(name)) + ); +} + +/** Whether `renderer` is a GPU that plausibly drives a `width` x `height` screen. */ +export function gpuScreenIsPlausible( + renderer: string | null | undefined, + width: number | null | undefined, + height: number | null | undefined, +): boolean { + if (!renderer || !width || !height) return true; + if (isSoftwareRenderer(renderer)) return true; + if (!DISCRETE_GPU_BUCKETS.has(rendererBucket(renderer))) return true; + return width * height > NETBOOK_MAX_PIXELS; +} + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +/** The bundled-presets file for a Firefox version (v150 bundle from 149 up). */ +export function selectPresetsFile(ffVersion?: string | number | null): string { + let major = 0; + if (ffVersion) { + const parsed = Number.parseInt( + String(ffVersion).split(".", 1)[0].trim(), + 10, + ); + major = /^\s*[+-]?\d+\s*$/.test(String(ffVersion).split(".", 1)[0]) + ? parsed + : 0; + } + if (major >= PRESETS_V150_MIN_FF && fs.existsSync(PRESETS_V150_FILE)) + return PRESETS_V150_FILE; + return PRESETS_FILE; +} + +/** Load the bundled fingerprint presets. */ +export function loadPresets( + ffVersion?: string | number | null, +): PresetBundle | null { + const file = selectPresetsFile(ffVersion); + const cached = PRESETS_CACHE.get(file); + if (cached) return cached; + if (!fs.existsSync(file)) return null; + const bundle = JSON.parse(fs.readFileSync(file, "utf-8")) as PresetBundle; + PRESETS_CACHE.set(file, bundle); + return bundle; +} + +const OS_TO_PRESET_KEY: Readonly> = { + windows: "windows", + macos: "macos", + linux: "linux", + win: "windows", + mac: "macos", + lin: "linux", +}; + +/** + * A random preset for the given OS (or OSes), or null when none is bundled. + * Draws from the shared `pyRandom` (Python's module-level random.choice). + */ +export function getRandomPreset( + os?: string | readonly string[] | null, + ffVersion?: string | number | null, +): Preset | null { + const presets = loadPresets(ffVersion); + if (!pyTruthy(presets)) return null; + let osKeys: string[]; + if (os?.length) { + const names = typeof os === "string" ? [os] : [...os]; + osKeys = names.map((o) => OS_TO_PRESET_KEY[o] ?? o); + } else { + osKeys = ["macos", "windows", "linux"]; + } + const candidates: Preset[] = []; + for (const key of osKeys) { + candidates.push( + ...((presets?.presets as Record | undefined)?.[key] ?? + []), + ); + } + if (!candidates.length) return null; + return pyRandom.choice(candidates); +} + +// Tokens that name the machine rather than the platform. +const APP_VERSION_DROPPED: ReadonlySet = new Set([ + "Win64", + "x64", + "Mobile", + "Tablet", +]); + +/** + * The appVersion Firefox reports for a browser sending this user agent: + * "5.0 ()" without the architecture, the Gecko revision, or the + * Windows build number. + */ +export function appVersionFromUserAgent( + userAgent: string | null | undefined, +): string | null { + const block = /^Mozilla\/5\.0 \(([^)]*)\)/.exec(userAgent ?? ""); + if (!block) return null; + const kept: string[] = []; + for (const token of block[1].split(";").map((part) => part.trim())) { + if ( + token.startsWith("rv:") || + APP_VERSION_DROPPED.has(token) || + token.startsWith("Linux ") || + token.startsWith("Intel Mac OS X") + ) { + continue; + } + kept.push(token.startsWith("Windows") ? "Windows" : token); + } + return kept.length ? `5.0 (${kept.join("; ")})` : null; +} + +function oscpuFromPlatform(plat: string): string | null { + if (plat === "MacIntel") return "Intel Mac OS X 10.15"; + if (plat === "Win32") return "Windows NT 10.0; Win64; x64"; + if (plat.includes("Linux") || plat.includes("linux")) return "Linux x86_64"; + return null; +} + +/** + * Convert a real fingerprint preset to CAMOU_CONFIG format. + * + * `salt` (identitySalt) keys the font/voice draws; undefined draws a fresh + * one, so two users of the same recorded device do not share its font list. + */ +export function fromPreset( + preset: Preset, + ffVersion?: string | number | null, + salt?: number | bigint | null, +): Config { + const s = isNone(salt) ? identitySalt() : (salt as number | bigint); + const config: Config = {}; + + const nav: Record = preset.navigator ?? {}; + if (pyTruthy(nav.userAgent)) { + let ua: string = nav.userAgent; + if (ffVersion) { + ua = ua.replace(/Firefox\/\d+\.0/g, `Firefox/${ffVersion}.0`); + ua = ua.replace(/rv:\d+\.0/g, `rv:${ffVersion}.0`); + } + config["navigator.userAgent"] = ua; + } + if (pyTruthy(nav.platform)) config["navigator.platform"] = nav.platform; + if (pyTruthy(nav.hardwareConcurrency)) + config["navigator.hardwareConcurrency"] = nav.hardwareConcurrency; + if (pyTruthy(nav.oscpu)) { + config["navigator.oscpu"] = nav.oscpu; + } else if (pyTruthy(nav.platform)) { + const oscpu = oscpuFromPlatform(nav.platform); + if (oscpu) config["navigator.oscpu"] = oscpu; + } + if (pyTruthy(nav.appVersion)) { + config["navigator.appVersion"] = nav.appVersion; + } else if (pyTruthy(config["navigator.userAgent"])) { + // Left unset, appVersion falls through to the HOST's value and + // contradicts the userAgent and platform set above. + const derived = appVersionFromUserAgent(config["navigator.userAgent"]); + if (derived) config["navigator.appVersion"] = derived; + } + if (Object.hasOwn(nav, "maxTouchPoints")) + config["navigator.maxTouchPoints"] = nav.maxTouchPoints; + + const screen: Record = preset.screen ?? {}; + if (pyTruthy(screen.width)) config["screen.width"] = screen.width; + if (pyTruthy(screen.height)) config["screen.height"] = screen.height; + if (pyTruthy(screen.colorDepth)) { + config["screen.colorDepth"] = screen.colorDepth; + config["screen.pixelDepth"] = screen.colorDepth; + } + if (pyTruthy(screen.availWidth)) + config["screen.availWidth"] = screen.availWidth; + if (pyTruthy(screen.availHeight)) + config["screen.availHeight"] = screen.availHeight; + + const webgl: Record = preset.webgl ?? {}; + if (pyTruthy(webgl.unmaskedVendor)) + config["webGl:vendor"] = webgl.unmaskedVendor; + if (pyTruthy(webgl.unmaskedRenderer)) + config["webGl:renderer"] = webgl.unmaskedRenderer; + + // A unique audio seed per launch. + config["audio:seed"] = pyRandom.randint(1, 4_294_967_295); + + if (pyTruthy(preset.timezone)) config.timezone = preset.timezone; + + const plat: string = nav.platform ?? ""; + let targetOs: string; + if (plat === "MacIntel") targetOs = "macos"; + else if (plat === "Win32") targetOs = "windows"; + else if (plat.includes("Linux") || plat.includes("linux")) targetOs = "linux"; + else targetOs = "macos"; + + const presetKey = `${pyStr(config["navigator.userAgent"])} / ${pyStr(config["webGl:renderer"])}`; + try { + config.fonts = generateRandomFontSubset(targetOs, identitySeed(config, s)); + } catch (e) { + if (!isPyError(e, "OSError", "ValueError")) throw e; + FallbackWarning.warn( + "Drawing the font list", + pyTruthy(preset.fonts) + ? "the preset's recorded fonts" + : "the browser's own fonts", + e, + presetKey, + ); + if (pyTruthy(preset.fonts)) { + const fonts = [...(preset.fonts as string[])]; + ensureMarkerFonts( + fonts, + { + macos: MACOS_MARKER_FONTS, + windows: WINDOWS_MARKER_FONTS, + linux: LINUX_MARKER_FONTS, + }[targetOs] ?? MACOS_MARKER_FONTS, + ); + config.fonts = fonts; + } + } + try { + config.voices = generateRandomVoiceSubset( + targetOs, + null, + identitySeed(config, s), + ); + } catch (e) { + if (!isPyError(e, "OSError", "ValueError", "KeyError")) throw e; + FallbackWarning.warn( + "Drawing the speech voices", + pyTruthy(preset.speechVoices) + ? "the preset's recorded voices" + : "the browser's own voices", + e, + presetKey, + ); + if (pyTruthy(preset.speechVoices)) { + config.voices = normalizePresetVoices( + preset.speechVoices as Array, + targetOs, + ); + } + } + return config; +} + +// --------------------------------------------------------------------------- +// Per-context fingerprints +// --------------------------------------------------------------------------- + +export interface InitValues { + audioFingerprintSeed?: number; + navigatorPlatform?: string; + navigatorOscpu?: string; + navigatorUserAgent?: string; + hardwareConcurrency?: number; + webglVendor?: string; + webglRenderer?: string; + screenWidth?: number; + screenHeight?: number; + screenColorDepth?: number; + timezone?: string; + fontList?: string[]; + speechVoices?: Array; + webrtcIP?: string; +} + +/** + * The JavaScript init script that calls the per-context window.setXxx() + * functions. Those self-destruct after their first call, so they must run + * via addInitScript. + */ +export function buildInitScript(values: InitValues): string { + const lines = ["(function(v) {", " var w = window;"]; + + const setters: Array<[keyof InitValues, string]> = [ + ["audioFingerprintSeed", "setAudioFingerprintSeed"], + ["navigatorPlatform", "setNavigatorPlatform"], + ["navigatorOscpu", "setNavigatorOscpu"], + ["navigatorUserAgent", "setNavigatorUserAgent"], + ["hardwareConcurrency", "setNavigatorHardwareConcurrency"], + ["webglVendor", "setWebGLVendor"], + ["webglRenderer", "setWebGLRenderer"], + ]; + for (const [key, fnName] of setters) { + const val = values[key]; + if (!isNone(val)) { + lines.push( + ` if (typeof w.${fnName} === "function") w.${fnName}(${pyJsonDumps(val)});`, + ); + } + } + + // Screen dimensions (requires width + height together) + const sw = values.screenWidth; + const sh = values.screenHeight; + if (pyTruthy(sw) && pyTruthy(sh)) { + lines.push( + ` if (typeof w.setScreenDimensions === "function") w.setScreenDimensions(${sw}, ${sh});`, + ); + const scd = values.screenColorDepth; + if (pyTruthy(scd)) { + lines.push( + ` if (typeof w.setScreenColorDepth === "function") w.setScreenColorDepth(${scd});`, + ); + } + } + + // Timezone -- only with an explicit value; MaskConfig handles the rest. + const tz = values.timezone; + if (pyTruthy(tz)) { + lines.push( + ` if (typeof w.setTimezone === "function") w.setTimezone(${pyJsonDumps(tz)});`, + ); + } + + // WebRTC IP + const ip = values.webrtcIP; + if (pyTruthy(ip)) { + validateIP(ip as string); + const fnName = validIPv4(ip as string) ? "setWebRTCIPv4" : "setWebRTCIPv6"; + lines.push( + ` if (typeof w.${fnName} === "function") w.${fnName}(${pyJsonDumps(ip)});`, + ); + } else { + lines.push( + ' if (typeof w.setWebRTCIPv4 === "function") w.setWebRTCIPv4("");', + ); + } + + // Font list (comma-separated) + const fontList = values.fontList; + if (fontList?.length) { + lines.push( + ` if (typeof w.setFontList === "function") w.setFontList(${pyJsonDumps(fontList.join(","))});`, + ); + } + + // Speech voices (comma-separated names) + const voices = values.speechVoices; + if (voices?.length) { + const names = voices.map((v) => + v !== null && typeof v === "object" ? v.name : v, + ); + lines.push( + ` if (typeof w.setSpeechVoices === "function") w.setSpeechVoices(${pyJsonDumps(names.join(","))});`, + ); + } + + lines.push("})();"); + return lines.join("\n"); +} + +/** json.dumps() with its defaults (ASCII-escaped, ", " / ": " separators) for scalars. */ +function pyJsonDumps(value: unknown): string { + if (typeof value === "string") { + return JSON.stringify(value).replace( + /[\u007f-\uffff]/g, + (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + } + if (typeof value === "boolean") return value ? "true" : "false"; + if (isNone(value)) return "null"; + if (typeof value === "number" && !Number.isSafeInteger(value)) { + if (Number.isNaN(value)) return "NaN"; + if (!Number.isFinite(value)) return value > 0 ? "Infinity" : "-Infinity"; + return pyStr(value); + } + return String(value); +} + +export interface ContextFingerprint { + init_script: string; + context_options: Record; + config: Config; + preset: Preset; +} + +function targetOsFromPlatform(plat: string): SupportedOS { + if (plat === "Win32") return "windows"; + if (plat.includes("Linux") || plat.includes("linux")) return "linux"; + return "macos"; +} + +/** + * Fingerprint values for a single per-context identity: the init script plus + * the Playwright context options (camelCase, for playwright-core). + * + * By default an fpgen fingerprint is generated; pass a preset to use a real + * recorded one instead. + * + * @param timezone IANA timezone; takes priority over the preset's. + * @param locale BCP-47 locale; also sets context_options.locale. + * @param config_overrides CAMOU_CONFIG keys applied after the config is built + * and before the init script is rendered. + */ +export function generateContextFingerprint({ + preset, + os, + ff_version, + webrtc_ip, + timezone, + locale, + config_overrides, +}: { + preset?: Preset | null; + os?: string | null; + ff_version?: string | null; + webrtc_ip?: string | null; + timezone?: string | null; + locale?: string | null; + config_overrides?: Config | null; +} = {}): ContextFingerprint { + let config: Config; + let nav: Record; + let screen: Record; + let webgl: Record; + let resolvedPreset: Preset; + + if (!isNone(preset)) { + const p = preset as Preset; + config = fromPreset(p, ff_version); + nav = p.navigator ?? {}; + screen = p.screen ?? {}; + webgl = p.webgl ?? {}; + resolvedPreset = p; + } else { + const fp = generateFingerprint({ os: os ?? undefined }); + config = fromFpgen(fp, ff_version); + + // A fresh identity: every seeded draw below gets its own salt. + const salt = identitySalt(); + + if (!("audio:seed" in config)) + config["audio:seed"] = pyRandom.randint(1, 4_294_967_295); + + const osName = targetOsFromPlatform( + pyStr(get(config, "navigator.platform", "")), + ); + + if (!("fonts" in config)) { + try { + config.fonts = generateRandomFontSubset( + osName, + identitySeed(config, salt), + ); + } catch (e) { + if (!isPyError(e, "OSError", "ValueError")) throw e; + FallbackWarning.warn( + "Drawing the font list", + "the browser's launch-time fonts", + e, + config["navigator.userAgent"], + ); + } + } + if (!("voices" in config)) { + try { + config.voices = generateRandomVoiceSubset( + osName, + null, + identitySeed(config, salt), + ); + } catch (e) { + if (!isPyError(e, "OSError", "ValueError", "KeyError")) throw e; + FallbackWarning.warn( + "Drawing the speech voices", + "the browser's launch-time voices", + e, + config["navigator.userAgent"], + ); + } + } + if (!("navigator.oscpu" in config)) { + const oscpu = oscpuFromPlatform( + pyStr(get(config, "navigator.platform", "")), + ); + if (oscpu) config["navigator.oscpu"] = oscpu; + } + + if ( + !pyTruthy(config["webGl:vendor"]) || + !pyTruthy(config["webGl:renderer"]) + ) { + const osMap: Record = { + macos: "mac", + linux: "lin", + windows: "win", + }; + let targetOs: TargetOS | undefined = osMap[os ?? ""]; + if (!targetOs) { + const plat = pyStr(get(config, "navigator.platform", "")); + targetOs = + plat === "Win32" + ? "win" + : plat.includes("Linux") || plat.includes("linux") + ? "lin" + : "mac"; + } + // Same coherence treatment launchOptions applies (#729): lift netbook + // geometry, then keep the GPU consistent with the resulting screen. + raiseScreenToModernFloor(config); + const webglFp = sampleWebglForScreen( + targetOs, + config["screen.width"], + config["screen.height"], + ); + delete webglFp.webGl2Enabled; + Object.assign(config, webglFp); + } + + nav = { + platform: config["navigator.platform"], + hardwareConcurrency: config["navigator.hardwareConcurrency"], + }; + screen = { + width: config["screen.width"], + height: config["screen.height"], + colorDepth: config["screen.colorDepth"], + devicePixelRatio: null, + }; + webgl = { + unmaskedVendor: config["webGl:vendor"], + unmaskedRenderer: config["webGl:renderer"], + }; + resolvedPreset = { navigator: nav, screen, webgl }; + } + + if (timezone) config.timezone = timezone; + if (locale) { + const parsed = normalizeLocale(locale); + config["locale:language"] = parsed.language; + config["locale:region"] = parsed.region; + config["navigator.language"] = parsed.asString; + if (parsed.script) config["locale:script"] = parsed.script; + } + + if (config_overrides) Object.assign(config, config_overrides); + + const initValues: InitValues = { + audioFingerprintSeed: config["audio:seed"], + navigatorPlatform: nav.platform, + navigatorOscpu: config["navigator.oscpu"], + navigatorUserAgent: config["navigator.userAgent"], + hardwareConcurrency: pyTruthy(nav.hardwareConcurrency) + ? nav.hardwareConcurrency + : config["navigator.hardwareConcurrency"], + webglVendor: webgl.unmaskedVendor, + webglRenderer: webgl.unmaskedRenderer, + screenWidth: screen.width, + screenHeight: screen.height, + screenColorDepth: screen.colorDepth, + timezone: + typeof resolvedPreset.timezone === "string" + ? resolvedPreset.timezone + : config.timezone, + fontList: config.fonts, + speechVoices: config.voices, + webrtcIP: webrtc_ip || "", + }; + const initScript = buildInitScript(initValues); + + const contextOptions: Record = {}; + const ua = config["navigator.userAgent"]; + if (pyTruthy(ua)) contextOptions.userAgent = ua; + const sw = screen.width; + const sh = screen.height; + if (pyTruthy(sw) && pyTruthy(sh)) + contextOptions.viewport = { width: sw, height: Math.max(sh - 28, 600) }; + const dpr = screen.devicePixelRatio; + if (pyTruthy(dpr)) contextOptions.deviceScaleFactor = dpr; + let tz = config.timezone; + if (!pyTruthy(tz)) tz = resolvedPreset.timezone; + if (pyTruthy(tz)) contextOptions.timezoneId = tz; + const navLang = config["navigator.language"]; + if (pyTruthy(navLang)) contextOptions.locale = navLang; + + return { + init_script: initScript, + context_options: contextOptions, + config, + preset: resolvedPreset, + }; +} + +// --------------------------------------------------------------------------- +// fpgen fingerprints +// --------------------------------------------------------------------------- + +function isPlainObject(value: unknown): value is Record { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) + ); +} + +/** Casts an fpgen fingerprint node onto Camoufox config properties. */ +export function castToProperties( + camoufoxData: Config, + castEnum: Record, + fpDict: Record, + ffVersion?: string | number | null, +): void { + for (const [key, raw] of Object.entries(fpDict)) { + // Ignore non-truthy values + if (!pyTruthy(raw)) continue; + const typeKey = castEnum[key]; + if (!pyTruthy(typeKey)) continue; + if (isPlainObject(raw)) { + castToProperties(camoufoxData, typeKey, raw, ffVersion); + continue; + } + let data: any = raw; + // fpgen carries header values as a list; a single string is the value. + if (Array.isArray(data)) { + if (data.length === 1 && typeof data[0] === "string") data = data[0]; + else continue; + } + if ( + typeof typeKey === "string" && + typeKey.startsWith("screen.") && + (typeof data === "boolean" || + (typeof data === "number" && Number.isInteger(data))) && + Number(data) < 0 + ) { + data = 0; + } + if (ffVersion && typeof data === "string") { + data = data.replace( + /(? `${ffVersion}${dot}`, + ); + } + camoufoxData[typeKey] = data; + } +} + +/** Sets window.screenY from the generated screenX value. */ +export function handleScreenXY( + camoufoxData: Config, + fingerprint: Record, +): void { + if ("window.screenY" in camoufoxData) return; + const screen = pyTruthy(fingerprint.screen) ? fingerprint.screen : {}; + const window = pyTruthy(fingerprint.window) ? fingerprint.window : {}; + const screenX = window.screenX; + if (!pyTruthy(screenX)) { + camoufoxData["window.screenX"] = 0; + camoufoxData["window.screenY"] = 0; + return; + } + if (Number.isInteger(screenX) && screenX >= -50 && screenX <= 50) { + camoufoxData["window.screenY"] = screenX; + return; + } + // The generator thinks the browser is windowed. Randomly generate a screenY. + const screenY = num(screen.availHeight || 0) - num(window.outerHeight || 0); + if (screenY === 0) camoufoxData["window.screenY"] = 0; + else if (screenY > 0) + camoufoxData["window.screenY"] = pyRandom.randrange(0, screenY); + else camoufoxData["window.screenY"] = pyRandom.randrange(screenY, 0); +} + +/** Converts an fpgen fingerprint to a Camoufox config. */ +export function fromFpgen( + fingerprint: Record, + ffVersion?: string | number | null, +): Config { + const camoufoxData: Config = {}; + castToProperties(camoufoxData, FPGEN_DATA, fingerprint, ffVersion); + handleScreenXY(camoufoxData, fingerprint); + return camoufoxData; +} + +/** Sets a custom outer window size and centers it in the screen (in place). */ +export function handleWindowSize( + fp: Record, + outerWidth: number, + outerHeight: number, +): void { + fp.screen ??= {}; + fp.window ??= {}; + const screen = fp.screen; + const window = fp.window; + + window.screenX = + (window.screenX || 0) + + Math.floor(((screen.width || outerWidth) - outerWidth) / 2); + window.screenY = Math.floor( + ((screen.height || outerHeight) - outerHeight) / 2, + ); + + if (pyTruthy(window.innerWidth)) { + window.innerWidth = Math.max( + outerWidth - (window.outerWidth || 0) + window.innerWidth, + 0, + ); + } + if (pyTruthy(window.innerHeight)) { + window.innerHeight = Math.max( + outerHeight - (window.outerHeight || 0) + window.innerHeight, + 0, + ); + } + window.outerWidth = outerWidth; + window.outerHeight = outerHeight; +} + +export interface GenerateFingerprintOptions { + /** Outer window size [width, height], applied after generation. */ + window?: readonly [number, number] | null; + /** A bound on the generated screen (best-effort, see below). */ + screen?: Screen | null; + /** Camoufox OS name(s): 'linux', 'macos', 'windows' (or lin/mac/win). */ + os?: string | readonly string[] | null; + /** Any other fpgen conditions. */ + [condition: string]: any; +} + +/** + * Generates a Firefox fingerprint with fpgen (the model must be installed: + * `await ensureModel()` from ./fpgen/index.js). + * + * `screen` bounds the generated screen; `window` overrides the outer window + * size afterwards; `os` is Camoufox's name for the platform; anything else is + * passed to fpgen as a condition. + */ +export function generateFingerprint({ + window, + screen, + os, + ...conditions +}: GenerateFingerprintOptions = {}): Record { + if (os?.length) { + const names = typeof os === "string" ? [os] : [...os]; + const resolved = names.map((n) => { + const v = FPGEN_OS[String(n).toLowerCase()]; + if (!v) throw new Error(`Unknown OS for fingerprint generation: '${n}'`); + return v; + }); + // fpgen takes one value or a predicate, not a list of alternatives. + // DIVERGENCE from Python: fpgen hands a predicate the CASEFOLDED value + // ("linux"), so Python's `lambda v: v in set(resolved)` compares it with + // "Linux" and never matches -- os=['linux', 'windows'] always raises + // InvalidConstraints there. The comparison here is casefolded, so a + // list of OSes works. + const allowed = new Set(resolved.map((r) => r.toLowerCase())); + conditions.os = + resolved.length === 1 + ? resolved[0] + : (v: unknown) => allowed.has(String(v).toLowerCase()); + } + const screenConditions = screen ? screen.asConditions() : {}; + let fingerprint: Record; + try { + fingerprint = generator().generate({ + browser: "Firefox", + ...conditions, + ...screenConditions, + }); + } catch (err) { + if ( + !Object.keys(screenConditions).length || + !(err instanceof InvalidConstraints) + ) + throw err; + // The screen bound is best-effort: a display the pool has nothing to fit + // must not stop a fingerprint being generated. clampScreenToDisplay() + // still bounds the result afterwards. + fingerprint = generator().generate({ browser: "Firefox", ...conditions }); + } + if (window) handleWindowSize(fingerprint, window[0], window[1]); + return fingerprint; +} diff --git a/typescript/src/fpgen/NOTICE b/typescript/src/fpgen/NOTICE new file mode 100644 index 000000000..1293aeb7f --- /dev/null +++ b/typescript/src/fpgen/NOTICE @@ -0,0 +1,42 @@ +Camoufox fpgen (TypeScript) +=========================== + +The files in this directory are a TypeScript port of fpgen 1.3.0 +("fingerprint-generator", https://github.com/scrapfly/fingerprint-generator), +the browser fingerprint generator by daijro and Scrapfly. + +fpgen is licensed under the Apache License, Version 2.0. This port is a +Derivative Work of it and is distributed under the same license: + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use these files except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +The upstream project ships no NOTICE file of its own. + +Changes made in the port (Apache-2.0 section 4(b)): + + - Translated from Python to TypeScript. The generator, Bayesian network + (beam-search trace), condition handling, query and trace follow the + Python line for line; see the header of each file for any deliberate + deviation. + - The model is NOT fetched the way fpgen fetches it (unverified TLS, no + checksum, first-listed GitHub release). It is downloaded from the release + pinned in Camoufox's scripts/data/fpgen-model.json with TLS verification + on, and its sha256 is checked before anything is extracted (model.ts). + - values.dat is decompressed once with Node's built-in zstd and cached on + disk, instead of being read through indexed_zstd. + - The deprecated get_shared_possibilities / get_distribution_for_node + helpers and the `python -m fpgen` CLI are not ported. + +The fingerprint model data itself (fingerprint-network.json, values.json, +values.dat) is not part of this directory; it is downloaded at run time from +the upstream release and remains under its upstream terms. diff --git a/typescript/src/fpgen/bayesian-network.ts b/typescript/src/fpgen/bayesian-network.ts new file mode 100644 index 000000000..d889cfeae --- /dev/null +++ b/typescript/src/fpgen/bayesian-network.ts @@ -0,0 +1,389 @@ +/** + * Ported from fpgen/bayesian_network.py (scrapfly/fingerprint-generator, + * Apache-2.0; see ./NOTICE). + * + * The inference is a line-for-line port: the same beam search, the same + * BEAM_WIDTH, the same multiplication order, the same stable top-k pruning. + * `trace()` therefore returns bit-identical probabilities to Python (the tests + * compare them). Only the draws differ, because Python uses `random.random()` + * and this uses `Math.random()`. + * + * Kept on purpose, because they change results: + * - probability tables are Maps in document order (see pyjson.ts); + * - validate_evidence passes a single *string* as the allowed values, and + * Python's `value in "abc"` is a substring test. `isAllowed` reproduces it, + * so the same condition sets are rejected in both languages. + * + * Not ported: get_distribution_for_node, get_shared_possibilities, + * _intersect_parents and collect_parents. Nothing in fpgen calls them (the + * docstring marks get_shared_possibilities deprecated since 1.3.0). + */ +import { RestrictiveConstraints } from "./exceptions.js"; +import { casefold } from "./pyjson.js"; + +/** Width for beam search. Cuts off values that are way too low or contaminated. */ +export const BEAM_WIDTH = 1000; + +/** A conditional probability table: parent value -> ... -> value -> p. */ +export type CPT = Map; +/** A distribution over value ids, in insertion order (Python dict order). */ +export type Distribution = Map; +/** Allowed value ids for a node. A bare string is Python's `str` (substring `in`). */ +export type Allowed = ReadonlySet | string; +export type Evidence = ReadonlyMap; + +/** Anything that can turn value ids into their stored JSON text. */ +export interface ValueLookup { + lookupValueList(indexList: Iterable): string[]; +} + +function isAllowed(allowed: Allowed, value: string): boolean { + return typeof allowed === "string" + ? allowed.includes(value) + : allowed.has(value); +} + +function sumValues(dist: Distribution): number { + let total = 0; + for (const p of dist.values()) total += p; + return total; +} + +/** A single node in the network, with its conditional probability table. */ +export class BayesianNode { + readonly name: string; + readonly parentNames: readonly string[]; + readonly possibleValues: readonly string[]; + readonly probabilities: CPT; + + constructor( + readonly nodeDefinition: Map, + readonly index: number, + ) { + this.name = nodeDefinition.get("name") as string; + this.parentNames = nodeDefinition.get("parentNames") as string[]; + this.possibleValues = nodeDefinition.get("possibleValues") as string[]; + this.probabilities = nodeDefinition.get("conditionalProbabilities") as CPT; + } + + /** This node's value probabilities given its parents' values. */ + getProbabilitiesGivenKnownValues( + parentValues: ReadonlyMap, + ): Distribution { + let probabilities: CPT = this.probabilities; + for (const parentName of this.parentNames) { + const parentValue = parentValues.get(parentName) as string; + const next = probabilities.get(parentValue); + probabilities = next instanceof Map ? next : new Map(); + } + return probabilities as Distribution; + } +} + +/** Map with casefolded string keys (structs.CaseInsensitiveDict). */ +export class CaseInsensitiveMap extends Map { + override get(key: string): V | undefined { + return super.get(casefold(key)); + } + override set(key: string, value: V): this { + return super.set(casefold(key), value); + } + override has(key: string): boolean { + return super.has(casefold(key)); + } + override delete(key: string): boolean { + return super.delete(casefold(key)); + } +} + +interface BeamEntry { + /** Assigned value ids, aligned with the trace's ordered node list. */ + readonly values: string[]; + readonly prob: number; +} + +export class BayesianNetwork { + readonly nodesInSamplingOrder: BayesianNode[]; + readonly nodesByName: CaseInsensitiveMap; + /** The original (cased) node names, in sampling order. */ + readonly nodeNames: readonly string[]; + readonly ancestorsByName = new Map>(); + + constructor( + networkDefinition: unknown, + readonly values: ValueLookup, + ) { + const nodes = (networkDefinition as Map).get( + "nodes", + ) as Map[]; + this.nodesInSamplingOrder = nodes.map( + (def, index) => new BayesianNode(def, index), + ); + // dict comprehension: a later duplicate name replaces the node but keeps + // the first one's position. + const byName = new Map(); + for (const node of this.nodesInSamplingOrder) byName.set(node.name, node); + this.nodesByName = new CaseInsensitiveMap(); + for (const [name, node] of byName) this.nodesByName.set(name, node); + this.nodeNames = [...byName.keys()]; + for (const node of this.nodesInSamplingOrder) { + this.getAllAncestors(node.name); + } + } + + /** Look up a node by (case-insensitive) name; KeyError -> Error. */ + node(name: string): BayesianNode { + const node = this.nodesByName.get(name); + if (!node) throw new Error(`KeyError: '${casefold(name)}'`); + return node; + } + + /** Generate a full sample from the network. */ + generateConsistentSample( + evidence: ReadonlyMap>, + ): Map | null { + const result = new Map(); + // A working copy of the evidence, updated in place. + const currentEvidence = new Map(); + for (const [k, v] of evidence) currentEvidence.set(k, new Set(v)); + + for (const node of this.nodesInSamplingOrder) { + const nodeName = node.name; + let sampledValue: string; + + const allowedValues = currentEvidence.get(nodeName); + if (allowedValues !== undefined) { + // Explicit evidence: leave the node itself out of the beam search. + const searchEvidence = new Map(currentEvidence); + searchEvidence.delete(nodeName); + const distribution = this.trace(nodeName, searchEvidence); + + // Filter the distribution to allowed values and renormalize. + let filtered: Distribution = new Map(); + for (const [k, v] of distribution) { + if (isAllowed(allowedValues, k)) filtered.set(k, v); + } + if (filtered.size === 0 || sumValues(filtered) <= 0) { + // Only ever a Set here: it came from build_evidence or from + // a sampled value. + const allowed = allowedValues as ReadonlySet; + const uniform = 1.0 / allowed.size; + filtered = new Map([...allowed].map((v) => [v, uniform])); + } else { + const total = sumValues(filtered); + for (const [k, v] of filtered) filtered.set(k, v / total); + } + sampledValue = this.sampleValueFromDistribution(filtered); + } else { + // Unconstrained node: use all current evidence. + const distribution = this.trace(nodeName, currentEvidence); + sampledValue = this.sampleValueFromDistribution(distribution); + } + + result.set(nodeName, sampledValue); + // Update current evidence with the newly sampled value. + currentEvidence.set(nodeName, new Set([sampledValue])); + } + return result; + } + + /** Generate values for target nodes given conditions. */ + generateCertainNodes( + evidence: ReadonlyMap>, + targets?: Iterable | null, + ): Map | null { + // If no target specified, generate full sample + if (targets == null) return this.generateConsistentSample(evidence); + + const result = new Map(); + for (const targetNode of targets) { + let distribution = this.trace(targetNode, evidence); + + // Handle multi-value conditions for the target + const allowedValues = evidence.get(targetNode); + if (allowedValues !== undefined) { + const filtered: Distribution = new Map(); + for (const [k, v] of distribution) { + if (allowedValues.has(k)) filtered.set(k, v); + } + if (filtered.size === 0 || sumValues(filtered) <= 0) { + throw new RestrictiveConstraints( + `Cannot generate fingerprint: No valid values for ${targetNode} with current conditions.`, + ); + } + const total = sumValues(filtered); + for (const [k, v] of filtered) filtered.set(k, v / total); + distribution = filtered; + } + + if (distribution.size) { + result.set(targetNode, this.sampleValueFromDistribution(distribution)); + } else { + throw new RestrictiveConstraints( + `Cannot generate fingerprint: Empty distribution for ${targetNode}.`, + ); + } + } + return result; + } + + /** + * Validate that the evidence is mutually compatible given the network + * structure. Throws RestrictiveConstraints when it isn't. + */ + validateEvidence(evidence: ReadonlyMap>): void { + // Skip validation for single constraint + if (evidence.size <= 1) return; + + for (const [nodeName, allowedValues] of evidence) { + // The other conditions pinned to a single value. + const fixed = new Map(); + for (const [k, v] of evidence) { + if (k !== nodeName && v.size === 1) fixed.set(k, [...v][0]); + } + if (!fixed.size) continue; + + const dist = this.trace(nodeName, fixed); + if ( + dist.size && + [...allowedValues].every((val) => (dist.get(val) ?? 0) <= 0) + ) { + const allowed = [...allowedValues]; + let valuesStr = this.values + .lookupValueList(allowed.slice(0, 5)) + .join(", "); + if (allowed.length > 5) valuesStr += ", ..."; + const constraintValues = this.values.lookupValueList(fixed.values()); + const constraintsStr = [...fixed.keys()] + .map((k, i) => `${k}=${constraintValues[i]}`) + .join(", "); + throw new RestrictiveConstraints( + `Cannot generate fingerprint: ${nodeName}=(${valuesStr}) ` + + `is impossible with constraint: ${constraintsStr}`, + ); + } + } + } + + /** All ancestors of a node (the nodes that can influence its value). */ + getAllAncestors(nodeName: string): Set { + const cached = this.ancestorsByName.get(nodeName); + if (cached) return cached; + + const node = this.node(nodeName); + const ancestors = new Set(); + for (const parent of node.parentNames) { + ancestors.add(parent); + for (const a of this.getAllAncestors(parent)) ancestors.add(a); + } + this.ancestorsByName.set(nodeName, ancestors); + return ancestors; + } + + /** + * The conditional distribution of `target` given `evidence`, by beam + * search. Empty when the evidence admits nothing. + */ + trace(target: string, evidence: Evidence): Distribution { + // The actual target name, and the nodes that matter for it. + const targetName = this.node(target).name; + const relevant = new Set(this.getAllAncestors(targetName)); + relevant.add(targetName); + for (const evNode of evidence.keys()) { + if (this.nodesByName.has(evNode)) { + relevant.add(evNode); + for (const a of this.getAllAncestors(evNode)) relevant.add(a); + } + } + + // Relevant nodes in sampling order, with each parent's slot. + const ordered = this.nodesInSamplingOrder.filter((n) => + relevant.has(n.name), + ); + const slot = new Map(); + for (const [i, n] of ordered.entries()) slot.set(n.name, i); + + let beam: BeamEntry[] = [{ values: [], prob: 1.0 }]; + + for (let i = 0; i < ordered.length; i++) { + const node = ordered[i]; + const nodeName = node.name; + const allowedValues = evidence.get(nodeName); + const parentSlots = node.parentNames.map((p) => { + const s = slot.get(p); + if (s === undefined) throw new Error(`KeyError: '${p}'`); + return s; + }); + const newBeam: BeamEntry[] = []; + // Python keys its cache by (node, parent values); one per node here. + const cptCache = new Map(); + + for (const { values, prob } of beam) { + const parentValues = parentSlots.map((s) => values[s]); + const cacheKey = parentValues.join("\u0000"); + let cpt = cptCache.get(cacheKey); + if (cpt === undefined) { + const known = new Map(); + node.parentNames.forEach((p, j) => { + known.set(p, parentValues[j]); + }); + cpt = node.getProbabilitiesGivenKnownValues(known); + // Use a uniform distribution if the table has no row. + if (!cpt.size && node.possibleValues.length) { + const uniform = 1.0 / node.possibleValues.length; + cpt = new Map(node.possibleValues.map((v) => [v, uniform])); + } + cptCache.set(cacheKey, cpt); + } + + // Expand the beam with new assignments + for (const [value, p] of cpt) { + if ( + (allowedValues === undefined || isAllowed(allowedValues, value)) && + p > 0 + ) { + const next = values.slice(); + next.push(value); + newBeam.push({ values: next, prob: prob * p }); + } + } + } + + if (!newBeam.length) return new Map(); + if (newBeam.length > BEAM_WIDTH) { + // heapq.nlargest(k, ..., key=prob) == sorted(reverse=True)[:k], + // which keeps equal-probability entries in their original order. + // Array.prototype.sort is stable, so this is the same list. + newBeam.sort((a, b) => b.prob - a.prob); + newBeam.length = BEAM_WIDTH; + } + beam = newBeam; + } + + // Extract the target distribution + const targetSlot = slot.get(targetName) as number; + const targetDist: Distribution = new Map(); + let totalProb = 0.0; + for (const { values, prob } of beam) { + const value = values[targetSlot]; + targetDist.set(value, (targetDist.get(value) ?? 0) + prob); + totalProb += prob; + } + if (totalProb > 0) { + for (const [v, p] of targetDist) targetDist.set(v, p / totalProb); + return targetDist; + } + return new Map(); + } + + /** Draw a value from a distribution (cumulative, falls back to the first). */ + sampleValueFromDistribution(distribution: Distribution): string { + const anchor = Math.random(); + let cumulative = 0.0; + for (const [value, probability] of distribution) { + cumulative += probability; + if (anchor < cumulative) return value; + } + return distribution.keys().next().value as string; + } +} diff --git a/typescript/src/fpgen/exceptions.ts b/typescript/src/fpgen/exceptions.ts new file mode 100644 index 000000000..692680615 --- /dev/null +++ b/typescript/src/fpgen/exceptions.ts @@ -0,0 +1,80 @@ +/** + * Ported from fpgen/exceptions.py (scrapfly/fingerprint-generator, Apache-2.0). + * See ./NOTICE. + * + * The hierarchy and the class names are kept: the Python launcher tells a + * constraint that matched nothing apart from one that was too restrictive by + * `type(exc).__name__`, so `name` is set to the exact Python class name. + * Python's builtin ValueError has no JS twin; it becomes a plain Error subclass + * with the same name, and NetworkError still derives from it. + */ + +export class ValueError extends Error { + constructor(message?: string) { + super(message); + this.name = "ValueError"; + } +} + +/** Error with the network. */ +export class NetworkError extends ValueError { + constructor(message?: string) { + super(message); + this.name = "NetworkError"; + } +} + +/** Raised when a constraint isn't possible. */ +export class InvalidConstraints extends NetworkError { + constructor(message?: string) { + super(message); + this.name = "InvalidConstraints"; + } +} + +/** Raised when the passed constraints are too restrictive. */ +export class RestrictiveConstraints extends InvalidConstraints { + constructor(message?: string) { + super(message); + this.name = "RestrictiveConstraints"; + } +} + +/** Raised when a node doesn't exist. */ +export class InvalidNode extends NetworkError { + constructor(message?: string) { + super(message); + this.name = "InvalidNode"; + } +} + +/** Raised when a key path doesn't exist. The message is the missing key. */ +export class NodePathError extends InvalidNode { + constructor(message?: string) { + super(message); + this.name = "NodePathError"; + } +} + +/** + * The pinned model is not on disk. Python fpgen downloads at import time; the + * TypeScript API is synchronous, so the download is a separate, awaited step + * (`ensureModel()`), and the synchronous calls throw this until it has run. + */ +export class ModelNotInstalled extends Error { + constructor(message?: string) { + super( + message ?? + "The fpgen model is not installed. Call `await ensureModel()` first.", + ); + this.name = "ModelNotInstalled"; + } +} + +/** The downloaded model failed its size / sha256 check, or is malformed. */ +export class ModelVerificationError extends Error { + constructor(message?: string) { + super(message); + this.name = "ModelVerificationError"; + } +} diff --git a/typescript/src/fpgen/generator.ts b/typescript/src/fpgen/generator.ts new file mode 100644 index 000000000..487d056ad --- /dev/null +++ b/typescript/src/fpgen/generator.ts @@ -0,0 +1,192 @@ +/** + * Ported from fpgen/generator.py (scrapfly/fingerprint-generator, Apache-2.0; + * see ./NOTICE). + * + * Python keyword arguments become two objects: the conditions, then the + * options (`strict`, `flatten`, `target`). `Generator(conditions, options)`, + * `generator.generate(conditions, options)`, and so on. + * + * The model must be on disk before any of this runs: `await ensureModel()`. + */ +import { RestrictiveConstraints } from "./exceptions.js"; +import { getModel } from "./model.js"; +import { + type TraceOptions, + type TraceResult, + type TraceResultDict, + traceWithEvidence, +} from "./trace.js"; +import { + assertConditions, + buildEvidence, + type Conditions, + type EvidenceMap, + findRoots, + makeOutputDict, + maybeFlatten, + reassembleTargets, +} from "./utils.js"; + +export interface GeneratorOptions { + /** Throw when the conditions are too strict (default true). */ + strict?: boolean; + /** Flatten the output object into dotted keys (default false). */ + flatten?: boolean; +} + +export interface GenerateOptions { + /** Overrides the Generator's `strict`. */ + strict?: boolean; + /** Overrides the Generator's `flatten`. */ + flatten?: boolean; + /** Only generate specific value(s): a node, a path inside one, or a prefix. */ + target?: string | readonly string[]; +} + +/** A generated fingerprint (nested unless `flatten`). */ +export type Fingerprint = Record; + +function hasConditions(conditions: Conditions | null | undefined): boolean { + return !!conditions && Object.keys(conditions).length > 0; +} + +/** Generates realistic browser fingerprints. */ +export class Generator { + strict: boolean; + flatten: boolean; + readonly evidence: EvidenceMap = new Map(); + + /** + * Conditions and options given here are inherited by every generate(). + */ + constructor( + conditions?: Conditions | null, + { strict = true, flatten = false }: GeneratorOptions = {}, + ) { + assertConditions(conditions); + this.strict = strict; + this.flatten = flatten; + if (hasConditions(conditions)) { + // fpgen passes no `strict` here, so construction is always strict. + buildEvidence(conditions as Conditions, this.evidence); + } + } + + /** Generate a fingerprint, or just `target` when given. */ + generate( + conditions: Conditions | null | undefined, + options: GenerateOptions & { target: string }, + ): any; + generate( + conditions?: Conditions | null, + options?: GenerateOptions, + ): Fingerprint; + generate(conditions?: Conditions | null, options: GenerateOptions = {}): any { + assertConditions(conditions); + const { network } = getModel(); + + const strict = options.strict ?? this.strict; + const flatten = options.flatten ?? this.flatten; + const { target } = options; + + // Inherit the evidence from the instance + const evidence: EvidenceMap = new Map(this.evidence); + if (hasConditions(conditions)) { + buildEvidence(conditions as Conditions, evidence, strict); + } + + const targetTup = + target === undefined + ? null + : typeof target === "string" + ? [target] + : [...target]; + const hasTarget = !!target && (targetTup as string[]).length > 0; + const targetRoots = hasTarget + ? new Set(findRoots(targetTup as string[])) + : null; + + let fingerprint: Map | null; + while (true) { + fingerprint = targetRoots?.size + ? network.generateCertainNodes(evidence, targetRoots) + : network.generateConsistentSample(evidence); + if (fingerprint !== null) break; + if (strict) { + throw new RestrictiveConstraints( + "Cannot generate fingerprint. Constraints are too restrictive.", + ); + } + // Relax the evidence until something can be generated + evidence.delete(evidence.keys().next().value as string); + } + + if (hasTarget) { + // Don't flatten yet + let output: any = reassembleTargets( + targetTup as string[], + makeOutputDict(fingerprint, false), + ); + if (typeof target === "string") output = output[target]; + return maybeFlatten(flatten, output); + } + return makeOutputDict(fingerprint, flatten); + } + + /** Generate a specific target. Shortcut for `generate(..., {target})`. */ + generateTarget( + target: string, + conditions?: Conditions | null, + options: Omit = {}, + ): any { + return this.generate(conditions, { ...options, target }); + } + + /** + * The probability distribution(s) of a target given conditions, on top of + * this Generator's conditions. + */ + trace( + target: string | readonly string[], + conditions?: Conditions | null, + options: TraceOptions = {}, + ): TraceResult[] | TraceResultDict { + return traceWithEvidence( + target, + conditions, + options, + new Map(this.evidence), + ); + } +} + +/* + * A global generate() for callers that don't build a Generator. + */ +let GLOBAL_GENERATOR: Generator | null = null; + +/** Generate a fingerprint with a shared, condition-less Generator. */ +export function generate( + conditions: Conditions | null | undefined, + options: GenerateOptions & { target: string }, +): any; +export function generate( + conditions?: Conditions | null, + options?: GenerateOptions, +): Fingerprint; +export function generate( + conditions?: Conditions | null, + options?: GenerateOptions, +): any { + GLOBAL_GENERATOR ??= new Generator(); + return GLOBAL_GENERATOR.generate(conditions, options); +} + +/** Generate a specific target. Shortcut for `generate(..., {target})`. */ +export function generateTarget( + target: string, + conditions?: Conditions | null, + options: Omit = {}, +): any { + return generate(conditions, { ...options, target }); +} diff --git a/typescript/src/fpgen/index.ts b/typescript/src/fpgen/index.ts new file mode 100644 index 000000000..25dc9dc41 --- /dev/null +++ b/typescript/src/fpgen/index.ts @@ -0,0 +1,58 @@ +/** + * TypeScript port of fpgen 1.3.0 (scrapfly/fingerprint-generator), the + * fingerprint generator the Python launcher uses. Apache-2.0; see ./NOTICE. + * + * import { ensureModel, Generator } from "./fpgen/index.js"; + * await ensureModel(); // pinned model, sha256-checked + * const fp = new Generator().generate({ browser: "Firefox", os: "Windows" }); + * + * Everything after ensureModel() is synchronous, like the Python API. + */ +export { + BayesianNetwork, + BayesianNode, + BEAM_WIDTH, + type Distribution, +} from "./bayesian-network.js"; +export { + InvalidConstraints, + InvalidNode, + ModelNotInstalled, + ModelVerificationError, + NetworkError, + NodePathError, + RestrictiveConstraints, + ValueError, +} from "./exceptions.js"; +export { + type Fingerprint, + type GenerateOptions, + Generator, + type GeneratorOptions, + generate, + generateTarget, +} from "./generator.js"; +export { + downloadArchive, + type EnsureModelOptions, + ensureModel, + FpgenModel, + getModel, + installArchive, + isModelInstalled, + modelDir, + resetModelCache, + verifyArchive, +} from "./model.js"; +export { MODEL_PIN, type ModelPin } from "./pin.js"; +export { + type TraceOptions, + TraceResult, + type TraceResultDict, + trace, +} from "./trace.js"; +export { + type Conditions, + type Predicate, + query, +} from "./utils.js"; diff --git a/typescript/src/fpgen/model.ts b/typescript/src/fpgen/model.ts new file mode 100644 index 000000000..04a9734b1 --- /dev/null +++ b/typescript/src/fpgen/model.ts @@ -0,0 +1,423 @@ +/** + * Model files for the fpgen port: where they live, how they get there, and the + * loaded model. Replaces fpgen/pkgman.py and the file half of fpgen/unpacker.py + * (scrapfly/fingerprint-generator, Apache-2.0; see ./NOTICE). + * + * Deliberately NOT like fpgen: + * - the archive comes from the release pinned in MODEL_PIN (the twin of + * scripts/data/fpgen-model.json), never "the first release the API lists"; + * - TLS is verified (Node's fetch always does), and the archive's size and + * sha256 are checked before a byte of it is extracted; + * - only the three expected members are written, by name, so a hostile + * archive cannot place files outside the data directory; + * - there is no five-week re-download: the model changes when the pin does. + * + * Layout of the data directory (compatible with scripts/pin-fpgen-model.py, so + * CAMOUFOX_FPGEN_DATA may point at a pinned Python fpgen `data/` directory): + * + * fingerprint-network.json.zst + * values.json.zst + * values.dat.zst + * values.dat decompressed once on first load (~210 MB), then read + * by offset -- fpgen does the same when values.dat exists + * .pinned-model sha256 of the verified archive; written last + */ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { pipeline } from "node:stream/promises"; +import * as zlib from "node:zlib"; +import AdmZip from "adm-zip"; +import { INSTALL_DIR } from "../paths.js"; +import { BayesianNetwork } from "./bayesian-network.js"; +import { ModelNotInstalled, ModelVerificationError } from "./exceptions.js"; +import { MODEL_PIN, type ModelPin } from "./pin.js"; +import { base85ToInt, parseOrdered } from "./pyjson.js"; + +export const STAMP_FILE = ".pinned-model"; +export const NETWORK_ZST = "fingerprint-network.json.zst"; +export const VALUES_JSON_ZST = "values.json.zst"; +export const VALUES_DAT_ZST = "values.dat.zst"; +export const VALUES_DAT = "values.dat"; + +/** + * The model directory: $CAMOUFOX_FPGEN_DATA when set, otherwise `fpgen/` in + * the Camoufox cache (next to the browsers, addons and fontconfig cache). + */ +export function modelDir(): string { + const override = process.env.CAMOUFOX_FPGEN_DATA; + if (override) return path.resolve(override); + return path.join(INSTALL_DIR, "fpgen"); +} + +/** True when `dir` holds the pinned model (stamp matches, members present). */ +export function isModelInstalled( + dir: string = modelDir(), + pin: ModelPin = MODEL_PIN, +): boolean { + let stamp: string; + try { + stamp = fs.readFileSync(path.join(dir, STAMP_FILE), "utf-8").trim(); + } catch { + return false; + } + if (stamp !== pin.sha256) return false; + return pin.files.every((f) => fs.existsSync(path.join(dir, f))); +} + +export interface EnsureModelOptions { + /** Directory to install into / load from. Defaults to `modelDir()`. */ + dir?: string; + /** Re-download even when the pinned model is already present. */ + force?: boolean; + /** Override the fetch implementation (tests). */ + fetchImpl?: typeof fetch; +} + +const inflight = new Map>(); + +export const LOCK_DIR = ".install.lock"; +/** A lock older than this was left by a process that died holding it. */ +export const STALE_LOCK_MS = 10 * 60 * 1000; + +/** + * Run `fn` holding `dir`'s install lock, across processes. `inflight` only + * dedupes within one process; several processes installing into an empty cache + * at once (a worker pool on a fresh machine, or vitest's parallel files) would + * otherwise each download the model, and one's install deleted the values.dat + * another had just decompressed and was about to read. mkdir is atomic on every + * platform, so the lock is a directory. + */ +export async function withInstallLock( + dir: string, + fn: () => Promise, +): Promise { + fs.mkdirSync(dir, { recursive: true }); + const lock = path.join(dir, LOCK_DIR); + for (;;) { + try { + fs.mkdirSync(lock); + break; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; + try { + if (Date.now() - fs.statSync(lock).mtimeMs > STALE_LOCK_MS) { + fs.rmSync(lock, { recursive: true, force: true }); + continue; + } + } catch { + continue; // released between the mkdir and the stat + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + try { + return await fn(); + } finally { + fs.rmSync(lock, { recursive: true, force: true }); + } +} + +/** + * Make sure the pinned model is installed and values.dat is decompressed. + * Downloads (TLS on, sha256-checked) only when it is missing. Returns the + * model directory. Safe to call repeatedly and concurrently. + */ +export function ensureModel(options: EnsureModelOptions = {}): Promise { + const dir = path.resolve(options.dir ?? modelDir()); + const running = inflight.get(dir); + if (running && !options.force) return running; + const job = (async () => { + // The unlocked check is the fast path for an installed model; the + // locked one decides, since another process may have just installed it. + if (!options.force && isModelInstalled(dir) && datIsReady(dir)) return dir; + await withInstallLock(dir, async () => { + if (options.force || !isModelInstalled(dir)) { + const archive = await downloadArchive(MODEL_PIN, options.fetchImpl); + installArchive(archive, dir, MODEL_PIN); + } + await decompressValuesDat(dir); + }); + return dir; + })(); + inflight.set(dir, job); + job.catch(() => inflight.delete(dir)); + return job; +} + +/** Download the pinned archive and verify its size and sha256. */ +export async function downloadArchive( + pin: ModelPin = MODEL_PIN, + fetchImpl: typeof fetch = fetch, + retries = 3, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < retries; attempt++) { + let response: Response; + try { + response = await fetchImpl(pin.url, { + headers: { "User-Agent": "camoufox-js" }, + redirect: "follow", + signal: AbortSignal.timeout(120_000), + }); + } catch (e) { + lastError = e; + continue; + } + if (!response.ok) { + lastError = new Error( + `fpgen model download failed: HTTP ${response.status} for ${pin.url}`, + ); + // 4xx will not get better by retrying. + if (response.status >= 400 && response.status < 500) break; + continue; + } + const buf = Buffer.from(await response.arrayBuffer()); + verifyArchive(buf, pin); + return buf; + } + throw lastError instanceof Error + ? lastError + : new Error(`fpgen model download failed: ${String(lastError)}`); +} + +/** Throws ModelVerificationError unless `buf` is exactly the pinned archive. */ +export function verifyArchive(buf: Buffer, pin: ModelPin = MODEL_PIN): void { + if (pin.size && buf.length !== pin.size) { + throw new ModelVerificationError( + `fpgen model size mismatch: got ${buf.length}, expected ${pin.size}`, + ); + } + const got = createHash("sha256").update(buf).digest("hex"); + if (got !== pin.sha256) { + throw new ModelVerificationError( + `fpgen model sha256 mismatch:\n got ${got}\n expected ${pin.sha256}`, + ); + } +} + +/** Write the pinned members of a verified archive into `dir`, stamp last. */ +export function installArchive( + archive: Buffer, + dir: string, + pin: ModelPin = MODEL_PIN, +): void { + verifyArchive(archive, pin); + fs.mkdirSync(dir, { recursive: true }); + let previous = ""; + try { + previous = fs.readFileSync(path.join(dir, STAMP_FILE), "utf-8").trim(); + } catch {} + // An interrupted install must not leave a stamp over a partial model. + fs.rmSync(path.join(dir, STAMP_FILE), { force: true }); + // A values.dat decompressed from a DIFFERENT model would be trusted (its + // size is all that is checked), so drop it -- but only then: reinstalling + // the same model must not pull the file out from under a process reading it. + if (previous !== pin.sha256) { + fs.rmSync(path.join(dir, VALUES_DAT), { force: true }); + } + const zip = new AdmZip(archive); + for (const name of pin.files) { + if (path.basename(name) !== name) { + throw new ModelVerificationError( + `unexpected member path in pin: ${name}`, + ); + } + const entry = zip.getEntry(name); + if (!entry || entry.isDirectory) { + throw new ModelVerificationError( + `fpgen model archive is missing ${name}`, + ); + } + atomicWrite(path.join(dir, name), entry.getData()); + } + fs.writeFileSync(path.join(dir, STAMP_FILE), `${pin.sha256}\n`); +} + +function atomicWrite(target: string, data: Uint8Array): void { + const tmp = `${target}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmp, data); + fs.renameSync(tmp, target); +} + +/** Byte length values.dat must have: the end of the furthest slice. */ +function expectedDatSize(valuePairs: readonly ValuePair[]): number { + let end = 0; + for (const [offset, length] of valuePairs) { + if (offset + length > end) end = offset + length; + } + return end; +} + +function datIsReady(dir: string): boolean { + try { + return datIsComplete(path.join(dir, VALUES_DAT), readValuePairs(dir)); + } catch { + return false; + } +} + +function datIsComplete( + datPath: string, + valuePairs: readonly ValuePair[], +): boolean { + try { + return fs.statSync(datPath).size === expectedDatSize(valuePairs); + } catch { + return false; + } +} + +/** Stream-decompress values.dat.zst -> values.dat if it isn't there yet. */ +async function decompressValuesDat(dir: string): Promise { + const datPath = path.join(dir, VALUES_DAT); + const pairs = readValuePairs(dir); + if (datIsComplete(datPath, pairs)) return; + const tmp = `${datPath}.${process.pid}.${Date.now()}.tmp`; + try { + await pipeline( + fs.createReadStream(path.join(dir, VALUES_DAT_ZST)), + zlib.createZstdDecompress(), + fs.createWriteStream(tmp), + ); + fs.renameSync(tmp, datPath); + } finally { + fs.rmSync(tmp, { force: true }); + } + if (!datIsComplete(datPath, pairs)) { + throw new ModelVerificationError( + `decompressed ${datPath} does not match values.json`, + ); + } +} + +function decompressValuesDatSync( + dir: string, + pairs: readonly ValuePair[], +): void { + const datPath = path.join(dir, VALUES_DAT); + if (datIsComplete(datPath, pairs)) return; + const data = zlib.zstdDecompressSync( + fs.readFileSync(path.join(dir, VALUES_DAT_ZST)), + ); + atomicWrite(datPath, data); + if (!datIsComplete(datPath, pairs)) { + throw new ModelVerificationError( + `decompressed ${datPath} does not match values.json`, + ); + } +} + +function readZstJson(file: string): unknown { + return parseOrdered( + zlib.zstdDecompressSync(fs.readFileSync(file)).toString("utf-8"), + ); +} + +/** [byte offset, byte length] of each value in values.dat, by value id. */ +export type ValuePair = readonly [offset: number, length: number]; + +function readValuePairs(dir: string): ValuePair[] { + const json = readZstJson(path.join(dir, VALUES_JSON_ZST)); + if (!(json instanceof Map)) { + throw new ModelVerificationError("values.json is not an object"); + } + // VALUE_PAIRS = list(values_json.items()) -- document order is the id. + const pairs: ValuePair[] = []; + for (const [hexOffset, length] of json as Map) { + pairs.push([Number.parseInt(hexOffset, 16), length]); + } + return pairs; +} + +/** + * The loaded model: the Bayesian network plus the value store it indexes into. + * One per data directory, loaded on first use (fpgen loads it at import). + */ +export class FpgenModel { + readonly network: BayesianNetwork; + readonly valuePairs: readonly ValuePair[]; + readonly datPath: string; + /** Held open for the model's lifetime: one open per lookup was a syscall + * per call, and a path reopened each time fails if the file is replaced. */ + private fd: number | null = null; + + constructor(readonly dir: string) { + this.valuePairs = readValuePairs(dir); + this.datPath = path.join(dir, VALUES_DAT); + decompressValuesDatSync(dir, this.valuePairs); + this.network = new BayesianNetwork( + readZstJson(path.join(dir, NETWORK_ZST)), + this, + ); + } + + /** unpacker.lookup_value: the raw JSON text stored for a value id. */ + lookupValue(index: string): string { + return this.lookupValueList([index])[0]; + } + + /** + * unpacker.lookup_value_list: raw JSON text for each id, in input order. + * Reads in ascending offset order, as fpgen does. + */ + lookupValueList(indexList: Iterable): string[] { + const ids = [...indexList]; + const out = new Array(ids.length); + const sorted = ids + .map((id, n) => [base85ToInt(id), n] as const) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]); + if (this.fd === null) this.fd = fs.openSync(this.datPath, "r"); + const fd = this.fd; + for (const [index, n] of sorted) { + const pair = this.valuePairs[index]; + if (pair === undefined) { + throw new RangeError(`list index out of range: value id ${index}`); + } + const [offset, length] = pair; + const buf = Buffer.allocUnsafe(length); + let read = 0; + while (read < length) { + const got = fs.readSync(fd, buf, read, length - read, offset + read); + if (got === 0) break; + read += got; + } + out[n] = buf.toString("utf-8", 0, read); + } + return out; + } + + /** Release the values.dat handle. The model reopens it on next use. */ + close(): void { + if (this.fd !== null) fs.closeSync(this.fd); + this.fd = null; + } +} + +const loaded = new Map(); + +/** + * The model for `dir` (default `modelDir()`), loading it on first call. + * Synchronous: throws ModelNotInstalled when the pinned model is not on disk -- + * `await ensureModel()` first. + */ +export function getModel(dir: string = modelDir()): FpgenModel { + const key = path.resolve(dir); + let model = loaded.get(key); + if (model) return model; + if (!isModelInstalled(key)) { + throw new ModelNotInstalled( + `The fpgen model (${MODEL_PIN.tag}) is not installed in ${key}. ` + + "Call `await ensureModel()` (camoufox/fpgen) before generating fingerprints.", + ); + } + model = new FpgenModel(key); + loaded.set(key, model); + return model; +} + +/** Drop loaded models (tests, or after changing CAMOUFOX_FPGEN_DATA). */ +export function resetModelCache(): void { + for (const model of loaded.values()) model.close(); + loaded.clear(); +} diff --git a/typescript/src/fpgen/pin.ts b/typescript/src/fpgen/pin.ts new file mode 100644 index 000000000..db31d4b49 --- /dev/null +++ b/typescript/src/fpgen/pin.ts @@ -0,0 +1,32 @@ +/** + * The pinned fpgen model: a copy of scripts/data/fpgen-model.json. + * + * That file is the single source of truth for the Python build; this constant + * is its twin for the npm package, which does not ship the repo's scripts/. + * tests/fpgen-model.test.ts fails if the two ever disagree, so bumping the + * model means editing both (the sha256 is the gate in each). + * + * See scripts/pin-fpgen-model.py for why fpgen's own downloader is not used: + * it disables TLS verification, never checks a digest, and its "first listed + * release" rule cannot reach this tag. + */ + +export interface ModelPin { + readonly tag: string; + readonly asset: string; + readonly size: number; + readonly sha256: string; + readonly url: string; + readonly repo: string; + readonly files: readonly string[]; +} + +export const MODEL_PIN: ModelPin = { + tag: "model-2/2026", + asset: "model-release.zip", + size: 1564571, + sha256: "6530b8322cdaa4ec042921c8d9a0369a0e6e0269ba636c01a7203e4a2f109936", + url: "https://github.com/scrapfly/fingerprint-generator/releases/download/model-2/2026/model-release.zip", + repo: "scrapfly/fingerprint-generator", + files: ["fingerprint-network.json.zst", "values.dat.zst", "values.json.zst"], +}; diff --git a/typescript/src/fpgen/pyjson.ts b/typescript/src/fpgen/pyjson.ts new file mode 100644 index 000000000..97174ba8c --- /dev/null +++ b/typescript/src/fpgen/pyjson.ts @@ -0,0 +1,240 @@ +/** + * Python-semantics helpers for the fpgen port (scrapfly/fingerprint-generator, + * Apache-2.0; see ./NOTICE). + * + * fpgen leans on three Python behaviours JavaScript does not share, and each + * one changes results if it is dropped: + * + * 1. dict order. Every probability table is a dict keyed by base85 value ids, + * and 5.5k of those ids ("0", "12", ...) look like array indices, which a JS + * object silently moves to the front. The order decides which value a + * cumulative draw lands on and how the beam breaks ties, so the network is + * parsed into Maps (`parseOrdered`). + * 2. int vs float. `query()` groups values by Python type and sorts each group, + * so 1.0 and 1 land in different places. `parsePyTyped` keeps floats + * wrapped as PyFloat until the grouping is done. + * 3. `==`. Nested conditions compare parsed values with Python equality, where + * 1 == 1.0 == True and dicts ignore key order (`pyEquals`). + */ + +import { ValueError } from "./exceptions.js"; + +/** Marks a Python float through `query()`'s type grouping. */ +export class PyFloat { + constructor(readonly value: number) {} +} + +const KEY_TOKEN = /"(?:[^"\\]|\\.)*"(\s*:)?/g; + +/** + * JSON.parse, but every object becomes a Map whose iteration order is the + * document order (the order Python's dict would have). + */ +export function parseOrdered(text: string): unknown { + // Prefix every object key with U+0001 (written as its JSON escape: a raw + // control character is not valid inside a JSON string) so none is integer-like, which makes + // the native parser keep insertion order; the reviver strips it again. The + // regex consumes whole string tokens, so a quote or colon inside a string + // can never be mistaken for a key. + const marked = text.replace(KEY_TOKEN, (tok, colon: string | undefined) => + colon === undefined ? tok : `"\\u0001${tok.slice(1)}`, + ); + return JSON.parse(marked, (_key, value) => { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return value; + } + const map = new Map(); + for (const k of Object.keys(value)) { + map.set(k.slice(1), value[k]); + } + return map; + }); +} + +const TYPED_TOKEN = + /"(?:[^"\\]|\\.)*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g; +const FLOAT_MARK = "\u0001pyfloat"; +const FLOAT_MARK_JSON = "\\u0001pyfloat"; + +/** + * JSON.parse that keeps Python's int/float distinction: a number token written + * with a fraction or exponent (what Python's json/orjson emit for a float) comes + * back as a PyFloat. Use `unwrapPy` to get plain values back. + */ +export function parsePyTyped(text: string): unknown { + const marked = text.replace(TYPED_TOKEN, (tok) => + tok[0] === '"' || !/[.eE]/.test(tok) + ? tok + : `{"${FLOAT_MARK_JSON}":${tok}}`, + ); + return JSON.parse(marked, (_key, value) => { + if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + FLOAT_MARK in value + ) { + return new PyFloat(value[FLOAT_MARK]); + } + return value; + }); +} + +/** Replace every PyFloat in a structure with its number. */ +export function unwrapPy(value: unknown): unknown { + if (value instanceof PyFloat) return value.value; + if (Array.isArray(value)) return value.map(unwrapPy); + if (isPlainObject(value)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = unwrapPy(v); + return out; + } + return value; +} + +export function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object") return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function pyNumber(value: unknown): number | undefined { + if (typeof value === "number") return value; + if (typeof value === "boolean") return value ? 1 : 0; + if (value instanceof PyFloat) return value.value; + return undefined; +} + +/** Python `==` over JSON-shaped values (1 == 1.0 == True, dicts unordered). */ +export function pyEquals(a: unknown, b: unknown): boolean { + const na = pyNumber(a); + const nb = pyNumber(b); + if (na !== undefined || nb !== undefined) return na === nb; + if (a === null || b === null) return a === b; + if (typeof a === "string" || typeof b === "string") return a === b; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + return a.every((v, i) => pyEquals(v, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const ka = Object.keys(a); + if (ka.length !== Object.keys(b).length) return false; + return ka.every((k) => Object.hasOwn(b, k) && pyEquals(a[k], b[k])); + } + return a === b; +} + +/** + * Python's str.casefold(). toLowerCase() agrees with it everywhere except the + * handful of characters whose full case folding expands or differs; those are + * mapped explicitly. (Every value in the model is ASCII, so this only matters + * for what a caller types.) + */ +export function casefold(s: string): string { + const lower = s.toLowerCase(); + // biome-ignore lint/suspicious/noControlCharactersInRegex: ASCII fast path + if (/^[\x00-\x7f]*$/.test(lower)) return lower; + return lower + .replace(/รŸ|แบž/g, "ss") + .replace(/ฯ‚/g, "ฯƒ") + .replace(/ลฟ/g, "s") + .replace(/๏ฌ€/g, "ff") + .replace(/๏ฌ/g, "fi") + .replace(/๏ฌ‚/g, "fl") + .replace(/๏ฌƒ/g, "ffi") + .replace(/๏ฌ„/g, "ffl") + .replace(/๏ฌ…|๏ฌ†/g, "st"); +} + +/** + * orjson.dumps(value).decode() for a condition value. JSON.stringify writes + * the same compact form; the one thing it cannot write is `1.0`, because JS + * has no separate float (build_evidence compensates, see utils.ts). + */ +export function pyDumps(value: unknown): string { + if (typeof value === "function") { + // orjson.JSONDecodeError is a TypeError subclass. + throw new TypeError("Type is not JSON serializable: function"); + } + const out = JSON.stringify(value); + if (out === undefined) { + throw new TypeError(`Type is not JSON serializable: ${typeof value}`); + } + return out; +} + +const B85_ALPHABET = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"; +const B85_DECODE = new Map( + [...B85_ALPHABET].map((c, i) => [c, i]), +); + +/** int.from_bytes(base64.b85decode(s), 'big') -- unpacker.base85_to_int. */ +export function base85ToInt(s: string): number { + const padding = (5 - (s.length % 5)) % 5; + const padded = s + "~".repeat(padding); + const bytes: number[] = []; + for (let i = 0; i < padded.length; i += 5) { + let acc = 0; + for (let j = i; j < i + 5; j++) { + const d = B85_DECODE.get(padded[j]); + if (d === undefined) { + throw new ValueError(`bad base85 character at position ${j}`); + } + acc = acc * 85 + d; + } + if (acc > 0xffffffff) { + throw new ValueError(`base85 overflow in hunk starting at byte ${i}`); + } + bytes.push( + (acc >>> 24) & 0xff, + (acc >>> 16) & 0xff, + (acc >>> 8) & 0xff, + acc & 0xff, + ); + } + if (padding) bytes.length -= padding; + let n = 0; + for (const b of bytes) { + n = n * 256 + b; + if (n > Number.MAX_SAFE_INTEGER) { + throw new ValueError(`base85 value too large: ${s}`); + } + } + return n; +} + +/** Python's default ordering for a homogeneous group (str by code point). */ +export function pyCompare(a: unknown, b: unknown): number { + const na = pyNumber(a); + const nb = pyNumber(b); + if (na !== undefined && nb !== undefined) return na - nb; + if (typeof a === "string" && typeof b === "string") { + // Code-point order, not UTF-16 code-unit order. + const ia = a[Symbol.iterator](); + const ib = b[Symbol.iterator](); + for (;;) { + const x = ia.next(); + const y = ib.next(); + if (x.done || y.done) return x.done ? (y.done ? 0 : -1) : 1; + const cx = x.value.codePointAt(0) as number; + const cy = y.value.codePointAt(0) as number; + if (cx !== cy) return cx - cy; + } + } + return 0; +} + +/** Python's `type(x).__name__` for a (PyFloat-tagged) JSON value. */ +export function pyTypeName(value: unknown): string { + if (value === null) return "NoneType"; + if (value instanceof PyFloat) return "float"; + if (typeof value === "boolean") return "bool"; + if (typeof value === "number") + return Number.isInteger(value) ? "int" : "float"; + if (typeof value === "string") return "str"; + if (Array.isArray(value)) return "list"; + return "dict"; +} diff --git a/typescript/src/fpgen/trace.ts b/typescript/src/fpgen/trace.ts new file mode 100644 index 000000000..fd567b0f1 --- /dev/null +++ b/typescript/src/fpgen/trace.ts @@ -0,0 +1,129 @@ +/** + * Ported from fpgen/trace.py (scrapfly/fingerprint-generator, Apache-2.0; see + * ./NOTICE): the probability distribution of a target given conditions. + */ +import { RestrictiveConstraints, ValueError } from "./exceptions.js"; +import { getModel } from "./model.js"; +import { + assertConditions, + buildEvidence, + type Conditions, + type EvidenceMap, + findRoots, +} from "./utils.js"; + +export class TraceResult { + constructor( + readonly value: any, + readonly probability: number, + /** + * The value's JSON as the model stores it (a TypeScript addition). It + * is the key of the node's lookup index, and JSON.parse cannot always + * give it back: 2**64 re-serialises as 18446744073709552000, where + * the store holds orjson's 1.8446744073709552e19. + */ + readonly text: string, + ) {} + + toString(): string { + return `<${typeof this.value === "string" ? this.value : JSON.stringify(this.value)}: ${(this.probability * 100).toFixed(5)}%>`; + } +} + +/** Recursive result type for several targets. */ +export interface TraceResultDict { + [key: string]: TraceResult[] | TraceResultDict; +} + +export interface TraceOptions { + /** Return a flat {node: results} object for several targets. */ + flatten?: boolean; +} + +/** + * The probability distribution(s) of a target given conditions. + * + * One target resolving to one node -> TraceResult[] (most likely first); + * otherwise an object of them. + */ +export function trace( + target: string, + conditions?: Conditions | null, + options?: TraceOptions, +): TraceResult[] | TraceResultDict; +export function trace( + target: readonly string[], + conditions?: Conditions | null, + options?: TraceOptions, +): TraceResultDict | TraceResult[]; +export function trace( + target: string | readonly string[], + conditions?: Conditions | null, + options: TraceOptions = {}, +): TraceResult[] | TraceResultDict { + return traceWithEvidence(target, conditions, options, new Map()); +} + +/** trace() with evidence inherited from a Generator (Python's __evidence__). */ +export function traceWithEvidence( + target: string | readonly string[], + conditions: Conditions | null | undefined, + { flatten = false }: TraceOptions, + evidence: EvidenceMap, +): TraceResult[] | TraceResultDict { + assertConditions(conditions); + getModel(); + + if (conditions && Object.keys(conditions).length) { + buildEvidence(conditions, evidence); + } + + const targetTup = typeof target === "string" ? [target] : [...target]; + const targetRoots = findRoots(targetTup); + + if (!targetTup.length) { + throw new ValueError("Please pass at least one valid target."); + } + + // One target: return its results directly + if (targetRoots.length === 1) return pullTarget(targetRoots[0], evidence); + + if (flatten) { + const out: TraceResultDict = {}; + for (const root of targetRoots) out[root] = pullTarget(root, evidence); + return out; + } + + // NOTE: fpgen builds the intermediate dicts but then assigns every leaf at + // the TOP level (`output[parts[-1]] = ...`, not `d[parts[-1]]`). Kept as-is + // so the output has the same shape in both languages. + const output: TraceResultDict = {}; + for (const root of targetRoots) { + const parts = root.split("."); + let d: TraceResultDict = output; + for (const part of parts.slice(0, -1)) { + if (!Object.hasOwn(d, part)) d[part] = {}; + d = d[part] as TraceResultDict; + } + output[parts[parts.length - 1]] = pullTarget(root, evidence); + } + return output; +} + +function pullTarget(target: string, evidence: EvidenceMap): TraceResult[] { + const model = getModel(); + const possibilities = model.network.trace(target, evidence); + if (!possibilities.size) { + throw new RestrictiveConstraints( + `Restraints are too restrictive. No possible values for ${target}.`, + ); + } + const data = model.lookupValueList(possibilities.keys()); + const probs = [...possibilities.values()]; + const resp = data.map( + (text, i) => new TraceResult(JSON.parse(text), probs[i], text), + ); + // list.sort is stable, as is Array.prototype.sort. + resp.sort((a, b) => b.probability - a.probability); + return resp; +} diff --git a/typescript/src/fpgen/utils.ts b/typescript/src/fpgen/utils.ts new file mode 100644 index 000000000..ca15099f9 --- /dev/null +++ b/typescript/src/fpgen/utils.ts @@ -0,0 +1,571 @@ +/** + * Ported from fpgen/utils.py (scrapfly/fingerprint-generator, Apache-2.0; see + * ./NOTICE): condition parsing (build_evidence), query(), and the helpers that + * walk node names up and down the network and assemble output. + * + * Conditions, in JS terms (Python's kwargs become one object): + * - a plain object is flattened into dotted keys, like a Python dict; + * - a function is a predicate. NOTE, as in Python: it is called with the + * value parsed from the CASEFOLDED JSON, so strings arrive lowercased + * (`os: (v) => v === "windows"`, not "Windows"); + * - a Set is a list of alternatives (Python's set/tuple). An array is one + * value, as a Python list is; + * - anything else is one JSON value; `undefined` means "no condition". + * + * One deliberate deviation: Python matches a plain value by comparing + * orjson.dumps(value) with the stored JSON text, so `1.0` only matches "1.0". + * JS cannot write 1.0, so when the exact text finds nothing the lookup falls + * back to comparing the re-serialised stored value. That only ever turns a + * Python InvalidConstraints into a match on the numerically equal value. + */ +import type { BayesianNetwork } from "./bayesian-network.js"; +import { + InvalidConstraints, + InvalidNode, + NodePathError, + RestrictiveConstraints, + ValueError, +} from "./exceptions.js"; +import { type FpgenModel, getModel } from "./model.js"; +import { + casefold, + isPlainObject, + parsePyTyped, + pyCompare, + pyDumps, + pyEquals, + pyTypeName, + unwrapPy, +} from "./pyjson.js"; + +/** A predicate over a (casefolded, parsed) node value. */ +export type Predicate = (value: any) => unknown; +export type ConditionValue = unknown; +/** Conditions for generation / tracing. See the module header. */ +export type Conditions = Record; +/** Evidence: node name -> allowed value ids, in insertion order. */ +export type EvidenceMap = Map>; + +export function model(): FpgenModel { + return getModel(); +} + +function network(): BayesianNetwork { + return getModel().network; +} + +/** + * Query the possible values of a target. + * + * @param target a node, a path inside a node's value, or a prefix of nodes + * @param options.flatten flatten the output dictionary + * @param options.sort sort the output arrays + */ +export function query( + target: string, + { flatten = false, sort = false }: { flatten?: boolean; sort?: boolean } = {}, +): Record | any[] { + getModel(); + return unwrapPy(queryTyped(target, flatten, sort)) as + | Record + | any[]; +} + +function queryTyped(target: string, flatten: boolean, sort: boolean): unknown { + // Check node list first + const values = lookupPossibilities(target, false); + if (values?.size) { + const output = [...values.keys()].map(parsePyTyped); + // Merge dicts if the data is all dicts, else return a deduped list + if (output.every(isPlainObject)) { + return maybeFlatten(flatten, mergeDicts(output, sort)); + } + return dedupe(output, sort); + } + + // Target is within a node: look up the tree + const nestedKeys: string[] = []; + const rootData = lookupRootPossibilities(target, { + nestedKeys, + noneIfMissing: true, + casefold: false, + }); + if (rootData !== null) { + const output = [...rootData[1].keys()].map((d) => + atPath(parsePyTyped(d), nestedKeys), + ); + if (output.every(isPlainObject)) { + return maybeFlatten(flatten, mergeDicts(output, sort)); + } + return dedupe(output, sort); + } + + // Search down the tree + const data = searchDownward(target); + const resp = new Map(); + for (const key of data) { + const stripped = key.startsWith(`${target}.`) + ? key.slice(target.length + 1) + : key; + resp.set( + stripped, + [...(lookupPossibilities(key, false)?.keys() ?? [])].map(parsePyTyped), + ); + } + if (flatten) { + const deduped: Record = {}; + for (const [node, vals] of resp) deduped[node] = dedupe(vals, sort); + return flattenDict(deduped); + } + return unflatten(resp, sort); +} + +/* Helpers for searching for nodes up/down the network */ + +/** The value at a nested path (_at_path). Throws NodePathError(key). */ +export function atPath( + data: unknown, + pathKeys: Iterable, + { casefold: fold = false }: { casefold?: boolean } = {}, +): any { + let cur: any = data; + for (const key of pathKeys) { + if (!isPlainObject(cur)) throw new NodePathError(key); + if (fold) { + // CaseInsensitiveDict(data)[key]: last colliding key wins. + const want = casefold(key); + let found = false; + let value: unknown; + for (const k of Object.keys(cur)) { + if (casefold(k) === want) { + found = true; + value = cur[k]; + } + } + if (!found) throw new NodePathError(key); + cur = value; + } else { + if (!Object.hasOwn(cur, key)) throw new NodePathError(key); + cur = cur[key]; + } + } + return cur; +} + +/** + * Find the first node that is a prefix of `key` and return [node, its + * possibilities]. `nestedKeys` receives the remaining path. + */ +export function lookupRootPossibilities( + key: string, + { + nestedKeys, + casefold: fold = true, + noneIfMissing = false, + }: { + nestedKeys?: string[]; + casefold?: boolean; + noneIfMissing?: boolean; + } = {}, +): [string, Map] | null { + if (!key) throw new InvalidNode("Key cannot be empty."); + let possibleValues: Map | null = null; + while (key) { + const cut = key.lastIndexOf("."); + // Ran out of keys to parse + if (cut === -1) { + if (noneIfMissing) return null; + throw new InvalidNode(`${key} is not a valid node`); + } + const sliced = key.slice(cut + 1); + key = key.slice(0, cut); + nestedKeys?.push(sliced); + + possibleValues = lookupPossibilities(key, fold); + if (possibleValues !== null) break; + } + if (possibleValues === null) { + if (noneIfMissing) return null; + throw new InvalidNode(`${key} is not a valid node`); + } + nestedKeys?.reverse(); + return [key, possibleValues]; +} + +/** + * The possible values of a node as {value JSON text: value id}, or null when + * the node doesn't exist. `fold` casefolds the text (the default, as in fpgen). + */ +export function lookupPossibilities( + nodeName: string, + fold = true, +): Map | null { + const node = network().nodesByName.get(nodeName); + if (!node) return null; + const lookupValues = node.possibleValues; + const actual = model().lookupValueList(lookupValues); + const out = new Map(); + actual.forEach((text, i) => { + out.set(fold ? casefold(text) : text, lookupValues[i]); + }); + return out; +} + +/** All (original-cased) node names under `domain`. Throws when there are none. */ +export function searchDownward(domain: string): string[] { + const net = network(); + const found: string[] = []; + let i = 0; + for (const node of net.nodesByName.keys()) { + const n = i++; + if (!node.startsWith(domain)) continue; + if (node.length > domain.length && node[domain.length] !== ".") continue; + found.push(net.nodeNames[n]); + } + if (!found.length) throw new InvalidNode(`Unknown node: "${domain}"`); + return found; +} + +/** The nodes that make up each target's data (_find_roots). */ +export function findRoots(targets: Iterable): string[] { + const net = network(); + const out: string[] = []; + for (const t of targets) { + let target = casefold(t); + while (true) { + // Found a valid target + if (net.nodesByName.has(target)) { + out.push(target); + break; + } + const cut = target.lastIndexOf("."); + if (cut !== -1) { + target = target.slice(0, cut); + continue; + } + // At the root key: find nodes below it before giving up + out.push(...searchDownward(target)); + break; + } + } + return out; +} + +export function reassembleTargets( + targets: readonly string[], + fingerprint: Record, +): Record { + const result: Record = {}; + for (const target of targets) { + try { + result[target] = atPath(fingerprint, target.split("."), { + casefold: true, + }); + } catch (e) { + if (e instanceof NodePathError) { + throw new InvalidNode( + `'${target}' is not a valid key path (missing ${e.message}).`, + ); + } + throw e; + } + } + return result; +} + +/* Miscellaneous list/dict helpers */ + +/** Group items by Python type, dedupe each group, order groups by type name. */ +export function dedupe(list: Iterable, sort: boolean): unknown[] { + const groups = new Map(); + for (const item of list) { + const t = pyTypeName(item); + let group = groups.get(t); + if (!group) { + group = []; + groups.set(t, group); + } + if (!group.some((g) => pyEquals(g, item))) group.push(item); + } + const result: unknown[] = []; + for (const t of [...groups.keys()].sort(pyCompare)) { + const items = groups.get(t) as unknown[]; + if (!sort || t === "list" || t === "dict") result.push(...items); + else result.push(...[...items].sort(pyCompare)); + } + return result; +} + +function unflatten( + dictionary: Map, + sort: boolean, +): Record { + const result: Record = {}; + for (const [key, raw] of dictionary) { + const parts = key.split("."); + let d = result; + for (const part of parts.slice(0, -1)) { + if (!Object.hasOwn(d, part)) d[part] = {}; + d = d[part]; + } + d[parts[parts.length - 1]] = Array.isArray(raw) ? dedupe(raw, sort) : raw; + } + return result; +} + +/** Turn a nested dictionary into a flattened one (dotted keys). */ +export function flattenDict( + dictionary: Record, + parentKey = "", +): Record { + const items: Record = {}; + for (const [key, value] of Object.entries(dictionary)) { + const newKey = parentKey ? `${parentKey}.${key}` : key; + if (isPlainObject(value)) Object.assign(items, flattenDict(value, newKey)); + else items[newKey] = value; + } + return items; +} + +export function maybeFlatten(flatten: boolean | undefined, data: any): any { + if (!isPlainObject(data)) return data; + return flatten ? flattenDict(data) : data; +} + +/** + * Merge a list of dicts: dict values merge recursively, list values are + * concatenated and deduped, anything else is deduped. + */ +function mergeDicts( + dictList: Record[], + sort: boolean, +): Record { + if (!dictList.length) return {}; + const merged: Record = {}; + const allKeys = new Set(); + for (const d of dictList) for (const k of Object.keys(d)) allKeys.add(k); + for (const key of allKeys) { + const values = dictList + .filter((d) => Object.hasOwn(d, key)) + .map((d) => d[key]); + if (values.every(isPlainObject)) { + merged[key] = mergeDicts(values as Record[], sort); + } else if (values.every(Array.isArray)) { + merged[key] = dedupe((values as unknown[][]).flat(), sort); + } else { + merged[key] = dedupe(values, sort); + } + } + return merged; +} + +/* Parse user input */ + +/** A flattened condition: one JSON text, a predicate, or alternatives. */ +type FlatCondition = string | Predicate | readonly (string | Predicate)[]; + +/** Flatten nested conditions into dotted keys (_flatten_conditions). */ +export function flattenConditions( + dictionary: Record, + parentKey = "", + fold = false, +): Map { + const items = new Map(); + for (const [key, value] of Object.entries(dictionary)) { + if (value === undefined) continue; + let newKey = parentKey ? `${parentKey}.${key}` : key; + if (isPlainObject(value)) { + // As in Python, the recursion does not casefold. + for (const [k, v] of flattenConditions(value, newKey)) items.set(k, v); + continue; + } + let flat: FlatCondition; + if (value instanceof Set) { + // A set (Python: set/tuple) is a list of alternatives. + flat = [...value].map((v) => pyDumps(v)); + } else if (typeof value === "function") { + flat = value as Predicate; + } else { + flat = pyDumps(value); + } + if (fold) newKey = casefold(newKey); + items.set(newKey, flat); + } + return items; +} + +function describe(val: unknown): string { + if (typeof val === "function") { + return `"}>`; + } + return String(val); +} + +/** + * Turn user conditions into evidence (node -> allowed value ids), validating + * them against the network. Mutates `evidence`. + */ +export function buildEvidence( + conditions: Record, + evidence: EvidenceMap, + strict?: boolean | null, +): void { + if (strict == null) strict = true; + const net = network(); + + // Flatten to match the format of the fingerprint network + const flat = flattenConditions(conditions, "", true); + + for (let [key, value] of flat) { + let possibleValues = lookupPossibilities(key); + + // Handle nested keys + let nestedKeys: string[] = []; + if (possibleValues === null) { + [key, possibleValues] = lookupRootPossibilities(key, { + nestedKeys, + }) as [string, Map]; + } + // Get the real name for the key + key = net.node(key).name; + + const allowed = new Set(); + evidence.set(key, allowed); + + const alternatives: readonly (string | Predicate)[] = + typeof value === "string" || typeof value === "function" + ? [value] + : value; + + for (const valueCon of alternatives) { + // Read the passed value + const val: unknown = + typeof valueCon === "function" + ? valueCon + : JSON.parse(casefold(valueCon)); + + // Nested keys: keep the possible values whose value at the nested + // path matches. + if (nestedKeys.length) { + nestedKeys = nestedKeys.map(casefold); + for (const [possValue, lookupIndex] of possibleValues) { + const outputtedPossible = JSON.parse(possValue); + let targetValue: unknown; + try { + targetValue = atPath(outputtedPossible, nestedKeys); + } catch (e) { + if (e instanceof NodePathError) continue; // bad data + throw e; + } + if (typeof val === "function" && val(targetValue)) { + allowed.add(lookupIndex); + } else if (pyEquals(targetValue, val)) { + allowed.add(lookupIndex); + } + } + if (!allowed.size) { + if (typeof val === "function") { + throw new InvalidConstraints( + `The passed function (${describe(val)}) yielded no possible values for "${key}" ` + + `at "${nestedKeys.join(".")}"`, + ); + } + throw new InvalidConstraints( + `${describe(valueCon)} is not a possible value for "${key}" ` + + `at "${nestedKeys.join(".")}"`, + ); + } + continue; + } + + // ===== NON NESTED VALUE HANDLING ===== + + if (typeof val === "function") { + let found = false; + for (const [possibleVal, lookupIndex] of possibleValues) { + if (val(JSON.parse(possibleVal))) { + allowed.add(lookupIndex); + found = true; + } + } + if (!found) { + throw new InvalidConstraints( + `The passed function (${describe(val)}) yielded no possible values for "${key}"`, + ); + } + continue; + } + + // Non nested values: look the JSON text up directly + const wanted = casefold(valueCon as string); + let lookupIndex = possibleValues.get(wanted); + if (lookupIndex === undefined) { + // JS has no 1.0; compare against the re-serialised stored value. + for (const [possibleVal, idx] of possibleValues) { + if (JSON.stringify(JSON.parse(possibleVal)) === wanted) { + lookupIndex = idx; + break; + } + } + } + if (lookupIndex === undefined) { + throw new InvalidConstraints( + `${describe(valueCon)} is not a possible value for "${key}"`, + ); + } + allowed.add(lookupIndex); + } + } + + // Validate the evidence (or, when not strict, relax it once). fpgen drops + // the FIRST key and does not re-validate; so does this. + try { + net.validateEvidence(evidence); + } catch (e) { + if (strict || !(e instanceof RestrictiveConstraints)) { + throw e; + } + const first = evidence.keys().next(); + if (!first.done) evidence.delete(first.value); + } +} + +/** Conditions must be a plain object when given (_assert_dict_xor_kwargs). */ +export function assertConditions(conditions: unknown): void { + if (conditions == null) return; + if (!isPlainObject(conditions)) { + throw new ValueError( + "Invalid argument. Constraints must be passed as kwargs or as a dictionary.", + ); + } +} + +/* Convert network output to human readable output */ + +/** Unflatten (or flatten) a sampled {node: value id} into the output object. */ +export function makeOutputDict( + data: Map, + flatten: boolean | undefined, +): Record { + const keys = [...data.keys()]; + const values = model().lookupValueList(data.values()); + if (flatten) { + const flat: Record = {}; + keys.forEach((k, i) => { + flat[k] = JSON.parse(values[i]); + }); + // Flatten node values that themselves are dicts + return flattenDict(flat); + } + const result: Record = {}; + keys.forEach((key, i) => { + const parts = key.split("."); + let d = result; + for (const part of parts.slice(0, -1)) { + if (!Object.hasOwn(d, part)) d[part] = {}; + d = d[part]; + } + d[parts[parts.length - 1]] = JSON.parse(values[i]); + }); + return result; +} diff --git a/typescript/src/geolocation.ts b/typescript/src/geolocation.ts new file mode 100644 index 000000000..47083f111 --- /dev/null +++ b/typescript/src/geolocation.ts @@ -0,0 +1,335 @@ +/** + * Helpers to fetch geolocation, timezone, and locale data given an IP. + * + * TypeScript twin of pythonlib/camoufox/geolocation.py. The on-disk layout + * (geoip/mmdb/-.mmdb and geoip/config.yml under the camoufox + * cache dir) is the Python package's, so both launchers share one database. + */ +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { NotInstalledGeoIPExtra, UnknownIPLocation } from "./exceptions.js"; +import { validateIP } from "./ip.js"; +import { Geolocation, SELECTOR } from "./locales.js"; +import { INSTALL_DIR, LOCAL_DATA } from "./paths.js"; + +export const GEOIP_DIR: string = path.join(INSTALL_DIR, "geoip"); +export const MMDB_DIR: string = path.join(GEOIP_DIR, "mmdb"); +export const GEOIP_CONFIG: string = path.join(GEOIP_DIR, "config.yml"); + +export interface GeoIPRepo { + name: string; + urls: Record; + paths: Record; + extract?: boolean; + [key: string]: any; +} + +/** A reader over an mmdb file: maxminddb.Reader's `get`. */ +export interface MmdbReader { + get(ip: string): any; + close?(): void; +} + +const require_ = createRequire(import.meta.url); + +/** + * Whether the mmdb reader is available. Python gates this on the optional + * `maxminddb` import (`pip install camoufox[geoip]`); here it is the optional + * `maxmind` package. + */ +export function allowGeoip(): boolean { + try { + require_.resolve("maxmind"); + return true; + } catch { + return false; + } +} + +/** + * Injection points (tests replace them): opening an mmdb, and downloading. + */ +export const geoipDeps = { + async openDatabase(mmdbPath: string): Promise { + const maxmind = (await import("maxmind")).default; + const buffer = fs.readFileSync(mmdbPath); + return new maxmind.Reader(buffer); + }, + downloadMmdb: (source?: string) => downloadMmdb(source), +}; + +/** + * Resolve a dotted path in a nested object. + */ +function findIn(data: any, key: string): any { + for (const part of key.split(".")) { + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return null; + } + data = data[part]; + if (data === undefined || data === null) { + return null; + } + } + return data; +} + +/** + * Load GeoIP repos and default name from repos.yml. + */ +function loadGeoipRepos(): [GeoIPRepo[], string] { + const data = + (parseYaml( + fs.readFileSync(path.join(LOCAL_DATA, "repos.yml"), "utf-8"), + ) as Record) ?? {}; + const geoipRepos: GeoIPRepo[] = data.geoip ?? []; + const defaultName: string = data.default?.geoip ?? "GeoLite2"; + return [geoipRepos, defaultName]; +} + +/** + * Get GeoIP config by name from repos.yml. If omitted, uses the default. + */ +export function getGeoipConfigByName(name?: string | null): GeoIPRepo { + const [repos, defaultName] = loadGeoipRepos(); + const targetName = name || defaultName; + + const validateRepo = (repo: GeoIPRepo): GeoIPRepo => { + const raw = repo as Record; + if (!("urls" in raw)) { + throw new Error(`GeoIP repo '${raw.name}' missing required urls`); + } + if (!("paths" in raw)) { + throw new Error(`GeoIP repo '${raw.name}' missing required paths`); + } + return repo; + }; + + for (const repo of repos) { + if ((repo.name ?? "").toLowerCase() === targetName.toLowerCase()) { + return validateRepo(repo); + } + } + + if (name) { + const available = repos.map((r) => `'${r.name ?? "Unknown"}'`); + throw new Error( + `GeoIP database '${name}' not found. Available: [${available.join(", ")}]`, + ); + } + + if (repos.length) { + return validateRepo(repos[0]); + } + throw new Error("No GeoIP repos configured in repos.yml"); +} + +/** + * Load the active GeoIP config from disk, falling back to the repos.yml default. + */ +export function loadGeoipConfig(): GeoIPRepo { + if (fs.existsSync(GEOIP_CONFIG)) { + const saved = + (parseYaml(fs.readFileSync(GEOIP_CONFIG, "utf-8")) as Record< + string, + any + >) ?? {}; + try { + return getGeoipConfigByName(saved.name); + } catch { + return saved as GeoIPRepo; + } + } + return getGeoipConfigByName(undefined); +} + +/** + * Save the active GeoIP source name to disk. + */ +export function saveGeoipConfig(config: GeoIPRepo): void { + fs.mkdirSync(GEOIP_DIR, { recursive: true }); + fs.writeFileSync(GEOIP_CONFIG, stringifyYaml({ name: config.name })); +} + +/** + * Get the path to the mmdb file for the specified IP version. + */ +export function getMmdbPath( + ipVersion: string = "ipv4", + config?: GeoIPRepo, +): string { + const cfg = config ?? loadGeoipConfig(); + const name = (cfg.name ?? "geolite2").toLowerCase(); + const urls = cfg.urls ?? {}; + if ("combined" in urls) { + return path.join(MMDB_DIR, `${name}-combined.mmdb`); + } + return path.join(MMDB_DIR, `${name}-${ipVersion}.mmdb`); +} + +/** + * Checks that the mmdb reader is available. + */ +export function geoipAllowed(): void { + if (!allowGeoip()) { + throw new NotInstalledGeoIPExtra( + "Please install the geoip extra to use this feature: npm install maxmind", + ); + } +} + +/** + * Downloads the GeoIP database(s) to geoip/mmdb/. + */ +export async function downloadMmdb( + source?: string, + progressCallback?: (downloaded: number, total: number) => void, +): Promise { + geoipAllowed(); + const { unzip, webdl } = await import("./pkgman.js"); + + const config = source ? getGeoipConfigByName(source) : loadGeoipConfig(); + const urls = config.urls; + const name = config.name.toLowerCase(); + + fs.mkdirSync(MMDB_DIR, { recursive: true }); + + const extract = config.extract ?? false; + const isCombined = "combined" in urls; + + for (const [ipVer, rawList] of Object.entries(urls)) { + const suffix = isCombined ? "" : ` (${ipVer})`; + let dlDesc = `Downloading ${config.name}${suffix}`; + let exDesc = `Extracting ${config.name}${suffix}`; + const maxLen = Math.max(dlDesc.length, exDesc.length); + dlDesc = dlDesc.padEnd(maxLen); + exDesc = exDesc.padEnd(maxLen); + + const mmdbPath = path.join(MMDB_DIR, `${name}-${ipVer}.mmdb`); + const urlList = typeof rawList === "string" ? [rawList] : rawList; + + let lastError: unknown; + let done = false; + for (const url of urlList) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-geoip-")); + try { + const buffer = await webdl( + url, + dlDesc, + progressCallback === undefined, + null, + { progressCallback }, + ); + if (extract) { + unzip(buffer, tmpDir, exDesc, progressCallback === undefined); + const found = findFirstMmdb(tmpDir); + if (!found) { + throw new Error("No .mmdb file found in archive"); + } + fs.renameSync(found, mmdbPath); + } else { + fs.writeFileSync(mmdbPath, buffer); + } + done = true; + break; + } catch (error) { + lastError = error; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + } + if (!done) { + throw lastError ?? new Error(`Failed to download ${ipVer}`); + } + } + + saveGeoipConfig(config); +} + +/** Path(tmpdir).rglob('*.mmdb')[0] */ +function findFirstMmdb(dir: string): string | null { + const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".mmdb")) { + return path.join(entry.parentPath, entry.name); + } + } + return null; +} + +/** + * Check if the GeoIP database needs an update (older than 30 days). + */ +export function needsUpdate(config?: GeoIPRepo): boolean { + const cfg = config ?? loadGeoipConfig(); + const updateDays = 30; + + const ipv4Path = getMmdbPath("ipv4", cfg); + if (!fs.existsSync(ipv4Path)) { + return true; + } + const age = Date.now() - fs.statSync(ipv4Path).mtimeMs; + return age > updateDays * 24 * 60 * 60 * 1000; +} + +/** float(x) for a value read out of the database. */ +function pyFloat(value: any): number { + if (value === null || value === undefined) { + throw new TypeError( + "float() argument must be a string or a real number, not 'NoneType'", + ); + } + const n = Number(value); + if (Number.isNaN(n)) { + throw new Error(`could not convert string to float: '${value}'`); + } + return n; +} + +/** + * Gets the geolocation for an IP address. + */ +export async function getGeolocation( + ip: string, + geoipDb?: string, +): Promise { + validateIP(ip); + const ipVersion = ip.includes(":") ? "ipv6" : "ipv4"; + let mmdbPath = getMmdbPath(ipVersion); + + if (!fs.existsSync(mmdbPath) || needsUpdate()) { + await geoipDeps.downloadMmdb(); + mmdbPath = getMmdbPath(ipVersion); + } + + const config = geoipDb ? getGeoipConfigByName(geoipDb) : loadGeoipConfig(); + const paths = config.paths; + + const reader = await geoipDeps.openDatabase(mmdbPath); + try { + const resp = reader.get(ip); + if (!resp) { + throw new UnknownIPLocation(`IP not found in database: ${ip}`); + } + + const isoCode = findIn(resp, paths.iso_code); + const longitude = findIn(resp, paths.longitude); + const latitude = findIn(resp, paths.latitude); + const timezone = findIn(resp, paths.timezone); + + const iso = String(isoCode ?? "None").toUpperCase(); + const locale = await SELECTOR.fromRegion(iso); + + return new Geolocation( + locale, + pyFloat(longitude), + pyFloat(latitude), + String(timezone ?? "None"), + ); + } finally { + reader.close?.(); + } +} diff --git a/typescript/src/index.ts b/typescript/src/index.ts new file mode 100644 index 000000000..6af1d7be5 --- /dev/null +++ b/typescript/src/index.ts @@ -0,0 +1,46 @@ +/** + * Public API. Mirrors pythonlib/camoufox/__init__.py (Camoufox, NewBrowser, + * NewContext, their Async* twins, DefaultAddons, launch_options), plus the + * package-management and server helpers the TS port has always exported. + */ +export type { DefaultAddon } from "./addons.js"; +export { DefaultAddons } from "./addons.js"; +export { + AsyncCamoufox, + AsyncNewBrowser, + AsyncNewContext, +} from "./async_api.js"; +export { + generateContextFingerprint, + getRandomPreset, + loadPresets, + Screen, +} from "./fingerprints.js"; +export { + findInstalledVersion, + listInstalled, + printTree, +} from "./multiversion.js"; +export { + CamoufoxFetcher, + INSTALL_DIR, + installedVerStr, + OS_NAME, + RepoConfig, +} from "./pkgman.js"; +export { type LaunchServerOptions, launchServer } from "./server.js"; +export { + Camoufox, + type Headless, + NewBrowser, + type NewBrowserOptions, + NewContext, + type NewContextOptions, +} from "./sync_api.js"; +export { + type LaunchOptions, + launchOptions, + launchOptions as launch_options, +} from "./utils.js"; +export { VirtualDisplay } from "./virtdisplay.js"; +export { FallbackWarning, LeakWarning } from "./warnings.js"; diff --git a/typescript/src/ip.ts b/typescript/src/ip.ts new file mode 100644 index 000000000..cffee9a36 --- /dev/null +++ b/typescript/src/ip.ts @@ -0,0 +1,207 @@ +/** + * Helpers to find the user's public IP address for geolocation. + * + * TypeScript twin of pythonlib/camoufox/ip.py. + */ +import { Impit } from "impit"; +import { InvalidIP, InvalidProxy } from "./exceptions.js"; + +export interface ProxyConfig { + server: string; + username?: string; + password?: string; + bypass?: string; +} + +// biome-ignore lint/complexity/noStaticOnlyClass: these are the Python Proxy dataclass's methods; the data itself is ProxyConfig +export class ProxyHelper { + /** + * Parses the proxy server string. + */ + static parseServer(server: string): { + schema: string; + url: string; + port?: string; + } { + const proxyMatch = server.match(/^(?:(\w+):\/\/)?(.*?)(?::(\d+))?$/); + if (!proxyMatch) { + throw new InvalidProxy(`Invalid proxy server: ${server}`); + } + return { + schema: proxyMatch[1] || "http", + url: proxyMatch[2], + port: proxyMatch[3], + }; + } + + static asString(proxy: ProxyConfig): string { + const { schema, url, port } = ProxyHelper.parseServer(proxy.server); + let result = `${schema}://`; + if (proxy.username) { + result += proxy.username; + if (proxy.password) { + result += `:${proxy.password}`; + } + result += "@"; + } + result += url; + if (port) { + result += `:${port}`; + } + return result; + } +} + +export function validIPv4(ip: string | false): boolean { + if (!ip) { + return false; + } + return /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(ip); +} + +export function validIPv6(ip: string | false): boolean { + if (!ip) { + return false; + } + return /^(([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4})$/.test(ip); +} + +export function validateIP(ip: string): void { + if (!validIPv4(ip) && !validIPv6(ip)) { + throw new InvalidIP(`Invalid IP address: ${ip}`); + } +} + +// Impit has no close/dispose API: each instance's native client (Tokio +// runtime resources, one UDP resolver socket) is only reclaimed when the +// JS wrapper is GC'd -- and V8 rarely collects the tiny wrappers, so +// per-call instances leak fds in long-running processes. Reuse instances +// via a small LRU keyed by proxy URL; evicted entries are reclaimed by GC. +const IMPIT_CACHE_MAX = 8; +const impitCache = new Map(); + +function getImpit(proxy?: string): Impit { + const key = proxy ?? ""; + const cached = impitCache.get(key); + if (cached) { + impitCache.delete(key); + impitCache.set(key, cached); + return cached; + } + // Certificates are verified (the Python twin's verify=True): an IP lookup + // is what geoip trusts for the whole identity, so a MITM must not pick it. + const impit = new Impit({ + proxyUrl: proxy, + timeout: 5000, + ignoreTlsErrors: false, + }); + impitCache.set(key, impit); + if (impitCache.size > IMPIT_CACHE_MAX) { + impitCache.delete(impitCache.keys().next().value as string); + } + return impit; +} + +export const PROXY_LOOKUP_FAILED = + "Could not look up the proxy's exit IP and timezone. Pass webrtc_ip and " + + "timezone_id explicitly to skip the lookup"; + +/** + * The exit IP of `proxy` and that IP's timezone, looked up through the proxy. + * Throws InvalidIP when the lookup fails: a context that silently kept the + * host's WebRTC IP and timezone behind a proxy would be a leak. + */ +export async function proxyExitGeo(proxy: string): Promise<[string, string]> { + let data: { + status?: string; + message?: string; + query?: string; + timezone?: string; + }; + try { + const response = await getImpit(proxy).fetch( + "http://ip-api.com/json?fields=status,message,query,timezone", + ); + if (!response.ok) { + throw new Error(`${response.status} Error: ${response.statusText}`); + } + data = await response.json(); + } catch (error) { + throw new InvalidIP(`${PROXY_LOOKUP_FAILED}: ${error}`); + } + if (data.status !== "success" || !data.query || !data.timezone) { + throw new InvalidIP( + `${PROXY_LOOKUP_FAILED}: ${data.message ?? JSON.stringify(data)}`, + ); + } + validateIP(data.query); + return [data.query, data.timezone]; +} + +const PUBLIC_IP_URLS = [ + // Prefers IPv4 + "https://api.ipify.org", + "https://checkip.amazonaws.com", + "https://ipinfo.io/ip", + // IPv4 & IPv6 + "https://icanhazip.com", + "https://ifconfig.co/ip", + "https://ipecho.net/plain", +]; + +// The Python twin memoizes public_ip() with lru_cache; mirror that so repeated +// launches through the same proxy don't re-hit the API endpoints. +const publicIPCache = new Map>(); + +/** + * Sends a request to a public IP api. + */ +export function publicIP(proxy?: string): Promise { + const key = proxy ?? ""; + const cached = publicIPCache.get(key); + if (cached) { + return cached; + } + const pending = resolvePublicIP(proxy).catch((error) => { + // Never cache a failure: the next launch should retry. + publicIPCache.delete(key); + throw error; + }); + publicIPCache.set(key, pending); + return pending; +} + +async function resolvePublicIP(proxy?: string): Promise { + let endException: unknown; + + for (const url of PUBLIC_IP_URLS) { + try { + const response = await getImpit(proxy).fetch(url); + if (!response.ok) { + // requests' raise_for_status() + throw new Error( + `${response.status} Error: ${response.statusText} for url: ${url}`, + ); + } + const ip = (await response.text()).trim(); + validateIP(ip); + return ip; + } catch (error) { + endException = error; + if (process.env.CAMOUFOX_DEBUG) { + console.warn( + new InvalidProxy( + `camoufox(warn): Failed to fetch public IP from ${url}, retrying with another URL...`, + { cause: error }, + ), + ); + } + } + } + + const detail = + endException instanceof Error ? endException.message : String(endException); + throw new InvalidIP(`Failed to get IP address: ${detail}`, { + cause: endException, + }); +} diff --git a/typescript/src/locale.ts b/typescript/src/locale.ts new file mode 100644 index 000000000..4398d40e5 --- /dev/null +++ b/typescript/src/locale.ts @@ -0,0 +1,8 @@ +/** + * Locale + geolocation handling: re-exports locales.ts and geolocation.ts, + * the twins of pythonlib/camoufox/locales.py and geolocation.py. + * + * Kept so existing `./locale.js` imports keep working. + */ +export * from "./geolocation.js"; +export * from "./locales.js"; diff --git a/typescript/src/locales.ts b/typescript/src/locales.ts new file mode 100644 index 000000000..8dd987d5e --- /dev/null +++ b/typescript/src/locales.ts @@ -0,0 +1,415 @@ +/** + * Locale data structures, validation and the statistical locale selector. + * + * TypeScript twin of pythonlib/camoufox/locales.py. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import tags from "language-tags"; +import xml2js from "xml2js"; +import { + InvalidLocale, + UnknownLanguage, + UnknownTerritory, +} from "./exceptions.js"; +import { LOCAL_DATA } from "./paths.js"; +import { LeakWarning } from "./warnings.js"; + +/* + * Data structures for locale and geolocation info + */ + +/** + * Stores locale, region, and script information. + */ +export class Locale { + constructor( + public language: string, + public region?: string, + public script?: string, + ) {} + + get asString(): string { + if (this.region) { + return `${this.language}-${this.region}`; + } + return this.language; + } + + /** + * Converts the locale to an intl config object. + */ + asConfig(): Record { + if (!this.region) { + throw new Error("AssertionError: Locale.asConfig() needs a region"); + } + const data: Record = { + "locale:region": this.region, + "locale:language": this.language, + }; + if (this.script) { + data["locale:script"] = this.script; + } + return data; + } +} + +/** + * Stores geolocation information. + */ +export class Geolocation { + constructor( + public readonly locale: Locale, + public readonly longitude: number, + public readonly latitude: number, + public readonly timezone: string, + public readonly accuracy?: number, + ) {} + + /** + * Converts the geolocation to a config object. + */ + asConfig(): Record { + const data: Record = { + "geolocation:longitude": this.longitude, + "geolocation:latitude": this.latitude, + timezone: this.timezone, + ...this.locale.asConfig(), + }; + if (this.accuracy) { + data["geolocation:accuracy"] = this.accuracy; + } + return data; + } +} + +/* + * Helpers to validate and normalize locales + */ + +/** + * Verifies that a locale is valid. Takes either language-region or language. + */ +export function verifyLocale(loc: string): void { + if (tags.check(loc)) { + return; + } + throw InvalidLocale.invalidInput(loc); +} + +/** + * Normalizes and validates a locale code. + * + * The script is the LANGUAGE's Suppress-Script ("Latn" for "en"), exactly as + * pythonlib does -- not the tag's explicit script subtag. Parity note: that + * makes "en-US" come out as en/Latn/US, and "zh-Hans-CN" as zh/(none)/CN. + */ +export function normalizeLocale(locale: string): Locale { + verifyLocale(locale); + + // Parse the locale + const parser = tags(locale); + const region = parser.region(); + if (!region) { + throw InvalidLocale.invalidInput(locale); + } + + const language = parser.language(); + if (!language) { + throw InvalidLocale.invalidInput(locale); + } + const suppressScript = language.script(); + + // Return a formatted locale object + return new Locale( + language.format(), + region.format(), + suppressScript ? suppressScript.format() : undefined, + ); +} + +/** + * Handles a locale input, normalizing it if necessary. + */ +export async function handleLocale( + locale: string, + ignoreRegion: boolean = false, +): Promise { + // If the caller passed `language-region` or `language-script-region`, normalize it. + if (locale.length > 3) { + return normalizeLocale(locale); + } + + // Case: caller passed a `region` and needs a full locale + try { + return await SELECTOR.fromRegion(locale); + } catch (e) { + if (!(e instanceof UnknownTerritory)) throw e; + } + + // Case: caller passed a `language`, and doesn't care about the region + if (ignoreRegion) { + verifyLocale(locale); + return new Locale(locale); + } + + // Case: caller passed a `language` and wants a region + let language: Locale | undefined; + try { + language = await SELECTOR.fromLanguage(locale); + } catch (e) { + if (!(e instanceof UnknownLanguage)) throw e; + } + if (language) { + LeakWarning.warn("no_region"); + return language; + } + + // Locale is not in a valid format. + throw InvalidLocale.invalidInput(locale); +} + +/** + * Handles a list of locales, writing them into the config. + */ +export async function handleLocales( + locales: string | string[], + config: Record, +): Promise { + const list = + typeof locales === "string" + ? locales.split(",").map((loc) => loc.trim()) + : locales; + + // First, handle the first locale. This will be used for the intl api. + const intlLocale = await handleLocale(list[0]); + Object.assign(config, intlLocale.asConfig()); + + if (list.length < 2) { + return; + } + + // If additional locales were passed, validate them. + // Note: in this case, we do not need the region. + const resolved: string[] = []; + for (const locale of list) { + resolved.push((await handleLocale(locale, true)).asString); + } + config["locale:all"] = joinUnique(resolved); +} + +/** + * Joins a sequence of strings without duplicates. + */ +function joinUnique(seq: string[]): string { + return [...new Set(seq)].join(", "); +} + +/* + * Gets a random language based on the territory code. + */ + +interface TerritoryElement { + $: Record; + languagePopulation?: Array<{ $: Record }>; +} + +let unicodeInfo: Promise | null = null; + +/** + * Fetches supplemental data from the territoryInfo.xml file. + * Source: https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/supplementalData.xml + */ +export function getUnicodeInfo(): Promise { + if (!unicodeInfo) { + unicodeInfo = (async () => { + const data = await fs.promises.readFile( + path.join(LOCAL_DATA, "territoryInfo.xml"), + ); + const parsed = await new xml2js.Parser().parseStringPromise(data); + const territories = parsed?.territoryInfo?.territory; + if (!territories) { + throw new Error("Failed to load territoryInfo.xml"); + } + return territories as TerritoryElement[]; + })(); + } + return unicodeInfo; +} + +/** float(element.get(attr, 0)) */ +function asFloat(attrs: Record, attr: string): number { + const raw = attrs[attr]; + if (raw === undefined) return 0; + const value = Number(raw.trim()); + if (Number.isNaN(value)) { + throw new Error(`could not convert string to float: '${raw}'`); + } + return value; +} + +/** + * numpy's pairwise summation (np.sum over a 1-D float64 array), so the + * normalised probabilities are bit-identical to the Python selector's. + */ +export function pairwiseSum(values: readonly number[]): number { + const sum = (lo: number, n: number): number => { + if (n < 8) { + let res = 0; + for (let i = 0; i < n; i++) res += values[lo + i]; + return res; + } + if (n <= 128) { + const r = values.slice(lo, lo + 8); + let i = 8; + for (; i < n - (n % 8); i += 8) { + for (let j = 0; j < 8; j++) r[j] += values[lo + i + j]; + } + let res = r[0] + r[1] + (r[2] + r[3]) + (r[4] + r[5] + (r[6] + r[7])); + for (; i < n; i++) res += values[lo + i]; + return res; + } + let n2 = Math.floor(n / 2); + n2 -= n2 % 8; + return sum(lo, n2) + sum(lo + n2, n - n2); + }; + return sum(0, values.length); +} + +/** + * Injection point for the selector's one random draw (tests pin it). + * `random()` plays numpy's RandomState.random_sample(). + */ +export const localeDeps = { + random: (): number => Math.random(), +}; + +/** + * numpy.random.choice(items, p=probabilities): searchsorted(right) of one + * uniform draw in the renormalised CDF. + */ +export function weightedChoice( + items: readonly T[], + p: readonly number[], +): T { + const cdf: number[] = []; + let acc = 0; + for (const x of p) { + acc += x; + cdf.push(acc); + } + const last = cdf[cdf.length - 1]; + for (let i = 0; i < cdf.length; i++) cdf[i] /= last; + const u = localeDeps.random(); + let idx = 0; + while (idx < cdf.length && cdf[idx] <= u) idx++; + return items[Math.min(idx, items.length - 1)]; +} + +/** + * Selects a random locale based on statistical data. Takes either a territory + * code or a language code, and generates a Locale. + */ +export class StatisticalLocaleSelector { + /** + * Calculates a random language based on the territory code, based on the + * probability that a person speaks the language in the territory. + */ + private async loadTerritoryData( + isoCode: string, + ): Promise<[string[], number[]]> { + const territories = await getUnicodeInfo(); + const territory = territories.find((t) => t.$?.type === isoCode); + if (!territory) { + throw new UnknownTerritory(`Unknown territory: ${isoCode}`); + } + + const langPopulations = territory.languagePopulation; + if (!langPopulations?.length) { + throw new Error(`No language data found for region: ${isoCode}`); + } + + const languages = langPopulations.map((lang) => lang.$.type); + const percentages = langPopulations.map((lang) => + asFloat(lang.$, "populationPercent"), + ); + + return this.normalizeProbabilities(languages, percentages); + } + + /** + * Calculates a random region for a language based on the total speakers of + * the language in that region. + */ + private async loadLanguageData( + language: string, + ): Promise<[string[], number[]]> { + const territories = (await getUnicodeInfo()).filter((t) => + t.languagePopulation?.some((lp) => lp.$?.type === language), + ); + if (!territories.length) { + throw new UnknownLanguage( + `No region data found for language: ${language}`, + ); + } + + const regions: string[] = []; + const percentages: number[] = []; + + for (const terr of territories) { + const region = terr.$?.type; + if (region === undefined) continue; // Skip if region is not found + + const langPop = terr.languagePopulation?.find( + (lp) => lp.$?.type === language, + ); + if (!langPop) continue; + + regions.push(region); + percentages.push( + ((asFloat(langPop.$, "populationPercent") * + asFloat(terr.$, "literacyPercent")) / + 10_000) * + asFloat(terr.$, "population"), + ); + } + + if (!regions.length) { + throw new Error(`No valid region data found for language: ${language}`); + } + + return this.normalizeProbabilities(regions, percentages); + } + + /** Normalize probabilities. */ + normalizeProbabilities( + items: string[], + freq: number[], + ): [string[], number[]] { + const total = pairwiseSum(freq); + return [items, freq.map((f) => f / total)]; + } + + /** + * Get a random locale based on the territory ISO code. + */ + async fromRegion(region: string): Promise { + const [languages, probabilities] = await this.loadTerritoryData(region); + const language = weightedChoice(languages, probabilities).replaceAll( + "_", + "-", + ); + return normalizeLocale(`${language}-${region}`); + } + + /** + * Get a random locale based on the language. + */ + async fromLanguage(language: string): Promise { + const [regions, probabilities] = await this.loadLanguageData(language); + const region = weightedChoice(regions, probabilities); + return normalizeLocale(`${language}-${region}`); + } +} + +export const SELECTOR = new StatisticalLocaleSelector(); diff --git a/typescript/src/multiversion.ts b/typescript/src/multiversion.ts new file mode 100644 index 000000000..f76354ad5 --- /dev/null +++ b/typescript/src/multiversion.ts @@ -0,0 +1,657 @@ +/** + * Manager for handling multiple Camoufox versions side by side. + * + * TypeScript twin of pythonlib/camoufox/multiversion.py. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { finished } from "node:stream/promises"; +import { INSTALL_DIR, OS_NAME, rprint } from "./paths.js"; +// pkgman and multiversion are mutually dependent, exactly as the Python twin's +// function-local imports are. Every use of these sits inside a function body, +// so the ESM cycle resolves before any binding is read. +import { + type AvailableVersion, + type CamoufoxFetcher, + cmpStr, + formatAssetDate, + makeExecutable, + RepoConfig, + unzip, + Version, + verifySha256, +} from "./pkgman.js"; + +export const BROWSERS_DIR: string = path.join(INSTALL_DIR, "browsers"); +export const CONFIG_FILE: string = path.join(INSTALL_DIR, "config.json"); +export const REPO_CACHE_FILE: string = path.join( + INSTALL_DIR, + "repo_cache.json", +); +export const COMPAT_FLAG: string = path.join(INSTALL_DIR, ".0.5_FLAG"); + +export interface CamoufoxConfig { + active_version?: string | null; + channel?: string; + pinned?: string; + pinned_sha?: string; + geoip?: string; + [key: string]: unknown; +} + +/** + * Load user config from disk, or return an empty object. + */ +export function loadConfig(): CamoufoxConfig { + if (fs.existsSync(CONFIG_FILE)) { + try { + return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); + } catch { + // Corrupt config: treat as absent, exactly as the Python twin does. + } + } + return {}; +} + +/** + * Get the default repo's stable channel string (like official/stable). + */ +export function getDefaultChannel(): string { + return `${RepoConfig.getDefaultName().toLowerCase()}/stable`; +} + +/** + * Save user config to disk. + */ +export function saveConfig(config: CamoufoxConfig): void { + fs.mkdirSync(INSTALL_DIR, { recursive: true }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +export interface CachedVersion { + version: string; + build: string; + url: string; + is_prerelease?: boolean; + asset_id?: number; + asset_size?: number; + asset_updated_at?: string; + sha256?: string; + created_at?: string; +} + +export interface RepoCache { + repos?: Array<{ name: string; versions?: CachedVersion[] }>; + [key: string]: unknown; +} + +/** + * Load cached repo data from disk. + */ +export function loadRepoCache(): RepoCache { + if (fs.existsSync(REPO_CACHE_FILE)) { + try { + return JSON.parse(fs.readFileSync(REPO_CACHE_FILE, "utf-8")); + } catch { + // Corrupt cache: treat as absent. + } + } + return {}; +} + +/** + * Save repo cache to disk. + */ +export function saveRepoCache(cache: RepoCache): void { + fs.mkdirSync(INSTALL_DIR, { recursive: true }); + fs.writeFileSync(REPO_CACHE_FILE, JSON.stringify(cache, null, 2)); +} + +/** + * Keep one cache entry per version-build, the newest by created_at. + */ +export function latestPerBuild(versions: CachedVersion[]): CachedVersion[] { + const best = new Map(); + for (const v of versions) { + const key = `${v.version}-${v.build}`; + const cur = best.get(key); + if (!cur || (v.created_at ?? "") > (cur.created_at ?? "")) { + best.set(key, v); + } + } + return [...best.values()].sort((a, b) => { + const byVersion = cmpStr(b.version, a.version); + if (byVersion !== 0) return byVersion; + return cmpStr(b.created_at ?? "", a.created_at ?? ""); + }); +} + +/** + * Get the display name for a repo from the repo config, lowercased. + */ +export function getRepoName(githubRepo: string): string { + for (const repo of RepoConfig.loadRepos()) { + if (repo.repos.includes(githubRepo)) { + return repo.name.toLowerCase(); + } + } + return githubRepo.split("/")[0].toLowerCase(); +} + +/** + * Information about an installed Camoufox version. + */ +export class InstalledVersion { + repoName: string; + version: Version; + path: string; + isActive: boolean; + isPrerelease: boolean; + assetId?: number; + assetSize?: number; + assetUpdatedAt?: string; + sha256?: string; + createdAt?: string; + + constructor(init: { + repoName: string; + version: Version; + path: string; + isActive?: boolean; + isPrerelease?: boolean; + assetId?: number; + assetSize?: number; + assetUpdatedAt?: string; + sha256?: string; + createdAt?: string; + }) { + this.repoName = init.repoName; + this.version = init.version; + this.path = init.path; + this.isActive = init.isActive ?? false; + this.isPrerelease = init.isPrerelease ?? false; + this.assetId = init.assetId; + this.assetSize = init.assetSize; + this.assetUpdatedAt = init.assetUpdatedAt; + this.sha256 = init.sha256; + this.createdAt = init.createdAt; + } + + /** Folder name, e.g. 150.0.2-beta.25-8020db3b. */ + get folderName(): string { + return path.basename(this.path); + } + + /** Path relative to INSTALL_DIR, e.g. browsers/official/150.0.2-beta.25. */ + get relativePath(): string { + return `browsers/${this.repoName}/${this.folderName}`; + } + + /** Channel display string (like official/stable/134.0.2-beta.20). */ + get channelPath(): string { + const ctype = this.isPrerelease ? "prerelease" : "stable"; + return `${this.repoName}/${ctype}/${this.version.fullString}`; + } + + /** + * Compare with an available version and return change indicators. + */ + getChanges(available: AvailableVersion): string[] { + const changes: string[] = []; + if (this.isPrerelease && !available.isPrerelease) { + changes.push("prerelease -> stable"); + } else if (!this.isPrerelease && available.isPrerelease) { + changes.push("stable -> prerelease"); + } + + if (this.assetUpdatedAt && available.assetUpdatedAt) { + if (this.assetUpdatedAt !== available.assetUpdatedAt) { + changes.push("asset updated"); + } + } else if (this.assetSize && available.assetSize) { + if (this.assetSize !== available.assetSize) { + changes.push("asset updated"); + } + } + + return changes; + } +} + +/** + * Install folder name with an optional sha8 suffix. + */ +export function versionFolderName( + version: string, + build: string, + sha8: string = "", +): string { + const base = `${version}-${build}`; + return sha8 ? `${base}-${sha8}` : base; +} + +/** + * Get the installed folder for a catalog item. Falls back to version-build/ + * (without the sha8) for backwards compatibility. + */ +function matchInstall( + full: string, + sha256: string | undefined, + byFolder: Map, + count: number, +): InstalledVersion | null { + const sha8 = (sha256 ?? "").slice(0, 8); + if (sha8) { + const exact = byFolder.get(`${full}-${sha8}`); + if (exact) return exact; + } + const legacy = byFolder.get(full); + if (!legacy) return null; + if (legacy.sha256) { + return legacy.sha256 === sha256 ? legacy : null; + } + return count <= 1 ? legacy : null; +} + +/** + * Match each catalog item to an install folder. + * Returns the matches and any orphaned leftovers. + */ +export function classifyInstalls( + versions: AvailableVersion[], + installed: InstalledVersion[], +): [Array, Array<[InstalledVersion, string]>] { + const counts = new Map(); + for (const v of versions) { + const key = v.version.fullString; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const byFolder = new Map(installed.map((iv) => [iv.folderName, iv])); + + const rowInst: Array = []; + const matched = new Set(); + for (const v of versions) { + const full = v.version.fullString; + const inst = matchInstall(full, v.sha256, byFolder, counts.get(full) ?? 0); + rowInst.push(inst); + if (inst) matched.add(inst.folderName); + } + + const extras: Array<[InstalledVersion, string]> = []; + for (const iv of installed) { + if (matched.has(iv.folderName)) continue; + const inCatalog = (counts.get(iv.version.fullString) ?? 0) > 0; + const note = inCatalog && !iv.sha256 ? "date unknown" : "unavailable"; + extras.push([iv, note]); + } + + return [rowInst, extras]; +} + +/** + * Installed version for a single version-build and sha; legacy folder allowed. + */ +export function findInstall( + versionBuild: string, + sha256: string | undefined, + installed: InstalledVersion[], + count: number = 1, +): InstalledVersion | null { + return matchInstall( + versionBuild, + sha256, + new Map(installed.map((iv) => [iv.folderName, iv])), + count, + ); +} + +/** + * Find an installed version by its build string. + */ +export function findInstalledByBuild( + build: string, + repoName?: string, +): InstalledVersion | null { + for (const v of listInstalled()) { + if (v.version.build === build && (!repoName || v.repoName === repoName)) { + return v; + } + } + return null; +} + +/** + * Scan browsers/ for installed versions, sorted by repo then version descending. + */ +export function listInstalled(): InstalledVersion[] { + const installed: InstalledVersion[] = []; + const active = loadConfig().active_version; + + if (!fs.existsSync(BROWSERS_DIR)) return installed; + + for (const repoEntry of fs.readdirSync(BROWSERS_DIR, { + withFileTypes: true, + })) { + if (!repoEntry.isDirectory() || repoEntry.name.startsWith(".")) continue; + const repoDir = path.join(BROWSERS_DIR, repoEntry.name); + + for (const versionEntry of fs.readdirSync(repoDir, { + withFileTypes: true, + })) { + if (!versionEntry.isDirectory()) continue; + const versionDir = path.join(repoDir, versionEntry.name); + const versionJson = path.join(versionDir, "version.json"); + if (!fs.existsSync(versionJson)) continue; + + try { + const ver = Version.fromPath(versionDir); + const versionData = JSON.parse(fs.readFileSync(versionJson, "utf-8")); + const relPath = `browsers/${repoEntry.name}/${versionEntry.name}`; + installed.push( + new InstalledVersion({ + repoName: repoEntry.name, + version: ver, + path: versionDir, + isActive: relPath === active, + isPrerelease: versionData.prerelease ?? false, + assetId: versionData.asset_id, + assetSize: versionData.asset_size, + assetUpdatedAt: versionData.asset_updated_at, + sha256: versionData.sha256, + createdAt: versionData.created_at, + }), + ); + } catch { + // Missing/corrupt version.json: not an install we can use. + } + } + } + + installed.sort((a, b) => { + const byRepo = cmpStr(b.repoName, a.repoName); + if (byRepo !== 0) return byRepo; + return b.version.compare(a.version); + }); + return installed; +} + +/** + * Get the path to the active version, or null if no version is active. + */ +export function getActivePath(): string | null { + const config = loadConfig(); + const active = config.active_version; + + if (active) { + const activePath = path.join(INSTALL_DIR, active); + if ( + fs.existsSync(activePath) && + fs.existsSync(path.join(activePath, "version.json")) + ) { + return activePath; + } + } + + // Only auto-select if the user didn't set a channel or pin + if (!config.channel && !config.pinned) { + const installed = listInstalled(); + if (installed.length) { + config.active_version = installed[0].relativePath; + saveConfig(config); + return installed[0].path; + } + } + + return null; +} + +/** + * Set the active version by its relative path. + */ +export function setActive(relativePath: string): void { + const config = loadConfig(); + config.active_version = relativePath; + saveConfig(config); +} + +/** + * Find an installed version by path, build, full version, or repo/build. + */ +export function findInstalledVersion(specifier: string): string | null { + const installed = listInstalled(); + if (!installed.length) return null; + + const lower = specifier.toLowerCase(); + + for (const v of installed) { + if ( + v.relativePath === specifier || + v.relativePath === `browsers/${specifier}` + ) { + return v.path; + } + if ( + `browsers/${v.repoName}/${v.version.fullString}`.endsWith(specifier) || + `${v.repoName}/${v.version.build}`.toLowerCase() === lower || + v.version.build.toLowerCase() === lower || + v.version.fullString.toLowerCase() === lower || + v.version.version?.toLowerCase() === lower + ) { + return v.path; + } + } + + return null; +} + +/** + * Install to browsers/{repoName}/{version}-{build}-{sha8}; the suffix is + * omitted when the release carries no sha. + */ +export async function installVersioned( + fetcher: CamoufoxFetcher, + replace: boolean = false, +): Promise { + const repoName = getRepoName(fetcher.githubRepo); + const sha8 = fetcher._selectedVersion?.sha256 + ? fetcher._selectedVersion.sha8 + : fetcher.installedSha8; + const versionFolder = versionFolderName(fetcher.version, fetcher.build, sha8); + const installPath = path.join(BROWSERS_DIR, repoName, versionFolder); + + if ( + fs.existsSync(installPath) && + fs.existsSync(path.join(installPath, "version.json")) + ) { + if (!replace) { + const installedV = findInstalledByBuild(fetcher.build, repoName); + let changeMsg = ""; + if (installedV && fetcher._selectedVersion) { + const changes = installedV.getChanges(fetcher._selectedVersion); + if (changes.length) changeMsg = ` (${changes.join(", ")})`; + } + + rprint( + `Version v${fetcher.verstr} already installed${changeMsg}.`, + "yellow", + ); + rprint( + changeMsg + ? "Use --replace to update with the new release." + : "Use --replace to reinstall.", + "yellow", + ); + if (!loadConfig().active_version) { + setActive(`browsers/${repoName}/${versionFolder}`); + } + return false; + } + rprint(`Replacing: ${installPath}`, "yellow"); + fs.rmSync(installPath, { recursive: true, force: true }); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-")); + const tempFilePath = path.join(tempDir, "camoufox.zip"); + + try { + fs.mkdirSync(installPath, { recursive: true }); + + const tempFileStream = fs.createWriteStream(tempFilePath); + // finished() listens for the stream's 'error' from the start. Without a + // listener, a failed write (disk full) is an uncaught 'error' event that + // kills the process before the catch below can remove the partial + // install. + const written = finished(tempFileStream); + written.catch(() => {}); // awaited below; a download error wins + try { + // Through the instance's class, as Python's fetcher.download_file(), + // so a subclass can override it. + await (fetcher.constructor as typeof CamoufoxFetcher).downloadFile( + tempFileStream, + fetcher.url, + ); + } finally { + tempFileStream.end(); + } + await written; + + const expectedSha = fetcher._selectedVersion + ? fetcher._selectedVersion.sha256 + : fetcher.installedSha256; + verifySha256(tempFilePath, expectedSha, `Camoufox v${fetcher.verstr}`); + + rprint(`Extracting Camoufox: ${installPath}`); + unzip(tempFilePath, installPath); + + const metadata = fetcher._selectedVersion + ? fetcher._selectedVersion.toMetadata() + : { + version: fetcher.version, + build: fetcher.build, + prerelease: fetcher.isPrerelease, + sha256: fetcher.installedSha256 ?? null, + created_at: fetcher.installedCreatedAt ?? null, + }; + fs.writeFileSync( + path.join(installPath, "version.json"), + JSON.stringify(metadata), + ); + + if (OS_NAME !== "win") { + makeExecutable(installPath); + } + + setActive(`browsers/${repoName}/${versionFolder}`); + + // Mark the install dir as compatible with this version + fs.writeFileSync(COMPAT_FLAG, ""); + + rprint(`\nCamoufox v${fetcher.verstr} installed.`, "green"); + rprint(`Path: ${installPath}`, "green"); + return true; + } catch (error) { + rprint(`Error: ${error}`, "red"); + if (fs.existsSync(installPath)) { + fs.rmSync(installPath, { recursive: true, force: true }); + } + throw error; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +/** + * Remove a specific version installation. + */ +export function removeVersion(versionPath: string): boolean { + if (!fs.existsSync(versionPath)) return false; + + rprint(`Removing: ${versionPath}`); + fs.rmSync(versionPath, { recursive: true, force: true }); + + const parent = path.dirname(versionPath); + if ( + fs.existsSync(parent) && + parent !== BROWSERS_DIR && + fs.readdirSync(parent).length === 0 + ) { + fs.rmdirSync(parent); + } + if ( + fs.existsSync(BROWSERS_DIR) && + fs.readdirSync(BROWSERS_DIR).length === 0 + ) { + fs.rmdirSync(BROWSERS_DIR); + } + + const config = loadConfig(); + const relPath = path.relative(INSTALL_DIR, versionPath); + if (!relPath.startsWith("..") && config.active_version === relPath) { + const remaining = listInstalled(); + config.active_version = remaining.length ? remaining[0].relativePath : null; + saveConfig(config); + } + + return true; +} + +/** + * Short tag to tell coexisting installs apart: date, else sha8. + */ +export function installedLabel(iv: InstalledVersion): string { + if (iv.createdAt) { + const date = formatAssetDate(iv.createdAt); + if (date) return date; + } + return (iv.sha256 ?? "").slice(0, 8); +} + +/** + * Print installed versions as a tree. + */ +export function printTree( + showHeader: boolean = true, + showPaths: boolean = false, +): void { + const installed = listInstalled(); + + if (!installed.length) { + rprint("No versions installed.", "yellow"); + rprint("Run `camoufox fetch` to install.", "yellow"); + return; + } + + if (showHeader) { + rprint("Installed versions:\n", "yellow"); + } + + let currentRepo: string | null = null; + for (let i = 0; i < installed.length; i++) { + const v = installed[i]; + const isLast = + i === installed.length - 1 || installed[i + 1].repoName !== v.repoName; + + if (v.repoName !== currentRepo) { + currentRepo = v.repoName; + rprint(`${currentRepo}/`, "cyan", false); + if (showPaths) { + rprint(` -> ${path.join(BROWSERS_DIR, currentRepo)}`, "bright_black"); + } else { + rprint(""); + } + } + + rprint(` ${isLast ? "โ””โ”€โ”€ " : "โ”œโ”€โ”€ "}`, undefined, false); + rprint(`v${v.version.fullString}`, v.isActive ? "green" : undefined, false); + rprint( + v.isPrerelease ? " (prerelease)" : " (stable)", + v.isPrerelease ? "yellow" : "blue", + false, + ); + const tag = installedLabel(v); + if (tag) rprint(` (${tag})`, "bright_black", false); + if (v.isActive) rprint(" (active)", "green", false); + rprint(""); + } +} diff --git a/typescript/src/paths.ts b/typescript/src/paths.ts new file mode 100644 index 000000000..113a03916 --- /dev/null +++ b/typescript/src/paths.ts @@ -0,0 +1,129 @@ +/** + * Platform constants and install-directory paths. + * + * These are the leaves of the module graph: pkgman.ts and multiversion.ts are + * mutually dependent (as their Python twins are, via function-local imports), + * and both need these values *at module-evaluation time*. Keeping them here + * means the cycle only ever involves function bodies, which ESM resolves + * cleanly. Everything is re-exported from pkgman.ts, which stays the public + * entry point for them. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { UnsupportedOS } from "./exceptions.js"; + +export const ARCH_MAP: Record = { + x64: "x86_64", + amd64: "x86_64", + x86: "x86_64", + ia32: "i686", + i686: "i686", + i386: "i686", + arm64: "arm64", + aarch64: "arm64", + arm: "arm64", +}; + +export const OS_MAP: Record = { + darwin: "mac", + linux: "lin", + win32: "win", +}; + +if (!(process.platform in OS_MAP)) { + throw new UnsupportedOS(`OS ${process.platform} is not supported`); +} + +export const OS_NAME: "mac" | "win" | "lin" = OS_MAP[process.platform]; + +const currentDir = + import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); + +/** + * platformdirs' user_cache_dir(appName), reimplemented so the TS and Python + * launchers share one install directory. Hardcoding ~/.cache would diverge on + * hosts that set XDG_CACHE_HOME, and on macOS/Windows entirely. + */ +export function userCacheDir(appName: string): string { + if (OS_NAME === "win") { + const localAppData = process.env.LOCALAPPDATA; + const base = + localAppData && path.isAbsolute(localAppData) + ? localAppData + : path.join(os.homedir(), "AppData", "Local"); + return path.join(base, appName, appName, "Cache"); + } + if (OS_NAME === "mac") { + return path.join(os.homedir(), "Library", "Caches", appName); + } + // platformdirs: any non-blank XDG_CACHE_HOME is taken as-is. + const xdg = process.env.XDG_CACHE_HOME ?? ""; + const base = xdg.trim() ? xdg : path.join(os.homedir(), ".cache"); + return path.join(base, appName); +} + +export const INSTALL_DIR: string = userCacheDir("camoufox"); + +/** + * The data files both launchers read. They live in pythonlib/camoufox/, the one + * copy in the repo; `pnpm build` copies them into dist/data-files/ for the npm + * tarball, so one seed draws one identity in either launcher. + */ +export const DATA_FILES: readonly string[] = [ + "essential-fonts.json", + "fingerprint-presets.json", + "fingerprint-presets-v150.json", + "font-bases.json", + "font-groups.json", + "fonts.json", + "media-devices.json", + "repos.yml", + "territoryInfo.xml", + "voice-manifests.json", + "voice-uris.json", + "warnings.yml", +]; + +/** + * Where DATA_FILES are read from: dist/data-files/ in a built package, else + * pythonlib's copies when running from src/ in the repo. + */ +export const LOCAL_DATA: string = fs.existsSync( + path.join(currentDir, "data-files"), +) + ? path.join(currentDir, "data-files") + : path.resolve(currentDir, "..", "..", "pythonlib", "camoufox"); + +export const OS_ARCH_MATRIX: Record = { + win: ["x86_64", "i686"], + mac: ["x86_64", "arm64"], + lin: ["x86_64", "arm64", "i686"], +}; + +export const LAUNCH_FILE: Record = { + win: "camoufox.exe", + mac: "../MacOS/camoufox", + lin: "camoufox-bin", +}; + +const COLORS: Record = { + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m", + bright_black: "\x1b[90m", +}; + +/** + * Print a styled message. The Python twin uses `rich`; keeping the same helper + * name means the ported call sites read identically. + */ +export function rprint(msg: string, fg?: string, nl: boolean = true): void { + const useColor = process.stdout.isTTY && !process.env.NO_COLOR; + const prefix = useColor ? `\x1b[1m${(fg && COLORS[fg]) || ""}` : ""; + const suffix = useColor ? "\x1b[0m" : ""; + process.stdout.write(`${prefix}${msg}${suffix}${nl ? "\n" : ""}`); +} diff --git a/typescript/src/pkgman.ts b/typescript/src/pkgman.ts new file mode 100644 index 000000000..de34b189e --- /dev/null +++ b/typescript/src/pkgman.ts @@ -0,0 +1,1281 @@ +/** + * Browser package management: version resolution, GitHub release discovery, + * download/extract, and path lookup. + * + * TypeScript twin of pythonlib/camoufox/pkgman.py. + */ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { Writable } from "node:stream"; +import AdmZip from "adm-zip"; +import cliProgress, { type Options as BarOptions } from "cli-progress"; +import prettyBytes from "pretty-bytes"; +import { parse as parseYaml } from "yaml"; +import { CONSTRAINTS, LIBRARY_VERSION } from "./__version__.js"; +import { + CamoufoxNotInstalled, + CorruptedDownload, + FileNotFoundError, + MissingRelease, + ProfileDirectoryError, + UnsupportedArchitecture, + UnsupportedOS, + UnsupportedVersion, +} from "./exceptions.js"; +// pkgman and multiversion are mutually dependent, exactly as the Python twin's +// function-local imports are. Every use below sits inside a function body, so +// the ESM cycle resolves before any binding is read. +import { + COMPAT_FLAG, + getActivePath, + getDefaultChannel, + installVersioned, + loadConfig, +} from "./multiversion.js"; +import { + ARCH_MAP, + INSTALL_DIR, + LAUNCH_FILE, + LOCAL_DATA, + OS_ARCH_MATRIX, + OS_MAP, + OS_NAME, + rprint, +} from "./paths.js"; + +// Platform constants and install paths live in paths.ts so that the +// pkgman <-> multiversion cycle never needs them mid-evaluation. Re-exported +// here so pkgman stays the single public entry point for them. +export { + ARCH_MAP, + INSTALL_DIR, + LAUNCH_FILE, + LOCAL_DATA, + OS_ARCH_MATRIX, + OS_MAP, + OS_NAME, + rprint, + userCacheDir, +} from "./paths.js"; + +/** GITHUB_TOKEN, as the Python twin reads it: once, at import. */ +const GITHUB_TOKEN: string | undefined = process.env.GITHUB_TOKEN; + +/** Bearer auth for GitHub API calls only (never for asset downloads). */ +function githubHeaders(url: string): Record { + return url.includes("api.github") && GITHUB_TOKEN + ? { Authorization: `Bearer ${GITHUB_TOKEN}` } + : {}; +} + +/** requests' raise_for_status() message, so callers can match "404". */ +function raiseForStatus(response: Response, url: string): void { + if (response.ok) return; + const kind = response.status < 500 ? "Client Error" : "Server Error"; + throw new Error( + `${response.status} ${kind}: ${response.statusText} for url: ${url}`, + ); +} + +/** + * Ensure Firefox's Linux application directory exists before startup. + * + * Firefox probes ~/.camoufox even when Playwright supplies a temporary profile. + * On a read-only HOME -- the normal shape for a container that bakes the bundle + * in as root and runs as a non-root user -- a missing directory makes startup + * stall with no diagnostic, surfacing as a launch timeout rather than as a + * permissions error. An existing directory may itself still be read-only, which + * is fine: Firefox only needs it to be there. + */ +export function ensureBrowserProfileDir( + env?: Record, +): string | undefined { + if (OS_NAME !== "lin") return undefined; + + const environment = env ?? process.env; + const configuredHome = environment.HOME; + const home = configuredHome + ? expandUser(String(configuredHome)) + : os.homedir(); + const profileDir = path.join(home, ".camoufox"); + if (fs.existsSync(profileDir) && fs.statSync(profileDir).isDirectory()) { + return profileDir; + } + + try { + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + } catch (error) { + throw new ProfileDirectoryError( + `Camoufox requires '${profileDir}' to exist before launch, but it could ` + + "not be created. For a read-only runtime, create this directory " + + "before making HOME read-only.", + { cause: error }, + ); + } + + if (!fs.statSync(profileDir).isDirectory()) { + throw new ProfileDirectoryError( + `Camoufox requires '${profileDir}' to be a directory before launch.`, + ); + } + return profileDir; +} + +/** os.path.expanduser for the forms a HOME value can take. */ +function expandUser(p: string): string { + if (p === "~") return os.homedir(); + if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + return p; +} + +/** + * Parse a semver string into a comparable tuple. + */ +function parseSemver(version: string): number[] { + const parts = version.replace(/^[\^~]+/, "").split("."); + // int() semantics: the whole part must be an integer ("1a" -> 0). + const out = parts.map((part) => + /^\s*[+-]?\d+\s*$/.test(part) ? Number.parseInt(part, 10) : 0, + ); + while (out.length < 3) out.push(0); + return out; +} + +function compareTuples(a: number[], b: number[]): number { + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const x = a[i] ?? 0; + const y = b[i] ?? 0; + if (x < y) return -1; + if (x > y) return 1; + } + return 0; +} + +/** + * A comparable browser version string (up to 5 parts). + */ +export class Version { + readonly build: string; + readonly version?: string; + readonly sortedRel: number[]; + + constructor(build: string, version?: string) { + this.build = build; + this.version = version; + // Mirrors the Python twin: digits stay numeric, a leading letter becomes + // ord(c) - 1024 so "alpha" < "beta" < numeric builds sort sanely. + const parts = build + .split(".") + .map((x) => (/^\d+$/.test(x) ? Number(x) : x.charCodeAt(0) - 1024)); + const padding = 5 - (build.split(".").length - 1); + for (let i = 0; i < padding; i++) parts.push(0); + this.sortedRel = parts; + } + + get fullString(): string { + return `${this.version}-${this.build}`; + } + + /** Whether the build channel is alpha (like "alpha.26"). */ + get isAlpha(): boolean { + return this.build.split(".")[0].toLowerCase() === "alpha"; + } + + compare(other: Version): number { + return compareTuples(this.sortedRel, other.sortedRel); + } + + equals(other: Version): boolean { + return this.compare(other) === 0; + } + + lessThan(other: Version): boolean { + return this.compare(other) < 0; + } + + greaterOrEqual(other: Version): boolean { + return this.compare(other) >= 0; + } + + isSupported(): boolean { + return ( + this.compare(effectiveVersionMin()) >= 0 && this.compare(VERSION_MAX) < 0 + ); + } + + /** + * Read the version from version.json at the given path. + */ + static fromPath(dir: string = INSTALL_DIR): Version { + const versionPath = path.join(dir, "version.json"); + if (!fs.existsSync(versionPath)) { + throw new FileNotFoundError( + `Version information not found at ${versionPath}. ` + + "Please run `camoufox fetch` to install.", + ); + } + const data = JSON.parse(fs.readFileSync(versionPath, "utf-8")); + // "release" and then "tag" win over "build", as the Python twin's pops do. + const build = + "release" in data ? data.release : "tag" in data ? data.tag : data.build; + if (build === undefined) { + throw new Error(`KeyError: 'build' (in ${versionPath})`); + } + return new Version(build, data.version ?? undefined); + } + + static buildMinMax(): [Version, Version] { + return [ + new Version(CONSTRAINTS.MIN_VERSION), + new Version(CONSTRAINTS.MAX_VERSION), + ]; + } +} + +export const [VERSION_MIN, VERSION_MAX] = Version.buildMinMax(); + +/** + * Seams the Python tests reach with monkeypatch (VERSION_MIN, + * _resolved_playwright_version, CamoufoxFetcher). Production code never + * reassigns these. + */ +export const pkgmanDeps = { + versionMin: (): Version => VERSION_MIN, + resolvedPlaywrightVersion: (): number[] | null => resolvedPlaywrightVersion(), + /** The fetcher the auto-install path runs `install()` on. */ + newFetcher: async (): Promise<{ install(): Promise }> => + new CamoufoxFetcher().init(), +}; + +/** + * The resolved playwright-core version string, or null when it cannot be read. + * The Python twin asks importlib.metadata for `playwright`; the npm package + * that plays that role here is playwright-core. + */ +function resolvedPlaywrightVersionRaw(): string | null { + try { + const require = createRequire(import.meta.url); + const pkg = JSON.parse( + fs.readFileSync(require.resolve("playwright-core/package.json"), "utf-8"), + ); + return typeof pkg.version === "string" ? pkg.version : null; + } catch { + return null; + } +} + +/** The installed Playwright version, or null if it cannot be determined. */ +export function resolvedPlaywrightVersion(): number[] | null { + const raw = resolvedPlaywrightVersionRaw(); + return raw === null ? null : parseSemver(raw); +} + +/** The installed Playwright version for messages ("the installed version" + * when it cannot be read). */ +export function resolvedPlaywrightVersionStr(): string { + return resolvedPlaywrightVersionRaw() ?? "the installed version"; +} + +/** + * The lowest browser build this install can actually talk to. + * + * VERSION_MIN, raised by whatever the resolved Playwright requires. When the + * Playwright version cannot be read we fall back to VERSION_MIN rather than + * assuming the worst: a spurious forced re-download is worse than leaving a + * working install alone, and package.json caps Playwright anyway. + */ +export function effectiveVersionMin(): Version { + let floor = pkgmanDeps.versionMin(); + const playwrightVersion = pkgmanDeps.resolvedPlaywrightVersion(); + if (playwrightVersion === null) return floor; + for (const [ + requiredPlaywright, + build, + ] of CONSTRAINTS.PLAYWRIGHT_BROWSER_FLOORS) { + if ( + compareTuples(playwrightVersion, [...requiredPlaywright]) >= 0 && + floor.lessThan(new Version(build)) + ) { + floor = new Version(build); + } + } + return floor; +} + +/** One `versions:` entry of a browser repo in repos.yml. */ +export interface BrowserVersionConstraint { + python_library?: { min?: string; max?: string }; + /** Absent means "assume every build is supported". */ + browser?: { + stable?: { min?: string; max?: string }; + prerelease?: { min?: string; max?: string }; + min?: string; + max?: string; + } | null; +} + +/** One `browsers:` entry of repos.yml. */ +export interface BrowserRepoEntry { + /** Primary repo first, then fallbacks: a comma-separated string or a list. */ + repo: string | string[]; + name: string; + pattern?: string; + versions?: BrowserVersionConstraint[]; +} + +/** + * Load a bundled YAML data file (repos.yml, warnings.yml, ...). + */ +export function loadYaml(file: string): Record { + return ( + (parseYaml(fs.readFileSync(path.join(LOCAL_DATA, file), "utf-8")) as Record< + string, + any + >) ?? {} + ); +} + +/** + * Find the browser build constraint for the current library version. + */ +function findVersionConstraints( + versions: BrowserVersionConstraint[], + libraryVersion: string, +): BrowserVersionConstraint["browser"] | undefined { + const libParts = parseSemver(libraryVersion); + let newest: BrowserVersionConstraint["browser"] | undefined; + let newestMin: number[] | undefined; + + for (const entry of versions) { + const pyLib = entry.python_library ?? {}; + const libMin = parseSemver(pyLib.min ?? "0"); + const libMax = parseSemver(pyLib.max ?? "999"); + if ( + compareTuples(libMin, libParts) <= 0 && + compareTuples(libParts, libMax) < 0 + ) { + return entry.browser; + } + if (newestMin === undefined || compareTuples(libMin, newestMin) > 0) { + newestMin = libMin; + newest = entry.browser; + } + } + return newest; +} + +/** + * Get the min and max build bounds for a channel. + */ +function channelBounds( + browser: BrowserVersionConstraint["browser"] | undefined, + channel: "stable" | "prerelease", +): [string | undefined, string | undefined] { + if (!browser) return [undefined, undefined]; + if ("stable" in browser || "prerelease" in browser) { + const section = browser[channel] ?? {}; + return [section.min, section.max]; + } + return [browser.min, browser.max]; +} + +/** + * Configuration for a Camoufox repository. + */ +export class RepoConfig { + repos: string[]; + name: string; + pattern: string; + stableMin?: string; + stableMax?: string; + prereleaseMin?: string; + prereleaseMax?: string; + + constructor(init: { + repos: string[]; + name: string; + pattern: string; + stableMin?: string; + stableMax?: string; + prereleaseMin?: string; + prereleaseMax?: string; + }) { + this.repos = init.repos; + this.name = init.name; + this.pattern = init.pattern; + this.stableMin = init.stableMin; + this.stableMax = init.stableMax; + this.prereleaseMin = init.prereleaseMin; + this.prereleaseMax = init.prereleaseMax; + } + + /** Primary GitHub repo. */ + get repo(): string { + return this.repos[0]; + } + + static loadRepos(spoofLibraryVersion?: string): RepoConfig[] { + const data = loadYaml("repos.yml"); + return ((data.browsers ?? []) as BrowserRepoEntry[]).map((r) => + RepoConfig.fromEntry(r, spoofLibraryVersion), + ); + } + + static getDefaultName(): string { + return loadYaml("repos.yml").default?.browser ?? "Official"; + } + + static fromEntry( + entry: BrowserRepoEntry, + spoofLibraryVersion?: string, + ): RepoConfig { + if (!("pattern" in entry) || entry.pattern == null) { + throw new Error( + `Repo '${entry.name ?? "unknown"}' missing required pattern`, + ); + } + + let browser: BrowserVersionConstraint["browser"] | undefined; + if (entry.versions?.length) { + browser = findVersionConstraints( + entry.versions, + spoofLibraryVersion || LIBRARY_VERSION, + ); + } + const [stableMin, stableMax] = channelBounds(browser, "stable"); + const [prereleaseMin, prereleaseMax] = channelBounds(browser, "prerelease"); + + // Parse comma separated repos list (primary + fallbacks) + const repos = + typeof entry.repo === "string" + ? entry.repo.split(",").map((r) => r.trim()) + : entry.repo; + + return new RepoConfig({ + repos, + name: entry.name, + pattern: String(entry.pattern), + stableMin, + stableMax, + prereleaseMin, + prereleaseMax, + }); + } + + static getDefault(): RepoConfig { + const found = RepoConfig.findByName(RepoConfig.getDefaultName()); + return found ?? RepoConfig.loadRepos()[0]; + } + + static findByName(name: string): RepoConfig | undefined { + const lower = name.toLowerCase(); + return RepoConfig.loadRepos().find((r) => r.name.toLowerCase() === lower); + } + + getOsName(spoofOs?: string): string { + if (spoofOs) return spoofOs; + const osName = OS_MAP[process.platform]; + if (!osName) { + throw new UnsupportedOS(`OS ${process.platform} is not supported`); + } + return osName; + } + + getArch(spoofArch?: string): string { + if (spoofArch) return spoofArch; + const platArch = os.arch().toLowerCase(); + const arch = ARCH_MAP[platArch]; + if (!arch) { + throw new UnsupportedArchitecture( + `Architecture ${platArch} is not supported`, + ); + } + return arch; + } + + /** + * Build the asset regex from the config pattern string. + */ + buildPattern(spoofOs?: string, spoofArch?: string): RegExp { + const replacements: Record = { + name: "(?\\w+)", + version: "(?[^-]+)", + build: "(?[^-]+)", + os: escapeRegExp(this.getOsName(spoofOs)), + arch: escapeRegExp(this.getArch(spoofArch)), + }; + const pattern = this.pattern.replace(/\./g, "\\."); + const regex = pattern.replace( + /\{(\w+)\}/g, + (match, key) => replacements[key] ?? match, + ); + return new RegExp(`^${regex}`); + } + + /** + * Check if a build is within the supported range for its channel. + */ + isVersionSupported(version: Version, isPrerelease: boolean = false): boolean { + const buildMin = isPrerelease ? this.prereleaseMin : this.stableMin; + const buildMax = isPrerelease ? this.prereleaseMax : this.stableMax; + if (buildMin == null || buildMax == null) { + return true; + } + return ( + new Version(buildMin).compare(version) <= 0 && + version.compare(new Version(buildMax)) <= 0 + ); + } +} + +/** Python's str ordering (code points), not localeCompare's collation. */ +export function cmpStr(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export interface GitHubAsset { + name: string; + browser_download_url: string; + id?: number; + size?: number; + updated_at?: string; + created_at?: string; + digest?: string; +} + +export interface GitHubRelease { + prerelease?: boolean; + assets: GitHubAsset[]; +} + +/** + * Manages fetching GitHub releases with fallback repos. + */ +export class GitHubDownloader { + githubRepos: string[]; + githubRepo: string; + isPrerelease = false; + + constructor(githubRepos: string | string[]) { + this.githubRepos = + typeof githubRepos === "string" ? [githubRepos] : githubRepos; + this.githubRepo = this.githubRepos[0]; + } + + /** Return truthy data if this is the desired asset, else null. */ + checkAsset(asset: GitHubAsset, _release?: GitHubRelease): any { + return asset.browser_download_url; + } + + missingAssetError(): never { + throw new MissingRelease( + `Could not find a release asset in ${this.githubRepo}.`, + ); + } + + protected async getReleases(githubRepo: string): Promise { + const apiUrl = `https://api.github.com/repos/${githubRepo}/releases`; + const response = await fetch(apiUrl, { + headers: githubHeaders(apiUrl), + signal: AbortSignal.timeout(20_000), + }); + raiseForStatus(response, apiUrl); + return (await response.json()) as GitHubRelease[]; + } + + /** + * Fetch the first matching release asset, trying fallback repos on failure. + */ + async getAsset(): Promise { + let lastError: unknown; + for (const repo of this.githubRepos) { + try { + const releases = await this.getReleases(repo); + for (const release of releases) { + for (const asset of release.assets ?? []) { + const data = this.checkAsset(asset, release); + if (data) { + this.githubRepo = repo; + this.isPrerelease = release.prerelease ?? false; + return data; + } + } + } + } catch (error) { + lastError = error; + } + } + + if (lastError) throw lastError; + this.missingAssetError(); + } +} + +/** + * Information about an available Camoufox version on GitHub. + */ +export class AvailableVersion { + version: Version; + url: string; + isPrerelease: boolean; + assetId?: number; + assetSize?: number; + assetUpdatedAt?: string; + sha256?: string; + assetCreatedAt?: string; + + constructor(init: { + version: Version; + url: string; + isPrerelease: boolean; + assetId?: number; + assetSize?: number; + assetUpdatedAt?: string; + sha256?: string; + assetCreatedAt?: string; + }) { + Object.assign(this, init); + this.version = init.version; + this.url = init.url; + this.isPrerelease = init.isPrerelease; + } + + /** First 8 hex chars of the sha256, or empty when unknown. */ + get sha8(): string { + return (this.sha256 ?? "").slice(0, 8); + } + + get display(): string { + const pre = this.isPrerelease ? " (prerelease)" : ""; + return `v${this.version.fullString}${pre}`; + } + + toMetadata(): Record { + return { + version: this.version.version ?? null, + build: this.version.build, + prerelease: this.isPrerelease, + asset_id: this.assetId ?? null, + asset_size: this.assetSize ?? null, + asset_updated_at: this.assetUpdatedAt ?? null, + sha256: this.sha256 ?? null, + created_at: this.assetCreatedAt ?? null, + }; + } +} + +/** + * Handles fetching and installing Camoufox. + */ +export class CamoufoxFetcher extends GitHubDownloader { + repoConfig: RepoConfig; + arch: string; + pattern: RegExp; + installedSha256?: string; + installedCreatedAt?: string; + _versionObj?: Version; + _selectedVersion?: AvailableVersion; + _url?: string; + + constructor(repoConfig?: RepoConfig, selectedVersion?: AvailableVersion) { + const config = repoConfig ?? RepoConfig.getDefault(); + super(config.repos); + this.repoConfig = config; + this.arch = this.getPlatformArch(); + this.pattern = this.repoConfig.buildPattern(); + + if (selectedVersion) { + this._selectedVersion = selectedVersion; + this._versionObj = selectedVersion.version; + this._url = selectedVersion.url; + this.isPrerelease = selectedVersion.isPrerelease; + this.installedSha256 = selectedVersion.sha256; + this.installedCreatedAt = selectedVersion.assetCreatedAt; + } + } + + /** + * The Python constructor calls fetch_latest() inline; downloads are async in + * JS, so callers do `await new CamoufoxFetcher().init()` instead. + */ + async init(): Promise { + if (!this._versionObj) { + await this.fetchLatest(); + } + return this; + } + + /** First 8 hex chars of the installed asset sha, or empty. */ + get installedSha8(): string { + return (this.installedSha256 ?? "").slice(0, 8); + } + + /** + * Match a release asset against version constraints, OS, and arch. + */ + checkAsset( + asset: GitHubAsset, + release?: GitHubRelease, + ): [Version, string] | null { + const match = this.pattern.exec(asset.name); + if (!match?.groups) return null; + + const version = new Version(match.groups.build, match.groups.version); + const isPrerelease = Boolean(release?.prerelease) || version.isAlpha; + if (!this.repoConfig.isVersionSupported(version, isPrerelease)) { + return null; + } + + const digest = asset.digest ?? ""; + if (digest.startsWith("sha256:")) { + this.installedSha256 = digest.slice("sha256:".length); + } + this.installedCreatedAt = asset.created_at; + + return [version, asset.browser_download_url]; + } + + missingAssetError(): never { + throw new MissingRelease( + `No matching release found for ${OS_NAME} ${this.arch} in the ` + + "supported range. Please update the Python library.", + ); + } + + getPlatformArch(): string { + const arch = (this.repoConfig ?? RepoConfig.getDefault()).getArch(); + if (!OS_ARCH_MATRIX[OS_NAME].includes(arch)) { + throw new UnsupportedArchitecture( + `Architecture ${arch} is not supported for ${OS_NAME}`, + ); + } + return arch; + } + + /** + * Fetch the latest camoufox release for the current platform. + */ + async fetchLatest(): Promise { + const [versionObj, url] = await this.getAsset(); + this._versionObj = versionObj; + this._url = url; + } + + static async downloadFile(file: Writable, url: string): Promise { + rprint(`Downloading package: ${url}`); + await webdl(url, undefined, true, file); + } + + /** + * Download and install camoufox to a versioned subdirectory. + */ + async install(replace: boolean = false): Promise { + const installed = await installVersioned(this, replace); + ensureBrowserProfileDir(); + return installed; + } + + get url(): string { + if (!this._url) { + throw new Error("Url is not available. Make sure to run init() first."); + } + return this._url; + } + + get version(): string { + if (!this._versionObj?.version) { + throw new Error( + "Version is not available. Make sure to run init() first.", + ); + } + return this._versionObj.version; + } + + get build(): string { + if (!this._versionObj) { + throw new Error( + "Build information is not available. Make sure to run init() first.", + ); + } + return this._versionObj.build; + } + + get verstr(): string { + if (!this._versionObj) { + throw new Error( + "Version is not available. Make sure to run init() first.", + ); + } + return this._versionObj.fullString; + } +} + +/** + * Fetch all supported versions from GitHub for the current platform. + */ +export async function listAvailableVersions( + repoConfig?: RepoConfig, + includePrerelease: boolean = true, + spoofOs?: string, + spoofArch?: string, +): Promise { + const config = repoConfig ?? RepoConfig.getDefault(); + const pattern = config.buildPattern(spoofOs, spoofArch); + + const osName = spoofOs ?? OS_NAME; + const arch = config.getArch(spoofArch); + if (!(OS_ARCH_MATRIX[osName] ?? []).includes(arch)) { + throw new UnsupportedArchitecture( + `Architecture ${arch} is not supported for ${osName}`, + ); + } + + let releases: GitHubRelease[] = []; + let lastError: unknown; + for (const repo of config.repos) { + try { + const apiUrl = `https://api.github.com/repos/${repo}/releases`; + const resp = await fetch(apiUrl, { + headers: githubHeaders(apiUrl), + signal: AbortSignal.timeout(20_000), + }); + raiseForStatus(resp, apiUrl); + releases = (await resp.json()) as GitHubRelease[]; + break; + } catch (error) { + lastError = error; + } + } + if (!releases.length && lastError) throw lastError; + + const versions: AvailableVersion[] = []; + + for (const release of releases) { + const isPrerelease = release.prerelease ?? false; + if (isPrerelease && !includePrerelease) continue; + + for (const asset of release.assets ?? []) { + const match = pattern.exec(asset.name); + if (!match?.groups) continue; + + const version = new Version(match.groups.build, match.groups.version); + const assetPrerelease = isPrerelease || version.isAlpha; + if (assetPrerelease && !includePrerelease) continue; + if (!config.isVersionSupported(version, assetPrerelease)) continue; + + const digest = asset.digest ?? ""; + const sha256 = digest.startsWith("sha256:") + ? digest.slice("sha256:".length) + : undefined; + + versions.push( + new AvailableVersion({ + version, + url: asset.browser_download_url, + isPrerelease: assetPrerelease, + assetId: asset.id, + assetSize: asset.size, + assetUpdatedAt: asset.updated_at, + sha256, + assetCreatedAt: asset.created_at, + }), + ); + } + } + + versions.sort((a, b) => { + const byVersion = b.version.compare(a.version); + if (byVersion !== 0) return byVersion; + return cmpStr(b.assetCreatedAt ?? "", a.assetCreatedAt ?? ""); + }); + return versions; +} + +/** + * Get the full version string of the active install. + */ +export function installedVerStr(fromDir?: string): string { + // An explicit directory (the folder holding a sandbox/alt-version + // executable_path) reads that build's version.json instead of the active + // install's. Mac-bundle aware: an executable inside + // Camoufox.app/Contents/MacOS/ belongs to an install whose version.json sits + // three levels up -- unless a deployment stamped one beside the binary. + if (fromDir) { + let dir = fromDir; + if ( + path.basename(dir) === "MacOS" && + !fs.existsSync(path.join(dir, "version.json")) + ) { + dir = path.dirname(path.dirname(path.dirname(dir))); + } + return Version.fromPath(dir).fullString; + } + + const active = getActivePath(); + if (active === null) { + const config = loadConfig(); + const pinned = config.pinned; + const channel = config.channel || getDefaultChannel(); + const activeDisplay = pinned ? `${channel}/${pinned}` : channel; + throw new CamoufoxNotInstalled( + `${activeDisplay} is not installed. Please run \`camoufox fetch\` to install.`, + ); + } + return Version.fromPath(active).fullString; +} + +/** + * Whether INSTALL_DIR's root holds a supported build. + * + * Only the pre-multiversion flat layout wrote version.json at the root; the + * versioned layout keeps it under browsers///. A missing root + * version.json means "no legacy install here", so the caller should fall + * through to a fetch rather than raise. The alpha.1 floor masked this: no + * install was ever unsupported, so this branch was never reached. + */ +export function rootInstallSupported(): boolean { + try { + return Version.fromPath().isSupported(); + } catch (error) { + if (error instanceof FileNotFoundError) return false; + throw error; + } +} + +function notInstalledError(): CamoufoxNotInstalled { + const config = loadConfig(); + const pinned = config.pinned; + const channel = config.channel || getDefaultChannel(); + const activeDisplay = pinned ? `${channel}/${pinned}` : channel; + return new CamoufoxNotInstalled( + `${activeDisplay} is not installed. Please run \`camoufox fetch\` to install.`, + ); +} + +/** + * The part of the Python twin's camoufox_path() that runs before it would + * download: returns the install to use, or null when a fetch is needed (only + * possible with `downloadIfMissing`; otherwise the Python errors are raised). + */ +function resolveInstalledPath(downloadIfMissing: boolean): string | null { + // Clean up incompatible old data directory + if ( + fs.existsSync(INSTALL_DIR) && + fs.readdirSync(INSTALL_DIR).length > 0 && + !fs.existsSync(COMPAT_FLAG) + ) { + rprint("Cleaning old data...", "yellow"); + fs.rmSync(INSTALL_DIR, { recursive: true, force: true }); + } + + const active = getActivePath(); + if (active && Version.fromPath(active).isSupported()) { + return active; + } + + if (!fs.existsSync(INSTALL_DIR) || fs.readdirSync(INSTALL_DIR).length === 0) { + if (!downloadIfMissing) throw notInstalledError(); + } else if (rootInstallSupported()) { + return INSTALL_DIR; + } else if (!downloadIfMissing) { + throw new UnsupportedVersion("Camoufox executable is outdated."); + } + return null; +} + +/** + * Full path to the active camoufox folder. + * + * This is the Python twin's camoufox_path(download_if_missing=False): it is + * called from synchronous path helpers, and a download is asynchronous in JS. + * `ensureCamoufoxInstalled()` is the download_if_missing=True half; + * `launchOptions()` awaits it before any path lookup, so first-run + * auto-download behaviour is preserved. + */ +export function camoufoxPath(): string { + return resolveInstalledPath(false) as string; +} + +/** + * Resolve the active browser, downloading it first when nothing usable is + * installed. The async counterpart to the Python twin's camoufox_path(). + */ +export async function ensureCamoufoxInstalled(): Promise { + const found = resolveInstalledPath(true); + if (found !== null) return found; + + const fetcher = await pkgmanDeps.newFetcher(); + await fetcher.install(); + + // Re-check rather than recurse. + // + // If the newest published build is still below the floor -- a library + // published ahead of its browser release, or a repos source that does not + // carry it -- install() is a no-op ("already installed") and recursing here + // spun ~1000 fetch attempts into a stack overflow, having hammered the + // GitHub API into a rate limit on the way. Say what is actually wrong. + const active = getActivePath(); + if (active && Version.fromPath(active).isSupported()) { + return active; + } + if (fs.existsSync(INSTALL_DIR) && rootInstallSupported()) { + return INSTALL_DIR; + } + throw new UnsupportedVersion( + "No available Camoufox build satisfies this library's minimum " + + `(${CONSTRAINTS.MIN_VERSION}). The matching browser release may not be ` + + "published yet; wait for it, or install an older camoufox release.", + ); +} + +/** + * Get the path to a file in the camoufox directory. + * + * `baseDir` (the directory of an explicit/sandbox executable_path) resolves + * resources relative to that build instead of the active install, so a sandbox + * binary doesn't fall back to the cache dir. + */ +export function getPath(file: string, baseDir?: string): string { + if (baseDir) { + // Mac-bundle aware: an executable in Camoufox.app/Contents/MacOS/ keeps + // its resources under ../Resources/. + if (path.basename(baseDir) === "MacOS") { + return path.join(baseDir, "..", "Resources", file); + } + return path.join(baseDir, file); + } + if (OS_NAME === "mac") { + return path.resolve( + camoufoxPath(), + "Camoufox.app", + "Contents", + "Resources", + file, + ); + } + return path.join(camoufoxPath(), file); +} + +/** + * Get the path to the camoufox executable. + */ +export function launchPath(browserPath?: string): string { + let execPath: string; + if (browserPath) { + execPath = + OS_NAME === "mac" + ? path.resolve( + browserPath, + "Camoufox.app", + "Contents", + "Resources", + LAUNCH_FILE[OS_NAME], + ) + : path.join(browserPath, LAUNCH_FILE[OS_NAME]); + } else { + execPath = getPath(LAUNCH_FILE[OS_NAME]); + } + + if (!fs.existsSync(execPath)) { + throw new CamoufoxNotInstalled( + `Camoufox is not installed at ${browserPath ?? camoufoxPath()}. ` + + "Please run `camoufox fetch` to install.", + ); + } + return execPath; +} + +const formatBytes = (v: number, _: BarOptions, type: string) => + type === "total" || type === "value" ? prettyBytes(v) : String(v); + +export type ProgressCallback = (downloaded: number, total: number) => void; + +/** + * Download a file from the given URL. Streams into `buffer` when one is given, + * otherwise accumulates and returns the bytes. + * + * One attempt, like the Python twin's requests.get(): an HTTP error raises + * requests' " Client Error: ... for url: ..." message. + */ +export async function webdl( + url: string, + desc?: string, + bar: boolean = true, + buffer: Writable | null = null, + { progressCallback }: { progressCallback?: ProgressCallback } = {}, +): Promise { + const response = await fetch(url, { headers: githubHeaders(url) }); + raiseForStatus(response, url); + + const totalSize = Number.parseInt( + response.headers.get("content-length") || "0", + 10, + ); + let progressBar: cliProgress.SingleBar | null = null; + if (!progressCallback && bar) { + progressBar = new cliProgress.SingleBar( + { + format: `${desc || "Downloading"} [{bar}] {percentage}% | ETA: {eta_formatted} | {value}/{total}`, + formatValue: formatBytes, + noTTYOutput: true, + }, + cliProgress.Presets.shades_classic, + ); + progressBar.start(totalSize, 0); + } + + const chunks: Uint8Array[] = []; + let downloaded = 0; + let lastUpdate = 0; + try { + if (!response.body) { + throw new Error(`Response from ${url} had no body`); + } + for await (const chunk of response.body as unknown as AsyncIterable) { + if (buffer) { + // A stream that already failed (disk full) never drains: surface + // its error instead of waiting on it. When it is merely slow, + // wait for it, or the whole archive ends up queued in memory. + if (buffer.errored) throw buffer.errored; + if (!buffer.write(chunk)) await once(buffer, "drain"); + } else { + chunks.push(chunk); + } + downloaded += chunk.length; + if (progressCallback) { + if (downloaded - lastUpdate >= 65536 || downloaded === totalSize) { + progressCallback(downloaded, totalSize); + lastUpdate = downloaded; + } + } else if (progressBar) { + progressBar.increment(chunk.length); + } else if (totalSize) { + const pct = (downloaded / totalSize) * 100; + process.stdout.write(`\r${desc}: ${pct.toFixed(0)}%`); + } + } + } finally { + progressBar?.stop(); + } + if (!progressCallback && !bar) { + process.stdout.write(desc ? `\r${desc}: Complete\n` : "\n"); + } + + return Buffer.concat(chunks); +} + +/** + * Check a downloaded file against its expected sha256 digest. + * + * Takes the downloaded bytes or the path of the file they were written to. + * Raises CorruptedDownload on mismatch. Skips (with a warning) when no digest + * is known, so installs from sources that publish no digest still work. + */ +export function verifySha256( + buffer: Buffer | Uint8Array | string, + expected: string | null | undefined, + desc: string = "asset", +): void { + if (!expected) { + rprint( + `Warning: no sha256 published for ${desc}; skipping verification.`, + "yellow", + ); + return; + } + + const digest = createHash("sha256"); + if (typeof buffer === "string") { + const fd = fs.openSync(buffer, "r"); + try { + const block = Buffer.alloc(1024 * 1024); + let read = fs.readSync(fd, block, 0, block.length, null); + while (read > 0) { + digest.update(block.subarray(0, read)); + read = fs.readSync(fd, block, 0, block.length, null); + } + } finally { + fs.closeSync(fd); + } + } else { + digest.update(buffer); + } + + const actual = digest.digest("hex"); + if (actual !== expected.toLowerCase()) { + throw new CorruptedDownload( + `Checksum mismatch for ${desc}.\n` + + ` expected sha256: ${expected.toLowerCase()}\n` + + ` actual sha256: ${actual}\n` + + "The download was corrupted or tampered with. Installation aborted.", + ); + } +} + +/** + * Extract a zip file to the given path. + */ +export function unzip( + zipFile: Buffer | string, + extractPath: string, + desc?: string, + bar: boolean = true, +): void { + const zip = new AdmZip(zipFile); + const entries = zip.getEntries(); + + if (bar) { + rprint(desc || "Extracting"); + for (const entry of entries) { + zip.extractEntryTo(entry, extractPath, true, true); + } + return; + } + + entries.forEach((entry, i) => { + zip.extractEntryTo(entry, extractPath, true, true); + if (desc) { + const pct = ((i + 1) / entries.length) * 100; + process.stdout.write(`\r${desc}: ${pct.toFixed(0)}%`); + } + }); + if (desc) process.stdout.write(`\r${desc}: Complete\n`); +} + +/** + * chmod -R 755 on POSIX so the freshly-extracted binaries are executable. + * (The zip does not carry usable permission bits on every platform.) + */ +export function makeExecutable(dir: string): void { + if (OS_NAME === "win") return; + try { + execFileSync("chmod", ["-R", "755", dir]); + } catch (error) { + rprint(`Warning: could not chmod ${dir}: ${error}`, "yellow"); + } +} + +/** + * Format an asset timestamp as "Mon D", or "Mon D, YYYY" when the year differs. + */ +export function formatAssetDate(iso?: string, now?: Date): string { + if (!iso) return ""; + const dt = new Date(iso); + if (Number.isNaN(dt.getTime())) return ""; + const currentYear = (now ?? new Date()).getFullYear(); + const month = dt.toLocaleString("en-US", { month: "short" }); + if (dt.getFullYear() === currentYear) { + return `${month} ${dt.getDate()}`; + } + return `${month} ${dt.getDate()}, ${dt.getFullYear()}`; +} diff --git a/typescript/src/pycompat.ts b/typescript/src/pycompat.ts new file mode 100644 index 000000000..ed1ea0843 --- /dev/null +++ b/typescript/src/pycompat.ts @@ -0,0 +1,413 @@ +/** + * The handful of CPython / orjson behaviours the identity layer has to + * reproduce bit-for-bit so that the same inputs yield the same identity in the + * Python and TypeScript launchers: orjson's JSON bytes (hashed by + * identity_salt), str() of a value (hashed by identity_seed), zlib.crc32, + * code-point string ordering, and the float summation builtin sum() uses. + * + * JavaScript has one number type where Python has two, so `1.0` and `1` are + * the same value here. Where the difference reaches a hash, wrap the float in + * {@link PyFloat}, or read the JSON with {@link parsePyJson}, which keeps the + * distinction (and keeps integers above 2**53 exact as bigint). + */ + +import { FileNotFoundError } from "./exceptions.js"; + +/** A Python float whose value happens to be integral (1.0, 3.4e38...). */ +export class PyFloat { + constructor(public readonly value: number) {} + + valueOf(): number { + return this.value; + } + + toJSON(): number { + return this.value; + } + + toString(): string { + return formatPyFloatRepr(this.value); + } +} + +/** Number of a value that may be a PyFloat. */ +export function num(value: unknown): number { + return value instanceof PyFloat ? value.value : (value as number); +} + +/** Whether `value` is a Python int (bool counts, as in Python). */ +export function isPyInt(value: unknown): boolean { + return ( + typeof value === "bigint" || + typeof value === "boolean" || + (typeof value === "number" && Number.isSafeInteger(value)) + ); +} + +/** + * JSON.parse that keeps what Python's json.loads would: `1.0` stays a float + * (a {@link PyFloat} when integral) and an integer beyond 2**53 stays exact + * (a bigint). + */ +export function parsePyJson(text: string): any { + return JSON.parse(text, function ( + this: any, + _key: string, + value: any, + context?: { source?: string }, + ) { + if (typeof value !== "number" || !context?.source) return value; + const src = context.source; + const isFloat = /[.eE]/.test(src); + if (!isFloat) { + return Number.isSafeInteger(value) ? value : BigInt(src); + } + return Number.isSafeInteger(value) ? new PyFloat(value) : value; + } as any); +} + +/** Shortest round-trip digits and decimal exponent of a finite, non-zero x. */ +function shortestDigits(x: number): { digits: string; exp: number } { + const [mantissa, exponent] = Math.abs(x).toExponential().split("e"); + return { digits: mantissa.replace(".", ""), exp: Number(exponent) }; +} + +function fixedNotation(digits: string, exp: number): string { + if (exp >= 0) { + if (digits.length <= exp + 1) { + return `${digits}${"0".repeat(exp + 1 - digits.length)}.0`; + } + return `${digits.slice(0, exp + 1)}.${digits.slice(exp + 1)}`; + } + return `0.${"0".repeat(-exp - 1)}${digits}`; +} + +function sciMantissa(digits: string): string { + return digits.length === 1 ? digits : `${digits[0]}.${digits.slice(1)}`; +} + +/** A float exactly as orjson serializes it. */ +export function formatOrjsonFloat(x: number): string { + if (!Number.isFinite(x)) return "null"; + if (x === 0) return Object.is(x, -0) ? "-0.0" : "0.0"; + const sign = x < 0 ? "-" : ""; + const { digits, exp } = shortestDigits(x); + if (exp >= -5 && exp < 16) return sign + fixedNotation(digits, exp); + return `${sign}${sciMantissa(digits)}e${exp < 0 ? "-" : "+"}${Math.abs(exp)}`; +} + +/** repr(float) / str(float) in CPython. */ +export function formatPyFloatRepr(x: number): string { + if (Number.isNaN(x)) return "nan"; + if (!Number.isFinite(x)) return x > 0 ? "inf" : "-inf"; + if (x === 0) return Object.is(x, -0) ? "-0.0" : "0.0"; + const sign = x < 0 ? "-" : ""; + const { digits, exp } = shortestDigits(x); + if (exp >= -4 && exp < 16) return sign + fixedNotation(digits, exp); + const e = Math.abs(exp).toString().padStart(2, "0"); + return `${sign}${sciMantissa(digits)}e${exp < 0 ? "-" : "+"}${e}`; +} + +/** Compare two strings by code point, as Python (and UTF-8 bytes) order them. */ +export function comparePyStr(a: string, b: string): number { + if (a === b) return 0; + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) { + const ca = a.charCodeAt(i); + const cb = b.charCodeAt(i); + if (ca === cb) continue; + // Surrogates (0xD800-0xDFFF) encode code points above every other + // BMP unit, so only the surrogate-vs-high-BMP case reorders. + const sa = ca >= 0xd800 && ca <= 0xdfff; + const sb = cb >= 0xd800 && cb <= 0xdfff; + if (sa !== sb) { + if (sa) return cb >= 0xe000 ? 1 : ca - cb; + return ca >= 0xe000 ? -1 : ca - cb; + } + return ca - cb; + } + return a.length - b.length; +} + +/** Python truthiness of a JSON-like value. */ +export function pyTruthy(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (value instanceof PyFloat) return value.value !== 0; + if (typeof value === "number") return value !== 0; + if (typeof value === "bigint") return value !== 0n; + if (typeof value === "string") return value.length > 0; + if (typeof value === "boolean") return value; + if (Array.isArray(value)) return value.length > 0; + if (value instanceof Map || value instanceof Set) return value.size > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; +} + +const NON_PRINTABLE = /[\p{C}\p{Z}]/u; + +/** repr() of a str. */ +export function pyStrRepr(s: string): string { + const quote = s.includes("'") && !s.includes('"') ? '"' : "'"; + let out = quote; + for (const ch of s) { + const cp = ch.codePointAt(0) as number; + if (ch === quote || ch === "\\") out += `\\${ch}`; + else if (ch === "\t") out += "\\t"; + else if (ch === "\n") out += "\\n"; + else if (ch === "\r") out += "\\r"; + else if (cp < 0x20 || cp === 0x7f) + out += `\\x${cp.toString(16).padStart(2, "0")}`; + else if (ch !== " " && NON_PRINTABLE.test(ch)) { + if (cp <= 0xff) out += `\\x${cp.toString(16).padStart(2, "0")}`; + else if (cp <= 0xffff) out += `\\u${cp.toString(16).padStart(4, "0")}`; + else out += `\\U${cp.toString(16).padStart(8, "0")}`; + } else out += ch; + } + return out + quote; +} + +function pyNumberStr(value: number): string { + if (Number.isSafeInteger(value)) return String(value); + return formatPyFloatRepr(value); +} + +/** repr() of a JSON-like value. */ +export function pyRepr(value: unknown): string { + if (typeof value === "string") return pyStrRepr(value); + if (Array.isArray(value)) return `[${value.map(pyRepr).join(", ")}]`; + if (value instanceof PyFloat) return formatPyFloatRepr(value.value); + if (value !== null && typeof value === "object") { + const entries = + value instanceof Map ? [...value.entries()] : Object.entries(value); + return `{${entries.map(([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`).join(", ")}}`; + } + return pyStr(value); +} + +/** str() of a JSON-like value. */ +export function pyStr(value: unknown): string { + if (value === null || value === undefined) return "None"; + if (value === true) return "True"; + if (value === false) return "False"; + if (typeof value === "string") return value; + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number") return pyNumberStr(value); + if (value instanceof PyFloat) return formatPyFloatRepr(value.value); + return pyRepr(value); +} + +function orjsonString(s: string): string { + let out = '"'; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c === 0x22) out += '\\"'; + else if (c === 0x5c) out += "\\\\"; + else if (c < 0x20) { + switch (c) { + case 0x08: + out += "\\b"; + break; + case 0x09: + out += "\\t"; + break; + case 0x0a: + out += "\\n"; + break; + case 0x0c: + out += "\\f"; + break; + case 0x0d: + out += "\\r"; + break; + default: + out += `\\u${c.toString(16).padStart(4, "0")}`; + } + } else out += s[i]; + } + return `${out}"`; +} + +function orjsonKey(key: unknown): string { + if (typeof key === "string") return key; + if (key === null || key === undefined) return "null"; + if (typeof key === "boolean") return key ? "true" : "false"; + if (typeof key === "bigint") return key.toString(); + if (key instanceof PyFloat) return formatOrjsonFloat(key.value); + if (typeof key === "number") { + return Number.isSafeInteger(key) ? String(key) : formatOrjsonFloat(key); + } + return pyStr(key); +} + +/** + * orjson.dumps(value, option=OPT_SORT_KEYS | OPT_NON_STR_KEYS, default=str), + * as a string (its UTF-8 encoding is orjson's bytes). + * + * Plain numbers that are safe integers serialize as Python ints, every other + * number as a float; use {@link PyFloat} for an integral float and bigint for + * an integer beyond 2**53. An object with a `toPyDict()` method (the TS twin + * of a dataclass) serializes as that dict. + */ +export function orjsonDumps(value: unknown, sortKeys = true): string { + if (value === null || value === undefined) return "null"; + if (value === true) return "true"; + if (value === false) return "false"; + if (typeof value === "string") return orjsonString(value); + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number") { + return Number.isSafeInteger(value) + ? String(value) + : formatOrjsonFloat(value); + } + if (value instanceof PyFloat) return formatOrjsonFloat(value.value); + if (Array.isArray(value)) { + return `[${value.map((v) => orjsonDumps(v, sortKeys)).join(",")}]`; + } + if (typeof value === "object") { + const withDict = value as { toPyDict?: () => unknown }; + if (typeof withDict.toPyDict === "function") { + return orjsonDumps(withDict.toPyDict(), sortKeys); + } + if (value instanceof Set) { + return orjsonString(pyStr([...value])); + } + let entries: Array<[string, unknown]>; + if (value instanceof Map) { + entries = [...value.entries()].map(([k, v]) => [orjsonKey(k), v]); + } else if ( + Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null + ) { + entries = Object.entries(value); + } else { + // default=str + return orjsonString(String(value)); + } + if (sortKeys) entries.sort((a, b) => comparePyStr(a[0], b[0])); + return `{${entries.map(([k, v]) => `${orjsonString(k)}:${orjsonDumps(v, sortKeys)}`).join(",")}}`; + } + return orjsonString(String(value)); +} + +/** + * sum() over floats, as CPython 3.12+ computes it (Neumaier-compensated). + * CPython 3.10/3.11 fold left without compensation; the two differ only in + * the last bit, and only for sums that lose precision. + */ +export function pySumFloats(values: Iterable): number { + let started = false; + let f = 0; + let c = 0; + for (const x of values) { + if (!started) { + // int 0 start + first float: a plain add + f = 0 + x; + started = true; + continue; + } + const t = f + x; + if (Math.abs(f) >= Math.abs(x)) c += f - t + x; + else c += x - t + f; + f = t; + } + if (c !== 0 && Number.isFinite(c)) f += c; + return f; +} + +/** + * sum() over a mix of ints and floats, as CPython 3.14 computes it: ints add + * exactly until the first float, which is added plainly; after that every item + * is Neumaier-compensated. (3.12/3.13 add those later ints without + * compensation and 3.10/3.11 compensate nothing, so a sum that loses + * precision can differ in its last bit there.) A safe + * integer number counts as an int here, anything else (or a PyFloat) as a + * float. Returns a number, or a bigint when every item was an int that only + * a bigint holds exactly. + */ +export function pySum(values: Iterable): number | bigint { + let i: bigint | null = 0n; + let f = 0; + let c = 0; + for (const v of values) { + const isInt = + typeof v === "bigint" || + typeof v === "boolean" || + (typeof v === "number" && Number.isSafeInteger(v)); + if (i !== null) { + if (isInt) { + i += typeof v === "bigint" ? v : BigInt(Number(v)); + continue; + } + f = Number(i) + num(v); + i = null; + continue; + } + const x = isInt ? Number(v) : num(v); + const t = f + x; + if (Math.abs(f) >= Math.abs(x)) c += f - t + x; + else c += x - t + f; + f = t; + } + if (i !== null) return Number.isSafeInteger(Number(i)) ? Number(i) : i; + if (c !== 0 && Number.isFinite(c)) f += c; + return f; +} + +let CRC_TABLE: Uint32Array | null = null; + +/** zlib.crc32. */ +export function crc32(data: Uint8Array | string): number { + if (!CRC_TABLE) { + CRC_TABLE = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + CRC_TABLE[n] = c >>> 0; + } + } + const bytes = + typeof data === "string" ? new TextEncoder().encode(data) : data; + let crc = 0xffffffff; + for (let i = 0; i < bytes.length; i++) { + crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +/** Python's OSError, for failures Node does not raise as a system error. */ +export class OSError extends Error { + name = "OSError"; +} + +/** Python's ValueError. */ +export class ValueError extends Error { + name = "ValueError"; +} + +/** Python's KeyError. */ +export class KeyError extends Error { + name = "KeyError"; +} + +const PY_ERROR_TESTS = { + // Node's system errors (fs, net) carry a string code and a numeric errno; + // FileNotFoundError is an OSError in Python. + OSError: (e: unknown) => + e instanceof OSError || + e instanceof FileNotFoundError || + (e instanceof Error && + typeof (e as NodeJS.ErrnoException).code === "string" && + typeof (e as NodeJS.ErrnoException).errno === "number"), + // JSON.parse's SyntaxError is json.JSONDecodeError, a ValueError. + ValueError: (e: unknown) => + e instanceof ValueError || e instanceof SyntaxError, + KeyError: (e: unknown) => e instanceof KeyError, +}; + +/** Whether `except ()` in the Python twin would catch `error`. */ +export function isPyError( + error: unknown, + ...kinds: Array +): boolean { + return kinds.some((kind) => PY_ERROR_TESTS[kind](error)); +} diff --git a/typescript/src/pyrandom.ts b/typescript/src/pyrandom.ts new file mode 100644 index 000000000..9f47fb80a --- /dev/null +++ b/typescript/src/pyrandom.ts @@ -0,0 +1,323 @@ +/** + * A bit-exact port of CPython's `random.Random` (MT19937). + * + * The Python launcher seeds every per-identity draw (fonts, voices, media + * devices) with `random.Random(seed)`, so the same identity must draw the same + * values here. Seeding follows `random_seed()` in Modules/_randommodule.c + * (init_by_array over the 32-bit words of abs(seed)); the methods follow + * Lib/random.py. Every method below returns the same values on CPython 3.10 + * through 3.14 (checked by the golden fixtures). + */ +import { createHash, randomBytes } from "node:crypto"; + +const N = 624; +const M = 397; +const MATRIX_A = 0x9908b0df; +const UPPER_MASK = 0x80000000; +const LOWER_MASK = 0x7fffffff; + +export type PySeed = number | bigint | string | null | undefined; + +function seedToKey(seed: number | bigint): Uint32Array { + let n = typeof seed === "bigint" ? seed : BigInt(seed); + if (n < 0n) n = -n; + const words: number[] = []; + if (n === 0n) words.push(0); + while (n > 0n) { + words.push(Number(n & 0xffffffffn)); + n >>= 32n; + } + return Uint32Array.from(words); +} + +export class PyRandom { + private mt = new Uint32Array(N); + private mti = N + 1; + + constructor(seed?: PySeed) { + this.seed(seed); + } + + /** + * random.seed(a): an int (number/bigint), a str (version 2: the str's + * UTF-8 bytes followed by their SHA-512), or None for OS entropy. + */ + seed(a?: PySeed): void { + if (a === null || a === undefined) { + const bytes = randomBytes(N * 4); + const key = new Uint32Array(N); + for (let i = 0; i < N; i++) key[i] = bytes.readUInt32LE(i * 4); + this.initByArray(key); + return; + } + if (typeof a === "string") { + const utf8 = Buffer.from(a, "utf-8"); + const digest = createHash("sha512").update(utf8).digest(); + const all = Buffer.concat([utf8, digest]); + a = all.length ? BigInt(`0x${all.toString("hex")}`) : 0n; + } + if (typeof a === "number" && !Number.isInteger(a)) { + throw new TypeError("PyRandom only seeds from integers and strings"); + } + this.initByArray(seedToKey(a)); + } + + private initGenrand(s: number): void { + const mt = this.mt; + mt[0] = s >>> 0; + for (let i = 1; i < N; i++) { + const prev = mt[i - 1] ^ (mt[i - 1] >>> 30); + mt[i] = (Math.imul(1812433253, prev) + i) >>> 0; + } + this.mti = N; + } + + private initByArray(key: Uint32Array): void { + const mt = this.mt; + this.initGenrand(19650218); + let i = 1; + let j = 0; + const keyLength = key.length; + for (let k = Math.max(N, keyLength); k; k--) { + const prev = mt[i - 1] ^ (mt[i - 1] >>> 30); + mt[i] = ((mt[i] ^ Math.imul(prev, 1664525)) + key[j] + j) >>> 0; + i++; + j++; + if (i >= N) { + mt[0] = mt[N - 1]; + i = 1; + } + if (j >= keyLength) j = 0; + } + for (let k = N - 1; k; k--) { + const prev = mt[i - 1] ^ (mt[i - 1] >>> 30); + mt[i] = ((mt[i] ^ Math.imul(prev, 1566083941)) - i) >>> 0; + i++; + if (i >= N) { + mt[0] = mt[N - 1]; + i = 1; + } + } + mt[0] = 0x80000000; + this.mti = N; + } + + /** genrand_uint32 */ + genrandUint32(): number { + const mt = this.mt; + let y: number; + if (this.mti >= N) { + let kk = 0; + for (; kk < N - M; kk++) { + y = (mt[kk] & UPPER_MASK) | (mt[kk + 1] & LOWER_MASK); + mt[kk] = mt[kk + M] ^ (y >>> 1) ^ (y & 1 ? MATRIX_A : 0); + } + for (; kk < N - 1; kk++) { + y = (mt[kk] & UPPER_MASK) | (mt[kk + 1] & LOWER_MASK); + mt[kk] = mt[kk + (M - N)] ^ (y >>> 1) ^ (y & 1 ? MATRIX_A : 0); + } + y = (mt[N - 1] & UPPER_MASK) | (mt[0] & LOWER_MASK); + mt[N - 1] = mt[M - 1] ^ (y >>> 1) ^ (y & 1 ? MATRIX_A : 0); + this.mti = 0; + } + y = mt[this.mti++]; + y ^= y >>> 11; + y ^= (y << 7) & 0x9d2c5680; + y ^= (y << 15) & 0xefc60000; + y ^= y >>> 18; + return y >>> 0; + } + + /** random.random(): a float in [0, 1) with 53 random bits. */ + random(): number { + const a = this.genrandUint32() >>> 5; + const b = this.genrandUint32() >>> 6; + return (a * 67108864.0 + b) * (1.0 / 9007199254740992.0); + } + + /** random.getrandbits(k) as a bigint (any k >= 0). */ + getrandbitsBig(k: number): bigint { + if (k < 0) throw new RangeError("number of bits must be non-negative"); + if (k === 0) return 0n; + if (k <= 32) return BigInt(this.genrandUint32() >>> (32 - k)); + let result = 0n; + let shift = 0n; + for (let left = k; left > 0; left -= 32) { + let r = this.genrandUint32(); + if (left < 32) r >>>= 32 - left; + result |= BigInt(r) << shift; + shift += 32n; + } + return result; + } + + /** random.getrandbits(k) as a number (k <= 53). */ + getrandbits(k: number): number { + if (k < 0) throw new RangeError("number of bits must be non-negative"); + if (k === 0) return 0; + if (k <= 32) return this.genrandUint32() >>> (32 - k); + if (k > 53) + throw new RangeError("use getrandbitsBig for more than 53 bits"); + const lo = this.genrandUint32(); + const hi = this.genrandUint32() >>> (64 - k); + return hi * 4294967296 + lo; + } + + /** random._randbelow(n): an int in [0, n). */ + randbelow(n: number): number { + if (n <= 0) return 0; + const k = bitLength(n); + let r = this.getrandbits(k); + while (r >= n) r = this.getrandbits(k); + return r; + } + + /** random.randrange(start[, stop[, step]]) */ + randrange(start: number, stop?: number, step = 1): number { + if ( + !Number.isInteger(start) || + (stop !== undefined && !Number.isInteger(stop)) || + !Number.isInteger(step) + ) { + throw new TypeError("randrange() arguments must be integers"); + } + if (stop === undefined) { + if (step !== 1) throw new TypeError("Missing a non-None stop argument"); + if (start > 0) return this.randbelow(start); + throw new RangeError("empty range for randrange()"); + } + const width = stop - start; + if (step === 1) { + if (width > 0) return start + this.randbelow(width); + throw new RangeError(`empty range in randrange(${start}, ${stop})`); + } + let n: number; + if (step > 0) n = Math.floor((width + step - 1) / step); + else if (step < 0) n = Math.floor((width + step + 1) / step); + else throw new RangeError("zero step for randrange()"); + if (n <= 0) + throw new RangeError( + `empty range in randrange(${start}, ${stop}, ${step})`, + ); + return start + step * this.randbelow(n); + } + + /** random.randint(a, b): an int in [a, b]. */ + randint(a: number, b: number): number { + return this.randrange(a, b + 1); + } + + /** random.choice(seq) */ + choice(seq: ArrayLike): T { + if (!seq.length) + throw new RangeError("Cannot choose from an empty sequence"); + return seq[this.randbelow(seq.length)]; + } + + /** random.choices(population, weights=None, *, cum_weights=None, k=1) */ + choices( + population: ArrayLike, + { + weights, + cumWeights, + k = 1, + }: { weights?: number[]; cumWeights?: number[]; k?: number } = {}, + ): T[] { + const n = population.length; + const out: T[] = []; + if (!cumWeights) { + if (!weights) { + for (let i = 0; i < k; i++) + out.push(population[Math.floor(this.random() * n)]); + return out; + } + cumWeights = []; + let acc = 0; + weights.forEach((w, i) => { + acc = i === 0 ? w : acc + w; + (cumWeights as number[]).push(acc); + }); + } else if (weights) { + throw new TypeError("Cannot specify both weights and cumulative weights"); + } + if (cumWeights.length !== n) { + throw new RangeError( + "The number of weights does not match the population", + ); + } + const total = cumWeights[n - 1] + 0.0; + if (total <= 0.0) + throw new RangeError("Total of weights must be greater than zero"); + if (!Number.isFinite(total)) + throw new RangeError("Total of weights must be finite"); + const hi = n - 1; + for (let i = 0; i < k; i++) { + out.push( + population[bisectRight(cumWeights, this.random() * total, 0, hi)], + ); + } + return out; + } + + /** random.shuffle(x), in place. */ + shuffle(x: T[]): void { + for (let i = x.length - 1; i > 0; i--) { + const j = this.randbelow(i + 1); + [x[i], x[j]] = [x[j], x[i]]; + } + } + + /** random.sample(population, k), including its pool-vs-set branch. */ + sample(population: ArrayLike, k: number): T[] { + const n = population.length; + if (!(k >= 0 && k <= n)) { + throw new RangeError("Sample larger than population or is negative"); + } + const result: T[] = new Array(k); + let setsize = 21; + if (k > 5) setsize += 4 ** Math.ceil(Math.log(k * 3) / Math.log(4)); + if (n <= setsize) { + const pool = Array.from(population); + for (let i = 0; i < k; i++) { + const j = this.randbelow(n - i); + result[i] = pool[j]; + pool[j] = pool[n - i - 1]; + } + } else { + const selected = new Set(); + for (let i = 0; i < k; i++) { + let j = this.randbelow(n); + while (selected.has(j)) j = this.randbelow(n); + selected.add(j); + result[i] = population[j]; + } + } + return result; + } + + /** random.uniform(a, b) */ + uniform(a: number, b: number): number { + return a + (b - a) * this.random(); + } +} + +function bitLength(n: number): number { + return n === 0 ? 0 : Math.trunc(Math.abs(n)).toString(2).length; +} + +function bisectRight(a: number[], x: number, lo: number, hi: number): number { + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (x < a[mid]) hi = mid; + else lo = mid + 1; + } + return lo; +} + +/** + * The module-level generator: Python's `random.random()`, `random.choice()` + * and friends all draw from one shared `Random` instance, and so do the TS + * functions that mirror an unseeded Python draw. Seed it (`pyRandom.seed(n)`) + * to reproduce a Python run that called `random.seed(n)`. + */ +export const pyRandom = new PyRandom(); diff --git a/typescript/src/server.ts b/typescript/src/server.ts new file mode 100644 index 000000000..e553aaa08 --- /dev/null +++ b/typescript/src/server.ts @@ -0,0 +1,94 @@ +/** + * Playwright server mode. + * + * TypeScript twin of pythonlib/camoufox/server.py. Python has to shell out to + * the Node runtime bundled with its Playwright driver (and hand it a base64 + * config frame over stdin, via launchServer.js) because there is no Python + * binding for BrowserServer. Here we already are that runtime, so this calls + * playwright-core's launchServer() directly with the same options Python + * would send: launchOptions() with every top-level key camelCased. + */ +import { type BrowserServer, firefox } from "playwright-core"; +import { withUnpinnedLaunch } from "./cpu_affinity.js"; +import { camelCase } from "./sync_api.js"; +import { type LaunchOptions, launchOptions } from "./utils.js"; +import { VirtualDisplay } from "./virtdisplay.js"; + +export interface LaunchServerOptions extends Omit { + /** Port to listen on. Defaults to a random free port. */ + port?: number; + /** Path of the websocket endpoint. Defaults to a random path. */ + ws_path?: string; + /** Whether to run the browser headless. `"virtual"` spawns an Xvfb display + * (a TS extension: Python's launch_server passes headless through). */ + headless?: boolean | "virtual"; +} + +/** + * Convert a dictionary's keys to camelCase (server.to_camel_case_dict). Keys + * without an underscore are already JS names and are kept as they are. + */ +export function toCamelCaseDict( + data: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + out[key.includes("_") ? camelCase(key) : key] = value; + } + return out; +} + +/** + * Launch a Playwright server. Takes the same options as `Camoufox()`. + * + * Note: persistent contexts are not servable. Playwright's `launchServer` + * routes through `BrowserType.launch()`, and its `PlaywrightServer` only + * accepts a pre-launched Browser -- there is no way to expose a persistent + * BrowserContext over a websocket endpoint. Reject those options up front + * rather than accepting them and silently launching a throwaway profile. + */ +export async function launchServer({ + headless, + ...options +}: LaunchServerOptions = {}): Promise { + for (const unsupported of ["persistent_context", "user_data_dir"] as const) { + if (options[unsupported]) { + throw new Error( + `launch_server() does not support '${unsupported}': Playwright cannot ` + + "serve a persistent context over a websocket endpoint. Use " + + "Camoufox(persistent_context=True, ...) in-process instead.", + ); + } + delete options[unsupported]; + } + + let virtualDisplay: VirtualDisplay | null = null; + let headlessBool: boolean | undefined; + if (headless === "virtual") { + virtualDisplay = new VirtualDisplay(options.debug ?? false); + options.virtual_display = await virtualDisplay.get(); + headlessBool = false; + } else { + headlessBool = headless; + } + + try { + const config = await launchOptions({ ...options, headless: headlessBool }); + // The server's browser is spawned from this process too, so it must not + // start inside another launch's CPU pin. + const server = await withUnpinnedLaunch(() => + firefox.launchServer(toCamelCaseDict(config)), + ); + + if (virtualDisplay) { + // BrowserServer has no "disconnected" event; "close" fires on shutdown. + const display = virtualDisplay; + server.on("close", () => display.kill()); + } + + return server; + } catch (error) { + virtualDisplay?.kill(); + throw error; + } +} diff --git a/typescript/src/sync_api.ts b/typescript/src/sync_api.ts new file mode 100644 index 000000000..1df12a286 --- /dev/null +++ b/typescript/src/sync_api.ts @@ -0,0 +1,296 @@ +/** + * The launcher entry points: Camoufox(), NewBrowser(), NewContext(). + * + * TypeScript twin of pythonlib/camoufox/sync_api.py (and async_api.py, whose + * names async_api.ts re-exports). The Python library ships a sync and an + * async variant because Playwright-Python has two APIs; playwright-core has + * only one (promise-based), so the behaviours of both live here: the + * no-viewport default, the stock media defaults, CPU-core pinning with its + * launch lock (async_api), and virtual-display teardown. + */ +import { + type Browser, + type BrowserContext, + type BrowserType, + firefox, +} from "playwright-core"; +import * as cpuAffinity from "./cpu_affinity.js"; +import { generateContextFingerprint } from "./fingerprints.js"; +import { ensureModel } from "./fpgen/index.js"; +import { type ProxyConfig, ProxyHelper, proxyExitGeo } from "./ip.js"; +import { + applyNoViewport, + attachNoViewportDefault, + attachStockMediaDefaults, + attachVirtualDisplay, + driverPid, + type LaunchOptions, + launchOptions, + pinnedCoreCount, + STOCK_MEDIA_DEFAULTS, + spoofsWindowDimensions, +} from "./utils.js"; +import { VirtualDisplay } from "./virtdisplay.js"; + +export type Headless = boolean | "virtual"; + +export interface NewBrowserOptions extends Omit { + /** Whether to run the browser headless. On Linux, `"virtual"` runs a real + * (non-headless) browser on an Xvfb display instead. */ + headless?: Headless; + /** A set of launch options generated by `launchOptions()` to use as-is. */ + from_options?: Record; + /** Whether to use a persistent context. */ + persistent_context?: boolean; + /** Directory for the persistent profile (Playwright's user_data_dir). */ + user_data_dir?: string; +} + +/** + * Launches a Camoufox browser and returns it (Python: `with Camoufox(...) as + * browser`). Close it with `browser.close()`; a virtual display, if one was + * spawned, is torn down with it. + */ +export async function Camoufox( + options: NewBrowserOptions & { persistent_context: true }, +): Promise; +export async function Camoufox(options?: NewBrowserOptions): Promise; +export async function Camoufox( + options: NewBrowserOptions = {}, +): Promise { + return NewBrowser(firefox, options); +} + +/** + * Launches a new browser instance for Camoufox given a set of launch options. + * + * `from_options`: a set of launch options generated by `launchOptions()` to + * use. `persistent_context`: whether to use a persistent context. Every other + * option is passed to `launchOptions()`. + */ +export async function NewBrowser( + playwright: BrowserType, + options: NewBrowserOptions & { persistent_context: true }, +): Promise; +export async function NewBrowser( + playwright: BrowserType, + options?: NewBrowserOptions, +): Promise; +export async function NewBrowser( + playwright: BrowserType, + { + headless, + from_options, + persistent_context, + debug, + ...kwargs + }: NewBrowserOptions = {}, +): Promise { + let virtualDisplay: VirtualDisplay | null = null; + let headlessBool: boolean | undefined; + if (headless === "virtual") { + virtualDisplay = new VirtualDisplay(debug ?? false); + kwargs.virtual_display = await virtualDisplay.get(); + headlessBool = false; + } else { + headlessBool = headless; + } + + let fromOptions = from_options; + try { + if (!fromOptions || !Object.keys(fromOptions).length) { + // Opt-in (2026-09-17). Pinning keeps the identity's core count by + // constraining the browser to that many cores; it costs real CPU, + // needs a launch lock, and does nothing on macOS. Off, the host's own + // snapped count is reported, so reported and measurable still agree. + kwargs.pin_cpu_cores ??= false; + fromOptions = await launchOptions({ + ...kwargs, + headless: headlessBool, + debug, + }); + } + } catch (error) { + // A failed launch must not leave the Xvfb process behind. + virtualDisplay?.kill(); + throw error; + } + + // Playwright's default viewport deadlocks Juggler when the window is spoofed + // to a different size (daijro/camoufox#666), so default to no viewport. + const noViewportDefault = spoofsWindowDimensions(fromOptions); + + // Pin the driver (and so the browser it is about to spawn) to as many cores + // as the identity reports, so measurable parallelism matches + // navigator.hardwareConcurrency; the driver gets its cores back afterwards. + const pinTo = pinnedCoreCount(fromOptions); + const launch = () => + launchWith( + playwright, + fromOptions as Record, + Boolean(persistent_context), + noViewportDefault, + virtualDisplay, + ); + try { + if (!pinTo) { + return await cpuAffinity.withUnpinnedLaunch(launch); + } + const pid = driverPid(); + // The browser inherits the driver's mask at spawn, so two concurrent + // launches must not interleave pin/restore. + return await cpuAffinity.withPinLock(async () => { + const previous = cpuAffinity.pin(pid, pinTo); + try { + return await launch(); + } finally { + cpuAffinity.restore(pid, previous); + } + }); + } catch (error) { + virtualDisplay?.kill(); + throw error; + } +} + +async function launchWith( + playwright: BrowserType, + fromOptions: Record, + persistentContext: boolean, + noViewportDefault: boolean, + virtualDisplay: VirtualDisplay | null, +): Promise { + // Persistent context. Python passes user_data_dir inside the options; the + // JS API takes it positionally. A user_data_dir alone also selects it. + const userDataDir = fromOptions.user_data_dir ?? fromOptions.userDataDir; + if (persistentContext || userDataDir !== undefined) { + let options: Record = { ...fromOptions }; + delete options.user_data_dir; + delete options.userDataDir; + if ( + noViewportDefault && + !("viewport" in options || "noViewport" in options) + ) { + options = applyNoViewport(options); + } else if ("noViewport" in options) { + options = applyNoViewport(options); + } + // The persistent context is created by the launch itself, so its media + // features come from these options rather than from newContext(). + for (const [key, value] of Object.entries(STOCK_MEDIA_DEFAULTS)) { + if (!(key in options)) options[key] = value; + } + const context = await playwright.launchPersistentContext( + userDataDir ?? "", + options, + ); + return attachVirtualDisplay(context, virtualDisplay); + } + + // Browser + const browser = await playwright.launch(fromOptions); + if (noViewportDefault) { + attachNoViewportDefault(browser); + } + attachStockMediaDefaults(browser); + return attachVirtualDisplay(browser, virtualDisplay); +} + +export interface NewContextOptions extends Record { + /** A fingerprint preset to use. If omitted, fpgen draws a new identity. */ + preset?: Record; + /** Target OS for the drawn identity ("windows", "macos", "linux"). */ + os?: string; + /** Firefox major version to claim in the UA. Defaults to the browser's own. */ + ff_version?: string; + /** IPv4 or IPv6 address to spoof for WebRTC ICE candidates. */ + webrtc_ip?: string; + /** Per-context proxy, in Playwright's format. */ + proxy?: ProxyConfig; + /** Per-context geolocation. */ + geolocation?: { latitude: number; longitude: number; accuracy?: number }; +} + +/** Injection point for the proxy exit-IP lookup (tests replace it). */ +export const contextDeps = { + resolveProxyGeo: (proxy: ProxyConfig) => + proxyExitGeo(ProxyHelper.asString(proxy)), +}; + +/** snake_case -> camelCase, as camoufox.server.camel_case does. */ +export function camelCase(snake: string): string { + if (snake.length < 2) return snake; + const parts = snake.toLowerCase().split("_"); + const joined = parts + .map((x) => (x ? x[0].toUpperCase() + x.slice(1) : "")) + .join(""); + return ( + (snake[0] === "_" ? "_" : "") + joined[0].toLowerCase() + joined.slice(1) + ); +} + +/** + * Creates a new browser context with a unique fingerprint identity. + * + * Each context gets its own identity (navigator, screen, WebGL, fonts, + * voices), drawn by fpgen unless a preset is given, with its own audio noise + * seed. All values are applied via addInitScript so they self-destruct before + * page scripts can detect them. + */ +export async function NewContext( + browser: Browser, + { + preset, + os, + ff_version, + webrtc_ip, + proxy, + geolocation, + ...contextOptions + }: NewContextOptions = {}, +): Promise { + // Python's fpgen loads its model on import; here it is fetched on first use. + // launchOptions() awaits it, but a browser from connect() or a custom + // executable never went through launchOptions(). + await ensureModel(); + + // The drawn UA carries fpgen's Firefox version, which must not disagree with + // the browser the page is actually talking to. + const ffVersion = ff_version || browser.version().split(".", 1)[0]; + + // Auto-derive the WebRTC IP and timezone from the proxy's exit IP when they + // aren't explicitly provided. + let webrtcIp = webrtc_ip; + if (proxy && (!webrtcIp || !("timezoneId" in contextOptions))) { + const [exitIp, timezone] = await contextDeps.resolveProxyGeo(proxy); + webrtcIp ||= exitIp; + if (!("timezoneId" in contextOptions)) contextOptions.timezoneId = timezone; + } + + const fp = generateContextFingerprint({ + preset: preset as any, + os, + ff_version: ffVersion, + webrtc_ip: webrtcIp, + }); + + // Merge the generated context options with user overrides (user wins). They + // are already Playwright's JS names (userAgent, deviceScaleFactor, + // timezoneId); re-casing them turned userAgent into `useragent`, which + // Playwright ignores, so the HTTP User-Agent contradicted navigator's. + const opts: Record = { + ...fp.context_options, + ...contextOptions, + }; + if (proxy) opts.proxy = proxy; + if (geolocation) { + opts.geolocation = geolocation; + opts.permissions ??= ["geolocation"]; + } + + const context = await browser.newContext(opts); + await context.addInitScript( + (fp as any).initScript ?? (fp as any).init_script, + ); + return context; +} diff --git a/typescript/src/utils.ts b/typescript/src/utils.ts new file mode 100644 index 000000000..f12248006 --- /dev/null +++ b/typescript/src/utils.ts @@ -0,0 +1,1812 @@ +/** + * Launch-option assembly: turns Camoufox's high-level options into the + * Playwright Firefox launch options plus the CAMOU_CONFIG environment. + * + * TypeScript twin of pythonlib/camoufox/utils.py. `launchOptions()` must + * produce what Python's `launch_options()` produces for the same inputs; the + * goldens in tests/fixtures/launch (scripts/golden/launch_golden.py) hold it + * to that. + * + * Every collaborator is reached through `utilsDeps`, the counterpart of the + * module globals the Python tests monkeypatch (`utils.generate_fingerprint`, + * `utils._stock_profile_disk_capacity_kb`, ...). Production code never + * touches it. + */ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { inspect } from "node:util"; +import { UAParser } from "ua-parser-js"; +import { addDefaultAddons, confirmPaths, type DefaultAddon } from "./addons.js"; +import * as coherence from "./coherence.js"; +import * as cpuAffinity from "./cpu_affinity.js"; +import { hasDisplay, largestDisplay } from "./display.js"; +import { + InvalidOS, + InvalidPropertyType, + NonFirefoxFingerprint, +} from "./exceptions.js"; +import { + audioSeedFromIdentity, + clampScreenToDisplay, + clampWindowDimensions, + clampWindowPosition, + fixHardwareConcurrency, + fixNavigatorArch, + fixScreenNoTaskbar, + fromFpgen, + fromPreset, + generateFingerprint, + generateRandomFontSubset, + generateRandomVoiceSubset, + getRandomPreset, + identitySalt, + identitySeed, + raiseScreenToModernFloor, + Screen, + setMediaDevicesDefaults, + WINDOWS_11_MARKER_FONTS, +} from "./fingerprints.js"; +import { ensureModel } from "./fpgen/index.js"; +import { geoipAllowed, getGeolocation } from "./geolocation.js"; +import { + type ProxyConfig, + ProxyHelper, + publicIP, + validIPv4, + validIPv6, +} from "./ip.js"; +import { handleLocales } from "./locales.js"; +import { + effectiveVersionMin, + ensureBrowserProfileDir, + ensureCamoufoxInstalled, + getPath, + INSTALL_DIR, + installedVerStr, + LOCAL_DATA, + launchPath, + OS_NAME, + resolvedPlaywrightVersionStr, + Version, +} from "./pkgman.js"; +import { + formatPyFloatRepr, + isPyError, + orjsonDumps, + PyFloat, + pyRepr, + pyStr, + ValueError, +} from "./pycompat.js"; +import type { VirtualDisplay } from "./virtdisplay.js"; +import { FallbackWarning, LeakWarning, warn } from "./warnings.js"; +import { sampleWebglForScreen, webglForGpu } from "./webgl.js"; + +export type ListOrString = string | string[]; +export type TargetOS = "mac" | "win" | "lin"; +export type EnvVars = Record; + +// Camoufox preferences to cache previous pages and requests +export const CACHE_PREFS: Record = { + "browser.sessionhistory.max_entries": 10, + "browser.sessionhistory.max_total_viewers": -1, + "browser.cache.memory.enable": true, + "browser.cache.disk_cache_ssl": true, + "browser.cache.disk.smart_size.enabled": true, +}; + +/** The host OS in fonts.json / target_os terms ('mac', 'win', 'lin'). */ +function hostOsKey(): TargetOS | null { + return ( + ({ darwin: "mac", win32: "win", linux: "lin" } as Record)[ + process.platform + ] ?? null + ); +} + +// navigator.storage.estimate().quota is not a constant: Gecko derives it from +// the disk. GetTemporaryStorageLimit() (dom/quota/ActorsParent.cpp) takes +// nsIFile::GetDiskCapacity() of the storage directory and halves it, then +// QuotaManager::GetGroupLimitForLimit() reports min(that / 5, 10 GiB) to the +// page -- so any disk of 100 GB or more reads back as exactly 10 GiB, and a +// smaller one as its own capacity / 10. +export const QUOTA_FIXED_LIMIT_PREF = + "dom.quotaManager.temporaryStorage.fixedLimit"; +// The pref is a signed 32-bit int in KB. Any value above 50 GiB already reports +// the 10 GiB group cap, so clamping a multi-terabyte disk changes nothing a page +// can see. +const INT32_MAX = 2 ** 31 - 1; + +/** shutil.disk_usage(path).total */ +function diskTotal(p: string): number { + const st = fs.statfsSync(p); + return st.blocks * st.bsize; +} + +/** + * Half the capacity of the disk a stock Firefox profile would live on, in KB. + * + * That is the number Gecko's own GetTemporaryStorageLimit() would compute on + * this machine, and it is what `dom.quotaManager.temporaryStorage.fixedLimit` + * takes. The disk a stock profile lives on, not the one Playwright's throwaway + * profile lands on: on a host whose temp directory is a tmpfs, that profile + * sits on a RAM-sized volume no real Firefox profile would. + */ +function stockProfileDiskCapacityKb(): number | null { + const home = os.homedir(); + let candidates: string[]; + const osName = utilsDeps.osName(); + if (osName === "win") { + const appdata = process.env.APPDATA; + candidates = [appdata ? path.join(appdata, "Mozilla") : home, home]; + } else if (osName === "mac") { + candidates = [ + path.join(home, "Library", "Application Support", "Firefox"), + home, + ]; + } else { + candidates = [path.join(home, ".mozilla"), home]; + } + + for (const candidate of candidates) { + // The directory only exists if Firefox has ever run here; walk up to the + // first path that does, which is on the same filesystem anyway. + let probe = candidate; + while (!fs.existsSync(probe) && probe !== path.dirname(probe)) { + probe = path.dirname(probe); + } + let total: number; + try { + total = utilsDeps.diskTotal(probe); + } catch { + continue; + } + if (total > 0) { + return Math.min(Math.floor(Math.floor(total / 2) / 1024), INT32_MAX); + } + } + return null; +} + +/** + * Injection points: everything launchOptions() reaches outside this module. + * Mirrors the names the Python tests monkeypatch on camoufox.utils. + */ +export const utilsDeps = { + osName: (): TargetOS => OS_NAME, + installDir: (): string => INSTALL_DIR, + hostOsKey, + diskTotal, + stockProfileDiskCapacityKb, + hasDisplay, + largestDisplay, + getScreenCons: (headless?: boolean) => getScreenCons(headless), + print: (line: string): void => { + process.stdout.write(`${line}\n`); + }, + ensureBrowserProfileDir, + ensureCamoufoxInstalled, + addDefaultAddons, + confirmPaths, + installedVerStr: () => installedVerStr(), + effectiveVersionMin, + resolvedPlaywrightVersionStr, + getPath, + launchPath, + identitySalt, + identitySeed, + generateFingerprint: async ( + options: Parameters[0], + ): Promise> => { + // fpgen's model is fetched on first use, like Python's. + await ensureModel(); + return generateFingerprint(options); + }, + fromFpgen, + fromPreset, + getRandomPreset, + fixNavigatorArch, + fixHardwareConcurrency, + fixScreenNoTaskbar, + clampScreenToDisplay, + clampWindowDimensions, + clampWindowPosition, + raiseScreenToModernFloor, + generateRandomFontSubset, + generateRandomVoiceSubset, + setMediaDevicesDefaults, + // The WebGL draws read fpgen's model, fetched on first use like Python's. + webglForGpu: async (...args: Parameters) => { + await ensureModel(); + return webglForGpu(...args); + }, + sampleWebglForScreen: async ( + ...args: Parameters + ) => { + await ensureModel(); + return sampleWebglForScreen(...args); + }, + publicIp: publicIP, + getGeolocation, + validateConfig: (config: Record, p?: string | null) => + validateConfig(config, p), + getEnvVars: (config: Record, uaOs: string, p?: string | null) => + getEnvVars(config, uaOs, p), + findInstalledVersion: async (spec: string): Promise => + (await import("./multiversion.js")).findInstalledVersion(spec) ?? null, +}; + +/* + * Python-compatible serialisation helpers + */ + +/** type(value).__name__ for a JSON-ish value. */ +export function pyTypeName(value: unknown): string { + if (value === null || value === undefined) return "NoneType"; + if (typeof value === "boolean") return "bool"; + if (value instanceof PyFloat) return "float"; + if (typeof value === "number") + return Number.isInteger(value) ? "int" : "float"; + if (typeof value === "bigint") return "int"; + if (typeof value === "string") return "str"; + if (Array.isArray(value)) return "list"; + return "dict"; +} + +/** + * json.dumps(value, ensure_ascii=True, separators=(',', ':')): compact, every + * non-ASCII (and DEL) character \u-escaped, floats in Python's repr. + */ +export function pyJsonDumpsAscii(value: unknown): string { + const encode = (v: unknown): string => { + if (v === null || v === undefined) return "null"; + if (v === true) return "true"; + if (v === false) return "false"; + if (typeof v === "number") { + if (Number.isInteger(v)) return String(v); + if (Number.isNaN(v)) return "NaN"; + if (!Number.isFinite(v)) return v > 0 ? "Infinity" : "-Infinity"; + return formatPyFloatRepr(v); + } + if (typeof v === "bigint") return v.toString(); + if (v instanceof PyFloat) return formatPyFloatRepr(v.value); + if (typeof v === "string") { + return JSON.stringify(v).replace( + /[\u007f-๏ฟฟ]/g, + (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + } + if (Array.isArray(v)) return `[${v.map(encode).join(",")}]`; + return `{${Object.entries(v as Record) + .map(([k, x]) => `${encode(k)}:${encode(x)}`) + .join(",")}}`; + }; + return encode(value); +} + +/** + * orjson.dumps(config): compact, UTF-8, insertion order. JavaScript has one + * number type, so the Python int/float split is carried the way pycompat + * carries it: a safe-integer number is an int, a bigint is an int beyond + * 2**53, a PyFloat is an integral float, and any other number is a float. + */ +export function configJson(value: unknown): string { + return orjsonDumps(value, false); +} + +/** Slice `s` into chunks of `size` code points (Python str slicing). */ +function chunkCodePoints(s: string, size: number): string[] { + // Fast path: no astral characters, so UTF-16 units == code points. + if (!/[\ud800-\udfff]/.test(s)) { + const out: string[] = []; + for (let i = 0; i < s.length; i += size) out.push(s.slice(i, i + size)); + return out; + } + const cps = Array.from(s); + const out: string[] = []; + for (let i = 0; i < cps.length; i += size) { + out.push(cps.slice(i, i + size).join("")); + } + return out; +} + +/* + * Environment + */ + +/** + * Generates a runtime fontconfig that resolves bundled font paths absolutely. + * The bundled fonts.conf uses prefix="cwd" relative paths which break when + * Playwright's working directory differs from the browser install directory. + * Writes a patched copy to the platform cache dir (deterministic, only + * regenerated when content changes). This must not live inside the versioned + * browser bundle: the bundle is commonly baked into an image as root and run + * as a non-root user, so it is read-only at launch time. + */ +export function generateFontconfig( + fontconfigPath: string, + executablePath?: string | null, + osDir?: string | null, +): string { + // Beside the caller's own binary when they supplied one; see getEnvVars. + const fontsDir = executablePath + ? path.join(path.dirname(executablePath), "fonts") + : utilsDeps.getPath("fonts"); + + // Which directories this identity's OS may see. + // + // fontconfig scans RECURSIVELY, so the parent must never be named: it + // would make every other OS's files reachable by the renderer -- hidden by + // the allowlist for direct lookups, but still candidates for glyph fallback. + // + // The bundle stores each face ONCE, in a directory named for the set of + // OSes that use it (L, M, W, LM, LW, MW, LMW) -- see bundle/fonts/groups.json + // and scripts/gen-font-groups.py. An OS reads the groups its letter appears + // in, so nothing has to be hidden after the fact. + let scanDirs: string[] = []; + const groupsPath = path.join(fontsDir, "groups.json"); + const osKey = ( + { linux: "lin", macos: "mac", windows: "win" } as Record + )[osDir ?? ""]; + if (osKey && fs.existsSync(groupsPath)) { + try { + const parsed = JSON.parse(fs.readFileSync(groupsPath, "utf-8")); + const readBy: string[] = parsed?.readBy?.[osKey] ?? []; + scanDirs = readBy + .map((g) => path.join(fontsDir, g)) + .filter((d) => isDir(d)); + } catch { + scanDirs = []; + } + } + if (!scanDirs.length) { + // Older bundles ship fonts// with each OS's set duplicated in full. + if (osDir && isDir(path.join(fontsDir, osDir))) { + scanDirs = [path.join(fontsDir, osDir)]; + } else { + scanDirs = [fontsDir]; + } + } + + const fontsConfSrc = path.join(fontconfigPath, "fonts.conf"); + let confContent = fs.readFileSync(fontsConfSrc, "utf-8"); + confContent = confContent.replace( + 'fonts', + scanDirs.map((d) => `${d}`).join("\n\t"), + ); + + // INSTALL_DIR is platformdirs' user_cache_dir("camoufox"); see paths.ts. + const cacheDir = path.join(utilsDeps.installDir(), "fontconfig"); + fs.mkdirSync(cacheDir, { recursive: true }); + + const contentHash = createHash("sha256") + .update(confContent) + .digest("hex") + .slice(0, 12); + const runtimeConf = path.join(cacheDir, `fonts-${contentHash}.conf`); + if (!fs.existsSync(runtimeConf)) { + fs.writeFileSync(runtimeConf, confContent); + } + return runtimeConf; +} + +function isDir(p: string): boolean { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +/** + * Warn when a caller's own binary is older than their Playwright needs. + * + * A managed install below the floor is simply upgraded (pkgman resolves it), + * but `executable_path` deliberately bypasses that. That leaves the one + * pairing nothing checks: an old build driven by Playwright >= 1.61, which + * sends viewport fields the older Juggler schema rejects. Warns rather than + * raises: the default no-viewport path still works on an old build. A build + * with no version.json beside it tells us nothing, so it is left alone. + */ +export function warnIfExecutablePredatesPlaywright( + executablePath?: string | null, +): void { + if (!executablePath) return; + let installed: Version; + try { + installed = Version.fromPath(path.dirname(executablePath)); + } catch { + return; + } + + const required = utilsDeps.effectiveVersionMin(); + if (!installed.lessThan(required)) return; + + warn( + `The Camoufox build at ${executablePath} is ${installed.build}, but Playwright ` + + `${utilsDeps.resolvedPlaywrightVersionStr()} needs at least ${required.build}. ` + + "Contexts created with an explicit viewport will fail with " + + '"Protocol error (Browser.setDefaultViewport)". Update the build, or pin ' + + "playwright<1.61.", + "RuntimeWarning", + ); +} + +/** + * Pass the launcher's Firefox prefs to settings/camoufox.cfg, which applies them + * at STARTUP (CAMOU_PREFS_1..N, chunked like CAMOU_CONFIG). + * + * Playwright's non-persistent launch writes no user.js: firefoxUserPrefs only + * reach the browser at runtime, through juggler's Browser.enable, after + * startup. Anything Gecko reads during startup therefore raced or never + * applied. + */ +export function getPrefEnvVars( + prefs: Record, +): Record { + if (!prefs || !Object.keys(prefs).length) return {}; + // ASCII only: on Windows autoconfig's getenv() reads the environment through + // the ANSI code page, which would mangle a raw UTF-8 pref value (\u escapes + // survive it and JSON.parse restores them). + const data = pyJsonDumpsAscii(prefs); + const chunkSize = utilsDeps.osName() === "win" ? 2047 : 32767; + const out: Record = {}; + chunkCodePoints(data, chunkSize).forEach((chunk, i) => { + out[`CAMOU_PREFS_${i + 1}`] = chunk; + }); + return out; +} + +/** + * Gets the environment variables for Camoufox: the chunked CAMOU_CONFIG, plus + * the Linux fontconfig pointer. + * + * `executablePath` is the caller's own executable, when they supplied one. The + * bundled fontconfig is read from beside that binary rather than from the + * managed install, the same way loadProperties() treats properties.json. + */ +export function getEnvVars( + configMap: Record, + userAgentOs: string, + executablePath?: string | null, +): EnvVars { + const envVars: EnvVars = {}; + const configStr = configJson(configMap); + + // Split the config into chunks + const osName = utilsDeps.osName(); + const chunkSize = osName === "win" ? 2047 : 32767; + chunkCodePoints(configStr, chunkSize).forEach((chunk, i) => { + envVars[`CAMOU_CONFIG_${i + 1}`] = chunk; + }); + + if (osName === "lin") { + // https://github.com/coryking/camoufox/commit/f21eeb2850a74cc104fb57e17e0a2fa27b7a2a28 + // Thanks @coryking + const directoryMap: Record = { + lin: "linux", + mac: "macos", + win: "windows", + }; + const osDir = directoryMap[userAgentOs] ?? userAgentOs; + + // v150+ uses "fontconfig/" (matching the Go launcher); older bundles + // shipped "fontconfigs/". + const bundlePath = (...parts: string[]): string => + executablePath + ? path.join(path.dirname(executablePath), ...parts) + : utilsDeps.getPath(path.join(...parts)); + + let fontconfigPath = bundlePath("fontconfig", osDir); + if (!fs.existsSync(path.join(fontconfigPath, "fonts.conf"))) { + fontconfigPath = bundlePath("fontconfigs", osDir); + } + + if (!fs.existsSync(path.join(fontconfigPath, "fonts.conf"))) { + const err = new Error( + `fonts.conf not found in ${fontconfigPath}! Something ain't right with your camoufox bundle.`, + ); + err.name = "FileNotFoundError"; + throw err; + } + + envVars.FONTCONFIG_FILE = generateFontconfig( + fontconfigPath, + executablePath, + osDir, + ); + } + + return envVars; +} + +/** + * Loads the properties.json file. + */ +function loadProperties( + executablePath?: string | null, +): Record { + let propFile: string; + if (executablePath) { + propFile = path.join(path.dirname(executablePath), "properties.json"); + if (!fs.existsSync(propFile)) { + // macOS app bundle: the binary is Contents/MacOS/camoufox, the + // packaged settings live in Contents/Resources/. + const bundled = path.join( + path.dirname(path.dirname(executablePath)), + "Resources", + "properties.json", + ); + if (fs.existsSync(bundled)) propFile = bundled; + } + } else { + propFile = utilsDeps.getPath("properties.json"); + } + const propDict: Array<{ property: string; type: string }> = JSON.parse( + fs.readFileSync(propFile, "utf-8"), + ); + const out: Record = {}; + for (const prop of propDict) out[prop.property] = prop.type; + return out; +} + +/** + * Validates the config map. + */ +export function validateConfig( + configMap: Record, + executablePath?: string | null, +): void { + const propertyTypes = loadProperties(executablePath); + + for (const [key, value] of Object.entries(configMap)) { + const expectedType = propertyTypes[key]; + if (!expectedType) { + utilsDeps.print(`Skipping unknown patch ${key} : ${pyStr(value)}`); + continue; // Property not supported by this browser version; skip silently + } + + if (!validateType(value, expectedType)) { + throw new InvalidPropertyType( + `Invalid type for property ${key}. Expected ${expectedType}, got ${pyTypeName(value)}`, + ); + } + + if (key === "voices") { + validateVoices(value); + } + } +} + +/** + * Validates the type of the value. (Python's bool is an int, so a boolean + * passes the numeric checks there, and here.) + */ +export function validateType(value: any, expectedType: string): boolean { + if (value instanceof PyFloat) { + // A Python float: int/uint only when it is integral (float.is_integer()). + const v = value.value; + if (expectedType === "double") return true; + if (expectedType === "int") return Number.isInteger(v); + if (expectedType === "uint") return Number.isInteger(v) && v >= 0; + return false; + } + const isInt = + typeof value === "boolean" || + typeof value === "bigint" || + (typeof value === "number" && Number.isInteger(value)); + switch (expectedType) { + case "str": + return typeof value === "string"; + case "int": + return isInt; + case "uint": + return isInt && Number(value) >= 0; + case "double": + return typeof value === "number" || isInt; + case "bool": + return typeof value === "boolean"; + case "array": + return Array.isArray(value); + case "dict": + return ( + typeof value === "object" && value !== null && !Array.isArray(value) + ); + default: + return false; + } +} + +// The five fields MaskConfig::MVoices() requires of every `voices` entry. It +// skips anything missing one of them, so a bare "Name:lang:type" string or a +// half-filled object registers nothing -- and a voice list that registers +// nothing leaves the host's native voices exposed (#731). +export const VOICE_FIELDS = [ + "lang", + "name", + "voiceUri", + "isDefault", + "isLocalService", +] as const; + +/** + * Validates that every `voices` entry is a complete voice object. + */ +export function validateVoices(voices: any): void { + if (!Array.isArray(voices)) { + throw new InvalidPropertyType( + `Invalid type for property voices. Expected array, got ${pyTypeName(voices)}`, + ); + } + voices.forEach((voice, index) => { + if (typeof voice !== "object" || voice === null || Array.isArray(voice)) { + throw new InvalidPropertyType( + `Invalid voices[${index}]: expected an object with ` + + `{${VOICE_FIELDS.join(", ")}}, got ${pyTypeName(voice)} ` + + `(${pyRepr(voice)}). Camoufox needs full voice objects, not names.`, + ); + } + const missing = VOICE_FIELDS.filter((field) => !(field in voice)); + if (missing.length) { + throw new InvalidPropertyType( + `Invalid voices[${index}]: missing ${missing.join(", ")}. ` + + `Every voice needs {${VOICE_FIELDS.join(", ")}}.`, + ); + } + }); +} + +/** + * Gets the OS from the config if the user agent is set, otherwise returns the + * OS of the current system. + */ +export function getTargetOs(config: Record): TargetOS { + if (config["navigator.userAgent"]) { + return determineUaOs(config["navigator.userAgent"]); + } + return utilsDeps.osName(); +} + +/** + * Determines the OS from the user agent string. + */ +export function determineUaOs(userAgent: string): TargetOS { + // Python's ua_parser answers "Other" rather than nothing for an + // unrecognised UA, so its `raise` never fires and "lin" is what runs. + const parsed = new UAParser(userAgent).getOS().name || "Other"; + // ua-parser-js reports "macOS"; the Python ua_parser reports "Mac OS X". + if (parsed.startsWith("Mac") || parsed.startsWith("macOS")) return "mac"; + if (parsed.startsWith("Windows")) return "win"; + return "lin"; +} + +/** + * Determines a sane viewport size for Camoufox if being ran in headful mode. + * + * Bounds are CSS pixels, the unit Firefox lays its windows out in -- see + * display.ts for why that differs from the monitor's physical size. + */ +export function getScreenCons(headless?: boolean): Screen | null { + if (headless === true) { + return null; // Skip if headless + } + const display = utilsDeps.largestDisplay(); + if (display === null) { + return null; // Skip if the display can't be probed + } + return new Screen({ maxWidth: display.width, maxHeight: display.height }); +} + +/** + * Updates the fonts for the target OS. + */ +export function updateFonts( + config: Record, + targetOs: string, +): void { + const fonts: string[] = JSON.parse( + fs.readFileSync(path.join(LOCAL_DATA, "fonts.json"), "utf-8"), + )[targetOs]; + + // Merge with existing fonts (np.unique sorts) + if ("fonts" in config) { + config.fonts = [...new Set([...fonts, ...config.fonts])].sort(pyCompare); + } else { + config.fonts = fonts; + } +} + +/** Python's str ordering: by code point, not UTF-16 unit. */ +function pyCompare(a: string, b: string): number { + const ca = Array.from(a); + const cb = Array.from(b); + for (let i = 0; i < Math.min(ca.length, cb.length); i++) { + const d = + (ca[i].codePointAt(0) as number) - (cb[i].codePointAt(0) as number); + if (d) return d; + } + return ca.length - cb.length; +} + +/** + * Asserts that the passed fingerprint is a valid Firefox fingerprint, and + * warns that passing one is not recommended. + */ +export function checkCustomFingerprint(fingerprint: Record): void { + const userAgent = fingerprint?.navigator?.userAgent || ""; + const browserName = parseUaFamily(userAgent); + if (browserName !== "Firefox") { + throw new NonFirefoxFingerprint( + `"${browserName}" fingerprints are not supported in Camoufox. ` + + "Using fingerprints from a browser other than Firefox WILL lead to detection. " + + "If this is intentional, pass `i_know_what_im_doing=True`.", + ); + } + + LeakWarning.warn("custom_fingerprint", false); +} + +/** ua_parser.user_agent_parser.ParseUserAgent(ua)['family']. */ +function parseUaFamily(userAgent: string): string { + const name = new UAParser(userAgent).getBrowser().name; + if (!name) return "Other"; + // ua-parser-js and uap-core name a few browsers differently. + const map: Record = { + "Mobile Firefox": "Firefox Mobile", + "Mobile Chrome": "Chrome Mobile", + "Mobile Safari": "Mobile Safari", + }; + return map[name] ?? name; +} + +/** + * Checks if the target OS is valid. + */ +export function checkValidOs(osValue: ListOrString): void { + if (typeof osValue !== "string") { + for (const osName of osValue) checkValidOs(osName); + return; + } + // Assert that the OS is lowercase (str.islower(): has a cased char, none upper) + if ( + !(osValue !== osValue.toUpperCase() && osValue === osValue.toLowerCase()) + ) { + throw new InvalidOS(`OS values must be lowercase: '${osValue}'`); + } + // Assert that the OS is supported by Camoufox + if (!["windows", "macos", "linux"].includes(osValue)) { + throw new InvalidOS(`Camoufox does not support the OS: '${osValue}'`); + } +} + +/** + * Merges new keys/values from the source into the target, given that the key + * does not exist in the target. + */ +export function mergeInto( + target: Record, + source: Record, +): void { + for (const [key, value] of Object.entries(source)) { + if (!(key in target)) target[key] = value; + } +} + +/** + * Sets a new key/value into the target, given that the key does not exist. + */ +export function setInto( + target: Record, + key: string, + value: any, +): void { + if (!(key in target)) target[key] = value; +} + +/** + * Checks if a domain is set in the config. + */ +export function isDomainSet( + config: Record, + ...properties: string[] +): boolean { + for (const prop of properties) { + // If the . prefix exists, check if the domain is a prefix of any key + if (prop.endsWith(".") || prop.endsWith(":")) { + if (Object.keys(config).some((key) => key.startsWith(prop))) return true; + } else if (prop in config) { + // Otherwise, check if the domain is a direct key in the config + return true; + } + } + return false; +} + +/** + * Warns the user if they are manually setting properties that Camoufox already + * sets internally. + */ +export function warnManualConfig(config: Record): void { + // Manual locale setting + if ( + isDomainSet( + config, + "navigator.language", + "headers.Accept-Language", + "locale:", + ) + ) { + LeakWarning.warn("locale", false); + } + // Manual geolocation and timezone setting + if (isDomainSet(config, "geolocation:", "timezone")) { + LeakWarning.warn("geolocation", false); + } + // Manual User-Agent setting + if (isDomainSet(config, "headers.User-Agent")) { + LeakWarning.warn("header-ua", false); + } + // Manual navigator setting + if (isDomainSet(config, "navigator.")) { + LeakWarning.warn("navigator", false); + } + // Touchscreen digitizer spoofing. Called out separately from the blanket + // navigator warning because the knock-on effects reach past navigator into + // CSS pointer media queries and the TouchEvent interfaces. + if (isDomainSet(config, "navigator.maxTouchPoints")) { + LeakWarning.warn("max_touch_points", false); + } + if (isTruthy(config.instantAnimations)) { + LeakWarning.warn("instant_animations", false); + } + // Manual screen/window setting + if (isDomainSet(config, "screen.", "window.", "document.body.")) { + LeakWarning.warn("viewport", false); + } +} + +const WINDOW_DIM_KEYS = [ + "window.outerWidth", + "window.outerHeight", + "window.innerWidth", + "window.innerHeight", +]; + +/** The CAMOU_CONFIG chunks of a set of launch options, reassembled in order. */ +export function camouConfigBlob(fromOptions: Record): string { + const env: Record = fromOptions.env ?? {}; + return Object.entries(env) + .filter(([k]) => k.startsWith("CAMOU_CONFIG_")) + .map( + ([k, v]) => + [ + Number.parseInt(k.slice(k.lastIndexOf("_") + 1), 10), + String(v), + ] as const, + ) + .sort((a, b) => a[0] - b[0]) + .map(([, v]) => v) + .join(""); +} + +/** + * The core count the browser must be pinned to for these launch options, or + * null: the identity's navigator.hardwareConcurrency when this host can honour + * it (see cpu_affinity), so a page measuring parallelism sees the reported + * number. + */ +export function pinnedCoreCount( + fromOptions: Record, +): number | null { + const blob = camouConfigBlob(fromOptions); + if (!blob || !cpuAffinity.supported()) return null; + let value: unknown; + try { + const parsed = JSON.parse(blob); + value = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed["navigator.hardwareConcurrency"] + : undefined; + } catch { + return null; + } + // The host's count, not this process's live mask: a concurrent pinned + // launch narrows that while its browser spawns. + const cores = cpuAffinity.hostCoreCount(); + if ( + typeof value === "number" && + Number.isInteger(value) && + value >= 1 && + value < cores + ) { + return value; + } + return null; +} + +/** + * PID of the process that will spawn the browser (its children inherit the + * CPU affinity set on it). Python's driver is a separate Node process; + * playwright-core runs in this one. + */ +export function driverPid(): number { + return process.pid; +} + +/** + * Whether the CAMOU_CONFIG in a set of launch options spoofs any window + * dimension. The config is chunked across CAMOU_CONFIG_ env vars, so + * reassemble it in index order before looking. + */ +export function spoofsWindowDimensions( + fromOptions: Record, +): boolean { + const blob = camouConfigBlob(fromOptions); + if (!blob) return false; + return WINDOW_DIM_KEYS.some((key) => blob.includes(key)); +} + +/** + * Playwright emulates four media features on every context it creates, whether + * or not the caller asked: `colorScheme` defaults to "light" and reducedMotion / + * forcedColors / contrast to their no-preference values. That is an override, + * not a passthrough -- the page then reports it whatever the host is set to. The + * JS API's `null` (Python's "no-override") is Playwright's own opt-out: it sends + * no emulation at all and the browser answers from the host. + */ +export const STOCK_MEDIA_DEFAULTS = { + colorScheme: null, + reducedMotion: null, + forcedColors: null, + contrast: null, +} as const; + +/** Fill STOCK_MEDIA_DEFAULTS into any media option the caller left unset. */ +export function withStockMediaDefaults( + opts: Record, +): Record { + const out = { ...opts }; + for (const [option, value] of Object.entries(STOCK_MEDIA_DEFAULTS)) { + if (out[option] === undefined) out[option] = value; + } + return out; +} + +/** + * Default newPage()/newContext() to the host's own media features. + * + * Explicit colorScheme / reducedMotion / forcedColors / contrast from the caller + * always wins; this only replaces Playwright's silent defaults. + */ +export function attachStockMediaDefaults(target: T): T { + for (const name of ["newPage", "newContext"] as const) { + const original = (target as any)[name]; + if (typeof original !== "function") continue; + (target as any)[name] = (options?: Record, ...rest: any[]) => + original.call(target, withStockMediaDefaults(options ?? {}), ...rest); + } + return target; +} + +/** + * Normalise a context-options object onto the JS API's `viewport: null`, + * defaulting to it when the caller expressed no preference. Playwright-Python + * takes `no_viewport=True`; a caller-supplied `noViewport` is accepted and + * translated. + */ +export function applyNoViewport( + opts: Record, +): Record { + const out = { ...opts }; + if ("noViewport" in out) { + const noViewport = out.noViewport; + delete out.noViewport; + if (noViewport && !("viewport" in out)) out.viewport = null; + return out; + } + if (!("viewport" in out)) out.viewport = null; + return out; +} + +/** + * Default newPage()/newContext() to no viewport. + * + * Playwright applies a 1280x720 viewport by default, which makes Juggler ask + * the content window to become 1280x720 (TargetRegistry.updateViewportSize). + * When Camoufox is pinning the window to a spoofed size, that request can + * never be satisfied, and awaitViewportDimensions has no timeout -- so the + * second newPage() hangs forever (daijro/camoufox#666). An explicit viewport + * from the caller always wins. + */ +export function attachNoViewportDefault(target: T): T { + for (const name of ["newPage", "newContext"] as const) { + const original = (target as any)[name]; + if (typeof original !== "function") continue; + (target as any)[name] = (options?: Record, ...rest: any[]) => + original.call(target, applyNoViewport(options ?? {}), ...rest); + } + return target; +} + +/** + * Attaches the virtual display to the browser's cleanup. (Python has an async + * and a sync variant; playwright-core has one API.) + */ +export function attachVirtualDisplay( + browser: T, + virtualDisplay?: VirtualDisplay | null, +): T { + if (!virtualDisplay) return browser; // Skip if no virtual display is provided + + const target = browser as any; + const originalClose = target.close.bind(target); + + target.close = async (...args: any[]) => { + try { + return await originalClose(...args); + } finally { + virtualDisplay.kill(); + } + }; + // close() never runs on an unexpected disconnect/close, so wire teardown + // directly too; kill() is idempotent. + target.on?.(target.contexts ? "disconnected" : "close", () => + virtualDisplay.kill(), + ); + target._virtualDisplay = virtualDisplay; + + return browser; +} + +/** + * The version of the build about to be launched. + * + * installedVerStr() answers "which release did `camoufox fetch` put in the + * cache", which is the wrong question when the caller named a binary. Firefox + * writes application.ini beside the executable, so when a path is given the + * answer is right there. Falls back to the installed release when it is not. + */ +export function resolveVerstr(executablePath?: string | null): string { + if (executablePath) { + const ini = path.join(path.dirname(executablePath), "application.ini"); + try { + for (const line of fs.readFileSync(ini, "utf-8").split(/\r\n|\r|\n/)) { + if (line.startsWith("Version=")) { + const version = line.split("=").slice(1).join("=").trim(); + if (version) return version; + } + } + } catch { + // fall through + } + } + return utilsDeps.installedVerStr(); +} + +/** A bound on the screen a generated fingerprint may claim. */ +export type ScreenConstraint = + | Screen + | { + min_width?: number; + max_width?: number; + min_height?: number; + max_height?: number; + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + }; + +function toScreen(screen: ScreenConstraint): Screen { + if (screen instanceof Screen) return screen; + const s = screen as Record; + return new Screen({ + minWidth: s.minWidth ?? s.min_width, + maxWidth: s.maxWidth ?? s.max_width, + minHeight: s.minHeight ?? s.min_height, + maxHeight: s.maxHeight ?? s.max_height, + }); +} + +export interface LaunchOptions { + /** Camoufox properties to use. + * (read https://github.com/daijro/camoufox/blob/main/README.md) */ + config?: Record; + /** Operating system to use for the fingerprint generation. Can be + * "windows", "macos", "linux", or a list to randomly choose from. */ + os?: ListOrString; + /** Whether to block all images. */ + block_images?: boolean; + /** Whether to block WebRTC entirely. */ + block_webrtc?: boolean; + /** Whether to block WebGL. To prevent leaks, only use this for special cases. */ + block_webgl?: boolean; + /** Disables the Cross-Origin-Opener-Policy, allowing elements in cross-origin + * iframes, such as the Turnstile checkbox, to be clicked. */ + disable_coop?: boolean; + /** Use a specific WebGL vendor/renderer pair, as [vendor, renderer]. */ + webgl_config?: [string, string]; + /** Calculate longitude, latitude, timezone, country, & locale based on the IP + * address. Pass the target IP address to use, or `true` to find it. */ + geoip?: string | boolean; + /** Name of the GeoIP database to use (e.g. "MaxMind GeoLite2"). */ + geoip_db?: string; + /** Humanize the cursor movement: `true`, or the MAX duration in seconds. */ + humanize?: boolean | number; + /** Locale(s) to use. The first listed locale is used for the Intl API. */ + locale?: string | string[]; + /** List of Firefox addons to use (paths to extracted addons). */ + addons?: string[]; + /** Fonts to load into Camoufox (in addition to the target `os`'s). */ + fonts?: string[]; + /** If enabled, OS-specific system fonts will not be passed to Camoufox. */ + custom_fonts_only?: boolean; + /** Default addons to exclude. */ + exclude_addons?: DefaultAddon[]; + /** Constrains the screen dimensions of the generated fingerprint. */ + screen?: ScreenConstraint; + /** Set a fixed window size instead of generating a random one. */ + window?: [number, number]; + /** Use a custom fpgen fingerprint. */ + fingerprint?: Record; + /** Opt into real fingerprint presets: `true` for a random bundled preset, + * or a preset object. */ + fingerprint_preset?: boolean | Record; + /** Firefox version to use. Defaults to the current Camoufox version. */ + ff_version?: number; + /** Whether to run the browser in headless mode. Defaults to false. */ + headless?: boolean; + /** Whether to enable running scripts in the main world ("mw:" prefix). */ + main_world_eval?: boolean; + /** Whether to allow addons to open new tabs. Defaults to false. */ + allow_addon_new_tab?: boolean; + /** Custom Camoufox browser executable path. */ + executable_path?: string; + /** Select a specific installed browser version ("official/beta.20", + * "beta.20", "134.0.2-beta.20"). Defaults to the active version. */ + browser?: string; + /** Firefox user preferences to set. */ + firefox_user_prefs?: Record; + /** Proxy to use for the browser. */ + proxy?: ProxyConfig; + /** Cache previous pages, requests, etc (uses more memory). */ + enable_cache?: boolean; + /** Arguments to pass to the browser. */ + args?: string[]; + /** Environment variables to set. Defaults to a copy of process.env. */ + env?: EnvVars; + /** Suppress leak warnings for options you are deliberately overriding. */ + i_know_what_im_doing?: boolean; + /** Prints the config being sent to Camoufox. */ + debug?: boolean; + /** Virtual display number, e.g. ":99". Handled by Camoufox & NewBrowser. */ + virtual_display?: string; + /** Pin the browser to navigator.hardwareConcurrency cores (Linux/Windows). + * OFF by default -- it costs real CPU and serializes concurrent launches. */ + pin_cpu_cores?: boolean; + /** Additional Firefox launch options, passed straight through to Playwright. */ + [key: string]: any; +} + +/** + * Builds the Playwright Firefox launch options for Camoufox. + * + * Accepts all Playwright Firefox launch options, along with the Camoufox ones + * documented on {@link LaunchOptions}. + */ +export async function launchOptions({ + config, + os: targetOsOption, + block_images, + block_webrtc, + block_webgl, + disable_coop, + webgl_config, + geoip, + geoip_db, + humanize, + locale, + addons, + fonts, + custom_fonts_only, + exclude_addons, + screen, + window, + fingerprint, + fingerprint_preset, + ff_version, + headless, + main_world_eval, + allow_addon_new_tab, + executable_path, + browser, + firefox_user_prefs, + proxy, + enable_cache, + args, + env, + i_know_what_im_doing, + debug, + virtual_display, + pin_cpu_cores, + ...passthrough +}: LaunchOptions = {}): Promise> { + utilsDeps.ensureBrowserProfileDir(env); + + // Build the config + config ??= {}; + + // Set default values for optional arguments + headless ??= false; + addons ??= []; + args ??= []; + firefox_user_prefs ??= {}; + custom_fonts_only ??= false; + i_know_what_im_doing ??= false; + // Keep per-launch overrides isolated from the process environment and from + // mappings supplied by callers. In particular, DISPLAY must not outlive the + // virtual display that owns it. + env = env == null ? ({ ...process.env } as EnvVars) : { ...env }; + if (executable_path == null) { + // Point every launch at a specific build without threading the path + // through each call site. Absent the variable nothing changes. + const envExecutable = (process.env.CAMOUFOX_EXECUTABLE_PATH ?? "").trim(); + if (envExecutable) { + executable_path = envExecutable; + } + } + if (typeof executable_path === "string") { + executable_path = path.resolve(executable_path); + } + + // Handle virtual display + if (virtual_display) { + env.DISPLAY = virtual_display; + // Virtual display uses Xvfb (X11). If the host session forces Wayland via + // env vars, GTK/Firefox may try Wayland and ignore DISPLAY. + env.GDK_BACKEND = "x11"; + delete env.WAYLAND_DISPLAY; + env.MOZ_ENABLE_WAYLAND = "0"; + } + + // Warn the user for manual config settings + if (!i_know_what_im_doing) { + warnManualConfig(config); + } + + // Snapshot which domains the USER set before fingerprint generation fills in + // the rest. The post-generation corrections below must only touch generated + // values, never override what the user passed. + const userSetNavigator = isDomainSet(config, "navigator."); + const userSetScreenWindow = isDomainSet(config, "screen.", "window."); + const userSetMediaDevices = isDomainSet(config, "mediaDevices:"); + const userSetFonts = Boolean(fonts?.length) || isDomainSet(config, "fonts"); + const userSetVoices = isDomainSet(config, "voices"); + const userSetDnt = "navigator.doNotTrack" in config; + const userSetGpc = "navigator.globalPrivacyControl" in config; + const userSetAcceptEncoding = "headers.Accept-Encoding" in config; + const userSetAudioSeed = "audio:seed" in config; + + // The salt that makes every seeded draw belong to this identity (see + // fingerprints.identitySalt): stable when the caller pinned the identity -- + // a fingerprint, a preset object, or their own config naming the UA -- and + // fresh otherwise. + let salt: bigint | number; + if (fingerprint != null) { + salt = utilsDeps.identitySalt(fingerprint); + } else if (isPlainObject(fingerprint_preset)) { + salt = utilsDeps.identitySalt(fingerprint_preset); + } else if ("navigator.userAgent" in config) { + salt = utilsDeps.identitySalt({ ...config }); + } else { + salt = utilsDeps.identitySalt(); + } + + // Assert the target OS is valid + if (isTruthy(targetOsOption)) { + checkValidOs(targetOsOption as ListOrString); + } else if (isTruthy(webgl_config)) { + // webgl_config requires OS to be set + throw new ValueError("OS must be set when using webgl_config"); + } + + // Add the default addons + await utilsDeps.addDefaultAddons(addons, exclude_addons); + + // Confirm all addon paths are valid + if (addons.length) { + utilsDeps.confirmPaths(addons); + config.addons = addons; + } + + // The managed install is resolved lazily in Python (camoufox_path() may + // download); here that download is async, so do it before any sync lookup. + if (!executable_path) { + await utilsDeps.ensureCamoufoxInstalled(); + } + + // Get the Firefox version + let ffVersionStr: string; + if (ff_version) { + ffVersionStr = String(ff_version); + LeakWarning.warn("ff_version", i_know_what_im_doing); + } else { + ffVersionStr = resolveVerstr(executable_path).split(".")[0]; + } + + // Generate a fingerprint + let usedPreset = false; + if (fingerprint != null) { + // User passed a custom fingerprint + if (!i_know_what_im_doing) { + checkCustomFingerprint(fingerprint); + } + } else if (isTruthy(fingerprint_preset)) { + // User opted into real fingerprint presets + const preset = isPlainObject(fingerprint_preset) + ? fingerprint_preset + : await utilsDeps.getRandomPreset(targetOsOption, ffVersionStr); + if (isTruthy(preset)) { + mergeInto( + config, + await utilsDeps.fromPreset(preset as any, ffVersionStr, salt), + ); + usedPreset = true; + } + } + + // Bound the geometry to the real display. The generator only honours this + // when its pool has a match, so it is re-applied after generation as well. + // `headless` and "is there a display to probe" are separate questions. + const screenCons: Screen | null = screen + ? toScreen(screen) + : utilsDeps.hasDisplay(env) + ? utilsDeps.getScreenCons(headless) + : null; + + if (!usedPreset && fingerprint == null) { + // Default: synthetic generation via fpgen (infinite unique fingerprints) + fingerprint = await utilsDeps.generateFingerprint({ + screen: screenCons ?? undefined, + window, + os: targetOsOption, + }); + } + + if (!usedPreset && fingerprint != null) { + // Inject the generated fingerprint into the config + mergeInto(config, utilsDeps.fromFpgen(fingerprint, ffVersionStr)); + } + + const targetOs = getTargetOs(config); + + // Drop values the source supplied that this identity cannot keep, before the + // pools below defer to them (a preset's own GPU pair wins over sampling). + coherence.dropIncoherentSourceValues(config, targetOs); + // A preset whose screen is a phone viewport is not a real desktop device. + if (!userSetScreenWindow && coherence.screenIsImplausible(config)) { + coherence.repairScreenOrientation(config); + utilsDeps.raiseScreenToModernFloor(config); + } + + // Correct fingerprint inconsistencies that leak as headless / + // impossible-geometry tells, unless the user is driving these themselves. + if (!userSetNavigator) { + utilsDeps.fixNavigatorArch(config, targetOs); + utilsDeps.fixHardwareConcurrency(config, Boolean(pin_cpu_cores)); + } + if (!userSetScreenWindow) { + // Lift netbook-era geometry to something current hardware reports, + // before the display clamp below so a genuinely small real monitor still + // wins (#729). Synthetic draws only: a preset is a real device. + if (!usedPreset) { + utilsDeps.raiseScreenToModernFloor(config); + } + // Headful on a real monitor only: this bound exists so the window fits + // the screen it is drawn on. headless has no window to overflow, and + // headless="virtual" reaches here as headless=false with a 1x1 Xvfb. + if (headless === false && !virtual_display && screenCons) { + utilsDeps.clampScreenToDisplay( + config, + screenCons.maxWidth as number, + screenCons.maxHeight as number, + ); + } + utilsDeps.fixScreenNoTaskbar(config, targetOs); + utilsDeps.clampWindowDimensions(config); + utilsDeps.clampWindowPosition(config); + } + + // Deliberately NOT setting window.history.length: settings/camoufox.cfg runs + // Firefox's stock max_entries, so the real value starts at 1 and grows with + // each navigation; pinning it would contradict history.back(). + + // Update fonts list + if (fonts?.length) { + config.fonts = fonts; + } + + if (custom_fonts_only) { + firefox_user_prefs["gfx.bundled-fonts.activate"] = 0; + if (fonts?.length) { + LeakWarning.warn("custom_fonts_only"); + } else { + throw new ValueError( + "No custom fonts were passed, but `custom_fonts_only` is enabled.", + ); + } + } else if (!userSetFonts || !isTruthy(config.fonts)) { + // Draw the font subset HERE, after every identity fix-up above, so the + // seed sees the final UA/screen/cores/GPU: the same presented identity + // always gets the same font list (#442/#765). + const osName = + ({ win: "windows", mac: "macos", lin: "linux" } as const)[targetOs] ?? + "macos"; + try { + config.fonts = utilsDeps.generateRandomFontSubset( + osName, + utilsDeps.identitySeed(config, salt), + // host's own OS on macOS/Windows: the real system fonts are used + // (font-hijacker.patch keeps the bundle inactive), so only the OS + // base is claimed + (targetOs === "mac" || targetOs === "win") && + utilsDeps.hostOsKey() === targetOs, + ); + } catch (e) { + if (!isPyError(e, "OSError", "ValueError")) throw e; + FallbackWarning.warn( + "Drawing the font list", + `every font fonts.json lists for ${targetOs}`, + e, + config["navigator.userAgent"], + ); + updateFonts(config, targetOs); + } + } + + // Draw the identity's media devices unless the caller set any mediaDevices: + // key. An empty enumerateDevices() list is a headless tell; a wrong label + // after a grant is a spoof tell. + if (!userSetMediaDevices) { + await utilsDeps.setMediaDevicesDefaults(config, salt); + } + + // Scrollbars: pin the look-and-feel to the claimed OS so headless == headed + // == stock, and for Windows to the version the identity's font draw + // presents (Win11 fonts with classic scrollbars is a pair no real machine + // produces). + if (targetOs === "win") { + const presentedFonts: string[] = config.fonts || []; + const windows11 = + !presentedFonts.length || + presentedFonts.some((font) => WINDOWS_11_MARKER_FONTS.has(font)); + setInto(firefox_user_prefs, "ui.useOverlayScrollbars", windows11 ? 1 : 0); + } else { + setInto(firefox_user_prefs, "ui.useOverlayScrollbars", 1); + } + + // Per-character font fallback, LINUX ONLY: with async fallback on, the + // first measurement of a character only one bundled family provides returns + // the primary family's .notdef. macOS must NOT get this. + if (targetOs === "lin") { + setInto(firefox_user_prefs, "gfx.font_rendering.fallback.async", false); + } + + // Storage quota, from the host's own disk (see stockProfileDiskCapacityKb). + const quotaLimitKb = utilsDeps.stockProfileDiskCapacityKb(); + if (quotaLimitKb) { + setInto(firefox_user_prefs, QUOTA_FIXED_LIMIT_PREF, quotaLimitKb); + } + + // navigator.doNotTrack and navigator.globalPrivacyControl are pref-backed in + // Firefox; spoofing them anywhere else leaves the wire (or the worker) + // contradicting the API (daijro/camoufox#760). A stock Firefox 152 reports + // "unspecified" and false, so generated values are dropped unless the + // caller set them explicitly. screen.colorDepth is left as drawn. + if (!userSetDnt) { + delete config["navigator.doNotTrack"]; + } + if (!userSetGpc) { + delete config["navigator.globalPrivacyControl"]; + } + const dnt = config["navigator.doNotTrack"]; + firefox_user_prefs["privacy.donottrackheader.enabled"] = + dnt != null && pyStr(dnt) === "1"; + const gpc = config["navigator.globalPrivacyControl"]; + firefox_user_prefs["privacy.globalprivacycontrol.enabled"] = + gpc != null ? isTruthy(gpc) : false; + + // Accept-Encoding: Firefox's own value is already what the identity claims, + // so the generated header is dropped unless the caller set it. + if (!userSetAcceptEncoding) { + delete config["headers.Accept-Encoding"]; + } + + // The audio noise seed follows the identity: a returning "same device" must + // reproduce its audio hash (#442/#765). Never 0 (0 disables the noise). + // There is no canvas seed: the browser adds no canvas noise (#528), and no + // glyph-spacing noise either (ci/tribal-rules.yml: no-glyph-spacing-noise). + if (!userSetAudioSeed) { + config["audio:seed"] = audioSeedFromIdentity( + utilsDeps.identitySeed(config, salt), + ); + } + + // Set geolocation + if (isTruthy(geoip)) { + geoipAllowed(); // Assert that geoip is allowed + + let geoipIp: string; + if (geoip === true) { + // Find the user's IP address + geoipIp = proxy + ? await utilsDeps.publicIp(ProxyHelper.asString(proxy)) + : await utilsDeps.publicIp(); + } else { + geoipIp = geoip as string; + } + + // Spoof WebRTC if not blocked + if (!block_webrtc) { + if (validIPv4(geoipIp)) { + setInto(config, "webrtc:ipv4", geoipIp); + firefox_user_prefs["network.dns.disableIPv6"] = true; + } else if (validIPv6(geoipIp)) { + setInto(config, "webrtc:ipv6", geoipIp); + } + } + + const geolocation = await utilsDeps.getGeolocation(geoipIp, geoip_db); + for (const [key, value] of Object.entries(geolocation.asConfig())) { + if ( + [ + "timezone", + "locale:language", + "locale:region", + "locale:script", + ].includes(key) + ) { + setInto(config, key, value); + } else { + config[key] = value; + } + } + } + + // A page that receives a position without a prompt must also see + // permissions.query({name: 'geolocation'}) report "granted" (#769). + if ("geolocation:latitude" in config && "geolocation:longitude" in config) { + setInto(firefox_user_prefs, "permissions.default.geo", 1); + } else if ( + // Raise a warning when a proxy is being used without spoofing + // geolocation. This warning cannot be ignored with i_know_what_im_doing. + isTruthy(proxy) && + !(proxy?.server ?? "").includes("localhost") && + !isDomainSet(config, "geolocation") + ) { + LeakWarning.warn("proxy_without_geoip"); + } + + // Set locale + if (isTruthy(locale)) { + await handleLocales(locale as string | string[], config); + } + + // Select the browser's UI locale to match the Intl locale. Always set: an + // EMPTY value would follow the host OS locale. + let requested: string; + if (isTruthy(config["locale:language"])) { + requested = [ + config["locale:language"], + config["locale:script"], + config["locale:region"], + ] + .filter((part) => isTruthy(part)) + .join("-"); + } else { + requested = "en-US"; + } + setInto(firefox_user_prefs, "intl.locale.requested", requested); + + // Spoof the speech-synthesis voice list. This has to fail CLOSED: leaving + // `voices` unset exposes every native voice on the box (#731). Drawn after + // the locale is resolved: the Windows voice list is the display language's + // pack. + if (!userSetVoices || !("voices" in config)) { + const osNameV = + ({ win: "windows", mac: "macos", lin: "linux" } as const)[targetOs] ?? + "macos"; + let voiceLocale = config["navigator.language"]; + if (isTruthy(config["locale:language"])) { + voiceLocale = [config["locale:language"], config["locale:region"]] + .filter((part) => isTruthy(part)) + .join("-"); + } + try { + config.voices = utilsDeps.generateRandomVoiceSubset( + osNameV, + voiceLocale ?? null, + utilsDeps.identitySeed(config, salt), + ); + } catch (e) { + if (!isPyError(e, "OSError", "ValueError", "KeyError")) throw e; + // An empty list still blocks the host's voices (see below), so a + // generation failure degrades to "no voices" rather than "all of + // the host's". + FallbackWarning.warn( + "Drawing the speech voices", + "no speech voices", + e, + config["navigator.userAgent"], + ); + config.voices = []; + } + } + + // Pin the block explicitly instead of relying on a non-empty list to imply + // it. setInto leaves an explicit caller value alone. + setInto(config, "voices:blockIfNotDefined", true); + + // Pass the humanize option + if (isTruthy(humanize)) { + setInto(config, "humanize", true); + // MaskConfig expects maxTime to be a JSON number. + // float(humanize): a JSON number with a floating-point representation. + if (typeof humanize === "number") { + setInto(config, "humanize:maxTime", new PyFloat(humanize)); + } + } + + // Enable the main world context creation + if (main_world_eval) { + setInto(config, "allowMainWorld", true); + } + + // Allow addon open new tabs + if (allow_addon_new_tab) { + setInto(config, "allowAddonNewtab", true); + } + + // Set Firefox user preferences + if (block_images) { + LeakWarning.warn("block_images", i_know_what_im_doing); + firefox_user_prefs["permissions.default.image"] = 2; + } + if (block_webrtc) { + firefox_user_prefs["media.peerconnection.enabled"] = false; + } + if (disable_coop) { + LeakWarning.warn("disable_coop", i_know_what_im_doing); + firefox_user_prefs["browser.tabs.remote.useCrossOriginOpenerPolicy"] = + false; + } + + if (block_webgl) { + firefox_user_prefs["webgl.disabled"] = true; + LeakWarning.warn("block_webgl", i_know_what_im_doing); + } else { + let webglFp: Record; + const seed = () => + utilsDeps.identitySeed(config as Record, salt); + // A pair the caller named, or the preset's own GPU, keeps its name and + // gets that device's recorded parameters. webglForGpu raises for a GPU + // fpgen has never seen: the caller asked for something that does not exist. + if (isTruthy(webgl_config)) { + const [vendor, renderer] = webgl_config as [string, string]; + webglFp = await utilsDeps.webglForGpu(targetOs, vendor, renderer, seed()); + } else if ( + isTruthy(config["webGl:vendor"]) && + isTruthy(config["webGl:renderer"]) + ) { + webglFp = await utilsDeps.webglForGpu( + targetOs, + config["webGl:vendor"], + config["webGl:renderer"], + seed(), + ); + } else { + // Synthetic path: keep the GPU coherent with the screen fpgen already + // picked. Sampling the two independently yields pairs no real machine + // ships -- a discrete desktop GPU behind a 1024x600 panel (#729). + webglFp = await utilsDeps.sampleWebglForScreen( + targetOs, + config["screen.width"], + config["screen.height"], + seed(), + ); + } + const enableWebgl2 = webglFp.webGl2Enabled; + delete webglFp.webGl2Enabled; + + // Merge the WebGL fingerprint into the config + mergeInto(config, webglFp); + // Set the WebGL preferences + mergeInto(firefox_user_prefs, { + "webgl.enable-webgl2": enableWebgl2, + "webgl.force-enabled": true, + }); + } + + // Every identity passes the whole-identity checks, whatever built it. The + // pools are sampled independently, so a machine that never existed can be + // assembled from parts that are each fine on their own. See coherence.ts. + const incoherent = coherence.apply(config, targetOs); + if (incoherent.length && debug) { + for (const violation of incoherent) { + console.log( + `Incoherent identity (${violation.rule}): ${violation.detail}`, + ); + } + } + + // Cache previous pages, requests, etc (uses more memory) + if (enable_cache) { + mergeInto(firefox_user_prefs, CACHE_PREFS); + } + + // Print the config if debug is enabled + if (debug) { + console.log("[DEBUG] Config:"); + console.log(inspect(config, { depth: null, sorted: true })); + } + + // Validate the config + warnIfExecutablePredatesPlaywright(executable_path); + utilsDeps.validateConfig(config, executable_path); + + // Prepare environment variables to pass to Camoufox + const envVars: EnvVars = { + ...utilsDeps.getEnvVars(config, targetOs, executable_path), + ...getPrefEnvVars(firefox_user_prefs), + ...env, + }; + + // Prepare the executable path + let resolvedExecutable: string; + if (executable_path) { + resolvedExecutable = String(executable_path); + } else if (browser) { + // Select a specific installed browser version + const browserPath = await utilsDeps.findInstalledVersion(browser); + if (!browserPath) { + throw new Error( + `Browser version '${browser}' not found. Run \`camoufox list\` to see installed versions.`, + ); + } + resolvedExecutable = utilsDeps.launchPath(browserPath); + } else { + resolvedExecutable = utilsDeps.launchPath(); + } + + const result: Record = { + executablePath: resolvedExecutable, + args, + env: envVars, + firefoxUserPrefs: firefox_user_prefs, + headless, + ...passthrough, + }; + // Only include proxy if it's set (Playwright validates this) + // https://github.com/coryking/camoufox/commit/1336e8e509e8c12a896a09d9ee51f131f739f106 + // Thanks @coryking + if (proxy != null) { + result.proxy = proxy; + } + + return result; +} + +/** Python truthiness for the option values launch_options() tests. */ +function isTruthy(value: unknown): boolean { + if (value === null || value === undefined || value === false) return false; + if (value === 0 || value === "" || Number.isNaN(value)) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value as object).length > 0; + return true; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/typescript/src/virtdisplay.ts b/typescript/src/virtdisplay.ts new file mode 100644 index 000000000..81a9773f0 --- /dev/null +++ b/typescript/src/virtdisplay.ts @@ -0,0 +1,320 @@ +/** + * A minimal virtual display implementation for Linux. + * + * TypeScript twin of pythonlib/camoufox/virtdisplay.py. + */ +import { type ChildProcess, execFileSync, spawn } from "node:child_process"; +import * as fs from "node:fs"; +import { + CannotExecuteXvfb, + CannotFindXvfb, + VirtualDisplayNotSupported, +} from "./exceptions.js"; +import { OS_NAME } from "./pkgman.js"; + +/** Safe timeout for Xvfb writing the display num; prevents an infinite hang. */ +const DISPLAYFD_READ_TIMEOUT_MS = 10_000; + +/** + * Xvfb screen geometry for headless="virtual". + * + * 1x1x24 is Camoufox's long-standing default and stays the default. The root + * window size is not observable as a fingerprint: screen.* comes from the + * generated fingerprint, applied per context in the browser, and + * clampScreenToDisplay() is skipped entirely for virtual displays (see the + * `!virtual_display` guard in utils.ts), so a 1x1 root never clamps a generated + * screen down to 1x1. + * + * Override with CAMOUFOX_VIRTUAL_DISPLAY_SIZE="xx", e.g. + * "1920x1080x24", for the cases that do want a real framebuffer to draw into. + * Depth may be omitted. + */ +export const DEFAULT_SCREEN = "1x1x24"; +export const SCREEN_ENV_VAR = "CAMOUFOX_VIRTUAL_DISPLAY_SIZE"; + +/** + * The Composite extension, disabled by default (Xvfb's `-extension COMPOSITE`). + * + * This was briefly enabled by default on the theory that #93 (no video under + * headless="virtual") was caused by disabling it. It was not: #93 was a juggler + * bug, fixed by capturing the screencast from the compositor instead of from + * libwebrtc's X11 window capturer. Both states were measured before that fix: + * + * composite off, record_video_dir -> a valid .webm of 24 pure-white frames + * composite ON, record_video_dir -> browser dies with SIGSEGV, no video + * composite ON, no recording -> fine + * + * The segfault was inside the X11 capturer, which the browser no longer uses, + * so enabling Composite is no longer dangerous -- but it is also no longer good + * for anything, since recording never touches X11 window capture now. Leave it + * off (Camoufox's long-standing default) and keep the escape hatch: + * CAMOUFOX_VIRTUAL_DISPLAY_COMPOSITE=1 enables it. + */ +export const COMPOSITE_ENV_VAR = "CAMOUFOX_VIRTUAL_DISPLAY_COMPOSITE"; + +/** Screen geometry for Xvfb's -screen argument. */ +function resolveScreen(): string { + const value = (process.env[SCREEN_ENV_VAR] ?? "").trim(); + if (!value) return DEFAULT_SCREEN; + const parts = value.toLowerCase().split("x"); + const valid = + (parts.length === 2 || parts.length === 3) && + parts.every((p) => /^\d+$/.test(p) && Number.parseInt(p, 10) > 0); + if (!valid) { + throw new VirtualDisplayNotSupported( + `${SCREEN_ENV_VAR} must look like '1920x1080' or '1920x1080x24', got '${value}'`, + ); + } + if (parts.length === 2) parts.push("24"); + return parts.join("x"); +} + +export class VirtualDisplay { + debug: boolean; + screen: string; + composite: boolean; + proc: ChildProcess | null = null; + private _display: number | null = null; + + constructor(debug: boolean = false, screen?: string, composite?: boolean) { + this.debug = debug; + this.screen = screen || resolveScreen(); + this.composite = + composite ?? + ["1", "true"].includes((process.env[COMPOSITE_ENV_VAR] ?? "0").trim()); + } + + get xvfbArgs(): string[] { + return [ + "-screen", + "0", + this.screen, + "-ac", + "-nolisten", + "tcp", + "-extension", + "RENDER", + "+extension", + "GLX", + this.composite ? "+extension" : "-extension", + "COMPOSITE", + "-extension", + "XVideo", + "-extension", + "XVideo-MotionCompensation", + "-extension", + "XINERAMA", + "-fp", + "built-ins", + "-nocursor", + "-br", + ]; + } + + get xvfbPath(): string { + let resolved: string; + try { + resolved = execFileSync("which", ["Xvfb"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + throw new CannotFindXvfb("Please install Xvfb to use headless mode."); + } + if (!resolved) { + throw new CannotFindXvfb("Please install Xvfb to use headless mode."); + } + try { + fs.accessSync(resolved, fs.constants.X_OK); + } catch { + throw new CannotExecuteXvfb( + `I do not have permission to execute Xvfb: ${resolved}`, + ); + } + return resolved; + } + + /** + * Spawn Xvfb (if it isn't already running) and return its ":N" display. + * + * Uses `-displayfd` so Xvfb itself picks a free display number atomically + * and reports it back, avoiding userspace races. Python's `pass_fds` keeps + * the fd at its parent number; Node's `stdio` array renumbers extra pipes + * from 3 upward, so we hand Xvfb fd 3 and read the pipe at index 3. + */ + async get(): Promise { + VirtualDisplay.assertLinux(); + + if (this.proc === null) { + const cmd = [this.xvfbPath, "-displayfd", "3", ...this.xvfbArgs]; + if (this.debug) { + console.log("Starting virtual display:", cmd.join(" ")); + } + this.proc = spawn(cmd[0], cmd.slice(1), { + stdio: [ + "ignore", + this.debug ? "inherit" : "ignore", + this.debug ? "inherit" : "ignore", + "pipe", + ], + detached: true, + env: { + ...process.env, + // Force Mesa software GLX; we don't use the GPU anyway. + __GLX_VENDOR_LIBRARY_NAME: "mesa", + LIBGL_ALWAYS_SOFTWARE: "1", + }, + }); + + // A spawn that fails after spawn() returns (EACCES, ENOEXEC, a noexec + // mount) is an 'error' event on the child; with no listener Node + // treats it as uncaught and exits. readDisplayNumber() turns it into + // CannotExecuteXvfb; this keeps any later one from killing the process. + this.proc.on("error", () => {}); + + const displayFd = this.proc.stdio[3] as NodeJS.ReadableStream | null; + if (!displayFd) { + this.kill(); + throw new CannotExecuteXvfb("Could not open Xvfb's -displayfd pipe"); + } + + const raw = await this.readDisplayNumber(displayFd); + const parsed = Number.parseInt(raw.trim(), 10); + if (Number.isNaN(parsed)) { + this.kill(); + throw new CannotExecuteXvfb(`Xvfb wrote non-integer display: '${raw}'`); + } + this._display = parsed; + } else if (this.debug) { + console.log(`Using virtual display: ${this._display}`); + } + + return `:${this._display}`; + } + + private readDisplayNumber(stream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let buf = ""; + let settled = false; + + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + stream.removeAllListeners("data"); + stream.removeAllListeners("end"); + stream.removeAllListeners("error"); + stream.on("error", () => {}); // a late pipe error is not fatal + proc?.removeListener("error", onSpawnError); + fn(); + }; + + const proc = this.proc; + const onSpawnError = (error: Error) => + finish(() => { + this.kill(); + reject(new CannotExecuteXvfb(`Could not start Xvfb: ${error}`)); + }); + proc?.once("error", onSpawnError); + + const timer = setTimeout(() => { + finish(() => { + this.kill(); + reject( + new CannotExecuteXvfb( + `Xvfb did not report a display within ${DISPLAYFD_READ_TIMEOUT_MS}ms`, + ), + ); + }); + }, DISPLAYFD_READ_TIMEOUT_MS); + + stream.on("data", (chunk) => { + buf += chunk.toString(); + if (buf.includes("\n")) { + finish(() => resolve(buf)); + } + }); + stream.on("end", () => { + finish(() => { + if (buf.includes("\n")) { + resolve(buf); + return; + } + const exit = this.proc?.exitCode; + this.kill(); + reject( + new CannotExecuteXvfb( + `Xvfb did not report a display (got '${buf}', exit=${exit})`, + ), + ); + }); + }); + stream.on("error", (error) => { + finish(() => { + this.kill(); + reject( + new CannotExecuteXvfb(`Failed to read Xvfb's display: ${error}`), + ); + }); + }); + }); + } + + /** + * Stop Xvfb if it is running, and remove its lock and socket either way. + * + * The cleanup deliberately does NOT depend on whether we did the killing. + * It used to: the whole body sat behind "is the process still running", so + * a display whose Xvfb had already died -- crashed, OOM-killed, or reaped + * with the browser's process group -- was never cleaned up at all. + * + * That is backwards. A SIGKILLed Xvfb never gets to remove its own socket, + * so the crash path is precisely the one where /tmp/.X11-unix/X is left + * behind. Those accumulate, and because -displayfd scans upward for a free + * number, every stranded socket pushes the next display higher until a + * long-running host stops being able to allocate one. + * + * Python waits for the killed process before unlinking; kill() is + * synchronous here, but a SIGKILLed Xvfb can no longer recreate either + * file, so unlinking right after the signal leaves the same end state. + */ + kill(): void { + if (!this.proc) return; + + if (this.proc.exitCode === null && this.proc.signalCode === null) { + if (this.debug) { + console.log("Terminating virtual display:", this._display); + } + try { + this.proc.kill("SIGKILL"); + } catch { + // Already gone. + } + } else if (this.debug) { + console.log("Virtual display already exited:", this._display); + } + + for (const stale of [ + `/tmp/.X${this._display}-lock`, + `/tmp/.X11-unix/X${this._display}`, + ]) { + try { + fs.unlinkSync(stale); + } catch { + // Missing is the normal case; anything else (a permission error + // from a number another user has since claimed) must not take + // down a teardown path. + } + } + + this.proc = null; + } + + static assertLinux(): void { + if (OS_NAME !== "lin") { + throw new VirtualDisplayNotSupported( + "Virtual display is only supported on Linux.", + ); + } + } +} diff --git a/typescript/src/warnings.ts b/typescript/src/warnings.ts new file mode 100644 index 000000000..0ccfa5381 --- /dev/null +++ b/typescript/src/warnings.ts @@ -0,0 +1,142 @@ +/** + * Leak and fallback warnings, and the warning channel the launcher reports through. + * + * TypeScript twin of pythonlib/camoufox/_warnings.py. The messages are read + * from the same warnings.yml the Python package ships, so both launchers say + * the same thing. + * + * Python routes these through the `warnings` module; the Node equivalent is + * `process.emitWarning`, which prints `(node:) : ` to + * stderr and fires `process.on("warning")`. Python's + * `warnings.catch_warnings(record=True)` is `recordWarnings()` here. + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parse as parseYaml } from "yaml"; +import { LIBRARY_VERSION } from "./__version__.js"; +import { CamoufoxNotInstalled } from "./exceptions.js"; +import { LOCAL_DATA } from "./paths.js"; +import { installedVerStr } from "./pkgman.js"; + +let warningsData: Record | undefined; + +/** warnings.yml, loaded once. */ +export function loadWarnings(): Record { + warningsData ??= parseYaml( + fs.readFileSync(path.join(LOCAL_DATA, "warnings.yml"), "utf-8"), + ) as Record; + return warningsData; +} + +export interface RecordedWarning { + category: string; + message: string; +} + +const recorder = new AsyncLocalStorage(); + +/** + * Emit a warning. While a `recordWarnings()` block is active the warning is + * captured instead of printed, as Python's catch_warnings(record=True) does. + */ +export function warn(message: string, category = "RuntimeWarning"): void { + const captured = recorder.getStore(); + if (captured) { + captured.push({ category, message }); + return; + } + process.emitWarning(message, { type: category }); +} + +/** + * Run `fn`, capturing every warning it emits (sync or async) instead of + * printing it. Scoped by AsyncLocalStorage, so concurrent work outside the + * block is not captured; nested blocks capture into the innermost one. + */ +export async function recordWarnings( + fn: () => T | Promise, +): Promise<{ result?: T; error?: unknown; warnings: RecordedWarning[] }> { + const captured: RecordedWarning[] = []; + try { + return { result: await recorder.run(captured, fn), warnings: captured }; + } catch (error) { + return { error, warnings: captured }; + } +} + +/** + * Emitted when a caller has a setting enabled that can cause detection. + */ +export class LeakWarning extends Error { + constructor(message: string) { + super(message); + this.name = "LeakWarning"; + } + + /** + * Warns the caller if a passed parameter can cause leaks. + */ + static warn(warningKey: string, iKnowWhatImDoing?: boolean): void { + let warning = loadWarnings()[warningKey]; + if (iKnowWhatImDoing) { + return; + } + if (iKnowWhatImDoing !== undefined) { + warning += "\nIf this is intentional, pass `i_know_what_im_doing=True`."; + } + warn(warning, "LeakWarning"); + } +} + +function browserVersion(): string { + try { + return installedVerStr(); + } catch (error) { + if (error instanceof CamoufoxNotInstalled) return "not installed"; + throw error; + } +} + +/** + * Emitted when part of an identity could not be drawn and a substitute was used. + */ +export class FallbackWarning extends Error { + constructor(message: string) { + super(message); + this.name = "FallbackWarning"; + } + + /** + * Warns that `what` failed with `error` and the identity uses `instead`, + * with a block of versions and the error for the user to paste into an issue. + */ + static warn( + what: string, + instead: string, + error: unknown, + identity?: string | null, + ): void { + const lines = [ + `camoufox: ${LIBRARY_VERSION} (npm)`, + `browser: ${browserVersion()}`, + `os: ${os.type()}-${os.release()}-${os.arch()}`, + `node: ${process.versions.node}`, + `error: ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`, + ]; + if (identity) lines.push(`identity: ${identity}`); + const values: Record = { + what, + instead, + report: lines.map((line) => ` ${line}`).join("\n"), + }; + warn( + loadWarnings().fallback.replace( + /\{(what|instead|report)\}/g, + (_, key: string) => values[key], + ), + "FallbackWarning", + ); + } +} diff --git a/typescript/src/webgl.ts b/typescript/src/webgl.ts new file mode 100644 index 000000000..76b160076 --- /dev/null +++ b/typescript/src/webgl.ts @@ -0,0 +1,261 @@ +/** + * WebGL identities, drawn from the Firefox devices fpgen has recorded. + * + * TypeScript twin of pythonlib/camoufox/webgl.py. Everything a page can read + * from WebGL -- vendor, renderer, context attributes, extensions, parameters + * and shader precisions, for WebGL1 and WebGL2 -- comes from one recorded + * device. fpgen's `webgl` node is conditioned on the GPU, and `webgl2` on the + * GPU and the `webgl` chosen for it, so a WebGL2 limit never contradicts its + * WebGL1 counterpart. + * + * Every draw takes one seeded PyRandom, in a fixed order (GPU, then webgl, + * then webgl2), so a seeded identity presents the same device in both + * launchers. + */ +import { gpuFitsOs } from "./coherence.js"; +import { + FPGEN_OS, + gpuScreenIsPlausible, + isSoftwareRenderer, +} from "./fingerprints.js"; +import { type TraceResult, traceWithEvidence } from "./fpgen/trace.js"; +import { lookupPossibilities } from "./fpgen/utils.js"; +import { + comparePyStr, + KeyError, + orjsonDumps, + parsePyJson, + pyStr, + pyStrRepr, + ValueError, +} from "./pycompat.js"; +import { PyRandom, type PySeed } from "./pyrandom.js"; + +export type TargetOS = "win" | "mac" | "lin"; + +/** The config keys a WebGL identity sets. */ +export interface WebGLData { + "webGl:vendor": string; + "webGl:renderer": string; + webGl2Enabled?: boolean; + [key: string]: any; +} + +// Extensions a release Firefox never exposes (draft extensions behind +// webgl.enable-draft-extensions, or mobile-only): fpgen's corpus carries some +// of them, and a spoofed list that names one is a tell on its own. +const NEVER_EXPOSED_EXTENSIONS: ReadonlySet = new Set([ + "WEBGL_multi_draw", + "WEBGL_clip_cull_distance", + "EXT_texture_norm16", + "WEBGL_compressed_texture_etc1", +]); + +// OVR_multiview2 depends on the graphics backend: ANGLE's D3D11 backend has it +// on every Windows GPU, a Linux or macOS host driver may not, and the browser +// answers from the spoofed list without asking the host. +const HOST_DEPENDENT_EXTENSIONS: ReadonlySet = new Set([ + "OVR_multiview2", +]); + +// What Firefox reports under privacy.resistFingerprinting, which a Camoufox +// identity otherwise does not present. +const RFP_RENDERER = "Mozilla"; + +function filteredExtensions(targetOs: string): ReadonlySet { + if (targetOs === "win") return NEVER_EXPOSED_EXTENSIONS; + return new Set([...NEVER_EXPOSED_EXTENSIONS, ...HOST_DEPENDENT_EXTENSIONS]); +} + +function fpgenOs(targetOs: string): string { + const name = FPGEN_OS[targetOs]; + if (name === undefined) throw new KeyError(pyStrRepr(targetOs)); + return name; +} + +type Pin = readonly [node: string, index: string]; + +/** + * Evidence fixing `node` to exactly the value stored as `text`. + * + * A dict passed to fpgen as a condition is flattened into one condition per + * leaf, and each leaf replaces the node's evidence, so only the last one + * applies. Pinning the value's own lookup index is exact. + */ +function pin(node: string, text: string): Pin { + const index = lookupPossibilities(node, false)?.get(text); + if (index === undefined) throw new KeyError(pyStrRepr(text)); + return [node, index]; +} + +const traceCache = new Map(); + +/** fpgen's distribution of `target` for Firefox on `targetOs`, in its order. */ +function trace( + target: string, + targetOs: string, + pinned: readonly Pin[] = [], +): readonly TraceResult[] { + const key = JSON.stringify([target, targetOs, pinned]); + let results = traceCache.get(key); + if (!results) { + results = traceWithEvidence( + target, + { browser: "Firefox", os: fpgenOs(targetOs) }, + {}, + new Map(pinned.map(([node, index]) => [node, new Set([index])])), + ) as TraceResult[]; + traceCache.set(key, results); + } + return results; +} + +function choose(rng: PyRandom, results: readonly TraceResult[]): TraceResult { + return rng.choices(results, { + weights: results.map((result) => result.probability), + })[0]; +} + +/** + * Every (vendor, renderer) that fpgen has seen Firefox report on this OS. + * + * A GPU outside this set has no recorded WebGL parameters behind it, so an + * identity naming it could only borrow another device's. + */ +export function firefoxGpus(targetOs: string): Array<[string, string]> { + return trace("gpu", targetOs.toLowerCase()).map((result) => [ + result.value.vendor, + result.value.renderer, + ]); +} + +function contextConfig( + prefix: string, + webgl: Record, + targetOs: string, +): Record { + const blocked = filteredExtensions(targetOs); + return { + [`${prefix}:contextAttributes`]: webgl.contextAttributes, + [`${prefix}:supportedExtensions`]: ( + webgl.supportedExtensions as string[] + ).filter((extension) => !blocked.has(extension)), + [`${prefix}:parameters`]: Object.fromEntries( + Object.entries(webgl.params as Record).map( + ([pname, param]) => [pname, param.value], + ), + ), + [`${prefix}:shaderPrecisionFormats`]: Object.fromEntries( + (webgl.shaderPrecisionFormats as Array>).map( + (entry) => [ + `${entry.shaderType},${entry.precisionType}`, + entry.shaderPrecisionFormat, + ], + ), + ), + }; +} + +/** + * fpgen's `webgl` and `webgl2` values as Camoufox config keys. `webgl2` is + * `[]` for a device without WebGL2. + * + * Read the values with parsePyJson, as Python reads them, so an integral + * float and an integer past 2**53 reach CAMOU_CONFIG as the same digits. + */ +export function toConfig( + webgl: Record, + webgl2: any, + targetOs: string, +): WebGLData { + const hasWebgl2 = Array.isArray(webgl2) + ? webgl2.length > 0 + : Object.keys(webgl2 ?? {}).length > 0; + const config: Record = { + "webGl:vendor": webgl.vendor, + "webGl:renderer": webgl.renderer, + ...contextConfig("webGl", webgl, targetOs), + webGl2Enabled: hasWebgl2, + }; + if (hasWebgl2) + Object.assign(config, contextConfig("webGl2", webgl2, targetOs)); + // The values are the trace's cached objects; the caller gets its own copy. + return parsePyJson(orjsonDumps(config, false)); +} + +function webglConfig( + targetOs: string, + gpuText: string, + rng: PyRandom, +): WebGLData { + const gpuPin = pin("gpu", gpuText); + const webgl = choose(rng, trace("webgl", targetOs, [gpuPin])); + const webgl2 = choose( + rng, + trace("webgl2", targetOs, [gpuPin, pin("webgl", webgl.text)]), + ); + return toConfig(parsePyJson(webgl.text), parsePyJson(webgl2.text), targetOs); +} + +/** + * The WebGL config of a device with this GPU, as Firefox on `targetOs` + * reports it. + * + * @throws ValueError for a GPU fpgen has never seen Firefox report on that OS: + * it has no recorded parameters, and another device's would contradict it. + */ +export function webglForGpu( + targetOs: string, + vendor: string, + renderer: string, + seed?: PySeed, +): WebGLData { + const gpus = firefoxGpus(targetOs); + if (!gpus.some(([v, r]) => v === vendor && r === renderer)) { + const pairs = [...gpus] + .sort((a, b) => comparePyStr(a[0], b[0]) || comparePyStr(a[1], b[1])) + .map(([v, r]) => `(${pyStrRepr(v)}, ${pyStrRepr(r)})`); + throw new ValueError( + `No recorded WebGL data for vendor ${pyStrRepr(vendor)} and renderer ${pyStrRepr(renderer)} ` + + `from Firefox on ${fpgenOs(targetOs)}. Possible pairs: [${pairs.join(", ")}]`, + ); + } + return webglConfig( + targetOs, + orjsonDumps({ vendor, renderer }, false), + new PyRandom(seed), + ); +} + +/** + * Draw a GPU for a synthetic identity, weighted as fpgen records Firefox on + * `targetOs`, and its WebGL config. + * + * Only GPUs the rest of the identity can stand beside are drawn: never a + * software rasteriser, a GPU the OS cannot report, the resistFingerprinting + * mask, or a discrete GPU behind a netbook panel. The screen is left alone: + * it has already been reconciled with the real display and the window (#499). + * + * @throws ValueError when no recorded GPU fits. + */ +export function sampleWebglForScreen( + targetOs: string, + width?: number | null, + height?: number | null, + seed?: PySeed, +): WebGLData { + const candidates = trace("gpu", targetOs).filter( + (result) => + !isSoftwareRenderer(result.value.renderer) && + result.value.renderer !== RFP_RENDERER && + gpuFitsOs(result.value.renderer, targetOs) && + gpuScreenIsPlausible(result.value.renderer, width, height), + ); + if (!candidates.length) { + throw new ValueError( + `No recorded ${targetOs} GPU fits a ${pyStr(width)}x${pyStr(height)} screen`, + ); + } + const rng = new PyRandom(seed); + return webglConfig(targetOs, choose(rng, candidates).text, rng); +} diff --git a/typescript/tests/addons.test.ts b/typescript/tests/addons.test.ts new file mode 100644 index 000000000..e76b69410 --- /dev/null +++ b/typescript/tests/addons.test.ts @@ -0,0 +1,83 @@ +/** + * Mirrors pythonlib/tests/test_addons.py: regression guard for #308, where a + * failed first download left an empty addon directory that was trusted + * forever afterwards. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +type Addons = typeof import("../src/addons.js"); + +let tmp: string; +let savedXdg: string | undefined; +let addons: Addons; +let ubo: string; + +beforeEach(async () => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-addons-")); + savedXdg = process.env.XDG_CACHE_HOME; + // Point the addon store at a throwaway dir so no real cache is touched. + process.env.XDG_CACHE_HOME = tmp; + vi.resetModules(); + addons = await import("../src/addons.js"); + ubo = addons.getAddonPath("UBO"); + expect(ubo.startsWith(tmp)).toBe(true); +}); + +afterEach(() => { + if (savedXdg === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = savedXdg; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +function writeManifest(extractPath: string) { + fs.mkdirSync(extractPath, { recursive: true }); + fs.writeFileSync(path.join(extractPath, "manifest.json"), "{}"); +} + +it("re-downloads a partial (manifest-less) directory", async () => { + fs.mkdirSync(ubo, { recursive: true }); + const calls: string[] = []; + addons.addonsDeps.downloadAndExtract = async (_url, extractPath, name) => { + calls.push(name); + writeManifest(extractPath); + }; + + const out: string[] = []; + await addons.maybeDownloadAddons({ ...addons.DefaultAddons }, out); + + expect(calls).toEqual(["UBO"]); + expect(fs.existsSync(path.join(ubo, "manifest.json"))).toBe(true); + expect(out).toEqual([ubo]); +}); + +it("does not re-download an extracted addon", async () => { + writeManifest(ubo); + addons.addonsDeps.downloadAndExtract = async () => { + throw new Error("must not re-download an already-extracted addon"); + }; + + const out: string[] = []; + await addons.maybeDownloadAddons({ ...addons.DefaultAddons }, out); + expect(out).toEqual([ubo]); +}); + +it("removes the partial directory when a download fails", async () => { + addons.addonsDeps.downloadAndExtract = async (_url, extractPath) => { + fs.mkdirSync(extractPath, { recursive: true }); // partial write, then die + throw new Error("network died mid-download"); + }; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + const out: string[] = []; + await addons.maybeDownloadAddons({ ...addons.DefaultAddons }, out); + + expect(fs.existsSync(ubo)).toBe(false); + expect(out).toEqual([]); + expect(log).toHaveBeenCalledWith( + "Failed to download and extract UBO: Error: network died mid-download", + ); + log.mockRestore(); +}); diff --git a/typescript/tests/cli-list.test.ts b/typescript/tests/cli-list.test.ts new file mode 100644 index 000000000..1a6d1ad1e --- /dev/null +++ b/typescript/tests/cli-list.test.ts @@ -0,0 +1,69 @@ +/** + * Port of pythonlib/tests/test_cli_list.py: `camoufox list --path` shows + * install paths in both listing modes. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +let tmp: string; +let savedXdg: string | undefined; +let savedArgv: string[]; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-cli-")); + savedXdg = process.env.XDG_CACHE_HOME; + savedArgv = process.argv; + process.env.XDG_CACHE_HOME = tmp; + vi.resetModules(); +}); + +afterEach(() => { + if (savedXdg === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = savedXdg; + process.argv = savedArgv; + vi.doUnmock("../src/multiversion.js"); + vi.restoreAllMocks(); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it("list all --path shows the path of an installed build", async () => { + const install = "/cache/browsers/official/152.0.4-beta.30"; + const actual = await import("../src/multiversion.js"); + const { Version } = await import("../src/pkgman.js"); + fs.mkdirSync(path.dirname(actual.REPO_CACHE_FILE), { recursive: true }); + fs.writeFileSync( + actual.REPO_CACHE_FILE, + JSON.stringify({ + repos: [ + { + name: "official", + versions: [{ version: "152.0.4", build: "beta.30" }], + }, + ], + }), + ); + const installed = new actual.InstalledVersion({ + repoName: "official", + version: new Version("beta.30", "152.0.4"), + path: install, + }); + vi.doMock("../src/multiversion.js", () => ({ + ...actual, + listInstalled: () => [installed], + })); + + let output = ""; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output += String(chunk); + return true; + }); + process.argv = ["node", "camoufox", "list", "all", "--path"]; + await import("../src/__main__.js"); + await new Promise((resolve) => setTimeout(resolve, 0)); + vi.mocked(process.stdout.write).mockRestore(); + + expect(process.exitCode ?? 0).toBe(0); + expect(output).toContain(install); +}); diff --git a/typescript/tests/coherence.test.ts b/typescript/tests/coherence.test.ts new file mode 100644 index 000000000..e971af87b --- /dev/null +++ b/typescript/tests/coherence.test.ts @@ -0,0 +1,290 @@ +/** + * Port of pythonlib/tests/test_coherence.py (the rule-level half; the + * launch-level half lives with the launcher tests) and test_shipped_data.py. + * + * Every identity Camoufox can produce has to be a machine that could exist: + * the pools are sampled independently, so an incoherent identity is assembled + * rather than inherited, and cleaning the pools cannot prevent it. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import * as coherence from "../src/coherence.js"; +import { fromPreset } from "../src/fingerprints.js"; +import { LOCAL_DATA } from "../src/pkgman.js"; +import { firefoxGpus } from "../src/webgl.js"; +import { MODEL } from "./fpgen-setup.js"; + +const rules = (config: Record, os: string) => + coherence.validate(config, os).map((v) => v.rule); + +describe("rules", () => { + it("Apple Silicon never has fewer than eight cores", () => { + const config = { + "webGl:renderer": "Apple M1, or similar", + "navigator.hardwareConcurrency": 2, + }; + expect(rules(config, "mac")).toEqual(["apple-silicon-cores"]); + expect(coherence.apply(config, "mac")).toEqual([]); + expect(config["navigator.hardwareConcurrency"]).toBe(8); + }); + + it("a Mac cannot report a Braswell Atom IGP", () => { + expect( + rules( + { "webGl:renderer": "Intel(R) HD Graphics 400, or similar" }, + "mac", + ), + ).toEqual(["gpu-matches-os"]); + }); + + it("a Mac cannot report an ANGLE renderer", () => { + expect( + rules( + { + "webGl:renderer": + "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0), or similar", + }, + "mac", + ), + ).toEqual(["gpu-matches-os"]); + }); + + it("Windows renders through ANGLE", () => { + expect( + coherence.gpuFitsOs( + "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11), or similar", + "win", + ), + ).toBe(true); + expect(coherence.gpuFitsOs("Apple M1, or similar", "win")).toBe(false); + }); + + it("Apple Silicon reports deep colour", () => { + const config: Record = { + "webGl:renderer": "Apple M1, or similar", + "screen.colorDepth": 24, + }; + expect(rules(config, "mac")).toEqual(["color-depth"]); + expect(coherence.apply(config, "mac")).toEqual([]); + expect(config["screen.colorDepth"]).toBe(30); + expect(config["screen.pixelDepth"]).toBe(30); + }); + + it("colour depth is 24 or 30", () => { + const config = { "screen.colorDepth": 32 }; + expect(rules(config, "lin")).toEqual(["color-depth"]); + expect(coherence.apply(config, "lin")).toEqual([]); + expect(config["screen.colorDepth"]).toBe(24); + }); + + it("touch points are a digitiser count", () => { + const config = { "navigator.maxTouchPoints": 256 }; + expect(rules(config, "win")).toEqual(["touch-points"]); + expect(coherence.apply(config, "win")).toEqual([]); + expect(config["navigator.maxTouchPoints"]).toBe(0); + for (const real of [0, 1, 2, 5, 10]) { + expect( + coherence.validate({ "navigator.maxTouchPoints": real }, "win"), + ).toEqual([]); + } + }); + + it("a Mac has no touchscreen", () => { + expect(rules({ "navigator.maxTouchPoints": 5 }, "mac")).toEqual([ + "touch-points", + ]); + }); + + it("devicePixelRatio is a real display mode", () => { + const config = { "window.devicePixelRatio": 1.8181818181818181 }; + expect(rules(config, "win")).toEqual(["device-pixel-ratio"]); + expect(coherence.apply(config, "win")).toEqual([]); + expect(config["window.devicePixelRatio"]).toBe(1.75); + expect(coherence.validate({ "window.devicePixelRatio": 1 }, "lin")).toEqual( + [], + ); + expect( + coherence.validate({ "window.devicePixelRatio": 2.5 }, "win"), + ).toEqual([]); + expect(coherence.validate({ "window.devicePixelRatio": 2 }, "mac")).toEqual( + [], + ); + expect(rules({ "window.devicePixelRatio": 1.5 }, "mac")).toEqual([ + "device-pixel-ratio", + ]); + }); + + it("desktop screens are landscape", () => { + const config = { "screen.width": 1440, "screen.height": 2560 }; + expect(rules(config, "win")).toEqual(["screen-shape"]); + expect(coherence.repairScreenOrientation(config)).toBe(true); + expect([config["screen.width"], config["screen.height"]]).toEqual([ + 2560, 1440, + ]); + }); + + it("a phone viewport is not a desktop screen", () => { + expect(rules({ "screen.width": 736, "screen.height": 414 }, "mac")).toEqual( + ["screen-shape"], + ); + }); + + it("avail never exceeds the screen", () => { + const config = { + "screen.width": 1920, + "screen.height": 1080, + "screen.availWidth": 2000, + }; + expect(rules(config, "lin")).toEqual(["avail-bounds"]); + expect(coherence.apply(config, "lin")).toEqual([]); + expect(config["screen.availWidth"]).toBe(1920); + }); + + it("the platform agrees with the user agent arch", () => { + expect( + rules( + { + "navigator.userAgent": + "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", + "navigator.platform": "Linux armv81", + }, + "lin", + ), + ).toEqual(["arch-agreement"]); + }); + + it("the viewport leaves room for the browser chrome", () => { + const config = { + "window.outerHeight": 801, + "window.innerHeight": 717, + "screen.availHeight": 1040, + }; + expect(rules(config, "lin")).toEqual(["window-chrome"]); + expect(coherence.apply(config, "lin")).toEqual([]); + expect(config["window.outerHeight"]).toBe( + 717 + coherence.BROWSER_CHROME_HEIGHT, + ); + }); + + it("the real machines pass", () => { + const real: Array<[string, Record]> = [ + [ + "lin", + { + "navigator.userAgent": + "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", + "navigator.platform": "Linux x86_64", + "navigator.hardwareConcurrency": 16, + "navigator.maxTouchPoints": 0, + "screen.width": 1920, + "screen.height": 1080, + "screen.colorDepth": 24, + "webGl:renderer": "Radeon HD 3200 Graphics, or similar", + }, + ], + [ + "win", + { + "navigator.platform": "Win32", + "navigator.hardwareConcurrency": 16, + "navigator.maxTouchPoints": 5, + "screen.width": 1382, + "screen.height": 864, + "screen.colorDepth": 24, + "webGl:renderer": + "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar", + }, + ], + [ + "mac", + { + "navigator.platform": "MacIntel", + "navigator.hardwareConcurrency": 10, + "navigator.maxTouchPoints": 0, + "screen.width": 2560, + "screen.height": 1440, + "screen.colorDepth": 30, + "webGl:renderer": "Apple M1, or similar", + }, + ], + ]; + for (const [os, config] of real) { + expect(coherence.validate(config, os), os).toEqual([]); + } + }); + + it("drops a source GPU the OS cannot report, and only that", () => { + const config: Record = { + "webGl:vendor": "Intel", + "webGl:renderer": "Intel(R) HD Graphics 400, or similar", + "screen.width": 1920, + }; + const dropped = coherence.dropIncoherentSourceValues(config, "mac"); + expect(dropped.map((v) => v.rule)).toEqual(["gpu-matches-os"]); + expect(config).toEqual({ "screen.width": 1920 }); + }); +}); + +describe("shipped data (test_shipped_data.py)", () => { + const OS_KEY: Record = { + macos: "mac", + windows: "win", + linux: "lin", + }; + for (const file of [ + "fingerprint-presets.json", + "fingerprint-presets-v150.json", + ]) { + it(`every preset in ${file} is coherent as stored`, () => { + const presets = JSON.parse( + fs.readFileSync(path.join(LOCAL_DATA, file), "utf-8"), + ).presets as Record; + for (const [os, entries] of Object.entries(presets)) { + expect( + entries.length, + `${file}/${os} has no presets left`, + ).toBeGreaterThan(0); + entries.forEach((preset, i) => { + const config = fromPreset(preset, "152", 0); + const violations = coherence.validate(config, OS_KEY[os]); + expect( + violations, + `${file} ${os}[${i}]: ${violations.map((v) => v.detail).join("; ")}`, + ).toEqual([]); + }); + } + }); + } + + for (const file of [ + "fingerprint-presets.json", + "fingerprint-presets-v150.json", + ]) { + // A preset records only its GPU's name; the WebGL parameters behind it + // come from fpgen, and a GPU fpgen never saw from Firefox on that OS has + // none, so launching it would borrow another device's. + it.skipIf(!MODEL.ok)(`every preset GPU in ${file} has WebGL data`, () => { + const presets = JSON.parse( + fs.readFileSync(path.join(LOCAL_DATA, file), "utf-8"), + ).presets as Record; + for (const [os, entries] of Object.entries(presets)) { + const known = new Set( + firefoxGpus( + os === "windows" ? "win" : os === "macos" ? "mac" : "lin", + ).map((gpu) => JSON.stringify(gpu)), + ); + entries.forEach((preset, i) => { + const gpu = [ + preset.webgl.unmaskedVendor, + preset.webgl.unmaskedRenderer, + ]; + expect( + known.has(JSON.stringify(gpu)), + `${file} ${os}[${i}]: ${gpu[1]} has no WebGL data`, + ).toBe(true); + }); + } + }); + } +}); diff --git a/typescript/tests/download-integrity.test.ts b/typescript/tests/download-integrity.test.ts new file mode 100644 index 000000000..977f9fb29 --- /dev/null +++ b/typescript/tests/download-integrity.test.ts @@ -0,0 +1,86 @@ +/** + * Mirrors pythonlib/tests/test_download_integrity.py: a release asset whose + * bytes do not match the GitHub-published sha256 digest must abort the + * install before extraction. + */ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { CorruptedDownload } from "../src/exceptions.js"; +import { verifySha256 } from "../src/pkgman.js"; + +// Large enough to span several read() blocks, so a single-shot read +// regression cannot pass by accident. +const PAYLOAD = Buffer.from("camoufox release asset".repeat(100_000)); +const DIGEST = createHash("sha256").update(PAYLOAD).digest("hex"); + +describe("verifySha256", () => { + it("accepts a matching digest", () => { + expect(() => verifySha256(PAYLOAD, DIGEST, "asset")).not.toThrow(); + }); + + it("compares digests case-insensitively", () => { + expect(() => + verifySha256(PAYLOAD, DIGEST.toUpperCase(), "asset"), + ).not.toThrow(); + }); + + const mutations: Record Buffer> = { + "first-byte-flipped": (b) => + Buffer.concat([Buffer.from([b[0] ^ 0xff]), b.subarray(1)]), + "last-bit-flipped": (b) => + Buffer.concat([b.subarray(0, -1), Buffer.from([b[b.length - 1] ^ 1])]), + truncated: (b) => b.subarray(0, -1), + appended: (b) => Buffer.concat([b, Buffer.from([0])]), + empty: () => Buffer.alloc(0), + }; + for (const [name, mutate] of Object.entries(mutations)) { + it(`aborts the install on a tampered payload (${name})`, () => { + expect(() => verifySha256(mutate(PAYLOAD), DIGEST, "asset")).toThrow( + CorruptedDownload, + ); + }); + } + + it("names both digests in the error", () => { + let message = ""; + try { + verifySha256(Buffer.from("wrong"), DIGEST, "Camoufox v1.2.3"); + } catch (e) { + expect(e).toBeInstanceOf(CorruptedDownload); + message = (e as Error).message; + } + expect(message).toContain("Camoufox v1.2.3"); + expect(message).toContain(DIGEST); + expect(message).toContain( + createHash("sha256").update("wrong").digest("hex"), + ); + }); + + for (const absent of [null, undefined, ""]) { + it(`does not block the install without a digest (${JSON.stringify(absent)})`, () => { + expect(() => verifySha256(PAYLOAD, absent, "asset")).not.toThrow(); + }); + } + + it("verifies a real temporary file, as the install path does", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-sha-")); + try { + const file = path.join(dir, "asset.zip"); + fs.writeFileSync(file, PAYLOAD); + expect(() => verifySha256(file, DIGEST, "asset")).not.toThrow(); + fs.appendFileSync(file, "x"); + expect(() => verifySha256(file, DIGEST, "asset")).toThrow( + CorruptedDownload, + ); + // The file is untouched for the extraction that follows. + expect(fs.readFileSync(file).subarray(0, PAYLOAD.length)).toEqual( + PAYLOAD, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/typescript/tests/download-stream.test.ts b/typescript/tests/download-stream.test.ts new file mode 100644 index 000000000..ad6a78bc3 --- /dev/null +++ b/typescript/tests/download-stream.test.ts @@ -0,0 +1,101 @@ +/** + * The browser download writes through a file stream. A failed write (disk + * full) must come back as an ordinary rejection that installVersioned() cleans + * up after -- an 'error' event with no listener kills the process instead -- + * and a slow disk must hold the download back rather than queue it in memory. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Writable } from "node:stream"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +let tmp: string; +let savedXdg: string | undefined; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-dl-")); + savedXdg = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = tmp; + vi.resetModules(); +}); + +afterEach(() => { + if (savedXdg === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = savedXdg; + vi.unstubAllGlobals(); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it("a failed write rejects, and the partial install is removed", async () => { + const { installVersioned, BROWSERS_DIR } = await import( + "../src/multiversion.js" + ); + const { CamoufoxFetcher } = await import("../src/pkgman.js"); + + class DiskFull extends CamoufoxFetcher { + static override async downloadFile(file: Writable): Promise { + file.write(Buffer.alloc(1024)); + file.destroy( + Object.assign(new Error("ENOSPC: no space left"), { code: "ENOSPC" }), + ); + await new Promise((r) => setTimeout(r, 20)); + } + } + // Skip the constructor: it would query GitHub. + const fetcher = Object.create(DiskFull.prototype); + const own = (value: unknown) => ({ value, configurable: true }); + Object.defineProperties(fetcher, { + githubRepo: own("example/camoufox"), + version: own("152.0.4"), + build: own("beta.99"), + verstr: own("152.0.4-beta.99"), + url: own("https://example.invalid/camoufox.zip"), + _selectedVersion: own(null), + }); + + await expect(installVersioned(fetcher)).rejects.toThrow(/ENOSPC/); + expect( + fs.existsSync(path.join(BROWSERS_DIR, "example", "152.0.4-beta.99")), + ).toBe(false); +}); + +it("waits for a slow stream to drain instead of queueing the download", async () => { + const { webdl } = await import("../src/pkgman.js"); + const chunks = 64; + const size = 1024; + vi.stubGlobal( + "fetch", + async () => + new Response( + new ReadableStream({ + start(controller) { + for (let i = 0; i < chunks; i++) + controller.enqueue(new Uint8Array(size)); + controller.close(); + }, + }), + { headers: { "content-length": String(chunks * size) } }, + ), + ); + let peak = 0; + let received = 0; + const slow: Writable = new Writable({ + highWaterMark: size, + write(chunk, _enc, done) { + received += chunk.length; + setTimeout(() => { + // What is still queued behind this chunk once the disk catches up. + peak = Math.max(peak, slow.writableLength); + done(); + }, 2); + }, + }); + await webdl("https://example.invalid/x.zip", "x", false, slow, { + progressCallback: () => {}, + }); + await new Promise((r) => slow.end(r)); + expect(received).toBe(chunks * size); + // Without waiting for 'drain' all 64 KiB sit in the stream's buffer. + expect(peak).toBeLessThanOrEqual(2 * size); +}); diff --git a/typescript/tests/e2e.test.ts b/typescript/tests/e2e.test.ts new file mode 100644 index 000000000..4c5279395 --- /dev/null +++ b/typescript/tests/e2e.test.ts @@ -0,0 +1,626 @@ +/** + * End-to-end: launch a real Camoufox build through the TypeScript API and + * check what a page actually sees. + * + * Opt-in -- it needs a browser binary: + * + * CAMOUFOX_E2E=1 CAMOUFOX_EXECUTABLE=/path/to/camoufox-bin pnpm test tests/e2e.test.ts + * + * Two kinds of assertion, deliberately kept apart: + * + * - PARITY, always enforced: for each fixed identity it launches headless, as a + * persistent context, through launchServer() and through NewContext(), runs + * tests/fixtures/e2e/probe.js on a local page, then launches the PYTHON + * camoufox on the same binary with the same identity + * (scripts/e2e/python_probe.py, run with $CAMOUFOX_E2E_PYTHON or the repo's + * .venv) and requires the same CAMOU_CONFIG and the same probe result. This is + * the claim this package makes, and it holds on any binary. + * + * - THE BROWSER HONOURS THE CONFIG: navigator/screen/window/fonts/timezone/ + * voices/WebGL on the page equal what the launcher sent. That is the + * browser's half of the contract, so it is only asserted on a binary that + * knows every key the launcher sets (its properties.json lists them). An + * older binary -- the published release, on a driver-only pull request, while + * the launcher is ahead of it -- ignores the keys it does not know, exactly + * as pythonlib's "Skipping unknown patch" says, and those tests skip naming + * the missing keys rather than failing on the skew. + */ +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as http from "node:http"; +import type { AddressInfo } from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import AdmZip from "adm-zip"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { PYTHON as GOLDEN_PYTHON } from "./golden-setup.js"; +import { prerequisite } from "./prereq.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, "..", ".."); +const EXECUTABLE = process.env.CAMOUFOX_EXECUTABLE ?? ""; +const ENABLED = process.env.CAMOUFOX_E2E === "1" && Boolean(EXECUTABLE); +const PYTHON = process.env.CAMOUFOX_E2E_PYTHON ?? GOLDEN_PYTHON; +const PROBE = fs.readFileSync( + path.join(HERE, "fixtures", "e2e", "probe.js"), + "utf-8", +); +const INPUTS = JSON.parse( + fs.readFileSync( + path.join(HERE, "fixtures", "launch", "inputs.json"), + "utf-8", + ), +); + +/** The fixed identities: an fpgen fingerprint and a cross-OS preset. */ +const IDENTITIES: Record> = { + fpgen_linux_de: { + fingerprint: INPUTS.fingerprints.linux, + os: "linux", + locale: "de-DE", + config: { timezone: "Asia/Tokyo" }, + }, + preset_windows_fr: { + fingerprint_preset: INPUTS.presets.windows, + locale: "fr-FR", + config: { timezone: "Europe/Paris" }, + }, +}; + +/** Config keys the binary under test does not know (see the header). */ +function unknownToBinary(config: Record): string[] { + const dir = path.dirname(EXECUTABLE); + const candidates = [ + path.join(dir, "properties.json"), + path.join(dir, "..", "Resources", "properties.json"), // macOS bundle + ]; + const file = candidates.find((f) => fs.existsSync(f)); + if (!file) return [""]; + const known = new Set( + JSON.parse(fs.readFileSync(file, "utf-8")).map((p: any) => p.property), + ); + return Object.keys(config).filter((k) => !known.has(k)); +} + +/** + * The locales the binary under test packages (res/multilocale.txt): loose in + * an unpackaged dist/bin, inside omni.ja in a packaged build. A spoofed locale + * the binary does not package falls back to en-US -- scripts/package.py adds the + * langpacks, so a packaged release has them and CI's unpackaged dist/bin does + * not. null when it cannot be read. + */ +function packagedLocales(): string[] | null { + const dir = path.dirname(EXECUTABLE); + try { + const loose = path.join(dir, "res", "multilocale.txt"); + const text = fs.existsSync(loose) + ? fs.readFileSync(loose, "utf-8") + : new AdmZip(path.join(dir, "omni.ja")) + .getEntry("res/multilocale.txt") + ?.getData() + .toString("utf-8"); + return text + ? text + .split(",") + .map((l) => l.trim()) + .filter(Boolean) + : null; + } catch { + return null; + } +} + +let server: http.Server; +let url = ""; +/** The User-Agent header of the last request the probe page served. */ +let lastRequestUserAgent = ""; + +beforeAll(async () => { + if (!ENABLED) return; + server = http.createServer((req, res) => { + lastRequestUserAgent = req.headers["user-agent"] ?? ""; + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end( + "probeprobe", + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`; +}); + +afterAll(async () => { + if (server) await new Promise((resolve) => server.close(resolve)); +}); + +function configOf(options: Record): Record { + const env = options.env as Record; + return JSON.parse( + Object.keys(env) + .filter((k) => k.startsWith("CAMOU_CONFIG_")) + .sort((a, b) => Number(a.split("_").pop()) - Number(b.split("_").pop())) + .map((k) => env[k]) + .join(""), + ); +} + +function kwargsFor(identity: string): Record { + return { + ...structuredClone(IDENTITIES[identity]), + executable_path: EXECUTABLE, + headless: true, + i_know_what_im_doing: true, + }; +} + +/** + * Give a headful page focus and wait until it has it. One bringToFront() on a + * bare Xvfb, with no window manager, sometimes left the window unfocused, and + * Firefox holds enumerateDevices() until the document has focus. + */ +async function focusPage(page: any): Promise { + for (let i = 0; i < 50; i++) { + await page.bringToFront(); + if (await page.evaluate("document.hasFocus()")) return; + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error("the headful page never got focus on Xvfb"); +} + +async function probePage(page: any): Promise { + await page.goto(url); + // Python's evaluate() calls a function-source string; JS's evaluates it as + // an expression, so call it explicitly. + return page.evaluate(`(${PROBE})()`); +} + +/** + * Run the Python side. Asynchronously: the probe page is served from this + * process, so blocking the event loop would hang Python's page.goto(). + */ +function pythonProbe( + mode: string, + kwargs: Record, + extra: Record = {}, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + PYTHON, + [path.join(REPO, "typescript", "scripts", "e2e", "python_probe.py")], + { + stdio: ["pipe", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { + stdout += d; + }); + child.stderr.on("data", (d) => { + stderr += d; + }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(`python probe failed (${code}): ${stderr}`)); + return; + } + try { + resolve(JSON.parse(stdout)); + } catch (err) { + reject( + new Error( + `python probe printed non-JSON (${err}): ${stdout.slice(0, 500)}`, + ), + ); + } + }); + child.stdin.end(JSON.stringify({ mode, url, kwargs, ...extra })); + }); +} + +/** + * The probe minus the canvas hash. The canvas readback differs between two + * launches of the SAME config -- measured with this build, + * TS and Python alike -- so it is compared by its size only. + */ +/** + * `promise`, or a rejection naming the step after `ms`. A hung launch otherwise + * surfaces only as vitest's whole-test timeout, which says nothing about where. + */ +function step(what: string, promise: Promise, ms = 60_000): Promise { + let timer: NodeJS.Timeout | undefined; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${what}: did not complete in ${ms / 1000} s`)), + ms, + ); + }), + ]).finally(() => clearTimeout(timer)); +} + +function stable(probe: any): any { + const { canvasHash, ...rest } = probe; + return { ...rest, canvasSize: String(canvasHash).split(":")[1] }; +} + +/** What the page must report for a config the launcher sent. */ +function expectMatchesConfig( + probe: any, + config: Record, + headful = false, +) { + expect(probe.navigator.userAgent).toBe(config["navigator.userAgent"]); + expect(probe.navigator.platform).toBe(config["navigator.platform"]); + if ("navigator.oscpu" in config) + expect(probe.navigator.oscpu).toBe(config["navigator.oscpu"]); + expect(probe.navigator.hardwareConcurrency).toBe( + config["navigator.hardwareConcurrency"], + ); + expect(probe.navigator.webdriver).toBe(false); + // DNT / GPC follow stock Firefox unless the caller set them. + expect(probe.navigator.doNotTrack).toBe("unspecified"); + expect(probe.navigator.globalPrivacyControl).toBe(false); + for (const key of [ + "width", + "height", + "availWidth", + "availHeight", + "colorDepth", + ]) { + if (`screen.${key}` in config) + expect(probe.screen[key], `screen.${key}`).toBe(config[`screen.${key}`]); + } + for (const key of ["outerWidth", "outerHeight"]) { + if (`window.${key}` in config) + expect(probe.window[key], `window.${key}`).toBe(config[`window.${key}`]); + } + expect(probe.intl.timeZone).toBe(config.timezone); + const lang = [config["locale:language"], config["locale:region"]].join("-"); + const packaged = packagedLocales(); + const hasLocale = + packaged === null || + packaged.includes(lang) || + packaged.includes(config["locale:language"]); + if ( + prerequisite( + "packaged-locales", + hasLocale, + `the binary packages ${packaged?.join(",")}, not ${lang}`, + ) + ) { + expect(probe.navigator.language).toBe(lang); + expect(probe.intl.locale).toBe(lang); + } + // Fonts: every probed family the identity claims resolves; the others don't. + const claimed = new Set(config.fonts); + for (const [family, present] of Object.entries(probe.fonts)) { + if (!claimed.has(family)) expect(present, `${family} leaked`).toBe(false); + } + // Voices: exactly the identity's list, never the host's. + expect(probe.voices.map((v: string) => v.split("|")[0]).sort()).toEqual( + config.voices.map((v: any) => v.name).sort(), + ); + if (probe.webgl) { + expect(probe.webgl.vendor).toBe(config["webGl:vendor"]); + expect(probe.webgl.renderer).toBe(config["webGl:renderer"]); + } + // Playwright's media emulation is off, so the host answers: headless is + // light; headful follows the desktop theme. + if (!headful) expect(probe.media.colorScheme).toBe("light"); + // Stock Firefox's max_entries: the history starts at 1. + expect(probe.historyLength).toBe(1); + // The storage quota is the stock-profile disk's, not the temp dir's. + expect(typeof probe.storageQuota).toBe("number"); + expect(probe.mediaDevices.length).toBeGreaterThan(0); +} + +describe.runIf(ENABLED)("e2e: the TS launcher drives a real Camoufox", () => { + let mods: { + sync: typeof import("../src/sync_api.js"); + server: typeof import("../src/server.js"); + utils: typeof import("../src/utils.js"); + warnings: typeof import("../src/warnings.js"); + }; + const results: Record = {}; + + beforeAll(async () => { + mods = { + sync: await import("../src/sync_api.js"), + server: await import("../src/server.js"), + utils: await import("../src/utils.js"), + warnings: await import("../src/warnings.js"), + }; + }); + + for (const identity of Object.keys(IDENTITIES)) { + describe(identity, () => { + it("builds the same CAMOU_CONFIG as Python", async () => { + const { result } = await mods.warnings.recordWarnings(() => + mods.utils.launchOptions(kwargsFor(identity)), + ); + const config = configOf(result as Record); + results[`${identity}:config`] = config; + const py = await pythonProbe("config-only", kwargsFor(identity)); + expect(config).toEqual(py.config); + }, 240_000); + + it("headless: the page sees the configured identity, same as Python", async () => { + const { result: browser } = await mods.warnings.recordWarnings(() => + mods.sync.Camoufox(kwargsFor(identity)), + ); + let probe: any; + try { + const page = await (browser as any).newPage(); + probe = await probePage(page); + } finally { + await (browser as any).close(); + } + results[`${identity}:headless`] = probe; + + // A second launch of the same identity presents the same device. + const { result: again } = await mods.warnings.recordWarnings(() => + mods.sync.Camoufox(kwargsFor(identity)), + ); + try { + expect( + stable(await probePage(await (again as any).newPage())), + ).toEqual(stable(probe)); + } finally { + await (again as any).close(); + } + + const py = await pythonProbe("headless", kwargsFor(identity)); + expect(stable(probe)).toEqual(stable(py.probe)); + }, 240_000); + + it("persistent context: same identity, same as Python", async () => { + const profile = fs.mkdtempSync( + path.join(os.tmpdir(), "camoufox-e2e-profile-"), + ); + let probe: any; + try { + const { result: context } = await mods.warnings.recordWarnings(() => + mods.sync.Camoufox({ + ...kwargsFor(identity), + persistent_context: true, + user_data_dir: profile, + }), + ); + try { + const page = await (context as any).newPage(); + probe = await probePage(page); + } finally { + await (context as any).close(); + } + } finally { + fs.rmSync(profile, { recursive: true, force: true }); + } + results[`${identity}:persistent`] = probe; + + const py = await pythonProbe("persistent", kwargsFor(identity)); + expect(stable(probe)).toEqual(stable(py.probe)); + }, 240_000); + + it("launchServer: a connected client sees the same identity", async () => { + const { firefox } = await import("playwright-core"); + const { result: bs } = await mods.warnings.recordWarnings(() => + mods.server.launchServer(kwargsFor(identity)), + ); + const browserServer = bs as import("playwright-core").BrowserServer; + let probe: any; + try { + const browser = await firefox.connect(browserServer.wsEndpoint()); + try { + // A remote client gets plain Playwright: no Camoufox defaults, + // so ask for them explicitly (as a Python client would). + const config = results[`${identity}:config`]; + const spoofsWindow = Object.keys(config).some((k) => + /^(window\.(outer|inner)|document\.body\.client)/.test(k), + ); + const page = await browser.newPage({ + ...(spoofsWindow ? { viewport: null } : {}), + ...mods.utils.STOCK_MEDIA_DEFAULTS, + }); + probe = await probePage(page); + } finally { + await browser.close(); + } + } finally { + await browserServer.close(); + } + results[`${identity}:server`] = probe; + expect(stable(probe)).toEqual(stable(results[`${identity}:headless`])); + }, 240_000); + + it("NewContext: a per-context identity, same as Python", async () => { + // A different identity from the launch one, so a context that + // inherited the browser's identity would be caught. + const preset = + identity === "fpgen_linux_de" + ? INPUTS.presets.windows + : INPUTS.presets.macos; + const { result: browser } = await mods.warnings.recordWarnings(() => + mods.sync.Camoufox(kwargsFor(identity)), + ); + let probe: any; + let second: any; + try { + const context = await mods.sync.NewContext(browser as any, { + preset, + }); + probe = await probePage(await context.newPage()); + // The HTTP header must tell the same story as navigator: a + // context that dropped its userAgent option sent the + // browser's own UA here while navigator was spoofed. + expect(lastRequestUserAgent).toBe(probe.navigator.userAgent); + // Contexts are isolated: another preset, another device. + const other = await mods.sync.NewContext(browser as any, { + preset: INPUTS.presets.linux, + }); + second = await probePage(await other.newPage()); + } finally { + await (browser as any).close(); + } + const launch = results[`${identity}:headless`]; + expect(probe.navigator.userAgent).not.toBe(launch.navigator.userAgent); + expect(second.navigator.userAgent).not.toBe(probe.navigator.userAgent); + + // Per-context noise seeds are drawn fresh in both launchers, so + // compare the identity itself, not the noise. + const identityOf = (p: any) => ({ + navigator: p.navigator, + screen: p.screen, + timeZone: p.intl.timeZone, + webgl: p.webgl, + }); + const py = await pythonProbe("context", kwargsFor(identity), { + preset, + }); + expect(identityOf(probe)).toEqual(identityOf(py.probe)); + }, 240_000); + + it("the browser honours the config (headless, persistent, launchServer)", async (ctx) => { + const config = results[`${identity}:config`]; + const missing = unknownToBinary(config); + if (missing.length) + ctx.skip( + `binary predates the launcher; it does not know: ${missing.join(", ")}`, + ); + for (const mode of ["headless", "persistent", "server"]) { + expect(results[`${identity}:${mode}`], mode).toBeTruthy(); + expectMatchesConfig(results[`${identity}:${mode}`], config); + } + // A persistent profile presents the same device as a throwaway one. + // (beta.30 did not: its storage quota was Firefox's pinned 10 GiB + // headless and the disk's in a profile -- LEAKS row 107.) + expect(stable(results[`${identity}:persistent`])).toEqual( + stable(results[`${identity}:headless`]), + ); + }); + + it.runIf(identity === "fpgen_linux_de" && process.platform === "linux")( + "pin_cpu_cores: the browser runs on as many cores as it reports", + async () => { + const selfCores = () => + fs + .readFileSync("/proc/self/status", "utf-8") + .match(/Cpus_allowed_list:\s*(.+)/)?.[1]; + const before = selfCores(); + const { result: browser } = await mods.warnings.recordWarnings(() => + mods.sync.Camoufox({ ...kwargsFor(identity), pin_cpu_cores: true }), + ); + try { + const page = await (browser as any).newPage(); + await page.goto(url); + const reported = await page.evaluate( + "navigator.hardwareConcurrency", + ); + // The launching process got its cores back... + expect(selfCores()).toBe(before); + // ...and the browser it spawned runs on exactly `reported` cores. + const { parseCpuList } = await import("../src/cpu_affinity.js"); + const children = fs + .readFileSync( + `/proc/${process.pid}/task/${process.pid}/children`, + "utf-8", + ) + .trim() + .split(/\s+/) + .filter((pid) => { + try { + return ( + fs.readFileSync(`/proc/${pid}/comm`, "utf-8").trim() === + "camoufox-bin" + ); + } catch { + return false; + } + }); + expect(children.length).toBeGreaterThan(0); + for (const pid of children) { + const list = + fs + .readFileSync(`/proc/${pid}/status`, "utf-8") + .match(/Cpus_allowed_list:\s*(.+)/)?.[1] ?? ""; + expect(parseCpuList(list)).toHaveLength(reported); + } + } finally { + await (browser as any).close(); + } + }, + 240_000, + ); + + it.runIf(identity === "fpgen_linux_de" && process.platform === "linux")( + "headless: 'virtual' runs headful on a private Xvfb and tears it down", + async () => { + const { result: browser } = await step( + "virtual: launch (Xvfb + headful browser)", + mods.warnings.recordWarnings(() => + mods.sync.Camoufox({ + ...kwargsFor(identity), + headless: "virtual", + }), + ), + ); + const display = (browser as any)._virtualDisplay; + expect(display).toBeTruthy(); + let probe: any; + try { + const page = await step( + "virtual: newPage", + (browser as any).newPage(), + ); + // Firefox defers enumerateDevices() until the document has + // focus (LEAKS row 57). Headless fakes focus; a headful window + // on a bare Xvfb only sometimes gets it, so the probe hung on + // some runs. A user's window has focus: give it one. + await step("virtual: goto", page.goto(url)); + await step("virtual: focus", focusPage(page)); + probe = await step( + "virtual: probe the page", + page.evaluate(`(${PROBE})()`), + ); + } finally { + await step("virtual: close", (browser as any).close()); + } + // close() killed the Xvfb it spawned. + expect(display.proc ?? null).toBeNull(); + results[`${identity}:virtual`] = probe; + + // Parity: Python's headless="virtual" on the same binary. + const py = await pythonProbe("virtual", kwargsFor(identity)); + expect(stable(probe)).toEqual(stable(py.probe)); + }, + 240_000, + ); + + it.runIf(identity === "fpgen_linux_de" && process.platform === "linux")( + "the browser honours the config headful, as it does headless", + async (ctx) => { + const config = results[`${identity}:config`]; + const missing = unknownToBinary(config); + if (missing.length) + ctx.skip( + `binary predates the launcher; it does not know: ${missing.join(", ")}`, + ); + const probe = results[`${identity}:virtual`]; + expect(probe, "the virtual-display test ran").toBeTruthy(); + expectMatchesConfig(probe, config, true); + // A headful window presents the same device as headless. On a + // runner with no media hardware, published beta.31 never settled + // enumerateDevices() headful while headless answered. + const { media: _m, ...headful } = stable(probe); + const { media: _h, ...headless } = stable( + results[`${identity}:headless`], + ); + expect(headful).toEqual(headless); + }, + 240_000, + ); + }); + } +}); diff --git a/typescript/tests/fallback-warnings.test.ts b/typescript/tests/fallback-warnings.test.ts new file mode 100644 index 000000000..68b7b6943 --- /dev/null +++ b/typescript/tests/fallback-warnings.test.ts @@ -0,0 +1,136 @@ +/** + * Port of pythonlib/tests/test_fallback_warnings.py (identity half; the launch + * half is in launch.test.ts): every place an identity falls back to a + * substitute value says so, with a report block to paste into an issue. + * + * Python breaks the draws with monkeypatch; here they read a copy of the data + * files with one file removed or corrupted, which fails the same way. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DATA_FILES, LOCAL_DATA } from "../src/paths.js"; +import { prerequisite } from "./prereq.js"; + +const REPORT = + "Please report this at https://github.com/daijro/camoufox/issues/new"; + +let modelReady = true; +try { + await (await import("../src/fpgen/index.js")).ensureModel(); +} catch (e) { + modelReady = prerequisite("fpgen-model", false, String(e)); +} + +let tmp: string; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-fallback-")); + vi.resetModules(); +}); + +afterEach(() => { + vi.doUnmock("../src/paths.js"); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +const missing = (file: string) => (dir: string) => + fs.rmSync(path.join(dir, file)); +const corrupt = (file: string) => (dir: string) => + fs.writeFileSync(path.join(dir, file), "{not json"); + +/** fingerprints.ts and warnings.ts reading a data directory `broken` has altered. */ +async function withData(broken: (dir: string) => void) { + const data = path.join(tmp, "data-files"); + fs.mkdirSync(data, { recursive: true }); + for (const name of DATA_FILES) + fs.copyFileSync(path.join(LOCAL_DATA, name), path.join(data, name)); + broken(data); + vi.doMock("../src/paths.js", async (importOriginal) => ({ + ...(await importOriginal()), + LOCAL_DATA: data, + })); + await (await import("../src/fpgen/index.js")).ensureModel(); + return { + fp: await import("../src/fingerprints.js"), + warnings: await import("../src/warnings.js"), + }; +} + +/** The one FallbackWarning `fn` emitted, checked for its report block. */ +async function report( + warnings: typeof import("../src/warnings.js"), + fn: () => unknown, +): Promise<{ result: any; text: string }> { + const { result, error, warnings: caught } = await warnings.recordWarnings(fn); + if (error) throw error; + const fallbacks = caught.filter((w) => w.category === "FallbackWarning"); + expect(fallbacks).toHaveLength(1); + const text = fallbacks[0].message; + for (const part of [REPORT, "camoufox:", "node:", "os:", "error:"]) { + expect(text).toContain(part); + } + return { result, text }; +} + +function preset(fp: typeof import("../src/fingerprints.js")) { + const first = fp.loadPresets("152")?.presets.windows[0]; + return { ...first, fonts: ["Arial"] }; +} + +it("a failed preset font draw falls back to the preset's fonts", async () => { + const { fp, warnings } = await withData(missing("fonts.json")); + const { result: config, text } = await report(warnings, () => + fp.fromPreset(preset(fp), "152"), + ); + expect(text).toContain("ENOENT"); + expect(text).toContain("the preset's recorded fonts"); + expect(config.fonts).toContain("Arial"); +}); + +it("a failed preset voice draw warns", async () => { + const { fp, warnings } = await withData(corrupt("voice-manifests.json")); + const { text } = await report(warnings, () => + fp.fromPreset(preset(fp), "152"), + ); + expect(text).toContain("error: SyntaxError:"); +}); + +describe.skipIf(!modelReady)("context draws", () => { + it.each([ + ["fonts.json", missing("fonts.json"), "fonts", "ENOENT"], + [ + "voice-manifests.json", + corrupt("voice-manifests.json"), + "voices", + "SyntaxError", + ], + ])("an unreadable %s leaves the launch-time value", async (_file, broken, key, error) => { + const { fp, warnings } = await withData(broken); + const { result: context, text } = await report(warnings, () => + fp.generateContextFingerprint({ os: "linux" }), + ); + expect(text).toContain(error); + expect(key in context.config).toBe(false); + }); +}); + +it.each([ + "font-groups.json", + "font-bases.json", +])("an unreadable %s warns and is read as empty", async (file) => { + const { fp, warnings } = await withData(missing(file)); + const { result: fonts, text } = await report(warnings, () => + fp.generateRandomFontSubset("windows", 1), + ); + expect(text).toContain(`Reading ${file}`); + expect(fonts.length).toBeGreaterThan(0); +}); + +it("an error the data cannot raise is not swallowed", async () => { + const { fp } = await withData((dir) => + fs.writeFileSync(path.join(dir, "fonts.json"), "null"), + ); + expect(() => fp.fromPreset(preset(fp), "152")).toThrow(TypeError); +}); diff --git a/typescript/tests/fingerprints.test.ts b/typescript/tests/fingerprints.test.ts new file mode 100644 index 000000000..397037437 --- /dev/null +++ b/typescript/tests/fingerprints.test.ts @@ -0,0 +1,890 @@ +/** + * The identity layer's behaviour: ports of pythonlib/tests/ + * test_fingerprint_fixes.py, test_preset_appversion.py, test_voices.py, + * test_font_distribution.py and the unit half of test_identity_salt.py. + * Exact parity with Python is pinned separately in identity-golden.test.ts. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { InvalidIP } from "../src/exceptions.js"; +import { + appVersionFromUserAgent, + audioSeedFromIdentity, + buildInitScript, + clampScreenToDisplay, + clampWindowDimensions, + clampWindowPosition, + drawMediaDevices, + fixHardwareConcurrency, + fixNavigatorArch, + fixScreenNoTaskbar, + fromFpgen, + fromPreset, + generateContextFingerprint, + generateFingerprint, + generateRandomFontSubset, + generateRandomVoiceSubset, + getRandomPreset, + identitySalt, + identitySeed, + normalizePresetVoices, + PLAUSIBLE_CORE_COUNTS, + type Preset, + Screen, + setMediaDevicesDefaults, + WINDOWS_11_MARKER_FONTS, +} from "../src/fingerprints.js"; +import { LOCAL_DATA } from "../src/pkgman.js"; +import { MODEL } from "./fpgen-setup.js"; + +function data(name: string): any { + return JSON.parse(fs.readFileSync(path.join(LOCAL_DATA, name), "utf-8")); +} + +describe("fixNavigatorArch", () => { + it("corrects armv81 to the UA arch", () => { + const c: Record = { + "navigator.userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) ...", + "navigator.platform": "Linux armv81", + "navigator.oscpu": "Linux armv81", + }; + fixNavigatorArch(c, "lin"); + expect(c["navigator.platform"]).toBe("Linux x86_64"); + expect(c["navigator.oscpu"]).toBe("Linux x86_64"); + }); + + it("only runs on Linux, and only with a UA", () => { + const mac = { + "navigator.userAgent": "... Macintosh ...", + "navigator.platform": "MacIntel", + }; + fixNavigatorArch(mac, "mac"); + expect(mac["navigator.platform"]).toBe("MacIntel"); + const noUa = { "navigator.platform": "Linux armv81" }; + fixNavigatorArch(noUa, "lin"); + expect(noUa["navigator.platform"]).toBe("Linux armv81"); + }); +}); + +describe("fixScreenNoTaskbar", () => { + it("subtracts the taskbar when avail equals the screen", () => { + const c: Record = { + "screen.width": 1920, + "screen.height": 1080, + "screen.availWidth": 1920, + "screen.availHeight": 1080, + "window.outerHeight": 1080, + "window.innerHeight": 1040, + }; + fixScreenNoTaskbar(c, "lin"); + expect(c["screen.availHeight"]).toBe(1080 - 27); + expect(c["window.outerHeight"]).toBe(1053); + expect(c["window.innerHeight"]).toBe(1053 - 40); + }); + + it("uses each OS's taskbar height", () => { + for (const [os, px] of [ + ["win", 40], + ["mac", 25], + ["lin", 27], + ] as const) { + const c: Record = { + "screen.width": 1920, + "screen.height": 1080, + "screen.availWidth": 1920, + "screen.availHeight": 1080, + }; + fixScreenNoTaskbar(c, os); + expect(c["screen.availHeight"]).toBe(1080 - px); + } + }); + + it("is a no-op when avail is already below the screen", () => { + const c = { + "screen.width": 1920, + "screen.height": 1080, + "screen.availWidth": 1920, + "screen.availHeight": 1040, + }; + fixScreenNoTaskbar(c, "lin"); + expect(c["screen.availHeight"]).toBe(1040); + }); +}); + +describe("clampWindowDimensions", () => { + it("clamps impossible geometry on both axes", () => { + const c: Record = { + "screen.width": 1920, + "screen.height": 1080, + "screen.availWidth": 2000, + "window.outerWidth": 2200, + "window.innerWidth": 2100, + }; + clampWindowDimensions(c); + expect(c["screen.availWidth"]).toBe(1920); + expect(c["window.outerWidth"]).toBe(1920); + expect(c["window.innerWidth"]).toBeLessThanOrEqual(c["window.outerWidth"]); + }); + + it("preserves the chrome delta", () => { + const c: Record = { + "screen.width": 1000, + "window.outerWidth": 1200, + "window.innerWidth": 1180, + }; + clampWindowDimensions(c); + expect(c["window.outerWidth"]).toBe(1000); + expect(c["window.innerWidth"]).toBe(980); + }); + + it("leaves a valid hierarchy alone", () => { + const c = { + "screen.width": 1920, + "screen.availWidth": 1920, + "window.outerWidth": 1280, + "window.innerWidth": 1264, + }; + clampWindowDimensions(c); + expect(c["window.outerWidth"]).toBe(1280); + expect(c["window.innerWidth"]).toBe(1264); + }); +}); + +describe("clampScreenToDisplay", () => { + it("shrinks the screen to the display, keeping the taskbar delta", () => { + const c: Record = { + "screen.width": 2560, + "screen.height": 1440, + "screen.availWidth": 2560, + "screen.availHeight": 1400, + }; + clampScreenToDisplay(c, 1366, 768); + expect(c).toEqual({ + "screen.width": 1366, + "screen.height": 768, + "screen.availWidth": 1366, + "screen.availHeight": 728, + }); + }); + + it("ignores unset bounds and never drops avail below one", () => { + const c = { "screen.width": 2560, "screen.height": 1440 }; + clampScreenToDisplay(c, null, null); + expect(c).toEqual({ "screen.width": 2560, "screen.height": 1440 }); + const tall = { "screen.height": 2000, "screen.availHeight": 100 }; + clampScreenToDisplay(tall, null, 768); + expect(tall["screen.availHeight"]).toBeGreaterThanOrEqual(1); + }); + + it("survives the clampWindowDimensions cascade", () => { + const c: Record = { + "screen.width": 2560, + "screen.height": 1440, + "screen.availWidth": 2560, + "screen.availHeight": 1400, + "window.outerWidth": 1920, + "window.outerHeight": 1055, + "window.innerWidth": 1920, + "window.innerHeight": 1000, + }; + clampScreenToDisplay(c, 1366, 768); + clampWindowDimensions(c); + expect(c["window.outerWidth"]).toBeLessThanOrEqual(c["screen.availWidth"]); + expect(c["screen.availWidth"]).toBeLessThanOrEqual(1366); + expect(c["window.outerHeight"]).toBeLessThanOrEqual( + c["screen.availHeight"], + ); + expect(c["window.innerHeight"]).toBeLessThanOrEqual( + c["window.outerHeight"], + ); + }); +}); + +describe("clampWindowPosition", () => { + it("pulls the window back inside the screen, never negative", () => { + const c: Record = { + "screen.width": 1366, + "screen.height": 768, + "window.outerWidth": 1366, + "window.outerHeight": 728, + "window.screenX": 250, + "window.screenY": 281, + }; + clampWindowPosition(c); + expect([c["window.screenX"], c["window.screenY"]]).toEqual([0, 40]); + const wide: Record = { + "screen.width": 800, + "window.outerWidth": 1000, + "window.screenX": 50, + }; + clampWindowPosition(wide); + expect(wide["window.screenX"]).toBe(0); + }); +}); + +describe("media devices", () => { + it("draws common desktop devices, seeded by the identity", () => { + const c: Record = { + "navigator.userAgent": "ua", + "navigator.platform": "Win32", + }; + setMediaDevicesDefaults(c); + expect(c["mediaDevices:enabled"]).toBe(true); + for (const [kind, key] of [ + ["micros", "microphone"], + ["webcams", "webcam"], + ["speakers", "speaker"], + ]) { + const n = c[`mediaDevices:${kind}`]; + expect(c[`mediaDevices:${key}Labels`]).toHaveLength(n); + expect(c[`mediaDevices:${key}Groups`]).toHaveLength(n); + } + expect(c["mediaDevices:speakers"]).toBeGreaterThanOrEqual(1); + expect( + c["mediaDevices:speakerLabels"].every((s: string) => s.includes("(")), + ).toBe(true); + const again: Record = { + "navigator.userAgent": "ua", + "navigator.platform": "Win32", + }; + setMediaDevicesDefaults(again); + expect(again).toEqual(c); + let mics = 0; + let cams = 0; + for (let i = 0; i < 400; i++) { + const d: Record = { + "navigator.userAgent": `ua${i}`, + "navigator.platform": "Win32", + }; + setMediaDevicesDefaults(d); + mics += d["mediaDevices:micros"] > 0 ? 1 : 0; + cams += d["mediaDevices:webcams"] > 0 ? 1 : 0; + } + expect(mics / 400).toBeGreaterThan(0.8); + expect(mics / 400).toBeLessThan(1.0); + expect(cams / 400).toBeGreaterThan(0.55); + expect(cams / 400).toBeLessThan(0.95); + }); + + it("labels devices in each OS's style", () => { + const mac = drawMediaDevices("mac", 7); + expect( + mac["mediaDevices:microphoneLabels"].some((s: string) => + s.startsWith("Microphone ("), + ), + ).toBe(false); + const lin = drawMediaDevices("lin", 7); + for (const o of lin["mediaDevices:speakerLabels"]) { + expect(lin["mediaDevices:microphoneLabels"]).toContain(`Monitor of ${o}`); + } + const win = drawMediaDevices("win", 11); + if (win["mediaDevices:micros"] && win["mediaDevices:speakers"]) { + expect(win["mediaDevices:microphoneGroups"][0]).toBe( + win["mediaDevices:speakerGroups"][0], + ); + } + for (const os of ["win", "mac", "lin"]) { + for (let seed = 0; seed < 50; seed++) { + const d = drawMediaDevices(os, seed); + const m: string[] = d["mediaDevices:microphoneLabels"]; + const cams: string[] = d["mediaDevices:webcamLabels"]; + expect(m).not.toContain("Default Audio Device"); + expect(cams).not.toContain("Default Video Device"); + expect(new Set(m).size).toBe(m.length); + expect(new Set(cams).size).toBe(cams.length); + } + } + }); + + it("respects caller-set mediaDevices keys", () => { + const c = { "mediaDevices:webcams": 5 }; + setMediaDevicesDefaults(c); + expect(c).toEqual({ "mediaDevices:webcams": 5 }); + }); +}); + +describe("fixHardwareConcurrency", () => { + it("keeps a plausible draw the host can be pinned to", () => { + for (const drawn of [4, 6, 8, 10, 12, 14, 16]) { + const c = { "navigator.hardwareConcurrency": drawn }; + fixHardwareConcurrency(c, undefined, { cpuCount: 16, canPinHost: true }); + expect(c["navigator.hardwareConcurrency"]).toBe(drawn); + } + }); + + it("snaps implausible draws down into the table", () => { + for (const [drawn, want] of [ + [1, 4], + [2, 4], + [3, 4], + [5, 4], + [7, 6], + [9, 8], + [11, 10], + [13, 12], + [15, 14], + [32, 16], + ]) { + const c = { "navigator.hardwareConcurrency": drawn }; + fixHardwareConcurrency(c, undefined, { cpuCount: 16, canPinHost: true }); + expect(c["navigator.hardwareConcurrency"], String(drawn)).toBe(want); + } + const c = { "navigator.hardwareConcurrency": 2 }; + fixHardwareConcurrency(c, undefined, { cpuCount: 4, canPinHost: true }); + expect(c["navigator.hardwareConcurrency"]).toBe(4); + }); + + it("snaps host parallelism when it cannot pin", () => { + for (const [host, want] of [ + [16, 16], + [10, 10], + [24, 24], + [26, 24], + [32, 32], + [64, 32], + [22, 22], + [7, 6], + [5, 4], + [2, 4], + [9, 8], + ]) { + const c = { "navigator.hardwareConcurrency": 2 }; + fixHardwareConcurrency(c, undefined, { + cpuCount: host, + canPinHost: false, + }); + expect(c["navigator.hardwareConcurrency"], String(host)).toBe(want); + } + const c = { "navigator.hardwareConcurrency": 32 }; + fixHardwareConcurrency(c, undefined, { cpuCount: 8, canPinHost: true }); + expect(c["navigator.hardwareConcurrency"]).toBe(8); + }); + + it("reports the host when the launch will not pin", () => { + const c = { "navigator.hardwareConcurrency": 8 }; + fixHardwareConcurrency(c, false, { cpuCount: 16, canPinHost: true }); + expect(c["navigator.hardwareConcurrency"]).toBe(16); + }); + + it("reports the table floor on a small pinnable host", () => { + for (const host of [1, 2, 3]) { + for (const drawn of [1, 2, 3, 8]) { + const c = { "navigator.hardwareConcurrency": drawn }; + fixHardwareConcurrency(c, undefined, { + cpuCount: host, + canPinHost: true, + }); + expect(c["navigator.hardwareConcurrency"]).toBe(4); + } + } + }); + + it("leaves the draw without a host count", () => { + const c = { "navigator.hardwareConcurrency": 8 }; + fixHardwareConcurrency(c, undefined, { cpuCount: null }); + expect(c["navigator.hardwareConcurrency"]).toBe(8); + }); + + it("keeps the recorded counts in the table", () => { + for (const n of [18, 22, 28, 32]) + expect(PLAUSIBLE_CORE_COUNTS).toContain(n); + }); +}); + +describe("preset appVersion (test_preset_appversion.py)", () => { + const preset = (platform: string, userAgent: string) => ({ + navigator: { platform, userAgent }, + }); + + it("follows the preset platform", () => { + for (const [platform, ua, want] of [ + [ + "Linux x86_64", + "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", + "5.0 (X11)", + ], + [ + "Win32", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0", + "5.0 (Windows)", + ], + [ + "MacIntel", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", + "5.0 (Macintosh)", + ], + ]) { + expect( + fromPreset(preset(platform, ua), null, 0)["navigator.appVersion"], + ).toBe(want); + } + }); + + it("keeps a captured appVersion, follows an unknown platform's UA", () => { + const p: any = preset( + "Win32", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Firefox/152.0", + ); + p.navigator.appVersion = "5.0 (Windows NT 10.0; Win64; x64)"; + expect(fromPreset(p, null, 0)["navigator.appVersion"]).toBe( + "5.0 (Windows NT 10.0; Win64; x64)", + ); + expect( + fromPreset( + preset( + "iPhone", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Gecko/20100101", + ), + null, + 0, + )["navigator.appVersion"], + ).toBe("5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"); + }); + + it("matches what Firefox reports", () => { + for (const [ua, want] of [ + [ + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", + "5.0 (X11; Ubuntu)", + ], + [ + "Mozilla/5.0 (Android 16; Mobile; rv:152.0) Gecko/152.0 Firefox/152.0", + "5.0 (Android 16)", + ], + ]) { + expect(appVersionFromUserAgent(ua)).toBe(want); + } + }); + + it("leaves a user agent it cannot read alone", () => { + expect( + "navigator.appVersion" in + fromPreset(preset("Win32", "not a user agent"), null, 0), + ).toBe(false); + }); + + it("rewrites the Firefox version when asked", () => { + const c = fromPreset( + preset( + "Linux x86_64", + "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0", + ), + "152", + 0, + ); + expect(c["navigator.userAgent"]).toBe( + "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", + ); + }); +}); + +const REQUIRED_VOICE_FIELDS = [ + "lang", + "name", + "voiceUri", + "isDefault", + "isLocalService", +]; + +describe("voices (test_voices.py)", () => { + for (const os of ["macos", "windows", "linux"]) { + it(`${os}: non-empty full objects, none marked default`, () => { + const voices = generateRandomVoiceSubset(os, "en-US"); + expect(voices.length).toBeGreaterThan(0); + for (const v of voices) { + for (const f of REQUIRED_VOICE_FIELDS) expect(v).toHaveProperty(f); + } + expect(voices.some((v) => v.isDefault)).toBe(false); + }); + } + + it("uses the Windows display language pack", () => { + expect(generateRandomVoiceSubset("windows", "de-DE", 1)[0].lang).toBe( + "de-DE", + ); + expect( + generateRandomVoiceSubset("windows", "en-US", 1) + .slice(0, 3) + .map((v) => v.name), + ).toEqual([ + "Microsoft David - English (United States)", + "Microsoft Mark - English (United States)", + "Microsoft Zira - English (United States)", + ]); + }); + + it("builds Linux speechd URIs like SpeechDispatcherService.cpp", () => { + const lin = generateRandomVoiceSubset("linux", "en-US"); + for (const v of lin) { + expect(v.voiceUri.startsWith("urn:moz-tts:speechd:")).toBe(true); + expect(v.voiceUri.endsWith(`?${v.lang}`)).toBe(true); + expect(v.isLocalService).toBe(true); + } + const gb = lin.find((v) => v.name === "English (Great Britain)"); + expect(gb?.voiceUri).toBe( + "urn:moz-tts:speechd:English%20(Great%20Britain)?en-GB", + ); + }); + + it("is deterministic per seed and falls back to macOS for an unknown OS", () => { + expect(generateRandomVoiceSubset("windows", "fr-FR", 5)).toEqual( + generateRandomVoiceSubset("windows", "fr-FR", 5), + ); + expect(generateRandomVoiceSubset("plan9", "en-US").length).toBeGreaterThan( + 0, + ); + }); + + it("normalizes preset voices", () => { + const out = normalizePresetVoices( + ["Albert:en-US:local", "Alice:it-IT:local"], + "macos", + ); + for (const v of out) { + for (const f of REQUIRED_VOICE_FIELDS) expect(v).toHaveProperty(f); + } + expect([out[0].name, out[0].lang]).toEqual(["Albert", "en-US"]); + expect(out.filter((v) => v.isDefault)).toHaveLength(1); + const obj = { + name: "Alex", + lang: "en-US", + voiceUri: "urn:moz-tts:osx:alex", + isDefault: true, + isLocalService: true, + }; + expect(normalizePresetVoices([obj], "macos")).toEqual([obj]); + }); +}); + +describe("font distribution (test_font_distribution.py)", () => { + const OS_KEYS: Record = { + windows: "win", + macos: "mac", + linux: "lin", + }; + const N = 1500; + const BASES = data("font-bases.json"); + const GROUPS = data("font-groups.json"); + const REPORTABLE = data("fonts.json"); + const samples: Record[]> = {}; + for (const os of Object.keys(OS_KEYS)) { + samples[os] = Array.from( + { length: N }, + (_, i) => new Set(generateRandomFontSubset(os, i)), + ); + } + const tolerance = (p: number, n = N, sigmas = 5.0, floor = 0.03) => + Math.max(floor, sigmas * Math.sqrt(Math.max(p * (1 - p), 1e-6) / n)); + const union = (sets: Iterable[]) => { + const out = new Set(); + for (const s of sets) for (const x of s) out.add(x); + return out; + }; + const unitExclusive = (osKey: string, unit: any) => { + const others = union( + GROUPS[osKey] + .filter((u: any) => u.id !== unit.id) + .map((u: any) => u.fonts), + ); + const inBase = union(BASES[osKey].map((b: any) => b.fonts)); + return new Set( + unit.fonts.filter((f: string) => !others.has(f) && !inBase.has(f)), + ); + }; + + for (const os of Object.keys(OS_KEYS)) { + const key = OS_KEYS[os]; + it(`${os}: every draw contains one complete base`, () => { + const bases = BASES[key].map((b: any) => new Set(b.fonts)); + samples[os].forEach((fonts, i) => { + const ok = bases.some((b: Set) => + [...b].every((f) => fonts.has(f)), + ); + expect(ok, `draw #${i}`).toBe(true); + }); + }); + + it(`${os}: base weights and unit probabilities match the manifest`, () => { + const bases = BASES[key]; + if (bases.length >= 2) { + for (const b of bases) { + const others = union( + bases.filter((o: any) => o.id !== b.id).map((o: any) => o.fonts), + ); + const ex = b.fonts.filter((f: string) => !others.has(f)); + if (!ex.length) continue; + const seen = + samples[os].filter((fonts) => ex.every((f: string) => fonts.has(f))) + .length / N; + expect(Math.abs(seen - b.weight), `base ${b.id}`).toBeLessThanOrEqual( + tolerance(b.weight), + ); + } + } + let checked = 0; + for (const unit of GROUPS[key]) { + if (unit.requiresLocale) continue; + const ex = unitExclusive(key, unit); + if (!ex.size) continue; + const hits = samples[os].filter((fonts) => + [...ex].some((f) => fonts.has(f)), + ).length; + checked++; + expect( + Math.abs(hits / N - unit.prob), + `unit ${unit.id}`, + ).toBeLessThanOrEqual(tolerance(unit.prob)); + } + expect(checked).toBeGreaterThan(0); + }); + + it(`${os}: bundles are all-or-nothing, a-la-carte units piecemeal`, () => { + let alacarte = 0; + for (const unit of GROUPS[key]) { + const ex = unitExclusive(key, unit); + if (unit.kind === "bundle" && ex.size >= 2) { + for (const fonts of samples[os]) { + const present = [...ex].filter((f) => fonts.has(f)).length; + expect( + present === 0 || present === ex.size, + `bundle ${unit.id}`, + ).toBe(true); + } + } + if (unit.kind === "alacarte" && ex.size >= 4) { + alacarte++; + const counts = new Set( + samples[os].map( + (fonts) => [...ex].filter((f) => fonts.has(f)).length, + ), + ); + expect( + [...counts].some((c) => c > 0 && c < ex.size), + `alacarte ${unit.id}`, + ).toBe(true); + } + } + expect(alacarte).toBeGreaterThan(0); + }); + + it(`${os}: draws vary, stay renderable, repeat per seed, never duplicate`, () => { + const lists = samples[os].map((s) => [...s].sort().join("\n")); + const counts = new Map(); + for (const l of lists) counts.set(l, (counts.get(l) ?? 0) + 1); + expect(counts.size).toBeGreaterThanOrEqual(50); + expect(Math.max(...counts.values()) / N).toBeLessThanOrEqual(0.5); + expect( + new Set(samples[os].map((s) => s.size)).size, + ).toBeGreaterThanOrEqual(5); + const pool = new Set(REPORTABLE[key]); + for (const fonts of samples[os]) { + for (const f of fonts) expect(pool.has(f), f).toBe(true); + } + for (const seed of [1, 7, 99]) { + expect(generateRandomFontSubset(os, seed)).toEqual( + generateRandomFontSubset(os, seed), + ); + } + for (let i = 0; i < 200; i++) { + const list = generateRandomFontSubset(os, i); + expect(new Set(list).size).toBe(list.length); + } + }); + } + + it("every Windows identity presents Windows 11", () => { + for (let i = 0; i < 50; i++) { + const fonts = new Set(generateRandomFontSubset("windows", i)); + for (const f of WINDOWS_11_MARKER_FONTS) expect(fonts.has(f)).toBe(true); + } + }); + + it("a native identity claims only the OS base", () => { + const native = generateRandomFontSubset("linux", 0, true); + expect(native).toEqual(generateRandomFontSubset("linux", 99, true)); + }); +}); + +describe("identity salt and seed (test_identity_salt.py)", () => { + it("unpinned salts differ", () => { + const config = { + "navigator.userAgent": "x", + "navigator.platform": "Win32", + "screen.width": 1920, + "screen.height": 1080, + "navigator.hardwareConcurrency": 8, + }; + const seeds = new Set( + Array.from({ length: 200 }, () => identitySeed(config, identitySalt())), + ); + expect(seeds.size).toBe(200); + }); + + it("the salt of equal objects is equal", () => { + expect(identitySalt({ a: 1, b: 2 })).toBe(identitySalt({ b: 2, a: 1 })); + const s = new Screen({ maxWidth: 1920, maxHeight: 1080 }); + expect(identitySalt(s)).toBe( + identitySalt(new Screen({ maxWidth: 1920, maxHeight: 1080 })), + ); + }); + + it("derives the audio seed without losing precision", () => { + // (ident * 2654435761 + 97) & 0xFFFFFFFF, checked with Python ints + expect(audioSeedFromIdentity(4294967295)).toBe(1640531632); + expect(audioSeedFromIdentity(0)).toBe(97); + }); + + it("a pinned preset reproduces its draws", () => { + const preset = getRandomPreset("windows", "150") as Preset; + expect(preset).not.toBeNull(); + const salt = identitySalt(preset); + const a = fromPreset(structuredClone(preset), "150", salt); + const b = fromPreset(structuredClone(preset), "150", salt); + expect(a.fonts).toEqual(b.fonts); + expect(a.voices).toEqual(b.voices); + }); +}); + +describe("buildInitScript", () => { + it("guards every setter and always clears the WebRTC IP", () => { + const script = buildInitScript({ + navigatorPlatform: "Win32", + hardwareConcurrency: 8, + screenWidth: 1920, + screenHeight: 1080, + fontList: ["Arial"], + speechVoices: [{ name: "A" } as any], + }); + for (const line of script.split("\n").slice(2, -1)) { + expect(line).toMatch( + /^ {2}if \(typeof w\.\w+ === "function"\) w\.\w+\(.*\);$/, + ); + } + expect(script).toContain('w.setWebRTCIPv4("")'); + expect(buildInitScript({})).not.toContain("setFontList"); + }); + + it("hands an IPv4 WebRTC address to the IPv4 setter", () => { + const script = buildInitScript({ webrtcIP: "203.0.113.7" }); + expect(script).toContain('w.setWebRTCIPv4("203.0.113.7")'); + expect(script).not.toContain("setWebRTCIPv6("); + }); + + it("hands an IPv6 WebRTC address to the IPv6 setter", () => { + const script = buildInitScript({ webrtcIP: "2001:db8::7" }); + expect(script).toContain('w.setWebRTCIPv6("2001:db8::7")'); + expect(script.split("setWebRTCIPv6")[0]).not.toContain("2001:db8::7"); + }); + + it("refuses an invalid WebRTC address", () => { + expect(() => buildInitScript({ webrtcIP: "not-an-ip" })).toThrow(InvalidIP); + }); +}); + +describe("fromFpgen", () => { + it("maps navigator/screen/window/headers and rewrites the Firefox version", () => { + const config = fromFpgen( + { + navigator: { + userAgent: + "Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0", + platform: "Linux x86_64", + hardwareConcurrency: 8, + deviceMemory: "undefined", + }, + screen: { width: 1920, height: 1080, availLeft: -3 }, + window: { screenX: 10, innerWidth: 1900 }, + headers: { "accept-encoding": ["gzip, deflate, br, zstd"] }, + }, + "152", + ); + expect(config).toEqual({ + "navigator.userAgent": + "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", + "navigator.platform": "Linux x86_64", + "navigator.hardwareConcurrency": 8, + "screen.width": 1920, + "screen.height": 1080, + "screen.availLeft": 0, + "window.screenX": 10, + "window.screenY": 10, + "headers.Accept-Encoding": "gzip, deflate, br, zstd", + }); + }); +}); + +describe.skipIf(!MODEL.ok)("fpgen generation (needs the model)", () => { + it("generates a Firefox fingerprint for each OS", () => { + for (const [os, platform] of [ + ["windows", "Win32"], + ["macos", "MacIntel"], + ["linux", "Linux"], + ]) { + const f = generateFingerprint({ os }); + expect(String(f.navigator.platform)).toContain(platform); + expect(String(f.navigator.userAgent)).toContain("Firefox"); + } + }); + + it("does not carry a drawn scroll offset into the config", () => { + // The browser returns screen.pageYOffset from scrollY on every read, so + // a drawn value froze the page at one scroll position. + const fingerprint = generateFingerprint({ os: "windows" }); + fingerprint.window = { + ...fingerprint.window, + pageXOffset: 17, + pageYOffset: 528, + }; + const config = fromFpgen(fingerprint, "152"); + expect("screen.pageXOffset" in config).toBe(false); + expect("screen.pageYOffset" in config).toBe(false); + }); + + it("honours the screen bound, best-effort", () => { + const f = generateFingerprint({ screen: new Screen({ maxWidth: 1400 }) }); + expect(f.screen.width).toBeLessThanOrEqual(1400); + // A bound the pool cannot meet falls back to an unbounded draw. + expect(() => + generateFingerprint({ screen: new Screen({ maxWidth: 1 }) }), + ).not.toThrow(); + }); + + it("applies a custom window size, centred", () => { + const f = generateFingerprint({ os: "linux", window: [800, 600] }); + expect([f.window.outerWidth, f.window.outerHeight]).toEqual([800, 600]); + }); + + it("accepts a list of OSes (Python raises InvalidConstraints here)", () => { + // Divergence by design: fpgen passes predicates the casefolded value, + // so Python's `v in {'Linux', 'Windows'}` never matches; TS compares + // casefolded. + const platforms = new Set(); + for (let i = 0; i < 20; i++) { + const f = generateFingerprint({ os: ["linux", "windows"] }); + const p = String(f.navigator.platform); + expect(p.includes("Linux") || p === "Win32", p).toBe(true); + platforms.add(p === "Win32" ? "win" : "lin"); + } + expect(platforms.size).toBe(2); + }); + + it("rejects an unknown OS", () => { + expect(() => generateFingerprint({ os: "plan9" })).toThrow(/Unknown OS/); + }); + + it("builds a context fingerprint from fpgen and from a preset", () => { + const gen = generateContextFingerprint({ os: "windows", locale: "de-DE" }); + expect(gen.config.fonts.length).toBeGreaterThan(0); + expect(gen.context_options.locale).toBe("de-DE"); + expect(gen.init_script).toContain("setNavigatorUserAgent"); + const preset = getRandomPreset("macos", "150"); + const fromP = generateContextFingerprint({ + preset, + timezone: "Europe/Paris", + config_overrides: { "audio:seed": 7 }, + }); + expect(fromP.context_options.timezoneId).toBe("Europe/Paris"); + expect(fromP.config["navigator.platform"]).toBe("MacIntel"); + }); +}); diff --git a/typescript/tests/fixtures/.gitignore b/typescript/tests/fixtures/.gitignore new file mode 100644 index 000000000..a96fe40f2 --- /dev/null +++ b/typescript/tests/fixtures/.gitignore @@ -0,0 +1,6 @@ +# Recorded from pythonlib by tests/golden-setup.ts on every test run. +/fpgen/* +!/fpgen/stats.json +/identity/ +/launch/scenario-*.json +/launch/host.json diff --git a/typescript/tests/fixtures/e2e/probe.js b/typescript/tests/fixtures/e2e/probe.js new file mode 100644 index 000000000..702f198ee --- /dev/null +++ b/typescript/tests/fixtures/e2e/probe.js @@ -0,0 +1,132 @@ +async () => { + const out = {}; + // An await that never settles would hang the whole test with no clue which + // one; this records "timeout: " in the result instead. + const within = (promise, what, ms = 10000) => + Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`timeout: ${what} (${ms} ms)`)), ms), + ), + ]); + const nav = navigator; + out.navigator = { + userAgent: nav.userAgent, + platform: nav.platform, + oscpu: nav.oscpu, + appVersion: nav.appVersion, + hardwareConcurrency: nav.hardwareConcurrency, + maxTouchPoints: nav.maxTouchPoints, + language: nav.language, + languages: [...nav.languages], + doNotTrack: nav.doNotTrack, + globalPrivacyControl: nav.globalPrivacyControl, + webdriver: nav.webdriver, + }; + out.screen = { + width: screen.width, + height: screen.height, + availWidth: screen.availWidth, + availHeight: screen.availHeight, + colorDepth: screen.colorDepth, + pixelDepth: screen.pixelDepth, + }; + out.window = { + outerWidth: window.outerWidth, + outerHeight: window.outerHeight, + devicePixelRatio: window.devicePixelRatio, + screenX: window.screenX, + screenY: window.screenY, + }; + const ro = Intl.DateTimeFormat().resolvedOptions(); + out.intl = { + timeZone: ro.timeZone, + locale: ro.locale, + offset: new Date(Date.UTC(2026, 0, 15, 12)).getTimezoneOffset(), + number: new Intl.NumberFormat().format(1234567.891), + }; + const fonts = [ + "Arial", "Helvetica", "Times New Roman", "Courier New", "Segoe UI", "Calibri", + "DejaVu Sans", "Liberation Sans", "Noto Sans", "Ubuntu", "Menlo", "Monaco", + "Comic Sans MS", "Verdana", "Georgia", "Cantarell", "Tahoma", "Impact", + ]; + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const width = (family) => { + ctx.font = `32px "${family}", monospace`; + return ctx.measureText("mmmmmmmmmmlli1WQ@#").width; + }; + const base = width("__no_such_font__"); + out.fonts = Object.fromEntries(fonts.map((f) => [f, width(f) !== base])); + out.fontWidths = Object.fromEntries(fonts.map((f) => [f, width(f)])); + canvas.width = 220; + canvas.height = 40; + ctx.textBaseline = "top"; + ctx.font = "16px Arial"; + ctx.fillStyle = "#f60"; + ctx.fillRect(10, 1, 62, 20); + ctx.fillStyle = "#069"; + ctx.fillText("Camoufox parity 1.0", 2, 15); + ctx.beginPath(); + ctx.arc(180, 20, 12, 0, Math.PI * 1.5); + ctx.strokeStyle = "rgba(10, 120, 40, 0.7)"; + ctx.stroke(); + const data = canvas.toDataURL(); + // FNV-1a over the data URL: the probe runs in Camoufox's isolated world, + // where handing TypedArrays across the Xray boundary is forbidden. + let h = 0x811c9dc5; + for (let i = 0; i < data.length; i++) { + h ^= data.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + out.canvasHash = `${h.toString(16)}:${data.length}`; + const gl = document.createElement("canvas").getContext("webgl"); + if (gl) { + const dbg = gl.getExtension("WEBGL_debug_renderer_info"); + out.webgl = { + vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : null, + renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null, + maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE), + extensions: gl.getSupportedExtensions(), + }; + } else { + out.webgl = null; + } + const voices = await new Promise((resolve) => { + const got = speechSynthesis.getVoices(); + if (got.length) return resolve(got); + const t = setTimeout(() => resolve(speechSynthesis.getVoices()), 1500); + speechSynthesis.onvoiceschanged = () => { + clearTimeout(t); + resolve(speechSynthesis.getVoices()); + }; + }); + out.voices = voices.map((v) => `${v.name}|${v.lang}|${v.default}|${v.localService}`); + try { + out.storageQuota = (await within(navigator.storage.estimate(), "storage.estimate")).quota; + } catch (e) { + out.storageQuota = `error: ${e}`; + } + out.media = { + colorScheme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", + reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, + }; + try { + const devices = await within( + navigator.mediaDevices.enumerateDevices(), + "enumerateDevices", + ); + out.mediaDevices = devices.map((d) => d.kind).sort(); + } catch (e) { + out.mediaDevices = `error: ${e}`; + } + try { + out.permissionsGeo = ( + await within(navigator.permissions.query({ name: "geolocation" }), "permissions.query") + ).state; + } catch (e) { + out.permissionsGeo = `error: ${e}`; + } + out.historyLength = history.length; + return out; +} diff --git a/typescript/tests/fixtures/fpgen/stats.json b/typescript/tests/fixtures/fpgen/stats.json new file mode 100644 index 000000000..86f98d0b5 --- /dev/null +++ b/typescript/tests/fixtures/fpgen/stats.json @@ -0,0 +1 @@ +{"n":3000,"scenarios":[{"name":"firefox_any","conditions":{"browser":"Firefox"},"counts":{"os":{"Linux":656,"MacOS":662,"Windows":1682},"uaPlatform":{"Macintosh; Intel Mac OS X 10.15":662,"Windows NT 10.0; Win64; x64":1682,"X11; Linux x86_64":582,"X11; Ubuntu; Linux x86_64":74},"firefoxMajor":{"128":13,"132":22,"136":34,"137":8,"139":12,"140":156,"142":27,"143":19,"144":21,"145":92,"146":2517,"147":52,"148":27},"screen":{"1088x612":9,"1200x800":2,"1280x1024":6,"1280x720":25,"1280x800":33,"1280x832":7,"1296x810":10,"1344x756":10,"1360x768":6,"1366x768":64,"1382x864":5,"1440x810":8,"1440x900":95,"1440x960":11,"1470x956":56,"1488x930":7,"1512x982":96,"1536x864":197,"1536x960":66,"1568x882":9,"1600x1200":6,"1600x900":28,"1621x912":3,"1632x1020":10,"1680x1050":14,"1707x1067":19,"1707x960":22,"1710x1107":36,"1710x1112":12,"1728x1117":45,"1760x990":13,"1792x1120":19,"1800x1169":26,"1824x1026":11,"1835x1147":6,"1920x1080":984,"1920x1200":44,"1920x955":6,"1984x1116":8,"2048x1080":19,"2048x1152":58,"2056x1329":25,"2133x1200":9,"2133x1333":8,"2176x1224":24,"2240x1260":6,"2304x1296":19,"2458x1536":7,"2560x1067":14,"2560x1080":6,"2560x1440":508,"2560x1600":15,"2637x1104":7,"2752x1152":22,"2944x1656":11,"3072x1728":39,"3440x1440":88,"3638x1919":4,"3840x1600":8,"3840x2160":37,"4096x1152":10,"4096x3072":7,"5120x1440":10,"7680x2160":5},"hardwareConcurrency":{"10":261,"11":10,"12":498,"14":26,"16":362,"2":183,"20":89,"22":20,"24":90,"28":10,"32":67,"4":267,"6":58,"8":1059},"gpuVendor":{"AMD":180,"ATI Technologies Inc.":28,"Apple":624,"Broadcom":15,"Google Inc. (AMD)":378,"Google Inc. (Intel)":589,"Google Inc. (Microsoft)":16,"Google Inc. (NVIDIA)":694,"Intel":268,"Intel Inc.":10,"Intel Open Source Technology Center":9,"Mesa":25,"Mozilla":21,"NVIDIA Corporation":137,"nouveau":6}}},{"name":"firefox_windows","conditions":{"browser":"Firefox","os":"Windows"},"counts":{"os":{"Windows":3000},"uaPlatform":{"Windows NT 10.0; Win64; x64":3000},"firefoxMajor":{"139":16,"140":149,"143":24,"144":9,"146":2708,"147":71,"148":23},"screen":{"1088x612":10,"1280x1024":14,"1280x720":42,"1280x800":50,"1344x756":15,"1366x768":55,"1382x864":16,"1440x810":8,"1440x900":17,"1440x960":10,"1488x930":15,"1536x864":341,"1536x960":55,"1568x882":6,"1600x1200":16,"1600x900":27,"1632x1020":11,"1680x1050":24,"1707x1067":34,"1707x960":37,"1760x990":20,"1824x1026":11,"1920x1080":1066,"1920x1200":22,"1920x955":12,"1984x1116":13,"2048x1152":72,"2133x1200":16,"2176x1224":43,"2240x1260":11,"2560x1067":23,"2560x1080":15,"2560x1440":575,"2560x1600":24,"2637x1104":9,"2752x1152":35,"3072x1728":53,"3440x1440":105,"3638x1919":4,"3840x1600":13,"3840x2160":9,"4096x3072":13,"5120x1440":16,"7680x2160":17},"hardwareConcurrency":{"12":586,"14":27,"16":459,"2":198,"20":192,"22":14,"24":143,"28":28,"32":123,"4":220,"6":52,"8":958},"gpuVendor":{"Google Inc. (AMD)":719,"Google Inc. (Intel)":953,"Google Inc. (Microsoft)":20,"Google Inc. (NVIDIA)":1273,"Mozilla":35}}},{"name":"firefox_linux","conditions":{"browser":"Firefox","os":"Linux"},"counts":{"os":{"Linux":3000},"uaPlatform":{"X11; Linux x86_64":2679,"X11; Ubuntu; Linux x86_64":321},"firefoxMajor":{"128":81,"132":84,"136":118,"137":43,"139":30,"140":215,"142":71,"143":28,"145":286,"146":1986,"147":29,"148":29},"screen":{"1200x800":27,"1280x720":39,"1360x768":28,"1366x768":112,"1440x900":29,"1600x900":86,"1621x912":27,"1835x1147":48,"1920x1080":1535,"1920x1200":162,"2048x1080":28,"2048x1152":84,"2133x1333":29,"2304x1296":33,"2458x1536":33,"2560x1440":473,"2944x1656":30,"3440x1440":30,"3840x2160":102,"4096x1152":37,"5120x1440":28},"hardwareConcurrency":{"12":639,"16":494,"2":162,"22":20,"24":33,"32":82,"4":527,"8":1043},"gpuVendor":{"AMD":924,"Broadcom":71,"Google Inc. (Intel)":20,"Intel":1140,"Intel Open Source Technology Center":35,"Mesa":133,"Mozilla":28,"NVIDIA Corporation":613,"nouveau":36}}},{"name":"firefox_macos","conditions":{"browser":"Firefox","os":"macOS"},"counts":{"os":{"MacOS":3000},"uaPlatform":{"Macintosh; Intel Mac OS X 10.15":3000},"firefoxMajor":{"140":38,"142":34,"144":58,"145":156,"146":2683,"147":31},"screen":{"1280x800":40,"1280x832":39,"1296x810":29,"1440x900":374,"1470x956":238,"1512x982":387,"1536x960":60,"1710x1107":162,"1710x1112":63,"1728x1117":289,"1792x1120":59,"1800x1169":114,"1920x1080":351,"2048x1080":28,"2056x1329":125,"2304x1296":42,"2560x1440":439,"3440x1440":132,"3840x2160":29},"hardwareConcurrency":{"10":1151,"11":34,"12":225,"14":27,"16":65,"2":233,"24":41,"6":70,"8":1154},"gpuVendor":{"ATI Technologies Inc.":129,"Apple":2801,"Intel Inc.":70}}},{"name":"firefox_windows_screen_bound","conditions":{"browser":"Firefox","os":"Windows","screen.width":{"$pred":"screen_width_1280_1920"}},"counts":{"os":{"Windows":3000},"uaPlatform":{"Windows NT 10.0; Win64; x64":3000},"firefoxMajor":{"139":21,"140":125,"143":48,"146":2750,"147":36,"148":20},"screen":{"1280x1024":19,"1280x720":71,"1280x800":61,"1344x756":27,"1366x768":88,"1382x864":21,"1440x810":12,"1440x900":39,"1440x960":20,"1488x930":24,"1536x864":508,"1536x960":93,"1568x882":19,"1600x1200":21,"1600x900":39,"1632x1020":23,"1680x1050":46,"1707x1067":50,"1707x960":76,"1760x990":25,"1824x1026":19,"1920x1080":1637,"1920x1200":36,"1920x955":26},"hardwareConcurrency":{"12":587,"14":16,"16":437,"2":223,"20":165,"22":12,"24":138,"28":25,"32":99,"4":236,"6":71,"8":991},"gpuVendor":{"Google Inc. (AMD)":674,"Google Inc. (Intel)":936,"Google Inc. (Microsoft)":30,"Google Inc. (NVIDIA)":1345,"Mozilla":15}}}]} diff --git a/typescript/tests/fixtures/launch/addons/example-addon/manifest.json b/typescript/tests/fixtures/launch/addons/example-addon/manifest.json new file mode 100644 index 000000000..58af2df4f --- /dev/null +++ b/typescript/tests/fixtures/launch/addons/example-addon/manifest.json @@ -0,0 +1 @@ +{"manifest_version": 2, "name": "example", "version": "1.0"} diff --git a/typescript/tests/fixtures/launch/bundle-old/application.ini b/typescript/tests/fixtures/launch/bundle-old/application.ini new file mode 100644 index 000000000..5bc1f2012 --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle-old/application.ini @@ -0,0 +1,5 @@ +[App] +Vendor=Mozilla +Name=Camoufox +Version=152.0.4 +BuildID=20260914000000 diff --git a/typescript/tests/fixtures/launch/bundle-old/camoufox-bin b/typescript/tests/fixtures/launch/bundle-old/camoufox-bin new file mode 100755 index 000000000..b595c46bc --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle-old/camoufox-bin @@ -0,0 +1,3 @@ +#!/bin/sh +# placeholder executable for launch goldens +exit 1 diff --git a/typescript/tests/fixtures/launch/bundle-old/fontconfigs/linux/fonts.conf b/typescript/tests/fixtures/launch/bundle-old/fontconfigs/linux/fonts.conf new file mode 100644 index 000000000..c24dc466f --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle-old/fontconfigs/linux/fonts.conf @@ -0,0 +1,7 @@ + + + + + fonts + fontconfig + diff --git a/typescript/tests/fixtures/launch/bundle-old/fontconfigs/macos/fonts.conf b/typescript/tests/fixtures/launch/bundle-old/fontconfigs/macos/fonts.conf new file mode 100644 index 000000000..489ef6a4b --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle-old/fontconfigs/macos/fonts.conf @@ -0,0 +1,7 @@ + + + + + fonts + fontconfig + diff --git a/typescript/tests/fixtures/launch/bundle-old/fontconfigs/windows/fonts.conf b/typescript/tests/fixtures/launch/bundle-old/fontconfigs/windows/fonts.conf new file mode 100644 index 000000000..f2b1cd5fd --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle-old/fontconfigs/windows/fonts.conf @@ -0,0 +1,7 @@ + + + + + fonts + fontconfig + diff --git a/typescript/tests/fixtures/launch/bundle-old/fonts/linux/.gitkeep b/typescript/tests/fixtures/launch/bundle-old/fonts/linux/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/tests/fixtures/launch/bundle-old/fonts/macos/.gitkeep b/typescript/tests/fixtures/launch/bundle-old/fonts/macos/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/tests/fixtures/launch/bundle-old/fonts/windows/.gitkeep b/typescript/tests/fixtures/launch/bundle-old/fonts/windows/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/tests/fixtures/launch/bundle-old/properties.json b/typescript/tests/fixtures/launch/bundle-old/properties.json new file mode 100644 index 000000000..f51b27a64 --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle-old/properties.json @@ -0,0 +1,119 @@ +[ + { "property": "navigator.userAgent", "type": "str" }, + { "property": "navigator.doNotTrack", "type": "str" }, + { "property": "navigator.appCodeName", "type": "str" }, + { "property": "navigator.appName", "type": "str" }, + { "property": "navigator.appVersion", "type": "str" }, + { "property": "navigator.oscpu", "type": "str" }, + { "property": "navigator.language", "type": "str" }, + { "property": "navigator.languages", "type": "array" }, + { "property": "navigator.platform", "type": "str" }, + { "property": "navigator.hardwareConcurrency", "type": "uint" }, + { "property": "navigator.product", "type": "str" }, + { "property": "navigator.productSub", "type": "str" }, + { "property": "navigator.maxTouchPoints", "type": "uint" }, + { "property": "navigator.cookieEnabled", "type": "bool" }, + { "property": "navigator.globalPrivacyControl", "type": "bool" }, + { "property": "navigator.buildID", "type": "str" }, + { "property": "navigator.onLine", "type": "bool" }, + { "property": "screen.availHeight", "type": "uint" }, + { "property": "screen.availWidth", "type": "uint" }, + { "property": "screen.availTop", "type": "uint" }, + { "property": "screen.availLeft", "type": "uint" }, + { "property": "screen.height", "type": "uint" }, + { "property": "screen.width", "type": "uint" }, + { "property": "screen.colorDepth", "type": "uint" }, + { "property": "screen.pixelDepth", "type": "uint" }, + { "property": "screen.pageXOffset", "type": "double" }, + { "property": "screen.pageYOffset", "type": "double" }, + { "property": "window.scrollMinX", "type": "int" }, + { "property": "window.scrollMinY", "type": "int" }, + { "property": "window.scrollMaxX", "type": "int" }, + { "property": "window.scrollMaxY", "type": "int" }, + { "property": "window.outerHeight", "type": "uint" }, + { "property": "window.outerWidth", "type": "uint" }, + { "property": "window.innerHeight", "type": "uint" }, + { "property": "window.innerWidth", "type": "uint" }, + { "property": "window.screenX", "type": "int" }, + { "property": "window.screenY", "type": "int" }, + { "property": "window.history.length", "type": "uint" }, + { "property": "window.devicePixelRatio", "type": "double" }, + { "property": "document.body.clientWidth", "type": "uint" }, + { "property": "document.body.clientHeight", "type": "uint" }, + { "property": "document.body.clientTop", "type": "uint" }, + { "property": "document.body.clientLeft", "type": "uint" }, + { "property": "headers.User-Agent", "type": "str" }, + { "property": "headers.Accept-Language", "type": "str" }, + { "property": "headers.Accept-Encoding", "type": "str" }, + { "property": "webrtc:ipv4", "type": "str" }, + { "property": "webrtc:ipv6", "type": "str" }, + { "property": "webrtc:localipv4", "type": "str" }, + { "property": "webrtc:localipv6", "type": "str" }, + { "property": "pdfViewerEnabled", "type": "bool" }, + { "property": "battery:charging", "type": "bool" }, + { "property": "battery:chargingTime", "type": "double" }, + { "property": "battery:dischargingTime", "type": "double" }, + { "property": "battery:level", "type": "double" }, + { "property": "fonts", "type": "array" }, + { "property": "fonts:spacing_seed", "type": "uint" }, + { "property": "audio:seed", "type": "uint" }, + { "property": "canvas:seed", "type": "uint" }, + { "property": "geolocation:latitude", "type": "double" }, + { "property": "geolocation:longitude", "type": "double" }, + { "property": "geolocation:accuracy", "type": "double" }, + { "property": "timezone", "type": "str" }, + { "property": "locale:language", "type": "str" }, + { "property": "locale:region", "type": "str" }, + { "property": "locale:script", "type": "str" }, + { "property": "locale:all", "type": "str" }, + { "property": "humanize", "type": "bool" }, + { "property": "humanize:maxTime", "type": "double" }, + { "property": "humanize:minTime", "type": "double" }, + { "property": "showcursor", "type": "bool" }, + { "property": "AudioContext:sampleRate", "type": "uint" }, + { "property": "AudioContext:outputLatency", "type": "double" }, + { "property": "AudioContext:maxChannelCount", "type": "uint" }, + { "property": "webGl:renderer", "type": "str" }, + { "property": "webGl:vendor", "type": "str" }, + { "property": "webGl:supportedExtensions", "type": "array" }, + { "property": "webGl2:supportedExtensions", "type": "array" }, + { "property": "webGl:parameters", "type": "dict" }, + { "property": "webGl:parameters:blockIfNotDefined", "type": "bool" }, + { "property": "webGl2:parameters", "type": "dict" }, + { "property": "webGl2:parameters:blockIfNotDefined", "type": "bool" }, + { "property": "webGl:shaderPrecisionFormats", "type": "dict" }, + { "property": "webGl:shaderPrecisionFormats:blockIfNotDefined", "type": "bool" }, + { "property": "webGl2:shaderPrecisionFormats", "type": "dict" }, + { "property": "webGl2:shaderPrecisionFormats:blockIfNotDefined", "type": "bool" }, + { "property": "webGl:contextAttributes", "type": "dict" }, + { "property": "webGl2:contextAttributes", "type": "dict" }, + { "property": "canvas:aaOffset", "type": "int" }, + { "property": "canvas:aaCapOffset", "type": "bool" }, + { "property": "voices", "type": "array" }, + { "property": "voices:blockIfNotDefined", "type": "bool" }, + { "property": "voices:fakeCompletion", "type": "bool" }, + { "property": "voices:fakeCompletion:charsPerSecond", "type": "double" }, + { "property": "mediaDevices:micros", "type": "uint" }, + { "property": "mediaDevices:webcams", "type": "uint" }, + { "property": "mediaDevices:speakers", "type": "uint" }, + { "property": "mediaDevices:enabled", "type": "bool" }, + { "property": "mediaDevices:microphoneLabels", "type": "array" }, + { "property": "mediaDevices:microphoneGroups", "type": "array" }, + { "property": "mediaDevices:webcamLabels", "type": "array" }, + { "property": "mediaDevices:webcamGroups", "type": "array" }, + { "property": "mediaDevices:speakerLabels", "type": "array" }, + { "property": "mediaDevices:speakerGroups", "type": "array" }, + { "property": "media:spoof_codecs", "type": "bool" }, + { "property": "allowMainWorld", "type": "bool" }, + { "property": "disableWorldIsolation", "type": "bool" }, + { "property": "allowAddonNewtab", "type": "bool" }, + { "property": "forceScopeAccess", "type": "bool" }, + + { "property": "disableTheming", "type": "bool" }, + { "property": "disableInstantAnimations", "type": "bool" }, + { "property": "memorysaver", "type": "bool" }, + { "property": "addons", "type": "array" }, + { "property": "certificatePaths", "type": "array" }, + { "property": "certificates", "type": "array" }, + { "property": "debug", "type": "bool" } +] diff --git a/typescript/tests/fixtures/launch/bundle-old/version.json b/typescript/tests/fixtures/launch/bundle-old/version.json new file mode 100644 index 000000000..6df35759d --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle-old/version.json @@ -0,0 +1 @@ +{"version": "152.0.4", "build": "beta.29"} diff --git a/typescript/tests/fixtures/launch/bundle/application.ini b/typescript/tests/fixtures/launch/bundle/application.ini new file mode 100644 index 000000000..5bc1f2012 --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle/application.ini @@ -0,0 +1,5 @@ +[App] +Vendor=Mozilla +Name=Camoufox +Version=152.0.4 +BuildID=20260914000000 diff --git a/typescript/tests/fixtures/launch/bundle/camoufox-bin b/typescript/tests/fixtures/launch/bundle/camoufox-bin new file mode 100755 index 000000000..b595c46bc --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle/camoufox-bin @@ -0,0 +1,3 @@ +#!/bin/sh +# placeholder executable for launch goldens +exit 1 diff --git a/typescript/tests/fixtures/launch/bundle/fontconfig/linux/fonts.conf b/typescript/tests/fixtures/launch/bundle/fontconfig/linux/fonts.conf new file mode 100644 index 000000000..c24dc466f --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle/fontconfig/linux/fonts.conf @@ -0,0 +1,7 @@ + + + + + fonts + fontconfig + diff --git a/typescript/tests/fixtures/launch/bundle/fontconfig/macos/fonts.conf b/typescript/tests/fixtures/launch/bundle/fontconfig/macos/fonts.conf new file mode 100644 index 000000000..489ef6a4b --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle/fontconfig/macos/fonts.conf @@ -0,0 +1,7 @@ + + + + + fonts + fontconfig + diff --git a/typescript/tests/fixtures/launch/bundle/fontconfig/windows/fonts.conf b/typescript/tests/fixtures/launch/bundle/fontconfig/windows/fonts.conf new file mode 100644 index 000000000..f2b1cd5fd --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle/fontconfig/windows/fonts.conf @@ -0,0 +1,7 @@ + + + + + fonts + fontconfig + diff --git a/typescript/tests/fixtures/launch/bundle/fonts/L/.gitkeep b/typescript/tests/fixtures/launch/bundle/fonts/L/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/tests/fixtures/launch/bundle/fonts/LM/.gitkeep b/typescript/tests/fixtures/launch/bundle/fonts/LM/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/tests/fixtures/launch/bundle/fonts/LMW/.gitkeep b/typescript/tests/fixtures/launch/bundle/fonts/LMW/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/tests/fixtures/launch/bundle/fonts/LW/.gitkeep b/typescript/tests/fixtures/launch/bundle/fonts/LW/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/tests/fixtures/launch/bundle/fonts/M/.gitkeep b/typescript/tests/fixtures/launch/bundle/fonts/M/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/tests/fixtures/launch/bundle/fonts/MW/.gitkeep b/typescript/tests/fixtures/launch/bundle/fonts/MW/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/tests/fixtures/launch/bundle/fonts/W/.gitkeep b/typescript/tests/fixtures/launch/bundle/fonts/W/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/typescript/tests/fixtures/launch/bundle/fonts/groups.json b/typescript/tests/fixtures/launch/bundle/fonts/groups.json new file mode 100644 index 000000000..92d5b4c3f --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle/fonts/groups.json @@ -0,0 +1,23 @@ +{ + "readBy": { + "lin": [ + "L", + "LM", + "LW", + "LMW" + ], + "mac": [ + "M", + "LM", + "MW", + "LMW", + "MX" + ], + "win": [ + "W", + "LW", + "MW", + "LMW" + ] + } +} diff --git a/typescript/tests/fixtures/launch/bundle/properties.json b/typescript/tests/fixtures/launch/bundle/properties.json new file mode 100644 index 000000000..078cdab73 --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle/properties.json @@ -0,0 +1,85 @@ +[ + { "property": "navigator.userAgent", "type": "str" }, + { "property": "navigator.doNotTrack", "type": "str" }, + { "property": "navigator.appVersion", "type": "str" }, + { "property": "navigator.oscpu", "type": "str" }, + { "property": "navigator.language", "type": "str" }, + { "property": "navigator.platform", "type": "str" }, + { "property": "navigator.hardwareConcurrency", "type": "uint" }, + { "property": "navigator.maxTouchPoints", "type": "uint" }, + { "property": "navigator.globalPrivacyControl", "type": "bool" }, + { "property": "navigator.buildID", "type": "str" }, + { "property": "screen.availHeight", "type": "uint" }, + { "property": "screen.availWidth", "type": "uint" }, + { "property": "screen.availTop", "type": "uint" }, + { "property": "screen.availLeft", "type": "uint" }, + { "property": "screen.height", "type": "uint" }, + { "property": "screen.width", "type": "uint" }, + { "property": "screen.colorDepth", "type": "uint" }, + { "property": "screen.pixelDepth", "type": "uint" }, + { "property": "window.outerHeight", "type": "uint" }, + { "property": "window.outerWidth", "type": "uint" }, + { "property": "window.innerHeight", "type": "uint" }, + { "property": "window.innerWidth", "type": "uint" }, + { "property": "window.screenX", "type": "int" }, + { "property": "window.screenY", "type": "int" }, + { "property": "window.devicePixelRatio", "type": "double" }, + { "property": "headers.User-Agent", "type": "str" }, + { "property": "headers.Accept-Language", "type": "str" }, + { "property": "headers.Accept-Encoding", "type": "str" }, + { "property": "webrtc:ipv4", "type": "str" }, + { "property": "webrtc:ipv6", "type": "str" }, + { "property": "fonts", "type": "array" }, + { "property": "audio:seed", "type": "uint" }, + { "property": "geolocation:latitude", "type": "double" }, + { "property": "geolocation:longitude", "type": "double" }, + { "property": "geolocation:accuracy", "type": "double" }, + { "property": "timezone", "type": "str" }, + { "property": "locale:language", "type": "str" }, + { "property": "locale:region", "type": "str" }, + { "property": "locale:script", "type": "str" }, + { "property": "locale:all", "type": "str" }, + { "property": "humanize", "type": "bool" }, + { "property": "humanize:maxTime", "type": "double" }, + { "property": "humanize:minTime", "type": "double" }, + { "property": "showcursor", "type": "bool" }, + { "property": "AudioContext:sampleRate", "type": "uint" }, + { "property": "AudioContext:outputLatency", "type": "double" }, + { "property": "AudioContext:maxChannelCount", "type": "uint" }, + { "property": "webGl:renderer", "type": "str" }, + { "property": "webGl:vendor", "type": "str" }, + { "property": "webGl:supportedExtensions", "type": "array" }, + { "property": "webGl2:supportedExtensions", "type": "array" }, + { "property": "webGl:parameters", "type": "dict" }, + { "property": "webGl2:parameters", "type": "dict" }, + { "property": "webGl:shaderPrecisionFormats", "type": "dict" }, + { "property": "webGl:shaderPrecisionFormats:blockIfNotDefined", "type": "bool" }, + { "property": "webGl2:shaderPrecisionFormats", "type": "dict" }, + { "property": "webGl2:shaderPrecisionFormats:blockIfNotDefined", "type": "bool" }, + { "property": "webGl:contextAttributes", "type": "dict" }, + { "property": "webGl2:contextAttributes", "type": "dict" }, + { "property": "voices", "type": "array" }, + { "property": "voices:blockIfNotDefined", "type": "bool" }, + { "property": "mediaDevices:micros", "type": "uint" }, + { "property": "mediaDevices:webcams", "type": "uint" }, + { "property": "mediaDevices:speakers", "type": "uint" }, + { "property": "mediaDevices:enabled", "type": "bool" }, + { "property": "mediaDevices:microphoneLabels", "type": "array" }, + { "property": "mediaDevices:microphoneGroups", "type": "array" }, + { "property": "mediaDevices:webcamLabels", "type": "array" }, + { "property": "mediaDevices:webcamGroups", "type": "array" }, + { "property": "mediaDevices:speakerLabels", "type": "array" }, + { "property": "mediaDevices:speakerGroups", "type": "array" }, + { "property": "media:spoof_codecs", "type": "bool" }, + { "property": "allowMainWorld", "type": "bool" }, + { "property": "disableWorldIsolation", "type": "bool" }, + { "property": "allowAddonNewtab", "type": "bool" }, + { "property": "forceScopeAccess", "type": "bool" }, + + { "property": "disableTheming", "type": "bool" }, + { "property": "instantAnimations", "type": "bool" }, + { "property": "addons", "type": "array" }, + { "property": "certificatePaths", "type": "array" }, + { "property": "certificates", "type": "array" }, + { "property": "debug", "type": "bool" } +] diff --git a/typescript/tests/fixtures/launch/bundle/version.json b/typescript/tests/fixtures/launch/bundle/version.json new file mode 100644 index 000000000..9d35085a2 --- /dev/null +++ b/typescript/tests/fixtures/launch/bundle/version.json @@ -0,0 +1 @@ +{"version": "152.0.4", "build": "beta.31"} diff --git a/typescript/tests/fixtures/launch/inputs.json b/typescript/tests/fixtures/launch/inputs.json new file mode 100644 index 000000000..38c18206d --- /dev/null +++ b/typescript/tests/fixtures/launch/inputs.json @@ -0,0 +1,533 @@ +{ + "fingerprints": { + "linux": { + "navigator": { + "appName": "Netscape", + "product": "Gecko", + "productSub": "20100101", + "appCodeName": "Mozilla", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + "languages": [ + "en-US", + "en" + ], + "language": "en-US", + "deviceMemory": "undefined", + "hardwareConcurrency": 8, + "platform": "Linux x86_64", + "appVersion": "5.0 (X11)", + "doNotTrack": "1", + "oscpu": "Linux x86_64", + "maxTouchPoints": 0 + }, + "screen": { + "availWidth": 1920, + "availHeight": 1080, + "availLeft": 0, + "availTop": 0, + "width": 1920, + "height": 1080, + "colorDepth": 24, + "pixelDepth": 24 + }, + "window": { + "innerWidth": 1916, + "innerHeight": 956, + "outerWidth": 1920, + "outerHeight": 1061, + "devicePixelRatio": 1, + "screenX": 0, + "screenY": 0, + "pageYOffset": 0 + }, + "headers": { + "accept-language": [ + "en-US,en;q=0.5" + ], + "accept-encoding": [ + "gzip, deflate, br, zstd" + ], + "accept": [ + "*/*" + ], + "priority": [ + "u=4" + ], + "sec-ch-ua": null, + "sec-ch-ua-mobile": null, + "sec-ch-ua-platform": null, + "sec-fetch-dest": [ + "empty" + ], + "sec-fetch-mode": [ + "cors" + ], + "sec-fetch-site": [ + "same-site" + ], + "sec-gpc": null, + "user-agent": [ + "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" + ] + } + }, + "windows": { + "navigator": { + "appName": "Netscape", + "product": "Gecko", + "productSub": "20100101", + "appCodeName": "Mozilla", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0) Gecko/20100101 Firefox/146.0", + "languages": [ + "en-GB", + "en" + ], + "language": "en-GB", + "deviceMemory": "undefined", + "hardwareConcurrency": 2, + "platform": "Win32", + "appVersion": "5.0 (Windows)", + "doNotTrack": "unspecified", + "oscpu": "Windows NT 10.0; Win64; x64", + "maxTouchPoints": 0 + }, + "screen": { + "availWidth": 1920, + "availHeight": 1032, + "availLeft": 0, + "availTop": 0, + "width": 1920, + "height": 1080, + "colorDepth": 24, + "pixelDepth": 24 + }, + "window": { + "innerWidth": 1722, + "innerHeight": 884, + "outerWidth": 1738, + "outerHeight": 978, + "devicePixelRatio": 1, + "screenX": 0, + "screenY": 4, + "pageYOffset": 0 + }, + "headers": { + "accept-language": [ + "en-GB,en;q=0.5" + ], + "accept-encoding": [ + "gzip, deflate, br, zstd" + ], + "accept": [ + "*/*" + ], + "priority": [ + "u=4" + ], + "sec-ch-ua": null, + "sec-ch-ua-mobile": null, + "sec-ch-ua-platform": null, + "sec-fetch-dest": [ + "empty" + ], + "sec-fetch-mode": [ + "cors" + ], + "sec-fetch-site": [ + "same-site" + ], + "sec-gpc": [ + "1" + ], + "user-agent": [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0) Gecko/20100101 Firefox/146.0" + ] + } + }, + "macos": { + "navigator": { + "appName": "Netscape", + "product": "Gecko", + "productSub": "20100101", + "appCodeName": "Mozilla", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0", + "languages": [ + "en-US", + "en" + ], + "language": "en-US", + "deviceMemory": "undefined", + "hardwareConcurrency": 10, + "platform": "MacIntel", + "appVersion": "5.0 (Macintosh)", + "doNotTrack": "1", + "oscpu": "Intel Mac OS X 10.15", + "maxTouchPoints": 0 + }, + "screen": { + "availWidth": 1512, + "availHeight": 882, + "availLeft": 0, + "availTop": 38, + "width": 1512, + "height": 982, + "colorDepth": 30, + "pixelDepth": 30 + }, + "window": { + "innerWidth": 1363, + "innerHeight": 841, + "outerWidth": 1512, + "outerHeight": 881, + "devicePixelRatio": 2, + "screenX": 0, + "screenY": 38, + "pageYOffset": 0 + }, + "headers": { + "accept-language": [ + "en-US,en;q=0.5" + ], + "accept-encoding": [ + "gzip, deflate, br, zstd" + ], + "accept": [ + "*/*" + ], + "priority": [ + "u=4" + ], + "sec-ch-ua": null, + "sec-ch-ua-mobile": null, + "sec-ch-ua-platform": null, + "sec-fetch-dest": [ + "empty" + ], + "sec-fetch-mode": [ + "cors" + ], + "sec-fetch-site": [ + "same-site" + ], + "sec-gpc": null, + "user-agent": [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0" + ] + } + } + }, + "presets": { + "windows": { + "navigator": { + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "Win32", + "hardwareConcurrency": 12, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1920, + "height": 1080, + "colorDepth": 24, + "availWidth": 1920, + "availHeight": 1032, + "devicePixelRatio": 1 + }, + "webgl": { + "unmaskedVendor": "Google Inc. (Intel)", + "unmaskedRenderer": "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar" + }, + "speechVoices": [ + "Microsoft David - English (United States):en-US:local", + "Microsoft Mark - English (United States):en-US:local", + "Microsoft Zira - English (United States):en-US:local", + "Microsoft David Desktop - English (United States):en-US:local", + "Microsoft Zira Desktop - English (United States):en-US:local" + ] + }, + "macos": { + "navigator": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "MacIntel", + "hardwareConcurrency": 10, + "maxTouchPoints": 0 + }, + "screen": { + "width": 2240, + "height": 1260, + "colorDepth": 30, + "availWidth": 2240, + "availHeight": 1230, + "devicePixelRatio": 2 + }, + "webgl": { + "unmaskedVendor": "Apple", + "unmaskedRenderer": "Apple M1, or similar" + }, + "speechVoices": [ + "Albert:en-US:local", + "Alice:it-IT:local", + "Soumya:kn-IN:local", + "Alva:sv-SE:local", + "Aman:en-IN:local", + "Amรฉlie:fr-CA:local", + "Amira:ms-MY:local", + "Anna:de-DE:local", + "Aru:kk-KZ:local", + "Bad News:en-US:local", + "Bahh:en-US:local", + "Bells:en-US:local", + "Boing:en-US:local", + "Bubbles:en-US:local", + "Carmit:he-IL:local", + "Cellos:en-US:local", + "Damayanti:id-ID:local", + "Daniel:en-GB:local", + "Daria:bg-BG:local", + "Wobble:en-US:local", + "Eddy (German (Germany)):de-DE:local", + "Eddy (English (UK)):en-GB:local", + "Eddy (English (US)):en-US:local", + "Eddy (Spanish (Spain)):es-ES:local", + "Eddy (Spanish (Mexico)):es-MX:local", + "Eddy (Finnish (Finland)):fi-FI:local", + "Eddy (French (Canada)):fr-CA:local", + "Eddy (French (France)):fr-FR:local", + "Eddy (Italian (Italy)):it-IT:local", + "Eddy (Japanese (Japan)):ja-JP:local", + "Eddy (Korean (South Korea)):ko-KR:local", + "Eddy (Portuguese (Brazil)):pt-BR:local", + "Eddy (Chinese (China mainland)):zh-CN:local", + "Eddy (Chinese (Taiwan)):zh-TW:local", + "Ellen:nl-BE:local", + "Flo (German (Germany)):de-DE:local", + "Flo (English (UK)):en-GB:local", + "Flo (English (US)):en-US:local", + "Flo (Spanish (Spain)):es-ES:local", + "Flo (Spanish (Mexico)):es-MX:local", + "Flo (Finnish (Finland)):fi-FI:local", + "Flo (French (Canada)):fr-CA:local", + "Flo (French (France)):fr-FR:local", + "Flo (Italian (Italy)):it-IT:local", + "Flo (Japanese (Japan)):ja-JP:local", + "Flo (Korean (South Korea)):ko-KR:local", + "Flo (Portuguese (Brazil)):pt-BR:local", + "Flo (Chinese (China mainland)):zh-CN:local", + "Flo (Chinese (Taiwan)):zh-TW:local", + "Fred:en-US:local", + "Geeta:te-IN:local", + "Good News:en-US:local", + "Grandma (German (Germany)):de-DE:local", + "Grandma (English (UK)):en-GB:local", + "Grandma (English (US)):en-US:local", + "Grandma (Spanish (Spain)):es-ES:local", + "Grandma (Spanish (Mexico)):es-MX:local", + "Grandma (Finnish (Finland)):fi-FI:local", + "Grandma (French (Canada)):fr-CA:local", + "Grandma (French (France)):fr-FR:local", + "Grandma (Italian (Italy)):it-IT:local", + "Grandma (Japanese (Japan)):ja-JP:local", + "Grandma (Korean (South Korea)):ko-KR:local", + "Grandma (Portuguese (Brazil)):pt-BR:local", + "Grandma (Chinese (China mainland)):zh-CN:local", + "Grandma (Chinese (Taiwan)):zh-TW:local", + "Grandpa (German (Germany)):de-DE:local", + "Grandpa (English (UK)):en-GB:local", + "Grandpa (English (US)):en-US:local", + "Grandpa (Spanish (Spain)):es-ES:local", + "Grandpa (Spanish (Mexico)):es-MX:local", + "Grandpa (Finnish (Finland)):fi-FI:local", + "Grandpa (French (Canada)):fr-CA:local", + "Grandpa (French (France)):fr-FR:local", + "Grandpa (Italian (Italy)):it-IT:local", + "Grandpa (Japanese (Japan)):ja-JP:local", + "Grandpa (Korean (South Korea)):ko-KR:local", + "Grandpa (Portuguese (Brazil)):pt-BR:local", + "Grandpa (Chinese (China mainland)):zh-CN:local", + "Grandpa (Chinese (Taiwan)):zh-TW:local", + "Jester:en-US:local", + "Ioana:ro-RO:local", + "Jacques:fr-FR:local", + "Joana:pt-PT:local", + "Junior:en-US:local", + "Kanya:th-TH:local", + "Karen:en-AU:local", + "Kathy:en-US:local", + "Kyoko:ja-JP:local", + "Lana:hr-HR:local", + "Laura:sk-SK:local", + "Lekha:hi-IN:local", + "Lesya:uk-UA:local", + "Linh:vi-VN:local", + "Luciana:pt-BR:local", + "Majed:ar-001:local", + "Tรผnde:hu-HU:local", + "Meijia:zh-TW:local", + "Melina:el-GR:local", + "Milena:ru-RU:local", + "Moira:en-IE:local", + "Mรณnica:es-ES:local", + "Montse:ca-ES:local", + "Nora:nb-NO:local", + "Ona:lt-LT:local", + "Organ:en-US:local", + "Paulina:es-MX:local", + "Piya:bn-IN:local", + "Superstar:en-US:local", + "Ralph:en-US:local", + "Reed (German (Germany)):de-DE:local", + "Reed (English (UK)):en-GB:local", + "Reed (English (US)):en-US:local", + "Reed (Spanish (Spain)):es-ES:local", + "Reed (Spanish (Mexico)):es-MX:local", + "Reed (Finnish (Finland)):fi-FI:local", + "Reed (French (Canada)):fr-CA:local", + "Reed (Italian (Italy)):it-IT:local", + "Reed (Japanese (Japan)):ja-JP:local", + "Reed (Korean (South Korea)):ko-KR:local", + "Reed (Portuguese (Brazil)):pt-BR:local", + "Reed (Chinese (China mainland)):zh-CN:local", + "Reed (Chinese (Taiwan)):zh-TW:local", + "Rishi:en-IN:local", + "Rocko (German (Germany)):de-DE:local", + "Rocko (English (UK)):en-GB:local", + "Rocko (English (US)):en-US:local", + "Rocko (Spanish (Spain)):es-ES:local", + "Rocko (Spanish (Mexico)):es-MX:local", + "Rocko (Finnish (Finland)):fi-FI:local", + "Rocko (French (Canada)):fr-CA:local", + "Rocko (French (France)):fr-FR:local", + "Rocko (Italian (Italy)):it-IT:local", + "Rocko (Japanese (Japan)):ja-JP:local", + "Rocko (Korean (South Korea)):ko-KR:local", + "Rocko (Portuguese (Brazil)):pt-BR:local", + "Rocko (Chinese (China mainland)):zh-CN:local", + "Rocko (Chinese (Taiwan)):zh-TW:local", + "Samantha:en-US:local", + "Sandy (German (Germany)):de-DE:local", + "Sandy (English (UK)):en-GB:local", + "Sandy (English (US)):en-US:local", + "Sandy (Spanish (Spain)):es-ES:local", + "Sandy (Spanish (Mexico)):es-MX:local", + "Sandy (Finnish (Finland)):fi-FI:local", + "Sandy (French (Canada)):fr-CA:local", + "Sandy (French (France)):fr-FR:local", + "Sandy (Italian (Italy)):it-IT:local", + "Sandy (Japanese (Japan)):ja-JP:local", + "Sandy (Korean (South Korea)):ko-KR:local", + "Sandy (Portuguese (Brazil)):pt-BR:local", + "Sandy (Chinese (China mainland)):zh-CN:local", + "Sandy (Chinese (Taiwan)):zh-TW:local", + "Sara:da-DK:local", + "Satu:fi-FI:local", + "Shelley (German (Germany)):de-DE:local", + "Shelley (English (UK)):en-GB:local", + "Shelley (English (US)):en-US:local", + "Shelley (Spanish (Spain)):es-ES:local", + "Shelley (Spanish (Mexico)):es-MX:local", + "Shelley (Finnish (Finland)):fi-FI:local", + "Shelley (French (Canada)):fr-CA:local", + "Shelley (French (France)):fr-FR:local", + "Shelley (Italian (Italy)):it-IT:local", + "Shelley (Japanese (Japan)):ja-JP:local", + "Shelley (Korean (South Korea)):ko-KR:local", + "Shelley (Portuguese (Brazil)):pt-BR:local", + "Shelley (Chinese (China mainland)):zh-CN:local", + "Shelley (Chinese (Taiwan)):zh-TW:local", + "Sinji:zh-HK:local", + "Tara:en-IN:local", + "Tessa:en-ZA:local", + "Thomas:fr-FR:local", + "Tina:sl-SI:local", + "Tingting:zh-CN:local", + "Trinoids:en-US:local", + "Vani:ta-IN:local", + "Whisper:en-US:local", + "Xander:nl-NL:local", + "Yelda:tr-TR:local", + "Yuna:ko-KR:local", + "Zarvox:en-US:local", + "Zosia:pl-PL:local", + "Zuzana:cs-CZ:local" + ] + }, + "linux": { + "navigator": { + "userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "Linux x86_64", + "hardwareConcurrency": 12, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1920, + "height": 1200, + "colorDepth": 24, + "availWidth": 1920, + "availHeight": 1200, + "devicePixelRatio": 1 + }, + "webgl": { + "unmaskedVendor": "Intel", + "unmaskedRenderer": "Intel(R) HD Graphics, or similar" + } + }, + "windows_unknown_gpu": { + "navigator": { + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0", + "platform": "Win32", + "hardwareConcurrency": 2, + "maxTouchPoints": 0 + }, + "screen": { + "width": 1366, + "height": 768, + "colorDepth": 24, + "availWidth": 1366, + "availHeight": 728, + "devicePixelRatio": 1 + }, + "webgl": { + "unmaskedVendor": "Google Inc. (AMD)", + "unmaskedRenderer": "ANGLE (AMD, Radeon R9 200 Series Direct3D11 vs_4_0 ps_4_0), or similar" + }, + "speechVoices": [ + "Microsoft Helena - Spanish (Spain):es-ES:local", + "Microsoft Laura - Spanish (Spain):es-ES:local", + "Microsoft Pablo - Spanish (Spain):es-ES:local", + "Microsoft Helena Desktop - Spanish (Spain):es-ES:local", + "Microsoft Zira Desktop - English (United States):en-US:local" + ] + } + }, + "webgl_pairs": { + "win": [ + [ + "Google Inc. (NVIDIA)", + "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar" + ], + [ + "Google Inc. (Intel)", + "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar" + ] + ], + "mac": [ + [ + "Apple", + "Apple M1, or similar" + ], + [ + "Intel Inc.", + "Intel(R) HD Graphics, or similar" + ] + ], + "lin": [ + [ + "Intel", + "Intel(R) HD Graphics, or similar" + ], + [ + "NVIDIA Corporation", + "NVIDIA GeForce GTX 980, or similar" + ] + ] + } +} diff --git a/typescript/tests/fpgen-golden.test.ts b/typescript/tests/fpgen-golden.test.ts new file mode 100644 index 000000000..e5965bee1 --- /dev/null +++ b/typescript/tests/fpgen-golden.test.ts @@ -0,0 +1,257 @@ +/** + * The deterministic half of fpgen: everything that does not depend on the + * random draw must be IDENTICAL to Python fpgen 1.3.0 on the same pinned model. + * Fixtures: scripts/golden/fpgen_golden.py. + * + * - the parsed network (nodes, parents, value ids, ancestor sets, and a digest + * of every probability table in document order, bit for bit); + * - the value store (offset table, base85 ids, and every referenced value); + * - condition handling: conditions -> evidence, including nested paths, + * predicates, alternatives, casefolding, and the errors it raises; + * - beam-search distributions (NETWORK.trace), float for float, including + * cases that prune the beam; + * - the public trace(), query() and draw-independent generate() outputs. + */ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + Generator, + getModel, + query, + type TraceResult, + trace, +} from "../src/fpgen/index.js"; +import { base85ToInt } from "../src/fpgen/pyjson.js"; +import { buildEvidence, type EvidenceMap } from "../src/fpgen/utils.js"; +import { fixture, fromFixture, MODEL } from "./fpgen-setup.js"; + +const sha = (s: string) => + createHash("sha256").update(s, "utf-8").digest("hex"); + +function floatHex(x: number): string { + const b = Buffer.alloc(8); + b.writeDoubleBE(x); + return b.toString("hex"); +} + +/** Same canonical form as cpt_canonical() in the golden script. */ +function cptCanonical(o: unknown): string { + if (o instanceof Map) { + return `{${[...o].map(([k, v]) => `${JSON.stringify(k)}:${cptCanonical(v)}`).join(",")}}`; + } + return floatHex(o as number); +} + +function evidenceFixture(evidence: EvidenceMap): [string, string[]][] { + return [...evidence].map(([k, v]) => [ + k, + [...v].sort((a, b) => base85ToInt(a) - base85ToInt(b)), + ]); +} + +function plainTrace(res: unknown): unknown { + if (Array.isArray(res)) { + return res.map((r: TraceResult) => ({ + value: r.value, + probability: r.probability, + })); + } + return Object.fromEntries( + Object.entries(res as object).map(([k, v]) => [k, plainTrace(v)]), + ); +} + +/** Python prints a lambda's address; compare the rest of the message. */ +const normFn = (m: string) => m.replace(/\(\)/g, "()"); + +describe.skipIf(!MODEL.ok)("fpgen network structure == Python", () => { + const golden = fixture("structure.json"); + + it("is built from the pinned model", () => { + expect(golden.pin).toBe( + "6530b8322cdaa4ec042921c8d9a0369a0e6e0269ba636c01a7203e4a2f109936", + ); + }); + + it("has the same nodes, parents, value ids, ancestors and tables", () => { + const { network } = getModel(); + expect(network.nodeNames).toEqual(golden.nodeNames); + expect(network.nodesInSamplingOrder.length).toBe(golden.nodes.length); + network.nodesInSamplingOrder.forEach((node, i) => { + const g = golden.nodes[i]; + expect(node.name).toBe(g.name); + expect(node.parentNames).toEqual(g.parentNames); + expect(node.possibleValues).toEqual(g.possibleValues); + expect([...network.getAllAncestors(node.name)].sort()).toEqual( + g.ancestors, + ); + // Order-sensitive digest: catches a table re-ordered by a JS object. + expect(sha(cptCanonical(node.probabilities)), node.name).toBe( + g.cptSha256, + ); + }); + }); + + it("looks nodes up case-insensitively", () => { + const { network } = getModel(); + expect(network.nodesByName.get("NAVIGATOR.USERAGENT")?.name).toBe( + "navigator.userAgent", + ); + }); + + it("reads the same value offset table", () => { + const { valuePairs } = getModel(); + expect(valuePairs.length).toBe(golden.valuePairs.count); + expect(sha(valuePairs.map(([o, n]) => `${o}:${n};`).join(""))).toBe( + golden.valuePairs.sha256, + ); + }); + + it("decodes base85 value ids identically", () => { + const { network } = getModel(); + const ids = [ + ...new Set(network.nodesInSamplingOrder.flatMap((n) => n.possibleValues)), + ].sort((a, b) => base85ToInt(a) - base85ToInt(b)); + expect(ids.length).toBe(golden.base85.count); + expect(sha(ids.map((i) => `${i}=${base85ToInt(i)};`).join(""))).toBe( + golden.base85.sha256, + ); + for (const [id, n] of Object.entries(golden.base85.samples)) { + expect(base85ToInt(id)).toBe(n); + } + }); +}); + +describe.skipIf(!MODEL.ok)("fpgen value store == Python", () => { + const golden = fixture("values.json"); + + it("returns the same text for sampled ids", () => { + const model = getModel(); + const ids = golden.samples.map((s: any) => s.id); + const texts = model.lookupValueList(ids); + golden.samples.forEach((s: any, i: number) => { + expect(Buffer.byteLength(texts[i]), s.id).toBe(s.length); + expect(sha(texts[i]), s.id).toBe(s.sha256); + if (s.text !== undefined) expect(texts[i]).toBe(s.text); + }); + expect(model.lookupValue(ids[0])).toBe(texts[0]); + }); + + it("returns the same text for every referenced value", () => { + const model = getModel(); + const ids = [ + ...new Set( + model.network.nodesInSamplingOrder.flatMap((n) => n.possibleValues), + ), + ].sort((a, b) => base85ToInt(a) - base85ToInt(b)); + expect(ids.length).toBe(golden.idCount); + const h = createHash("sha256"); + for (let n = 0; n < ids.length; n += 500) { + for (const t of model.lookupValueList(ids.slice(n, n + 500))) { + h.update(sha(t)); + } + } + expect(h.digest("hex")).toBe(golden.allIdsDigest); + }, 60_000); +}); + +describe.skipIf(!MODEL.ok)("fpgen conditions and beam search == Python", () => { + const golden = fixture("conditions.json"); + + it("covers beam pruning", () => { + const pruned = golden.cases.reduce((a: number, c: any) => a + c.pruned, 0); + expect(pruned).toBeGreaterThan(0); + }); + + for (const c of golden.cases) { + it(`${c.name}: same evidence and bit-identical distributions`, () => { + const evidence: EvidenceMap = new Map(); + buildEvidence(fromFixture(c.conditions), evidence); + expect(evidenceFixture(evidence)).toEqual(c.evidence); + + const { network } = getModel(); + for (const [target, dist] of Object.entries(c.traces)) { + // Same keys, same order, same doubles. + expect([...network.trace(target, evidence)], target).toEqual(dist); + } + }); + } + + for (const e of golden.errors) { + it(`${e.name}: ${e.error ?? "relaxed"}`, () => { + const evidence: EvidenceMap = new Map(); + const run = () => + buildEvidence(fromFixture(e.conditions), evidence, e.strict); + if (e.error) { + let caught: Error | undefined; + try { + run(); + } catch (err) { + caught = err as Error; + } + expect(caught?.name).toBe(e.error); + expect(normFn(caught?.message ?? "")).toBe(normFn(e.message)); + } else { + run(); + expect(evidenceFixture(evidence)).toEqual(e.evidence); + } + }); + } +}); + +describe.skipIf(!MODEL.ok)("fpgen public API == Python", () => { + const golden = fixture("api.json"); + + for (const t of golden.traces) { + it(`trace(): ${t.name}`, () => { + const res = trace(t.target, fromFixture(t.conditions), t.options); + expect(plainTrace(res)).toEqual(t.result); + }); + } + + it("Generator.trace() inherits the generator's conditions", () => { + const g = new Generator({ browser: "Firefox", os: "Windows" }); + expect(plainTrace(g.trace("navigator.platform"))).toEqual( + golden.generatorTrace, + ); + }); + + for (const q of golden.queries) { + it(`query(${q.target}, ${JSON.stringify(q.options)})`, () => { + expect(query(q.target, q.options)).toEqual(q.result); + }); + } + + for (const q of golden.queryErrors) { + it(`query(${q.target}) raises ${q.error}`, () => { + expect(() => query(q.target)).toThrow( + expect.objectContaining({ name: q.error, message: q.message }), + ); + }); + } + + for (const d of golden.deterministic) { + it(`generate(): ${d.name}`, () => { + for (let i = 0; i < 10; i++) { + expect(new Generator().generate(d.conditions, d.options)).toEqual( + d.result, + ); + } + }); + } + + it("produces the same output shape", () => { + const g = new Generator({ browser: "Firefox" }); + expect(Object.keys(g.generate()).sort()).toEqual( + golden.shapes.topLevelKeys, + ); + expect( + Object.keys(g.generate(null, { target: "navigator" })).sort(), + ).toEqual(golden.shapes.navigatorKeys); + expect( + Object.keys(g.generate(null, { flatten: true })) + .filter((k) => k.startsWith("navigator.")) + .sort(), + ).toEqual(golden.shapes.flatKeysSample); + }); +}); diff --git a/typescript/tests/fpgen-model.test.ts b/typescript/tests/fpgen-model.test.ts new file mode 100644 index 000000000..27ac04c68 --- /dev/null +++ b/typescript/tests/fpgen-model.test.ts @@ -0,0 +1,344 @@ +/** + * fpgen model handling -- the part deliberately NOT ported from fpgen: the pin, + * the sha256 gate, safe extraction, where the files live -- plus the Python + * semantics helpers and a few behaviours worth pinning down explicitly. + */ + +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import AdmZip from "adm-zip"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ensureModel, + Generator, + getModel, + InvalidConstraints, + installArchive, + isModelInstalled, + MODEL_PIN, + ModelNotInstalled, + type ModelPin, + ModelVerificationError, + modelDir, + query, + verifyArchive, +} from "../src/fpgen/index.js"; +import { + LOCK_DIR, + STALE_LOCK_MS, + withInstallLock, +} from "../src/fpgen/model.js"; +import { + casefold, + PyFloat, + parseOrdered, + parsePyTyped, + pyEquals, +} from "../src/fpgen/pyjson.js"; +import { MODEL } from "./fpgen-setup.js"; +import { prerequisite } from "./prereq.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_PIN = path.resolve(HERE, "../../scripts/data/fpgen-model.json"); + +const tmpDirs: string[] = []; +function tmpDir(): string { + const d = fs.mkdtempSync(path.join(os.tmpdir(), "fpgen-test-")); + tmpDirs.push(d); + return d; +} +afterEach(() => { + for (const d of tmpDirs.splice(0)) + fs.rmSync(d, { recursive: true, force: true }); +}); + +function pinFor(buf: Buffer, files: string[]): ModelPin { + return { + ...MODEL_PIN, + size: buf.length, + sha256: createHash("sha256").update(buf).digest("hex"), + files, + }; +} + +describe("fpgen model pin", () => { + it.skipIf(!prerequisite("repo-pin", fs.existsSync(REPO_PIN), REPO_PIN))( + "matches scripts/data/fpgen-model.json (bump both together)", + () => { + const repo = JSON.parse(fs.readFileSync(REPO_PIN, "utf-8")); + const { note: _note, ...fields } = repo; + expect({ ...MODEL_PIN, files: [...MODEL_PIN.files] }).toEqual(fields); + }, + ); + + it("downloads over https from the pinned tag", () => { + expect(MODEL_PIN.url.startsWith("https://github.com/")).toBe(true); + expect(MODEL_PIN.url).toContain(`/download/${MODEL_PIN.tag}/`); + }); + + it("rejects an archive of the wrong size or digest", () => { + expect(() => verifyArchive(Buffer.from("nope"))).toThrow( + ModelVerificationError, + ); + const sameSize = Buffer.alloc(MODEL_PIN.size); + expect(() => verifyArchive(sameSize)).toThrow(/sha256 mismatch/); + }); + + it("ensureModel refuses a tampered download and installs nothing", async () => { + const dir = tmpDir(); + const evil = Buffer.alloc(MODEL_PIN.size, 7); + const fetchImpl = (async () => + new Response(evil, { status: 200 })) as unknown as typeof fetch; + await expect(ensureModel({ dir, fetchImpl })).rejects.toThrow( + ModelVerificationError, + ); + expect(fs.readdirSync(dir)).toEqual([]); + expect(() => getModel(dir)).toThrow(ModelNotInstalled); + }); + + it("does not retry a 4xx", async () => { + let calls = 0; + const fetchImpl = (async () => { + calls++; + return new Response("gone", { status: 404 }); + }) as unknown as typeof fetch; + await expect(ensureModel({ dir: tmpDir(), fetchImpl })).rejects.toThrow( + /HTTP 404/, + ); + expect(calls).toBe(1); + }); + + it("extracts only the pinned members, and stamps last", () => { + const zip = new AdmZip(); + zip.addFile("a.zst", Buffer.from("A")); + zip.addFile("b.zst", Buffer.from("B")); + zip.addFile("../escape.txt", Buffer.from("x")); + zip.addFile("extra.txt", Buffer.from("x")); + const buf = zip.toBuffer(); + const pin = pinFor(buf, ["a.zst", "b.zst"]); + const parent = tmpDir(); + const dir = path.join(parent, "model"); + installArchive(buf, dir, pin); + expect(fs.readdirSync(dir).sort()).toEqual([ + ".pinned-model", + "a.zst", + "b.zst", + ]); + expect(fs.existsSync(path.join(parent, "escape.txt"))).toBe(false); + expect(isModelInstalled(dir, pin)).toBe(true); + expect(isModelInstalled(dir)).toBe(false); // stamp is the other pin's + }); + + it("fails on an archive missing a pinned member, without a stamp", () => { + const zip = new AdmZip(); + zip.addFile("a.zst", Buffer.from("A")); + const buf = zip.toBuffer(); + const dir = tmpDir(); + expect(() => + installArchive(buf, dir, pinFor(buf, ["a.zst", "b.zst"])), + ).toThrow(/missing b\.zst/); + expect(fs.existsSync(path.join(dir, ".pinned-model"))).toBe(false); + }); + + // Several processes installing into an empty cache at once: one's install + // deleted the values.dat another had just decompressed and was reading. + it("a same-model reinstall keeps values.dat; a different model drops it", () => { + const archive = (tag: string) => { + const zip = new AdmZip(); + zip.addFile("a.zst", Buffer.from(tag)); + return zip.toBuffer(); + }; + const dir = tmpDir(); + const dat = path.join(dir, "values.dat"); + const first = archive("one"); + installArchive(first, dir, pinFor(first, ["a.zst"])); + fs.writeFileSync(dat, "decompressed"); + installArchive(first, dir, pinFor(first, ["a.zst"])); + expect(fs.existsSync(dat)).toBe(true); + const second = archive("two"); + installArchive(second, dir, pinFor(second, ["a.zst"])); + expect(fs.existsSync(dat)).toBe(false); + }); + + it("the install lock admits one holder at a time, and releases", async () => { + const dir = tmpDir(); + const events: string[] = []; + const holder = (name: string, ms: number) => + withInstallLock(dir, async () => { + events.push(`${name}+`); + await new Promise((r) => setTimeout(r, ms)); + events.push(`${name}-`); + return name; + }); + // mkdir is the lock, so this is the same exclusion another process gets. + const results = await Promise.all([holder("a", 300), holder("b", 10)]); + expect(results).toEqual(["a", "b"]); + expect(events).toEqual(["a+", "a-", "b+", "b-"]); + expect(fs.existsSync(path.join(dir, LOCK_DIR))).toBe(false); + // ...and a holder that throws still releases it. + await expect( + withInstallLock(dir, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(fs.existsSync(path.join(dir, LOCK_DIR))).toBe(false); + }); + + it("reclaims a lock left by a process that died holding it", async () => { + const dir = tmpDir(); + const lock = path.join(dir, LOCK_DIR); + fs.mkdirSync(lock); + const old = (Date.now() - STALE_LOCK_MS - 60_000) / 1000; + fs.utimesSync(lock, old, old); + await expect(withInstallLock(dir, async () => "ok")).resolves.toBe("ok"); + }); + + it("honours CAMOUFOX_FPGEN_DATA", () => { + const old = process.env.CAMOUFOX_FPGEN_DATA; + try { + const dir = tmpDir(); + process.env.CAMOUFOX_FPGEN_DATA = dir; + expect(modelDir()).toBe(dir); + // Nothing there: the synchronous API says so instead of fetching. + expect(() => new Generator().generate()).toThrow(ModelNotInstalled); + expect(() => query("os")).toThrow(ModelNotInstalled); + } finally { + if (old === undefined) delete process.env.CAMOUFOX_FPGEN_DATA; + else process.env.CAMOUFOX_FPGEN_DATA = old; + } + expect(modelDir()).not.toBe(""); + }); +}); + +describe.skipIf(!MODEL.ok)("fpgen model on disk", () => { + it("is installed and pinned", () => { + expect(isModelInstalled()).toBe(true); + expect( + fs.readFileSync(path.join(modelDir(), ".pinned-model"), "utf-8").trim(), + ).toBe(MODEL_PIN.sha256); + }); + + it("decompresses values.dat on first synchronous load", () => { + const src = modelDir(); + const dir = tmpDir(); + for (const f of [...MODEL_PIN.files, ".pinned-model"]) { + fs.copyFileSync(path.join(src, f), path.join(dir, f)); + } + expect(fs.existsSync(path.join(dir, "values.dat"))).toBe(false); + const fresh = getModel(dir); + const cached = getModel(); + const ids = cached.network.nodesInSamplingOrder[7].possibleValues; + expect(fresh.lookupValueList(ids)).toEqual(cached.lookupValueList(ids)); + expect(fs.statSync(path.join(dir, "values.dat")).size).toBe( + fs.statSync(path.join(src, "values.dat")).size, + ); + }, 60_000); +}); + +describe.skipIf(!MODEL.ok)("fpgen behaviours kept from Python", () => { + it("hands predicates the CASEFOLDED value, as Python does", () => { + // So a predicate written against "Linux" matches nothing -- in Python + // too (this is why camoufox's multi-OS lambda must compare lowercase). + expect(() => + new Generator().generate({ os: (v: string) => v === "Linux" }), + ).toThrow(InvalidConstraints); + const seen: unknown[] = []; + new Generator().generate( + { + os: (v: string) => { + seen.push(v); + return v === "linux"; + }, + }, + { target: "os" }, + ); + expect(seen.sort()).toEqual(["chromeos", "linux", "macos", "windows"]); + }); + + it("names errors like Python, so callers can tell them apart", () => { + try { + new Generator().generate({ os: "Plan9" }); + } catch (e) { + expect((e as Error).name).toBe("InvalidConstraints"); + return; + } + throw new Error("expected InvalidConstraints"); + }); + + it("relaxes the first condition when strict is off", () => { + const chromeUa = (query("navigator.userAgent") as string[]).find( + (u) => u.includes("Chrome/") && !u.includes("Firefox"), + ) as string; + const g = new Generator(); + expect(() => + g.generate({ browser: "Firefox", "navigator.userAgent": chromeUa }), + ).toThrow(/is impossible with constraint/); + const fp = g.generate( + { browser: "Firefox", "navigator.userAgent": chromeUa }, + { strict: false }, + ); + expect(fp.navigator.userAgent).toBe(chromeUa); + expect(fp.browser).not.toBe("Firefox"); + }); + + it("takes a Set as alternatives and an array as one value", () => { + const g = new Generator(); + for (let i = 0; i < 20; i++) { + expect(["Linux", "Windows"]).toContain( + g.generate({ os: new Set(["Linux", "Windows"]) }, { target: "os" }), + ); + } + expect(() => g.generate({ os: ["Linux", "Windows"] })).toThrow( + InvalidConstraints, + ); + }); + + it("ignores undefined conditions", () => { + expect( + new Generator().generate( + { browser: "Firefox", os: undefined }, + { target: "navigator.appName" }, + ), + ).toBe("Netscape"); + }); +}); + +describe("fpgen Python-semantics helpers", () => { + it("parseOrdered keeps document order, integer-like keys included", () => { + const m = parseOrdered('{"b":1,"10":2,"a":{"2":3,"1":4},"0":5}') as Map< + string, + any + >; + expect([...m.keys()]).toEqual(["b", "10", "a", "0"]); + expect([...m.get("a").keys()]).toEqual(["2", "1"]); + expect(parseOrdered('{"k:\\"x\\":":"v:1"}')).toEqual( + new Map([['k:"x":', "v:1"]]), + ); + }); + + it("parsePyTyped keeps floats apart from ints", () => { + const v = parsePyTyped('{"a":1.0,"b":1,"c":[2.5e3,"1.0"]}') as any; + expect(v.a).toBeInstanceOf(PyFloat); + expect(v.b).toBe(1); + expect(v.c[0]).toEqual(new PyFloat(2500)); + expect(v.c[1]).toBe("1.0"); + }); + + it("pyEquals follows Python ==", () => { + expect(pyEquals(1, new PyFloat(1))).toBe(true); + expect(pyEquals(true, 1)).toBe(true); + expect(pyEquals({ a: 1, b: [1, 2] }, { b: [1, 2], a: 1 })).toBe(true); + expect(pyEquals([1, 2], [2, 1])).toBe(false); + expect(pyEquals("1", 1)).toBe(false); + expect(pyEquals(null, 0)).toBe(false); + }); + + it("casefold matches str.casefold on the cases that differ from lower()", () => { + expect(casefold("MacOS")).toBe("macos"); + expect(casefold("StraรŸe")).toBe("strasse"); + expect(casefold("ฮฃฮŠฮฃฮฅฮฆฮŸฯ‚")).toBe("ฯƒฮฏฯƒฯ…ฯ†ฮฟฯƒ"); + }); +}); diff --git a/typescript/tests/fpgen-setup.ts b/typescript/tests/fpgen-setup.ts new file mode 100644 index 000000000..0b020f57f --- /dev/null +++ b/typescript/tests/fpgen-setup.ts @@ -0,0 +1,59 @@ +/** + * Shared setup for the fpgen tests: the model, the Python golden fixtures, and + * the named predicates they refer to. + * + * The model is the pinned one (src/fpgen/pin.ts), fetched into the Camoufox + * cache on first run -- or read from $CAMOUFOX_FPGEN_DATA. When it is neither + * cached nor downloadable (offline CI), the model-dependent suites skip with + * the reason printed rather than failing. + * + * Fixtures come from scripts/golden/fpgen_golden.py (run with the worktree's + * .venv, which has fpgen 1.3.0 and the same pinned model). + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ensureModel } from "../src/fpgen/index.js"; +import { prerequisite } from "./prereq.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const FIXTURES = path.join(HERE, "fixtures", "fpgen"); + +export function fixture(name: string): T { + return JSON.parse(fs.readFileSync(path.join(FIXTURES, name), "utf-8")); +} + +async function prepare(): Promise<{ ok: boolean; reason?: string }> { + try { + await ensureModel(); + return { ok: true }; + } catch (e) { + const reason = `not cached and could not be downloaded: ${(e as Error).message}`; + prerequisite("fpgen-model", false, reason); + return { ok: false, reason }; + } +} + +/** Resolved once per test file (vitest isolates files). */ +export const MODEL = await prepare(); + +/** The predicates named in the fixtures. Python passes them casefolded values. */ +export const PREDICATES: Record boolean> = { + screen_width_1280_1920: (w) => Number.isInteger(w) && w >= 1280 && w <= 1920, + os_linux_or_windows: (v) => v === "linux" || v === "windows", + hc_at_least_8: (v) => Number.isInteger(v) && v >= 8, + ua_rv146: (v) => typeof v === "string" && v.includes("rv:146"), + never: () => false, +}; + +/** Fixture conditions -> TS conditions ({$pred} -> function, {$alt} -> Set). */ +export function fromFixture(cond: any): any { + if (cond === null || typeof cond !== "object" || Array.isArray(cond)) { + return cond; + } + if ("$pred" in cond) return PREDICATES[cond.$pred]; + if ("$alt" in cond) return new Set(cond.$alt.map(fromFixture)); + const out: Record = {}; + for (const [k, v] of Object.entries(cond)) out[k] = fromFixture(v); + return out; +} diff --git a/typescript/tests/fpgen-stats.test.ts b/typescript/tests/fpgen-stats.test.ts new file mode 100644 index 000000000..7fbd4a673 --- /dev/null +++ b/typescript/tests/fpgen-stats.test.ts @@ -0,0 +1,204 @@ +/** + * The random half of fpgen. Python draws with random.random() and this port + * with Math.random(), so single fingerprints cannot be compared; their + * distributions can. For each scenario the Python golden script recorded the + * marginal counts of key fields over 3000 full generate() calls + * (fixtures/fpgen/stats.json); this draws its own sample and runs a two-sample + * chi-square homogeneity test per field. + * + * alpha is 1e-4 per field (30 fields -> ~0.3% chance of a spurious failure per + * run). A real divergence -- a mis-ordered table, a wrong renormalisation, a + * condition that filters differently -- shows up as p ~ 0. + */ +import { describe, expect, it } from "vitest"; +import { Generator } from "../src/fpgen/index.js"; +import { fixture, fromFixture, MODEL } from "./fpgen-setup.js"; + +const N_TS = Number(process.env.FPGEN_STATS_N ?? 2000); +const ALPHA = 1e-4; + +/** Mirror of fields() in scripts/golden/fpgen_golden.py. */ +function fields(fp: Record): Record { + const ua: string = fp.navigator.userAgent; + const m = /\(([^)]*)\)/.exec(ua); + const rv = /rv:(\d+)/.exec(ua); + return { + os: fp.os, + uaPlatform: m ? m[1].split("; rv:")[0] : "", + firefoxMajor: rv ? rv[1] : "", + screen: `${fp.screen.width}x${fp.screen.height}`, + hardwareConcurrency: String(fp.navigator.hardwareConcurrency), + gpuVendor: fp.gpu.vendor, + }; +} + +/* Regularised upper incomplete gamma Q(a, x) (Numerical Recipes gammq). */ +function lnGamma(x: number): number { + const c = [ + 76.18009172947146, -86.50532032941678, 24.01409824083091, + -1.231739572450155, 0.1208650973866179e-2, -0.5395239384953e-5, + ]; + let y = x; + const tmp = x + 5.5 - (x + 0.5) * Math.log(x + 5.5); + let ser = 1.000000000190015; + for (const cj of c) ser += cj / ++y; + return -tmp + Math.log((Math.sqrt(2 * Math.PI) * ser) / x); +} + +function gammaQ(a: number, x: number): number { + if (x <= 0) return 1; + const gln = lnGamma(a); + if (x < a + 1) { + let ap = a; + let sum = 1 / a; + let del = sum; + for (let n = 0; n < 1000; n++) { + del *= x / ++ap; + sum += del; + if (Math.abs(del) < Math.abs(sum) * 1e-15) break; + } + return 1 - sum * Math.exp(-x + a * Math.log(x) - gln); + } + let b = x + 1 - a; + let c = 1 / 1e-300; + let d = 1 / b; + let h = d; + for (let i = 1; i < 1000; i++) { + const an = -i * (i - a); + b += 2; + d = an * d + b; + if (Math.abs(d) < 1e-300) d = 1e-300; + c = b + an / c; + if (Math.abs(c) < 1e-300) c = 1e-300; + d = 1 / d; + const del = d * c; + h *= del; + if (Math.abs(del - 1) < 1e-15) break; + } + return Math.exp(-x + a * Math.log(x) - gln) * h; +} + +interface Comparison { + p: number; + df: number; + tvd: number; + /** Categories with >= 1% share in one sample and absent from the other. */ + missing: string[]; +} + +/** + * Two-sample chi-square homogeneity test. Categories too thin for the + * approximation (expected < 5 in either sample) are pooled into one. + */ +function compare( + py: Record, + ts: Record, +): Comparison { + const nPy = Object.values(py).reduce((a, b) => a + b, 0); + const nTs = Object.values(ts).reduce((a, b) => a + b, 0); + const total = nPy + nTs; + const cats = [...new Set([...Object.keys(py), ...Object.keys(ts)])]; + + let tvd = 0; + const missing: string[] = []; + for (const k of cats) { + const a = (py[k] ?? 0) / nPy; + const b = (ts[k] ?? 0) / nTs; + tvd += Math.abs(a - b) / 2; + if ((a >= 0.01 && !ts[k]) || (b >= 0.01 && !py[k])) missing.push(k); + } + + const rows: [number, number][] = []; + const pooled: [number, number] = [0, 0]; + for (const k of cats) { + const a = py[k] ?? 0; + const b = ts[k] ?? 0; + const col = a + b; + if ((col * nPy) / total < 5 || (col * nTs) / total < 5) { + pooled[0] += a; + pooled[1] += b; + } else { + rows.push([a, b]); + } + } + if (pooled[0] + pooled[1] > 0) rows.push(pooled); + if (rows.length < 2) return { p: 1, df: 0, tvd, missing }; + + let stat = 0; + for (const [a, b] of rows) { + const col = a + b; + const ea = (col * nPy) / total; + const eb = (col * nTs) / total; + stat += (a - ea) ** 2 / ea + (b - eb) ** 2 / eb; + } + const df = rows.length - 1; + return { p: gammaQ(df / 2, stat / 2), df, tvd, missing }; +} + +describe("chi-square helper", () => { + it("gives textbook p-values", () => { + // chi2(df=1) = 3.841 -> p = 0.05; chi2(df=10) = 18.307 -> p = 0.05 + expect(gammaQ(0.5, 3.841 / 2)).toBeCloseTo(0.05, 3); + expect(gammaQ(5, 18.307 / 2)).toBeCloseTo(0.05, 3); + }); + + it("rejects clearly different samples and accepts equal ones", () => { + expect(compare({ a: 500, b: 500 }, { a: 800, b: 200 }).p).toBeLessThan( + 1e-10, + ); + expect(compare({ a: 500, b: 500 }, { a: 500, b: 500 }).p).toBeCloseTo(1); + }); +}); + +const STATS = fixture("stats.json"); + +describe.skipIf(!MODEL.ok)( + `fpgen draws match Python's distribution (${N_TS} TS vs ${STATS.n} Python per scenario)`, + () => { + const report: string[] = []; + const tsCounts: Record>> = {}; + + for (const scenario of STATS.scenarios) { + it(scenario.name, () => { + const conditions = fromFixture(scenario.conditions); + const gen = new Generator(); + const counts: Record> = {}; + for (let i = 0; i < N_TS; i++) { + for (const [k, v] of Object.entries( + fields(gen.generate(conditions)), + )) { + counts[k] ??= {}; + counts[k][v] = (counts[k][v] ?? 0) + 1; + } + } + tsCounts[scenario.name] = counts; + for (const [field, py] of Object.entries( + scenario.counts as Record>, + )) { + const r = compare(py, counts[field] ?? {}); + report.push( + `${scenario.name.padEnd(29)} ${field.padEnd(20)} p=${r.p.toExponential(2)} df=${String(r.df).padStart(2)} TVD=${r.tvd.toFixed(3)}`, + ); + expect(r.missing, `${field}: categories missing`).toEqual([]); + expect(r.p, `${field}: chi-square p-value`).toBeGreaterThan(ALPHA); + } + }, 180_000); + } + + it("report, and a negative control: the test can tell scenarios apart", () => { + console.log(`fpgen marginals, TS vs Python:\n${report.join("\n")}`); + expect(report.length).toBe(STATS.scenarios.length * 6); + // TS Windows draws against Python's unconstrained-OS draws: the same + // test must reject these, or it could not catch a real divergence. + const pyAny = STATS.scenarios.find((s: any) => s.name === "firefox_any"); + const ctl = compare( + pyAny.counts.gpuVendor, + tsCounts.firefox_windows.gpuVendor, + ); + console.log( + `negative control (gpuVendor, any vs windows): p=${ctl.p.toExponential(2)}`, + ); + expect(ctl.p).toBeLessThan(1e-10); + }); + }, +); diff --git a/typescript/tests/golden-setup.ts b/typescript/tests/golden-setup.ts new file mode 100644 index 000000000..b1546deb1 --- /dev/null +++ b/typescript/tests/golden-setup.ts @@ -0,0 +1,62 @@ +/** + * vitest globalSetup: record the golden fixtures from pythonlib before any test + * reads them. + * + * The goldens are pythonlib's own output over fixed inputs + * (scripts/golden/*.py -> tests/fixtures/{launch,identity,fpgen}). They are + * regenerated on every run rather than committed, so the TS tests always + * compare against the pythonlib in this checkout: a pythonlib change that + * typescript/ does not mirror fails here. fpgen's stats.json is the exception -- + * thousands of random draws, compared statistically, that change with the + * pinned model rather than with pythonlib -- and stays committed. + */ +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); + +/** The interpreter with pythonlib installed: $CAMOUFOX_PYTHON, else the repo's .venv. */ +export const PYTHON = + process.env.CAMOUFOX_PYTHON ?? + path.join( + REPO, + ".venv", + process.platform === "win32" ? "Scripts/python.exe" : "bin/python", + ); + +const GENERATORS: string[][] = [ + ["launch_golden.py"], + ["identity_golden.py"], + ["fpgen_golden.py", "structure", "values", "conditions", "api"], +]; + +export default function setup(): void { + if (!fs.existsSync(PYTHON)) { + throw new Error( + `The golden fixtures are recorded from pythonlib, and ${PYTHON} does not exist. ` + + "From the repo root:\n" + + " python3.14 -m venv .venv\n" + + " .venv/bin/pip install -r ci/requirements.txt -e pythonlib\n" + + " .venv/bin/python scripts/pin-fpgen-model.py\n" + + "or point CAMOUFOX_PYTHON at an interpreter that has them.", + ); + } + for (const [script, ...args] of GENERATORS) { + const proc = spawnSync( + PYTHON, + [path.join(REPO, "typescript", "scripts", "golden", script), ...args], + { cwd: REPO, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }, + ); + if (proc.status !== 0) { + throw new Error( + `${script} exited ${proc.status ?? proc.signal}:\n${proc.stderr}`, + ); + } + } +} diff --git a/typescript/tests/identity-golden.test.ts b/typescript/tests/identity-golden.test.ts new file mode 100644 index 000000000..a1e026bc6 --- /dev/null +++ b/typescript/tests/identity-golden.test.ts @@ -0,0 +1,667 @@ +/** + * Replays the Python identity layer's recorded outputs + * (scripts/golden/identity_golden.py -> tests/fixtures/identity) through the + * TypeScript port and requires the identical result: same seed and salt, same + * fonts, voices, media devices, GPU, geometry and coherence verdicts. + */ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { gunzipSync } from "node:zlib"; +import { describe, expect, it } from "vitest"; +import * as coherence from "../src/coherence.js"; +import * as fp from "../src/fingerprints.js"; +import { pairwiseSum } from "../src/locales.js"; +import { LOCAL_DATA } from "../src/pkgman.js"; +import { + crc32, + formatOrjsonFloat, + formatPyFloatRepr, + num, + orjsonDumps, + PyFloat, + parsePyJson, + pyStrRepr, + pySum, + pySumFloats, +} from "../src/pycompat.js"; +import { PyRandom, pyRandom } from "../src/pyrandom.js"; +import * as webgl from "../src/webgl.js"; +import { prerequisite } from "./prereq.js"; + +const FIXTURES = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures", + "identity", +); + +function load(name: string, pyTyped = false): any { + const file = path.join(FIXTURES, name); + const raw = fs.readFileSync(file); + const text = (name.endsWith(".gz") ? gunzipSync(raw) : raw).toString("utf-8"); + return pyTyped ? parsePyJson(text) : JSON.parse(text); +} + +/** + * Python's canon(): an int past 2**53 hashes as the float JavaScript would + * hold (integral floats below it already print as ints here). + */ +function canon(value: unknown): unknown { + if (typeof value === "bigint") return Number(value); + if (value instanceof PyFloat) return value.value; + if (Array.isArray(value)) return value.map(canon); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, canon(v)]), + ); + } + return value; +} + +/** The golden hash: sha256 of the sorted-key orjson bytes, first 20 hex. */ +function h(value: unknown): string { + return createHash("sha256") + .update(Buffer.from(orjsonDumps(canon(value)), "utf-8")) + .digest("hex") + .slice(0, 20); +} + +function big(s: string | number): number | bigint { + const n = BigInt(s); + return n <= BigInt(Number.MAX_SAFE_INTEGER) && + n >= -BigInt(Number.MAX_SAFE_INTEGER) + ? Number(n) + : n; +} + +function clone(v: T): T { + return structuredClone(v); +} + +function hashedLists(config: Record): Record { + const out = { ...config }; + for (const key of ["fonts", "voices"]) { + if (key in out) out[key] = { hash: h(out[key]), len: out[key].length }; + } + return out; +} + +describe("constants", () => { + const c = load("constants.json"); + it("match the Python module", () => { + expect(fp.FPGEN_DATA).toEqual(c.fpgenData); + expect(fp.ESSENTIAL_FONTS_MACOS).toEqual(c.essentialMacos); + expect(fp.ESSENTIAL_FONTS_WINDOWS).toEqual(c.essentialWindows); + expect(fp.ESSENTIAL_FONTS_LINUX).toEqual(c.essentialLinux); + expect(fp.MACOS_MARKER_FONTS).toEqual(c.markers.macos); + expect(fp.WINDOWS_MARKER_FONTS).toEqual(c.markers.windows); + expect(fp.LINUX_MARKER_FONTS).toEqual(c.markers.linux); + expect([...fp.WINDOWS_11_MARKER_FONTS].sort()).toEqual(c.windows11Markers); + expect(fp.PLAUSIBLE_CORE_COUNTS).toEqual(c.plausibleCoreCounts); + expect(fp.MODERN_SCREEN_FLOOR).toEqual(c.modernScreenFloor); + expect([...coherence.APPLE_SILICON_CORES].sort((a, b) => a - b)).toEqual( + c.appleSiliconCores, + ); + // Iteration order matters: the dpr repair breaks ties by it. + expect(coherence.PLAUSIBLE_DPR).toEqual(c.plausibleDpr); + expect([...coherence.PLAUSIBLE_COLOR_DEPTH].sort()).toEqual( + c.plausibleColorDepth, + ); + expect(coherence.MAX_PLAUSIBLE_TOUCH_POINTS).toBe(c.maxTouchPoints); + expect(coherence.BROWSER_CHROME_HEIGHT).toBe(c.browserChromeHeight); + expect(coherence.RULES.map((r) => r.name)).toEqual(c.rules); + expect([...fp.MAC_NOVELTY_VOICES].sort()).toEqual(c.macNovelty); + expect([...fp.MAC_ELOQUENCE_VOICES].sort()).toEqual(c.macEloquence); + expect(fp.PRESETS_V150_MIN_FF).toBe(c.presetsV150MinFf); + }); +}); + +describe("pyrandom (CPython random.Random)", () => { + const { cases } = load("pyrandom.json.gz"); + it(`reproduces ${cases.length} seeded streams`, () => { + for (const c of cases) { + const r = new PyRandom( + "int" in c.seed ? BigInt(c.seed.int) : (c.seed.str as string), + ); + const label = JSON.stringify(c.seed); + expect( + Array.from({ length: 8 }, () => r.random()), + label, + ).toEqual(c.random); + for (const [k, v] of c.getrandbits) { + expect(r.getrandbitsBig(k).toString(), `${label} bits ${k}`).toBe(v); + } + for (const [n, v] of c.randbelow) expect(r.randbelow(n)).toBe(v); + for (const [args, v] of c.randrange) { + expect(r.randrange(args[0], args[1], args[2] ?? 1)).toBe(v); + } + for (const [a, b, v] of c.randint) expect(r.randint(a, b)).toBe(v); + const pop = [..."abcdefghijklmnopqrstuvwxyz"]; + expect(Array.from({ length: 5 }, () => r.choice(pop))).toEqual(c.choice); + expect(r.choices(pop, { k: 6 })).toEqual(c.choices.plain); + expect( + r.choices(pop.slice(0, 5), { + weights: [0.1, 0.5, 2.0, 1.25, 0.15], + k: 6, + }), + ).toEqual(c.choices.weights); + expect( + r.choices(pop.slice(0, 4), { cumWeights: [1, 3, 6, 10], k: 6 }), + ).toEqual(c.choices.cum_weights); + const shuffled = Array.from({ length: 20 }, (_, i) => i); + r.shuffle(shuffled); + expect(shuffled).toEqual(c.shuffle); + for (const [n, k, v] of c.sample) { + expect( + r.sample( + Array.from({ length: n }, (_, i) => i), + k, + ), + `${label} sample(${n}, ${k})`, + ).toEqual(v); + } + expect(Array.from({ length: 3 }, () => r.uniform(-3.5, 10.25))).toEqual( + c.uniform, + ); + expect(r.random()).toBe(c.after); + } + }); +}); + +describe("numpy sum", () => { + const fx = load("numpy.json.gz"); + it("pairwise float64 sum", () => { + for (const s of fx.sums) + expect(pairwiseSum(s.values), `n=${s.values.length}`).toBe(s.sum); + }); +}); + +describe("python compatibility primitives", () => { + const fx = load("pycompat.json.gz", true); + it("orjson and repr float formatting", () => { + for (const [x, orj, rep] of fx.floats) { + expect(formatOrjsonFloat(num(x)), rep).toBe(orj); + expect(formatPyFloatRepr(num(x))).toBe(rep); + } + }); + it("sum() over ints and floats", (ctx) => { + // pySum() is sum() as 3.14 computes it; 3.12/3.13 round a mixed + // int/float sum that loses precision differently in the last bit. + const [major, minor] = fx.python; + if ( + !prerequisite( + "python-3.14", + major * 100 + minor >= 314, + `the goldens were recorded on Python ${major}.${minor}`, + ) + ) + ctx.skip(); + for (const s of fx.sums) { + const items = s.items.map((it: any) => + "i" in it + ? it.i + : Number.isInteger(num(it.f)) + ? new PyFloat(num(it.f)) + : num(it.f), + ); + const got = pySum(items); + if ("i" in s.result) expect(got).toBe(s.result.i); + else expect(got).toBe(num(s.result.f)); + } + }); + it("sum() over floats", () => { + for (const s of fx.floatSums) + expect(pySumFloats(s.values.map(num))).toBe(num(s.sum)); + }); + it("zlib.crc32", () => { + for (const [s, v] of fx.crc32) expect(crc32(s)).toBe(v); + }); + it("identity_salt over orjson bytes", () => { + for (const c of fx.salts) { + const value = + "screen" in c + ? new fp.Screen({ + minWidth: c.screen[0], + maxWidth: c.screen[1], + minHeight: c.screen[2], + maxHeight: c.screen[3], + }) + : c.value; + if (value === null) continue; // None draws a random salt + expect(fp.identitySalt(value).toString(), orjsonDumps(c.value)).toBe( + c.salt, + ); + } + }); + it("identity_seed", () => { + for (const c of fx.seeds) { + expect(fp.identitySeed(c.config, big(c.salt))).toBe(c.seed); + } + }); + it("repr(str)", () => { + for (const [s, r] of fx.reprs) expect(pyStrRepr(s)).toBe(r); + }); + it("identity_salt of full fpgen fingerprints", () => { + const cases = load("fpgen-salts.json.gz", true); + expect(cases.length).toBeGreaterThanOrEqual(3); + for (const c of cases) { + expect(fp.identitySalt(c.fingerprint).toString(), c.label).toBe(c.salt); + } + }); +}); + +describe("font draws", () => { + const { cases } = load("fonts.json.gz"); + it(`reproduces ${cases.length} font lists`, () => { + for (const c of cases) { + const fonts = fp.generateRandomFontSubset( + c.os, + big(c.seed), + c.native ?? false, + c.locale, + ); + const label = `${c.os} seed ${c.seed} ${c.locale} native=${c.native}`; + if (c.fonts) expect(fonts, label).toEqual(c.fonts); + expect(fonts.length, label).toBe(c.len); + expect(h(fonts), label).toBe(c.hash); + } + }); +}); + +describe("voice draws", () => { + const fx = load("voices.json.gz"); + it(`reproduces ${fx.cases.length} voice lists`, () => { + for (const c of fx.cases) { + const voices = fp.generateRandomVoiceSubset(c.os, c.locale, c.seed); + const label = `${c.os} ${c.locale} seed ${c.seed}`; + if (c.voices) expect(voices, label).toEqual(c.voices); + expect(voices.length, label).toBe(c.len); + expect(h(voices), label).toBe(c.hash); + } + }); + it("voice URIs", () => { + for (const [osKey, name, lang, uri] of fx.uris) { + expect(fp.voiceUri(osKey, name, lang)).toBe(uri); + } + // Every "Name:lang:type" entry in a voice manifest, in document order. + const entriesOf = (node: unknown): string[] => { + if (Array.isArray(node)) + return node.flatMap((item) => + typeof item === "string" && item.split(":").length >= 3 + ? [item] + : entriesOf(item), + ); + if (node && typeof node === "object") + return Object.values(node).flatMap(entriesOf); + return []; + }; + const manifests = JSON.parse( + fs.readFileSync(path.join(LOCAL_DATA, "voice-manifests.json"), "utf-8"), + ) as Record; + for (const [osKey, manifest] of Object.entries(manifests)) { + const uris = entriesOf(manifest).map((e) => { + const parts = e.split(":"); + const lang = parts[parts.length - 2]; + const name = parts.slice(0, -2).join(":"); + return fp.voiceUri(osKey, name, lang); + }); + expect(h(uris), osKey).toBe(fx.uriHashes[osKey]); + } + }); + it("preset voice normalization", () => { + const presets: Record = {}; + for (const file of [ + "fingerprint-presets.json", + "fingerprint-presets-v150.json", + ]) { + presets[file] = JSON.parse( + fs.readFileSync(path.join(LOCAL_DATA, file), "utf-8"), + ).presets; + } + for (const c of fx.normalized) { + if (c.extra) { + expect(fp.normalizePresetVoices(c.extra, c.os)).toEqual(c.out); + continue; + } + const out = fp.normalizePresetVoices( + presets[c.file][c.os][c.index].speechVoices, + c.os, + ); + expect(out.length).toBe(c.len); + expect(h(out)).toBe(c.hash); + } + }); +}); + +describe("media devices", () => { + const fx = load("media.json.gz"); + it(`reproduces ${fx.cases.length} device draws`, () => { + for (const c of fx.cases) { + const out = fp.drawMediaDevices(c.os, c.seed); + if (c.out) expect(out).toEqual(c.out); + expect(h(out), `${c.os} ${c.seed}`).toBe(c.hash); + } + }); + it("setMediaDevicesDefaults", () => { + for (const c of fx.defaults) { + const config = clone(c.config); + fp.setMediaDevicesDefaults(config, big(c.salt)); + expect(h(config)).toBe(c.hash); + } + }); +}); + +/** Python's exact(): sha256 of the orjson bytes, key order and number types included. */ +function exact(value: unknown): string { + return createHash("sha256") + .update(Buffer.from(orjsonDumps(value, false), "utf-8")) + .digest("hex") + .slice(0, 20); +} + +describe("webgl", () => { + // Read as Python reads it: the recorded device's 1.0 must stay a float. + const fx = load("webgl.json.gz", true); + it("traces the same GPUs, in the same order", () => { + for (const [osKey, gpus] of Object.entries(fx.gpus)) { + expect(webgl.firefoxGpus(osKey), osKey).toEqual(gpus); + } + }); + it(`reproduces ${fx.forScreen.length} screen-coherent draws`, () => { + for (const c of fx.forScreen) { + const out = webgl.sampleWebglForScreen(c.os, c.w, c.h, big(c.seed)); + const label = `${c.os} ${c.w}x${c.h} seed ${c.seed}`; + expect(out["webGl:renderer"], label).toBe(c.renderer); + expect(exact(out), label).toBe(c.hash); + } + }); + it(`reproduces ${fx.forGpu.length} draws for a named GPU`, () => { + for (const c of fx.forGpu) { + let got: any; + try { + const out = webgl.webglForGpu(c.os, c.vendor, c.renderer, c.seed); + got = { ok: exact(out) }; + } catch (e) { + got = { error: (e as Error).name, message: (e as Error).message }; + } + expect(got, `${c.os} ${c.renderer} seed ${c.seed}`).toEqual( + "ok" in c ? { ok: c.ok } : { error: c.error, message: c.message }, + ); + } + }); + it("raises as Python does", () => { + const { error, message } = fx.errors.unknownOs; + expect(() => webgl.sampleWebglForScreen("bsd", 1920, 1080, 0)).toThrow( + expect.objectContaining({ name: error, message }), + ); + }); + it("converts a recorded device, filtering extensions per OS", () => { + for (const c of fx.converted) { + expect( + exact(webgl.toConfig(fx.recorded, c.webgl2, c.os)), + `${c.os} webgl2=${Array.isArray(c.webgl2) ? "[]" : "dict"}`, + ).toBe(c.hash); + } + }); +}); + +function ret(v: unknown): unknown { + return v === undefined ? null : v; +} + +describe("geometry fixes", () => { + const fx = load("geometry.json.gz"); + it(`reproduces ${fx.cases.length} random geometries`, () => { + fx.cases.forEach((c: any, i: number) => { + const fns: Record unknown> = { + fixScreenNoTaskbar: (d) => fp.fixScreenNoTaskbar(d, c.os), + clampWindowDimensions: fp.clampWindowDimensions, + clampScreenToDisplay: (d) => fp.clampScreenToDisplay(d, c.capW, c.capH), + clampWindowPosition: fp.clampWindowPosition, + raiseScreenToModernFloor: fp.raiseScreenToModernFloor, + repairScreenOrientation: coherence.repairScreenOrientation, + }; + for (const [name, fn] of Object.entries(fns)) { + const d = clone(c.input); + const r = fn(d); + const want = c.out[name]; + const label = `case ${i} ${name}`; + expect(ret(r), label).toEqual(want.ret); + if (want.config) { + expect(d, label).toEqual(want.config); + expect(Object.keys(d), label).toEqual(Object.keys(want.config)); + } else { + expect(h(d), label).toBe(want.hash); + expect(h(Object.keys(d)), label).toBe(want.keys); + } + } + const d = clone(c.input); + if (coherence.screenIsImplausible(d)) { + coherence.repairScreenOrientation(d); + fp.raiseScreenToModernFloor(d); + } + fp.raiseScreenToModernFloor(d); + fp.clampScreenToDisplay(d, c.capW, c.capH); + fp.fixScreenNoTaskbar(d, c.os); + fp.clampWindowDimensions(d); + fp.clampWindowPosition(d); + const want = c.out.pipeline; + if (want.config) expect(d, `case ${i} pipeline`).toEqual(want.config); + else expect(h(d), `case ${i} pipeline`).toBe(want.hash); + }); + }); + it("fixNavigatorArch", () => { + for (const [ua, plat, oscpu, target, platOut, oscpuOut] of fx.arch) { + const c: Record = { "navigator.userAgent": ua }; + if (plat !== null) c["navigator.platform"] = plat; + if (oscpu !== null) c["navigator.oscpu"] = oscpu; + fp.fixNavigatorArch(c, target); + expect([ + c["navigator.platform"] ?? null, + c["navigator.oscpu"] ?? null, + ]).toEqual([platOut, oscpuOut]); + } + }); + it(`fixHardwareConcurrency (${fx.hardwareConcurrency.length} host/draw combinations)`, () => { + for (const [ + drawn, + host, + supported, + canPin, + out, + ] of fx.hardwareConcurrency) { + const c: Record = + drawn === null ? {} : { "navigator.hardwareConcurrency": drawn }; + fp.fixHardwareConcurrency(c, canPin, { + cpuCount: host, + canPinHost: supported, + }); + const got = + "navigator.hardwareConcurrency" in c + ? c["navigator.hardwareConcurrency"] + : "__missing__"; + expect(got, JSON.stringify([drawn, host, supported, canPin])).toEqual( + out, + ); + } + }); +}); + +describe("coherence", () => { + const fx = load("coherence.json.gz"); + it("gpuFitsOs / gpuScreenIsPlausible / rendererBucket", () => { + for (const [r, osKey, fits] of fx.fits) { + expect(coherence.gpuFitsOs(r, osKey), `${r} ${osKey}`).toBe(fits); + } + for (const [r, w, hh, plausible, software, bucket] of fx.gpuScreen) { + expect(fp.gpuScreenIsPlausible(r, w, hh), `${r} ${w}x${hh}`).toBe( + plausible, + ); + expect(fp.isSoftwareRenderer(r)).toBe(software); + if (r !== null) expect(fp.rendererBucket(r)).toBe(bucket); + } + }); + it(`validate / apply / drop over ${fx.cases.length} identities`, () => { + fx.cases.forEach((c: any, i: number) => { + const label = `case ${i} ${c.os}`; + const pairs = (vs: coherence.Violation[]) => + vs.map((v) => [v.rule, v.detail]); + const v = pairs(coherence.validate(clone(c.input), c.os)); + expect( + v.map((x) => x[0]), + label, + ).toEqual(c.rules); + if (c.validateFull) expect(v, label).toEqual(c.validateFull); + expect(h(v), label).toBe(c.validate); + + const applied = clone(c.input); + const left = pairs(coherence.apply(applied, c.os)); + expect( + left.map((x) => x[0]), + label, + ).toEqual(c.apply); + expect(h(left), label).toBe(c.applyHash); + if (c.appliedFull) expect(applied, label).toEqual(c.appliedFull); + expect(h(applied), label).toBe(c.applied); + + const dropped = clone(c.input); + expect( + pairs(coherence.dropIncoherentSourceValues(dropped, c.os)), + ).toEqual(c.dropped); + expect(h(dropped), label).toBe(c.droppedConfig); + expect(coherence.screenIsImplausible(c.input)).toBe(c.implausible); + }); + }); +}); + +describe("fromFpgen", () => { + const fx = load("from-fpgen.json.gz"); + it(`reproduces ${fx.cases.length} configs`, () => { + for (const c of fx.cases) { + pyRandom.seed(c.moduleSeed); + const config = fp.fromFpgen(clone(fx.inputs[c.input]), c.ffVersion); + const label = `input ${c.input} ff ${c.ffVersion}`; + expect(config, label).toEqual(c.config); + expect(Object.keys(config), label).toEqual(c.configKeys); + expect(fp.identitySeed(config, 12345678901234567890n), label).toBe( + c.identitySeed, + ); + } + }); + it("handleWindowSize", () => { + for (const c of fx.windowSize) { + const d = clone(fx.inputs[c.input]); + fp.handleWindowSize(d, c.w, c.h); + expect(d).toEqual(c.out); + } + }); + it("Screen.asConditions", () => { + // Python's 1080.0 is a float (rejected); JavaScript's 1080 is not. + const probes = [800, 1366, 1920, 2560, null, "1920", null]; + for (const c of fx.screens) { + const [minWidth, maxWidth, minHeight, maxHeight] = c.bounds; + const conds = new fp.Screen({ + minWidth, + maxWidth, + minHeight, + maxHeight, + }).asConditions(); + expect(Object.keys(conds).sort()).toEqual(c.keys); + for (const [key, want] of [ + ["screen.width", c.width], + ["screen.height", c.height], + ]) { + if (want === null) continue; + probes.forEach((p, i) => { + if (i === 4) return; + expect(conds[key](p)).toBe(want[i]); + }); + } + } + }); +}); + +describe("presets", () => { + const fx = load("presets.json.gz"); + const bundles: Record = {}; + for (const file of [ + "fingerprint-presets.json", + "fingerprint-presets-v150.json", + ]) { + bundles[file] = JSON.parse( + fs.readFileSync(path.join(LOCAL_DATA, file), "utf-8"), + ).presets; + } + const osKey: Record = { + macos: "mac", + windows: "win", + linux: "lin", + }; + it(`fromPreset over ${fx.cases.length} bundled presets`, () => { + for (const c of fx.cases) { + pyRandom.seed(c.moduleSeed); + const preset = bundles[c.file][c.os][c.index]; + const config = fp.fromPreset(clone(preset), c.ffVersion, big(c.salt)); + const label = `${c.file} ${c.os}[${c.index}] salt ${c.salt}`; + expect(h(Object.keys(config)), label).toBe(c.keys); + expect(h(config), label).toBe(c.hash); + expect( + coherence.validate(config, osKey[c.os]).map((v) => v.rule), + label, + ).toEqual(c.validate); + } + for (const c of fx.full) { + pyRandom.seed(c.index * 7); + const config = fp.fromPreset( + clone(bundles[c.file][c.os][c.index]), + c.file.includes("v150") ? "152" : null, + 0, + ); + expect(hashedLists(config)).toEqual(c.config); + } + }); + it("synthetic presets", () => { + for (const c of fx.synthetic) { + pyRandom.seed(c.moduleSeed); + const config = fp.fromPreset(clone(c.preset), c.ffVersion, 99); + expect(hashedLists(config)).toEqual(c.config); + } + }); + it("getRandomPreset", () => { + for (const c of fx.random) { + pyRandom.seed(c.moduleSeed); + const preset = fp.getRandomPreset(c.os, c.ffVersion); + expect(preset === null ? null : h(preset), JSON.stringify(c)).toBe( + c.hash, + ); + } + }); + it("appVersion derivation and the presets file choice", () => { + for (const [ua, want] of fx.appVersions) { + expect(fp.appVersionFromUserAgent(ua), ua).toBe(want); + } + const inputs: Array<[string, string | number | null]> = [ + ["None", null], + ["148", "148"], + ["149", "149"], + ["150.0.2", "150.0.2"], + ["abc", "abc"], + ["152", 152], + ["", ""], + [" 150", " 150"], + ]; + for (const [key, value] of inputs) { + expect(path.basename(fp.selectPresetsFile(value)), key).toBe( + fx.presetsFile[key], + ); + } + }); +}); + +describe("init script", () => { + const { cases } = load("init-script.json"); + it("renders byte-identical scripts", () => { + for (const c of cases) expect(fp.buildInitScript(c.values)).toBe(c.script); + }); +}); diff --git a/typescript/tests/launch-golden.test.ts b/typescript/tests/launch-golden.test.ts new file mode 100644 index 000000000..9365e8e9f --- /dev/null +++ b/typescript/tests/launch-golden.test.ts @@ -0,0 +1,352 @@ +/** + * launchOptions() must reproduce Python's launch_options() exactly. + * + * The goldens in tests/fixtures/launch are recorded by + * scripts/golden/launch_golden.py (run it with the repo's .venv python). Each + * scenario is replayed here with the same pinned host probes, and everything + * launch_options() returns is compared: the CAMOU_CONFIG blob byte for byte + * (and its chunking), CAMOU_PREFS, firefoxUserPrefs, args, env, the generated + * fontconfig, the warnings in order, stdout, and any error. + * + * Linux only: the goldens are recorded there (chunk size and fontconfig depend + * on the host OS). + */ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { PyFloat, parsePyJson } from "../src/pycompat.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES = path.join(HERE, "fixtures", "launch"); +const SCRATCH = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-golden-ts-")); +const XDG = path.join(SCRATCH, "xdg-cache"); +const CACHE = path.join(XDG, "camoufox"); +const HOME = path.join(SCRATCH, "home"); + +// parsePyJson: the fresh salt is a 64-bit int, beyond a JS number. +const hostInfo = parsePyJson( + fs.readFileSync(path.join(FIXTURES, "host.json"), "utf-8"), +); +const HOST = hostInfo.host; +const GEO_TABLE: Record = hostInfo.geo_table; +const INPUTS = JSON.parse( + fs.readFileSync(path.join(FIXTURES, "inputs.json"), "utf-8"), +); +const SCENARIOS: any[] = (hostInfo.scenarios as string[]).map((name) => + JSON.parse( + fs.readFileSync(path.join(FIXTURES, `scenario-${name}.json`), "utf-8"), + ), +); + +// Scenarios whose INPUT carries a Python float that is integral (1920.0). A +// JS caller has to spell that as a PyFloat to get Python's bytes; these do. +const FLOAT_INPUTS: Record void> = { + config_float_int: (kwargs) => { + kwargs.config["screen.width"] = new PyFloat(kwargs.config["screen.width"]); + }, +}; +const JS_EQUIVALENT_BLOB = new Set(); + +const PLACEHOLDERS: Array<[string, string]> = [ + [path.join(FIXTURES, "bundle-old"), ""], + [path.join(FIXTURES, "bundle"), ""], + [path.join(FIXTURES, "addons", "example-addon"), ""], + [CACHE, ""], + [HOME, ""], +]; + +function fill(value: any): any { + if (typeof value === "string") { + let out = value; + for (const [real, ph] of PLACEHOLDERS) out = out.split(ph).join(real); + return out; + } + if (Array.isArray(value)) return value.map(fill); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, fill(v)]), + ); + } + return value; +} + +function mask(value: any): any { + if (typeof value === "string") { + let out = value; + for (const [real, ph] of PLACEHOLDERS) out = out.split(real).join(ph); + // A FallbackWarning's report block names the host and the runtime + // (python/node), so neither launcher can reproduce the other's. + out = out.replace(/(and include:\n\n)( {4}.*(\n|$))+/g, "$1"); + return out.replace(/fonts-[0-9a-f]{12}\.conf/g, "fonts-.conf"); + } + if (Array.isArray(value)) return value.map(mask); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [mask(k), mask(v)]), + ); + } + return value; +} + +/** camoufox.server.camel_case, for keys that are snake_case. */ +function camel(key: string): string { + if (!key.includes("_")) return key; + const parts = key.toLowerCase().split("_"); + const joined = parts + .map((x) => (x ? x[0].toUpperCase() + x.slice(1) : "")) + .join(""); + return joined[0].toLowerCase() + joined.slice(1); +} + +/** Where two strings first differ, with some context either side. */ +function firstDiff(have: string, want: string): string { + if (have === want) return ""; + let i = 0; + while (i < have.length && have[i] === want[i]) i++; + const from = Math.max(0, i - 120); + return `first difference at ${i}:\n ts: ${have.slice(from, i + 120)}\n py: ${want.slice(from, i + 120)}`; +} + +/** Python exception type -> the TS error name it corresponds to. */ +let mods: { + utils: typeof import("../src/utils.js"); + fingerprints: typeof import("../src/fingerprints.js"); + locales: typeof import("../src/locales.js"); + geolocation: typeof import("../src/geolocation.js"); + warnings: typeof import("../src/warnings.js"); +}; + +const ORIGINAL_ENV = { ...process.env }; + +beforeAll(async () => { + // INSTALL_DIR is computed at import time, so point the cache first. + process.env.XDG_CACHE_HOME = XDG; + fs.mkdirSync(HOME, { recursive: true }); + mods = { + utils: await import("../src/utils.js"), + fingerprints: await import("../src/fingerprints.js"), + locales: await import("../src/locales.js"), + geolocation: await import("../src/geolocation.js"), + warnings: await import("../src/warnings.js"), + }; + expect(mods.utils.utilsDeps.installDir()).toBe(CACHE); + + // The mmdb files only have to exist and be fresh; the fake reader answers. + fs.mkdirSync(mods.geolocation.MMDB_DIR, { recursive: true }); + for (const name of [ + "maxmind geolite2-ipv4.mmdb", + "maxmind geolite2-ipv6.mmdb", + ]) { + fs.writeFileSync(path.join(mods.geolocation.MMDB_DIR, name), ""); + } + // The default addon, already "downloaded", so nothing is fetched. + fs.mkdirSync(path.join(CACHE, "addons", "UBO"), { recursive: true }); + fs.writeFileSync(path.join(CACHE, "addons", "UBO", "manifest.json"), "{}"); + + const deps = mods.utils.utilsDeps; + const realFix = mods.fingerprints.fixHardwareConcurrency; + deps.fixHardwareConcurrency = (config, canPin) => + realFix(config, canPin, { cpuCount: HOST.cpu_count, canPinHost: true }); + deps.stockProfileDiskCapacityKb = () => HOST.disk_capacity_kb; + deps.hostOsKey = () => HOST.host_os_key; + deps.largestDisplay = () => ({ + width: HOST.display[0], + height: HOST.display[1], + }); + deps.publicIp = async () => HOST.public_ip; + const realSalt = mods.fingerprints.identitySalt; + deps.identitySalt = (pinned?: unknown) => + pinned === undefined || pinned === null + ? BigInt(HOST.fresh_salt as bigint) + : realSalt(pinned); + mods.locales.localeDeps.random = () => HOST.locale_uniform; + mods.geolocation.geoipDeps.openDatabase = async () => ({ + get: (ip: string) => structuredClone(GEO_TABLE[ip] ?? null), + }); +}, 120_000); + +afterAll(() => { + process.env = ORIGINAL_ENV; + fs.rmSync(SCRATCH, { recursive: true, force: true }); +}); + +async function replay(scenario: any) { + const deps = mods.utils.utilsDeps; + const kwargs = fill(structuredClone(scenario.kwargs)); + const special = scenario.special ?? {}; + const calls: any[] = []; + const stdout: string[] = []; + const restore: Array<() => void> = []; + + if (kwargs.executable_path === null) delete kwargs.executable_path; + FLOAT_INPUTS[scenario.name]?.(kwargs); + + const printBefore = deps.print; + deps.print = (line: string) => { + stdout.push(`${line}\n`); + }; + restore.push(() => { + deps.print = printBefore; + }); + + if (special.generate) { + const base = INPUTS.fingerprints[special.generate]; + const before = deps.generateFingerprint; + deps.generateFingerprint = async ({ window, screen, os: osOpt }: any) => { + calls.push({ + fn: "generate_fingerprint", + window: window ? [...window] : null, + screen: screen + ? { + min_width: screen.minWidth, + max_width: screen.maxWidth, + min_height: screen.minHeight, + max_height: screen.maxHeight, + } + : null, + os: osOpt ?? null, + }); + const fp = structuredClone(base); + if (window) mods.fingerprints.handleWindowSize(fp, window[0], window[1]); + return fp; + }; + restore.push(() => { + deps.generateFingerprint = before; + }); + } + if (special.random_preset) { + const preset = INPUTS.presets[special.random_preset]; + const before = deps.getRandomPreset; + deps.getRandomPreset = ((osOpt: any, ffVersion: any) => { + calls.push({ + fn: "get_random_preset", + os: osOpt ?? null, + ff_version: ffVersion ?? null, + }); + return structuredClone(preset); + }) as any; + restore.push(() => { + deps.getRandomPreset = before; + }); + } + for (const [k, v] of Object.entries(special.process_env ?? {})) { + const prev = process.env[k]; + process.env[k] = fill(v); + restore.push(() => { + if (prev === undefined) delete process.env[k]; + else process.env[k] = prev; + }); + } + + try { + const { result, error, warnings } = await mods.warnings.recordWarnings(() => + mods.utils.launchOptions(kwargs), + ); + return { result, error, warnings, stdout: stdout.join(""), calls }; + } finally { + for (const undo of restore.reverse()) undo(); + } +} + +function describeResult(result: Record) { + const env: Record = { ...result.env }; + const chunks = (prefix: string) => + Object.keys(env) + .filter((k) => k.startsWith(prefix)) + .map((k) => [Number.parseInt(k.slice(prefix.length), 10), k] as const) + .sort((a, b) => a[0] - b[0]) + .map(([, k]) => k); + const configKeys = chunks("CAMOU_CONFIG_"); + const prefKeys = chunks("CAMOU_PREFS_"); + let fontconfig: any = null; + if (env.FONTCONFIG_FILE) { + const file = String(env.FONTCONFIG_FILE); + const content = fs.readFileSync(file, "utf-8"); + // The file is named for the hash of its (unmasked) content. + const hash = createHash("sha256") + .update(content) + .digest("hex") + .slice(0, 12); + expect(path.basename(file)).toBe(`fonts-${hash}.conf`); + fontconfig = { path: mask(file), content: mask(content) }; + } + const otherEnv = Object.fromEntries( + Object.entries(env).filter( + ([k]) => !k.startsWith("CAMOU_CONFIG_") && !k.startsWith("CAMOU_PREFS_"), + ), + ); + const { env: _env, ...rest } = result; + return { + options: mask(rest), + env: mask(otherEnv), + config_chunks: configKeys.map((k) => Array.from(String(env[k])).length), + prefs_chunks: prefKeys.map((k) => String(env[k]).length), + config_blob: mask(configKeys.map((k) => env[k]).join("")), + prefs_blob: prefKeys.map((k) => env[k]).join(""), + fontconfig, + }; +} + +describe.skipIf(process.platform !== "linux")( + "launchOptions() matches Python launch_options()", + () => { + it("replays every recorded scenario", () => { + expect(SCENARIOS.length).toBeGreaterThan(80); + }); + + for (const scenario of SCENARIOS) { + it(scenario.name, async () => { + const got = await replay(scenario); + + // Errors + if (scenario.error) { + expect(got.error, "expected an error").toBeDefined(); + const err = got.error as Error; + expect(err.name).toBe(scenario.error.type); + expect(mask(err.message)).toBe(scenario.error.message); + } else if (got.error) { + throw got.error; + } + + expect(mask(got.warnings)).toEqual(scenario.warnings); + expect(mask(got.stdout)).toBe(scenario.stdout); + expect(mask(got.calls)).toEqual(scenario.calls); + if (scenario.error) return; + + const want = scenario.result; + const have = describeResult(got.result as Record); + + // The config blob, byte for byte. + const wantBlob = JS_EQUIVALENT_BLOB.has(scenario.name) + ? want.config_blob_js + : (want.config_blob_raw ?? want.config_blob_js); + expect(have.config_blob, firstDiff(have.config_blob, wantBlob)).toBe( + wantBlob, + ); + // Chunking: same number of CAMOU_CONFIG_, every one but the last + // full. (Exact lengths differ by the scratch-path length.) + const full = (c: number[]) => c.slice(0, -1).every((n) => n === 32767); + expect(have.config_chunks.length).toBe(want.config_chunks.length); + expect(full(have.config_chunks)).toBe(true); + expect(full(want.config_chunks)).toBe(true); + + expect(have.prefs_blob).toBe(want.prefs_blob); + expect(have.prefs_chunks).toEqual(want.prefs_chunks); + + const wantOptions = Object.fromEntries( + Object.entries(want.options).map(([k, v]) => [camel(k), v]), + ); + const haveOptions = Object.fromEntries( + Object.entries(have.options).map(([k, v]) => [camel(k), v]), + ); + expect(haveOptions).toEqual(mask(wantOptions)); + + expect(have.env).toEqual(mask(want.env)); + expect(have.fontconfig).toEqual(mask(want.fontconfig)); + }); + } + }, +); diff --git a/typescript/tests/launch-host.ts b/typescript/tests/launch-host.ts new file mode 100644 index 000000000..f087f2c3c --- /dev/null +++ b/typescript/tests/launch-host.ts @@ -0,0 +1,94 @@ +/** + * Shared scaffolding for the launch-layer tests: a scratch camoufox cache + * (XDG_CACHE_HOME, set before any src module is imported -- INSTALL_DIR is + * computed at import time), the fixture browser bundle, and the counterpart + * of the Python tests' `isolated_launch_dependencies` fixture. + * + * Import this module FIRST, then import src modules dynamically. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const FIXTURES = path.join(HERE, "fixtures", "launch"); +export const BUNDLE = path.join(FIXTURES, "bundle"); +export const BUNDLE_EXE = path.join(BUNDLE, "camoufox-bin"); + +// Keep reading the real fpgen model: it lives in the real cache, which the +// scratch XDG_CACHE_HOME below would otherwise hide. +if (!process.env.CAMOUFOX_FPGEN_DATA) { + const xdg = process.env.XDG_CACHE_HOME?.trim(); + const base = xdg ? xdg : path.join(os.homedir(), ".cache"); + process.env.CAMOUFOX_FPGEN_DATA = path.join(base, "camoufox", "fpgen"); +} + +export const SCRATCH = fs.mkdtempSync( + path.join(os.tmpdir(), "camoufox-launch-test-"), +); +process.env.XDG_CACHE_HOME = path.join(SCRATCH, "xdg-cache"); +export const CACHE = path.join(SCRATCH, "xdg-cache", "camoufox"); +export const HOME = path.join(SCRATCH, "home"); +fs.mkdirSync(HOME, { recursive: true }); + +// The default addon, already "downloaded", so nothing is fetched. +fs.mkdirSync(path.join(CACHE, "addons", "UBO"), { recursive: true }); +fs.writeFileSync(path.join(CACHE, "addons", "UBO", "manifest.json"), "{}"); + +export const utils = await import("../src/utils.js"); +export const fingerprints = await import("../src/fingerprints.js"); +export const warnings = await import("../src/warnings.js"); + +/** Reassemble the chunked CAMOU_CONFIG_ env vars into the config. */ +export function configOf(options: Record): Record { + const blob = utils.camouConfigBlob(options); + return JSON.parse(blob); +} + +const deps = utils.utilsDeps; +const ORIGINAL = { ...deps }; + +/** Put every injection point back. */ +export function restoreDeps(): void { + Object.assign(deps, ORIGINAL); +} + +/** The host every launch test runs against: no display, a fixed disk. */ +export function stubHost(): void { + deps.hasDisplay = () => false; + deps.getScreenCons = () => null; + deps.stockProfileDiskCapacityKb = () => 250_000_000; + deps.ensureCamoufoxInstalled = async () => BUNDLE; + deps.installedVerStr = () => "152.0.4-beta.31"; + deps.launchPath = (() => BUNDLE_EXE) as any; + deps.getPath = ((file: string) => path.join(BUNDLE, file)) as any; +} + +/** + * pythonlib/tests' `isolated_launch_dependencies`: launchOptions() reduced to + * environment assembly -- no fingerprint, fonts, voices, geometry fixes, + * validation or env generation. + */ +export function isolateLaunch(): void { + stubHost(); + deps.addDefaultAddons = async () => undefined; + deps.generateFingerprint = async () => ({}); + deps.fromFpgen = () => ({}); + deps.getScreenCons = () => null; + deps.generateRandomFontSubset = () => []; + deps.generateRandomVoiceSubset = () => []; + deps.fixNavigatorArch = () => undefined; + deps.fixScreenNoTaskbar = () => undefined; + deps.clampWindowDimensions = () => undefined; + deps.setMediaDevicesDefaults = () => undefined; + deps.validateConfig = () => undefined; + deps.getEnvVars = () => ({}); +} + +/** Run `fn` with warnings captured instead of printed. */ +export async function quietly(fn: () => Promise): Promise { + const { result, error } = await warnings.recordWarnings(fn); + if (error) throw error; + return result as T; +} diff --git a/typescript/tests/launch.test.ts b/typescript/tests/launch.test.ts new file mode 100644 index 000000000..864f8aa6c --- /dev/null +++ b/typescript/tests/launch.test.ts @@ -0,0 +1,997 @@ +/** + * Ports of the pythonlib launch-layer tests: + * test_launch_environment.py, test_launch_geometry.py, test_humanize.py, + * test_viewport_default.py, test_executable_path_bundle.py, + * test_executable_path_version_warning.py, test_voices.py (launch half), + * test_identity_salt.py (launch half). + * + * The goldens (launch-golden.test.ts) pin exact output; these pin the + * behaviours the Python suite names, with the same stubs. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + BUNDLE, + BUNDLE_EXE, + configOf, + fingerprints, + HOME, + isolateLaunch, + quietly, + restoreDeps, + SCRATCH, + stubHost, + utils, + warnings, +} from "./launch-host.js"; +import { prerequisite } from "./prereq.js"; + +const { ensureModel } = await import("../src/fpgen/index.js"); +const { OSError, PyFloat } = await import("../src/pycompat.js"); +const { Version } = await import("../src/pkgman.js"); +const { InvalidPropertyType } = await import("../src/exceptions.js"); +const cpuAffinity = await import("../src/cpu_affinity.js"); + +let modelReady = true; +try { + await ensureModel(); +} catch (e) { + modelReady = prerequisite("fpgen-model", false, String(e)); +} + +const deps = utils.utilsDeps; +// The real disk probe, captured before any test stubs it. +const realQuotaProbe = deps.stockProfileDiskCapacityKb; +const LINUX_UA = + "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"; +const MAC_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0"; +const WIN_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0"; + +afterEach(() => restoreDeps()); +afterAll(() => fs.rmSync(SCRATCH, { recursive: true, force: true })); + +const launch = (opts: Record) => + quietly(() => utils.launchOptions({ env: { HOME }, ...opts })); + +/** The launch's config, and whether it emitted `category` containing `text`. */ +async function launchWarning( + opts: Record, + category: string, + text: string, +): Promise<{ config: Record; warned: boolean }> { + const { + result, + error, + warnings: caught, + } = await warnings.recordWarnings(() => + utils.launchOptions({ env: { HOME }, ...opts }), + ); + if (error) throw error; + return { + config: configOf(result as Record), + warned: caught.some( + (w) => w.category === category && w.message.includes(text), + ), + }; +} +const REPORT = "github.com/daijro/camoufox/issues/new"; + +describe("test_launch_environment: virtual display", () => { + beforeEach(() => isolateLaunch()); + + const withVirtualDisplay = (extra: Record = {}) => + launch({ + virtual_display: ":4242", + headless: true, + block_webgl: true, + i_know_what_im_doing: true, + ...extra, + }); + + it("does not mutate the process environment", async () => { + const keys = [ + "DISPLAY", + "GDK_BACKEND", + "WAYLAND_DISPLAY", + "MOZ_ENABLE_WAYLAND", + ]; + const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]])); + delete process.env.DISPLAY; + process.env.GDK_BACKEND = "wayland"; + process.env.WAYLAND_DISPLAY = "wayland-0"; + process.env.MOZ_ENABLE_WAYLAND = "1"; + try { + const before = Object.fromEntries(keys.map((k) => [k, process.env[k]])); + const options = await quietly(() => + utils.launchOptions({ + virtual_display: ":4242", + headless: true, + block_webgl: true, + i_know_what_im_doing: true, + }), + ); + expect(Object.fromEntries(keys.map((k) => [k, process.env[k]]))).toEqual( + before, + ); + expect(options.env.DISPLAY).toBe(":4242"); + expect(options.env.GDK_BACKEND).toBe("x11"); + expect("WAYLAND_DISPLAY" in options.env).toBe(false); + expect(options.env.MOZ_ENABLE_WAYLAND).toBe("0"); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + }); + + it("does not mutate the caller's environment", async () => { + const callerEnv = { + UNCHANGED: "value", + GDK_BACKEND: "wayland", + WAYLAND_DISPLAY: "wayland-0", + MOZ_ENABLE_WAYLAND: "1", + }; + const before = { ...callerEnv }; + const options = await withVirtualDisplay({ env: callerEnv }); + expect(callerEnv).toEqual(before); + expect(options.env.UNCHANGED).toBe("value"); + expect(options.env.DISPLAY).toBe(":4242"); + expect(options.env.GDK_BACKEND).toBe("x11"); + expect("WAYLAND_DISPLAY" in options.env).toBe(false); + expect(options.env.MOZ_ENABLE_WAYLAND).toBe("0"); + }); +}); + +describe("test_launch_environment: prefs", () => { + beforeEach(() => isolateLaunch()); + + const prefsFor = async (opts: Record) => + (await launch({ i_know_what_im_doing: true, ...opts })).firefoxUserPrefs; + const ua = (u: string, config: Record = {}) => ({ + config: { "navigator.userAgent": u, ...config }, + }); + + describe("font fallback async pref (Linux only)", () => { + const PREF = "gfx.font_rendering.fallback.async"; + it("linux disables it", async () => { + expect((await prefsFor(ua(LINUX_UA)))[PREF]).toBe(false); + }); + it("macOS keeps it", async () => { + expect(PREF in (await prefsFor(ua(MAC_UA)))).toBe(false); + }); + it("windows keeps it", async () => { + expect(PREF in (await prefsFor(ua(WIN_UA)))).toBe(false); + }); + it("the caller's pref wins", async () => { + const prefs = await prefsFor({ + ...ua(LINUX_UA), + firefox_user_prefs: { [PREF]: true }, + }); + expect(prefs[PREF]).toBe(true); + }); + }); + + describe("UI locale follows the Intl locale", () => { + const PREF = "intl.locale.requested"; + it("a default identity pins en-US", async () => { + expect((await prefsFor(ua(LINUX_UA)))[PREF]).toBe("en-US"); + }); + it("a spoofed locale selects the matching UI locale", async () => { + // handle_locale adds the likely script (the language's Suppress-Script). + expect((await prefsFor({ ...ua(LINUX_UA), locale: "fr-FR" }))[PREF]).toBe( + "fr-Latn-FR", + ); + expect((await prefsFor({ ...ua(LINUX_UA), locale: "pt-BR" }))[PREF]).toBe( + "pt-Latn-BR", + ); + }); + it("the first of several locales wins", async () => { + expect( + (await prefsFor({ ...ua(LINUX_UA), locale: "de-DE, en-US" }))[PREF], + ).toBe("de-Latn-DE"); + }); + it("a geoip-style config locale is used", async () => { + const config = { "locale:language": "ja", "locale:region": "JP" }; + expect((await prefsFor(ua(LINUX_UA, config)))[PREF]).toBe("ja-JP"); + }); + it("keeps a script subtag", async () => { + const config = { + "locale:language": "zh", + "locale:script": "Hant", + "locale:region": "TW", + }; + expect((await prefsFor(ua(LINUX_UA, config)))[PREF]).toBe("zh-Hant-TW"); + }); + it("the caller's pref wins", async () => { + const prefs = await prefsFor({ + ...ua(LINUX_UA), + locale: "fr-FR", + firefox_user_prefs: { [PREF]: "de" }, + }); + expect(prefs[PREF]).toBe("de"); + }); + }); + + describe("prefs reach startup (CAMOU_PREFS_)", () => { + it("passes the prefs as env", async () => { + const opts = await launch({ + ...ua(WIN_UA), + locale: "fr-FR", + i_know_what_im_doing: true, + }); + const keys = Object.keys(opts.env) + .filter((k) => k.startsWith("CAMOU_PREFS_")) + .sort( + (a, b) => Number(a.split("_").pop()) - Number(b.split("_").pop()), + ); + expect(keys[0]).toBe("CAMOU_PREFS_1"); + const prefs = JSON.parse(keys.map((k) => opts.env[k]).join("")); + expect(prefs).toEqual(opts.firefoxUserPrefs); + expect(prefs["intl.locale.requested"]).toBe("fr-Latn-FR"); + }); + + it("chunks large prefs in order (2047 on Windows)", () => { + deps.osName = () => "win"; + const prefs = Object.fromEntries( + Array.from({ length: 200 }, (_, i) => [ + `camoufox.test.pref${i}`, + "x".repeat(50), + ]), + ); + const env = utils.getPrefEnvVars(prefs); + const values = Object.values(env); + expect(values.length).toBeGreaterThan(1); + expect(values.every((v) => v.length <= 2047)).toBe(true); + const joined = Array.from( + { length: values.length }, + (_, i) => env[`CAMOU_PREFS_${i + 1}`], + ).join(""); + expect(JSON.parse(joined)).toEqual(prefs); + }); + + it("no prefs, no env", () => { + expect(utils.getPrefEnvVars({})).toEqual({}); + }); + + it("is ASCII, and round-trips non-ASCII values (test_identity_salt)", () => { + const prefs = { + "font.name.serif.ja": "ๆธธๆ˜Žๆœ", + "intl.accept_languages": "fr-FR, fr", + }; + const env = utils.getPrefEnvVars(prefs); + const joined = Array.from( + { length: Object.keys(env).length }, + (_, i) => env[`CAMOU_PREFS_${i + 1}`], + ).join(""); + expect([...joined].every((c) => c.charCodeAt(0) < 0x80)).toBe(true); + expect(JSON.parse(joined)).toEqual(prefs); + // json.dumps(ensure_ascii=True): lowercase \u escapes. + expect(joined).toContain("\\u6e38\\u660e\\u671d"); + }); + }); + + describe("Windows scrollbars follow the version the fonts present", () => { + const PREF = "ui.useOverlayScrollbars"; + const prefs = (fonts: string[], u = WIN_UA) => prefsFor(ua(u, { fonts })); + it("Windows 11 fonts get overlay scrollbars", async () => { + expect((await prefs(["Arial", "Segoe UI Variable Text"]))[PREF]).toBe(1); + }); + it("Windows 10 fonts get classic ones", async () => { + expect((await prefs(["Arial", "Segoe UI", "Calibri"]))[PREF]).toBe(0); + }); + it("other OSes get overlay", async () => { + expect((await prefs(["DejaVu Sans"], LINUX_UA))[PREF]).toBe(1); + }); + }); + + describe("storage quota follows the host disk", () => { + const PREF = "dom.quotaManager.temporaryStorage.fixedLimit"; + /** The real probe (isolateLaunch stubs it), over a stubbed disk. */ + const withDisk = (total: number | Error) => { + deps.stockProfileDiskCapacityKb = realQuotaProbe; + deps.diskTotal = () => { + if (total instanceof Error) throw total; + return total; + }; + }; + const quotaPrefs = (extra: Record = {}) => + prefsFor({ ...ua(LINUX_UA), ...extra }); + + it("is half the host disk, in KB", async () => { + const capacity = 512 * 1024 ** 3; + withDisk(capacity); + expect((await quotaPrefs())[PREF]).toBe(Math.floor(capacity / 2 / 1024)); + }); + + it("a small disk reports less than the 10 GiB cap", async () => { + const capacity = 40 * 1000 ** 3; + withDisk(capacity); + const limitKb = (await quotaPrefs())[PREF]; + const groupLimit = Math.min( + Math.floor((limitKb * 1024) / 5), + 10 * 1024 ** 3, + ); + expect(groupLimit).toBe(Math.floor(capacity / 2 / 5)); + expect(groupLimit).toBeLessThan(10 * 1024 ** 3); + }); + + it("a multi-terabyte disk stays in int32", async () => { + withDisk(16 * 1024 ** 4); + const limitKb = (await quotaPrefs())[PREF]; + expect(limitKb).toBeLessThanOrEqual(2 ** 31 - 1); + expect(Math.floor((limitKb * 1024) / 5)).toBeGreaterThanOrEqual( + 10 * 1024 ** 3, + ); + }); + + it("an unreadable disk leaves Gecko to measure", async () => { + withDisk(new Error("no such device")); + expect(PREF in (await quotaPrefs())).toBe(false); + }); + + it("the caller's pref wins", async () => { + withDisk(512 * 1024 ** 3); + expect( + (await quotaPrefs({ firefox_user_prefs: { [PREF]: 1234 } }))[PREF], + ).toBe(1234); + }); + }); +}); + +describe("test_launch_environment: stock media defaults", () => { + it("defaults are the JS API's no-override (null)", () => { + expect(utils.STOCK_MEDIA_DEFAULTS).toEqual({ + colorScheme: null, + reducedMotion: null, + forcedColors: null, + contrast: null, + }); + }); + + class FakeBrowser { + calls: Array<[string, Record]> = []; + newPage(opts: Record = {}) { + this.calls.push(["newPage", opts]); + } + newContext(opts: Record = {}) { + this.calls.push(["newContext", opts]); + } + } + + it("newPage and newContext get them", () => { + const browser = utils.attachStockMediaDefaults(new FakeBrowser()); + browser.newPage(); + browser.newContext(); + for (const [, opts] of browser.calls) { + expect(opts).toEqual({ + colorScheme: null, + reducedMotion: null, + forcedColors: null, + contrast: null, + }); + } + }); + + it("the caller's value wins", () => { + const browser = utils.attachStockMediaDefaults(new FakeBrowser()); + browser.newContext({ colorScheme: "dark", forcedColors: "active" }); + const opts = browser.calls[0][1]; + expect(opts.colorScheme).toBe("dark"); + expect(opts.forcedColors).toBe("active"); + expect(opts.reducedMotion).toBeNull(); + }); +}); + +describe("test_viewport_default", () => { + const opts = (blob: string) => { + const chunks: string[] = []; + for (let i = 0; i < blob.length; i += 10) + chunks.push(blob.slice(i, i + 10)); + if (!chunks.length) chunks.push(""); + return { + env: Object.fromEntries( + chunks.map((c, i) => [`CAMOU_CONFIG_${i + 1}`, c]), + ), + }; + }; + + it.each([ + ['{"window.outerWidth": 360}', true], + ['{"window.innerHeight": 740}', true], + ['{"screen.width": 360}', false], + ['{"navigator.userAgent": "x"}', false], + ["{}", false], + ])("detects window dimension spoofing in %s", (blob, expected) => { + expect(utils.spoofsWindowDimensions(opts(blob))).toBe(expected); + }); + + it("reassembles chunks in index order", () => { + const blob = `{"padding": "${"x".repeat(200)}", "window.outerWidth": 360}`; + expect(utils.spoofsWindowDimensions(opts(blob))).toBe(true); + }); + + it("no env is not spoofed", () => { + expect(utils.spoofsWindowDimensions({})).toBe(false); + }); + + class FakeBrowser { + calls: Record[] = []; + newPage(o: Record = {}) { + this.calls.push(o); + return "page"; + } + newContext(o: Record = {}) { + this.calls.push(o); + return "context"; + } + } + + it("defaults to no viewport (viewport: null)", () => { + const b = utils.attachNoViewportDefault(new FakeBrowser()); + b.newPage(); + b.newContext(); + expect(b.calls).toEqual([{ viewport: null }, { viewport: null }]); + }); + + it.each([ + [ + { viewport: { width: 800, height: 600 } }, + { viewport: { width: 800, height: 600 } }, + ], + [{ noViewport: false }, {}], + [{ viewport: null }, { viewport: null }], + ])("the caller's explicit choice wins: %j", (override, expected) => { + const b = utils.attachNoViewportDefault(new FakeBrowser()); + b.newPage(override); + expect(b.calls).toEqual([expected]); + }); + + it("forwards other options", () => { + const b = utils.attachNoViewportDefault(new FakeBrowser()); + b.newPage({ locale: "en-US" }); + expect(b.calls).toEqual([{ locale: "en-US", viewport: null }]); + }); +}); + +describe("test_humanize", () => { + let captured: Record = {}; + beforeEach(() => { + isolateLaunch(); + deps.getEnvVars = (config) => { + captured = { ...config }; + return {}; + }; + }); + + const launchHumanize = (humanize: boolean | number) => + launch({ humanize, block_webgl: true, i_know_what_im_doing: true }).then( + () => captured, + ); + + it("humanize: true sets no duration", async () => { + const config = await launchHumanize(true); + expect(config.humanize).toBe(true); + expect("humanize:maxTime" in config).toBe(false); + }); + + it.each([ + 1, 5, 1.25, + ])("a duration (%s) is encoded as a double", async (duration) => { + const config = await launchHumanize(duration); + expect(config.humanize).toBe(true); + expect(config["humanize:maxTime"]).toBeInstanceOf(PyFloat); + expect(Number(config["humanize:maxTime"])).toBe(duration); + // ...and serialized with a floating-point representation. + expect(utils.configJson({ t: config["humanize:maxTime"] })).toBe( + `{"t":${Number.isInteger(duration) ? `${duration}.0` : duration}}`, + ); + }); +}); + +describe("test_executable_path_bundle", () => { + it("reads the fontconfig from the supplied bundle", () => { + deps.getPath = (() => { + throw new Error( + "resolved against the managed install despite executable_path", + ); + }) as any; + deps.osName = () => "lin"; + const env = utils.getEnvVars({}, "lin", BUNDLE_EXE); + const generated = String(env.FONTCONFIG_FILE); + expect(fs.existsSync(generated)).toBe(true); + // The bundled conf's cwd-relative is rewritten to this bundle's fonts. + expect(fs.readFileSync(generated, "utf-8")).toContain( + path.join(BUNDLE, "fonts"), + ); + }); + + it("uses the managed install when no path is given", () => { + const calls: string[] = []; + deps.osName = () => "lin"; + deps.getPath = ((file: string) => { + calls.push(file); + return "/nonexistent"; + }) as any; + expect(() => utils.getEnvVars({}, "lin")).toThrow(); + expect(calls.length).toBeGreaterThan(0); + }); +}); + +describe("test_executable_path_version_warning", () => { + const bundle = (build: string) => { + const dir = path.join(SCRATCH, `bundle-${build}`); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "version.json"), + JSON.stringify({ version: "152.0.4", build }), + ); + return path.join(dir, "camoufox-bin"); + }; + beforeEach(() => { + deps.effectiveVersionMin = () => new Version("beta.30"); + }); + const runtimeWarnings = async (exe: string | null) => + ( + await warnings.recordWarnings(() => + utils.warnIfExecutablePredatesPlaywright(exe), + ) + ).warnings.filter((w) => w.category === "RuntimeWarning"); + + it("warns when the supplied build is too old, naming the symptom", async () => { + const caught = await runtimeWarnings(bundle("beta.29")); + expect(caught).toHaveLength(1); + expect(caught[0].message).toMatch(/beta\.29.*beta\.30/); + expect(caught[0].message).toContain("Browser.setDefaultViewport"); + }); + + it.each([ + "beta.30", + "beta.31", + ])("is silent when the build (%s) is new enough", async (build) => { + expect(await runtimeWarnings(bundle(build))).toEqual([]); + }); + + it("is silent for a custom build with no version.json", async () => { + fs.mkdirSync(path.join(SCRATCH, "dist"), { recursive: true }); + expect( + await runtimeWarnings(path.join(SCRATCH, "dist", "camoufox-bin")), + ).toEqual([]); + }); + + it("is silent when no executable path was given", async () => { + expect(await runtimeWarnings(null)).toEqual([]); + }); +}); + +describe("test_voices (launch half)", () => { + beforeEach(() => stubHost()); + const cfgOptions = (extra: Record = {}) => ({ + headless: true, + i_know_what_im_doing: true, + executable_path: BUNDLE_EXE, + os: "macos", + fingerprint_preset: fingerprints.getRandomPreset("macos", "152"), + ...extra, + }); + const cfg = async (extra: Record = {}) => + configOf(await launch(cfgOptions(extra))); + + it("pins the block flag by default", async () => { + expect((await cfg())["voices:blockIfNotDefined"]).toBe(true); + }); + + it("lets the caller override the block flag", async () => { + expect( + (await cfg({ config: { "voices:blockIfNotDefined": false } }))[ + "voices:blockIfNotDefined" + ], + ).toBe(false); + }); + + it("fails closed when voice generation fails", async () => { + deps.generateRandomVoiceSubset = () => { + throw new OSError("voice-manifests.json unreadable"); + }; + const { config, warned } = await launchWarning( + cfgOptions(), + "FallbackWarning", + REPORT, + ); + expect(warned).toBe(true); + // An empty list plus the block flag means "no voices" -- never "all of + // the host's". + expect(config.voices).toEqual([]); + expect(config["voices:blockIfNotDefined"]).toBe(true); + }); + + it.each([ + [["Alex:en-US:local"]], + [[{ name: "Alex", lang: "en-US" }]], + ["Alex"], + ])("validateVoices rejects %j", (value) => { + expect(() => utils.validateVoices(value)).toThrow(InvalidPropertyType); + }); + + it.each([ + [[]], + [ + [ + { + lang: "en-US", + name: "Alex", + voiceUri: "urn:moz-tts:osx:alex", + isDefault: true, + isLocalService: true, + }, + ], + ], + ])("validateVoices accepts %j", (value) => { + expect(() => utils.validateVoices(value)).not.toThrow(); + }); +}); + +describe.skipIf(!modelReady)( + "test_launch_geometry / test_identity_salt (fpgen draws)", + () => { + beforeEach(() => stubHost()); + + const launchConfig = async (extra: Record) => + configOf( + await launch({ + os: "windows", + i_know_what_im_doing: true, + executable_path: BUNDLE_EXE, + ...extra, + }), + ); + const withDisplay = ( + screen: { maxWidth: number; maxHeight: number } | null, + ) => { + deps.hasDisplay = () => true; + deps.getScreenCons = () => + screen ? new fingerprints.Screen(screen) : null; + }; + + it("a virtual display is not a screen: the fingerprint is not shrunk to the 1x1 Xvfb", async () => { + withDisplay({ maxWidth: 1, maxHeight: 1 }); + const config = await launchConfig({ + headless: false, + virtual_display: ":99", + }); + expect(config["screen.width"]).toBeGreaterThan(1); + expect(config["screen.height"]).toBeGreaterThan(1); + expect(config["window.outerWidth"]).toBeGreaterThan(1); + for (const [key, value] of Object.entries(config)) { + if (key.startsWith("screen.") || key.startsWith("window.outer")) { + expect(value as number, key).toBeGreaterThanOrEqual(0); + } + } + }); + + it("headful generated geometry never exceeds the display (15 draws)", async () => { + withDisplay({ maxWidth: 1280, maxHeight: 720 }); + for (let attempt = 0; attempt < 15; attempt++) { + const config = await launchConfig({ headless: false }); + for (const [key, bound] of [ + ["screen.width", 1280], + ["screen.height", 720], + ["window.outerWidth", 1280], + ["window.outerHeight", 720], + ] as const) { + expect(config[key], key).toBeLessThanOrEqual(bound); + } + expect(config["screen.availWidth"]).toBeLessThanOrEqual( + config["screen.width"], + ); + expect(config["screen.availHeight"]).toBeLessThan( + config["screen.height"], + ); + expect(config["window.outerWidth"]).toBeLessThanOrEqual( + config["screen.availWidth"], + ); + expect(config["window.outerHeight"]).toBeLessThanOrEqual( + config["screen.availHeight"], + ); + } + }); + + it("an unprobeable display is not clamped", async () => { + withDisplay(null); + let clamped = false; + deps.clampScreenToDisplay = () => { + clamped = true; + }; + await launchConfig({ headless: false }); + expect(clamped).toBe(false); + }); + + it("never spoofs the inner window dimensions", async () => { + for (const osName of ["linux", "windows", "macos"]) { + for (let i = 0; i < 5; i++) { + const config = await launchConfig({ os: osName }); + expect("window.innerWidth" in config, osName).toBe(false); + expect("window.innerHeight" in config, osName).toBe(false); + } + } + }); + + it("unpinned launches never share noise seeds", async () => { + const seeds = new Set(); + for (let i = 0; i < 40; i++) { + seeds.add( + (await launchConfig({ os: "linux", headless: true }))["audio:seed"], + ); + } + expect(seeds.size).toBe(40); + }); + + it("a fixed fingerprint reproduces every draw", async () => { + const fp = fingerprints.generateFingerprint({ os: "linux" }); + const drawn = (c: Record) => + JSON.stringify( + ["audio:seed", "fonts", "voices", "webGl:renderer"].map((k) => c[k]), + ); + const first = await launchConfig({ os: "linux", fingerprint: fp }); + const second = await launchConfig({ os: "linux", fingerprint: fp }); + expect(drawn(first)).toBe(drawn(second)); + }); + + it("a fixed preset reproduces the noise seeds and fonts", async () => { + const preset = fingerprints.getRandomPreset("windows", "150"); + const first = await launchConfig({ fingerprint_preset: preset }); + const second = await launchConfig({ fingerprint_preset: preset }); + expect(first["audio:seed"]).toBe(second["audio:seed"]); + expect(first.fonts).toEqual(second.fonts); + }); + + it.each([ + undefined, + false, + ])("fingerprint_preset=%s never draws a preset", async (off) => { + // False used to be checked with `!= null`, so it drew a random preset. + deps.getRandomPreset = () => { + throw new Error("preset drawn"); + }; + await launchConfig({ os: "linux", fingerprint_preset: off }); + }); + + it("generates no glyph-spacing seed", async () => { + // The noise was itself a fingerprint; the browser no longer has it. + expect("fonts:spacing_seed" in (await launchConfig({}))).toBe(false); + const context = fingerprints.generateContextFingerprint({ os: "linux" }); + expect("fonts:spacing_seed" in context.config).toBe(false); + expect(context.init_script).not.toContain("setFontSpacingSeed"); + }); + + it("config_overrides reach the config and the init script", () => { + const context = fingerprints.generateContextFingerprint({ + os: "linux", + config_overrides: { "audio:seed": 7 }, + }); + expect(context.config["audio:seed"]).toBe(7); + expect(context.init_script).toContain("setAudioFingerprintSeed(7)"); + }); + + it("a failed font draw warns and uses the OS's font list", async () => { + deps.generateRandomFontSubset = () => { + throw new OSError("font-bases.json missing"); + }; + const { config, warned } = await launchWarning( + { + os: "windows", + i_know_what_im_doing: true, + executable_path: BUNDLE_EXE, + }, + "FallbackWarning", + "OSError: font-bases.json missing", + ); + expect(warned).toBe(true); + expect(config.fonts.length).toBeGreaterThan(0); + }); + + it("a preset keeps its own GPU (test_webgl.py)", async () => { + const preset = fingerprints.loadPresets("152")?.presets.linux[0]; + const config = await launchConfig({ + os: "linux", + fingerprint_preset: preset, + }); + expect([config["webGl:vendor"], config["webGl:renderer"]]).toEqual([ + preset.webgl.unmaskedVendor, + preset.webgl.unmaskedRenderer, + ]); + }); + + it("a preset GPU fpgen has never seen raises", async () => { + const preset = { + ...fingerprints.loadPresets("152")?.presets.windows[0], + webgl: { + unmaskedVendor: "Google Inc. (Acme)", + unmaskedRenderer: + "ANGLE (Acme, Acme GPU 9000 Direct3D11 vs_5_0 ps_5_0)", + }, + }; + await expect( + launchConfig({ os: "windows", fingerprint_preset: preset }), + ).rejects.toThrow(/Acme GPU 9000/); + }); + + it("an unknown webgl_config raises", async () => { + await expect( + launchConfig({ + os: "windows", + webgl_config: ["Apple", "Apple M1, or similar"], + }), + ).rejects.toThrow(/No recorded WebGL data/); + }); + + it("a device without WebGL2 turns WebGL2 off", async () => { + const options = await launch({ + os: "windows", + headless: true, + i_know_what_im_doing: true, + executable_path: BUNDLE_EXE, + webgl_config: [ + "Google Inc. (Microsoft)", + "ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0), or similar", + ], + }); + expect(options.firefoxUserPrefs["webgl.enable-webgl2"]).toBe(false); + }); + + it("instantAnimations warns that it is detectable", async () => { + const { warned } = await launchWarning( + { + os: "windows", + executable_path: BUNDLE_EXE, + config: { instantAnimations: true }, + i_know_what_im_doing: false, + }, + "LeakWarning", + "getComputedTiming", + ); + expect(warned).toBe(true); + }); + + it("keeps the caller's seeds", async () => { + const config = await launchConfig({ + os: "linux", + config: { "audio:seed": 9 }, + }); + expect(config["audio:seed"]).toBe(9); + }); + + it("a Windows fr-FR identity has French voices", async () => { + for (let i = 0; i < 5; i++) { + const config = await launchConfig({ os: "windows", locale: "fr-FR" }); + expect( + new Set(config.voices.map((v: any) => v.lang)).has("fr-FR"), + ).toBe(true); + } + }); + + it.each([ + "windows", + "macos", + "linux", + ])("every bundled %s preset launches with its own GPU", async (osName) => { + const presets = fingerprints.loadPresets("150")?.presets[osName] ?? []; + expect(presets.length).toBeGreaterThan(0); + for (const [i, preset] of presets.entries()) { + const config = await launchConfig({ + os: osName, + fingerprint_preset: preset, + }); + expect(config["webGl:parameters"], `${osName} ${i}`).toBeTruthy(); + expect(config["webGl:renderer"], `${osName} ${i}`).toBe( + preset.webgl.unmaskedRenderer, + ); + } + }, 120_000); + }, +); + +describe("cpu_affinity", () => { + it("picks adjacent cores from a random start (test_identity_salt.TestAffinityPick)", () => { + const cores = Array.from({ length: 16 }, (_, i) => i); + const seen = new Set(); + for (let n = 0; n < 200; n++) { + const picked = cpuAffinity.pick(cores, 4); + expect(picked).toHaveLength(4); + expect(picked.every((c) => cores.includes(c))).toBe(true); + expect( + picked.some((s) => + [0, 1, 2, 3].every((i) => picked.includes((s + i) % 16)), + ), + ).toBe(true); + seen.add(picked.join(",")); + } + expect(seen.size).toBeGreaterThan(4); + }); + + it("parses a Linux cpu list", () => { + expect(cpuAffinity.parseCpuList("0-3,8,10-11\n")).toEqual([ + 0, 1, 2, 3, 8, 10, 11, + ]); + }); + + it("round-trips a Windows affinity mask", () => { + expect( + cpuAffinity.maskToCores(cpuAffinity.coresToMask([0, 2, 5, 63])), + ).toEqual([0, 2, 5, 63]); + }); + + it("pins only to a count the host can honour", () => { + const options = (hc: number) => ({ + env: utils.getEnvVars( + { "navigator.hardwareConcurrency": hc }, + "win", + BUNDLE_EXE, + ), + }); + const cores = cpuAffinity.hostCores() ?? []; + if (!cpuAffinity.supported() || cores.length < 3) return; + expect(utils.pinnedCoreCount(options(2))).toBe(2); + expect(utils.pinnedCoreCount(options(cores.length))).toBeNull(); + expect(utils.pinnedCoreCount({})).toBeNull(); + }); + + it.runIf(process.platform === "linux")( + "pins and restores this process on Linux", + () => { + if (!cpuAffinity.supported()) return; + const before = cpuAffinity.hostCores() ?? []; + if (before.length < 2) return; + const previous = cpuAffinity.pin(process.pid, 1); + try { + expect(previous).toEqual(before); + expect(cpuAffinity.hostCores()).toHaveLength(1); + } finally { + cpuAffinity.restore(process.pid, previous); + } + expect(cpuAffinity.hostCores()).toEqual(before); + }, + ); + + it.runIf(process.platform === "linux")( + "a concurrent launch sees the host's cores, not another launch's pin", + () => { + if (!cpuAffinity.supported()) return; + const host = cpuAffinity.hostCores() ?? []; + if (host.length < 3) return; + const options = utils.getEnvVars( + { "navigator.hardwareConcurrency": 2 }, + "win", + BUNDLE_EXE, + ); + const previous = cpuAffinity.pin(process.pid, 1); + try { + expect(cpuAffinity.hostCores()).toHaveLength(1); + expect(cpuAffinity.hostCoreCount()).toBe(host.length); + expect(fingerprints.hostCpuCount()).toBe(host.length); + expect(utils.pinnedCoreCount({ env: options })).toBe(2); + } finally { + cpuAffinity.restore(process.pid, previous); + } + }, + ); + + it("an unpinned launch waits for a pinned one to spawn and restore", async () => { + const order: string[] = []; + const pinned = cpuAffinity.withPinLock(async () => { + await new Promise((r) => setTimeout(r, 30)); + order.push("pinned launch restored"); + }); + const unpinned = cpuAffinity.withUnpinnedLaunch(async () => { + order.push("unpinned launch spawned"); + }); + await Promise.all([pinned, unpinned]); + expect(order).toEqual([ + "pinned launch restored", + "unpinned launch spawned", + ]); + }); +}); diff --git a/typescript/tests/locale.test.ts b/typescript/tests/locale.test.ts new file mode 100644 index 000000000..f2d531a39 --- /dev/null +++ b/typescript/tests/locale.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { InvalidLocale } from "../src/exceptions.js"; +import { handleLocale, handleLocales, normalizeLocale } from "../src/locale.js"; + +describe("normalizeLocale", () => { + it("splits language and region", () => { + const locale = normalizeLocale("en-US"); + expect(locale.language).toBe("en"); + expect(locale.region).toBe("US"); + expect(locale.asString).toBe("en-US"); + }); + + // Parity with pythonlib: the script is the LANGUAGE's Suppress-Script + // (record['Suppress-Script']), not the tag's explicit script subtag. + it("uses the language's suppress-script, not the tag's", () => { + const locale = normalizeLocale("zh-Hans-CN"); + expect(locale.language).toBe("zh"); + expect(locale.script).toBeUndefined(); + expect(locale.region).toBe("CN"); + }); + + it("adds the implicit suppress-script", () => { + expect(normalizeLocale("en-US").script).toBe("Latn"); + expect(normalizeLocale("fr-FR").script).toBe("Latn"); + }); + + it("rejects a locale with no region", () => { + expect(() => normalizeLocale("en")).toThrow(InvalidLocale); + }); + + it("rejects nonsense", () => { + expect(() => normalizeLocale("not a locale")).toThrow(InvalidLocale); + }); +}); + +describe("Locale.asConfig", () => { + it("emits the intl config keys", () => { + expect(normalizeLocale("fr-FR").asConfig()).toEqual({ + "locale:language": "fr", + "locale:region": "FR", + "locale:script": "Latn", + }); + }); + + it("omits the script when the language has no suppress-script", () => { + expect(normalizeLocale("zh-Hant-TW").asConfig()).toEqual({ + "locale:language": "zh", + "locale:region": "TW", + }); + }); +}); + +describe("handleLocale", () => { + it("resolves a bare region to a plausible language", async () => { + const locale = await handleLocale("JP"); + expect(locale.region).toBe("JP"); + expect(locale.language).toBeTruthy(); + }); + + it("passes a full tag straight through", async () => { + expect((await handleLocale("pt-BR")).asString).toBe("pt-BR"); + }); + + it("keeps a bare language when the region is not required", async () => { + expect((await handleLocale("de", true)).asString).toBe("de"); + }); +}); + +describe("handleLocales", () => { + it("uses the first locale for the intl config", async () => { + const config: Record = {}; + await handleLocales("fr-FR", config); + expect(config["locale:language"]).toBe("fr"); + expect(config["locale:region"]).toBe("FR"); + expect(config["locale:all"]).toBeUndefined(); + }); + + it("writes locale:all for a comma-separated list, deduplicated", async () => { + const config: Record = {}; + await handleLocales("en-US, fr-FR, en-US, de", config); + expect(config["locale:language"]).toBe("en"); + expect(config["locale:all"]).toBe("en-US, fr-FR, de"); + }); + + it("accepts an array as well as a string", async () => { + const config: Record = {}; + await handleLocales(["es-ES", "ca-ES"], config); + expect(config["locale:all"]).toBe("es-ES, ca-ES"); + }); +}); + +describe("StatisticalLocaleSelector (numpy parity)", () => { + it("sums like numpy (pairwise)", async () => { + const { pairwiseSum } = await import("../src/locales.js"); + // Values from numpy: 200 x 0.1 sums to 20.0 pairwise (20.000000000000014 + // sequentially); the 115-element one exercises the 8-way unroll + tail. + expect(pairwiseSum(Array.from({ length: 200 }, () => 0.1))).toBe(20); + const mixed = Array.from({ length: 23 }, () => [ + 0.3, 0.7, 1.1, 2.9, 0.01, + ]).flat(); + expect(pairwiseSum(mixed)).toBe(115.22999999999999); + expect(pairwiseSum([1, 2, 3])).toBe(6); + }); + + it("picks by searchsorted(right) of one uniform draw", async () => { + const { localeDeps, weightedChoice } = await import("../src/locales.js"); + const before = localeDeps.random; + try { + localeDeps.random = () => 0.5; + expect(weightedChoice(["a", "b", "c"], [0.25, 0.25, 0.5])).toBe("c"); + localeDeps.random = () => 0.49; + expect(weightedChoice(["a", "b", "c"], [0.25, 0.25, 0.5])).toBe("b"); + localeDeps.random = () => 0; + expect(weightedChoice(["a", "b"], [0, 1])).toBe("b"); + } finally { + localeDeps.random = before; + } + }); +}); diff --git a/typescript/tests/multiversion.test.ts b/typescript/tests/multiversion.test.ts new file mode 100644 index 000000000..efd4c45b2 --- /dev/null +++ b/typescript/tests/multiversion.test.ts @@ -0,0 +1,161 @@ +/** + * multiversion.ts against pythonlib/camoufox/multiversion.py: ordering is + * Python's (code-point string comparison, not locale collation), the install + * scan and specifier lookup, and removal bookkeeping. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +type MV = typeof import("../src/multiversion.js"); + +let tmp: string; +let savedXdg: string | undefined; +let mv: MV; + +beforeEach(async () => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-mv-")); + savedXdg = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = tmp; + vi.resetModules(); + mv = await import("../src/multiversion.js"); +}); + +afterEach(() => { + if (savedXdg === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = savedXdg; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +function addInstall(repo: string, folder: string, data: object) { + const dir = path.join(mv.BROWSERS_DIR, repo, folder); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "version.json"), JSON.stringify(data)); + return dir; +} + +it("latestPerBuild keeps the newest asset per build, sorted like Python", () => { + const v = (version: string, build: string, created_at: string) => + ({ version, build, url: "", created_at }) as any; + const out = mv.latestPerBuild([ + v("152.0.4", "beta.9", "2026-01-01"), + v("152.0.4", "beta.9", "2026-02-01"), + v("152.0.4", "beta.30", "2026-03-01"), + // Upper case sorts before lower case by code point; localeCompare would + // interleave them. + v("B", "x", "2026-01-01"), + v("a", "x", "2026-01-01"), + ]); + expect(out.map((x) => `${x.version}-${x.build}@${x.created_at}`)).toEqual([ + "a-x@2026-01-01", + "B-x@2026-01-01", + // Same version string: the tie falls to created_at, newest first. + "152.0.4-beta.30@2026-03-01", + "152.0.4-beta.9@2026-02-01", + ]); +}); + +it("lists installs by repo then version, descending, and finds them", () => { + addInstall("official", "152.0.4-beta.29", { + version: "152.0.4", + build: "beta.29", + }); + addInstall("official", "152.0.4-beta.30-3a7958c8", { + version: "152.0.4", + build: "beta.30", + sha256: "3a7958c8ffff", + prerelease: false, + }); + addInstall("Zed", "1.0-alpha.1", { version: "1.0", build: "alpha.1" }); + fs.mkdirSync(path.join(mv.BROWSERS_DIR, ".hidden", "x"), { recursive: true }); + + const installed = mv.listInstalled(); + expect(installed.map((v) => v.relativePath)).toEqual([ + "browsers/official/152.0.4-beta.30-3a7958c8", + "browsers/official/152.0.4-beta.29", + "browsers/Zed/1.0-alpha.1", + ]); + expect(installed[0].channelPath).toBe("official/stable/152.0.4-beta.30"); + + expect(mv.findInstalledVersion("beta.29")).toBe(installed[1].path); + expect(mv.findInstalledVersion("official/beta.30")).toBe(installed[0].path); + expect(mv.findInstalledVersion("152.0.4-beta.30")).toBe(installed[0].path); + expect(mv.findInstalledVersion("nope")).toBeNull(); +}); + +it("auto-selects the first install when no channel or pin is set", () => { + const dir = addInstall("official", "152.0.4-beta.30", { + version: "152.0.4", + build: "beta.30", + }); + expect(mv.getActivePath()).toBe(dir); + expect(mv.loadConfig().active_version).toBe( + "browsers/official/152.0.4-beta.30", + ); + // With a channel set, nothing is auto-selected. + mv.saveConfig({ channel: "official/stable" }); + expect(mv.getActivePath()).toBeNull(); +}); + +it("writes config.json as orjson OPT_INDENT_2 does", () => { + mv.saveConfig({ channel: "official/stable", pinned: "x" }); + expect(fs.readFileSync(mv.CONFIG_FILE, "utf-8")).toBe( + '{\n "channel": "official/stable",\n "pinned": "x"\n}', + ); +}); + +it("removeVersion prunes empty parents and re-points the active version", () => { + const a = addInstall("official", "152.0.4-beta.30", { + version: "152.0.4", + build: "beta.30", + }); + const b = addInstall("other", "1.0-beta.1", { + version: "1.0", + build: "beta.1", + }); + mv.setActive("browsers/other/1.0-beta.1"); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + expect(mv.removeVersion(b)).toBe(true); + expect(fs.existsSync(path.dirname(b))).toBe(false); + expect(mv.loadConfig().active_version).toBe( + "browsers/official/152.0.4-beta.30", + ); + + expect(mv.removeVersion(a)).toBe(true); + expect(fs.existsSync(mv.BROWSERS_DIR)).toBe(false); + expect(mv.loadConfig().active_version).toBeNull(); + expect(mv.removeVersion(a)).toBe(false); + vi.restoreAllMocks(); +}); + +it("classifies catalog items against installs, legacy folders included", async () => { + const { AvailableVersion, Version } = await import("../src/pkgman.js"); + addInstall("official", "152.0.4-beta.30-aaaaaaaa", { + version: "152.0.4", + build: "beta.30", + sha256: "aaaaaaaa11", + }); + addInstall("official", "152.0.4-beta.29", { + version: "152.0.4", + build: "beta.29", + }); + const av = (build: string, sha?: string) => + new AvailableVersion({ + version: new Version(build, "152.0.4"), + url: "", + isPrerelease: false, + sha256: sha, + }); + const [rows, extras] = mv.classifyInstalls( + [av("beta.30", "aaaaaaaa11"), av("beta.30", "bbbbbbbb22"), av("beta.29")], + mv.listInstalled(), + ); + expect(rows.map((r) => r?.folderName ?? null)).toEqual([ + "152.0.4-beta.30-aaaaaaaa", + null, + "152.0.4-beta.29", + ]); + expect(extras).toEqual([]); +}); diff --git a/typescript/tests/package-parity.test.ts b/typescript/tests/package-parity.test.ts new file mode 100644 index 000000000..b3c282971 --- /dev/null +++ b/typescript/tests/package-parity.test.ts @@ -0,0 +1,50 @@ +/** + * The package ships what the Python package ships, at the same version. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { LIBRARY_VERSION } from "../src/__version__.js"; +import { DATA_FILES, LOCAL_DATA } from "../src/paths.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG = path.resolve(HERE, ".."); +const PYLIB = path.resolve(PKG, "..", "pythonlib"); +const pkg = JSON.parse( + fs.readFileSync(path.join(PKG, "package.json"), "utf-8"), +); +const pyproject = fs.readFileSync(path.join(PYLIB, "pyproject.toml"), "utf-8"); + +describe("package.json tracks pyproject.toml", () => { + it("has the same version, and __version__.ts agrees", () => { + const version = pyproject.match(/^version = "([^"]+)"/m)?.[1]; + expect(pkg.version).toBe(version); + expect(LIBRARY_VERSION).toBe(version); + }); + + it("caps playwright-core where pyproject caps playwright", () => { + const cap = pyproject.match(/^playwright = "([^"]+)"/m)?.[1]; + expect(cap).toBeDefined(); + expect(pkg.peerDependencies["playwright-core"]).toBe(cap); + const installed = JSON.parse( + fs.readFileSync( + path.join(PKG, "node_modules", "playwright-core", "package.json"), + "utf-8", + ), + ).version as string; + const [major, minor] = installed.split(".").map(Number); + const [capMajor, capMinor] = String(cap) + .replace("<", "") + .split(".") + .map(Number); + expect(major * 1000 + minor).toBeLessThan(capMajor * 1000 + capMinor); + }); + + it("reads every data file from pythonlib, the one copy", () => { + expect(path.resolve(LOCAL_DATA)).toBe(path.join(PYLIB, "camoufox")); + for (const name of DATA_FILES) + expect(fs.existsSync(path.join(LOCAL_DATA, name)), name).toBe(true); + expect(fs.existsSync(path.join(PKG, "src", "fpgen", "NOTICE"))).toBe(true); + }); +}); diff --git a/typescript/tests/pkgman.test.ts b/typescript/tests/pkgman.test.ts new file mode 100644 index 000000000..b095b0a6c --- /dev/null +++ b/typescript/tests/pkgman.test.ts @@ -0,0 +1,178 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { FileNotFoundError } from "../src/exceptions.js"; +import { + AvailableVersion, + formatAssetDate, + LOCAL_DATA, + loadYaml, + OS_ARCH_MATRIX, + OS_NAME, + pkgmanDeps, + RepoConfig, + Version, +} from "../src/pkgman.js"; + +describe("Version ordering", () => { + it("orders alpha < beta < numeric builds", () => { + const alpha = new Version("alpha.5"); + const beta = new Version("beta.5"); + expect(alpha.lessThan(beta)).toBe(true); + expect(beta.lessThan(alpha)).toBe(false); + }); + + it("orders numerically within a channel", () => { + expect(new Version("beta.9").lessThan(new Version("beta.20"))).toBe(true); + expect(new Version("beta.20").lessThan(new Version("beta.9"))).toBe(false); + }); + + it("treats equal builds as equal", () => { + expect(new Version("beta.20").equals(new Version("beta.20"))).toBe(true); + }); + + it("reports the full version string", () => { + expect(new Version("beta.28", "152.0.4").fullString).toBe( + "152.0.4-beta.28", + ); + }); + + it("detects the alpha channel", () => { + expect(new Version("alpha.26").isAlpha).toBe(true); + expect(new Version("beta.26").isAlpha).toBe(false); + }); + + it("accepts builds inside the supported range", () => { + // CONSTRAINTS is alpha.1 <= v < 1, raised by the Playwright floor + // (beta.30 from Playwright 1.61). + const saved = pkgmanDeps.resolvedPlaywrightVersion; + try { + pkgmanDeps.resolvedPlaywrightVersion = () => [1, 60, 0]; + expect(new Version("beta.28").isSupported()).toBe(true); + expect(new Version("alpha.1").isSupported()).toBe(true); + pkgmanDeps.resolvedPlaywrightVersion = () => [1, 62, 0]; + expect(new Version("beta.28").isSupported()).toBe(false); + expect(new Version("beta.30").isSupported()).toBe(true); + expect(new Version("1").isSupported()).toBe(false); + } finally { + pkgmanDeps.resolvedPlaywrightVersion = saved; + } + }); + + it("reads version.json like the Python twin (release/tag win over build)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-vj-")); + try { + const write = (data: object) => + fs.writeFileSync(path.join(dir, "version.json"), JSON.stringify(data)); + write({ version: "1.0", build: "beta.1" }); + expect(Version.fromPath(dir).fullString).toBe("1.0-beta.1"); + write({ version: "1.0", build: "beta.1", release: "beta.2" }); + expect(Version.fromPath(dir).build).toBe("beta.2"); + write({ version: "1.0", tag: "beta.3" }); + expect(Version.fromPath(dir).build).toBe("beta.3"); + fs.rmSync(path.join(dir, "version.json")); + expect(() => Version.fromPath(dir)).toThrow(FileNotFoundError); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("RepoConfig", () => { + it("parses the comma-separated fallback repo list", () => { + const official = RepoConfig.findByName("Official"); + expect(official).toBeDefined(); + expect(official?.repos).toEqual(["daijro/camoufox", "camoufox/camoufox"]); + expect(official?.repo).toBe("daijro/camoufox"); + }); + + it("defaults to the repo named in the config", () => { + expect(RepoConfig.getDefaultName()).toBe("Official"); + expect(RepoConfig.getDefault().name).toBe("Official"); + }); + + it("builds an asset regex that captures name/version/build", () => { + const config = RepoConfig.getDefault(); + const pattern = config.buildPattern("lin", "x86_64"); + const match = pattern.exec("camoufox-152.0.4-beta.28-lin.x86_64.zip"); + expect(match?.groups?.version).toBe("152.0.4"); + expect(match?.groups?.build).toBe("beta.28"); + }); + + it("does not match another platform's asset", () => { + const pattern = RepoConfig.getDefault().buildPattern("lin", "x86_64"); + expect(pattern.exec("camoufox-152.0.4-beta.28-win.x86_64.zip")).toBeNull(); + }); + + it("applies the stable channel's build floor", () => { + const official = RepoConfig.getDefault(); + // Official pins stable to beta.19+; prerelease is unbounded. + expect(official.isVersionSupported(new Version("beta.28"), false)).toBe( + true, + ); + expect(official.isVersionSupported(new Version("beta.10"), false)).toBe( + false, + ); + expect(official.isVersionSupported(new Version("alpha.1"), true)).toBe( + true, + ); + }); + + it("treats a repo with no browser constraints as unbounded", () => { + const coryking = RepoConfig.findByName("CoryKing"); + expect(coryking?.isVersionSupported(new Version("beta.1"), false)).toBe( + true, + ); + }); +}); + +describe("platform matrix", () => { + it("knows the arches the current OS ships", () => { + expect(OS_ARCH_MATRIX[OS_NAME].length).toBeGreaterThan(0); + }); +}); + +describe("formatAssetDate", () => { + it("omits the year for the current year", () => { + const now = new Date("2026-08-02T00:00:00Z"); + expect(formatAssetDate("2026-03-14T10:00:00Z", now)).toMatch(/^Mar 1[34]$/); + }); + + it("includes the year for another year", () => { + const now = new Date("2026-08-02T00:00:00Z"); + expect(formatAssetDate("2024-03-14T10:00:00Z", now)).toMatch( + /^Mar 1[34], 2024$/, + ); + }); + + it("returns empty for missing or unparseable input", () => { + expect(formatAssetDate(undefined)).toBe(""); + expect(formatAssetDate("not-a-date")).toBe(""); + }); +}); + +describe("repos.yml", () => { + it("is the file the Python package ships", () => { + const shipped = fs.readFileSync( + path.join(import.meta.dirname, "../../pythonlib/camoufox/repos.yml"), + "utf-8", + ); + const ours = fs.readFileSync(path.join(LOCAL_DATA, "repos.yml"), "utf-8"); + expect(ours).toBe(shipped); + expect(loadYaml("repos.yml").default.browser).toBe("Official"); + }); +}); + +describe("AvailableVersion.toMetadata", () => { + it("writes unknown fields as null, as orjson does for None", () => { + const v = new AvailableVersion({ + version: new Version("beta.30", "152.0.4"), + url: "u", + isPrerelease: false, + }); + expect(JSON.stringify(v.toMetadata())).toBe( + '{"version":"152.0.4","build":"beta.30","prerelease":false,"asset_id":null,"asset_size":null,"asset_updated_at":null,"sha256":null,"created_at":null}', + ); + }); +}); diff --git a/typescript/tests/prereq.ts b/typescript/tests/prereq.ts new file mode 100644 index 000000000..9e87ff60b --- /dev/null +++ b/typescript/tests/prereq.ts @@ -0,0 +1,36 @@ +/** + * Test prerequisites that live outside the repository: the fpgen model, a + * Python with pythonlib, and Xvfb. + * + * On a developer machine a missing one skips the tests that need it, with the + * reason printed. In CI (GitHub sets CI=true) it FAILS instead: a skip reads as + * green, so a job that forgot to install something would pass having tested + * nothing -- which is how these suites came to be skipped on the runner while + * passing on the machine they were written on. A CI job that genuinely cannot + * provide one lists it in CAMOUFOX_TEST_ALLOW_MISSING (comma-separated), next to + * a comment saying why. + */ + +const allowedMissing = new Set( + (process.env.CAMOUFOX_TEST_ALLOW_MISSING ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), +); + +/** + * `ok` when the prerequisite `name` is available. Otherwise false (skip) on a + * developer machine, and a thrown error -- failing the test file -- in CI. + */ +export function prerequisite(name: string, ok: boolean, detail = ""): boolean { + if (ok) return true; + const why = detail ? `${name} (${detail})` : name; + if (process.env.CI && !allowedMissing.has(name)) { + throw new Error( + `test prerequisite missing in CI: ${why}. Install it in the job, or add ` + + `"${name}" to CAMOUFOX_TEST_ALLOW_MISSING with the reason it cannot be.`, + ); + } + console.warn(`[tests] skipping what needs ${why}: not available here`); + return false; +} diff --git a/typescript/tests/profile-directory.test.ts b/typescript/tests/profile-directory.test.ts new file mode 100644 index 000000000..107467bff --- /dev/null +++ b/typescript/tests/profile-directory.test.ts @@ -0,0 +1,118 @@ +/** + * Mirrors pythonlib/tests/test_profile_directory.py (the pkgman half; the + * launch-preflight ordering is covered with the launcher tests): Firefox + * probes ~/.camoufox at startup even with a Playwright-supplied profile. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +let tmp: string; +let home: string; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-profile-")); + home = path.join(tmp, "home"); + vi.resetModules(); +}); + +afterEach(() => { + vi.doUnmock("../src/paths.js"); + vi.doUnmock("../src/multiversion.js"); + for (const p of [path.join(home, ".camoufox"), home]) { + try { + fs.chmodSync(p, 0o700); + } catch {} + } + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +async function pkgmanAs(osName: "lin" | "mac" | "win") { + vi.doMock("../src/paths.js", async (importOriginal) => ({ + ...(await importOriginal()), + OS_NAME: osName, + })); + return import("../src/pkgman.js"); +} + +it("creates a missing Linux profile directory", async () => { + fs.mkdirSync(home); + const pkgman = await pkgmanAs("lin"); + + const profileDir = pkgman.ensureBrowserProfileDir({ HOME: home }); + + expect(profileDir).toBe(path.join(home, ".camoufox")); + expect(fs.statSync(profileDir as string).isDirectory()).toBe(true); +}); + +it("accepts an existing profile directory that is read-only", async () => { + const profileDir = path.join(home, ".camoufox"); + fs.mkdirSync(profileDir, { recursive: true }); + const pkgman = await pkgmanAs("lin"); + fs.chmodSync(home, 0o500); + fs.chmodSync(profileDir, 0o500); + + expect(pkgman.ensureBrowserProfileDir({ HOME: home })).toBe(profileDir); +}); + +it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "fails fast when the profile directory cannot be created", + async () => { + fs.mkdirSync(home); + const pkgman = await pkgmanAs("lin"); + const { ProfileDirectoryError } = await import("../src/exceptions.js"); + fs.chmodSync(home, 0o500); + + expect(() => pkgman.ensureBrowserProfileDir({ HOME: home })).toThrow( + ProfileDirectoryError, + ); + expect(() => pkgman.ensureBrowserProfileDir({ HOME: home })).toThrow( + /\.camoufox.*before.*read-only/, + ); + }, +); + +it("does not create the Linux directory on other platforms", async () => { + fs.mkdirSync(home); + const pkgman = await pkgmanAs("mac"); + + expect(pkgman.ensureBrowserProfileDir({ HOME: home })).toBeUndefined(); + expect(fs.existsSync(path.join(home, ".camoufox"))).toBe(false); +}); + +it("falls back to the process HOME when the env mapping has none", async () => { + fs.mkdirSync(home); + const saved = process.env.HOME; + process.env.HOME = home; + try { + const pkgman = await pkgmanAs("lin"); + expect(pkgman.ensureBrowserProfileDir({})).toBe( + path.join(home, ".camoufox"), + ); + } finally { + process.env.HOME = saved; + } +}); + +it("fetch prepares the profile directory", async () => { + fs.mkdirSync(home); + const saved = process.env.HOME; + process.env.HOME = home; + try { + vi.doMock("../src/multiversion.js", async (importOriginal) => ({ + ...(await importOriginal()), + installVersioned: async () => true, + })); + const pkgman = await pkgmanAs("lin"); + const fetcher = Object.create( + pkgman.CamoufoxFetcher.prototype, + ) as InstanceType; + + await fetcher.install(); + + expect(fs.statSync(path.join(home, ".camoufox")).isDirectory()).toBe(true); + } finally { + process.env.HOME = saved; + } +}); diff --git a/typescript/tests/proxy-geo.test.ts b/typescript/tests/proxy-geo.test.ts new file mode 100644 index 000000000..d58804fa2 --- /dev/null +++ b/typescript/tests/proxy-geo.test.ts @@ -0,0 +1,142 @@ +/** + * NewContext derives the WebRTC IP and timezone from the proxy's exit IP. + * Twin of pythonlib/tests/test_proxy_geo.py: the lookup must go through the + * proxy as Playwright would reach it (a scheme-less server is http), and a + * failed lookup must raise rather than leave the context on the host's values. + * Also the twin of test_new_context_version.py: the context's UA names the + * browser's Firefox version. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const impit = vi.hoisted(() => ({ + proxyUrls: [] as (string | undefined)[], + respond: async (): Promise => ({}), +})); + +vi.mock("impit", () => ({ + Impit: class { + // ip.ts caches one client per proxy URL, so record each request. + constructor(private options: { proxyUrl?: string }) {} + async fetch() { + impit.proxyUrls.push(this.options.proxyUrl); + const body = await impit.respond(); + return { ok: true, status: 200, json: async () => body }; + } + }, +})); + +const { NewContext } = await import("../src/sync_api.js"); +const { InvalidIP } = await import("../src/exceptions.js"); +const { getRandomPreset } = await import("../src/fingerprints.js"); + +const EXIT = { + status: "success", + query: "203.0.113.7", + timezone: "Europe/Paris", +}; + +function fakeBrowser(version = "152.0.4") { + const calls: { options?: any; script?: string } = {}; + const browser = { + // Playwright's Browser.version() for Firefox: MOZ_APP_VERSION_DISPLAY. + version: () => version, + newContext: async (options: any) => { + calls.options = options; + return { + addInitScript: async (script: string) => { + calls.script = script; + }, + }; + }, + }; + return { browser: browser as any, calls }; +} + +beforeEach(() => { + impit.proxyUrls.length = 0; + impit.respond = async () => EXIT; +}); + +describe("NewContext proxy lookup", () => { + it.each([ + ["1.2.3.4:8080", "http://u:p@1.2.3.4:8080"], + ["proxy.example.com:8080", "http://u:p@proxy.example.com:8080"], + ["http://proxy.example.com:8080", "http://u:p@proxy.example.com:8080"], + ["socks5://proxy.example.com:1080", "socks5://u:p@proxy.example.com:1080"], + ])("%s is looked up through %s", async (server, expected) => { + const { browser, calls } = fakeBrowser(); + await NewContext(browser, { + os: "linux", + proxy: { server, username: "u", password: "p" }, + }); + expect(impit.proxyUrls).toContain(expected); + expect(calls.options.timezoneId).toBe("Europe/Paris"); + expect(calls.script).toContain("203.0.113.7"); + }); + + it.each([ + [ + "an unreachable proxy", + async () => { + throw new Error("proxy refused"); + }, + ], + [ + "a failed lookup", + async () => ({ status: "fail", message: "private range" }), + ], + ])("%s raises instead of launching without the values", async (_, respond) => { + impit.respond = respond; + const { browser } = fakeBrowser(); + const launched = NewContext(browser, { + os: "linux", + proxy: { server: "1.2.3.4:8080" }, + }); + await expect(launched).rejects.toThrow(InvalidIP); + await expect(launched).rejects.toThrow(/webrtc_ip/); + }); + + it("skips the lookup when both values are given", async () => { + const { browser } = fakeBrowser(); + await NewContext(browser, { + os: "linux", + proxy: { server: "1.2.3.4:8080" }, + webrtc_ip: "198.51.100.1", + timezoneId: "UTC", + } as any); + expect(impit.proxyUrls).toEqual([]); + }); +}); + +describe("NewContext identity", () => { + it("the user agent carries the browser's Firefox version", async () => { + const { browser, calls } = fakeBrowser("160.0.1"); + await NewContext(browser, { os: "linux" }); + const userAgent = /setNavigatorUserAgent\("([^"]+)"\)/.exec( + calls.script ?? "", + )?.[1]; + expect(userAgent).toContain("Firefox/160.0"); + expect(userAgent).toContain("rv:160.0"); + }); + + it("hands Playwright the identity under its own option names", async () => { + // Re-casing these turned userAgent into `useragent`, which Playwright + // drops: the HTTP User-Agent then disagreed with navigator.userAgent. + const { browser, calls } = fakeBrowser(); + // The v150 presets all carry a DPR; the older bundle's do not. + const preset = getRandomPreset("windows", "152"); + expect(preset?.screen?.devicePixelRatio).toBeGreaterThan(0); + await NewContext(browser, { preset, timezoneId: "Asia/Tokyo" } as any); + const userAgent = /setNavigatorUserAgent\("([^"]+)"\)/.exec( + calls.script ?? "", + )?.[1]; + expect(calls.options.userAgent).toBe(userAgent); + expect(calls.options.deviceScaleFactor).toBeGreaterThan(0); + expect(calls.options.viewport.width).toBeGreaterThan(0); + expect(calls.options.timezoneId).toBe("Asia/Tokyo"); + for (const key of Object.keys(calls.options)) + expect(key, "a key Playwright does not know").not.toMatch( + /^(useragent|devicescalefactor|timezoneid)$/, + ); + }); +}); diff --git a/typescript/tests/pyrandom.test.ts b/typescript/tests/pyrandom.test.ts new file mode 100644 index 000000000..b64ef7829 --- /dev/null +++ b/typescript/tests/pyrandom.test.ts @@ -0,0 +1,59 @@ +/** + * PyRandom contract checks. The bit-exact sequences (every method, 21 seeds, + * recorded under CPython 3.10-3.14 alike) live in identity-golden.test.ts. + */ +import { describe, expect, it } from "vitest"; +import { PyRandom, pyRandom } from "../src/pyrandom.js"; + +describe("PyRandom", () => { + it("matches CPython's first draws for well-known seeds", () => { + // python3 -c "import random; r=random.Random(42); print(r.random())" + expect(new PyRandom(42).random()).toBe(0.6394267984578837); + expect(new PyRandom(0).random()).toBe(0.8444218515250481); + }); + + it("seeds from abs(n), like CPython", () => { + expect(new PyRandom(-7).random()).toBe(new PyRandom(7).random()); + expect(new PyRandom(2n ** 70n).random()).not.toBe(new PyRandom(0).random()); + }); + + it("reseeding restarts the stream", () => { + const r = new PyRandom(5); + const first = [r.random(), r.random()]; + r.seed(5); + expect([r.random(), r.random()]).toEqual(first); + }); + + it("unseeded generators differ", () => { + expect(new PyRandom().random()).not.toBe(new PyRandom().random()); + }); + + it("raises where CPython raises", () => { + const r = new PyRandom(1); + expect(() => r.choice([])).toThrow(); + expect(() => r.sample([1, 2], 3)).toThrow(/larger than population/); + expect(() => r.randrange(5, 5)).toThrow(/empty range/); + expect(() => r.randrange(0, 10, 0)).toThrow(/zero step/); + expect(() => r.choices([1, 2], { weights: [1], k: 1 })).toThrow(); + expect(() => new PyRandom(1.5)).toThrow(); + }); + + it("keeps sample() results distinct in both branches", () => { + const r = new PyRandom(3); + for (const [n, k] of [ + [10, 10], + [1000, 50], + ]) { + const s = r.sample( + Array.from({ length: n }, (_, i) => i), + k, + ); + expect(new Set(s).size).toBe(k); + } + }); + + it("exposes a shared module-level generator", () => { + pyRandom.seed(42); + expect(pyRandom.random()).toBe(0.6394267984578837); + }); +}); diff --git a/typescript/tests/server-display.test.ts b/typescript/tests/server-display.test.ts new file mode 100644 index 000000000..8dc4b0b0a --- /dev/null +++ b/typescript/tests/server-display.test.ts @@ -0,0 +1,50 @@ +/** + * Ports of pythonlib/tests/test_server.py (the parts that apply: TS calls + * playwright-core's launchServer in-process, so there is no launchServer.js + * child to reap) and test_display.py's has_display(). + */ +import { describe, expect, it } from "vitest"; +import { hasDisplay } from "../src/display.js"; +import { OS_NAME } from "../src/pkgman.js"; +import { launchServer, toCamelCaseDict } from "../src/server.js"; +import { camelCase } from "../src/sync_api.js"; + +describe("launchServer", () => { + it.each([ + "persistent_context", + "user_data_dir", + ])("rejects %s: a persistent context cannot be served", async (option) => { + await expect( + launchServer({ + [option]: option === "user_data_dir" ? "/tmp/profile" : true, + }), + ).rejects.toThrow(/does not support/); + }); + + it("camelCases option keys the way server.camel_case does", () => { + expect(camelCase("ws_path")).toBe("wsPath"); + expect(camelCase("firefox_user_prefs")).toBe("firefoxUserPrefs"); + expect(camelCase("_private_key")).toBe("_privateKey"); + expect(camelCase("x")).toBe("x"); + expect( + toCamelCaseDict({ ws_path: "/a", port: 1, executablePath: "/b" }), + ).toEqual({ + wsPath: "/a", + port: 1, + executablePath: "/b", + }); + }); +}); + +describe("hasDisplay", () => { + it.runIf(OS_NAME !== "lin")("is always true off Linux", () => { + expect(hasDisplay({})).toBe(true); + }); + + it.runIf(OS_NAME === "lin")("needs a session on Linux", () => { + expect(hasDisplay({})).toBe(false); + expect(hasDisplay({ DISPLAY: ":0" })).toBe(true); + expect(hasDisplay({ WAYLAND_DISPLAY: "wayland-0" })).toBe(true); + expect(hasDisplay({ DISPLAY: "" })).toBe(false); + }); +}); diff --git a/typescript/tests/utils.test.ts b/typescript/tests/utils.test.ts new file mode 100644 index 000000000..be02e8e5b --- /dev/null +++ b/typescript/tests/utils.test.ts @@ -0,0 +1,196 @@ +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { PyFloat } from "../src/pycompat.js"; +import { + checkValidOs, + configJson, + determineUaOs, + getEnvVars, + getTargetOs, + isDomainSet, + mergeInto, + pyJsonDumpsAscii, + pyTypeName, + setInto, + spoofsWindowDimensions, + validateType, +} from "../src/utils.js"; + +const BUNDLE_EXE = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures", + "launch", + "bundle", + "camoufox-bin", +); + +const WINDOWS_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0"; +const MAC_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0"; +const LINUX_UA = + "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"; + +describe("determineUaOs", () => { + it("maps each supported UA to its short OS name", () => { + expect(determineUaOs(WINDOWS_UA)).toBe("win"); + expect(determineUaOs(MAC_UA)).toBe("mac"); + expect(determineUaOs(LINUX_UA)).toBe("lin"); + }); + + it("falls back to lin for an unparseable UA, as the Python twin does", () => { + // Python's ua_parser answers "Other" rather than nothing, so its `raise` + // never fires. Throwing here instead would make + // `config: {"navigator.userAgent": ...}` launch under one launcher and + // hard-error under the other. + expect(determineUaOs("X")).toBe("lin"); + expect(determineUaOs("")).toBe("lin"); + }); +}); + +describe("getTargetOs", () => { + it("derives the OS from the config's user agent", () => { + expect(getTargetOs({ "navigator.userAgent": WINDOWS_UA })).toBe("win"); + }); +}); + +describe("checkValidOs", () => { + it("accepts the supported OS names, individually and as a list", () => { + expect(() => checkValidOs("windows")).not.toThrow(); + expect(() => checkValidOs(["macos", "linux"])).not.toThrow(); + }); + + it("rejects unsupported and non-lowercase names", () => { + expect(() => checkValidOs("Windows")).toThrow(/lowercase/); + expect(() => checkValidOs("solaris")).toThrow(/does not support/); + }); +}); + +describe("validateType", () => { + it("matches properties.json type names to JS values", () => { + expect(validateType("x", "str")).toBe(true); + expect(validateType(1, "str")).toBe(false); + expect(validateType(1, "int")).toBe(true); + expect(validateType(1.5, "int")).toBe(false); + expect(validateType(-1, "uint")).toBe(false); + expect(validateType(1.5, "double")).toBe(true); + expect(validateType(true, "bool")).toBe(true); + expect(validateType([], "array")).toBe(true); + expect(validateType({}, "dict")).toBe(true); + expect(validateType([], "dict")).toBe(false); + expect(validateType("x", "unknown-type")).toBe(false); + // Python's bool is an int, so it passes the numeric checks there too. + expect(validateType(true, "uint")).toBe(true); + // An integral Python float passes int/uint (float.is_integer()). + expect(validateType(new PyFloat(2), "uint")).toBe(true); + expect(validateType(new PyFloat(2), "str")).toBe(false); + }); +}); + +describe("isDomainSet", () => { + it("matches exact keys and dotted/colon prefixes", () => { + const config = { "navigator.platform": "Win32", "locale:region": "US" }; + expect(isDomainSet(config, "navigator.platform")).toBe(true); + expect(isDomainSet(config, "navigator.")).toBe(true); + expect(isDomainSet(config, "locale:")).toBe(true); + expect(isDomainSet(config, "screen.")).toBe(false); + expect(isDomainSet(config, "timezone")).toBe(false); + }); +}); + +describe("mergeInto / setInto", () => { + it("never overwrites an existing key", () => { + const target: Record = { a: 1 }; + mergeInto(target, { a: 2, b: 3 }); + expect(target).toEqual({ a: 1, b: 3 }); + + setInto(target, "a", 9); + setInto(target, "c", 9); + expect(target).toEqual({ a: 1, b: 3, c: 9 }); + }); +}); + +describe("getEnvVars", () => { + it("chunks the config across CAMOU_CONFIG_ in index order", () => { + // A payload comfortably larger than the 32767-char POSIX chunk size. + const config = { "navigator.userAgent": "x".repeat(70_000) }; + const env = getEnvVars(config, "win", BUNDLE_EXE); + + const keys = Object.keys(env) + .filter((k) => k.startsWith("CAMOU_CONFIG_")) + .sort((a, b) => Number(a.split("_").pop()) - Number(b.split("_").pop())); + expect(keys.length).toBeGreaterThan(1); + expect(keys[0]).toBe("CAMOU_CONFIG_1"); + + const reassembled = keys.map((k) => env[k]).join(""); + expect(JSON.parse(reassembled)).toEqual(config); + }); +}); + +describe("spoofsWindowDimensions", () => { + it("reassembles chunks before looking for a window dimension key", () => { + const config = { "window.outerWidth": 1280 }; + expect( + spoofsWindowDimensions({ env: getEnvVars(config, "win", BUNDLE_EXE) }), + ).toBe(true); + }); + + it("is false when nothing spoofs a window dimension", () => { + expect( + spoofsWindowDimensions({ + env: getEnvVars({ "screen.width": 1920 }, "win", BUNDLE_EXE), + }), + ).toBe(false); + expect(spoofsWindowDimensions({})).toBe(false); + }); + + it("finds a key that straddles a chunk boundary", () => { + // Pad so "window.outerHeight" is split across two CAMOU_CONFIG_ vars. + const config = { + pad: "x".repeat(32_750), + "window.outerHeight": 720, + }; + const env = getEnvVars(config, "win", BUNDLE_EXE); + expect( + Object.keys(env).filter((k) => k.startsWith("CAMOU_CONFIG_")).length, + ).toBeGreaterThan(1); + expect(spoofsWindowDimensions({ env })).toBe(true); + }); +}); + +describe("Python-compatible serialisation", () => { + it("chunks by code point, as Python slices a str", () => { + // 32767 emoji: one chunk in Python (code points), two UTF-16 halves each. + const config = { v: "\u{1F600}".repeat(40_000) }; + const env = getEnvVars(config, "win", BUNDLE_EXE); + const chunks = Object.keys(env).filter((k) => + k.startsWith("CAMOU_CONFIG_"), + ); + const blob = JSON.stringify(config); + expect(chunks).toHaveLength(Math.ceil(Array.from(blob).length / 32767)); + expect(Array.from(String(env.CAMOU_CONFIG_1))).toHaveLength(32767); + }); + + it("writes a PyFloat with its .0 and a bigint exactly", () => { + expect(configJson({ a: new PyFloat(2), b: 2, c: 1.5, d: 2n ** 64n })).toBe( + '{"a":2.0,"b":2,"c":1.5,"d":18446744073709551616}', + ); + }); + + it("names types the way Python does in its errors", () => { + expect(pyTypeName(1)).toBe("int"); + expect(pyTypeName(1.5)).toBe("float"); + expect(pyTypeName(new PyFloat(1))).toBe("float"); + expect(pyTypeName(true)).toBe("bool"); + expect(pyTypeName(null)).toBe("NoneType"); + expect(pyTypeName([])).toBe("list"); + expect(pyTypeName({})).toBe("dict"); + }); + + it("json.dumps(ensure_ascii=True) for the prefs", () => { + expect(pyJsonDumpsAscii({ a: "รฉ\u007f", b: 1e-5, c: [true, null] })).toBe( + '{"a":"\\u00e9\\u007f","b":1e-05,"c":[true,null]}', + ); + }); +}); diff --git a/typescript/tests/version-floor.test.ts b/typescript/tests/version-floor.test.ts new file mode 100644 index 000000000..704db7c7b --- /dev/null +++ b/typescript/tests/version-floor.test.ts @@ -0,0 +1,182 @@ +/** + * Mirrors pythonlib/tests/test_version_floor_upgrade.py: raising the browser + * floor must upgrade a below-floor install, in both install layouts, and the + * floor is keyed on the resolved Playwright. + * + * INSTALL_DIR is computed at import from XDG_CACHE_HOME, so every test points + * that at a fresh directory and re-imports the modules. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type Pkgman = typeof import("../src/pkgman.js"); +type Exceptions = typeof import("../src/exceptions.js"); + +let tmp: string; +let savedXdg: string | undefined; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-floor-")); + savedXdg = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = tmp; + vi.resetModules(); +}); + +afterEach(() => { + if (savedXdg === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = savedXdg; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +async function load(): Promise<{ pkgman: Pkgman; exc: Exceptions }> { + const pkgman = await import("../src/pkgman.js"); + const exc = await import("../src/exceptions.js"); + return { pkgman, exc }; +} + +/** Build an install dir in the given layout and point the library at it. */ +async function install( + layout: "versioned" | "legacy", + build: string, + floor: string, +) { + const { pkgman, exc } = await load(); + const root = pkgman.INSTALL_DIR; + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync(path.join(root, ".0.5_FLAG"), ""); + fs.writeFileSync(path.join(root, "repo_cache.json"), "{}"); + + let versionDir: string; + if (layout === "versioned") { + const relative = `browsers/official/152.0.4-${build}`; + fs.writeFileSync( + path.join(root, "config.json"), + JSON.stringify({ active_version: relative }), + ); + versionDir = path.join(root, relative); + fs.mkdirSync(versionDir, { recursive: true }); + } else { + fs.writeFileSync(path.join(root, "config.json"), "{}"); + versionDir = root; + } + fs.writeFileSync( + path.join(versionDir, "version.json"), + JSON.stringify({ version: "152.0.4", build }), + ); + pkgman.pkgmanDeps.versionMin = () => new pkgman.Version(floor); + // The floor under test is VERSION_MIN alone unless a test says otherwise. + pkgman.pkgmanDeps.resolvedPlaywrightVersion = () => null; + return { pkgman, exc, root }; +} + +for (const layout of ["versioned", "legacy"] as const) { + it(`reports a below-floor ${layout} install as outdated, never as missing`, async () => { + const { pkgman, exc } = await install(layout, "beta.29", "beta.30"); + expect(() => pkgman.camoufoxPath()).toThrow(exc.UnsupportedVersion); + }); + + it(`keeps an at-floor ${layout} install`, async () => { + const { pkgman, root } = await install(layout, "beta.30", "beta.30"); + const expected = + layout === "legacy" + ? root + : path.join(root, "browsers/official/152.0.4-beta.30"); + expect(pkgman.camoufoxPath()).toBe(expected); + }); +} + +it("upgrades a below-floor install instead of raising", async () => { + const { pkgman, root } = await install("versioned", "beta.29", "beta.30"); + const installed: boolean[] = []; + pkgman.pkgmanDeps.newFetcher = async () => ({ + async install() { + installed.push(true); + const relative = "browsers/official/152.0.4-beta.30"; + const versionDir = path.join(root, relative); + fs.mkdirSync(versionDir, { recursive: true }); + fs.writeFileSync( + path.join(versionDir, "version.json"), + JSON.stringify({ version: "152.0.4", build: "beta.30" }), + ); + fs.writeFileSync( + path.join(root, "config.json"), + JSON.stringify({ active_version: relative }), + ); + }, + }); + + const resolved = await pkgman.ensureCamoufoxInstalled(); + + expect(installed).toEqual([true]); + expect(resolved).toBe(path.join(root, "browsers/official/152.0.4-beta.30")); +}); + +it("the root probe reports false, rather than raising, with no root file", async () => { + const { pkgman } = await load(); + fs.mkdirSync(pkgman.INSTALL_DIR, { recursive: true }); + expect(pkgman.rootInstallSupported()).toBe(false); +}); + +it("an unsatisfiable floor reports instead of recursing", async () => { + const { pkgman, exc } = await install("versioned", "beta.29", "beta.30"); + const attempts: boolean[] = []; + pkgman.pkgmanDeps.newFetcher = async () => ({ + async install() { + attempts.push(true); // newest published build is still below the floor + }, + }); + + await expect(pkgman.ensureCamoufoxInstalled()).rejects.toBeInstanceOf( + exc.UnsupportedVersion, + ); + expect(attempts).toEqual([true]); +}); + +describe("conditional floor", () => { + async function withPlaywright(version: string | null) { + const { pkgman } = await load(); + pkgman.pkgmanDeps.resolvedPlaywrightVersion = () => + version === null ? null : version.split(".").map(Number); + return pkgman; + } + + for (const version of ["1.53.0", "1.60.0"]) { + it(`leaves older builds alone on Playwright ${version}`, async () => { + const pkgman = await withPlaywright(version); + expect(pkgman.effectiveVersionMin().build).toBe("alpha.1"); + }); + } + + for (const version of ["1.61.0", "1.62.0"]) { + it(`raises the floor on Playwright ${version}`, async () => { + const pkgman = await withPlaywright(version); + expect(pkgman.effectiveVersionMin().build).toBe("beta.30"); + }); + } + + it("falls back permissive when Playwright cannot be read", async () => { + const pkgman = await withPlaywright(null); + expect(pkgman.effectiveVersionMin().build).toBe("alpha.1"); + }); + + it("reads the resolved playwright-core", async () => { + const { pkgman } = await load(); + const version = pkgman.resolvedPlaywrightVersion(); + expect(version).not.toBeNull(); + expect(pkgman.resolvedPlaywrightVersionStr()).toBe(version?.join(".")); + }); + + it("keeps a below-floor install on old Playwright", async () => { + const { pkgman } = await install("versioned", "beta.29", "alpha.1"); + pkgman.pkgmanDeps.resolvedPlaywrightVersion = () => [1, 60, 0]; + expect(path.basename(pkgman.camoufoxPath())).toBe("152.0.4-beta.29"); + }); + + it("moves a below-floor install on new Playwright", async () => { + const { pkgman, exc } = await install("versioned", "beta.29", "alpha.1"); + pkgman.pkgmanDeps.resolvedPlaywrightVersion = () => [1, 62, 0]; + expect(() => pkgman.camoufoxPath()).toThrow(exc.UnsupportedVersion); + }); +}); diff --git a/typescript/tests/virtdisplay.test.ts b/typescript/tests/virtdisplay.test.ts new file mode 100644 index 000000000..c3eb5ced9 --- /dev/null +++ b/typescript/tests/virtdisplay.test.ts @@ -0,0 +1,225 @@ +/** + * Mirrors pythonlib/tests/test_virtdisplay.py: the screen-geometry + * resolution and the Xvfb argument vector (no X server needed), plus the real + * Xvfb lifecycle when Xvfb is installed (Linux only). + * + * VIRTDISPLAY_TEST_N controls the concurrent-launch count (default 50). + */ +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + CannotExecuteXvfb, + VirtualDisplayNotSupported, +} from "../src/exceptions.js"; +import { + COMPOSITE_ENV_VAR, + DEFAULT_SCREEN, + SCREEN_ENV_VAR, + VirtualDisplay, +} from "../src/virtdisplay.js"; +import { prerequisite } from "./prereq.js"; + +afterEach(() => { + delete process.env[SCREEN_ENV_VAR]; + delete process.env[COMPOSITE_ENV_VAR]; +}); + +describe("screen geometry", () => { + it("defaults to 1x1x24", () => { + expect(new VirtualDisplay().screen).toBe(DEFAULT_SCREEN); + expect(DEFAULT_SCREEN).toBe("1x1x24"); + }); + + it("accepts an explicit constructor override", () => { + expect(new VirtualDisplay(false, "800x600x16").screen).toBe("800x600x16"); + }); + + it("reads WxH from the env var and appends a depth of 24", () => { + process.env[SCREEN_ENV_VAR] = "1920x1080"; + expect(new VirtualDisplay().screen).toBe("1920x1080x24"); + }); + + it("reads WxHxD from the env var verbatim", () => { + process.env[SCREEN_ENV_VAR] = "1920x1080x16"; + expect(new VirtualDisplay().screen).toBe("1920x1080x16"); + }); + + it("falls back to the default for an empty env var", () => { + process.env[SCREEN_ENV_VAR] = " "; + expect(new VirtualDisplay().screen).toBe(DEFAULT_SCREEN); + }); + + it("rejects malformed geometry rather than handing Xvfb junk", () => { + for (const bad of ["1920", "1920x", "axb", "1920x1080x24x8", "0x1080"]) { + process.env[SCREEN_ENV_VAR] = bad; + expect(() => new VirtualDisplay()).toThrow(VirtualDisplayNotSupported); + } + }); +}); + +describe("xvfbArgs", () => { + it("passes the resolved screen through to -screen 0", () => { + const args = new VirtualDisplay(false, "1280x720x24").xvfbArgs; + const idx = args.indexOf("-screen"); + expect(idx).toBeGreaterThanOrEqual(0); + expect(args[idx + 1]).toBe("0"); + expect(args[idx + 2]).toBe("1280x720x24"); + }); + + it("disables COMPOSITE by default", () => { + const args = new VirtualDisplay().xvfbArgs; + expect(args[args.indexOf("COMPOSITE") - 1]).toBe("-extension"); + }); + + it("enables COMPOSITE via the env escape hatch", () => { + process.env[COMPOSITE_ENV_VAR] = "1"; + const args = new VirtualDisplay().xvfbArgs; + expect(args[args.indexOf("COMPOSITE") - 1]).toBe("+extension"); + }); + + it("enables COMPOSITE via the constructor", () => { + const args = new VirtualDisplay(false, undefined, true).xvfbArgs; + expect(args[args.indexOf("COMPOSITE") - 1]).toBe("+extension"); + }); + + it("keeps GLX on and the cursor off", () => { + const args = new VirtualDisplay().xvfbArgs; + expect(args[args.indexOf("GLX") - 1]).toBe("+extension"); + expect(args).toContain("-nocursor"); + expect(args).toContain("-nolisten"); + }); +}); + +describe("kill", () => { + it("is safe to call on a display that was never started", () => { + expect(() => new VirtualDisplay().kill()).not.toThrow(); + }); +}); + +function hasXvfb(): boolean { + if (process.platform !== "linux") return false; + try { + execFileSync("which", ["Xvfb"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +const DISPLAY_RE = /^:\d+$/; +const N = Number.parseInt(process.env.VIRTDISPLAY_TEST_N ?? "50", 10); + +describe.skipIf( + process.platform !== "linux" || + !prerequisite("xvfb", hasXvfb(), "apt-get install xvfb"), +)("Xvfb lifecycle", () => { + const tracked: VirtualDisplay[] = []; + const track = (vd: VirtualDisplay) => { + tracked.push(vd); + return vd; + }; + afterEach(() => { + for (const vd of tracked.splice(0)) { + try { + vd.kill(); + } catch {} + } + }); + + async function waitForExit( + proc: import("node:child_process").ChildProcess, + timeoutMs = 5000, + ) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (proc.exitCode !== null || proc.signalCode !== null) return; + await new Promise((r) => setTimeout(r, 25)); + } + } + + it("single launch returns a valid display and kill terminates Xvfb", async () => { + const vd = track(new VirtualDisplay()); + const display = await vd.get(); + expect(display).toMatch(DISPLAY_RE); + const proc = vd.proc; + expect(proc).not.toBeNull(); + expect(proc?.exitCode).toBeNull(); + + vd.kill(); + expect(vd.proc).toBeNull(); + if (proc) await waitForExit(proc); + expect(proc?.exitCode !== null || proc?.signalCode !== null).toBe(true); + }); + + it("get() is idempotent within one VirtualDisplay", async () => { + const vd = track(new VirtualDisplay()); + expect(await vd.get()).toBe(await vd.get()); + }); + + it("concurrent reservations all get unique displays", async () => { + const vds = Array.from({ length: N }, () => track(new VirtualDisplay())); + const displays = await Promise.all(vds.map((vd) => vd.get())); + for (const d of displays) expect(d).toMatch(DISPLAY_RE); + expect(new Set(displays).size).toBe(displays.length); + for (const vd of vds) expect(vd.proc?.exitCode).toBeNull(); + const procs = vds.map((vd) => vd.proc); + for (const vd of vds) vd.kill(); + for (const p of procs) if (p) await waitForExit(p); + for (const p of procs) { + expect(p?.exitCode !== null || p?.signalCode !== null).toBe(true); + } + }, 60_000); + + it("released display numbers can be reused on the next launch", async () => { + const a = track(new VirtualDisplay()); + const aDisplay = await a.get(); + const aProc = a.proc; + a.kill(); + if (aProc) await waitForExit(aProc); + + const b = track(new VirtualDisplay()); + expect(await b.get()).toMatch(DISPLAY_RE); + b.kill(); + expect(aDisplay).toMatch(DISPLAY_RE); + }); + + it("kill() removes the lock and socket even when Xvfb already died", async () => { + const vd = track(new VirtualDisplay()); + const display = (await vd.get()).slice(1); + const proc = vd.proc; + // SIGKILL behind the wrapper's back: Xvfb never gets to clean up. + proc?.kill("SIGKILL"); + if (proc) await waitForExit(proc); + expect(fs.existsSync(`/tmp/.X11-unix/X${display}`)).toBe(true); + + vd.kill(); + expect(vd.proc).toBeNull(); + expect(fs.existsSync(`/tmp/.X${display}-lock`)).toBe(false); + expect(fs.existsSync(`/tmp/.X11-unix/X${display}`)).toBe(false); + }); +}); + +describe.runIf(process.platform === "linux")( + "an Xvfb that cannot start", + () => { + it("throws CannotExecuteXvfb instead of crashing the process", async () => { + // Bypass the xvfbPath getter's up-front checks, as a binary that changes + // between them and the spawn would: execve then fails with EACCES after + // spawn() returns, as an 'error' event on the child. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-xvfb-")); + const fake = path.join(dir, "Xvfb"); + fs.writeFileSync(fake, "#!/bin/sh\n"); + fs.chmodSync(fake, 0o644); + const vd = new VirtualDisplay(); + Object.defineProperty(vd, "xvfbPath", { get: () => fake }); + try { + await expect(vd.get()).rejects.toThrow(CannotExecuteXvfb); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + }, +); diff --git a/typescript/tests/vitest.config.ts b/typescript/tests/vitest.config.ts new file mode 100644 index 000000000..cf5a8820d --- /dev/null +++ b/typescript/tests/vitest.config.ts @@ -0,0 +1,20 @@ +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +// The config lives in the test dir, so pin the root to the package dir rather +// than letting it fall out of the caller's cwd. +const PACKAGE_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +export default defineConfig({ + root: PACKAGE_ROOT, + test: { + include: ["tests/**/*.test.ts"], + globalSetup: ["tests/golden-setup.ts"], + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}); diff --git a/typescript/tests/webgl.test.ts b/typescript/tests/webgl.test.ts new file mode 100644 index 000000000..563c15d4f --- /dev/null +++ b/typescript/tests/webgl.test.ts @@ -0,0 +1,365 @@ +/** + * WebGL: ports of pythonlib/tests/test_webgl.py and + * test_webgl_screen_consistency.py. The seeded draws themselves are pinned + * against Python in identity-golden.test.ts. (test_no_coherent_gpu_raises is + * not ported: it needs fpgen's trace replaced, which an ES module cannot do.) + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { gpuFitsOs } from "../src/coherence.js"; +import { + generateContextFingerprint, + gpuScreenIsPlausible, + isSoftwareRenderer, + MODERN_SCREEN_FLOOR, + raiseScreenToModernFloor, + rendererBucket, +} from "../src/fingerprints.js"; +import { orjsonDumps } from "../src/pycompat.js"; +import { sampleWebglForScreen, toConfig, webglForGpu } from "../src/webgl.js"; +import { MODEL } from "./fpgen-setup.js"; +import { prerequisite } from "./prereq.js"; + +const OSES = ["win", "mac", "lin"] as const; +const SEEDS = Array.from({ length: 300 }, (_, i) => i); +const GTX_980_LINUX = [ + "NVIDIA Corporation", + "NVIDIA GeForce GTX 980, or similar", +] as const; +const BASIC_RENDER_DRIVER = [ + "Google Inc. (Microsoft)", + "ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0), or similar", +] as const; +// Limits WebGL1 and WebGL2 read from the same device. +const SHARED_LIMITS = [ + "3379", + "3386", + "34024", + "34076", + "34921", + "34930", + "35660", + "35661", + "36347", + "36348", + "36349", +]; +const GTX_980_FIXTURE = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../pythonlib/tests/data/webgl-gtx980-linux.json", +); + +describe.skipIf(!MODEL.ok)("webgl (test_webgl.py)", () => { + it.skipIf( + !prerequisite( + "pythonlib-source", + fs.existsSync(GTX_980_FIXTURE), + GTX_980_FIXTURE, + ), + )("the converter reproduces the recorded device", () => { + // What the retired webgl_data.db gave for this GPU. Parameters are + // compared where the old row had a value, less the UNMASKED_* strings. + const old = JSON.parse(fs.readFileSync(GTX_980_FIXTURE, "utf-8")); + const fresh = JSON.parse( + orjsonDumps(webglForGpu("lin", ...GTX_980_LINUX, 0), false), + ); + expect(Object.keys(fresh).sort()).toEqual(Object.keys(old).sort()); + for (const key of Object.keys(old)) { + if (key.endsWith(":parameters")) { + for (const [pname, value] of Object.entries(old[key])) { + if (value === null || pname === "37445" || pname === "37446") + continue; + expect(fresh[key][pname], `${key} ${pname}`).toEqual(value); + } + } else { + expect(fresh[key], key).toEqual(old[key]); + } + } + }); + + it("the same seed draws the same device", () => { + for (const os of OSES) { + for (const seed of [0, 1, 12345]) { + expect(sampleWebglForScreen(os, 1920, 1080, seed)).toEqual( + sampleWebglForScreen(os, 1920, 1080, seed), + ); + } + } + const gpu = ["AMD", "Radeon R9 200 Series, or similar"] as const; + expect(webglForGpu("lin", ...gpu, 7)).toEqual( + webglForGpu("lin", ...gpu, 7), + ); + }); + + it("the seed chooses among a GPU's recorded devices", () => { + const drawn = new Set( + Array.from({ length: 40 }, (_, s) => + orjsonDumps( + webglForGpu("lin", "AMD", "Radeon R9 200 Series, or similar", s), + false, + ), + ), + ); + expect(drawn.size).toBeGreaterThan(1); + }); + + it.each( + OSES, + )("a synthetic %s draw is a hardware GPU the OS reports", (os) => { + const renderers = new Set( + SEEDS.map( + (s) => sampleWebglForScreen(os, 1920, 1080, s)["webGl:renderer"], + ), + ); + for (const renderer of renderers) { + expect(isSoftwareRenderer(renderer), renderer).toBe(false); + expect(renderer).not.toBe("Mozilla"); + expect(gpuFitsOs(renderer, os), renderer).toBe(true); + } + // A single GPU per OS is its own tell. + expect(renderers.size).toBeGreaterThanOrEqual(2); + }); + + it.each(OSES)("a %s netbook screen never draws a discrete GPU", (os) => { + for (const seed of SEEDS) { + const renderer = sampleWebglForScreen(os, 1024, 600, seed)[ + "webGl:renderer" + ]; + expect(gpuScreenIsPlausible(renderer, 1024, 600), renderer).toBe(true); + } + }); + + it.each(OSES)("%s WebGL2 comes from the same device as WebGL1", (os) => { + for (const seed of SEEDS) { + const config = sampleWebglForScreen(os, 1920, 1080, seed); + for (const pname of SHARED_LIMITS) { + expect( + config["webGl:parameters"][pname], + `seed ${seed} ${pname}`, + ).toEqual(config["webGl2:parameters"][pname]); + } + } + }); + + it("the GPU is pinned by vendor and renderer", () => { + // "Mesa" and "AMD" both report this renderer on Linux. + for (let seed = 0; seed < 40; seed++) { + expect( + webglForGpu("lin", "Mesa", "Radeon HD 3200 Graphics, or similar", seed)[ + "webGl:vendor" + ], + ).toBe("Mesa"); + } + }); + + it("a device without WebGL2 sets no webGl2 keys", () => { + const config = webglForGpu("win", ...BASIC_RENDER_DRIVER, 0); + expect(config.webGl2Enabled).toBe(false); + expect(Object.keys(config).some((key) => key.startsWith("webGl2:"))).toBe( + false, + ); + }); + + it("a GPU fpgen has never seen raises", () => { + expect(() => + webglForGpu("win", "Apple", "Apple M1, or similar", 0), + ).toThrow(/No recorded WebGL data/); + }); + + const extensions = (os: string, key: string) => + Array.from( + { length: 100 }, + (_, s) => + new Set(sampleWebglForScreen(os, 1920, 1080, s)[key] ?? []), + ); + + it("Windows keeps OVR_multiview2 on WebGL2", () => { + expect( + extensions("win", "webGl2:supportedExtensions").some((e) => + e.has("OVR_multiview2"), + ), + ).toBe(true); + }); + + it("Linux filters OVR_multiview2", () => { + expect( + extensions("lin", "webGl2:supportedExtensions").some((e) => + e.has("OVR_multiview2"), + ), + ).toBe(false); + }); +}); + +it("draft extensions are filtered on every OS", () => { + const recorded = { + vendor: "v", + renderer: "r", + contextAttributes: {}, + params: {}, + shaderPrecisionFormats: [], + supportedExtensions: [ + "ANGLE_instanced_arrays", + "WEBGL_multi_draw", + "WEBGL_compressed_texture_etc1", + ], + }; + const webgl2 = { + ...recorded, + supportedExtensions: [ + "EXT_texture_norm16", + "WEBGL_clip_cull_distance", + "OVR_multiview2", + ], + }; + for (const os of OSES) { + const config = toConfig(recorded, webgl2, os); + expect(config["webGl:supportedExtensions"]).toEqual([ + "ANGLE_instanced_arrays", + ]); + expect(config["webGl2:supportedExtensions"]).toEqual( + os === "win" ? ["OVR_multiview2"] : [], + ); + } +}); + +// The three spellings Gecko emits for one discrete-NVIDIA bucket. +const NV_ANGLE = + "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar"; +const NV_PCIE = "NVIDIA GeForce GTX 980/PCIe/SSE2"; +const NV_NOUVEAU = "GeForce GTX 980, or similar"; +const AMD_IGP = + "ANGLE (AMD, Radeon HD 3200 Graphics Direct3D11 vs_5_0 ps_5_0), or similar"; +const INTEL = + "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar"; +const APPLE = "Apple M1, or similar"; +const LLVMPIPE = "llvmpipe, or similar"; + +describe("WebGL <-> screen coherence (test_webgl_screen_consistency.py)", () => { + it("every spelling of one GPU reduces to one bucket", () => { + for (const r of [NV_ANGLE, NV_PCIE, NV_NOUVEAU]) { + expect(rendererBucket(r)).toBe("GeForce GTX 980"); + } + }); + + it("the ANGLE vendor field does not decide the bucket", () => { + expect(rendererBucket(AMD_IGP)).toBe("Radeon HD 3200 Graphics"); + expect(rendererBucket("Radeon HD 3200 Graphics, or similar")).toBe( + "Radeon HD 3200 Graphics", + ); + }); + + it("the ANGLE Vulkan form is unwrapped", () => { + expect(rendererBucket("ANGLE (Samsung Xclipse 920) on Vulkan")).toBe( + "Samsung Xclipse 920", + ); + }); + + it("a discrete GPU is rejected on netbook panels only", () => { + for (const r of [NV_ANGLE, NV_PCIE, NV_NOUVEAU]) { + expect(gpuScreenIsPlausible(r, 1024, 600)).toBe(false); + } + for (const [w, h] of [ + [1024, 768], + [1280, 720], + [1280, 800], + [1366, 768], + [1920, 1080], + ]) { + expect(gpuScreenIsPlausible(NV_ANGLE, w, h)).toBe(true); + } + for (const [w, h] of [ + [1024, 600], + [800, 480], + [1024, 576], + ]) { + expect(gpuScreenIsPlausible(NV_ANGLE, w, h)).toBe(false); + } + }); + + it("integrated parts, Apple silicon, raw model names and missing values are unconstrained", () => { + expect(gpuScreenIsPlausible(INTEL, 1024, 600)).toBe(true); + expect(gpuScreenIsPlausible(AMD_IGP, 1024, 600)).toBe(true); + expect(gpuScreenIsPlausible(APPLE, 1280, 800)).toBe(true); + expect( + gpuScreenIsPlausible( + "ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0)", + 1024, + 600, + ), + ).toBe(true); + expect(gpuScreenIsPlausible(null, 1920, 1080)).toBe(true); + expect(gpuScreenIsPlausible(NV_ANGLE, null, null)).toBe(true); + }); + + it("software renderers are recognized and unconstrained", () => { + for (const r of [ + LLVMPIPE, + "ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)", + "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)", + "Generic Renderer", + ]) { + expect(isSoftwareRenderer(r)).toBe(true); + expect(gpuScreenIsPlausible(r, 1024, 600)).toBe(true); + } + for (const r of [NV_ANGLE, INTEL, APPLE, AMD_IGP]) { + expect(isSoftwareRenderer(r)).toBe(false); + } + }); + + it.skipIf(!MODEL.ok)("the sampled GPU is coherent with the screen", () => { + for (const os of ["win", "mac", "lin"]) { + for (let seed = 0; seed < 25; seed++) { + const fp = sampleWebglForScreen(os, 1280, 800, seed); + expect(gpuScreenIsPlausible(fp["webGl:renderer"], 1280, 800)).toBe( + true, + ); + } + } + }); + + it("the screen floor lifts netbook geometry and keeps the taskbar gap", () => { + const config: Record = { + "screen.width": 1024, + "screen.height": 600, + "screen.availWidth": 1024, + "screen.availHeight": 560, + }; + raiseScreenToModernFloor(config); + expect([config["screen.width"], config["screen.height"]]).toEqual([ + ...MODERN_SCREEN_FLOOR, + ]); + expect(config["screen.height"] - config["screen.availHeight"]).toBe(40); + expect(config["screen.width"] - config["screen.availWidth"]).toBe(0); + }); + + it("the screen floor leaves an adequate screen, or no screen, alone", () => { + const config = { + "screen.width": 1920, + "screen.height": 1080, + "screen.availWidth": 1920, + "screen.availHeight": 1040, + }; + const before = { ...config }; + raiseScreenToModernFloor(config); + expect(config).toEqual(before); + const empty = {}; + raiseScreenToModernFloor(empty); + expect(empty).toEqual({}); + }); + + it.skipIf(!MODEL.ok)("context fingerprints get the same treatment", () => { + for (const os of ["windows", "macos", "linux"]) { + for (let i = 0; i < 5; i++) { + const { config } = generateContextFingerprint({ os }); + const r = config["webGl:renderer"]; + const w = config["screen.width"]; + const h = config["screen.height"]; + expect(r && w && h).toBeTruthy(); + expect(gpuScreenIsPlausible(r, w, h)).toBe(true); + expect(w * h).toBeGreaterThan(1024 * 600); + } + } + }); +}); diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json new file mode 100644 index 000000000..d2bcb67a6 --- /dev/null +++ b/typescript/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "Node16", + "moduleResolution": "node16", + "preserveConstEnums": true, + "sourceMap": false, + "removeComments": false, + "outDir": "./dist", + "declaration": true, + "strict": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +}