Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
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.
# The npm half of a launcher release. The two packages ship together, at one
# version (scripts/check-pack.mjs refuses a package.json that differs from
# pythonlib's), and "Publish to pypi" is the one place a release starts:
#
# 1. publish-pypi.yml calls this workflow as a dry run -- every check, the
# build, the pack check and `npm publish --dry-run` -- so a broken npm
# package stops the release before anything is uploaded anywhere;
# 2. it uploads to PyPI;
# 3. its success triggers this workflow again (workflow_run), which publishes
# the same commit to npm.
#
# Started by hand, this workflow only retries step 3 -- after an npm outage,
# say -- and refuses unless PyPI already has this version, so npm can never get
# a release PyPI does not.
#
# Authentication is npm trusted publishing (OIDC): no token is stored anywhere.
# npm accepts this workflow's identity because the package's settings on
Expand All @@ -12,6 +23,14 @@ name: Publish to npm
# publisher is configured on a package that already exists.

on:
workflow_run:
workflows: ["Publish to pypi"]
types: [completed]
workflow_call:
inputs:
dry_run:
type: boolean
default: true
workflow_dispatch:
inputs:
dry_run:
Expand All @@ -24,6 +43,8 @@ permissions:

jobs:
publish:
# After a PyPI run, only a successful one.
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -37,6 +58,9 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
# The commit PyPI was released from, not whatever main is now.
ref: ${{ github.event.workflow_run.head_sha || github.sha }}

- name: Set up Python
uses: actions/setup-python@v5
Expand Down Expand Up @@ -83,5 +107,22 @@ jobs:
- name: Check package
run: node scripts/check-pack.mjs

- name: PyPI has this version
# npm never gets a release PyPI does not have. Retried for a few minutes:
# straight after the upload, PyPI's JSON API can lag.
if: ${{ !inputs.dry_run }}
run: |
version=$(node -p "require('./package.json').version")
for attempt in $(seq 1 20); do
if curl -fsS -o /dev/null "https://pypi.org/pypi/camoufox/$version/json"; then
echo "camoufox $version is on PyPI"
exit 0
fi
sleep 15
done
echo "::error::camoufox $version is not on PyPI. Release through 'Publish to pypi', which publishes to npm after it."
exit 1

- name: Publish to npm
run: npm publish --access public ${{ inputs.dry_run && '--dry-run' || '' }}
# A dry run uploads nothing, so it has nothing to attest.
run: npm publish --access public ${{ inputs.dry_run && '--dry-run --provenance=false' || '' }}
14 changes: 14 additions & 0 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,22 @@ on:
permissions:
contents: read

# The one place a launcher release starts. The npm package is checked first --
# as a dry run of publish-npm.yml -- so a broken one stops the release before
# PyPI has it; PyPI's success then triggers publish-npm.yml to publish the same
# commit to npm (see the comment there).

jobs:
npm-preflight:
permissions:
contents: read
id-token: write # publish-npm.yml's job declares it; a dry run does not use it
uses: ./.github/workflows/publish-npm.yml
with:
dry_run: true

publish:
needs: npm-preflight
runs-on: ubuntu-latest
defaults:
run:
Expand Down
55 changes: 38 additions & 17 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -785,9 +785,23 @@ jobs:
patch-guards:
# Tier 3a: the cheap checks on a fresh browser. If these fail the browser is
# broken in an obvious way and tier 3b would only say so more slowly.
#
# Whether the patches APPLY is the build's job; these check that what they
# do still works. One leg per kind of guarantee (ci/run_patch_guards.py
# GROUPS), plus the skiplist audit, so a failure names which kind broke and
# the legs run side by side. One job id, so everything that waits on the
# guards keeps `needs: patch-guards`.
if: always() && (needs.build.result == 'success' || needs.fetch-browser.result == 'success')
name: Patch guards
name: ${{ matrix.title }}
needs: [resolve, build, fetch-browser]
strategy:
fail-fast: false
matrix:
include:
- {leg: spoofing, title: "Patch guards: spoofing"}
- {leg: automation, title: "Patch guards: automation"}
- {leg: parity, title: "Patch guards: stock parity"}
- {leg: skiplist, title: "Skiplist audit"}
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
Expand All @@ -808,8 +822,10 @@ jobs:
python3 scripts/pin-fpgen-model.py
# Fonts and properties.json are staged into the artifact by the build job;
# `make stage-fonts` here would find no source tree and do nothing.
- run: xvfb-run -a python3 -m ci.run_patch_guards --binary "$CAMOUFOX_BINARY"
- if: matrix.leg != 'skiplist'
run: xvfb-run -a python3 -m ci.run_patch_guards --group "${{ matrix.leg }}" --binary "$CAMOUFOX_BINARY"
- name: Every skiplist entry is still failing
if: matrix.leg == 'skiplist'
# Seconds, because a correct skiplist is short. This is what stops
# ci/skiplist.yml turning into a list of tests that would now pass --
# which is what it was: 193 of the 202 tests it skipped passed.
Expand All @@ -824,7 +840,7 @@ jobs:
- uses: actions/upload-artifact@v4
if: always()
with:
name: results-patch-guards
name: results-patch-guards-${{ matrix.leg }}
path: .ci-work/results/
include-hidden-files: true
if-no-files-found: warn
Expand Down Expand Up @@ -993,21 +1009,25 @@ jobs:

# ---------------------------------------------------------------------------
growth:
name: Memory growth (scheduled)
# Deliberately NOT in the merge gate. It takes ~37 minutes, because every
# mechanism is churned twice -- at n and 4n -- to measure whether growth
# scales with the count. That is the wrong price to pay on every pull
# request for a detector aimed at a slow-moving class of bug, so it runs on
# the schedule and on demand, and a failure opens a conversation rather than
# blocking a merge.
name: Memory growth ${{ matrix.shard }}
# Every mechanism is churned twice -- at n and 4n -- to measure whether
# growth scales with the count: ~38 minutes in one process, which kept this
# off pull requests. One test per runner (ci.run_native --shard) brings it
# to the length of a Playwright shard, so it runs on every pull request and
# is part of the merge gate. Seven shards for the seven tests in
# native-tests/test_memory_growth.py; with more tests than shards a shard
# simply runs two.
needs: [resolve, build, fetch-browser, patch-guards, build-tester]
if: >-
always()
&& (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
&& needs.patch-guards.result == 'success'
&& needs.build-tester.result == 'success'
runs-on: ubuntu-24.04
timeout-minutes: 120
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: ["1/7", "2/7", "3/7", "4/7", "5/7", "6/7", "7/7"]
permissions:
contents: read
steps:
Expand All @@ -1024,11 +1044,12 @@ jobs:
# April-2025 model. Install the pinned one first: see
# scripts/pin-fpgen-model.py.
python3 scripts/pin-fpgen-model.py
- run: xvfb-run -a python3 -m ci.run_native --subset growth --binary "$CAMOUFOX_BINARY"
- run: xvfb-run -a python3 -m ci.run_native --subset growth --shard "${{ matrix.shard }}" --binary "$CAMOUFOX_BINARY"
- uses: actions/upload-artifact@v4
if: always()
with:
name: results-growth
# A shard's "3/7" cannot go in an artifact name.
name: results-growth-${{ strategy.job-index }}
path: .ci-work/results/
include-hidden-files: true
if-no-files-found: warn
Expand Down Expand Up @@ -1086,7 +1107,7 @@ jobs:
summary:
name: Summary
needs: [resolve, static, pythonlib, typescript, build, fetch-browser, playwright,
patch-guards, build-tester, native, typescript-browser, sundial]
patch-guards, build-tester, native, typescript-browser, growth, sundial]
if: always() && needs.resolve.result == 'success'
runs-on: ubuntu-24.04
permissions:
Expand Down Expand Up @@ -1126,7 +1147,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 typescript typescript_browser native_rules patch_guards skiplist_audit build_tester playwright native_browser"
required="pythonlib typescript typescript_browser native_rules patch_guards_spoofing patch_guards_automation patch_guards_parity skiplist_audit build_tester playwright native_browser native_growth"
if [ "${{ needs.resolve.outputs.browser_changed }}" = "true" ]; then
required="$required build"
fi
Expand Down Expand Up @@ -1192,7 +1213,7 @@ jobs:
# did not run has not passed, and silently skipping one is the cheapest way
# to a green tick.
needs: [resolve, static, pythonlib, typescript, build, fetch-browser, playwright,
patch-guards, build-tester, native, typescript-browser, sundial, summary]
patch-guards, build-tester, native, typescript-browser, growth, sundial, summary]
if: always()
runs-on: ubuntu-24.04
permissions:
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ tests passed`**. Run the suite that covers your change while you work:

| You changed | Run |
|---|---|
| Patches, C++, Juggler | `python3 -m ci.run_build_tester --binary <camoufox-bin>` (the anti-detect suite) and `python3 -m ci.run_patch_guards --binary <camoufox-bin>` (one guard per spoofing behaviour) |
| Patches, C++, Juggler | `python3 -m ci.run_build_tester --binary <camoufox-bin>` (the anti-detect suite) and `python3 -m ci.run_patch_guards --binary <camoufox-bin>` (one guard per shipped behaviour, in three groups: `--group spoofing|automation|parity`) |
| Automation behaviour | `make tests`, the upstream Playwright suite with `ci/skiplist.yml` applied |
| `pythonlib/` | `python3 -m ci.run_pythonlib` |
| `ci/` itself | `python3 -m pytest ci/tests -q` |
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ 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 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.
**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 and memory-growth suites, and the stealth check. Branch protection requires exactly one check, **`All tests passed`**, which is green only when every applicable suite is.

The stealth check needs a credential, and GitHub gives secrets only to pull requests whose branch is in this repository, never to one from a fork. If you have write access, push your branch here rather than to a fork, so it runs; on a fork pull request it is skipped, and says so in the summary.

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.

Expand Down
65 changes: 62 additions & 3 deletions additions/juggler/TargetRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,16 @@ export class TargetRegistry {
return TargetRegistry._instance || null;
}

constructor() {
constructor({ lastWindowQuits = true } = {}) {
helper.decorateAsEventEmitter(this);
TargetRegistry._instance = this;

// False when Juggler holds the last-window-closing survival area (-silent,
// every non-persistent launch); a persistent launch quits with its last window.
this._lastWindowQuits = lastWindowQuits;
// Crashed pages whose tab is the browser's last; see _closeCrashedTab.
this._retainedCrashedTargets = new Set();

this._browserContextIdToBrowserContext = new Map();
this._userContextIdToBrowserContext = new Map();
this._browserToTarget = new Map();
Expand Down Expand Up @@ -189,6 +195,14 @@ export class TargetRegistry {
return;
target.emit(PageTarget.Events.Crashed);
target.dispose();
// dispose() detaches the page from every client and drops it from its
// context, so nothing could close this tab afterwards: context.close()
// only closes the pages it still tracks, and the default context closes
// none. Each crashed page kept its window alive until the browser
// exited -- 15-60 MB of parent RSS apiece, growing without bound across
// repeated crashes (#762's scraper). Deferred so the tab is not torn
// down inside Gecko's own crash notification.
setTimeout(() => this._closeCrashedTab(target), 0);
}
}, 'oop-frameloader-crashed');

Expand All @@ -215,6 +229,11 @@ export class TargetRegistry {
target.updateViewportSize();
if (browserContext.videoRecordingOptions)
target._startVideoRecording(browserContext.videoRecordingOptions);

// Another tab exists now, so a crashed page kept as the last one can go.
for (const crashed of this._retainedCrashedTargets)
setTimeout(() => this._closeCrashedTab(crashed), 0);
this._retainedCrashedTargets.clear();
};

const onTabCloseListener = event => {
Expand Down Expand Up @@ -369,6 +388,16 @@ export class TargetRegistry {
return this._userContextIdToBrowserContext.get(userContextId);
}

_closeCrashedTab(target) {
if (this._lastWindowQuits && target.isLastTab()) {
// Closing it would quit a persistent-context browser under the client.
// Keep it until another tab opens; onTabOpenListener comes back for it.
this._retainedCrashedTargets.add(target);
return;
}
target.closeCrashedTab();
}

async newPage({browserContextId}) {
const result = globalNewPageChain.then(() => this._newPageInternal({browserContextId}));
globalNewPageChain = result.catch(error => { /* swallow errors to keep chain running */ });
Expand Down Expand Up @@ -620,6 +649,7 @@ export class PageTarget {
this.updateUserAgent(browsingContext);
this.updatePlatform(browsingContext);
this.updateDPPXOverride(browsingContext);
this.updateMobileEmulation(browsingContext);
this.updateZoom(browsingContext);
this.updateEmulatedMedia(browsingContext);
this.updateColorSchemeOverride(browsingContext);
Expand Down Expand Up @@ -670,6 +700,19 @@ export class PageTarget {
browsingContext.overrideDPPX = dppx;
}

updateMobileEmulation(browsingContext = undefined) {
// Responsive Design Mode is devtools' mobile mode: overlay scrollbars, a
// mouse click's pointer events dropped under touch emulation, and RDM
// branches in screen, window and navigator getters, all readable by the
// page. It matches no real browser -- Firefox for Android never runs it --
// and Camoufox has only desktop identities, so it stays off even for
// isMobile. Playwright's Juggler turns it on for isMobile
// (microsoft/playwright#41859); this is a deliberate difference, and the
// launchers warn when isMobile is passed. The viewport does not need RDM:
// the <browser> element's size sets it.
(browsingContext || this._linkedBrowser.browsingContext).inRDMPane = false;
}

async updateZoom(browsingContext = undefined) {
browsingContext ||= this._linkedBrowser.browsingContext;
// Update dpr first, and then UI zoom.
Expand Down Expand Up @@ -699,6 +742,7 @@ export class PageTarget {
async updateViewportSize() {
await waitForWindowReady(this._window);
this.updateDPPXOverride();
this.updateMobileEmulation();

// Viewport size is defined by three arguments:
// 1. default size. Could be explicit if set as part of `window.open` call, e.g.
Expand All @@ -718,7 +762,6 @@ export class PageTarget {
this._linkedBrowser.closest('.browserStack').style.setProperty('overflow', 'auto');
this._linkedBrowser.closest('.browserStack').style.setProperty('contain', 'size');
this._linkedBrowser.closest('.browserStack').style.setProperty('scrollbar-width', 'none');
this._linkedBrowser.browsingContext.inRDMPane = true;

const stackRect = this._linkedBrowser.closest('.browserStack').getBoundingClientRect();
const toolbarTop = stackRect.y;
Expand All @@ -732,7 +775,6 @@ export class PageTarget {
this._linkedBrowser.closest('.browserStack').style.removeProperty('overflow');
this._linkedBrowser.closest('.browserStack').style.removeProperty('contain');
this._linkedBrowser.closest('.browserStack').style.removeProperty('scrollbar-width');
this._linkedBrowser.browsingContext.inRDMPane = false;

const actualSize = this._linkedBrowser.getBoundingClientRect();
await this._channel.connect('').send('awaitViewportDimensions', {
Expand Down Expand Up @@ -813,6 +855,23 @@ export class PageTarget {
});
}

closeCrashedTab() {
// Its window or context may have closed it first.
if (!this._tab.isConnected || this._tab.closing)
return;
this.close();
}

isLastTab() {
if (this._gBrowser.tabs.length > 1)
return false;
for (const win of Services.wm.getEnumerator('navigator:browser')) {
if (win !== this._window && !win.closed)
return false;
}
return true;
}

channel() {
return this._channel;
}
Expand Down
2 changes: 1 addition & 1 deletion additions/juggler/components/Juggler.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class Juggler {
case "final-ui-startup":
Services.obs.removeObserver(this, topic);

const targetRegistry = new TargetRegistry();
const targetRegistry = new TargetRegistry({ lastWindowQuits: !this._silent });
new NetworkObserver(targetRegistry);

const loadStyleSheet = () => {
Expand Down
Loading
Loading