From 52d6746a4a67830ec8a24e2196822204ba843134 Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Mon, 14 Sep 2026 16:52:05 +0000 Subject: [PATCH] ci: a repo-wide test pipeline, and the one check that gates merge on it (#772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: a repo-wide test pipeline, and the suites Camoufox was missing Nothing checked a pull request before this. `build.yml` runs on tags and takes about forty minutes, and `lint.yml` ran a single static script, so a change could reach main having had no browser suite run against it at all. This adds one pipeline, driven identically from a pull request, a push to main, and -- through `workflow_call` -- any caller that needs to test a specific browser version, so there is exactly one definition of "the tests pass". resolve ──┬─ static ────────── tribal rules, skiplist, self-tests ├─ pythonlib ─────── the package's own tests └─ build ──┬─ playwright upstream × 6 shards (conformance) ├─ playwright vendored (regression) ├─ native ───────────── leaks, contexts ├─ patch guards ─────── one per spoofing patch ├─ build-tester ─────── 8 fingerprint profiles └─ sundial ──────────── stealth grade (off, see below) │ summary ──► one comment on the PR Two Playwright suites, because they answer different questions. `tests/` is a frozen ~v1.55-era fork carrying roughly 1800 lines of Camoufox adaptations, so every test in it has a known prior outcome: that is the regression check. The upstream suite is fetched fresh at the tag `ci/versions.py` resolves and runs unmodified, which is the conformance check -- `ci/pw_camoufox_plugin.py` adapts the environment around it rather than editing it, hooking BrowserType at the _impl layer so upstream can refactor its fixtures freely. `native-tests/` covers what neither can ask about: that resource cost does not scale with launch count (the shape an FD or socket leak actually has), that two contexts in one browser get different fingerprints while two pages in one context get the same one (get this wrong and per-context injection silently degrades to process-global, which passes every single-context test there is), and that decisions already made stay made -- `ci/tribal-rules.yml` lists them with the issue or PR that settled each. Cost is tiered so a two-second lint failure never reaches a build, and a driver-only pull request never builds at all: it fetches the published release and tests against the build users are actually running, a minute instead of seventy. Merges gate on one required check, `All tests passed`, so the branch-protection list does not need editing every time a suite is added or resharded; `ci/branch-protection.json` holds the settings so they are reviewable rather than lore. **The stealth check ships disabled** (`ci/sundial.yml: enabled: false`). It drives a private detection suite, and the deployment it talks to predates that suite's score mode; an older one ignores `?score=1` and posts the entire report -- every vector's id, name, brief, source and value -- to whatever collector asked. Receiving that on a public runner and discarding it afterwards is not the same guarantee as never being sent it, so while the flag is false the job is not scheduled, no credential enters a runner, and `run_sundial.py` refuses a hand-run too. When it is enabled, `redact()` publishes a grade and counts against a runtime whitelist and refuses anything that is not already aggregated. Also included: the fixes these suites exposed on a clean runner -- build-tester hashing canvas pixels rather than a prefix of the data URL, the virtdisplay cleanup when Xvfb has already died, a juggler sandbox released on frame destroy rather than only on navigation, and the pythonlib geometry and version-floor corrections. `lint.yml` is removed because the static job absorbed its one check. Verified locally: ci/tests 68 passed, tribal rules 24 passed, pythonlib 209 passed, input-dispatch clean, `ci.versions` resolves 152.0.4/beta.31 against playwright v1.61.0, and `ci.summarize` folds a run to "all suites passed". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * ci: make result files survive the trip from job to summary The first full run failed, and the summary could not say why: five suites came back "required, but produced no result", including two whose jobs had passed. Three separate plumbing bugs, none of them in a test. **Hidden files.** `actions/upload-artifact@v4` excludes dotfiles unless told otherwise, and every result we write lives under `.ci-work`. The jobs whose `path:` was a list containing a glob uploaded nothing at all -- the Playwright suites and the leak suite each wrote their evidence and then had it silently dropped: evidence -> .../.ci-work/results/playwright_vendored.json (fail, 1203 tests) ##[warning]No files were found with the provided path: .ci-work/results/ .ci-work/junit-*.xml. No artifacts will be uploaded. **Common root.** Where a list did upload, the second entry moved upload-artifact's common root from `.ci-work/results/` up to `.ci-work/`, so the JSON arrived at `results/build_tester.json` instead of the artifact root. The summary merges every `results-*` into one directory and `load_all()` globs a single level, so the file was there and invisible. build_tester passed and was reported missing. Every `results-*` artifact now uploads exactly `.ci-work/results/`, with diagnostics (junit XML, the build-tester graded tree) split into their own `diagnostics-*` artifacts that the summary's `results-*` pattern ignores. `include-hidden-files: true` everywhere that touches `.ci-work`. **A required name nothing writes.** `static` was in the required list, but it is a job, not a suite -- no runner writes a result by that name, so summarize reported it missing on every run including a wholly green one. The suites that job runs are the pipeline self-tests, which write no result, and native_rules, which is required by name. The job is already covered: the gate fails on any job that is not success. Three guards, each verified by reintroducing the bug it catches: - results-* artifacts upload exactly one path, so nothing nests - anything touching .ci-work sets include-hidden-files - every required name is one some runner can actually write This changes no test. The real failures the first run found -- 6 in the vendored suite, plus upstream shards 1 and 5 and the leak suite -- were masked by the above and should now be reported rather than swallowed. ci/tests 71 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * test: two failures that were the tests' fault, not the browser's **The leak check waited on the wrong set of processes.** `test_a_single_launch_leaves_nothing` failed with Gecko's GPU probe still alive: 1 process(es) this test started are still alive: glxtest(2887, now ppid=1) `settle()` polled `children(recursive=True)`, but `survivors()` judges the sampled PID set -- deliberately, so that a process reparented to init cannot hide a leak. Those two sets differ exactly when a process outlives its parent: it stops being our child, `settle()` sees nothing left and returns at once, and anything still winding down is reported as leaked. `glxtest` does this on every launch; it is spawned by Gecko, its parent exits first, and it needs a moment. So settle on the set the assertion actually uses. This is a grace period, not an exemption -- a process that is still there when the timeout expires fails the test exactly as before, and no name is special-cased. **Playwright renamed a protocol method the tracing tests spelled out.** `Page.waitForEventInfo` is `Page.__waitInfo__` in newer versions, so two tracing assertions failed on a name, not on behaviour. The suite is pinned to a range (`playwright<1.63`), not a version, so hard-coding either spelling is wrong. Normalised in `get_trace_actions()`, next to the comment about the last time Playwright moved this data -- the tests care which actions ran and in what order, not what Playwright calls them this month. Neither of these was Camoufox misbehaving. Still failing, and genuinely about the browser or by design -- triaged next: navigation popup load state, locator handler visibility, clock pause off by 1ms, websocket close reason, and the three upstream ones (request headers, worker locale, screencast viewport) which all look like deliberate spoofing divergence and probably belong in the skiplist with a stated reason. ci/tests 71 passed, tribal rules 24 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix: the failures the new pipeline found, and the flake that hid them Eleven gates were red on PR #9. Each one is now either a fixed defect or an entry that says why the test cannot apply here -- nothing is silenced. One real browser bug, found by the conformance suite: The compositor-backed screencast added in fa8a935 reported the *scaled frame* size as the viewport. Playwright's Firefox delegate maps deviceWidth/Height straight onto the client-visible viewportWidth/viewportHeight, and every other backend fills them from the page's viewport -- the native path right below it sends pageWidth/pageHeight, clamped to the viewport and never scaled. So a client asking for a 500x400 frame of a 1000x400 page was told the viewport was 500x200, and asking for a frame *larger* than the page reported a viewport larger than the page. Confirmed against the built binary, fixed, and confirmed again: 11 screencast and video tests pass, where the requested size no longer moves the reported viewport at all. Four stale expectations in the vendored suite. All four pass in upstream's v1.61 suite against this same binary, which is what identified them as the suite's problem rather than the browser's: websocket Firefox no longer collapses a refused handshake to CLOSE_ABNORMAL; it reports the HTTP status, like the other engines. The handler also set a settled future twice, which surfaced as a suite ERROR rather than a failure. navigation Firefox now reports a window.open('') popup as "complete". The old assertion also mis-parsed -- the conditional bound to the whole assert, so the non-Firefox arm compared nothing. locator expect().to_be_visible() kept re-arming the locator handler the click was still waiting to see finish, so the check meant to observe the interstitial kept it alive. One-shot is_visible(). page_clock resume() before reading the clock added the real second spent in wait_for_timeout back, landing at 1001 -- one millisecond out, every time. Two upstream tests that encode a stock-Firefox quirk this fork does not reproduce, now in the skiplist with the reason: A worker inheriting the context locale -- upstream expects en-US from a ru-RU context, citing playwright#38919, because stock Firefox applies the locale to the page and not to its workers. Camoufox sets it below that layer, so matching upstream would mean reintroducing a main-thread/worker disagreement that anything looking in both places gets for free. "Firefox" in the User-Agent -- the bare binary advertises its own build token; the Python package replaces it when it injects a fingerprint. The vendored suite already asserts what this layer can promise. And three pieces of the harness that were reporting badly: A profile that asked for llvmpipe is no longer graded as headless. camoufox's own preset pool ships "llvmpipe, or similar", so when that preset is drawn, reporting it is the WebGL spoof working -- and grading it a failure made this gate fail at random depending on which presets the run happened to draw. The check still fails on a software renderer the profile did not ask for, which is the case it exists for. junit ids put a test's class in the path (test_page_clock/TestWhileRunning.py ::test_should_pause), naming a directory that does not exist -- so the id could not be fed back to pytest and no skiplist entry could match it. A `test:` skiplist entry was compared for exact equality against a node id that always ends in [firefox], so every such entry was a silent no-op: the test went on running and failing while the list read as handled. Finally, `mach bootstrap` pulls toolchains from Taskcluster, and a connection reset there failed the whole pull request (run 34673115086). ci.run_prepare retries the two steps that download things, and only when the failure reads as transient -- a failed patch hunk still fails on the first try, because retrying a broken tree only spends a runner to reach the same answer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * docs(ci): say what the stealth gate actually guarantees now The README described a `ci` role that is not merged into sundial's master and so is not deployed, and a kill switch that had since been flipped. Both claims now match what is running. The substantive change is separating the two halves of the guarantee, because only one of them is enforced by the server: `guest` is refused the private-vector bundle by sundial's middleware, and this repository refuses to process anything that is not a score payload. What is still missing is a server that refuses the *request* -- which is what the `ci` role adds, and why the upgrade path is worth keeping written down rather than implied. Also documents ci.run_prepare, since "the build retries" is the kind of thing that needs its limits stated: the network, and nothing else. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): authenticate to sundial with either shape of credential `SUNDIAL_AUTOMATION_KEY` has been two different things over this repository's life -- an automation key for `/automated?key=`, which is what sundial's `make pages-automation-keys` mints and what resolves to the `guest` role, and a password for the login form. They are indistinguishable by inspection, and the gate only knew how to use it as a password, so a repository holding a key would have failed to log in with no hint as to why. Try the key route first, since it needs no username and so nothing has to be kept in step with whatever `GUEST_USER` was set to, and fall back to the form. When the secret really is a password the only cost is one extra request that 401s. If both routes fail the error names both attempts and says what the secret is supposed to be. Also sends browser headers on every request rather than `User-Agent: camoufox-harness`. Cloudflare sits in front of this host and refuses a document request from a non-browser agent before it reaches sundial at all, which produces a 403 that looks like a permissions problem and is not one. test_a_disabled_gate_makes_no_request now makes every route out fatal -- authenticate() and both login functions, not just the one main() used to call -- so the kill switch cannot be bypassed through a path the test does not watch. Mutation-checked: making the gate ignore `enabled:` fails it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): never let run_prepare invent a success `--attempts 0` would have fallen straight past the loop bound and returned 0 without running the step at all, which is the one answer it must not invent. Clamps to one attempt and makes falling out of the loop an assertion rather than a bare success. Also corrects a direction in the screencast comment: the native path it contrasts with sits above _startSnapshotScreencast, not below it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): a sundial category nobody has ruled on now fails the gate ci/sundial.yml partitions sundial's taxonomy: its nine SECTIONS labels (Identity, Security, JS Engine, Graphics, Display, Locale, Audio, CPU, Network) are exactly the six gated plus the three ungated, with nothing left over. That is now asserted, because it is the property the pass rate depends on and nothing was checking it. If sundial grows a tenth section, every check in it used to fold into "out of scope" -- measured, never gated, and indistinguishable in the summary from a category someone had deliberately decided not to gate. That answers "does Camoufox claim this?" by default, in the only direction that never fails a build: a stealth blind spot that reads as a clean run. The count is now carried separately and fails the gate, with a note saying to put the new section in one list or the other. Only ever a count -- which category, like which vector, does not leave redact(). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): stream the prepare log, and make its timeout real Two problems with capturing the output of `make dir` / `make mozbootstrap` instead of streaming it. The visible one: the step printed nothing for minutes while aria2c pulled a 500MB tarball, which looks exactly like a hung job. The one that mattered: draining the pipe on the calling thread blocks in readline until EOF and only *then* reaches proc.wait(timeout=...), so a step that wedged without printing anything -- a stalled download, precisely the failure this module exists for -- would never have been timed out at all. The reader now runs on its own thread, so output appears as it arrives and the timeout covers a silent hang. Both are asserted against real processes: stdout and stderr both survive into the text the transient-classifier reads, and `sleep 60` under a 2s timeout dies in 2s with exit 124 rather than reporting success. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * docs(ci): two comments that said less than they meant to The skiplist header claimed to quote failure text it only described, and the sundial header comment had lost the word that made it a sentence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * docs(ci): the stealth job comment described the role we do not have yet It claimed the credential is for a role sundial refuses to serve a report to. That is the `ci` role, which is not deployed. Says what is actually true of `guest` instead: the request asks for counts, the role cannot load the private vectors, and the gate refuses a payload that is not a score. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * test(ci): name what this section actually asserts The heading said "the score-only `ci` role" and the docstring said the gate runs as a role sundial refuses to serve a report to. Neither is true yet -- that role is not deployed. What the tests actually pin is that score mode is a requirement rather than a preference: a full report is refused whoever asked for it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): the sundial credential must not ride out on an error message Whatever reaches result.note() is written to the results artifact and posted as a pull request comment. An exception raised inside urllib can carry the URL that produced it -- and for the token route that URL *is* the credential, percent-encoded in the query string. A 401 from a stale key would have published the key. Every string built from an exception now goes through scrub() first, which removes both the raw secret and its percent-encoded form. The message still says which routes were tried and what the secret is supposed to be, so a real misconfiguration is still diagnosable from the log alone. Mutation-checked: with the replacement removed, both new tests fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * docs(ci): say which role the private-vector gate actually blocks "serves that bundle to nobody else" read as though sundial withheld the private vectors from everyone. It withholds them from `guest` specifically -- admin and private still get them -- which is the whole reason CI authenticates as the least privileged account rather than whichever one was to hand. Verified against the deployed middleware: guests get a 200 with a no-op body, not a 404. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * docs(ci): the Cloudflare 403 is narrower than the comment claimed It said any non-browser User-Agent is refused. In fact `POST /__auth/login` worked for months with `User-Agent: camoufox-harness` -- the block tracks document-shaped requests, which is what `/automated?key=` is. Browser headers everywhere are still right, but as "cheaper than remembering which hop is which", not as a fix for an outage that was never happening. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): verify the sundial role instead of assuming it The CI log for the first real run says `sundial auth OK (automation key)` -- the stored credential is an automation key, not a form password. Which matters more than it sounds: sundial's `/automated?key=` resolves to the **private** role when handed the private key and to `guest` when handed the guest one, and the two are indistinguishable by looking at them. `private` is served the private vectors. So the guarantee this gate has been documenting -- "CI runs as a role sundial withholds the vectors from" -- was resting on whoever set the secret having picked the right key. redact() does not help here: it governs what this repository *publishes*, not what the browser is *given*, and the thing being prevented is a public runner holding the vectors at all. The gate now reads sundial's own /__auth/me and refuses to open the browser unless the session is a role the vectors are withheld from. Not knowing the role counts as not safe: an absent or unreadable endpoint fails closed, because the alternative is loading them on the assumption that the credential was right. Probed live: a bogus cookie yields no role and the check refuses, as it should. The positive case is what the next run confirms -- and if that credential turns out to be the private key, this goes red, which is the correct and useful outcome. Also corrects the Cloudflare comment, which I had just rewritten on a false premise. The form-login route was never exercised in CI (the gate shipped disabled), so "POST /__auth/login worked for months with camoufox-harness" was unfounded. What is measured: /automated?key= answers 401 with a browser User-Agent and 403 with urllib's default -- and since that is the route the real credential uses, the browser headers were load-bearing, not cosmetic. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): record the sundial role in the evidence, not just in the log `result.metrics["sundial_role"]` was set before the scan and then thrown away: redact()'s output replaces result.metrics wholesale a few lines later. The check itself was unaffected -- it ran, and it fails closed -- but the saved artifact did not say which role had been confirmed, which leaves "the vectors were never served to this session" unverifiable after the fact. That is most of the reason to record it. Caught by reading the artifact the live run actually published, not by the 105 tests, none of which exercised gate() end to end. There is now one that does, and it fails when the re-assignment is removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * test: delete the vendored Playwright fork, keep only what is ours `tests/` held a fork of a ~v1.55-era playwright-python suite, run in CI as `playwright_vendored` alongside upstream's own suite. Measuring the two against the same binary says the fork was strictly the weaker of them: * 73 of the 74 tests it skipped as "Not supported by Camoufox" PASS in upstream's copy. Those skips predate main-world execution and were never revisited, so the fork was asserting the browser is worse than it is. * Of its passing tests, eight had no upstream counterpart. Six were genuinely Camoufox-specific; the other two were tests upstream had since renamed. * Everything else was upstream code, one generation stale, run twice. So the fork goes, and the six tests that were doing real work become `tests/camoufox/` -- three modules that `ci/suite.py` overlays into the fetched upstream checkout, where they run against its conftest and its server at whatever tag was resolved. Nothing frozen, so nothing to go stale. Two of them take over from skiplist entries that had been pointing at the fork: * the worker locale. Upstream expects a worker NOT to inherit the context locale (playwright#38919); Camoufox sets it below that layer, so its workers agree with the main thread. A page/worker disagreement is a free signal, so the replacement asserts they match rather than hardcoding one string. * the User-Agent. The bare binary advertises `Camoufox/`, not `Firefox/`, and only the Python package rewrites it. The replacement asserts a well-formed Gecko token and, more usefully, that the wire and the DOM agree on it. A skiplist entry that hands its job to another test now says so in a `replaced-by:` field, and `ci/summarize.py` fails the run if that file does not exist -- otherwise a rename quietly turns "covered elsewhere" into "not covered". Also here, because deleting the fork exposed them: * `tests/local-requirements.txt` was the only thing holding the suite below pythonlib's `playwright = "<1.63"` ceiling, and it is gone. `ci/versions.py` now applies that ceiling directly, so a future Firefox bump cannot silently resolve to a client the shipped package refuses to install. * the pytest header named `/skiplist.yml` whatever it had actually read. Since ci/suite.py copies the plugin into the checkout, that was a path with no file at the end of it -- misleading precisely when someone is chasing down a skip. It now reports the file it loaded. * the summary line printed "Camoufox 152.0.4 ... against Playwright v1.61.0, which targets Firefox 151.0", which reads like a misconfiguration. Playwright trails Firefox and skips generations -- it pinned 151 then 153, never 152 -- so it now says which rule picked the tag and that the browser is Firefox 152. `make tests` runs the one suite. Verified: the three overlaid modules pass against 152.0.4-beta.31 (8/8), the overlay refuses to shadow an upstream module and is idempotent across a reused checkout, and both new guards were mutation-checked. ci/tests: 112 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * feat(ci): a local way to ask where the stealth failures are "Which five sundial checks failed?" has no answer in CI, and the reason is worth writing down rather than rediscovering: score mode's payload is buckets keyed "|" holding two integers each. It carries no check names and no ids, so a failing check's identity is not something the gate discards -- it is something sundial never sends. No artifact, log or sealed report can recover it. What the payload does know is which category the failures are in, and the gate was throwing that away too. `--explain` prints a per-category breakdown, and: * it prints, never records -- `result` is untouched, so the artifact still carries only `_PUBLISHABLE`; * it is refused outright under GITHUB_ACTIONS, before anything is sent. A category table is not a vector, but "Graphics 3/17" is the most useful single fact an adversary could take from a public log, which is precisely why redact() does not publish one; * it says in its own output that names need --allow-full-report and a role sundial serves full reports to, so a reader does not mistake the category view for the whole answer; * an unclassified category is flagged there too, on the same rule the gate uses -- a new sundial section must not default to ignored. Also corrects ci/README.md, which claimed "identities in the results file are HMACs". They are not, and have not been: redact() ships no per-check rows at all, deliberately, because a map of HMACs still says how many distinct checks fail and lets a reader follow one across releases. The README was describing a weaker guarantee than the code actually makes. The CI refusal is mutation-checked. ci/tests: 116 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): 193 of the 202 tests the skiplist skipped actually pass The skiplist was wrong, and wrong in the way that matters: it claimed the browser could not pass tests it passes, and the stated reasons made that look checked. Where it came from: the first version of ci/skiplist.yml took all nine `tests/async/*.disabled` modules from the vendored suite -- a 1:1 match, nothing independently derived -- and gave each a plausible justification without running any of them. That is the exact thing the file's own header calls "a regression wearing a disguise", committed in the commit that wrote the header. Ran every entry with the skiplist disabled, against 152.0.4-beta.31: test_element_handle.py 59 skipped 0 fail test_popup.py 26 skipped 0 fail test_dispatch_event.py 12 skipped 0 fail test_check.py 9 skipped 0 fail test_launcher.py 9 skipped 0 fail test_focus.py 6 skipped 0 fail test_fill.py 3 skipped 0 fail test_click.py 71 skipped 2 fail client_certificates 5 skipped 5 fail (two individual tests) 2 skipped 2 fail Seven of nine modules failed nothing at all. 193 of 202 passed. The suite was reporting 1339 passing while silently excluding 193 more that also pass -- and nothing would have caught those 193 regressing. Some reasons were not just over-broad but wrong. test_popup.py was skipped for "Camoufox resolves the User-Agent from its fingerprint config, so an arbitrary override does not and must not stick"; the two UA tests in it pass, because the bare binary under plain Playwright does honour `user_agent=` -- the fingerprint resolution is in the pythonlib wrapper, which this suite does not use. The neighbouring test_request_headers_should_work entry gets that distinction right, four entries earlier. test_dispatch_event.py was skipped for "Camoufox only emits trusted events"; that is true and those tests never assert isTrusted. So: 13 entries down to 5. test_click.py narrows from the module to the two tests that fail -- Playwright's stable-position wait polls the bounding box then dispatches instantly, and the humanized path spends real time travelling, so an animating button is clicked mid-flight (offset 100, expected 300). The client-certificate module stays whole: all five fail at the TLS layer, which is support that genuinely is not compiled in. The `[chromium]` and `[webkit]` patterns are deleted -- the runner pins `--browser firefox`, so they matched 0 tests and only made the list look more considered than it was. And the guard, because a reason is an assertion about the browser and nothing was checking it: ci/run_skiplist_audit.py runs every entry with the skiplist disabled and FAILS THE BUILD if a skipped test passes. It is cheap exactly because a correct skiplist is short -- 9 tests, 8 seconds -- so it runs on every PR in tier 3a and is a required gate. Mutation-checked both directions: exit 1 naming the newly-passing tests when a stale entry is re-added, exit 0 now. ci/tests: 118 passed, including that every shipped entry is actually auditable (a `pattern` entry cannot be, and now fails that test rather than riding along unverified). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * feat(ci): run the sync suite too, and say what is left out The Playwright gate ran `tests/async/` and nothing else. Upstream v1.61 collects 2306 tests; that was 1584 of them. The other 722 -- `tests/sync/` (715), `tests/common/` (4), `test_reference_count_async.py` (2), `test_installation.py` (1) -- never ran, and unlike a skiplist entry there was nothing anywhere saying so. No reason was recorded because there was no decision: the vendored fork in tests/ carried `async/` and `async_imp/` and no sync suite, and this runner was pointed at the same shape without checking what upstream had. Same inherited shape as the stale skiplist, one level up. TARGETS now names `tests/async/` and `tests/sync/`. The sync API is a greenlet wrapper over the same Juggler traffic, so much of it duplicates async at the protocol level; it is here because pythonlib ships a sync API users actually drive, and the wrapper has its own timeout and reentrancy behaviour the async tests cannot reach. ISOLATED_TARGETS runs `tests/common/` and `test_reference_count_async.py` in a second pytest process, on the first shard only. They cannot share a process with the others: each calls sync_playwright()/async_playwright() inside the test body, which cannot start while the session fixtures hold a loop. Together all six fail with "Cannot run the event loop while another loop is running"; alone all six pass. They are worth the extra invocation rather than dropping, because ProtocolCallback objects accumulate when the browser never replies to a protocol message -- and this fork patches Juggler heavily, so that leak can be ours. EXCLUDED holds the one real exclusion with its reason: test_installation.py pip-installs playwright to check packaging, which exercises Playwright's release process and not this browser. A self-test requires every exclusion to carry a reason. And unclaimed() closes the level above the skiplist: if upstream adds a test path that is in neither set, the run errors instead of quietly getting narrower. It ignores assets/ and golden-*/ fixtures, and a self-test proves it catches a new tests/integration/. Verified against the CI-built binary: 2225 passed, 0 failed, 66 skipped, 2291 collected. ci/tests: 122 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): client certs are only unsupported where the BROWSER does the handshake The skiplist audit failed its first CI run, on an entry written one commit earlier. It was right to. `tests/async/test_browsercontext_client_certificates.py` was skipped as a whole module, reasoned as "client-certificate support is not built into the Camoufox binary". On the GitHub runner two of its five tests pass: test_should_throw_with_untrusted_client_certs PASSES test_should_work_with_global_api_request_context PASSES test_should_work_with_new_context fails test_should_work_with_new_context_passing_as_content fails test_should_work_with_new_persistent_context fails The split is not noise. Playwright offers client certificates two ways: playwright.request.new_context(client_certificates=...) the Node driver does the TLS handshake itself -- works, and those are the two that pass. browser.new_context(client_certificates=...) the BROWSER does the handshake -- and this build has nothing to do it with. So the reason was over-broad rather than wrong, and a module entry was exactly the shape that hid the distinction. Now six per-test entries, async and sync, naming the browser-side handshake specifically. Worth recording how the bad entry got written, because the mechanism matters more than the entry: locally all five fail, because this machine's Node/OpenSSL rejects the fixture server outright ("wrong version number"). That looked like uniform absence of support and it was not. A local run is a hypothesis; CI is the authority for what fails. The audit is what turned that from an opinion into a build failure, one run after the mistake. The sync entries assume the same split from identical test names; the audit will confirm or correct that on the next run rather than my asserting it from here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): async and sync cannot share a pytest process Adding tests/sync/ to the same pytest run as tests/async/ produced 50 tests that passed only on retry. 47 were fixture-setup errors in two fetch modules: RuntimeError: Runner.run() cannot be called from a running event loop Upstream's sync suite is a greenlet wrapper; its async suite runs under pytest-asyncio. In one process, whichever runs second breaks the other's loop. Measured, against the CI-built binary: tests/async alone 1526 passed, 1 timing flake the two fetch modules alone 102 passed async fetch + one sync module 14 failed sync module first, then async fetch 46 errors So sharding was not hiding anything -- async alone is clean -- and the fault is the mixing, not the size of the run. What makes this worth a structural fix rather than a retry budget: the damage lands in async FIXTURE SETUP, so it presents as "the fetch tests are flaky" -- a browser-shaped symptom for a harness-shaped cause -- and the retry pass then makes it vanish. Left alone, CI goes green with 50 silent retries, and the natural response to any that stuck would be a skiplist entry recording a browser failure that does not exist. That is the failure this branch has spent its last several commits removing. GROUPS now names three sets, each run in its own process: tests/async/, tests/sync/, and (tests/common/ + test_reference_count_async.py). The first two shard; the third is six tests and does not, because splitting it hands some shard an empty selection and pytest exits 5 for that. The pairing in the third group is measured, not assumed -- those two run together cleanly (6 passed). Retries stay inside their own group, for the reason the groups exist. Verified: 2225 passed, 4 failed, 66 skipped, 2295 collected, and 1 retry-passer instead of 50. All four failures are the two client-cert tests that go through playwright.request.new_context(), which fail only on this machine -- its Node/OpenSSL rejects the fixture server ("wrong version number"). They pass on the runner, which is why they are not skiplisted; the audit is what will hold that claim honest. Expect 2229/0 in CI. ci/tests: 124 passed, three of them pinning this shape -- async and sync in different groups, no target in two groups, and the small group unsharded. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): refuse to test a Firefox the branch does not pin Suite selection follows the browser version correctly: ci/versions.py resolves Firefox 155 to Playwright v1.62.0 and 152 to v1.61.0, and the workflow threads `browser_version` from workflow_call/dispatch through resolve into the Playwright job, which fetches that tag. That half works. The other half does not. Nothing else honours the input: ci.run_build / make read upstream.sh fetch-browser `python -m camoufox fetch`, whatever is current ccache key uses the resolved version (cosmetic) So `browser_version: 155.0` on a branch pinning 152.0.4 compiles 152 and judges it against the suite chosen for 155. It passes, it means nothing, and no output anywhere says the browser and the suite are describing different releases. For a gate whose entire job is to make an upgrade provable, that is the worst available outcome. The legitimate flow never had this in it: an upgrade to a new Firefox is a branch that edits upstream.sh -- that is what an upgrade is -- and resolution then reads it by default, so the build, the suite and the input cannot disagree. The mismatch only arises from a dispatch that asks for a version the branch does not pin. `--check-upstream` refuses that combination with a message saying what would have happened and what to do instead (bump upstream.sh). The workflow passes it, and a self-test asserts the workflow passes it, because a guard nothing invokes is decoration. Mutation-checked. Not fixed here, and deliberately: making the build honour an arbitrary version would mean synthesising an upstream.sh -- version plus release tag plus closedsrc_rev -- for a release that may not exist. Refusing the contradiction is the honest amount of machinery for it. ci/tests: 126 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): the fetched browser must be the generation the suite was chosen for A driver-only pull request does not build. It downloads the current release, which is the right browser to judge a driver change against -- it is what users run. But the Playwright suite is chosen from upstream.sh, and those two agree only until an upgrade window opens. The sequence that breaks: the version agent bumps upstream.sh to Firefox 155 and that merges. No build of 155 is published yet. The next driver-only PR resolves its suite for 155, fetches the published 152, and tests the old browser against the new suite. It passes, and says nothing. Today they happen to agree exactly -- upstream.sh pins 152.0.4-beta.31 and the latest release is v152.0.4-beta.31 -- which is timing, not a guarantee, and precisely the kind of coincidence that hides this until the upgrade it is supposed to protect. So the fetch step now reports which build it installed and refuses a mismatch. Beta drift inside a generation is fine and expected: beta.30 against beta.31 does not change which Playwright tag is right, and demanding an exact match would fail every run between a bump and a release. A generation apart is not fine, and that is what is checked. Fails closed on input it cannot read, rather than passing by accident. Mutation-checked. A self-test asserts the workflow actually invokes it. ci/tests: 128 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): a driver-only pull request cannot satisfy a required build The summary was told to require the `build` suite on every run: required="pythonlib native_rules" if [ "$browser_changed" != "skip" ]; then required="$required build patch_guards ..." fi `browser_changed` is only ever `true` or `false` -- nothing emits `skip` -- so the branch is always taken and `build` is always required. But the build job is deliberately skipped whenever the browser was fetched rather than compiled, and only that job writes a `build` result. summarize then reports `build` is required but produced no result file. A suite that did not run has not passed. which is the correct rule applied to a suite that was never supposed to run, and the gate goes red. That blocked every pull request touching only pythonlib/, ci/, tests/ or the documentation -- most of them, and precisely the cheap path ci/README.md advertises as "driver-only pull requests never build". It was never seen because this branch edits the Makefile and additions/, so its own runs always took the build path. The browser suites stay required either way: fetched or built, the browser is there and they run against it. `build` is the only one that follows. `test_build_is_required_only_when_the_browser_was_built` extracts the workflow's own `required=` assembly and runs it under bash for both values, rather than pattern-matching the shell -- the bug was a comparison that read as deliberate, and only running it says what it does. Mutation-checked: restoring `!= "skip"` fails it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): sundial being down is a skip, not a verdict on the browser The stealth check drives a service on another host. Any failure reaching it -- DNS, a refused connection, the edge answering 502 for a minute -- came back as ERROR, failed the job, and failed the merge gate. So an outage over there blocked every pull request in this repository, including ones that had nothing to do with stealth, and the only fix available to a contributor was to wait. That is not a statement about the browser. When sundial cannot be reached the browser was not measured at all, so neither a pass nor a failure is true. It is now recorded as SKIP with the reason attached, the job exits 0, and `ci/summarize.py --allow-skip sundial` tolerates it: shown on the summary table with its own icon and its reason, and not a merge block. The line is drawn at whether sundial answered: down no HTTP reply at all (URLError, timeout, connection reset), or a 5xx, or a 429 -- the origin is broken or is refusing everyone. answered everything else. 401 is a bad credential, 403 is the edge refusing a non-browser request, and both are this repository's problem to fix. Skipping past those would turn a misconfigured stealth gate into a permanently green one. And an answer stays an answer further in: a role sundial would serve the private vectors to, a full report where a score was requested, a pass rate under the floor -- all still fail, as before. Both auth routes are tried, and the key route 401s whenever the stored secret is a password rather than an automation key. One real reply is enough to know sundial is up, so `authenticate()` reports an outage only when neither route got an answer. The skip note goes through `scrub()` like every other published string: the token route puts the credential in the URL, and a URLError carries the URL that raised it. `--allow-skip` is per suite and nothing else is on the list. Every other suite runs on the runner; none of them has this excuse. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * feat(ci): run the suite isolated first, and count what needs the main world The conformance suite forced `disableWorldIsolation` for every run. That made upstream's tests pass -- they assert upstream semantics, reading globals their own page scripts defined -- but it measured a mode nobody ships, and it produced no number at all for what isolation costs. Isolation is the reason this fork exists; running 2229 tests against it turned off says less than it looks like. Each group is now run up to three times: 1. isolated -- the configuration users get. A test reading a page-defined global fails here by design. 2. the same failures again, still isolated. A pass is a flake, and a flake must not be counted as a world difference. 3. what is still failing, with isolation off. A test that passes in 3 is a **main-world fallback**: it counts as a pass -- the browser does honour the contract -- and its identity is recorded in `metrics.main_world_fallbacks`, with the count on the summary table. That count is the isolated-world conformance gap. It is invisible in the pass/fail totals by construction, which is exactly why it has to be printed: a jump in it means the isolation boundary moved, and nothing else in this pipeline would say so. A test failing in *both* worlds is a plain failure, as before. `CI_WORLD` selects the world and defaults to isolated, so a plain `pytest -p pw_camoufox_plugin` by hand measures the browser as it ships. `_apply_world()` *clears* a stale `disableWorldIsolation` as well as setting it: the passes are separate processes inheriting one job environment, and a leftover flag would make the isolated pass quietly measure the main world -- which would silently zero the very number this is for. Two things this depends on, fixed here: Each group gets its own pytest cache. All three run in one checkout, and pytest only drops a `lastfailed` entry when that test is collected again and passes -- so with the shared cache the async group's rerun was selecting from a set the sync group had also written into. Depending on which groups had failed that meant re-running the whole group or selecting nothing at all. Per-group, `--last-failed` means what it says, and passes 2 and 3 can use it to name exactly the right tests. `ci/run_skiplist_audit.py` pins the MAIN world. The suite now counts a test needing the main world as a fallback rather than a failure, so a skiplist entry has to claim the test cannot pass in either world -- auditing under isolation would let an entry justify itself with a failure the suite would never have counted, which is the same class of untrue-but-plausible reason the audit exists to catch. Shard merging sums the fallback counts and unions the identity lists; taking the first shard's, as the generic metric merge did, would report a sixth of the number. Mutation-checked, along with the stale-flag clear. The first CI run on this is what establishes the real fallback count. The plugin's own note put it at roughly 37; that was measured a while ago and is not a promise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * docs: describe the pipeline that exists, not the one that is coming CONTRIBUTING.md and the pull request template still described the process this branch replaced: run both suites by hand, screenshot the output, paste it in. CLAUDE.md was already pointing at CONTRIBUTING.md for "the full pipeline" that CONTRIBUTING.md did not mention. Since the premise of this work is that the rule in CONTRIBUTING.md was never enforced, leaving it unchanged left the rule describing the wrong thing. Both now say what CI does and what the one required check is. The template no longer asks for a screenshot: nothing checked that the browser in one was built from the branch under review, which is the whole reason the pipeline exists, and CI leaves its own report as a comment. The local commands stay, because running build-tester by hand while working on a spoofing patch is still the fastest way to find out whether it did what you meant. Several comments described this repository as containing an auto-update harness that is not in it: `ci/results.py` credited `verify.py` and "the repair agent" with computing the verdict that `ci/summarize.py` computes, and `ci/sundial.yml` and `ci/build-tester.yml` sited themselves relative to a `harness/policy.yml` nobody can open. Each now names what actually decides here and marks the harness as the out-of-repository caller it is. `run_sundial.py waive` says outright that the file it prints a stanza for is not in this repository, which is worth knowing before going to look for it. Also: PEP 8 blank lines around `resolve_verstr()`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * perf(ci): drop the same-world retry -- 7m50s a shard, nothing recovered The first real run of the isolated-first suite took 21m13s a shard against 4m34s before. Most of that was a pass that could not have worked. Shard 4, both sharded groups: isolated (full) 335s + 331s 35 and 11 failures isolated retry 205s + 265s 0 recovered main world 19s + 12s 46 recovered The retry was there so that a flake could not be mistaken for a world difference. It cannot do that job: * These failures are deterministic. A test reading a global its own page script defined does not intermittently stop seeing it. * Failing that way is slow. The read returns undefined rather than throwing, so the test sits on a Playwright timeout -- which is why re-running 46 known failures cost nearly eight minutes while proving them in the other world cost thirty-one seconds. * Upstream already reruns. The `105 rerun` on that first line is every one of those 35 failures having been retried three times by the suite's own pytest-rerunfailures before the run reported them. A flake does not survive that, so there was nothing left for a fourth and fifth attempt to find. So: isolated, then the main world, then -- only for what failed in BOTH -- one retry. That last set is normally empty, so the retry is free on a healthy run and still answers the one open question on an unhealthy one: a test no world satisfies is either broken or flaky. It runs in the main world, where a pass means "not reproducible" rather than "needed isolation off", which is already known by then. A flake surviving upstream's three reruns and then passing in the main world would now be counted as a fallback rather than as a flake. That is the trade, and it is worth it: the count is reported, not gated, and no verdict moves. Also adds the guard that was missing between the phases. `--last-failed` with nothing previously failed does not select nothing -- pytest declines to filter, and runs the whole group. Unguarded, a group that passed cleanly under isolation would have been re-run end to end in the main world, silently replacing the result it was meant to refine. Both rerun passes are now behind a non-empty check, and a self-test asserts it of every `--last-failed` in the loop. Mutation-checked, as is the single-isolated-pass rule -- "retry it in the same world first, just to be safe" reads as obviously correct and costs eight minutes a shard. Expected shard time is now ~11 min: the isolated pass, which is the measurement, plus half a minute to resolve it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * perf(ci): bound the isolated pass, which is where the timeouts come from Some isolated failures do not fail. They hang for the full 180s per-test timeout and are then rerun three more times, so one test can cost twelve minutes. The main-world baseline had zero timeouts across all six shards, so this arrived with isolation. Reproduced against a build, and the cause is not the harness: page script calls window.exposedFn() isolated -> HANG main -> resolves evaluate() calls window.exposedFn() isolated -> resolves main -> resolves `expose_function` installs its binding on the isolated world's global. Page script calling `window.fn()` looks at the page's own window, does not find it, and the call never reaches Python -- so a test awaiting the future that call was meant to resolve waits forever, because that await has no Playwright timeout behind it. evaluate() works because it runs in the same world as the binding. That is isolation doing exactly what it is for. A page that can reach an automation binding can detect it, which is the reason this fork exists. These tests assert a behaviour Camoufox deliberately does not have, and they cannot be fixed -- only recognised, which pass 2 does in about half a second each. What can be fixed is the price of recognising them. Pass 1 is a classifier: its only question is whether a test passes as Camoufox ships, and a test that hangs has already answered it. So pass 1, and only pass 1, is bounded: per-test timeout 90s. The slowest test in the entire main-world baseline was 30.4s of 2295; two exceeded 30s and none exceeded 45s. A Playwright action times out at 30s. Three times the slowest honest thing that happens, and half the previous bound. upstream's reruns off, via CI="". tests/conftest.py sets `reruns = 3` whenever $CI is set, and that is the only thing it reads $CI for. It is insurance that almost never pays out -- 2 reruns across all 2295 baseline tests -- and under isolation it turned every deterministic world difference into four attempts: 138 reruns in a single shard's isolated pass, recovering nothing. Note that `--reruns 0` as an argument would not work; conftest overwrites config.option.reruns in pytest_configure, so the environment is the only lever that holds. A hang now costs one 90s wait instead of up to 720s. A flake missed by not rerunning is not lost: it fails pass 1, passes pass 2, and is counted as a fallback -- noise in a reported metric, not a change in any verdict. Passes 2 and 3 keep upstream's conditions untouched. They are the ones deciding what an answer means, and they run against a handful of tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * perf(ci): stop rebuilding a browser that has not changed Every push to this branch recompiled Firefox, 24 minutes at a time, including the three in a row that changed nothing but ci/ and documentation. `browser_changed` is computed against the pull request's BASE, not against the push, so a branch that touched patches/ or additions/ even once keeps rebuilding forever. That part is correct and should stay: the published release does not contain this branch's browser changes, so testing against it would test a different browser than the one under review. Using the release is not the alternative. The alternative is not building the same thing twice. The build job now asks a narrower question first -- not "does this branch change the browser" but "has the browser changed since the last one we built" -- and answers it with a cache keyed on a hash of every input that can alter the binary: the same path list browser_changed greps for, plus this workflow, which pins the toolchain the build runs on. On a hit the 634 MB dist is restored and every build step is skipped, which is the difference between 24 minutes and about one. Two things that would otherwise have made this quietly wrong: A hit still has to report a `build` result. It is a required suite whenever the browser was built rather than fetched, and a required suite that produced no result is -- correctly -- a failure. Same trap as requiring `build` on a driver-only pull request, reached from the other side. The hit path writes one recording that the browser was restored and under which key, so "this run compiled nothing" is a fact in the evidence rather than an absence in it. No restore-keys. Everywhere else in this workflow a prefix match is right; a partly warm ccache is still warm. Here it would hand the test jobs a browser built from different sources while every suite reported on it looking perfectly healthy. The self-tests hold the two lists together: if a path is ever added to the browser_changed grep without being added to the cache key, a change there would neither force a build nor invalidate the cache, and the run would silently test a browser that predates it. Mutation-checked, along with the guards on each expensive step and the absence of restore-keys. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * perf(ci): a Juggler JavaScript change does not need libxul relinked Juggler is mostly JavaScript, and JavaScript does not need a compiler. Measured on a real build, ccache reported a 98.63% hit rate (4167/4225), so almost none of those 24 minutes was compiling C++ -- it was Rust, linking libxul, and packaging, none of which a .js file affects. And there is no omni.ja to rebuild either. CI archives the UNPACKAGED dist/bin; omni.ja only exists in dist/camoufox/, which `mach package` produces and nothing here uses. In dist/bin, Juggler is loose files under chrome/juggler/ -- symlinks into the source tree, dereferenced into the artifact by `tar -ch`. Delivering new JavaScript is a copy. So the build cache is now keyed on a hash of the COMPILED inputs only. If that hash matches, the compiled half is identical by construction and this branch's resources are laid over the restored browser. The hash is the classification: there is no "did only JavaScript change?" diff, because a diff answers the wrong question -- it compares against the pull request's base, while what matters is whether the cached browser has the same native sources. Same hash, same binary, whatever the diff says. Two traps, both closed and both mutation-tested, because either one silently serves a browser that is not the one under review while every suite reports green: additions/juggler/ is not all JavaScript. It also holds the screencast encoder and the remote-debugging pipe -- 5 .cpp, 5 .h, 2 .idl, 3 components.conf, 4 moz.build -- compiled into libxul. Only the files jar.mn lists are treated as resources; everything else, including any extension nobody has considered yet, is native and forces a build. jar.mn is itself native, so a resource removed from it cannot leave a stale copy behind. The mapping is per-file, not a prefix. jar.mn maps TargetRegistry.js to content/TargetRegistry.js (a level added), content/FrameTree.js to content/content/FrameTree.js (preserved), and content/JugglerFrameChild.sys.mjs to content/JugglerFrameChild.sys.mjs (dropped). Two files in one source directory land at different depths. A prefix rule writes one of them to the wrong path and leaves the old copy in place. Verified against a real build rather than reasoned about: all 22 jar.mn entries resolve to files that exist in dist/bin, and overlaying this branch onto a dist built before it turns FrameTree.js from 0 occurrences of nukeSandbox to 1, with all 22 resources byte-identical to the branch afterwards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * docs(ci): name the mechanism that actually hangs, not the one I found first The comment blamed expose_function. That mechanism is real -- verified against a build -- but no upstream test has that shape, so it is not what hangs in CI. Running the full async suite isolated against a build names them: four tests, always the same four, all in tests/async/test_route_web_socket.py. 225 of 1509 fail under isolation; four of those hang. The general shape, which is worth stating because it will recur: a Playwright feature implemented by installing something on the page's global lands in the isolated world instead, so anything the PAGE originates never reaches the automation. route_web_socket replaces window.WebSocket from an init script; isolated, that replacement is in the sandbox and a socket the page's own script opens is never intercepted. expose_function puts its binding on the sandbox global, so page script calling window.fn() finds nothing -- called from evaluate() it works, which is why it does not hang. They hang rather than fail because the waits involved have no Playwright timeout behind them: a Twisted future from the test server, an asyncio future a binding was meant to resolve. Everything else isolation breaks fails at Playwright's 30s. One thing to flag beyond the test suite: the route_web_socket half is not a test artifact. Measured with a page whose own script opens a socket -- what a real site does -- the handler fires in the main world and never fires isolated. A user calling page.route_web_socket() against a real site gets no interception and no error. It is not fixable here, because the feature works by replacing a page global and that is exactly what an isolated world exists to prevent a page from seeing, but it deserves an issue of its own rather than a comment in a CI runner. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): the isolated pass cannot bound a hang, so stop asking it to Four shards ran for two hours each and were killed by timeout-minutes, three runs in a row. The cause is not the duration of anything. Measured on run 34799668707, with ISOLATED_TIMEOUT already lowered to 90s: tests/async/ isolated pass completed in 296s. The bound works. tests/sync/ test_should_work_with_ws_close printed pytest-timeout's "+++ Timeout +++" banner at exactly 90s -- and the process then sat there for the remaining 1h50m. So the signal fires and the test dies; the process does not. pytest-timeout's signal method raises at the next bytecode boundary, and Playwright's sync API is parked in a greenlet switch that never reaches one cleanly, so the raise lands inside the dispatcher and wedges it. `--timeout-method=thread` fires reliably but kills the interpreter and takes the other ~1500 tests in the group with it. No per-test value bounds this, which is why lowering 180 -> 90 changed nothing. Declared rather than discovered, then. The isolated pass cannot learn that these hang without hanging, so ISOLATION_HANGS tells it: they are --ignore'd out of pass 1 and run directly in the main world, where they pass and are counted as fallbacks exactly as if isolation had failed them honestly. Coverage is not lost -- the same tests run, in the world that can run them. Verified in the same run: tests/async/test_route_web_socket.py::test_should_work_without_server isolated -> Timeout main -> PASSED Deliberately not ci/skiplist.yml. That list means "fails in the most permissive world", and run_skiplist_audit.py enforces it by running every entry with CI_WORLD=main and failing the build on any that pass. These pass there, so an entry would be rejected by the audit and would be untrue as written. The tests keep the two lists apart. The second half is the backstop, because the next unboundable hang will not be this one. Each pytest invocation was bounded at args.timeout, default 10800s -- three hours, against a job capped at 120 minutes. It could never fire: GitHub hard-killed the runner first, taking the junit and diagnostics uploads with it. Now --group-timeout, 1200s, roughly four times the slowest healthy invocation measured, and the job drops 120 -> 40. A wedge costs twenty minutes and still reports what it collected, instead of two hours and nothing. The browser bug underneath is real and is not a test artifact: route_web_socket replaces window.WebSocket from an init script, which under isolation lands in the sandbox, so a socket the page's own script opens is never intercepted and the caller gets no error. Filed as #775, with the native-interception fix that keeps it undetectable. When that lands these stop hanging and the declaration goes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): a cache-hit build cannot repack the archive over its own input The prebuilt-browser path failed every time it actually hit: zstd: camoufox-dist.tar.zst already exists; stdin is an input - not proceeding. tar: -: Cannot write: Broken pipe `zstd -o` refuses an existing destination and exits 1, and the overlay step repacks to the same filename it just unpacked from. It went unnoticed because until now every run changed the browser sources and rebuilt instead -- the cache restored, the overlay ran ("overlaid 22 resource(s)"), and the step died one line later. The first pull request that did not touch the browser found it. Repack to a temporary name and mv it into place. That sidesteps the refusal, and means a repack that dies partway cannot leave a truncated archive where the restored one was. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s * fix(ci): a six-test module cannot be sharded six ways The declared-hang pass ran with the shard filter still applied, so a shard that owned none of the module got: collected 6 items / 6 deselected / 0 selected ============================ 6 deselected in 0.05s =========================== pytest exits 5 on an empty selection and writes no junit, which is exactly what "the module did not run" looks like -- so the guard that exists to catch lost coverage failed three of six shards instead. The other three owned some of the six and passed, which is why it looked intermittent. Run it unsharded on the first shard, the way tests/common/ already is and for the same reason. Running the module whole also keeps its fallback accounting in one place rather than spread across shards that each saw a fraction of it. CI_SHARD is cleared rather than dropped: ci/_util.run() layers env over os.environ, so an omitted key would still inherit one. parse_shard() reads empty as "no shard", the same way _NO_UPSTREAM_RERUNS clears $CI. The rest of the run confirms the mechanism. On shard 3, which completed: tests/sync/test_route_web_socket.py::test_should_work_with_ws_close PASSED in 3.07s -- the test that wedged a runner for 1h50m two runs ago -- and the shard finished 386 passed, 0 failed, 0 errored. No shard hung. The whole run took 26 minutes against 2h35m, and the build was 53s against 24m now that the prebuilt cache can repack. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/PULL_REQUEST_TEMPLATE.md | 26 +- .github/actions/prepare-browser/action.yml | 79 + .github/workflows/lint.yml | 21 - .github/workflows/tests.yml | 1036 +++++++ .gitignore | 8 + CLAUDE.md | 43 +- CONTRIBUTING.md | 20 +- Makefile | 8 +- additions/juggler/TargetRegistry.js | 16 +- additions/juggler/content/FrameTree.js | 24 + build-tester/README.md | 2 +- build-tester/requirements.txt | 18 +- build-tester/scripts/run_tests.py | 6 + build-tester/scripts/runner.py | 8 + build-tester/src/lib/checks/collectors.ts | 31 +- ci/README.md | 628 ++++ ci/__init__.py | 16 + ci/_pytest.py | 147 + ci/_util.py | 289 ++ ci/branch-protection.json | 13 + ci/browser_inputs.py | 187 ++ ci/build-tester.yml | 39 + ci/pw_camoufox_plugin.py | 296 ++ ci/requirements.txt | 8 + ci/results.py | 132 + ci/run_build.py | 75 + ci/run_build_tester.py | 333 +++ ci/run_native.py | 140 + ci/run_patch_guards.py | 87 + ci/run_playwright.py | 519 ++++ ci/run_prepare.py | 172 ++ ci/run_pythonlib.py | 66 + ci/run_skiplist_audit.py | 159 + ci/run_sundial.py | 1052 +++++++ ci/skiplist.yml | 141 + ci/suite.py | 234 ++ ci/summarize.py | 334 +++ ci/sundial.yml | 73 + {tests => ci/tests}/__init__.py | 0 ci/tests/test_ci.py | 2551 +++++++++++++++++ ci/tribal-rules.yml | 310 ++ ci/versions.py | 308 ++ {tests/async => native-tests}/__init__.py | 0 native-tests/_chaos.py | 162 ++ native-tests/_churn.py | 153 + native-tests/conftest.py | 310 ++ native-tests/pytest.ini | 8 + native-tests/test_contexts_vs_browsers.py | 280 ++ native-tests/test_crash_recovery.py | 682 +++++ native-tests/test_memory_growth.py | 162 ++ native-tests/test_no_leaks.py | 319 +++ native-tests/test_tribal_rules.py | 571 ++++ pythonlib/camoufox/utils.py | 37 +- pythonlib/camoufox/virtdisplay.py | 39 +- pythonlib/tests/test_launch_geometry.py | 21 +- tests/README.md | 63 +- tests/assets/beforeunload.html | 9 - tests/assets/client-certificates/README.md | 60 - .../client/self-signed/cert.pem | 28 - .../client/self-signed/csr.pem | 26 - .../client/self-signed/key.pem | 52 - .../client/trusted/cert.pem | 29 - .../client/trusted/csr.pem | 26 - .../client/trusted/key.pem | 52 - .../server/server_cert.pem | 32 - .../client-certificates/server/server_key.pem | 52 - tests/assets/client.py | 34 - tests/assets/consolelog.html | 11 - tests/assets/csp.html | 1 - tests/assets/digits/0.png | Bin 434 -> 0 bytes tests/assets/digits/1.png | Bin 346 -> 0 bytes tests/assets/digits/2.png | Bin 413 -> 0 bytes tests/assets/digits/3.png | Bin 434 -> 0 bytes tests/assets/digits/4.png | Bin 403 -> 0 bytes tests/assets/digits/5.png | Bin 422 -> 0 bytes tests/assets/digits/6.png | Bin 445 -> 0 bytes tests/assets/digits/7.png | Bin 387 -> 0 bytes tests/assets/digits/8.png | Bin 447 -> 0 bytes tests/assets/digits/9.png | Bin 437 -> 0 bytes tests/assets/dom.html | 4 - tests/assets/download-blob.html | 29 - tests/assets/drag-n-drop.html | 40 - tests/assets/dummy_bad_browser_executable.js | 3 - tests/assets/empty.html | 0 tests/assets/error.html | 17 - tests/assets/es6/.eslintrc | 5 - tests/assets/es6/es6import.js | 2 - tests/assets/es6/es6module.js | 1 - tests/assets/es6/es6pathimport.js | 2 - tests/assets/file-to-upload-2.txt | 1 - tests/assets/file-to-upload.txt | 1 - tests/assets/frames/child-redirect.html | 1 - tests/assets/frames/frame.html | 15 - tests/assets/frames/frameset.html | 8 - tests/assets/frames/nested-frames.html | 30 - tests/assets/frames/one-frame.html | 1 - tests/assets/frames/redirect-my-parent.html | 3 - tests/assets/frames/script.js | 1 - tests/assets/frames/style.css | 3 - tests/assets/frames/two-frames.html | 16 - tests/assets/geolocation.html | 7 - tests/assets/global-var.html | 3 - tests/assets/grid.html | 52 - tests/assets/har-fulfill.har | 366 --- tests/assets/har-redirect.har | 620 ---- tests/assets/har-sha1-main-response.txt | 1 - tests/assets/har-sha1.har | 95 - tests/assets/har.html | 3 - tests/assets/headings.html | 15 - tests/assets/historyapi.html | 5 - tests/assets/injectedfile.js | 3 - tests/assets/injectedstyle.css | 3 - tests/assets/input/animating-button.html | 42 - tests/assets/input/button.html | 32 - tests/assets/input/checkbox.html | 42 - tests/assets/input/fileupload-multi.html | 12 - tests/assets/input/fileupload.html | 12 - tests/assets/input/folderupload.html | 12 - tests/assets/input/handle-locator.html | 91 - tests/assets/input/keyboard.html | 42 - tests/assets/input/mouse-helper.js | 62 - tests/assets/input/rotatedButton.html | 21 - tests/assets/input/scrollable.html | 23 - tests/assets/input/select.html | 69 - tests/assets/input/textarea.html | 20 - tests/assets/input/touches.html | 35 - tests/assets/mobile.html | 1 - tests/assets/networkidle.html | 1 - tests/assets/networkidle.js | 12 - tests/assets/offscreenbuttons.html | 55 - tests/assets/one-style.css | 3 - tests/assets/one-style.html | 2 - tests/assets/playground.html | 15 - tests/assets/popup/popup.html | 12 - tests/assets/popup/window-open.html | 11 - tests/assets/pptr.png | Bin 6138 -> 0 bytes tests/assets/react.html | 33 - .../react/react-dom@16.13.1.production.min.js | 239 -- .../react/react@16.13.1.production.min.js | 32 - tests/assets/sectionselectorengine.js | 10 - tests/assets/self-request.html | 5 - tests/assets/serviceworkers/empty/sw.html | 3 - tests/assets/serviceworkers/empty/sw.js | 0 tests/assets/serviceworkers/fetch/style.css | 3 - tests/assets/serviceworkers/fetch/sw.html | 5 - tests/assets/serviceworkers/fetch/sw.js | 7 - .../assets/serviceworkers/fetchdummy/sw.html | 12 - tests/assets/serviceworkers/fetchdummy/sw.js | 15 - tests/assets/shadow.html | 17 - .../assets/simple-extension/content-script.js | 2 - tests/assets/simple-extension/index.js | 2 - tests/assets/simple-extension/manifest.json | 14 - tests/assets/simple.json | 1 - tests/assets/title.html | 1 - tests/assets/worker/worker.html | 14 - tests/assets/worker/worker.js | 16 - tests/assets/wrappedlink.html | 32 - tests/async/conftest.py | 139 - tests/async/test_add_init_script.py | 98 - tests/async/test_assertions.py | 817 ------ tests/async/test_asyncio.py | 69 - tests/async/test_browser.py | 53 - tests/async/test_browsercontext.py | 780 ----- .../async/test_browsercontext_add_cookies.py | 413 --- .../async/test_browsercontext_clearcookies.py | 194 -- ...sercontext_client_certificates.py.disabled | 227 -- tests/async/test_browsercontext_cookies.py | 229 -- tests/async/test_browsercontext_events.py | 193 -- tests/async/test_browsercontext_proxy.py | 135 - .../test_browsercontext_request_fallback.py | 288 -- .../test_browsercontext_request_intercept.py | 192 -- tests/async/test_browsercontext_route.py | 490 ---- ...st_browsercontext_service_worker_policy.py | 36 - .../test_browsercontext_storage_state.py | 112 - tests/async/test_browsertype_connect.py | 458 --- tests/async/test_browsertype_connect_cdp.py | 117 - tests/async/test_cdp_session.py | 93 - tests/async/test_check.py.disabled | 85 - tests/async/test_click.py.disabled | 1113 ------- tests/async/test_console.py | 152 - tests/async/test_context_manager.py | 36 - tests/async/test_defaultbrowsercontext.py | 438 --- tests/async/test_device_descriptors.py | 54 - tests/async/test_dialog.py | 102 - tests/async/test_dispatch_event.py.disabled | 191 -- tests/async/test_download.py | 376 --- tests/async/test_element_handle.py.disabled | 754 ----- ...t_element_handle_wait_for_element_state.py | 156 - tests/async/test_emulation_focus.py | 173 -- tests/async/test_expect_misc.py | 76 - tests/async/test_fetch_browser_context.py | 317 -- tests/async/test_fetch_global.py | 472 --- tests/async/test_fill.py.disabled | 31 - tests/async/test_focus.py.disabled | 110 - tests/async/test_frames.py | 280 -- tests/async/test_geolocation.py | 136 - tests/async/test_har.py | 750 ----- tests/async/test_headful.py | 185 -- tests/async/test_ignore_https_errors.py | 37 - tests/async/test_input.py | 500 ---- tests/async/test_issues.py | 53 - tests/async/test_jshandle.py | 231 -- tests/async/test_keyboard.py | 525 ---- tests/async/test_launcher.py.disabled | 145 - tests/async/test_listeners.py | 32 - tests/async/test_locators.py | 1039 ------- tests/async/test_navigation.py | 1057 ------- tests/async/test_network.py | 926 ------ tests/async/test_page.py | 1345 --------- tests/async/test_page_add_locator_handler.py | 377 --- tests/async/test_page_base_url.py | 114 - tests/async/test_page_clock.py | 462 --- tests/async/test_page_evaluate.py | 320 --- tests/async/test_page_network_request.py | 61 - tests/async/test_page_network_response.py | 70 - tests/async/test_page_request_fallback.py | 352 --- tests/async/test_page_request_intercept.py | 95 - tests/async/test_page_route.py | 1018 ------- tests/async/test_page_select_option.py | 214 -- tests/async/test_pdf.py | 43 - tests/async/test_popup.py.disabled | 448 --- tests/async/test_proxy.py | 131 - tests/async/test_queryselector.py | 352 --- tests/async/test_request_continue.py | 186 -- tests/async/test_request_fulfill.py | 71 - tests/async/test_request_intercept.py | 171 -- tests/async/test_resource_timing.py | 106 - tests/async/test_screenshot.py | 44 - tests/async/test_selector_generator.py | 48 - tests/async/test_selectors_get_by.py | 176 -- tests/async/test_selectors_misc.py | 54 - tests/async/test_selectors_text.py | 210 -- tests/async/test_tap.py | 237 -- tests/async/test_tracing.py | 285 -- tests/async/test_unroute_behavior.py | 453 --- tests/async/test_video.py | 82 - tests/async/test_wait_for_function.py | 91 - tests/async/test_wait_for_url.py | 130 - tests/async/test_websocket.py | 193 -- tests/async/test_worker.py | 191 -- tests/async/utils.py | 75 - tests/async_imp/__init__.py | 0 tests/async_imp/conftest.py | 139 - tests/async_imp/test_frames.py | 280 -- tests/async_imp/utils.py | 75 - tests/camoufox/README.md | 31 + .../test_route_request_fingerprint.py | 0 tests/camoufox/test_user_agent_token.py | 48 + tests/camoufox/test_worker_locale.py | 61 + tests/conftest.py | 356 --- tests/golden-firefox/grid-cell-0.png | Bin 331 -> 0 bytes .../mask-should-work-with-element-handle.png | Bin 98144 -> 0 bytes .../mask-should-work-with-locator.png | Bin 98144 -> 0 bytes .../mask-should-work-with-page.png | Bin 45962 -> 0 bytes tests/golden-firefox/mock-binary-response.png | Bin 6001 -> 0 bytes tests/golden-firefox/mock-svg.png | Bin 206 -> 0 bytes .../screenshot-element-bounding-box.png | Bin 311 -> 0 bytes tests/golden-firefox/screenshot-sanity.png | Bin 36252 -> 0 bytes tests/local-requirements.txt | 25 - .../{ => patches}/assets/touch-reference.html | 0 tests/patches/touchscreen-digitizer.py | 2 +- tests/pyproject.toml | 19 - tests/run-tests.sh | 77 - tests/server.py | 318 -- tests/setup-venv.sh | 7 - tests/testserver/cert.pem | 28 - tests/testserver/key.pem | 52 - tests/utils.py | 82 - 268 files changed, 12501 insertions(+), 28289 deletions(-) create mode 100644 .github/actions/prepare-browser/action.yml delete mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/tests.yml create mode 100644 ci/README.md create mode 100644 ci/__init__.py create mode 100644 ci/_pytest.py create mode 100644 ci/_util.py create mode 100644 ci/branch-protection.json create mode 100644 ci/browser_inputs.py create mode 100644 ci/build-tester.yml create mode 100644 ci/pw_camoufox_plugin.py create mode 100644 ci/requirements.txt create mode 100644 ci/results.py create mode 100644 ci/run_build.py create mode 100644 ci/run_build_tester.py create mode 100644 ci/run_native.py create mode 100644 ci/run_patch_guards.py create mode 100644 ci/run_playwright.py create mode 100644 ci/run_prepare.py create mode 100644 ci/run_pythonlib.py create mode 100644 ci/run_skiplist_audit.py create mode 100644 ci/run_sundial.py create mode 100644 ci/skiplist.yml create mode 100644 ci/suite.py create mode 100644 ci/summarize.py create mode 100644 ci/sundial.yml rename {tests => ci/tests}/__init__.py (100%) create mode 100644 ci/tests/test_ci.py create mode 100644 ci/tribal-rules.yml create mode 100644 ci/versions.py rename {tests/async => native-tests}/__init__.py (100%) create mode 100644 native-tests/_chaos.py create mode 100644 native-tests/_churn.py create mode 100644 native-tests/conftest.py create mode 100644 native-tests/pytest.ini create mode 100644 native-tests/test_contexts_vs_browsers.py create mode 100644 native-tests/test_crash_recovery.py create mode 100644 native-tests/test_memory_growth.py create mode 100644 native-tests/test_no_leaks.py create mode 100644 native-tests/test_tribal_rules.py delete mode 100644 tests/assets/beforeunload.html delete mode 100644 tests/assets/client-certificates/README.md delete mode 100644 tests/assets/client-certificates/client/self-signed/cert.pem delete mode 100644 tests/assets/client-certificates/client/self-signed/csr.pem delete mode 100644 tests/assets/client-certificates/client/self-signed/key.pem delete mode 100644 tests/assets/client-certificates/client/trusted/cert.pem delete mode 100644 tests/assets/client-certificates/client/trusted/csr.pem delete mode 100644 tests/assets/client-certificates/client/trusted/key.pem delete mode 100644 tests/assets/client-certificates/server/server_cert.pem delete mode 100644 tests/assets/client-certificates/server/server_key.pem delete mode 100644 tests/assets/client.py delete mode 100644 tests/assets/consolelog.html delete mode 100644 tests/assets/csp.html delete mode 100644 tests/assets/digits/0.png delete mode 100644 tests/assets/digits/1.png delete mode 100644 tests/assets/digits/2.png delete mode 100644 tests/assets/digits/3.png delete mode 100644 tests/assets/digits/4.png delete mode 100644 tests/assets/digits/5.png delete mode 100644 tests/assets/digits/6.png delete mode 100644 tests/assets/digits/7.png delete mode 100644 tests/assets/digits/8.png delete mode 100644 tests/assets/digits/9.png delete mode 100644 tests/assets/dom.html delete mode 100644 tests/assets/download-blob.html delete mode 100644 tests/assets/drag-n-drop.html delete mode 100755 tests/assets/dummy_bad_browser_executable.js delete mode 100644 tests/assets/empty.html delete mode 100644 tests/assets/error.html delete mode 100644 tests/assets/es6/.eslintrc delete mode 100644 tests/assets/es6/es6import.js delete mode 100644 tests/assets/es6/es6module.js delete mode 100644 tests/assets/es6/es6pathimport.js delete mode 100644 tests/assets/file-to-upload-2.txt delete mode 100644 tests/assets/file-to-upload.txt delete mode 100644 tests/assets/frames/child-redirect.html delete mode 100644 tests/assets/frames/frame.html delete mode 100644 tests/assets/frames/frameset.html delete mode 100644 tests/assets/frames/nested-frames.html delete mode 100644 tests/assets/frames/one-frame.html delete mode 100644 tests/assets/frames/redirect-my-parent.html delete mode 100644 tests/assets/frames/script.js delete mode 100644 tests/assets/frames/style.css delete mode 100644 tests/assets/frames/two-frames.html delete mode 100644 tests/assets/geolocation.html delete mode 100644 tests/assets/global-var.html delete mode 100644 tests/assets/grid.html delete mode 100644 tests/assets/har-fulfill.har delete mode 100644 tests/assets/har-redirect.har delete mode 100644 tests/assets/har-sha1-main-response.txt delete mode 100644 tests/assets/har-sha1.har delete mode 100644 tests/assets/har.html delete mode 100644 tests/assets/headings.html delete mode 100644 tests/assets/historyapi.html delete mode 100644 tests/assets/injectedfile.js delete mode 100644 tests/assets/injectedstyle.css delete mode 100644 tests/assets/input/animating-button.html delete mode 100644 tests/assets/input/button.html delete mode 100644 tests/assets/input/checkbox.html delete mode 100644 tests/assets/input/fileupload-multi.html delete mode 100644 tests/assets/input/fileupload.html delete mode 100644 tests/assets/input/folderupload.html delete mode 100644 tests/assets/input/handle-locator.html delete mode 100644 tests/assets/input/keyboard.html delete mode 100644 tests/assets/input/mouse-helper.js delete mode 100644 tests/assets/input/rotatedButton.html delete mode 100644 tests/assets/input/scrollable.html delete mode 100644 tests/assets/input/select.html delete mode 100644 tests/assets/input/textarea.html delete mode 100644 tests/assets/input/touches.html delete mode 100644 tests/assets/mobile.html delete mode 100644 tests/assets/networkidle.html delete mode 100644 tests/assets/networkidle.js delete mode 100644 tests/assets/offscreenbuttons.html delete mode 100644 tests/assets/one-style.css delete mode 100644 tests/assets/one-style.html delete mode 100644 tests/assets/playground.html delete mode 100644 tests/assets/popup/popup.html delete mode 100644 tests/assets/popup/window-open.html delete mode 100644 tests/assets/pptr.png delete mode 100644 tests/assets/react.html delete mode 100644 tests/assets/react/react-dom@16.13.1.production.min.js delete mode 100644 tests/assets/react/react@16.13.1.production.min.js delete mode 100644 tests/assets/sectionselectorengine.js delete mode 100644 tests/assets/self-request.html delete mode 100644 tests/assets/serviceworkers/empty/sw.html delete mode 100644 tests/assets/serviceworkers/empty/sw.js delete mode 100644 tests/assets/serviceworkers/fetch/style.css delete mode 100644 tests/assets/serviceworkers/fetch/sw.html delete mode 100644 tests/assets/serviceworkers/fetch/sw.js delete mode 100644 tests/assets/serviceworkers/fetchdummy/sw.html delete mode 100644 tests/assets/serviceworkers/fetchdummy/sw.js delete mode 100644 tests/assets/shadow.html delete mode 100644 tests/assets/simple-extension/content-script.js delete mode 100644 tests/assets/simple-extension/index.js delete mode 100644 tests/assets/simple-extension/manifest.json delete mode 100644 tests/assets/simple.json delete mode 100644 tests/assets/title.html delete mode 100644 tests/assets/worker/worker.html delete mode 100644 tests/assets/worker/worker.js delete mode 100644 tests/assets/wrappedlink.html delete mode 100644 tests/async/conftest.py delete mode 100644 tests/async/test_add_init_script.py delete mode 100644 tests/async/test_assertions.py delete mode 100644 tests/async/test_asyncio.py delete mode 100644 tests/async/test_browser.py delete mode 100644 tests/async/test_browsercontext.py delete mode 100644 tests/async/test_browsercontext_add_cookies.py delete mode 100644 tests/async/test_browsercontext_clearcookies.py delete mode 100644 tests/async/test_browsercontext_client_certificates.py.disabled delete mode 100644 tests/async/test_browsercontext_cookies.py delete mode 100644 tests/async/test_browsercontext_events.py delete mode 100644 tests/async/test_browsercontext_proxy.py delete mode 100644 tests/async/test_browsercontext_request_fallback.py delete mode 100644 tests/async/test_browsercontext_request_intercept.py delete mode 100644 tests/async/test_browsercontext_route.py delete mode 100644 tests/async/test_browsercontext_service_worker_policy.py delete mode 100644 tests/async/test_browsercontext_storage_state.py delete mode 100644 tests/async/test_browsertype_connect.py delete mode 100644 tests/async/test_browsertype_connect_cdp.py delete mode 100644 tests/async/test_cdp_session.py delete mode 100644 tests/async/test_check.py.disabled delete mode 100644 tests/async/test_click.py.disabled delete mode 100644 tests/async/test_console.py delete mode 100644 tests/async/test_context_manager.py delete mode 100644 tests/async/test_defaultbrowsercontext.py delete mode 100644 tests/async/test_device_descriptors.py delete mode 100644 tests/async/test_dialog.py delete mode 100644 tests/async/test_dispatch_event.py.disabled delete mode 100644 tests/async/test_download.py delete mode 100644 tests/async/test_element_handle.py.disabled delete mode 100644 tests/async/test_element_handle_wait_for_element_state.py delete mode 100644 tests/async/test_emulation_focus.py delete mode 100644 tests/async/test_expect_misc.py delete mode 100644 tests/async/test_fetch_browser_context.py delete mode 100644 tests/async/test_fetch_global.py delete mode 100644 tests/async/test_fill.py.disabled delete mode 100644 tests/async/test_focus.py.disabled delete mode 100644 tests/async/test_frames.py delete mode 100644 tests/async/test_geolocation.py delete mode 100644 tests/async/test_har.py delete mode 100644 tests/async/test_headful.py delete mode 100644 tests/async/test_ignore_https_errors.py delete mode 100644 tests/async/test_input.py delete mode 100644 tests/async/test_issues.py delete mode 100644 tests/async/test_jshandle.py delete mode 100644 tests/async/test_keyboard.py delete mode 100644 tests/async/test_launcher.py.disabled delete mode 100644 tests/async/test_listeners.py delete mode 100644 tests/async/test_locators.py delete mode 100644 tests/async/test_navigation.py delete mode 100644 tests/async/test_network.py delete mode 100644 tests/async/test_page.py delete mode 100644 tests/async/test_page_add_locator_handler.py delete mode 100644 tests/async/test_page_base_url.py delete mode 100644 tests/async/test_page_clock.py delete mode 100644 tests/async/test_page_evaluate.py delete mode 100644 tests/async/test_page_network_request.py delete mode 100644 tests/async/test_page_network_response.py delete mode 100644 tests/async/test_page_request_fallback.py delete mode 100644 tests/async/test_page_request_intercept.py delete mode 100644 tests/async/test_page_route.py delete mode 100644 tests/async/test_page_select_option.py delete mode 100644 tests/async/test_pdf.py delete mode 100644 tests/async/test_popup.py.disabled delete mode 100644 tests/async/test_proxy.py delete mode 100644 tests/async/test_queryselector.py delete mode 100644 tests/async/test_request_continue.py delete mode 100644 tests/async/test_request_fulfill.py delete mode 100644 tests/async/test_request_intercept.py delete mode 100644 tests/async/test_resource_timing.py delete mode 100644 tests/async/test_screenshot.py delete mode 100644 tests/async/test_selector_generator.py delete mode 100644 tests/async/test_selectors_get_by.py delete mode 100644 tests/async/test_selectors_misc.py delete mode 100644 tests/async/test_selectors_text.py delete mode 100644 tests/async/test_tap.py delete mode 100644 tests/async/test_tracing.py delete mode 100644 tests/async/test_unroute_behavior.py delete mode 100644 tests/async/test_video.py delete mode 100644 tests/async/test_wait_for_function.py delete mode 100644 tests/async/test_wait_for_url.py delete mode 100644 tests/async/test_websocket.py delete mode 100644 tests/async/test_worker.py delete mode 100644 tests/async/utils.py delete mode 100644 tests/async_imp/__init__.py delete mode 100644 tests/async_imp/conftest.py delete mode 100644 tests/async_imp/test_frames.py delete mode 100644 tests/async_imp/utils.py create mode 100644 tests/camoufox/README.md rename tests/{async => camoufox}/test_route_request_fingerprint.py (100%) create mode 100644 tests/camoufox/test_user_agent_token.py create mode 100644 tests/camoufox/test_worker_locale.py delete mode 100644 tests/conftest.py delete mode 100644 tests/golden-firefox/grid-cell-0.png delete mode 100644 tests/golden-firefox/mask-should-work-with-element-handle.png delete mode 100644 tests/golden-firefox/mask-should-work-with-locator.png delete mode 100644 tests/golden-firefox/mask-should-work-with-page.png delete mode 100644 tests/golden-firefox/mock-binary-response.png delete mode 100644 tests/golden-firefox/mock-svg.png delete mode 100644 tests/golden-firefox/screenshot-element-bounding-box.png delete mode 100644 tests/golden-firefox/screenshot-sanity.png delete mode 100644 tests/local-requirements.txt rename tests/{ => patches}/assets/touch-reference.html (100%) delete mode 100644 tests/pyproject.toml delete mode 100644 tests/run-tests.sh delete mode 100644 tests/server.py delete mode 100644 tests/setup-venv.sh delete mode 100644 tests/testserver/cert.pem delete mode 100644 tests/testserver/key.pem delete mode 100644 tests/utils.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a113ce815..9f6eaf6db 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,23 +15,21 @@ Closes # ## Testing - - -## Fingerprint Report - -Please submit a report from both the service tester and build tester. - -
-Fingerprint report - - - -
+ + + ## Checklist - [ ] I have linked a related issue above - [ ] My changes are focused on a single logical change - [ ] I have added testing instructions which include the desired result -- [ ] ~~Service tests pass (for python library changes) -`./service-tester/run_tests.sh --browser-version official/prerelease/146.0.1-alpha.25` (attach screenshot)~~ temporarily out of service lol -- [ ] Build test passes (for patch changes) - `./build-tester/run_tests.sh` A score of at least 1000 must be achieved (attach screenshot) +- [ ] The `All tests passed` check is green (CI runs every suite; see `ci/README.md`) diff --git a/.github/actions/prepare-browser/action.yml b/.github/actions/prepare-browser/action.yml new file mode 100644 index 000000000..9f0e31d36 --- /dev/null +++ b/.github/actions/prepare-browser/action.yml @@ -0,0 +1,79 @@ +name: Prepare browser +description: >- + Check out the repo, install Python and the runtime libraries Firefox needs, + and unpack the built Camoufox from the build job's artifact. Every job that + drives a browser starts with this, so there is one place to fix when the + runtime dependency list drifts. + +inputs: + python-version: + required: false + default: "3.12" + +outputs: + binary: + description: Absolute path to camoufox-bin + value: ${{ steps.unpack.outputs.binary }} + +runs: + using: composite + steps: + # No checkout here on purpose. A local composite action (`uses: ./...`) can + # only be resolved once the repository is already in the workspace, so every + # caller must run actions/checkout itself before reaching this. + - uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - shell: bash + run: pip install -r ci/requirements.txt + + - name: Install browser runtime libraries + shell: bash + run: | + sudo apt-get update -qq + # What a Firefox binary needs to actually start on a bare runner. Xvfb + # because every browser job runs headless-with-a-display rather than + # headless-mode: see ci/tribal/rules.yml on why. + sudo apt-get install -y --no-install-recommends \ + xvfb libgtk-3-0 libasound2t64 libdbus-glib-1-2 libx11-xcb1 \ + libxcb-shm0 libxcomposite1 libxcursor1 libxdamage1 libxfixes3 \ + libxi6 libxrandr2 libxtst6 libpci3 zstd + + - uses: actions/download-artifact@v4 + with: + name: camoufox-dist + path: ${{ github.workspace }} + + - name: Unpack + id: unpack + shell: bash + run: | + mkdir -p .ci-browser + zstd -d -c camoufox-dist.tar.zst | tar -C .ci-browser -xf - + binary="$(realpath .ci-browser/bin/camoufox-bin)" + test -x "$binary" || { echo "::error::camoufox-bin missing from the artifact"; exit 1; } + # The Python API resolves properties.json next to the binary, and + # camoufox.cfg is where every spoofing pref lives. Without either, the + # browser either refuses to launch or launches as plain Firefox. + for required in properties.json camoufox.cfg; do + test -f ".ci-browser/bin/$required" || { + echo "::error::$required missing beside the binary -- the build job should have staged it" + exit 1; } + done + + # The whole class, not just the two files above. mach builds dist/bin out + # of symlinks, some of them absolute into the source tree; archived + # without --dereference they resolve on the build runner and dangle + # everywhere else. A dangling link is invisible until something opens it, + # so check for them directly. + dangling="$(find .ci-browser -xtype l | head -20)" + if [ -n "$dangling" ]; then + echo "::error::the artifact contains dangling symlinks -- it was packed without tar --dereference" + echo "$dangling" + exit 1 + fi + echo "binary=$binary" >> "$GITHUB_OUTPUT" + # Consumed by every runner via ci/_pytest.py::built_binary(). + echo "CAMOUFOX_BINARY=$binary" >> "$GITHUB_ENV" + "$binary" --version || true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index b15e402fe..000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Lint - -# Static checks only -- no browser build, so these can gate every pull request. -# The build workflow runs on tags and takes ~40 minutes; nothing was checking -# pull requests before this. -on: - pull_request: - push: - branches: [main] - workflow_dispatch: - -jobs: - input-dispatch: - name: Synthesized input goes through one chokepoint - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: python3 scripts/check-input-dispatch.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..7f64af2a4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,1036 @@ +name: Tests + +# The repository's test pipeline. Runs on every pull request, on pushes to main, +# on demand, and -- via workflow_call -- from any workflow that needs to test a +# specific browser version, +# so a contributor's pull request and an automated Firefox bump are judged by +# exactly the same checks. +# +# The browser version comes from upstream.sh unless a caller passes one in, +# which is what lets one pipeline test both a pull request and a version bump. +# ci/versions.py then picks the newest released Playwright suite that is not +# ahead of that browser -- Playwright trails Firefox and skips generations, so +# the suite's own Firefox pin is usually a release or two behind the browser +# under test, and that is expected rather than a mismatch. ci/run_playwright.py +# fetches it fresh, applies ci/skiplist.yml, overlays tests/camoufox/ and runs +# it with world isolation ON -- the configuration Camoufox ships -- re-running +# only what fails with isolation off, and counting those as main-world +# fallbacks. A test that needs the fallback still passes; the size of that set +# is reported, because it is the isolated-world conformance gap. +# +# The stealth check reports a letter grade and a count. Its per-vector detail +# never leaves ci/run_sundial.py, because this repository is public. It depends +# on a service outside this repository, so an outage there records a SKIP with +# its reason rather than blocking every merge (see `--allow-skip` below); a bad +# credential or a bad score still fails. +# +# Ordering: cheapest first, and every tier gates the next, so a pull request that +# fails a two-second lint never reaches a seventy-minute build. +# +# 0 static lint, self-tests, settled decisions seconds +# 1 unit pythonlib ~1 min +# 2 browser BUILD if patches/additions changed, +# otherwise FETCH the released binary ~70 min / ~1 min +# 3a smoke patch guards, skiplist audit, build-tester ~15 min +# 3b full Playwright (6 shards), leaks, stealth ~40 min +# 4 gate the single required status check +# +# A driver-only pull request never builds: there is nothing new to compile, so it +# is tested against the published browser its users actually run. Tier 3b waits +# on 3a so a browser that fails its guards does not also burn six Playwright +# shards proving the same thing. + +on: + pull_request: + push: + branches: [main] + schedule: + # Keeps the ccache alive. GitHub evicts a cache after 7 days unused, and a + # cold Firefox build is over an hour; twice a week keeps pull-request builds + # restoring a warm one from main. + - cron: "0 5 * * 1,4" + workflow_dispatch: + inputs: + browser_version: + description: "Firefox version to test. Must match upstream.sh -- the build follows that, not this." + required: false + type: string + playwright_tag: + description: "Pin the Playwright suite (default: resolved from the browser)" + required: false + type: string + shards: + description: "How many runners to split the upstream suite across" + required: false + default: "6" + type: string + workflow_call: + inputs: + browser_version: + required: false + type: string + playwright_tag: + required: false + type: string + shards: + required: false + default: "6" + type: string + ref: + description: "Commit to test; defaults to the calling workflow's ref" + required: false + type: string + secrets: + SUNDIAL_USERNAME: + required: false + SUNDIAL_AUTOMATION_KEY: + required: false + outputs: + verdict: + description: "pass or fail" + value: ${{ jobs.summary.outputs.verdict }} + browser_version: + value: ${{ jobs.resolve.outputs.browser_version }} + playwright_tag: + value: ${{ jobs.resolve.outputs.playwright_tag }} + +permissions: {} + +concurrency: + group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + PYTHON_VERSION: "3.12" + CI_WORK_DIR: ${{ github.workspace }}/.ci-work + CI_RESULTS_DIR: ${{ github.workspace }}/.ci-work/results + +jobs: + # --------------------------------------------------------------------------- + resolve: + name: Resolve versions + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + browser_version: ${{ steps.versions.outputs.browser_version }} + browser_release: ${{ steps.versions.outputs.browser_release }} + playwright_tag: ${{ steps.versions.outputs.playwright_tag }} + playwright_firefox: ${{ steps.versions.outputs.playwright_firefox }} + version_note: ${{ steps.versions.outputs.note }} + browser_changed: ${{ steps.scope.outputs.browser_changed }} + has_sundial: ${{ steps.sundial.outputs.has_sundial }} + shard_matrix: ${{ steps.shards.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + - run: pip install -r ci/requirements.txt + + - id: versions + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + # --check-upstream: only suite SELECTION follows browser_version. + # The build reads upstream.sh, and the fetch path downloads whatever + # pythonlib considers current, so a browser_version the branch does not + # pin would compile the OLD browser and judge it against the NEW + # suite -- silently. A real Firefox bump edits upstream.sh, and then + # resolution reads it by default and the two cannot disagree. + python3 -m ci.versions --check-upstream \ + ${{ inputs.browser_version && format('--browser-version {0}', inputs.browser_version) || '' }} \ + ${{ inputs.playwright_tag && format('--playwright-tag {0}', inputs.playwright_tag) || '' }} + + - name: Does this change the browser? + id: scope + # 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. + 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 + 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 + echo "browser_changed=true" >> "$GITHUB_OUTPUT" + echo "::notice::Browser sources changed -- rebuilding from source." + else + echo "browser_changed=false" >> "$GITHUB_OUTPUT" + echo "::notice::No browser sources changed -- testing against the published release." + fi + + - name: May the stealth check run? + id: sundial + # Two conditions, and the config one is checked FIRST and without the + # secret in scope. While ci/sundial.yml says `enabled: false` the job is + # not scheduled at all, so SUNDIAL_AUTOMATION_KEY never enters a runner + # environment and no request is made. That ordering is what made the + # kill switch real while the live sundial still predated score mode and + # would have posted the whole report back; it stays that way round so + # the switch keeps working the next time it is needed. + # + # Secrets are absent for pull requests from forks, so the credential + # check is resolved here once rather than from a job-level `if`, which + # the secrets context is not available in. + env: + KEY: ${{ secrets.SUNDIAL_AUTOMATION_KEY }} + run: | + if [ "$(python3 -m ci.run_sundial status)" != "true" ]; then + echo "has_sundial=false" >> "$GITHUB_OUTPUT" + echo "::notice::Stealth check not run: disabled in ci/sundial.yml. See the comment there." + exit 0 + fi + # Only the password gates the job. The username names an account, not + # a secret, so ci/run_sundial.py defaults it (to `guest`, the least + # privileged role the deployment has -- sundial refuses it the + # private-vector bundle) instead of demanding a second secret. + if [ -n "$KEY" ]; then + echo "has_sundial=true" >> "$GITHUB_OUTPUT" + else + echo "has_sundial=false" >> "$GITHUB_OUTPUT" + echo "::notice::Stealth check skipped: no sundial credential on this run (normal for a fork PR)." + fi + + - name: Build the shard matrix + id: shards + run: | + python3 -c " + import json, os + n = max(1, int('${{ inputs.shards || '6' }}')) + print('matrix=' + json.dumps([f'{i}/{n}' for i in range(1, n + 1)])) + " >> "$GITHUB_OUTPUT" + cat "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------------- + static: + name: Static checks + needs: resolve + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + # -e pythonlib because the settled-decisions tests import camoufox.* to + # assert against it. It is a pure-Python install; no browser involved. + - run: pip install -r ci/requirements.txt pytest -e pythonlib + + - name: Synthesized input goes through one chokepoint + run: python3 scripts/check-input-dispatch.py + + - name: CI pipeline self-tests + # These assert that the pipeline reports honestly: skips carry reasons, + # shards partition exactly, and nothing identifying a sundial vector + # survives redaction. + run: python3 -m pytest ci/tests -q + + - name: Settled decisions are still in force + # No browser needed: these read the source. They fail a pull request in + # seconds rather than after a 40-minute build, which matters because the + # thing they catch is usually a well-meaning change that looks obviously + # correct until you read the closed PR that rejected it. + run: python3 -m ci.run_native --subset rules + + - name: Skiplist is valid + run: | + python3 -c " + from ci.summarize import validate_skiplist + from ci.pw_camoufox_plugin import load_skiplist + problems = validate_skiplist() + if problems: + raise SystemExit('\n'.join(problems)) + print(f'skiplist OK: {len(load_skiplist())} entries, every one with a reason') + " + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: results-static + path: .ci-work/results/ + include-hidden-files: true + if-no-files-found: warn + + # --------------------------------------------------------------------------- + pythonlib: + name: pythonlib + # Tier 1. Waits on the static checks so an obvious mistake costs seconds. + needs: [resolve, static] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + - run: pip install -r ci/requirements.txt pytest -e pythonlib + - name: Run + # pythonlib resolves the published release through the GitHub API + # (pkgman.py honours GITHUB_TOKEN). Unauthenticated, a runner shares the + # 60-requests-an-hour anonymous quota for its whole IP range and the job + # fails on `403 rate limit exceeded` having tested nothing. + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 -m ci.run_pythonlib + - uses: actions/upload-artifact@v4 + if: always() + with: + name: results-pythonlib + path: .ci-work/results/ + include-hidden-files: true + if-no-files-found: warn + + # --------------------------------------------------------------------------- + build: + name: Build (linux x86_64) + # Tier 2. Nothing gets compiled until the cheap tiers are green -- this is + # over an hour cold, and a lint failure should never cost that. + needs: [resolve, static, pythonlib] + if: needs.resolve.outputs.browser_changed == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 330 + permissions: + contents: read + env: + # scripts/patch.py writes the mozconfig from BUILD_TARGET and defaults to + # macos,arm64 when it is unset -- sensible for a developer on a Mac, + # wrong here. Without this, configure goes looking for the macOS SDK and + # dies with "No such file or directory: MacOSX26.5.sdk/SDKSettings.plist" + # three minutes in, which reads like a missing dependency rather than a + # cross-compile nobody asked for. multibuild.py sets this itself; `make + # dir` + `make build` do not. + BUILD_TARGET: linux,x86_64 + steps: + # Checkout first, because the cache key below is a hash of the tree. + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Hash the inputs that can change compiled output + id: native + # Not hashFiles(): this has to EXCLUDE the files jar.mn packages as + # resources, and hashFiles has no way to say "everything except". + # ci/browser_inputs.py is stdlib-only on purpose -- it runs here, before + # the pip install that a cache hit skips. + run: echo "hash=$(python3 -m ci.browser_inputs --digest)" >> "$GITHUB_OUTPUT" + + - name: Is a browser with this compiled half already built? + id: prebuilt + uses: actions/cache@v4 + with: + path: camoufox-dist.tar.zst + # Keyed on the COMPILED inputs only, so a Juggler JavaScript change + # still hits: the .js files jar.mn packages are laid over the restored + # browser below instead of relinking libxul to deliver them. + # + # The hash is the classification. There is no "did only JS change?" + # diff, because a diff compares against the pull request's base while + # the question is whether THIS cached browser has the same native + # sources -- and if the hash matches, it does, whatever the diff says. + # + # `browser_changed` (see resolve) is a different question and stays as + # it is: it decides build-versus-fetch against the base, and is true + # for every push to a branch that touched the browser once. + key: browser-${{ needs.resolve.outputs.browser_version }}-${{ needs.resolve.outputs.browser_release }}-native-${{ steps.native.outputs.hash }} + # No restore-keys, deliberately. A prefix match would serve a browser + # whose compiled half was built from different sources, and every + # suite downstream would report on it looking perfectly healthy. + + - name: Maximize build space + if: steps.prebuilt.outputs.cache-hit != 'true' + uses: AdityaGarg8/remove-unwanted-software@8831c82abf29b34eb2caac48d5f999ecfc0d8eef # v4.1 + with: + remove-dotnet: "true" + remove-android: "true" + remove-haskell: "true" + remove-codeql: "true" + remove-docker-images: "true" + remove-cached-tools: "true" + remove-swapfile: "true" + + - name: Remove unwanted tools + if: steps.prebuilt.outputs.cache-hit != 'true' + run: | + sudo apt-get remove -y '^aspnetcore-.*' '^dotnet-.*' '^llvm-.*' 'php.*' \ + '^mongodb-.*' '^mysql-.*' > /dev/null 2>&1 || true + sudo apt-get remove -y azure-cli google-chrome-stable firefox mono-devel \ + libgl1-mesa-dri --fix-missing > /dev/null 2>&1 || true + sudo apt-get autoremove -y > /dev/null 2>&1 || true + sudo apt-get clean > /dev/null 2>&1 || true + df -h / + + - uses: actions/setup-python@v5 + if: steps.prebuilt.outputs.cache-hit != 'true' + with: + python-version: ${{ env.PYTHON_VERSION }} + - uses: actions/setup-go@v5 + if: steps.prebuilt.outputs.cache-hit != 'true' + with: + go-version: "1.23" + + - name: Install build dependencies + if: steps.prebuilt.outputs.cache-hit != 'true' + run: | + wget -q https://apt.llvm.org/llvm.sh && chmod +x llvm.sh && sudo ./llvm.sh 18 + sudo apt-get install -y lld-18 clang-18 + sudo update-alternatives --install /usr/bin/ld.lld ld.lld /usr/bin/ld.lld-18 100 + sudo apt-get update + # The mozconfig sets --with-ccache; configure fails hard without it + # rather than degrading. + sudo apt-get install -y msitools p7zip-full aria2 ccache libsqlite3-dev + + - name: Restore ccache + if: steps.prebuilt.outputs.cache-hit != 'true' + uses: actions/cache@v4 + with: + path: ~/.ccache + # Keyed on the browser version and the patch set, so a pull request + # that does not touch patches/ starts from a fully warm cache. + key: ccache-${{ needs.resolve.outputs.browser_version }}-${{ hashFiles('patches/**', 'additions/**', 'assets/*.mozconfig') }} + restore-keys: | + ccache-${{ needs.resolve.outputs.browser_version }}- + ccache- + + - name: Create swap + if: steps.prebuilt.outputs.cache-hit != 'true' + run: | + # The link step is OOM-killed on a standard runner without this, and + # reports as a bare SIGTERM that looks nothing like out-of-memory. + sudo fallocate -l 24G /swapfile && sudo chmod 600 /swapfile + sudo mkswap /swapfile && sudo swapon /swapfile + free -h + + - run: pip install -r ci/requirements.txt + if: steps.prebuilt.outputs.cache-hit != 'true' + + - name: Prepare the source tree + if: steps.prebuilt.outputs.cache-hit != 'true' + env: + CAMOUFOX_PASSWD: ${{ secrets.CAMOUFOX_PASSWD }} + run: | + ccache -M 8G && ccache -z + echo "CCACHE_DIR=$HOME/.ccache" >> "$GITHUB_ENV" + # ci.run_prepare runs setup-minimal -> dir -> mozbootstrap, retrying + # only the two that download things and only when the failure reads as + # transient. `mach bootstrap` pulls toolchains from Taskcluster, and a + # connection reset there used to fail the pull request outright. + # + # setup-minimal, not `make dir` alone: `dir` falls through to `make + # setup`, which git-inits the source tree and commits -- and a bare + # runner has no git identity, so that dies with "empty ident name". + # The local dev repo is only needed by the patch-repair loop, which + # sets an identity of its own. + python3 -m ci.run_prepare + + - name: Build + if: steps.prebuilt.outputs.cache-hit != 'true' + env: + CARGO_BUILD_JOBS: "1" + run: python3 -m ci.run_build + + - run: ccache -s + if: steps.prebuilt.outputs.cache-hit != 'true' + + - name: Package the binary for the test jobs + if: steps.prebuilt.outputs.cache-hit != 'true' + run: | + set -euo pipefail + src="camoufox-${{ needs.resolve.outputs.browser_version }}-${{ needs.resolve.outputs.browser_release }}/obj-x86_64-pc-linux-gnu/dist/bin" + test -x "$src/camoufox-bin" || { echo "::error::no camoufox-bin at $src"; exit 1; } + + # `mach build` produces an *unpackaged* tree. Two things scripts/package.py + # would add are load-bearing for the test jobs and are missing here: + # + # fonts/ + fontconfig/ -- without them every glyph in page content + # renders as tofu, silently, because the + # browser chrome still has system fonts. + # properties.json -- the Python API resolves it next to the + # binary, so AsyncCamoufox dies with + # FileNotFoundError without it. That breaks + # patch-guards, the leak suite and sundial. + make stage-fonts + for f in properties.json chrome.css camoucfg.jvv; do + [ -f "$src/$f" ] || cp -v "settings/$f" "$src/$f" + done + + # Fail here, once, rather than in five browser jobs with five + # different confusing errors. + for required in camoufox-bin properties.json camoufox.cfg fonts/linux fontconfig/linux; do + [ -e "$src/$required" ] || { echo "::error::artifact is missing $required"; exit 1; } + done + + # -h (--dereference) is load-bearing, not tidiness. mach builds dist/bin + # out of symlinks -- 17 of them here, and properties.json and + # camoufox.cfg are ABSOLUTE links into the source tree. Archiving the + # links means they resolve on this runner, where the tree exists, and + # dangle on every runner that only downloads the artifact. The browser + # then starts with no config at all, which is where every spoofing pref + # lives, and the failure surfaces as "properties.json missing" three + # jobs later. + # + # It also defeats the check above: `[ -e ]` follows a symlink, so the + # file looked present right up until it was unpacked somewhere else. + tar -C "$(dirname "$src")" -chf - bin | zstd -3 -T0 -o camoufox-dist.tar.zst + ls -lh camoufox-dist.tar.zst + + # A restored browser still has to produce the `build` result, or the + # summary reports a required suite that never ran and the gate goes red -- + # which is the same trap as requiring `build` on a driver-only pull + # request, arrived at from the other direction. It records WHERE the + # binary came from, so "this run did not compile anything" is a fact in + # the evidence rather than an absence in it. + - name: Lay this branch's resources over the restored browser + if: steps.prebuilt.outputs.cache-hit == 'true' + # The compiled half is identical by construction -- that is what the key + # asserts. What can still differ is the JavaScript, and in the + # unpackaged dist/bin that CI archives there is no omni.ja to rebuild: + # Juggler is loose files under chrome/juggler/, so delivering new + # JavaScript is a copy. ci/browser_inputs.py reads the destinations out + # of jar.mn rather than assuming a prefix, because two files in the same + # source directory land at different depths. + run: | + set -euo pipefail + command -v zstd >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y zstd; } + mkdir -p restored + zstd -d -c camoufox-dist.tar.zst | tar -C restored -xf - + python3 -m ci.browser_inputs --overlay restored/bin + test -f restored/bin/camoufox-bin || { echo "::error::restored artifact has no camoufox-bin"; exit 1; } + # Via a temporary name, not over the input. `zstd -o` refuses an + # existing destination ("already exists; stdin is an input - not + # proceeding") and exits 1, which failed every cache-hit build as soon + # as one actually hit. `mv` also means a repack that dies partway + # cannot leave a truncated archive where the restored one was. + tar -C restored -chf - bin | zstd -3 -T0 -o camoufox-dist.tar.zst.new + mv -f camoufox-dist.tar.zst.new camoufox-dist.tar.zst + rm -rf restored + + - name: Record that the browser was restored, not built + if: steps.prebuilt.outputs.cache-hit == 'true' + run: | + python3 -c " + from ci import results + from ci.browser_inputs import jar_entries + r = results.GateResult(gate='build') + r.metrics['from_cache'] = True + r.metrics['resources_overlaid'] = len(jar_entries()) + r.metrics['cache_key'] = '''${{ steps.prebuilt.outputs.cache-primary-key }}''' + r.note('restored a browser whose compiled half was built from identical ' + 'sources, and laid this branch\\'s resources over it; nothing was compiled') + r.finish(results.PASS).save() + " + + - uses: actions/upload-artifact@v4 + with: + name: camoufox-dist + path: camoufox-dist.tar.zst + retention-days: 3 + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: results-build + path: .ci-work/results/ + include-hidden-files: true + if-no-files-found: warn + + + # --------------------------------------------------------------------------- + fetch-browser: + name: Fetch the released browser + # The other half of tier 2. A driver-only change has nothing new to compile, + # so it is tested against the build its users are actually running. Uploads + # the same artifact name as `build`, so every downstream job is identical + # whichever way the browser arrived. + needs: [resolve, static, pythonlib] + if: needs.resolve.outputs.browser_changed == 'false' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + - run: pip install -r ci/requirements.txt -e pythonlib + + - name: Download + # Same GitHub API path as the pythonlib job above, and the same anonymous + # rate limit if the token is missing. + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + python -m camoufox fetch + install_dir="$(python -m camoufox path)" + 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 + # that is what users run. But the SUITE comes from upstream.sh, and in + # an upgrade window the two part company: upstream.sh moves to the new + # Firefox before any build of it is published, and this would then test + # the old browser against the new suite. Beta drift inside a generation + # is fine; a generation apart is not. + active="$(python -m camoufox active)" + echo "fetched: $active" + python3 -m ci.versions --check-fetched "$active" \ + ${{ inputs.browser_version && format('--browser-version {0}', inputs.browser_version) || '' }} + test -x "$install_dir/camoufox-bin" || { + echo "::error::no camoufox-bin under $install_dir after fetch"; exit 1; } + # The published build is packaged, so properties.json and the font + # bundles are already beside the binary -- the two things the Python + # API resolves there. Assert rather than assume. + for required in properties.json fonts; do + [ -e "$install_dir/$required" ] || { + echo "::error::the published build has no $required beside the binary"; exit 1; } + done + mkdir -p pack/bin + cp -a "$install_dir/." pack/bin/ + tar -C pack -cf - bin | zstd -3 -T0 -o camoufox-dist.tar.zst + ls -lh camoufox-dist.tar.zst + + - uses: actions/upload-artifact@v4 + with: + name: camoufox-dist + path: camoufox-dist.tar.zst + retention-days: 3 + + # --------------------------------------------------------------------------- + playwright: + name: Playwright ${{ matrix.shard }} + # Tier 3b. Gated on 3a: a browser that fails its patch guards is broken, and + # six shards would take forty minutes to reach the same conclusion. + needs: [resolve, build, fetch-browser, patch-guards, build-tester] + if: always() && needs.patch-guards.result == 'success' && needs.build-tester.result == 'success' + runs-on: ubuntu-24.04 + # A healthy shard is 5-9 minutes. At 120 a wedged shard sat on a runner for + # two hours before anyone found out, four shards at a time -- which is also + # a queueing problem for everything behind it. ci/run_playwright.py bounds + # each pytest invocation at 20 minutes, so this only has to be comfortably + # clear of one backstop firing and still reporting. + timeout-minutes: 40 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + shard: ${{ fromJson(needs.resolve.outputs.shard_matrix) }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: ./.github/actions/prepare-browser + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Run + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + xvfb-run -a python3 -m ci.run_playwright \ + --shard "${{ matrix.shard }}" \ + --binary "$CAMOUFOX_BINARY" \ + --browser-version "${{ needs.resolve.outputs.browser_version }}" \ + --playwright-tag "${{ needs.resolve.outputs.playwright_tag }}" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: results-playwright-${{ strategy.job-index }} + # One path, and no glob. The summary merges every results-* artifact + # into one directory and load_all() globs a single level, so these + # must arrive at the artifact's top level. A second path would move + # upload-artifact's common root and nest them under results/. + path: .ci-work/results/ + include-hidden-files: true + if-no-files-found: warn + - uses: actions/upload-artifact@v4 + if: always() + with: + name: diagnostics-playwright-${{ strategy.job-index }} + path: | + .ci-work/junit-*.xml + include-hidden-files: true + if-no-files-found: ignore + + # --------------------------------------------------------------------------- + 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. + if: always() && (needs.build.result == 'success' || needs.fetch-browser.result == 'success') + name: Patch guards + needs: [resolve, build, fetch-browser] + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read + 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 + # 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" + - name: Every skiplist entry is still failing + # 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. + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + xvfb-run -a python3 -m ci.run_skiplist_audit \ + --binary "$CAMOUFOX_BINARY" \ + --browser-version "${{ needs.resolve.outputs.browser_version }}" \ + --playwright-tag "${{ needs.resolve.outputs.playwright_tag }}" + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: results-patch-guards + path: .ci-work/results/ + include-hidden-files: true + if-no-files-found: warn + + # --------------------------------------------------------------------------- + build-tester: + # 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. + if: always() && (needs.build.result == 'success' || needs.fetch-browser.result == 'success') + name: build-tester + needs: [resolve, build, fetch-browser] + runs-on: ubuntu-24.04 + timeout-minutes: 90 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: ./.github/actions/prepare-browser + with: + python-version: ${{ env.PYTHON_VERSION }} + - run: | + cd build-tester && npm install && pip install -r requirements.txt + - run: xvfb-run -a python3 -m ci.run_build_tester --binary "$CAMOUFOX_BINARY" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: results-build-tester + # One path, and no glob. The summary merges every results-* artifact + # into one directory and load_all() globs a single level, so these + # must arrive at the artifact's top level. A second path would move + # upload-artifact's common root and nest them under results/. + path: .ci-work/results/ + include-hidden-files: true + if-no-files-found: warn + - uses: actions/upload-artifact@v4 + if: always() + with: + name: diagnostics-build-tester + path: | + .ci-work/build-tester-result.json + include-hidden-files: true + if-no-files-found: ignore + + # --------------------------------------------------------------------------- + native: + name: Leaks and context semantics + needs: [resolve, build, fetch-browser, patch-guards, build-tester] + if: always() && needs.patch-guards.result == 'success' && needs.build-tester.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 90 + permissions: + contents: read + 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 + - name: Run + # Launches browsers, kills them, and proves nothing survived -- the + # failure a long-running scraper hits after six hours and no Playwright + # test can see. Also checks that a context and a browser mean what the + # project says they mean. + run: | + xvfb-run -a python3 -m ci.run_native \ + --subset browser \ + --binary "$CAMOUFOX_BINARY" \ + --rounds 4 --browsers 3 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: results-native + # One path, and no glob. The summary merges every results-* artifact + # into one directory and load_all() globs a single level, so these + # must arrive at the artifact's top level. A second path would move + # upload-artifact's common root and nest them under results/. + path: .ci-work/results/ + include-hidden-files: true + if-no-files-found: warn + - uses: actions/upload-artifact@v4 + if: always() + with: + name: diagnostics-native + path: | + .ci-work/junit-*.xml + include-hidden-files: true + if-no-files-found: ignore + + # --------------------------------------------------------------------------- + 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. + 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 + permissions: + contents: read + 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 + - run: xvfb-run -a python3 -m ci.run_native --subset growth --binary "$CAMOUFOX_BINARY" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: results-growth + path: .ci-work/results/ + include-hidden-files: true + if-no-files-found: warn + + # --------------------------------------------------------------------------- + sundial: + name: Stealth check + needs: [resolve, build, fetch-browser, patch-guards, build-tester] + if: always() && needs.resolve.outputs.has_sundial == 'true' && needs.patch-guards.result == 'success' && needs.build-tester.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + 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 + - name: Run + # Exits 0 with a SKIP result if sundial itself is unreachable -- the + # browser was never measured, so neither a pass nor a failure would be + # true, and an outage on someone else's host must not block this + # repository. Anything sundial actually answers -- rejected credential, + # a role that would be served the vectors, a full report where a score + # was asked for, a pass rate under the floor -- still fails. + # + # The only step in this workflow that sees the sundial credential. It + # asks for `?auto=1&score=1`, so what comes back is already counts + # rather than a report; ci/run_sundial.py checks the session's role + # against sundial's own /__auth/me and refuses to open the browser at + # all unless the vectors are withheld from it, and refuses to process + # anything that is not a score. The artifact below is a grade and counts. + env: + SUNDIAL_USERNAME: ${{ secrets.SUNDIAL_USERNAME }} + SUNDIAL_AUTOMATION_KEY: ${{ secrets.SUNDIAL_AUTOMATION_KEY }} + run: xvfb-run -a python3 -m ci.run_sundial --binary "$CAMOUFOX_BINARY" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: results-sundial + path: .ci-work/results/ + include-hidden-files: true + if-no-files-found: warn + + # --------------------------------------------------------------------------- + summary: + name: Summary + needs: [resolve, static, pythonlib, build, fetch-browser, playwright, + patch-guards, build-tester, native, sundial] + if: always() && needs.resolve.result == 'success' + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + outputs: + verdict: ${{ steps.summarize.outputs.verdict }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + - run: pip install -r ci/requirements.txt + + - uses: actions/download-artifact@v4 + with: + pattern: results-* + merge-multiple: true + path: .ci-work/results + continue-on-error: true + + - name: Summarize + id: summarize + run: | + set +e + # Suite names, not job names. `static` is a job; the suites it runs are + # the pipeline self-tests, which write no result, and native_rules, + # which is named here. The gate separately fails if the job itself did + # not succeed, so nothing is lost by leaving it out. + # + # The browser suites are required either way -- they run against a + # fetched release just as they do against a fresh build. `build` is + # the one that is not: on a driver-only pull request that job is + # skipped by design and writes no result, and requiring it there made + # 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" + if [ "${{ needs.resolve.outputs.browser_changed }}" = "true" ]; then + required="$required build" + fi + if [ "${{ needs.resolve.outputs.has_sundial }}" = "true" ]; then + required="$required sundial" + fi + python3 -m ci.summarize \ + --results-dir .ci-work/results \ + --require $required \ + --allow-skip sundial \ + --markdown summary.md \ + --out summary.json \ + --browser-version "${{ needs.resolve.outputs.browser_version }}" \ + --browser-release "${{ needs.resolve.outputs.browser_release }}" \ + --playwright-tag "${{ needs.resolve.outputs.playwright_tag }}" \ + --playwright-firefox "${{ needs.resolve.outputs.playwright_firefox }}" \ + --version-note "${{ needs.resolve.outputs.version_note }}" + code=$? + echo "verdict=$([ $code -eq 0 ] && echo pass || echo fail)" >> "$GITHUB_OUTPUT" + exit $code + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-summary + path: | + summary.md + summary.json + .ci-work/results/ + include-hidden-files: true + + - name: Comment on the pull request + if: always() && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + continue-on-error: true + run: | + # One rolling comment rather than a new one per push. + marker="" + { echo "$marker"; cat summary.md; } > body.md + existing=$(gh pr view "${{ github.event.pull_request.number }}" \ + --json comments --jq "[.comments[] | select(.body | startswith(\"$marker\"))][0].id" 2>/dev/null || true) + if [ -n "$existing" ] && [ "$existing" != "null" ]; then + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/${existing#*_}" \ + -f body="$(cat body.md)" >/dev/null 2>&1 \ + || gh pr comment "${{ github.event.pull_request.number }}" --body-file body.md + else + gh pr comment "${{ github.event.pull_request.number }}" --body-file body.md + fi + + # --------------------------------------------------------------------------- + gate: + name: All tests passed + # THE required status check. Branch protection points at this one job rather + # than at a dozen, so the required-check list does not have to be edited + # every time a suite is added, renamed, or sharded differently. + # + # A job that was legitimately not applicable is allowed to be skipped -- the + # build when the browser was fetched instead, the fetch when it was built, + # the stealth check on a fork pull request with no credentials or while it is + # disabled in ci/sundial.yml. Anything else + # 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] + if: always() + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Check every tier + env: + RESULTS: ${{ toJSON(needs) }} + BROWSER_CHANGED: ${{ needs.resolve.outputs.browser_changed }} + HAS_SUNDIAL: ${{ needs.resolve.outputs.has_sundial }} + run: | + python3 - <<'PY' + import json, os, sys + + results = {name: job["result"] for name, job in json.loads(os.environ["RESULTS"]).items()} + built = os.environ.get("BROWSER_CHANGED") == "true" + + # The only jobs allowed to be skipped, and only for these reasons. + may_skip = { + "build": not built, + "fetch-browser": built, + "sundial": os.environ.get("HAS_SUNDIAL") != "true", + } + + problems = [] + for name, result in sorted(results.items()): + if result == "success": + continue + if result == "skipped" and may_skip.get(name): + print(f" - {name}: skipped (not applicable to this run)") + continue + problems.append(f"{name}: {result}") + + width = max(len(n) for n in results) + print("\ntier results:") + for name, result in sorted(results.items()): + mark = "ok " if result == "success" else "FAIL" + print(f" {mark} {name:<{width}} {result}") + + if problems: + print("\nnot mergeable:") + for problem in problems: + print(f" - {problem}") + sys.exit(1) + print("\nevery tier passed; this pull request is mergeable.") + PY diff --git a/.gitignore b/.gitignore index 8d8bf104d..abe736224 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,11 @@ node_modules/ proxies.txt checks-bundle.js .env + +# Auto-update harness scratch space (evidence, fetched suites, build logs) +.harness-work/ + +# CI scratch space (results, junit, the sealed sundial report). Never +# committed: this is where a stealth run's output lands, and an evidence +# file in a public repository is permanent. +.ci-work/ diff --git a/CLAUDE.md b/CLAUDE.md index 1ecc539d0..21ef08523 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,15 +60,42 @@ Low-level equivalents: `make patch ./patches/x.patch`, `make unpatch ./patches/x ## Testing -Two suites, **both required for PRs** (they cover different layers): - -- **`build-tester/`** — tests the raw binary directly (bypasses the Python package); fingerprints injected via `generate_context_fingerprint` + `addInitScript` and `CAMOU_CONFIG`. Run when changing patches / C++ / JS browser layer: +`ci/` is the whole pipeline, and it runs identically locally and on a pull +request — see [`ci/README.md`](ci/README.md). Every gate below must pass before a +PR can merge; the workflow is `.github/workflows/tests.yml`. + +- **`build-tester/`** — the raw binary directly, bypassing the Python package: + eight fingerprint profiles, injected via `generate_context_fingerprint` + + `addInitScript` and `CAMOU_CONFIG`. **This is the anti-detect suite** — run it + when changing patches, C++, or the JS browser layer. + ```bash + python3 -m ci.run_build_tester --binary /path/to/camoufox-bin + ``` +- **`tests/patches/`** — one standalone guard per shipped spoofing behaviour + (isolated evaluate, trusted events, fonts, mouse trajectories, touchscreen). + The most direct evidence a Firefox bump did not neuter a patch that still + *applies* cleanly. ```bash - cd build-tester && npm install && pip install -r requirements.txt - python scripts/run_tests.py /path/to/camoufox-binary + python3 -m ci.run_patch_guards --binary /path/to/camoufox-bin ``` -- **`service-tester/`** — tests the Python package / service layer. -- **`tests/`** — Playwright tests, run via `make tests` (add `headful=true` for headful): points at `camoufox-*/obj-*/dist/bin/camoufox-bin`. +- **Playwright** — upstream playwright-python, fetched fresh at the tag + `ci/versions.py` resolves for the browser, with `ci/skiplist.yml` applied and + `tests/camoufox/` overlaid. This is the automation-contract check, not the + stealth check. It runs **isolated-world first** (what users ship) and re-runs + only the failures with isolation off; those are counted and named as + main-world fallbacks rather than hidden, so the size of the isolated-world + gap is visible per run. + ```bash + make tests # or: python3 -m ci.run_playwright --binary ... + ``` +- **`native-tests/`** — leaks, context lifetime, and the repo's own conventions. +- **`pythonlib/`**, **`service-tester/`** — the Python package and service layer. +- **stealth grade** — `ci/run_sundial.py` reports a letter grade and a count. + Its per-vector detail never leaves that module, because this repo is public. + +The Playwright suite is **not** a fork: `tests/` holds only `patches/` and +`camoufox/`. Do not vendor upstream tests back into it — a deliberate difference +from upstream belongs in `ci/skiplist.yml` with a stated reason. `ccache` is enabled in the build config — install it for fast incremental rebuilds (cold ~40 min, incremental ~5 min). @@ -76,4 +103,4 @@ Two suites, **both required for PRs** (they cover different layers): - The `camoufox-*/` source directory is regenerated — persist changes as patches, never as edits committed to that tree. - Keep the `Makefile` diff clean against `main` unless a change genuinely belongs there — dependency setup lives in `scripts/install-deps.sh`, not the Makefile. -- Every PR must be tied to a GitHub issue and pass both test suites (see `CONTRIBUTING.md`). +- Every PR must be tied to a GitHub issue and pass the full pipeline (see `CONTRIBUTING.md` and `ci/README.md`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19d7d5299..f619a2d0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,11 +30,25 @@ ccache is already enabled in the build config. A cold build takes the usual ~40 2. Follow the pull request template 3. Keep commits focused — one logical change per commit. 4. Open a PR with a clear description of what you changed and why. -5. All pull requests must pass both the **build-tester** and **service-tester** test suites before merging. +5. All pull requests must pass the test pipeline before merging. It runs automatically — see below. ## Testing Requirements -**Both test suites are required for every PR.** They test different layers of the stack and catch different classes of bugs — passing one does not substitute for the other. +**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 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. + +[`ci/README.md`](ci/README.md) documents the pipeline: what each tier runs, what is deliberately skipped and why, and how to reproduce any gate locally against your own build: + +```bash +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_playwright --binary /path/to/camoufox-bin # or --shard 3/6 +python3 -m ci.run_skiplist_audit --binary /path/to/camoufox-bin +python3 -m pytest ci/tests -q # the pipeline's own tests +``` + +The two suites below are the ones worth running by hand while you work, because they are the ones that tell you quickly whether a spoofing change did what you meant. They test different layers and catch different classes of bug — passing one does not substitute for the other. ### build-tester @@ -49,6 +63,8 @@ pip install -r requirements.txt python scripts/run_tests.py /path/to/camoufox-binary ``` +Or `python3 -m ci.run_build_tester --binary /path/to/camoufox-bin`, which is what CI runs — same suite, graded per check. + See [`build-tester/README.md`](build-tester/README.md) for full details. --- diff --git a/Makefile b/Makefile index 920fb9b02..9b9b161f3 100644 --- a/Makefile +++ b/Makefile @@ -243,10 +243,12 @@ workspace: make first-checkpoint || true make patch $(_ARGS) +# The Playwright suite: upstream playwright-python at the tag ci/versions.py +# resolves for this browser, fetched fresh, plus tests/camoufox/. The first run +# builds a virtualenv under .ci-work/ and is slow; later runs reuse it. tests: - cd ./tests && \ - bash run-tests.sh \ - --executable-path ../$(cf_source_dir)/obj-x86_64-pc-linux-gnu/dist/bin/camoufox-bin \ + python3 -m ci.run_playwright \ + --binary ./$(cf_source_dir)/obj-x86_64-pc-linux-gnu/dist/bin/camoufox-bin \ $(if $(filter true,$(headful)),--headful,) # Lets tests/patches/*.py run against an unpackaged build. Not needed by `run` diff --git a/additions/juggler/TargetRegistry.js b/additions/juggler/TargetRegistry.js index 3e29748b1..8e7ab1a2c 100644 --- a/additions/juggler/TargetRegistry.js +++ b/additions/juggler/TargetRegistry.js @@ -1013,8 +1013,20 @@ export class PageTarget { state.inFlight = true; this.emit(PageTarget.Events.ScreencastFrame, { data: dataURL.substring(dataURL.indexOf(',') + 1), - deviceWidth: frameWidth, - deviceHeight: frameHeight, + // The viewport this frame depicts -- NOT the JPEG's own dimensions. + // Playwright's Firefox delegate maps deviceWidth/deviceHeight straight + // onto the client-visible viewportWidth/viewportHeight, and every other + // backend fills them from the page's viewport: the native path above + // sends pageWidth/pageHeight (clamped to the viewport, never scaled by + // the requested frame size), and the Chromium delegate forwards CDP's + // metadata.deviceWidth. Sending frameWidth/frameHeight here made the + // pair track `size=` instead, so a client asking for a 500x400 frame of + // a 1000x400 page was told the viewport was 500x200 -- and asking for a + // frame larger than the page reported a viewport larger than the page. + // The scaled dimensions are still carried by the JPEG itself, which is + // where a consumer that wants the image size reads them from. + deviceWidth: Math.round(rect.width), + deviceHeight: Math.round(rect.height), timestamp: Date.now() / 1000, }); }; diff --git a/additions/juggler/content/FrameTree.js b/additions/juggler/content/FrameTree.js index 9c98fbb0a..ecc24881b 100644 --- a/additions/juggler/content/FrameTree.js +++ b/additions/juggler/content/FrameTree.js @@ -654,6 +654,30 @@ class Frame { for (const context of this._worldNameToContext.values()) this._runtime.destroyExecutionContext(context); this._worldNameToContext.clear(); + + // Camoufox: release the master sandbox with the frame. + // + // This method came from upstream, which has no such field, so when + // _masterSandbox was added only the navigation path (_onGlobalObjectCleared) + // learned to drop it. A frame that is *destroyed* rather than navigated -- + // an iframe removed from the DOM, which is what an ad stack does + // continuously -- kept it. + // + // It matters more than an ordinary stale reference: the sandbox is built + // over `sandboxPrototype: domWindow` with a system principal, so while it + // is alive it holds the window's global, and with it the whole document. + // Nuking severs the cross-compartment wrappers so the compartment can + // actually go away; dropping the reference alone leaves that to the cycle + // collector's goodwill. + if (this._masterSandbox) { + try { + Cu.nukeSandbox(this._masterSandbox); + } catch (e) { + // Already nuked, or the compartment is gone. Either way the reference + // below is what matters, and a throw here would abort frame teardown. + } + this._masterSandbox = null; + } } _addBinding(worldName, name, script) { diff --git a/build-tester/README.md b/build-tester/README.md index dabd56870..960eda131 100644 --- a/build-tester/README.md +++ b/build-tester/README.md @@ -14,7 +14,7 @@ Tests a raw Camoufox binary (Firefox) directly against the same antibot-detectio npm install # Install Python deps -pip install -r requirements.txt +pip install -r requirements.txt # installs -e ../pythonlib, this tree's package ``` ## Usage diff --git a/build-tester/requirements.txt b/build-tester/requirements.txt index ac1d734ff..a190faafc 100644 --- a/build-tester/requirements.txt +++ b/build-tester/requirements.txt @@ -1,4 +1,20 @@ -cloverlabs-camoufox +# This repository's own Python package, not a third-party redistribution. +# +# build-tester generates its fingerprints with camoufox.fingerprints +# (scripts/presets.py, scripts/generate-presets.py). Which `camoufox` provides +# that decides what is actually under test, so it has to be pythonlib/ from this +# tree -- otherwise the binary is graded against someone else's fingerprint +# generation and a divergence between the two reads as a browser bug. +# +# This line was `cloverlabs-camoufox` between #521 (2026-03-15) and now: a +# third-party fork on PyPI, published by the same author as the commit that +# added it. scripts/presets.py already told you the right answer in its own +# ImportError handler -- "pip install -e ../pythonlib". +# +# Relative to build-tester/, which is where both entry points run from: +# run_tests.sh cd's here, and so does the CI job. +-e ../pythonlib + # 1.61 sent viewport.isMobile in Browser.setDefaultViewport, which this # juggler's protocol schema rejected; the schema now accepts it, so the cap # moves up rather than away. Matches the cap in pythonlib/pyproject.toml. diff --git a/build-tester/scripts/run_tests.py b/build-tester/scripts/run_tests.py index 8a4af4129..52c073d79 100755 --- a/build-tester/scripts/run_tests.py +++ b/build-tester/scripts/run_tests.py @@ -13,6 +13,7 @@ --secret KEY HMAC signing key for certificate --save-cert PATH Save certificate text to this file --no-cert Skip certificate generation + --json PATH Write full machine-readable results to this path """ import argparse @@ -45,6 +46,10 @@ def main(): "--no-cert", action="store_true", help="Skip certificate generation", ) + parser.add_argument( + "--json", metavar="PATH", dest="json_out", + help="Write the full machine-readable result tree to this path", + ) args = parser.parse_args() profile_count = max(1, min(8, args.profile_count)) @@ -75,6 +80,7 @@ def main(): secret=args.secret, save_cert=args.save_cert, no_cert=args.no_cert, + json_out=args.json_out, ) ) sys.exit(exit_code) diff --git a/build-tester/scripts/runner.py b/build-tester/scripts/runner.py index 90acc1e98..07f8e6508 100644 --- a/build-tester/scripts/runner.py +++ b/build-tester/scripts/runner.py @@ -219,6 +219,7 @@ async def run_tests( secret: str, save_cert: Optional[str], no_cert: bool, + json_out: Optional[str] = None, ) -> int: project_dir = Path(__file__).parent.parent @@ -393,6 +394,13 @@ async def run_tests( "binaryPath": binary_path, } + # The auto-update harness grades this run per check rather than by reading + # the printed table, so offer the same structure it prints from. + if json_out: + Path(json_out).parent.mkdir(parents=True, exist_ok=True) + Path(json_out).write_text(json.dumps(full_result, indent=2, default=str), encoding="utf-8") + print(f"Machine-readable results written to: {json_out}") + print(f"\n{'═' * 62}") gc = grade_color(overall_grade) print(f"OVERALL: {gc}{BOLD}[{overall_grade}]{RESET} {total_passed}/{total_checks_sum} checks passed ({total_profiles} profiles)") diff --git a/build-tester/src/lib/checks/collectors.ts b/build-tester/src/lib/checks/collectors.ts index ff9c0a9f8..a9c41aa8b 100644 --- a/build-tester/src/lib/checks/collectors.ts +++ b/build-tester/src/lib/checks/collectors.ts @@ -2,7 +2,7 @@ import type { FingerprintData, WebRTCResult } from "../types"; -function simpleHash(data: Float32Array | Uint8Array): string { +function simpleHash(data: Float32Array | Uint8Array | Uint8ClampedArray): string { let hash = 0; for (let i = 0; i < data.length; i++) { const val = data[i]; @@ -11,15 +11,6 @@ function simpleHash(data: Float32Array | Uint8Array): string { return (hash >>> 0).toString(16).padStart(8, "0"); } -function canvasHash(operations: (ctx: CanvasRenderingContext2D) => void): string { - const canvas = document.createElement("canvas"); - canvas.width = 200; - canvas.height = 50; - const ctx = canvas.getContext("2d"); - if (!ctx) return "no-context"; - operations(ctx); - return canvas.toDataURL().substring(0, 100); -} export async function collectFingerprints(): Promise { // Navigator @@ -90,7 +81,20 @@ export async function collectFingerprints(): Promise { ctx.fillStyle = "rgba(102, 204, 0, 0.7)"; ctx.fillText("Cwm fjordbank", 4, 17); const url = c.toDataURL(); - return { hash: url.substring(0, 100), dataUrlPrefix: url.substring(0, 30) }; + // Hash the pixels. `url.substring(0, 100)` was not a hash and barely + // reached the image: "data:image/png;base64," takes 22 characters, + // leaving ~58 bytes of PNG -- signature, IHDR, the IDAT header and about + // fifteen bytes of deflate stream. Cross-profile uniqueness therefore + // turned on whether the per-context noise happened to land in the + // top-left of the first scanline, and two contexts whose canvas differed + // everywhere else compared equal. The same value was used to check + // stability between two reads, so that check was comparing PNG headers. + // getImageData is the readback Camoufox noises and the one a + // fingerprinter reads. + return { + hash: simpleHash(ctx.getImageData(0, 0, c.width, c.height).data), + dataUrlPrefix: url.substring(0, 30), + }; } catch { return { hash: "error", dataUrlPrefix: "" }; } @@ -229,7 +233,10 @@ export async function collectFingerprints(): Promise { if (!ctx) return { hash: "no-context" }; ctx.font = "32px serif"; ctx.fillText("\uD83D\uDE00\uD83D\uDC4D\uD83C\uDFE0\u2764\uFE0F", 0, 40); - return { hash: c.toDataURL().substring(50, 120) }; + // substring(50, 120) skipped the PNG header but still covered only ~50 + // bytes of deflate stream, so two different emoji rasterisations could + // compare equal. Hash the pixels for the same reason as above. + return { hash: simpleHash(ctx.getImageData(0, 0, c.width, c.height).data) }; } catch { return { hash: "error" }; } diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 000000000..c17cfcfd0 --- /dev/null +++ b/ci/README.md @@ -0,0 +1,628 @@ +# The test pipeline + +Everything that runs a suite against Camoufox. Driven identically from a pull +request, a push to main, and — through `workflow_call` — any caller that needs +to test a specific browser version, so there is one definition of "the tests +pass", not two. + +``` +resolve ──┬─ static ────────── tribal rules, skiplist, self-tests (seconds) + ├─ pythonlib ─────── the package's own tests (a minute) + └─ build ──┬─ playwright × 6 shards (conformance + our own) + ├─ skiplist audit ───── every skip must still fail + ├─ native ───────────── leaks, contexts (ours) + ├─ patch guards ─────── one per spoofing patch + ├─ build-tester ─────── 8 fingerprint profiles + └─ sundial ──────────── stealth grade (off: see below) + │ + summary ──► one comment on the PR +``` + +## Which browser, which suite + +`ci/versions.py` answers both, and every entry point uses it: + +- **browser** — from `upstream.sh`, or whatever a caller passes in. A caller + moving to a new Firefox passes the version it is moving to, which is what lets + one pipeline test both a pull request and an upgrade. +- **suite** — the newest *released* playwright-python tag whose pinned Firefox is + not ahead of that browser, and which is below the Playwright ceiling + `pythonlib/pyproject.toml` pins. + +Newest-not-ahead, rather than an exact match, because Playwright trails Firefox +and skips generations: it pinned Firefox 151 and then 153, never 152, so a +browser built on 152 has no exact suite and never will. Requiring a match would +leave most of a release cycle with no suite at all, and taking a newer one would +test against an automation contract that assumes engine work the build does not +have. **So the suite's Firefox pin being a release or two behind the browser is +the normal case, not a misconfiguration** — the summary line says which rule +picked the tag. + +The ceiling matters for the same reason it exists in `pythonlib`: `camoufox.server` +imports `playwright._impl._driver`, a private API, and every Playwright minor is +free to change Juggler. Testing above the ceiling would exercise a client the +shipped package will not install. + +```bash +python3 -m ci.versions --json # what would run +python3 -m ci.versions --browser-version 153.0.4 --json +``` + +**The version under test has to be the version that gets built.** Only *suite +selection* follows `--browser-version`; the build reads `upstream.sh` and the +fetch path downloads whatever pythonlib considers current. Asking for a version +the branch does not pin would therefore compile the old browser and judge it +against the new suite — green, meaningless and silent. `--check-upstream` +refuses that, and the workflow passes it. + +So an upgrade to a new Firefox is a branch that **edits `upstream.sh`**, which is +what an upgrade is anyway. Resolution then reads it by default, the build +produces it, and the suite is chosen for it — the three cannot disagree. The +`browser_version` input exists for a caller that wants to state the version +explicitly; it must match. + +## The Playwright suite + +One suite, fetched fresh per run: upstream playwright-python at the resolved tag. +It runs unmodified — `ci/pw_camoufox_plugin.py` adapts the environment around it +rather than editing it, hooking `BrowserType` at the `_impl` layer so upstream +can refactor its fixtures freely — and `ci/suite.py` overlays `tests/camoufox/` +into it. + +`tests/camoufox/` is small on purpose: behaviour upstream has no test for (that +`page.route()` must not change what a request looks like on the wire), or asserts +the opposite of on purpose (that a worker should *not* inherit the context +locale, which stock Firefox gets wrong and Camoufox does not). It is not a fork +of anything, so it cannot go stale; it runs against upstream's own conftest and +server at whatever tag was resolved. + +`tests/` used to hold a fork of a ~v1.55-era upstream suite. It was deleted after +measuring it against the same binary: + +- **73 of the 74 tests it skipped as "Not supported by Camoufox" pass** in + upstream's copy. Those skips predated main-world execution and were never + revisited, so the fork was asserting the browser was worse than it is. +- Of its passing tests, eight had no upstream counterpart. Six of those were + Camoufox-specific and now live in `tests/camoufox/` as three modules; the + other two were tests upstream had since renamed. + +A re-fetched suite cannot drift, and a deliberate difference from upstream now +has to be written down in `ci/skiplist.yml` with a reason, where it is visible, +instead of being encoded as a silent edit to a vendored file. + +## What "the suite" means + +`ci/run_playwright.py` names its targets explicitly rather than pointing at +`tests/`, so the one thing left out stays visible: + +| | | +|---|---| +| `tests/async/` + `tests/sync/` | one pytest process | +| `tests/common/`, `tests/test_reference_count_async.py` | **their own** process | +| `tests/test_installation.py` | excluded, with a reason | + +The isolated pair each call `sync_playwright()`/`async_playwright()` inside the +test body, which cannot start while the session fixtures already hold a loop +(`Cannot run the event loop while another loop is running`). Run with the others +all six fail; run alone all six pass. Skiplisting them for that would have +recorded a browser failure that does not exist. + +This used to be `tests/async/` alone — 722 tests, 31% of the suite, excluded with +nothing written down. Not a decision: the vendored fork carried `async/` and no +sync suite, and this runner was pointed at the same shape without checking what +upstream shipped. `unclaimed()` now fails the run if upstream adds a test path +that is neither in `TARGETS` nor in `EXCLUDED` with a reason. + +## Which world, and the skip list + +The suite runs **isolated first** — the configuration Camoufox actually ships — +and falls back to the main world only for what fails, counting every test that +needed the fallback. + +Upstream asserts upstream semantics: tests read globals their own page scripts +defined and pass handles into `evaluate()`, and a test doing that fails under +isolation by design. Running the whole suite main-world-only (the previous +behaviour) made those pass, which is true but uninformative — it measured a mode +nobody ships and produced no number for what isolation costs. + +Each group is therefore run up to three times, and normally twice: + +| Pass | `CI_WORLD` | What it establishes | +| --- | --- | --- | +| 1 | `isolated` | the browser as users run it | +| 2 | `main` | the failures again with isolation off | +| 3 | `main` | only what failed in *both*, retried once — normally empty | + +There is deliberately **no second isolated pass**. One sat between 1 and 2 on +the theory that a flake must not be mistaken for a world difference; measured on +the first real CI run it cost 7m50s per shard and recovered nothing: + +``` +isolated (full) 335s + 331s 35 and 11 failures +isolated retry 205s + 265s 0 recovered <- deleted +main world 19s + 12s 46 recovered +``` + +Two reasons it was never going to earn that. These failures are deterministic — +a test reading a global its page script defined does not intermittently see it — +and failing that way is *slow*, because the read returns undefined and the test +sits on a Playwright timeout rather than throwing. And upstream's suite already +ships `pytest-rerunfailures`: pass 1 reported `105 rerun`, which is each of +those 35 failures having been retried three times before the run even reported +them. A flake does not survive that. + +A test that passes in pass 2 is recorded as a **main-world fallback**: it counts +as a pass for the run, and its identity goes into +`metrics.main_world_fallbacks`, with `metrics.main_world_fallback_count` on the +summary table (summed across shards). A test failing in both worlds *and* on +retry is a plain failure. The fallback count is the isolated-world conformance +gap — watch it between runs; a jump means the isolation boundary moved. + +Both rerun passes are guarded on the failure set being non-empty, which is +load-bearing rather than tidy: pytest declines to filter when nothing it +collected previously failed, so an unguarded `--last-failed` runs the *whole* +group again — in the other world, silently replacing the result it was meant to +refine. + +### Why some isolated failures hang + +Not every isolated failure fails. Some wait forever, in both +`tests/async/test_route_web_socket.py` and `tests/sync/test_route_web_socket.py` +— and the two are not equally recoverable, which is the subject of the second +half of this section. + +The shape recurs, so it is worth stating generally: **a Playwright feature +implemented by installing something on the page's global lands in the isolated +world instead, so anything the page itself originates never reaches the +automation.** Two instances, both measured directly against a build: + +``` +page's own script opens a WebSocket isolated -> handler never fires main -> intercepted +page script calls window.exposedFn() isolated -> HANG main -> resolves +evaluate() calls window.exposedFn() isolated -> resolves main -> resolves +``` + +`route_web_socket` works by replacing `window.WebSocket` from an init script; +isolated, that replacement lands in the sandbox, so a socket the page opens is +never seen. `expose_function` installs its binding on the sandbox global, so page +script calling `window.fn()` finds nothing — though called from `evaluate()` it +works, which is why that one does not hang here. + +They **hang** rather than fail because the waits involved — a Twisted future from +the test server, an asyncio future a binding was meant to resolve — have no +Playwright timeout behind them. Everything else isolation breaks fails at +Playwright's 30s. + +Worth being clear that the `route_web_socket` half is **not a test artifact**: a +real site's WebSocket is not intercepted either, and the user gets no error +saying so. It is not fixable at this layer — the feature works by replacing a +page global, which is precisely what an isolated world exists to stop a page +from seeing. Tracked in [#775][ws-issue]; the fix is native interception below +the DOM object, which is also the only version of it that stays undetectable. + +[ws-issue]: https://github.com/daijro/camoufox/issues/775 + +So pass 1 is **cost-bounded**, and only pass 1: + +| Bound | Value | Why | +| --- | --- | --- | +| per-test timeout | 90s | the slowest test in the whole main-world baseline was 30.4s; only two exceeded 30s and none exceeded 45s. A Playwright action times out at 30s | +| upstream reruns | off (`CI=""`) | `tests/conftest.py` sets `reruns = 3` whenever `$CI` is set — the only thing it reads `$CI` for. The baseline recorded **2** reruns across all 2295 tests; the isolated pass recorded **138 in one shard**, all re-running deterministic world differences | + +Together that turns a hang from up to 4 × 180s into one 90s wait. A flake missed +by not rerunning is not lost — it fails pass 1, passes pass 2, and is counted as +a fallback. Note that `--reruns 0` as an *argument* would not work: upstream's +conftest overwrites `config.option.reruns` in `pytest_configure`, so clearing +the environment variable is the only lever that holds. + +#### The ones a timeout cannot bound + +That bound is not enough for all of them, and it is worth knowing exactly where +it stops working. Measured on run 34799668707 with the 90s bound already in +place: + +| Group | Isolated pass | Outcome | +| --- | --- | --- | +| `tests/async/` | completed in 296s | the bound works | +| `tests/sync/` | `test_should_work_with_ws_close` printed pytest-timeout's `+++ Timeout +++` banner at exactly 90s | **the process then sat for 1h50m**, until the job's `timeout-minutes` killed it | + +So the signal fires and the *test* dies; the *process* does not. pytest-timeout's +signal method raises at the next bytecode boundary, and Playwright's sync API is +parked in a greenlet switch that never reaches one cleanly — the raise lands +inside the dispatcher and wedges it. `--timeout-method=thread` fires reliably but +kills the interpreter, taking the other ~1500 tests in the group with it. **There +is no per-test timeout value that bounds this.** + +So those modules are **declared, not discovered** — `ISOLATION_HANGS` in +`ci/run_playwright.py`. The isolated pass cannot learn that they hang without +hanging, so it is told: they are `--ignore`d out of pass 1 and run directly in +the main world (pass 1b), where they pass and are counted as fallbacks exactly +as if isolation had failed them honestly. The same tests still run, in the world +that can run them. + +**Why not `ci/skiplist.yml`.** That list means "fails in the most permissive +world", and `ci/run_skiplist_audit.py` enforces it by running every entry with +`CI_WORLD=main` and failing the build on any that **pass**. A `route_web_socket` +test passes there — the main world is precisely where the feature works — so an +entry would be rejected by the audit, and would be untrue as written. The two +lists are not interchangeable, and `test_isolation_hangs_are_not_in_the_skiplist` +keeps them apart. + +Pass 1b sits **above** the `if not failing: continue` guard, deliberately: a +group whose isolated pass found nothing would otherwise skip it, and coverage +would disappear on exactly the runs that look healthiest. + +`tests/patches/isolated-evaluate.py` still owns the direct coverage of isolated +evaluation, and must keep passing regardless. That file is what to check if +isolation itself regresses. + +`ci/run_skiplist_audit.py` deliberately runs in the **main world**: a skiplist +entry has to claim a test cannot pass in *either* world, or the suite would have +counted it as a fallback rather than a failure. + +Ten tests are deselected outright by [`ci/skiplist.yml`](skiplist.yml), which +requires a stated reason per entry — `ci/summarize.py` fails the run on an +unreasoned one. + +**A reason is not evidence, so the reasons are checked.** The first version of +this file inherited all nine `tests/async/*.disabled` modules from the vendored +suite and gave each a plausible justification without running any of them: of +the 202 tests it skipped, **193 passed**, and seven of the nine modules failed +nothing at all. A written reason made them look verified, which is worse than +leaving them bare. + +`ci/run_skiplist_audit.py` now runs every entry with the skiplist disabled and +**fails the build if a skipped test passes**. It is cheap precisely because a +correct skiplist is short — ten tests, a few seconds — and it is what keeps the +list from drifting back into a place failing tests go to disappear. + +```bash +python3 -m ci.run_skiplist_audit --binary /path/to/camoufox-bin +``` + +What remains after the audit, 10 tests: two `test_click.py` tests where +Playwright's stable-position wait races the humanized travel time; six +client-certificate tests (async and sync) that need the **browser** to present a +certificate during the TLS handshake — the two that go through the Node driver's +own request context instead pass, and are not skipped; and the two upstream +expectations that encode a stock-Firefox quirk, replaced by `tests/camoufox/`. + +That client-certificate split is the audit earning its place. The entry was +first written as a whole module, because on a local machine all five fail — +Node/OpenSSL there rejects the fixture server outright. In CI two of them pass, +and the audit failed the build one run after the entry was written. **CI is the +authority for what fails; a local run is a hypothesis.** + +## Camoufox's own suite + +`native-tests/` covers what the Playwright suite cannot ask about: + +- **Leaks.** Launch browsers, kill them, prove nothing survived — file + descriptors, sockets, child processes, X11 lock files. The real assertion is + that cost does not *scale* with launch count, because that is the shape a leak + actually has: a scraper that runs fine for six hours and then dies of EMFILE. + Scope is honest: this measures resources held by our process and its children, + not Gecko's internal heap. +- **Contexts versus browsers.** Two contexts in one browser must get different + fingerprints; two pages in one context must get the same one. Get this wrong + and per-context injection silently degrades to process-global — which passes + every single-context test there is. It has happened here before (commit + `d17c887`, "fix screen size leak in contexts"). +- **Settled decisions.** `ci/tribal-rules.yml` lists choices this project already + made, each with the issue or PR that made it, and + `native-tests/test_tribal_rules.py` asserts them. A comment explaining a + decision only works on someone who reads it. + +## Sundial + +> **On since 2026-09-12.** sundial 0.5.0 is deployed and serving score mode, and +> sundial's master branch now deploys itself on push, so merged does mean +> deployed. It was off for as long as the live build predated score mode: an +> older sundial ignores `?score=1` and posts the entire report — every vector's +> id, name, brief, source and value — to whatever collector asked. Receiving +> that on a public runner and discarding it afterwards is not the guarantee this +> section describes; not receiving it is. `enabled: false` in `ci/sundial.yml` +> is still the kill switch, and the resolve job checks it *before* the +> credential comes into scope, so flipping it back stops the request rather than +> just the reporting. + +The stealth check reports **a letter grade and a count**. Nothing else leaves +`ci/run_sundial.py::redact()` — not a vector name, description, measured value, +source, and not a per-category breakdown either: a table reading "Graphics 3/17" +is the most useful single fact an adversary could take from a public CI log. + +There are **no per-check rows in the results file at all** — not even opaque +ones. An HMAC does not name a vector, but a map of them still publishes how many +distinct checks fail and lets a reader follow one across releases, which is +per-vector data wearing a hash. The instruction was a score, so it is a score: +regression detection is per-score, via `min_pass_rate` and a maximum allowed +drop. Scope and thresholds live in [`ci/sundial.yml`](sundial.yml); only +categories Camoufox actually claims are gated. + +Everything that leaves `redact()` is checked against a **whitelist** at runtime, +not a blacklist — a blacklist only stops the leaks somebody already thought of. +Adding a field without adding it to `_PUBLISHABLE` fails the run: + +```json +{ "grade": "A", "checks_total": 412, "checks_passed": 403, "pass_rate": 0.978, + "out_of_scope_failed": 6, "cross_os_total": 24, "cross_os_passed": 5, + "os": "linux", "sundial_version": "0.3.1", "schema_version": 1 } +``` + +**Cross-OS detectors are counted, never scored.** They read the host machine +rather than the disguise: a browser claiming macOS while running on Linux fails +them however good its spoofing is, and Camoufox does not claim byte-identical +cross-OS emulation. Folding them into one average would mark it down for a +promise nobody made, and would hide a real regression behind noise it cannot +control. They are reported separately so a drop there reads as "the host shows +through more than it did", which is a different conversation. + +The run asks sundial for `?auto=1&score=1`, so it receives counts and the +vectors never cross the wire at all. + +### Finding out which checks failed + +Worth being precise about, because the answer is "you can't, from CI", and that +is deliberate rather than an oversight. Score mode's payload is buckets keyed +`"|"` holding two integers each. **It carries no check names and +no ids**, so a failing check's identity is not something the CI process discards +— it is something sundial never sends. Nothing in the artifact, the log, or the +sealed report can recover it. + +Two steps down from there, both local only: + +```bash +# which CATEGORY the failures are in -- works with the credential CI already has +python3 -m ci.run_sundial --explain --binary /path/to/camoufox-bin + +# which CHECKS -- needs a role sundial serves full reports to +python3 -m ci.run_sundial --explain --allow-full-report --binary /path/to/camoufox-bin +``` + +`--explain` prints to the terminal and never writes to a result file, and is +**refused outright under `GITHUB_ACTIONS`**: a category-level table is not a +vector, but "Graphics 3/17" is still the most useful single fact an adversary +could take from a public log, which is exactly why `redact()` does not publish +one. + +### Order of operations + +`?score=1` needs a sundial that has it. An older deployment ignores the unknown +parameter and posts the whole report; the numbers still come out right and +`redact()` still discards everything identifying, but **nothing is classified**, +so every cross-OS tally reads `0` — which looks like "no host-OS failures" +rather than "nobody sorted them". `score_mode: false` in the result says which +it is, and the gate says so in its notes rather than leaving you to notice. + +So the dependency runs one way, and setting the GitHub secrets is the *last* +step, not the first: + +1. deploy sundial's score mode — done; it ships in 0.5.0, and master now + deploys on push +2. `gh secret set SUNDIAL_AUTOMATION_KEY -R ` for every repository whose + CI runs this. Without it the job skips, which is the normal case for a fork + pull request +3. flip `enabled: true` in `ci/sundial.yml` — done + +Two things keep a vector out of a public log, and it is worth separating them, +because only one is enforced by the server: + +| | guarantee | +|---|---| +| server-side | The run logs in as `guest`, and sundial's middleware refuses `guest` the private-vector bundle outright — those definitions are never served to the session. | +| client-side | This gate only ever requests `/?auto=1&score=1`, and `redact(require_score_mode=True)` **fails the run** if a full report arrives anyway, rather than folding it down and carrying on. | + +The stricter option is sundial's score-only `ci` role, which is refused anything +but `/?auto=1&score=1` server-side and so cannot be handed a report even if the +credential leaks. That role is not in sundial's master branch and is therefore +not deployed; when it lands, mint the credential (`make pages-ci`, then +redeploy) and set `SUNDIAL_USERNAME=ci`. Nothing in this repository changes — +the client-side half already behaves as though the server were enforcing it. + +There are deliberately **no per-vector rows**, not even opaque ones. An HMAC +names nothing, but a map of them publishes how many distinct checks fail and +lets a reader follow the same id from release to release. + +The cost is real: regression detection drops from per-vector ("the check that +passed last release fails now") to per-score ("we got worse"), covered by +`min_pass_rate` in `ci/sundial.yml` — and, once an auto-update pipeline exists +to compare releases, by a maximum allowed drop in its policy file. To get the per-vector view back for your own debugging, +set `SUNDIAL_REPORT_AGE_RECIPIENT` to an `age` public key — the full report is +then kept encrypted to you and nobody else can open it. + +Needs **`SUNDIAL_AUTOMATION_KEY`** (the password). **`SUNDIAL_USERNAME`** is +optional and names the account, which is not a secret — it defaults to `guest`. +Absent the password — a pull request from a fork — the job is skipped and the +summary says so. + +### What actually stops a vector reaching the log + +Asking for `?score=1` is a promise the caller makes, and a promise is not a +mechanism. Today two things back it: + +- **`guest` cannot load the private vectors, and that is checked.** sundial's + middleware answers `isPrivateVectorAsset` paths with an empty stub for that + role specifically. `admin` and `private` do get them — and `/automated?key=` + resolves to `private` when handed the private key, which is indistinguishable + from the guest one by looking at it. So "we set the right key" stays an + assumption until something checks: the gate reads sundial's own `/__auth/me` + and **refuses to open the browser at all** unless the session is a role the + vectors are withheld from. Not knowing the role counts as not safe. +- **A non-score payload fails the run.** `redact(require_score_mode=True)` + refuses to process a full report rather than folding it down, so a deployment + that ignored `score=1` is a red build, not a quiet leak. + +What is still missing is a server that refuses the *request*. sundial's +score-only `ci` role does exactly that — a bare `/`, `auto=1` without `score=1`, +`?mode=raw`, `?download=true`, `?key=`, the `/automated` and `/locale` export +routes, and any parameter not on its allow-list each get a 403, and on the pages +it does serve the report is never written to a global, so there is nothing for +`page.evaluate`, devtools or a screenshot to read. That role is not merged into +sundial's master and so is not deployed. When it is, set `SUNDIAL_USERNAME=ci`; +nothing here changes, because this side already behaves as though the server +were enforcing it. + +The distinction is worth keeping straight: under `guest`, dropping `score=1` by +accident would put the whole report in the collector and leave `redact()` as the +only thing between it and a public artifact. Under `ci`, the same mistake is a +403 at the first request. `--allow-full-report` exists for a deliberate local +run under an account that is allowed one. + +## Blocking a merge + +Branch protection on `main` requires exactly one check: **`All tests passed`**, +the `gate` job. Pointing at one job instead of a dozen means the required-check +list does not need editing every time a suite is added, renamed, or resharded. + +The gate allows exactly three skips, each for a stated reason: + +| Skipped | Because | +| --- | --- | +| `build` | the browser was fetched, not compiled | +| `fetch-browser` | the browser was compiled, not fetched | +| `sundial` | disabled in `ci/sundial.yml`, or a fork pull request with no stealth credentials | + +Anything else that is not `success` fails it — **including `skipped`**. A suite +that did not run has not passed, and quietly skipping one is the cheapest route +to a green tick. + +`build` is also the one suite dropped from `--require` when the browser was +fetched rather than compiled: that job writes no result, and requiring a name +nothing produces fails a run where everything passed. + +One suite may additionally record a **`skip` result** without failing the run, +named explicitly in `--allow-skip`: `sundial`, and only when sundial itself is +unreachable. It is a separate service on a separate host, so an outage there +means this browser was never measured — neither a pass nor a failure is true, +and blocking every merge in the repository on someone else's downtime is the +wrong answer. The summary shows it as skipped with the reason. A rejected +credential, a role that would be served the private vectors, a full report where +a score was requested, or a score under the floor all still fail: those are +answers, and an answer gets judged. + +The settings live in [`ci/branch-protection.json`](branch-protection.json) so +they are reviewable rather than lore. To apply them (needs admin): + +```bash +gh api -X PUT repos///branches/main/protection \ + --input ci/branch-protection.json +``` + +Two choices worth knowing about: + +- **`enforce_admins: false`** — you can still merge when CI itself is broken. + Protection should stop mistakes, not lock you out of your own repository. +- **`strict: false`** — a pull request does not have to be rebased onto the + latest `main` before merging. With `true`, every push to `main` would + invalidate every open pull request and force another build, and a build here + is over an hour cold. + +Reviews are deliberately not required: a solo maintainer cannot approve their +own pull request, so requiring one would block every merge. + +## Cost control + +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 +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, build-tester ~15 min +3b full Playwright x2, 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. + +**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 +minutes was compiling C++ — it was Rust, linking libxul, and packaging, none of +which a `.js` file affects. And in the *unpackaged* `dist/bin` that CI archives +there is no `omni.ja` at all: Juggler is loose files under `chrome/juggler/`, so +delivering new JavaScript is a file copy. + +So the build cache is keyed on a hash of the **compiled** inputs only +(`ci/browser_inputs.py`). If that hash matches, the compiled half is identical +*by construction* — no diff required, and no dependence on what the pull request +base happened to contain — and this branch's resources are laid over the +restored browser. A Juggler JavaScript change costs about a minute instead of +twenty-four. + +Two things make that dangerous, and both are closed and mutation-tested: + +| Trap | What closes it | +| --- | --- | +| `additions/juggler/` is **not** all JavaScript — it holds the screencast encoder and the debugging pipe (5 `.cpp`, 5 `.h`, 2 `.idl`, 3 `components.conf`, 4 `moz.build`) | Only files `jar.mn` actually lists are resources. Everything else — including any extension nobody has considered yet — is native and forces a build. `jar.mn` itself is native, so removing an entry cannot leave a stale file behind | +| The source→destination mapping is **per-file, not a prefix** | It is read from `jar.mn`. `TargetRegistry.js` → `content/TargetRegistry.js` (a level added), `content/FrameTree.js` → `content/content/FrameTree.js` (preserved), `content/JugglerFrameChild.sys.mjs` → `content/JugglerFrameChild.sys.mjs` (dropped). Two files in one source directory landing at different depths is exactly what a prefix rule gets wrong — and it would run stale Juggler while every suite went green | + +**A browser that is already built is not built again.** `browser_changed` is +computed against the pull request's *base*, so it stays true for every push to a +branch that touched `patches/` even once. That is right — the published release +does not contain that branch's browser changes, so it cannot be tested against — +but taken alone it meant recompiling a byte-identical browser on every push, 24 +minutes at a time, to fix a typo in `ci/`. + +The build job therefore asks a narrower question first: not "does this branch +change the browser" but "has the browser changed since the last one we built". +The answer is a cache keyed on a hash of every input that can alter the binary — +the same path list `browser_changed` uses, plus this workflow, which pins the +toolchain. On a hit, a 634 MB `camoufox-dist.tar.zst` is restored and every +build step is skipped; the run still records a `build` result saying the browser +was restored rather than compiled, because a required suite that reports nothing +fails the gate, and "nothing was compiled" should be a fact in the evidence +rather than a hole in it. + +Deliberately **no `restore-keys`** on that cache. Everywhere else a partial +match is fine — a partly warm ccache is still warm — but here it would hand the +test jobs a browser built from different sources, and every suite would report +on it looking perfectly healthy. A self-test asserts the key covers every path +`browser_changed` considers browser-affecting, so the two cannot drift apart. + +**The ccache is kept warm from `main`.** Pushes to `main` populate it and a +twice-weekly schedule keeps it from being evicted (GitHub drops a cache after +seven days unused). Pull requests restore it through `restore-keys`, so a build +in a pull request starts warm even though its own key is new. + +A prebuilt image in `ghcr.io` with the object cache baked in would be warmer +still and would not need the eviction guard. It also needs registry credentials +and a rebuild pipeline of its own; this is the version that works with no setup. + +**Preparing the tree retries the network, and nothing else.** `mach bootstrap` +pulls toolchains from Taskcluster, and a connection reset there used to fail the +whole pull request. `ci.run_prepare` runs `setup-minimal` → `dir` → +`mozbootstrap`, retrying the two that download things and only when the failure +text reads as transient. A failed patch hunk or a compile error still fails on +the first attempt — retrying a broken tree only spends a runner to reach the +same answer, and a retry loop that swallows a real breakage turns a red build +into a slow red build. + +> One consequence of `cancel-in-progress`: pushing to a branch cancels its +> running build. That is right while iterating, but a 70-minute build will not +> survive a push made 20 minutes in. + +## Running a piece by hand + +```bash +python3 -m ci.run_playwright --binary path/to/camoufox-bin +python3 -m ci.run_playwright --binary path/to/camoufox-bin --shard 3/6 +python3 -m ci.run_native --subset rules # no browser needed +python3 -m ci.run_native --subset browser --binary path/to/camoufox-bin +python3 -m ci.run_sundial --binary path/to/camoufox-bin +python3 -m ci.summarize --results-dir .ci-work/results +``` + +Each writes one result file to `.ci-work/results/`. `ci/summarize.py` folds the +shards, decides, and renders the table. A required suite that produced no result +file is a **failure**, never a skip — otherwise deleting a job would be the +cheapest way to a green tick. + +## Self-tests + +`ci/tests/` asserts the pipeline reports honestly: redaction leaks nothing, +skips carry reasons, shards partition exactly once, version resolution never +picks a suite newer than the browser. These run in the `static` job on every +pull request. diff --git a/ci/__init__.py b/ci/__init__.py new file mode 100644 index 000000000..aeb5c85c7 --- /dev/null +++ b/ci/__init__.py @@ -0,0 +1,16 @@ +"""Camoufox's test pipeline. + +Everything that *runs* a suite and reports what happened lives here, and is +driven identically from a pull request, a push to main, and -- through +`workflow_call` -- any caller that needs to test a specific browser version. +That is deliberate: a version bump should be provable by running the same checks +a contributor's pull request runs, so there is exactly one definition of "the +tests pass". + + versions.py which browser build, and which Playwright suite tests it + suite.py fetch and prepare that Playwright suite + skiplist.yml tests Camoufox cannot pass by design, each with a reason + pw_camoufox_plugin.py main-world execution, binary selection, skips, sharding + run_*.py one runner per suite; each writes one result file + summarize.py fold the result files into a verdict and a readable table +""" diff --git a/ci/_pytest.py b/ci/_pytest.py new file mode 100644 index 000000000..ea611a031 --- /dev/null +++ b/ci/_pytest.py @@ -0,0 +1,147 @@ +"""Shared pytest plumbing for the suite runners. + +Locating the built binary, running pytest against a chosen interpreter, and +reading back the JUnit XML it writes. `junit_test_id` is the one that matters +beyond this file: it produces the identity a test is known by, and that identity +has to survive a suite being re-fetched at a different tag, or run from a +different working directory, or sharded -- otherwise a run cannot be compared +with the one before it. +""" + +from __future__ import annotations + +import os +import re +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Dict, List, Optional + +from ._util import REPO_ROOT, Result, read_upstream_sh, run + + +def source_dir(version: Optional[str] = None, release: Optional[str] = None) -> Path: + """The generated Firefox tree, e.g. camoufox-153.0.4-beta.32/.""" + up = read_upstream_sh() + return REPO_ROOT / f"camoufox-{version or up['version']}-{release or up['release']}" + + +def built_binary(version: Optional[str] = None, release: Optional[str] = None) -> Path: + """Path to the freshly built camoufox-bin. Not guaranteed to exist.""" + override = os.environ.get("CAMOUFOX_BINARY") + if override: + return Path(override) + return source_dir(version, release) / "obj-x86_64-pc-linux-gnu" / "dist" / "bin" / "camoufox-bin" + + +def require_binary(version: Optional[str] = None, release: Optional[str] = None) -> Path: + path = built_binary(version, release) + if not path.exists(): + raise FileNotFoundError( + f"no built binary at {path}. Run `make build` first, or set CAMOUFOX_BINARY." + ) + return path + + +# --------------------------------------------------------------------------- +# pytest / junit +# --------------------------------------------------------------------------- + +# pytest junit escapes some characters; normalise so ids are stable across +# pytest versions and across checkouts rooted at different paths. +_NORM = re.compile(r"\s+") + + +def junit_test_id(classname: str, name: str) -> str: + """A stable identity for one test, in pytest node-id form. + + junit's `classname` is the dotted module path, and how many leading segments + it carries depends on where pytest was invoked from -- `tests.async.test_page` + from the checkout root, `async.test_page` from inside `tests/`. Trimming to + the last two segments makes a run comparable with any other run of the same + test, however it was launched. + + For a test inside a class, pytest appends the class to that dotted path + (`async.test_page_clock.TestWhileRunning`), and the trailing segment is then + a class, not a module. Splitting on the last `test_*` segment tells the two + apart, so a class-based test comes back as + + async/test_page_clock.py::TestWhileRunning::test_should_pause + + -- a node id pytest will actually accept. Treating the class as a module + instead produced `test_page_clock/TestWhileRunning.py::test_should_pause`, + which names a directory that does not exist, so the id could not be fed back + to pytest and no skiplist entry could ever match it. + """ + parts = [p for p in classname.split(".") if p and p != "tests"] + # The last segment that looks like a test module is the file; anything after + # it is class nesting. Test files are `test_*.py` by pytest convention, and + # classes are `Test*` -- so this does not have to guess from case alone. + module_end = len(parts) + for i in range(len(parts) - 1, -1, -1): + if parts[i].startswith("test_"): + module_end = i + 1 + break + module_parts, class_parts = parts[:module_end], parts[module_end:] + module = ( + "/".join(module_parts[-2:]) + if len(module_parts) >= 2 + else (module_parts[-1] if module_parts else "") + ) + suffix = "".join(f"::{c}" for c in class_parts) + return _NORM.sub(" ", f"{module}.py{suffix}::{name}").strip() + + +def parse_junit(path: Path) -> Dict[str, str]: + """junit XML -> {test_id: pass|fail|error|skip}.""" + if not path.exists(): + return {} + outcomes: Dict[str, str] = {} + tree = ET.parse(path) + for case in tree.getroot().iter("testcase"): + tid = junit_test_id(case.get("classname", ""), case.get("name", "")) + if not tid: + continue + if case.find("error") is not None: + outcome = "error" + elif case.find("failure") is not None: + outcome = "fail" + elif case.find("skipped") is not None: + outcome = "skip" + else: + outcome = "pass" + # With --count / reruns the same id appears twice; a pass on any + # attempt wins, matching evidence.GateResult.record(). + if outcomes.get(tid) == "pass": + continue + outcomes[tid] = outcome + return outcomes + + +def _has_plugin(python: Path, module: str) -> bool: + return run([str(python), "-c", f"import {module}"]).ok + + +def run_pytest( + *, + cwd: Path, + python: Path, + args: List[str], + junit: Path, + env: Optional[Dict[str, str]] = None, + timeout: int = 7200, + per_test_timeout: Optional[int] = 180, +) -> Result: + """Run pytest and write junit XML. + + `per_test_timeout` guards against a hung browser wedging the whole job, but + it needs pytest-timeout. Passing the flag without the plugin makes pytest + exit 4 on an unrecognised argument -- which looks exactly like "the suite + did not run", because it did not. So the flag is only added when the plugin + is actually importable in that interpreter. + """ + junit.parent.mkdir(parents=True, exist_ok=True) + cmd = [str(python), "-m", "pytest", f"--junitxml={junit}", "-p", "no:randomly"] + if per_test_timeout and _has_plugin(python, "pytest_timeout"): + cmd.append(f"--timeout={per_test_timeout}") + cmd.extend(args) + return run(cmd, cwd=cwd, env=env, timeout=timeout, tee=True, capture=False) diff --git a/ci/_util.py b/ci/_util.py new file mode 100644 index 000000000..b4262983c --- /dev/null +++ b/ci/_util.py @@ -0,0 +1,289 @@ +"""Shared plumbing for the pipeline: paths, subprocess, JSON, hashing, logging. + +Deliberately dependency-free (stdlib only) so the early steps can run on a bare +runner before anything is installed. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import re +import shlex +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, NoReturn, Optional, Sequence, Tuple + +REPO_ROOT = Path(__file__).resolve().parent.parent +CI_DIR = REPO_ROOT / "ci" +SKIPLIST_PATH = CI_DIR / "skiplist.yml" + +# Where a run scatters its intermediate state. Overridable so a local operator +# can keep several runs side by side, and so a sharded CI job can hand each +# shard its own directory. +WORK_DIR = Path(os.environ.get("CI_WORK_DIR", os.environ.get("HARNESS_WORK_DIR", REPO_ROOT / ".ci-work"))) +RESULTS_DIR = Path(os.environ.get("CI_RESULTS_DIR", os.environ.get("HARNESS_EVIDENCE_DIR", WORK_DIR / "results"))) + +# Kept under the old name so harness code and existing call sites read the same. +EVIDENCE_DIR = RESULTS_DIR + +# Salt for the opaque test identifiers written into the baseline. Sundial's +# vector names are private (see TRIBAL-KNOWLEDGE.md, "Never publish a vector +# name"), so the baseline stores HMACs instead. The default salt is a repo +# constant on purpose: it makes the baseline reproducible for anyone who can +# already run sundial, and useless to anyone who cannot, because inverting it +# requires the private vector list. Override only if you want the baseline +# unreadable even to someone holding that list. +DEFAULT_ID_SALT = "camoufox-harness/v1" + + +# -------------------------------------------------------------------------- +# logging +# -------------------------------------------------------------------------- + +_START = time.monotonic() + + +def log(msg: str, *, level: str = "INFO") -> None: + # stderr, so a `--json` stdout stays machine-readable and every CLI here + # composes with a pipe. Actions shows both streams either way. + elapsed = time.monotonic() - _START + print(f"[{elapsed:7.1f}s] {level:<5} {msg}", file=sys.stderr, flush=True) + + +def die(msg: str, code: int = 1) -> NoReturn: + log(msg, level="FATAL") + sys.exit(code) + + +def group(title: str) -> None: + """Open a collapsible section in the Actions log (a no-op elsewhere).""" + if os.environ.get("GITHUB_ACTIONS"): + print(f"::group::{title}", flush=True) + else: + log(f"--- {title} ---") + + +def endgroup() -> None: + if os.environ.get("GITHUB_ACTIONS"): + print("::endgroup::", flush=True) + + +def set_output(name: str, value: str) -> None: + """Publish a step output when running under Actions; echo otherwise.""" + log(f"output {name}={value}") + path = os.environ.get("GITHUB_OUTPUT") + if not path: + return + # Multi-line values need the heredoc form. + with open(path, "a", encoding="utf-8") as fh: + if "\n" in value: + delim = f"__EOF_{hashlib.sha256(value.encode()).hexdigest()[:16]}__" + fh.write(f"{name}<<{delim}\n{value}\n{delim}\n") + else: + fh.write(f"{name}={value}\n") + + +def summary(markdown: str) -> None: + """Append to the Actions job summary (the human-facing proof surface).""" + path = os.environ.get("GITHUB_STEP_SUMMARY") + if not path: + print(markdown, flush=True) + return + with open(path, "a", encoding="utf-8") as fh: + fh.write(markdown.rstrip() + "\n") + + +# -------------------------------------------------------------------------- +# subprocess +# -------------------------------------------------------------------------- + + +@dataclass +class Result: + code: int + stdout: str + stderr: str + + @property + def ok(self) -> bool: + return self.code == 0 + + def combined(self) -> str: + return (self.stdout or "") + (("\n" + self.stderr) if self.stderr else "") + + +def run( + cmd: Sequence[str] | str, + *, + cwd: Optional[Path] = None, + env: Optional[Dict[str, str]] = None, + timeout: Optional[int] = None, + check: bool = False, + capture: bool = True, + tee: bool = False, +) -> Result: + """Run a command. Never raises on a non-zero exit unless check=True.""" + shell = isinstance(cmd, str) + printable = cmd if shell else " ".join(shlex.quote(c) for c in cmd) + log(f"$ {printable}" + (f" (cwd={cwd})" if cwd else "")) + + full_env = {**os.environ, **(env or {})} + try: + proc = subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + env=full_env, + shell=shell, + timeout=timeout, + capture_output=capture and not tee, + text=True, + ) + except subprocess.TimeoutExpired as exc: + out = exc.stdout or "" + err = exc.stderr or "" + if isinstance(out, bytes): + out = out.decode("utf-8", "replace") + if isinstance(err, bytes): + err = err.decode("utf-8", "replace") + res = Result(124, out, err + f"\nTIMEOUT after {timeout}s") + if check: + die(f"command timed out: {printable}") + return res + + res = Result(proc.returncode, proc.stdout or "", proc.stderr or "") + if check and not res.ok: + log(res.combined()[-4000:], level="ERROR") + die(f"command failed ({res.code}): {printable}") + return res + + +# -------------------------------------------------------------------------- +# json / files +# -------------------------------------------------------------------------- + + +def read_json(path: Path, default: Any = None) -> Any: + try: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + except FileNotFoundError: + if default is not None: + return default + raise + except json.JSONDecodeError as exc: + die(f"{path} is not valid JSON: {exc}") + + +def write_json(path: Path, obj: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(obj, fh, indent=2, sort_keys=True) + fh.write("\n") + + +def http_json(url: str, *, timeout: int = 30, headers: Optional[Dict[str, str]] = None) -> Any: + """GET a URL and parse JSON. Stdlib only so this works pre-pip.""" + import urllib.request + + req = urllib.request.Request(url, headers={"User-Agent": "camoufox-harness", **(headers or {})}) + with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 + return json.loads(resp.read().decode("utf-8")) + + +def http_text(url: str, *, timeout: int = 30, headers: Optional[Dict[str, str]] = None) -> str: + import urllib.request + + req = urllib.request.Request(url, headers={"User-Agent": "camoufox-harness", **(headers or {})}) + with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 + return resp.read().decode("utf-8") + + +# -------------------------------------------------------------------------- +# identifiers +# -------------------------------------------------------------------------- + + +def opaque_id(raw: str, *, salt: Optional[str] = None) -> str: + """Stable, non-invertible id for a private test vector. + + Used for anything sourced from sundial. The repo is public; a vector name + in a committed baseline is a permanent leak, an HMAC is not. + """ + key = (salt or os.environ.get("HARNESS_ID_SALT") or DEFAULT_ID_SALT).encode() + return hmac.new(key, raw.encode("utf-8"), hashlib.sha256).hexdigest()[:20] + + +def digest_files(paths: Iterable[Path]) -> str: + """Order-independent digest over a set of files, for tamper detection.""" + h = hashlib.sha256() + for path in sorted(set(Path(p) for p in paths), key=lambda p: str(p)): + h.update(str(path.relative_to(REPO_ROOT) if path.is_absolute() else path).encode()) + h.update(b"\0") + h.update(path.read_bytes() if path.exists() else b"") + h.update(b"\0") + return h.hexdigest() + + +# -------------------------------------------------------------------------- +# versions +# -------------------------------------------------------------------------- + +_VER_RE = re.compile(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?") + + +def parse_version(v: str) -> Tuple[int, int, int]: + m = _VER_RE.match(v.strip()) + if not m: + raise ValueError(f"unparseable version: {v!r}") + return (int(m.group(1)), int(m.group(2) or 0), int(m.group(3) or 0)) + + +def major(v: str) -> int: + return parse_version(v)[0] + + +def read_upstream_sh(path: Optional[Path] = None) -> Dict[str, str]: + """Parse upstream.sh into a dict. It is shell, but strictly key=value.""" + path = path or (REPO_ROOT / "upstream.sh") + out: Dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + out[key.strip()] = val.strip().strip("'\"") + return out + + +def write_upstream_sh(values: Dict[str, str], path: Optional[Path] = None) -> None: + """Rewrite upstream.sh in place, preserving key order and comments.""" + path = path or (REPO_ROOT / "upstream.sh") + lines: List[str] = [] + seen = set() + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + key = stripped.partition("=")[0].strip() + if key in values: + lines.append(f"{key}={values[key]}") + seen.add(key) + continue + lines.append(line) + for key, val in values.items(): + if key not in seen: + lines.append(f"{key}={val}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def bump_release(release: str) -> str: + """beta.31 -> beta.32. Anything unrecognised gets a .1 suffix.""" + m = re.match(r"^(.*?)(\d+)$", release) + if not m: + return f"{release}.1" + return f"{m.group(1)}{int(m.group(2)) + 1}" diff --git a/ci/branch-protection.json b/ci/branch-protection.json new file mode 100644 index 000000000..052ccf193 --- /dev/null +++ b/ci/branch-protection.json @@ -0,0 +1,13 @@ +{ + "required_status_checks": { + "strict": false, + "contexts": ["All tests passed"] + }, + "enforce_admins": false, + "required_pull_request_reviews": null, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false, + "required_linear_history": false, + "required_conversation_resolution": true +} diff --git a/ci/browser_inputs.py b/ci/browser_inputs.py new file mode 100644 index 000000000..08badbff6 --- /dev/null +++ b/ci/browser_inputs.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Split the browser's inputs into what needs a compiler and what does not. + +Juggler is mostly JavaScript, and JavaScript does not need libxul relinked. But +a full build is what every change to `additions/` used to cost: 24 minutes, of +which ccache reported 98.63% hits -- so almost none of it was compiling C++. It +was Rust, linking, and packaging, none of which a `.js` file affects. + +The saving is real and the trap is sharp, so this module is deliberately small +and deliberately paranoid. + +**The hash IS the classification.** There is no "did only JS change?" diff here, +because a diff answers the wrong question -- it compares against the pull +request's base, while what matters is whether the cached browser was built from +the same native sources. So: hash every input that can change compiled output. +If that hash matches a cached browser, the compiled half is identical *by +construction*, whatever the diff says, and the resources can simply be laid over +it. + +**Two things it would be easy to get wrong, and how they are closed:** + + `additions/juggler/` is not all JavaScript. It also holds the screencast + encoder and the remote-debugging pipe -- 5 .cpp, 5 .h, 2 .idl, 3 + components.conf, 4 moz.build -- which are compiled into libxul. Only the files + `jar.mn` actually lists as packaged resources are treated as resources. + Everything else, including anything with an extension nobody has thought about + yet, counts as native and forces a build. Fail closed. + + The source-to-destination mapping is per-file, not a prefix rule. jar.mn maps + `TargetRegistry.js` to `content/TargetRegistry.js` (a level added), + `content/FrameTree.js` to `content/content/FrameTree.js` (preserved), and + `content/JugglerFrameChild.sys.mjs` to `content/JugglerFrameChild.sys.mjs` (a + level dropped). Two files in the same source directory land at different + depths. Assuming a prefix would write one of them to the wrong path, leave the + old copy in place, and run a browser with stale Juggler -- while every suite + reported green, because the browser works fine, it is just not the one under + review. So the mapping is read from jar.mn, never inferred. + + jar.mn itself is native: it decides both the mapping and what is packaged at + all, so changing it must force a real build rather than a re-overlay. + +Run: + python3 -m ci.browser_inputs --digest # the native-inputs hash + python3 -m ci.browser_inputs --list # what feeds that hash + python3 -m ci.browser_inputs --resources # source -> path in the dist + python3 -m ci.browser_inputs --overlay DIR # lay resources over a dist/bin +""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import shutil +import sys +from pathlib import Path +from typing import Dict, List, Optional + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# The same set `resolve` greps to decide whether the browser changed at all. +# Kept in step by ci/tests/test_ci.py, because a path that can change the binary +# and is not hashed here would be served a stale browser. +BROWSER_DIRS = ("patches", "additions", "settings", "assets", "scripts") +BROWSER_FILES = ("upstream.sh", "Makefile") + +JUGGLER = Path("additions") / "juggler" +JAR_MN = JUGGLER / "jar.mn" + +# `content/Helper.js (Helper.js)` -- destination first, source in parentheses. +_ENTRY = re.compile(r"^\s*(\S+)\s+\((\S+)\)\s*$") +# `% content juggler %content/` -- the chrome package this jar registers. +_PACKAGE = re.compile(r"^\s*%\s+content\s+(\S+)\s+%") + + +def jar_entries(root: Optional[Path] = None) -> Dict[str, str]: + """{repo-relative source: path inside the built dist}, straight from jar.mn. + + The dist path is `chrome//` -- observed directly + against a build: `content/Helper.js` in jar.mn is + `dist/bin/chrome/juggler/content/Helper.js` on disk. + """ + root = root or REPO_ROOT + text = (root / JAR_MN).read_text(encoding="utf-8") + package = "juggler" + for line in text.splitlines(): + found = _PACKAGE.match(line) + if found: + package = found.group(1) + break + + entries: Dict[str, str] = {} + for line in text.splitlines(): + if line.lstrip().startswith("#") or line.lstrip().startswith("%"): + continue + found = _ENTRY.match(line) + if not found: + continue + destination, source = found.group(1), found.group(2) + entries[str(JUGGLER / source)] = f"chrome/{package}/{destination}" + return entries + + +def resource_sources(root: Optional[Path] = None) -> set: + """Repo-relative paths that are packaged as-is and never compiled.""" + return set(jar_entries(root)) + + +def native_inputs(root: Optional[Path] = None) -> List[str]: + """Every browser input that is not a packaged resource, sorted. + + Anything unrecognised lands here rather than being skipped: a new file type + under additions/ forces a build until somebody decides otherwise, which is + the safe direction to be wrong in. + """ + root = root or REPO_ROOT + resources = resource_sources(root) + found: List[str] = [] + for name in BROWSER_DIRS: + base = root / name + if not base.is_dir(): + continue + for path in base.rglob("*"): + if not path.is_file(): + continue + rel = str(path.relative_to(root)) + if rel not in resources: + found.append(rel) + for name in BROWSER_FILES: + if (root / name).is_file(): + found.append(name) + return sorted(found) + + +def native_digest(root: Optional[Path] = None) -> str: + """A hash of the compiled browser's inputs. Same hash, same binary.""" + root = root or REPO_ROOT + digest = hashlib.sha256() + for rel in native_inputs(root): + digest.update(rel.encode("utf-8")) + digest.update(b"\0") + digest.update(hashlib.sha256((root / rel).read_bytes()).digest()) + return digest.hexdigest()[:32] + + +def overlay(dist_bin: Path, root: Optional[Path] = None) -> List[str]: + """Lay the current resources over an already-built dist/bin. + + Every entry is written, not just the changed ones: the set is then always + exactly what jar.mn says, so a stale file cannot survive. (A resource + *removed* from jar.mn is handled by jar.mn being a native input -- that + forces a real build rather than an overlay.) + """ + root = root or REPO_ROOT + written = [] + for source, destination in sorted(jar_entries(root).items()): + target = dist_bin / destination + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(root / source, target) + written.append(destination) + return written + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--digest", action="store_true") + parser.add_argument("--list", action="store_true") + parser.add_argument("--resources", action="store_true") + parser.add_argument("--overlay", type=Path, metavar="DIST_BIN") + args = parser.parse_args(argv) + + if args.digest: + print(native_digest()) + if args.list: + for rel in native_inputs(): + print(rel) + if args.resources: + for source, destination in sorted(jar_entries().items()): + print(f"{source} -> {destination}") + if args.overlay: + written = overlay(args.overlay) + print(f"overlaid {len(written)} resource(s) onto {args.overlay}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/build-tester.yml b/ci/build-tester.yml new file mode 100644 index 000000000..fe378dd30 --- /dev/null +++ b/ci/build-tester.yml @@ -0,0 +1,39 @@ +# Scope and thresholds for the build-tester suite. +# +# Here, in the repository, for the same reason ci/sundial.yml is: this runs on +# every pull request, and the auto-update harness (not part of this repository) +# is only one of its callers. + +schema: 1 + +# 6 per-context profiles + 2 global (CAMOU_CONFIG) profiles. +profile_count: 8 + +# These must come back with zero failing checks. A failure here is a leak, not +# a flake. +required_categories: + - Automation Detection + - JS Engine + - Lie Detection + - Firefox APIs + - Cross-Signal + - CSS Fingerprint + - Canvas Noise + - WebGL Render + +# Cross-profile uniqueness is judged by what each slot actually promises, not by +# one count across all of them (see ci/run_build_tester.py): +# +# must vary audio, canvas, timezone -- derived per context. Two contexts +# sharing one is the leak this suite exists to catch, so the +# tolerance below is 0. +# may collide fonts, screens, voices, WebGL -- drawn from the preset pool. +# Three draws from a pool of a dozen collide regularly; that is +# the birthday paradox, and the pools are small on purpose +# because they hold real devices. Reported, never fatal. +# must match platform -- every macOS context reports MacIntel. A collision +# is the correct answer here and variation is the bug. +# +# The earlier flat count failed a run that scored 1054/1054, by reading three +# macOS contexts all reporting MacIntel as three collisions. +allow_uniqueness_collisions: 0 diff --git a/ci/pw_camoufox_plugin.py b/ci/pw_camoufox_plugin.py new file mode 100644 index 000000000..a3f892e94 --- /dev/null +++ b/ci/pw_camoufox_plugin.py @@ -0,0 +1,296 @@ +"""pytest plugin that lets upstream's own Playwright suite drive a Camoufox build. + +Loaded with `-p` against an *unmodified* checkout of playwright-python's tests, +so upstream can refactor its conftest freely without breaking us. Four jobs: + +1. **Point every launch at the build under test.** Upstream's `launch_arguments` + fixture has no way to select a binary, so launches are intercepted at the + implementation layer instead. Hooking `_impl` rather than the fixture is what + makes this survive upstream reshuffling its fixtures. If no binary is given + the plugin refuses to start, because the alternative is silently testing a + downloaded stock Firefox and reporting a meaningless pass. + +2. **Choose which world `evaluate()` runs in.** `CI_WORLD` selects it: + `isolated` (the default, and what users get) or `main`. + + Camoufox evaluates in an isolated world -- the reason the fork exists. + Upstream's suite asserts upstream semantics: tests read globals their own + page scripts defined and pass handles into `evaluate()`, so a number of them + fail on "X is not defined" for a global the page really did set. That is a + known and deliberate divergence, not a regression. + + The suite therefore runs **isolated first**, which is the configuration users + actually ship, and `ci/run_playwright.py` re-runs only what failed with + `CI_WORLD=main` -- counting, naming and publishing every test that needed the + fallback. A test that passes either way is conformant; the size of the + fallback set is the isolated-world conformance gap, and watching it move is + the point of measuring it this way round. + + Camoufox's own isolated-world behaviour keeps separate coverage in + `tests/patches/isolated-evaluate.py`, which must go on passing regardless -- + that is the file to check if isolation itself regresses, not this suite. + +3. **Apply `ci/skiplist.yml`.** Deselects, rather than xfails, the tests Camoufox + cannot pass by design. Deselection keeps them out of the totals entirely, so + the pass rate means something. + +4. **Shard.** `CI_SHARD=i/n` keeps a deterministic slice, so a 1500-test suite + spreads across parallel runners. Sharding by a hash of the node id rather + than by position means a shard's contents do not shift when upstream adds a + test in the middle of a file. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +_EXECUTABLE_ENV = "CAMOUFOX_EXECUTABLE_PATH" +_SHARD_ENV = "CI_SHARD" +_SKIPLIST_ENV = "CI_SKIPLIST" +_WORLD_ENV = "CI_WORLD" + +MAIN_WORLD = "main" +ISOLATED_WORLD = "isolated" + + +# --------------------------------------------------------------------------- +# skiplist +# --------------------------------------------------------------------------- + + +def skiplist_path(path: Optional[Path] = None) -> Path: + """Where the skiplist is read from: $CI_SKIPLIST, else next to this file.""" + if path is not None: + return path + # Path("") is Path("."), which exists and is a directory -- so an unset + # CI_SKIPLIST must be treated as unset, not as a path. + override = os.environ.get(_SKIPLIST_ENV, "").strip() + return Path(override) if override else Path(__file__).resolve().parent / "skiplist.yml" + + +def load_skiplist(path: Optional[Path] = None) -> List[Dict[str, str]]: + path = skiplist_path(path) + if not path.is_file(): + print(f"camoufox: skiplist not found at {path}; running with no skips") + return [] + try: + import yaml + except ImportError as exc: # pragma: no cover -- environment problem + raise RuntimeError( + "PyYAML is missing from the interpreter running this suite, so " + f"{path} cannot be read. Refusing to continue: without the skiplist " + "roughly 200 tests Camoufox cannot pass by design would run and fail, " + "which looks like a broken browser rather than a broken environment." + ) from exc + + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + entries = [] + for entry in data.get("skip") or []: + if not isinstance(entry, dict): + continue + if not str(entry.get("reason", "")).strip(): + # Mirrors ci/summarize.py, which fails the run on an unreasoned + # entry. Ignoring it here would let one skip tests silently. + raise RuntimeError(f"skiplist entry has no reason: {entry!r}") + entries.append(entry) + return entries + + +def skip_reason(nodeid: str, entries: List[Dict[str, str]]) -> Optional[str]: + """The reason this node id is skipped, or None to run it.""" + # Every test in this suite is parameterised by browser, so the node ids that + # actually arrive here end in `[firefox]`. Compare against the id with its + # parameters stripped as well as the whole thing, so a `test:` entry written + # the way a human reads a node id out of a failure report does what its + # author meant. Requiring the exact `...[firefox]` spelling instead made + # every `test:` entry a silent no-op -- the entry looked applied, the test + # went on running and failing, and nothing reported the mismatch. + base = nodeid.partition("[")[0] + # pytest node ids are posix-style even on Windows. + for entry in entries: + if "module" in entry: + module = str(entry["module"]).lstrip("./") + if nodeid.startswith(module + "::") or nodeid == module: + return str(entry["reason"]) + elif "test" in entry: + target = str(entry["test"]).lstrip("./") + if nodeid == target or base == target: + return str(entry["reason"]) + elif "pattern" in entry: + if str(entry["pattern"]) in nodeid: + return str(entry["reason"]) + return None + + +# --------------------------------------------------------------------------- +# sharding +# --------------------------------------------------------------------------- + + +def parse_shard(raw: Optional[str]) -> Optional[Tuple[int, int]]: + """"3/6" -> (3, 6). One-based, like every CI UI that will display it.""" + if not raw: + return None + try: + index, _, total = raw.partition("/") + shard, count = int(index), int(total) + except ValueError: + raise RuntimeError(f"{_SHARD_ENV} must look like '3/6', got {raw!r}") from None + if not (1 <= shard <= count): + raise RuntimeError(f"{_SHARD_ENV}={raw!r} is out of range") + return shard, count + + +def shard_of(nodeid: str, count: int) -> int: + """Stable one-based shard for a node id. + + Hashed rather than positional: adding a test in the middle of a file must + not reshuffle which shard every later test lands in, or a flake starts + looking like it moved between runners. + """ + digest = hashlib.sha256(nodeid.encode("utf-8")).digest() + return (int.from_bytes(digest[:8], "big") % count) + 1 + + +# --------------------------------------------------------------------------- +# browser wiring +# --------------------------------------------------------------------------- + + +def selected_world() -> str: + """Which world this process evaluates in. Isolated unless asked otherwise. + + Defaulting to isolated means the plain `pytest -p pw_camoufox_plugin` + someone runs by hand measures the browser as it actually ships, rather than + a mode only the conformance suite uses. + """ + return MAIN_WORLD if os.environ.get(_WORLD_ENV, "").strip().lower() == MAIN_WORLD else ISOLATED_WORLD + + +def _apply_world(world: str) -> None: + """Set (or clear) `disableWorldIsolation` in CAMOU_CONFIG for this process. + + Clearing matters as much as setting: the fallback pass and the isolated pass + are separate pytest processes but may inherit the same CAMOU_CONFIG from the + job environment, and a stale `disableWorldIsolation: true` would make an + "isolated" run quietly measure the main world -- which is precisely the + reading this whole arrangement exists to produce. + """ + raw = os.environ.get("CAMOU_CONFIG") + config = json.loads(raw) if raw else {} + if world == MAIN_WORLD: + config["disableWorldIsolation"] = True + else: + config.pop("disableWorldIsolation", None) + os.environ["CAMOU_CONFIG"] = json.dumps(config) + + +def _install_executable_path(path: str) -> None: + from playwright._impl._browser_type import BrowserType + + for name in ("launch", "launch_persistent_context"): + original = getattr(BrowserType, name, None) + if original is None or getattr(original, "_camoufox_wrapped", False): + continue + + def make(original: Any): # noqa: ANN401 + async def wrapper(self, *args: Any, **kwargs: Any): # noqa: ANN401 + # playwright's _impl uses camelCase; accept either spelling so a + # rename upstream degrades to "we set it twice", not "we set + # nothing". A test that supplies its own path keeps it. + if not kwargs.get("executablePath") and not kwargs.get("executable_path"): + kwargs["executablePath"] = path + return await original(self, *args, **kwargs) + + wrapper._camoufox_wrapped = True # type: ignore[attr-defined] + return wrapper + + setattr(BrowserType, name, make(original)) + + +# --------------------------------------------------------------------------- +# hooks +# --------------------------------------------------------------------------- + + +def pytest_configure(config) -> None: # noqa: ANN001 + config._camoufox_world = selected_world() + _apply_world(config._camoufox_world) + executable = os.environ.get(_EXECUTABLE_ENV) + if not executable: + raise RuntimeError( + f"{_EXECUTABLE_ENV} is not set. The upstream conformance suite has no way to " + "select a browser binary, so without it pytest would silently test a " + "downloaded stock Firefox and report a meaningless pass." + ) + _install_executable_path(os.path.abspath(executable)) + config._camoufox_skiplist = load_skiplist() + # Report the file we actually read, not where this plugin happens to sit. + # ci/suite.py copies the plugin into the fetched checkout, so those two are + # different directories and the header used to name a path with no file at + # the end of it -- which is worse than saying nothing when a skip is being + # chased down. + config._camoufox_skiplist_path = skiplist_path() + config._camoufox_shard = parse_shard(os.environ.get(_SHARD_ENV)) + config._camoufox_skipped: Dict[str, str] = {} + + +def pytest_collection_modifyitems(config, items) -> None: # noqa: ANN001 + entries = getattr(config, "_camoufox_skiplist", []) + shard = getattr(config, "_camoufox_shard", None) + skipped: Dict[str, str] = getattr(config, "_camoufox_skipped", {}) + + keep = [] + deselected = [] + for item in items: + reason = skip_reason(item.nodeid, entries) + if reason: + skipped[item.nodeid] = reason + deselected.append(item) + continue + if shard and shard_of(item.nodeid, shard[1]) != shard[0]: + deselected.append(item) + continue + keep.append(item) + + if deselected: + config.hook.pytest_deselected(items=deselected) + items[:] = keep + + +def pytest_report_header(config) -> List[str]: # noqa: ANN001 + shard = getattr(config, "_camoufox_shard", None) + world = getattr(config, "_camoufox_world", ISOLATED_WORLD) + lines = [ + f"camoufox: binary={os.environ.get(_EXECUTABLE_ENV)}", + f"camoufox: evaluating in the {world} world" + + ( + " -- this is the main-world FALLBACK pass; a test passing here failed " + "under isolation" + if world == MAIN_WORLD + else " (as shipped). Failures are re-run in the main world and counted " + "as fallbacks, not hidden." + ), + f"camoufox: {len(getattr(config, '_camoufox_skiplist', []))} skiplist entries " + f"from {getattr(config, '_camoufox_skiplist_path', '(not loaded)')}", + ] + if shard: + lines.append(f"camoufox: shard {shard[0]} of {shard[1]}") + return lines + + +def pytest_terminal_summary(terminalreporter, exitstatus, config) -> None: # noqa: ANN001 + """Say what was skipped and why, so the list stays visible rather than silent.""" + skipped: Dict[str, str] = getattr(config, "_camoufox_skipped", {}) + if not skipped: + return + by_reason: Dict[str, int] = {} + for reason in skipped.values(): + by_reason[reason] = by_reason.get(reason, 0) + 1 + terminalreporter.write_sep("-", f"camoufox skiplist: {len(skipped)} test(s) deselected") + for reason, count in sorted(by_reason.items(), key=lambda kv: -kv[1]): + terminalreporter.write_line(f" {count:>4} {' '.join(reason.split())[:150]}") diff --git a/ci/requirements.txt b/ci/requirements.txt new file mode 100644 index 000000000..b4c81799d --- /dev/null +++ b/ci/requirements.txt @@ -0,0 +1,8 @@ +# The CI pipeline itself. Individual suites install their own dependencies: +# ci/suite.py builds a venv from the fetched Playwright checkout's own pins, +# and build-tester/requirements.txt covers the binary tests. +PyYAML>=6.0 +pytest>=8.0 +pytest-timeout>=2.3 +pytest-asyncio>=0.21 +psutil>=5.9 diff --git a/ci/results.py b/ci/results.py new file mode 100644 index 000000000..5f3f5b935 --- /dev/null +++ b/ci/results.py @@ -0,0 +1,132 @@ +"""The evidence bundle: the only thing allowed to decide whether a run is green. + +Every gate writes one JSON file here and nothing else. `ci/summarize.py` reads +those files and computes the run's verdict; no gate reports its own verdict to +the pull request. (The auto-update harness, which lives outside this repository, +consumes the same files against a stored baseline -- which is why the records +carry per-test identities rather than just counts.) + +Two properties do the real work: + + * A required gate with no evidence file is a FAILURE, not a skip. Deleting or + short-circuiting a gate cannot make a run pass. + * Every record is stamped with the current run id. A stale file left over + from an earlier run does not satisfy a gate. + +The one deliberate exception is a gate that records SKIP for a stated reason -- +currently only the stealth check, when sundial itself is unreachable. That is +tolerated only for suites the workflow names in `--allow-skip`, and it is still +not a pass: the summary shows it as skipped, with the reason. +""" + +from __future__ import annotations + +import os +import platform +import socket +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from ._util import EVIDENCE_DIR, log, read_json, write_json + +SCHEMA = 1 + +PASS = "pass" +FAIL = "fail" +ERROR = "error" +SKIP = "skip" + + +def run_id() -> str: + """Identity of the current run, so stale evidence cannot be reused.""" + return ( + os.environ.get("HARNESS_RUN_ID") + or os.environ.get("GITHUB_RUN_ID", "") + + ("-" + os.environ.get("GITHUB_RUN_ATTEMPT", "") if os.environ.get("GITHUB_RUN_ATTEMPT") else "") + or f"local-{socket.gethostname()}-{os.getpid()}" + ) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +@dataclass +class GateResult: + """One gate's findings. + + `tests` is the part that matters for regression detection: a mapping of + stable test identity -> outcome. Identities, not counts, are what the + baseline compares, because a suite whose totals match can still have + swapped which tests pass. + """ + + gate: str + status: str = ERROR + started: str = field(default_factory=_now) + finished: Optional[str] = None + tests: Dict[str, str] = field(default_factory=dict) + metrics: Dict[str, Any] = field(default_factory=dict) + notes: List[str] = field(default_factory=list) + artifacts: List[str] = field(default_factory=list) + run_id: str = field(default_factory=run_id) + schema: int = SCHEMA + host: str = field(default_factory=lambda: f"{platform.system()}-{platform.machine()}") + + def note(self, msg: str) -> None: + log(f"[{self.gate}] {msg}") + self.notes.append(msg) + + def record(self, test_id: str, outcome: str) -> None: + # A test that ran twice (retries) keeps its best outcome: a test that + # passes on any attempt is not a regression. + prior = self.tests.get(test_id) + if prior == PASS: + return + self.tests[test_id] = outcome + + def tally(self) -> Dict[str, int]: + out: Dict[str, int] = {} + for outcome in self.tests.values(): + out[outcome] = out.get(outcome, 0) + 1 + out["total"] = len(self.tests) + return out + + def finish(self, status: str) -> "GateResult": + self.status = status + self.finished = _now() + if self.tests: + self.metrics.setdefault("tally", self.tally()) + return self + + def save(self, directory: Optional[Path] = None) -> Path: + directory = directory or EVIDENCE_DIR + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{self.gate}.json" + write_json(path, asdict(self)) + log(f"[{self.gate}] evidence -> {path} ({self.status}, {len(self.tests)} tests)") + return path + + +def load(gate: str, directory: Optional[Path] = None) -> Optional[Dict[str, Any]]: + directory = directory or EVIDENCE_DIR + path = directory / f"{gate}.json" + if not path.exists(): + return None + return read_json(path) + + +def load_all(directory: Optional[Path] = None) -> Dict[str, Dict[str, Any]]: + directory = directory or EVIDENCE_DIR + if not directory.exists(): + return {} + out: Dict[str, Dict[str, Any]] = {} + for path in sorted(directory.glob("*.json")): + if path.name.startswith("_"): + continue + data = read_json(path, default={}) + if isinstance(data, dict) and data.get("gate"): + out[data["gate"]] = data + return out diff --git a/ci/run_build.py b/ci/run_build.py new file mode 100644 index 000000000..b9d17f6a7 --- /dev/null +++ b/ci/run_build.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Build gate: ./mach build for linux x86_64. + +Records the compiler's verdict as evidence in its own right. A tree whose +patches all apply but which does not compile is the single most common outcome +of a Firefox bump, and it has to be a named gate rather than an implicit +precondition -- otherwise a build failure looks like "the test gates did not +run" and reads as a skip. + +Run: + python3 -m ci.run_build +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from typing import List, Optional + +from . import results as evidence +from ._util import EVIDENCE_DIR, REPO_ROOT, read_upstream_sh, run +from ._pytest import built_binary + +# mach prints errors in a few shapes; catch the common ones for the summary. +_ERROR_RE = re.compile( + r"^(?:.*?:\d+:\d+: (?:fatal )?error: .*|error\[E\d+\].*|.*\berror: .*)$", + re.MULTILINE, +) + + +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("--timeout", type=int, default=18000) + parser.add_argument("--log", type=Path, help="also tee the build log here") + args = parser.parse_args(argv) + + up = read_upstream_sh() + result = evidence.GateResult(gate="build") + result.metrics.update(version=up.get("version"), release=up.get("release"), target="linux-x86_64") + + proc = run(["make", "build"], cwd=REPO_ROOT, timeout=args.timeout, tee=True, capture=False) + + binary = built_binary() + result.metrics["exit_code"] = proc.code + result.metrics["binary"] = str(binary) + + if proc.code == 0 and binary.exists(): + size_mb = binary.stat().st_size / (1024 * 1024) + result.metrics["binary_size_mb"] = round(size_mb, 1) + result.note(f"built {binary.name} ({size_mb:.1f} MB)") + result.finish(evidence.PASS).save(args.evidence_dir) + return 0 + + if proc.code == 0 and not binary.exists(): + result.note( + f"mach reported success but {binary} does not exist. Treating as a failure: " + "every later gate would otherwise silently test nothing." + ) + result.finish(evidence.FAIL).save(args.evidence_dir) + return 1 + + errors = _ERROR_RE.findall(proc.combined())[:15] + result.note(f"mach build exited {proc.code}") + for line in errors: + result.note(line.strip()[:300]) + result.metrics["error_count"] = len(errors) + result.finish(evidence.FAIL).save(args.evidence_dir) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/run_build_tester.py b/ci/run_build_tester.py new file mode 100644 index 000000000..9e01846ac --- /dev/null +++ b/ci/run_build_tester.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""build-tester gate: the raw binary, 8 fingerprint profiles, graded per check. + +Grades per individual check rather than per profile, so the evidence carries +~hundreds of stable identities and a baseline comparison can say "this exact +check passed on the last release and does not now" instead of "the grade dropped +from A to B". (Nothing in this repository holds that baseline; the auto-update +harness does. Here the identities make a failure legible.) + +Run: + python3 -m ci.run_build_tester --binary /path/to/camoufox-bin +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Dict, List, Optional + +from . import results as evidence +from ._util import CI_DIR, RESULTS_DIR, REPO_ROOT, WORK_DIR, read_json, run + +BUILD_TESTER = REPO_ROOT / "build-tester" +CONFIG_PATH = CI_DIR / "build-tester.yml" + +# Cross-profile uniqueness, split by what each slot actually promises. Treating +# them alike made the gate fail a run that scored 1054/1054: it counted three +# macOS contexts all reporting "MacIntel" as three collisions, which is the +# correct answer to a question nobody asked. +# +# Values Camoufox derives per context. Two contexts sharing one is the leak +# this whole suite exists to catch, so a single collision here is fatal. +_MUST_VARY = ("uniqueAudio", "uniqueTimezones") + +# Canvas belongs in _MUST_VARY and is not there yet, because it does not +# currently hold. Measured across 24 profiles in 3 runs against +# v152.0.4-beta.30, once the canvas fingerprint was actually being hashed +# (it had been a 100-character prefix of the data URL, which compared equal +# for almost anything): +# +# audio 24 distinct / 24 samples every one unique +# canvas 16 distinct / 24 samples values recurring across runs +# macOS 7/12, Linux 9/12 one value seen three times +# +# Same hash, same harness, same runs -- so this is not the measurement. Two +# contexts share a canvas fingerprint roughly a third of the time, which matches +# the rate at which CI flagged it. Gating on it would fail about one run in +# three for a real reason nobody has fixed yet, so it is reported every run and +# tracked here rather than quietly dropped or quietly tolerated. +# +# See ci/tribal-rules.yml: canvas-noise-entropy-is-lower-than-audio. +_TRACKED_LOW_ENTROPY = ("uniqueCanvas",) + +# Values drawn from the preset pool. Three draws from a pool of a dozen collide +# regularly -- that is the birthday paradox, not a leak, and the pools are +# deliberately small because they hold real devices. Counted and reported, +# never fatal on their own. +_MAY_COLLIDE = ("uniqueFonts", "uniqueScreens", "uniqueVoices", "uniqueWebGL") + +# Properties of the operating system. Every macOS context reports MacIntel and +# every Linux one reports Linux x86_64, because that is what those systems +# report. Here a collision is the correct outcome and *variation* would be the +# bug, so it is asserted in the opposite direction. +_MUST_MATCH = ("uniquePlatforms",) + + +# A renderer string that names a software rasteriser. The page-side +# headlessDetection/noSwiftShader check flags these, because a browser reporting +# one is usually a headless browser on a machine with no GPU. +_SOFTWARE_RENDERERS = ("swiftshader", "llvmpipe", "software") + + +def _is_software_renderer(value: Optional[str]) -> bool: + low = (value or "").lower() + return any(token in low for token in _SOFTWARE_RENDERERS) + + +def check_passed(meta: dict, section: str, category: str, check: str, payload: dict) -> bool: + """Did this check pass, judged against what the profile actually asked for? + + Almost always just `payload["passed"]`. The exception is + extended/headlessDetection/noSwiftShader, which reads the WebGL renderer and + fails on a software rasteriser -- a sound headless signal in general, and the + wrong question to ask here. Camoufox's own fingerprint pool contains real + Linux machines whose renderer IS llvmpipe (`fingerprint-presets-v150.json` + ships "llvmpipe, or similar"), so when such a profile is drawn, reporting + llvmpipe is the spoof working exactly as instructed -- and reporting anything + else would be the bug. Grading it as a failure made this gate fail at random, + depending on which presets that run's `generate_context_fingerprint()` drew. + + So the check fails only when the browser reports a software renderer the + profile did not ask for, which is the case that would really mean the WebGL + spoof had fallen through to the host. + """ + passed = bool(payload.get("passed")) + if passed: + return True + if (section, category, check) == ("extended", "headlessDetection", "noSwiftShader"): + return _is_software_renderer(meta.get("webglRenderer")) + return False + + +def flatten(full: dict) -> Dict[str, str]: + """The whole result tree -> {check_id: pass|fail}.""" + tests: Dict[str, str] = {} + for profile in full.get("profiles") or []: + meta = profile.get("profile") or {} + # Identify by os+mode+index, never by the display name, which carries a + # random letter suffix and would churn every run. + slot = f"{meta.get('os', '?')}-{meta.get('mode', '?')}-{meta.get('index', profile.get('index', 0))}" + results = profile.get("results") or {} + if profile.get("error"): + tests[f"{slot}/launch"] = evidence.ERROR + continue + tests[f"{slot}/launch"] = evidence.PASS + + for section in ("core", "extended", "workers", "selfDestruct"): + categories = results.get(section) or {} + if not isinstance(categories, dict): + continue + for category, checks in categories.items(): + if not isinstance(checks, dict): + continue + for check, payload in checks.items(): + if not isinstance(payload, dict) or not isinstance(payload.get("passed"), bool): + continue + tid = f"{slot}/{section}/{category}/{check}" + ok = check_passed(meta, section, category, check, payload) + tests[tid] = evidence.PASS if ok else evidence.FAIL + + webrtc = results.get("webrtc") or {} + if webrtc: + tests[f"{slot}/webrtc"] = evidence.PASS if webrtc.get("passed") else evidence.FAIL + stability = results.get("stability") or {} + if stability: + tests[f"{slot}/stability"] = evidence.PASS if stability.get("stable") else evidence.FAIL + for match in profile.get("matchResults") or []: + name = match.get("name") or match.get("key") or "match" + tests[f"{slot}/match/{name}"] = evidence.PASS if match.get("passed") else evidence.FAIL + return tests + + +def category_failures(full: dict, required: List[str]) -> Dict[str, int]: + """Failing check counts for the categories policy insists must be clean.""" + wanted = {c.lower() for c in required} + out: Dict[str, int] = {} + for profile in full.get("profiles") or []: + meta = profile.get("profile") or {} + results = profile.get("results") or {} + for section in ("core", "extended", "workers", "selfDestruct"): + for category, checks in (results.get(section) or {}).items(): + if category.lower() not in wanted or not isinstance(checks, dict): + continue + for check, payload in checks.items(): + if not isinstance(payload, dict) or payload.get("passed") is not False: + continue + # Same judgement flatten() makes, so the required-category + # gate and the per-check evidence cannot disagree. + if not check_passed(meta, section, category, check, payload): + out[category] = out.get(category, 0) + 1 + return out + + +def uniqueness(full: dict) -> Dict[str, List[str]]: + """Sort the cross-profile slots into leaks, noise, and things not measured. + + Returns {"leaks": [...], "noise": [...], "absent": [...], "not_constant": [...]}. + Only `leaks` and `not_constant` should fail a build. + """ + out: Dict[str, List[str]] = { + "leaks": [], "noise": [], "low_entropy": [], "absent": [], "not_constant": [] + } + + for group, stats in (full.get("crossProfile") or {}).items(): + total = stats.get("total") or 0 + if total < 2: + continue + + def describe(key: str) -> str: + return f"{group}.{key} ({stats.get(key)}/{total} distinct)" + + for key in _MUST_VARY + _MAY_COLLIDE + _TRACKED_LOW_ENTROPY: + value = stats.get(key) + if not isinstance(value, int): + continue + if value == 0: + # Nothing was gathered -- no speech voices under a headless + # session, say. "Zero distinct" is absence, and counting it as a + # collision reports a leak where there is no data at all. + out["absent"].append(describe(key)) + elif value < total: + if key in _MUST_VARY: + bucket = "leaks" + elif key in _TRACKED_LOW_ENTROPY: + bucket = "low_entropy" + else: + bucket = "noise" + out[bucket].append(describe(key)) + + for key in _MUST_MATCH: + value = stats.get(key) + if isinstance(value, int) and value > 1: + out["not_constant"].append(describe(key)) + + return out + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path) + parser.add_argument("--evidence-dir", type=Path, default=RESULTS_DIR) + parser.add_argument("--timeout", type=int, default=3600) + args = parser.parse_args(argv) + + import yaml + + with open(CONFIG_PATH, encoding="utf-8") as fh: + cfg = yaml.safe_load(fh) or {} + + from ._pytest import require_binary + + result = evidence.GateResult(gate="build_tester") + out_json = WORK_DIR / "build-tester-result.json" + + try: + binary = args.binary or require_binary() + except FileNotFoundError as exc: + result.note(str(exc)) + result.finish(evidence.ERROR).save(args.evidence_dir) + return 1 + + proc = run( + [ + sys.executable, "scripts/run_tests.py", str(binary), + "--profile-count", str(cfg.get("profile_count", 8)), + "--json", str(out_json), + "--no-cert", + ], + cwd=BUILD_TESTER, + timeout=args.timeout, + tee=True, + capture=False, + ) + + if not out_json.exists(): + result.note( + f"build-tester exited {proc.code} without writing {out_json.name}. " + "It crashed before grading; treat this as a failure, not a flake." + ) + result.finish(evidence.ERROR).save(args.evidence_dir) + return 1 + + full = read_json(out_json) + result.tests = flatten(full) + result.artifacts.append(out_json.name) + result.metrics.update( + overall_grade=full.get("overallGrade"), + total_passed=full.get("totalPassed"), + total_checks=full.get("totalChecks"), + cross_profile=full.get("crossProfile"), + exit_code=proc.code, + ) + + status = evidence.PASS + # Rules that hold regardless of what the baseline looked like. verify.py + # fails the run on these even when the previous release was equally dirty. + violations: List[str] = [] + + failures = category_failures(full, cfg.get("required_categories") or []) + if failures: + pretty = ", ".join(f"{k}: {v}" for k, v in sorted(failures.items())) + result.note(f"categories policy requires clean have failing checks -- {pretty}") + violations.append(f"categories that must be clean have failing checks: {pretty}") + status = evidence.FAIL + + slots = uniqueness(full) + allowed = int(cfg.get("allow_uniqueness_collisions", 0)) + result.metrics["uniqueness"] = slots + + if slots["low_entropy"]: + result.note( + "KNOWN, UNFIXED -- per-context values that collide more often than they should: " + + ", ".join(slots["low_entropy"]) + + ". Measured 16 distinct canvas fingerprints in 24 samples where audio gave " + "24/24, so two contexts are linkable by canvas roughly a third of the time. " + "Reported every run, not gated, because it is real and unfixed. See " + "ci/tribal-rules.yml: canvas-noise-entropy-is-lower-than-audio." + ) + + if slots["noise"]: + result.note( + f"{len(slots['noise'])} preset-pool collision(s), not gated: " + + ", ".join(slots["noise"]) + ) + if slots["absent"]: + result.note( + f"{len(slots['absent'])} slot(s) collected nothing: " + ", ".join(slots["absent"]) + ) + + if slots["leaks"]: + result.note( + f"{len(slots['leaks'])} per-context value(s) shared between contexts: " + + ", ".join(slots["leaks"]) + + f" (policy tolerates {allowed})" + ) + if len(slots["leaks"]) > allowed: + violations.append( + "per-context values shared between contexts, which is the leak this suite " + "exists to catch: " + ", ".join(slots["leaks"]) + ) + status = evidence.FAIL + + if slots["not_constant"]: + result.note("OS-constant value varied between contexts: " + ", ".join(slots["not_constant"])) + violations.append( + "a value that is a property of the operating system differed between contexts of " + "the same OS: " + ", ".join(slots["not_constant"]) + ) + status = evidence.FAIL + + result.metrics["policy_violations"] = violations + + result.note( + f"grade {full.get('overallGrade')}, {full.get('totalPassed')}/{full.get('totalChecks')} checks, " + f"{len(result.tests)} identities recorded" + ) + 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/run_native.py b/ci/run_native.py new file mode 100644 index 000000000..a37a4bff5 --- /dev/null +++ b/ci/run_native.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Camoufox's own suite: leaks, context-vs-browser semantics, and settled decisions. + +Everything the Playwright suites cannot ask about. Split in two so the cheap +half gives fast feedback: + + --subset rules no browser needed. Asserts the decisions in + ci/tribal-rules.yml are still in force -- runs in seconds + in the static job and fails a pull request before anyone + waits 40 minutes for a build. + + --subset browser needs a built binary. Launches browsers, kills them, and + proves nothing was left behind; checks that a context and a + browser mean what the project says they mean. + +Run: + python3 -m ci.run_native --subset rules + python3 -m ci.run_native --subset browser --binary path/to/camoufox-bin +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from typing import List, Optional + +from . import results +from ._pytest import parse_junit, run_pytest +from ._util import REPO_ROOT, RESULTS_DIR, WORK_DIR + +SUITE_DIR = REPO_ROOT / "native-tests" + +FILES = { + "rules": ["test_tribal_rules.py"], + "browser": [ + "test_no_leaks.py", + "test_contexts_vs_browsers.py", + "test_crash_recovery.py", + ], + # Slow by construction: each mechanism is churned twice, at n and 4n, to + # measure whether growth scales with the count. Kept out of "browser" so a + # pull request is not waiting on it, and run on its own schedule. + "growth": ["test_memory_growth.py"], +} + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--subset", choices=["rules", "browser", "growth", "all"], default="all" + ) + parser.add_argument("--binary", type=Path) + parser.add_argument("--results-dir", type=Path, default=RESULTS_DIR) + parser.add_argument("--rounds", type=int, default=3, help="launch/close rounds for leak tests") + parser.add_argument("--browsers", type=int, default=3, help="concurrent browsers to launch") + parser.add_argument("--timeout", type=int, default=3600) + args = parser.parse_args(argv) + + name = "native" if args.subset == "all" else f"native_{args.subset}" + result = results.GateResult(gate=name) + result.metrics["subset"] = args.subset + + files = ( + [f for group in FILES.values() for f in group] + if args.subset == "all" + else FILES[args.subset] + ) + + env = { + # native-tests/conftest.py puts pythonlib on sys.path itself; this is + # for the browser half, which needs a binary to point at. + "PYTHONPATH": os.pathsep.join( + filter(None, [str(REPO_ROOT / "pythonlib"), os.environ.get("PYTHONPATH", "")]) + ), + } + if args.subset != "rules": + binary = args.binary + if binary is None: + from ._pytest import built_binary + + binary = built_binary() + if not binary.exists(): + result.note( + f"no built binary at {binary}. The browser half of this suite cannot run, " + "and a suite that did not run has not passed." + ) + result.finish(results.ERROR).save(args.results_dir) + return 1 + env["CAMOUFOX_EXECUTABLE_PATH"] = str(binary.resolve()) + result.metrics["binary"] = str(binary) + + junit = WORK_DIR / f"junit-{name}.xml" + proc = run_pytest( + cwd=SUITE_DIR, + python=Path(sys.executable), + args=[ + *files, + "--rounds", str(args.rounds), + "--browsers", str(args.browsers), + # This suite is run from several places against several binaries; + # a .pytest_cache left in the tree would make --last-failed and + # friends carry state between them. + "-p", "no:cacheprovider", + ], + junit=junit, + env=env, + timeout=args.timeout, + # A leak round launches several browsers and waits for them to settle; + # the default 180s per test is too tight for that. + per_test_timeout=900, + ) + + outcomes = parse_junit(junit) + if not outcomes: + result.note(f"pytest exited {proc.code} with no junit output; the suite did not run") + result.finish(results.ERROR).save(args.results_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) + status = results.PASS if failing == 0 else results.FAIL + result.finish(status).save(args.results_dir) + return 0 if status == results.PASS else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/run_patch_guards.py b/ci/run_patch_guards.py new file mode 100644 index 000000000..60a195bc7 --- /dev/null +++ b/ci/run_patch_guards.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Patch-guard gate: tests/patches/*.py, one standalone guard per shipped behaviour. + +These are the assertions that each spoofing patch still does what it claims -- +isolated evaluate, trusted events, font spoofing, mouse trajectories and so on. +They are the most direct evidence that a Firefox bump did not quietly neuter a +patch that still applies cleanly, which is the failure mode a compile check +cannot catch. + +Each guard is a standalone script exiting 0 or 1. Policy allows zero failures. + +Run: + python3 -m ci.run_patch_guards --binary /path/to/camoufox-bin +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from typing import List, Optional + +from . import results as evidence +from ._util import EVIDENCE_DIR, REPO_ROOT, log, run + +GUARD_DIR = REPO_ROOT / "tests" / "patches" + + +def guards() -> List[Path]: + """Every guard script. helpers.py is a library, not a guard.""" + return sorted(p for p in GUARD_DIR.glob("*.py") if p.name != "helpers.py") + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path) + parser.add_argument("--evidence-dir", type=Path, default=EVIDENCE_DIR) + parser.add_argument("--timeout", type=int, default=600, help="per guard") + parser.add_argument("--only", nargs="*", help="run only these guard names") + args = parser.parse_args(argv) + + from ._pytest import built_binary + + result = evidence.GateResult(gate="patch_guards") + binary = args.binary or built_binary() + if not binary.exists(): + result.note(f"no built binary at {binary}") + result.finish(evidence.ERROR).save(args.evidence_dir) + return 1 + + env = { + "CAMOUFOX_EXECUTABLE_PATH": str(binary), + # The guards drive the browser through the Python package, which resolves + # the binary from this variable rather than a packaged install. + "PYTHONPATH": os.pathsep.join( + filter(None, [str(REPO_ROOT / "pythonlib"), os.environ.get("PYTHONPATH", "")]) + ), + } + + selected = [g for g in guards() if not args.only or g.stem in args.only] + if not selected: + result.note("no guards found -- tests/patches/ is empty or the filter matched nothing") + result.finish(evidence.ERROR).save(args.evidence_dir) + return 1 + + failed: List[str] = [] + for guard in selected: + proc = run([sys.executable, str(guard)], cwd=REPO_ROOT, env=env, timeout=args.timeout) + outcome = evidence.PASS if proc.ok else evidence.FAIL + result.record(f"patches/{guard.name}", outcome) + if not proc.ok: + failed.append(guard.name) + tail = proc.combined().strip().splitlines()[-6:] + result.note(f"{guard.name} failed ({proc.code}): " + " | ".join(t.strip() for t in tail)) + else: + log(f" ✓ {guard.name}") + + passed = len(selected) - len(failed) + result.note(f"{passed}/{len(selected)} guards passed") + status = evidence.PASS if not failed 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/run_playwright.py b/ci/run_playwright.py new file mode 100644 index 000000000..06f45a0d5 --- /dev/null +++ b/ci/run_playwright.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +"""Run the Playwright suite against a Camoufox build. + +One suite: playwright-python's own tests, fetched fresh at the tag +`ci/versions.py` resolved for this browser, run unmodified with +`ci/skiplist.yml` applied, plus the Camoufox-specific modules `ci/suite.py` +overlays from `tests/camoufox/`. + +This is the conformance check -- does Camoufox still honour the automation +contract its users hold it to -- and, through the overlay, the regression check +for the behaviours that are ours alone. Shardable. + +**Isolated first, main world as a counted fallback.** Each group is run up to +three times, and normally twice: + + 1. isolated world -- the configuration Camoufox actually ships. `evaluate()` + runs in its own compartment, so a test that reads a global its page script + defined fails here by design. Upstream's own pytest-rerunfailures has + already retried anything that failed, so what arrives at 2 is settled. + 2. those failures with isolation off. A test that passes now is recorded as a + **main-world fallback**: it counts as a pass for the run, and is named and + counted in the result so the size of that set is visible and comparable + between runs. A change in it means the isolated-world conformance gap + moved, which is a fact about the browser worth seeing. + 3. only what failed in BOTH worlds, retried once. That set is normally empty, + so this normally costs nothing. + +Running main-world-only (the previous behaviour) hid that number entirely. A +test failing in both worlds and on retry is a plain failure. + +Run: + python3 -m ci.run_playwright --binary path/to/camoufox-bin + python3 -m ci.run_playwright --binary path/to/camoufox-bin --shard 3/6 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Dict, List, NamedTuple, Optional, Tuple + +from . import results +from ._pytest import parse_junit, require_binary, run_pytest +from ._util import REPO_ROOT, RESULTS_DIR, WORK_DIR, log +from .pw_camoufox_plugin import ISOLATED_WORLD, MAIN_WORLD +from .suite import prepare +from .versions import resolve + +# What "the suite" means. Named explicitly rather than pointed at `tests/`, +# because the one thing deliberately left out has to be visible. +# +# This used to be `tests/async/` alone, which excluded 722 tests -- 31% of the +# suite -- with nothing recorded anywhere to say so. That was not a decision: +# the vendored fork in tests/ carried `async/` and `async_imp/` and no sync +# suite, and this runner was pointed at the same shape without checking what +# upstream had. The sync tests were never incompatible; they had simply never +# been run. +class Group(NamedTuple): + """A set of paths that share one pytest process, and whether it shards.""" + + targets: Tuple[str, ...] + sharded: bool + + +# Each group gets its OWN pytest process. This is not tidiness: upstream's sync +# suite is a greenlet wrapper and its async suite runs under pytest-asyncio, and +# putting them in one process breaks the loop for whichever runs second -- +# +# RuntimeError: Runner.run() cannot be called from a running event loop +# +# Measured: async alone, 1526 passed / 1 timing flake. async + one sync module, +# 14 failed. Sync first, 46 errors. The damage lands in async fixture setup, so +# it reads as "the fetch tests are flaky" rather than as a harness fault, and the +# retry logic quietly hides it -- 50 tests passed only on retry before this split. +# +# tests/common/ and test_reference_count_async.py each start their own Playwright +# inside the test body, which cannot happen while session fixtures hold a loop. +# They are fine together (6 passed) but not with the suites above. +GROUPS: Tuple[Group, ...] = ( + Group(("tests/async/",), sharded=True), + # The sync API is a greenlet wrapper over the same Juggler traffic, so much of + # this duplicates tests/async/ at the protocol level. It is here because + # pythonlib ships a sync API that users drive, and the wrapper has its own + # timeout and reentrancy behaviour the async tests cannot reach. + Group(("tests/sync/",), sharded=True), + # Six tests. Not sharded: splitting them would hand some shard an empty + # selection, which pytest exits 5 for. Kept because ProtocolCallback objects + # accumulate when the browser never replies to a protocol message, and this + # fork patches Juggler heavily, so that leak can be ours. + Group(("tests/common/", "tests/test_reference_count_async.py"), sharded=False), +) + +TARGETS: Tuple[str, ...] = tuple(t for g in GROUPS for t in g.targets) + + +# The isolated pass is a classifier: its only question is "does this test pass +# as Camoufox ships?". Both settings below bound what a "no" is allowed to cost, +# and neither applies to the passes that adjudicate afterwards. +# +# Some isolated failures do not fail -- they HANG, because the waits involved +# (a Twisted future from the test server, an asyncio future a binding was meant +# to resolve) have no Playwright timeout behind them. Everything else that +# isolation breaks fails at Playwright's 30s. +# +# The bound below catches the ones it can. The ones it cannot are declared in +# ISOLATION_HANGS and never reach this pass -- see the note there, which is also +# where the reason they hang is written down. +# +# 90s, against a measured worst case of 30.4s across all 2295 tests in the +# main-world baseline (only two exceeded 30s, none exceeded 45s) and a 30s +# Playwright action timeout. Three times the slowest thing that legitimately +# happens, and half the default. +ISOLATED_TIMEOUT = 90 + +# upstream's tests/conftest.py sets `reruns = 3` whenever $CI is set, which is +# the only thing it reads $CI for. Insurance that almost never pays out -- the +# main-world baseline recorded 2 reruns across all 2295 tests -- and under +# isolation it turns every deterministic world difference into four attempts: +# 138 reruns in one shard's isolated pass, recovering nothing, at up to 180s +# each for the ones that hang. A flake missed here is not lost; it fails the +# isolated pass, passes pass 2, and is counted as a fallback. +_NO_UPSTREAM_RERUNS = {"CI": ""} + + +# Isolation does not fail these -- it HANGS them, and unlike everything else in +# this file that is not a duration that can be tuned down. +# +# Measured on run 34799668707, with ISOLATED_TIMEOUT already at 90s: +# +# tests/async/ isolated pass completed in 296s. The bound works. +# tests/sync/ test_should_work_with_ws_close printed pytest-timeout's +# "+++ Timeout +++" banner at exactly 90s -- and the process +# then sat there for the remaining 1h50m, until the job's +# timeout-minutes killed it. +# +# So the signal fires and the test dies; the PROCESS does not. pytest-timeout's +# signal method raises at the next bytecode boundary, and Playwright's sync API +# is parked in a greenlet switch that never reaches one cleanly -- the raise +# lands inside the dispatcher and wedges it. `--timeout-method=thread` would +# fire, but it kills the interpreter outright and takes the other ~1500 tests in +# the group with it. There is no per-test value that bounds this. +# +# Hence declared rather than discovered. The isolated pass cannot find out that +# these hang without hanging, so it is told, and they are run in the main world +# directly -- where they pass, and where they are counted as fallbacks exactly +# as if isolation had failed them honestly. Coverage is not lost: the same tests +# run, in the world that can run them. +# +# Why not ci/skiplist.yml, which is where tests Camoufox cannot pass live: that +# list means "fails in the most permissive world", and run_skiplist_audit.py +# enforces it by running every entry with CI_WORLD=main and failing the build on +# any that pass. These pass there. An entry would be rejected by the audit, and +# would be wrong on its own terms. +# +# The cause is real and is not a test artifact: page.route_web_socket() works by +# replacing window.WebSocket from an init script, which under isolation lands in +# the sandbox, so a socket the page's own script opens is never intercepted and +# the handler never fires. A user gets no interception and no error. Tracked in +# https://github.com/daijro/camoufox/issues/775 -- when that is fixed these stop +# hanging under isolation and this list goes with it. +ISOLATION_HANGS: Tuple[str, ...] = ( + "tests/async/test_route_web_socket.py", + "tests/sync/test_route_web_socket.py", +) + + +# Left out on purpose, with the reason, so "not run" is never merely implied. +EXCLUDED = { + "tests/test_installation.py": ( + "pip-installs playwright into a scratch environment to check packaging. " + "That exercises Playwright's own release process, not this browser." + ), +} + + +def unclaimed(checkout: Path) -> List[str]: + """Test paths upstream ships that TARGETS neither runs nor EXCLUDED names. + + Upstream is free to add a directory, and the failure mode is silence: the + suite quietly gets narrower and the total still looks healthy. This is the + same hole the skiplist had one level down, so it gets the same treatment -- + a new subtree fails the run until somebody decides about it. + """ + claimed = {t.rstrip("/") for t in TARGETS} | set(EXCLUDED) + root = checkout / "tests" + missed: List[str] = [] + for child in sorted(root.iterdir()): + rel = f"tests/{child.name}" + if rel in claimed: + continue + if child.is_dir(): + # Only directories that actually hold tests; assets/ and golden-*/ + # are fixtures. + if any(child.glob("test_*.py")): + missed.append(rel + "/") + elif child.name.startswith("test_") and child.suffix == ".py": + missed.append(rel) + return missed + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path) + parser.add_argument("--browser-version", help="passed through to ci.versions") + parser.add_argument("--playwright-tag", help="pin the suite instead of resolving one") + parser.add_argument("--shard", help="e.g. 3/6") + parser.add_argument("--results-dir", type=Path, default=RESULTS_DIR) + parser.add_argument("--name", help="result file name; defaults to playwright[-shard]") + # The backstop for a hang nothing else bounds, so it has to be shorter than + # the job's timeout-minutes or it can never fire: at 10800 (3h) against a + # 120-minute job, GitHub hard-killed the runner first and the junit and + # diagnostics uploads went with it. This is per pytest invocation, not per + # job. The slowest healthy one measured is 296s, so 20 minutes is roughly + # four times the real worst case -- long enough never to cut a slow-but- + # working group short, short enough that a wedge costs minutes. + parser.add_argument("--group-timeout", type=int, default=1200) + parser.add_argument("--retries", type=int, default=1, help="rerun failures this many times") + parser.add_argument("--headful", action="store_true") + args = parser.parse_args(argv) + + suffix = f"-{args.shard.replace('/', 'of')}" if args.shard else "" + name = args.name or f"playwright{suffix}" + result = results.GateResult(gate=name) + + try: + binary = args.binary or require_binary() + except FileNotFoundError as exc: + result.note(str(exc)) + result.finish(results.ERROR).save(args.results_dir) + return 1 + + env = {"CAMOUFOX_EXECUTABLE_PATH": str(binary.resolve())} + + versions = resolve( + browser_version=args.browser_version, playwright_tag=args.playwright_tag + ) + tag = versions["playwright_tag"] + result.metrics.update( + playwright_tag=tag, + playwright_firefox=versions["playwright_firefox"], + browser_version=versions["browser_version"], + ) + + manifest = prepare(tag) + cwd = Path(manifest["checkout"]) + python = Path(manifest["python"]) + result.metrics["camoufox_tests"] = len(manifest.get("camoufox_tests", [])) + + missed = unclaimed(cwd) + if missed: + result.note( + f"{tag} ships test paths this runner neither runs nor excludes: " + + ", ".join(missed) + + ". Add them to TARGETS, or to EXCLUDED with a reason. Refusing to report a " + "pass over a suite that quietly got narrower." + ) + result.finish(results.ERROR).save(args.results_dir) + return 1 + + base_args = ["-p", "pw_camoufox_plugin", "--browser", "firefox"] + if args.headful: + base_args.append("--headed") + + # The plugin reads the skiplist from the repository, not the fetched + # checkout, so a local edit takes effect without re-preparing. + env["CI_SKIPLIST"] = str(REPO_ROOT / "ci" / "skiplist.yml") + if args.shard: + result.metrics["shard"] = args.shard + + first_shard = not args.shard or args.shard.split("/")[0] == "1" + outcomes: Dict[str, str] = {} + ran: List[Group] = [] + # Still failing under isolation once flakes are excluded -- the set handed + # to the main-world pass. + isolated_failures: List[str] = [] + # ...and the subset of those that passed with isolation off. + fallbacks: List[str] = [] + fallback_junits: List[str] = [] + last_code = 0 + + for index, group in enumerate(GROUPS): + if not group.sharded and not first_shard: + continue + group_env = dict(env) + if args.shard and group.sharded: + group_env["CI_SHARD"] = args.shard + + # One pytest cache per group, never the checkout's shared default. + # `--last-failed` below reads it, and pytest's `lastfailed` accumulates + # across every run sharing a cache -- it only drops an entry when that + # test is collected again and passes. With one cache for the whole + # checkout, the async group's rerun would be selecting from a set the + # sync group had also written into: pytest keeps the entries it did not + # collect, so the selection was either "everything in this group" + # (when this group had no failures of its own, since pytest declines to + # filter when nothing selected previously failed) or nothing at all. + # Per-group, `--last-failed` means exactly what it says. + cache_dir = WORK_DIR / f"pytest-cache{suffix}" / str(index) + common = [*base_args, "-o", f"cache_dir={cache_dir}"] + targets = ", ".join(group.targets) + + # Modules isolation hangs rather than fails. Deselected from the pass + # below, because a hang there is not bounded by anything (ISOLATION_HANGS). + hangs = [m for m in ISOLATION_HANGS if any(m.startswith(t) for t in group.targets)] + + # --- 1. isolated world: the browser as it ships -------------------- + log(f"group {index + 1}/{len(GROUPS)}: {targets} [{ISOLATED_WORLD} world]") + group_junit = WORK_DIR / f"junit{suffix}-{index}.xml" + proc = run_pytest( + cwd=cwd, + python=python, + args=[*common, *[f"--ignore={m}" for m in hangs], *group.targets], + junit=group_junit, + env={**group_env, **_NO_UPSTREAM_RERUNS, "CI_WORLD": ISOLATED_WORLD}, + timeout=args.group_timeout, + per_test_timeout=ISOLATED_TIMEOUT, + ) + last_code = proc.code + part = parse_junit(group_junit) + if not part: + result.note( + f"{targets} exited {proc.code} and produced no junit " + "results. That group did not run; it is a failure, not an empty pass." + ) + result.finish(results.ERROR).save(args.results_dir) + return 1 + for tid, outcome in part.items(): + if outcomes.get(tid) != results.PASS: + outcomes[tid] = outcome + ran.append(group) + + # --- 1b. declared hangs, straight to the main world ---------------- + # + # Above the `failing` guard on purpose: these owe nothing to what the + # isolated pass found, and a group with no failures at all still has to + # run them or they would silently stop being covered. + # + # Its own cache_dir, for the reason the shared one is avoided above -- + # `--last-failed` in pass 2 reads that cache, and a module that failed + # here would otherwise be re-selected there and run a second time. + # + # Unsharded, and on the first shard only, for the reason tests/common/ + # is unsharded: these are a handful of tests, and sharding a handful + # hands most shards an empty selection. pytest exits 5 for that and + # writes no junit, which is indistinguishable from "did not run" -- so + # the guard below fired on every shard that happened to own none of + # them ("collected 6 items / 6 deselected / 0 selected"). Running them + # once, whole, also means the fallback accounting is not spread across + # shards that each saw a fraction of the module. + if hangs and first_shard: + log(f" declared isolation hangs [{MAIN_WORLD} world]: {', '.join(hangs)}") + hang_cache = WORK_DIR / f"pytest-cache{suffix}" / f"{index}-hangs" + hang_junit = WORK_DIR / f"junit{suffix}-{index}-hangs.xml" + run_pytest( + cwd=cwd, + python=python, + args=[*base_args, "-o", f"cache_dir={hang_cache}", *hangs], + junit=hang_junit, + # Cleared rather than omitted: ci/_util.run() layers env over + # os.environ, so dropping the key would still inherit one. + # parse_shard() reads empty as "no shard", the same way + # _NO_UPSTREAM_RERUNS clears $CI. + env={**group_env, "CI_SHARD": "", "CI_WORLD": MAIN_WORLD}, + timeout=args.group_timeout, + ) + fallback_junits.append(hang_junit.name) + hung = parse_junit(hang_junit) + if not hung: + result.note( + f"the declared isolation hangs ({', '.join(hangs)}) produced no junit " + "results, so they did not run. Treating that as a failure: a declared " + "hang that stops running is how coverage disappears quietly." + ) + result.finish(results.ERROR).save(args.results_dir) + return 1 + for tid, outcome in hung.items(): + outcomes[tid] = outcome + # Accounted for exactly like a discovered fallback, so the published + # isolated-world gap keeps meaning "what isolation costs us" rather + # than "what isolation cost us, minus the part we knew about". + isolated_failures.extend(sorted(hung)) + fallbacks.extend(sorted(t for t, o in hung.items() if o == results.PASS)) + + failing = {t for t, o in part.items() if o in (results.FAIL, results.ERROR)} + if not failing: + # Nothing to re-run, and this guard is load-bearing rather than an + # optimisation: pytest declines to filter when nothing it collected + # previously failed, so a `--last-failed` pass with an empty cache + # runs the ENTIRE group again -- in the main world, silently + # discarding the isolated result it was meant to refine. + continue + + # --- 2. main world: what isolation, specifically, costs ----------- + # + # Straight to the other world, with no same-world retry in between. + # That retry used to sit here on the theory that a flake must not be + # mistaken for a world difference, and measured on the first real run it + # cost 7m50s a shard and recovered nothing at all: + # + # isolated (full) 335s + 331s 35 and 11 failures + # isolated retry 205s + 265s 0 recovered + # main world 19s + 12s 46 recovered + # + # Two reasons it was never going to earn that. These failures are + # deterministic -- a test reading a global its page script defined does + # not intermittently see it -- and failing that way is *slow*, because + # the read returns undefined and the test sits on a Playwright timeout + # rather than throwing. And upstream's suite already ships + # pytest-rerunfailures: the "105 rerun" on that first line is every one + # of those 35 failures having been retried three times before the run + # even reported them. A flake does not survive that. + isolated_failures.extend(sorted(failing)) + log(f" fallback [{MAIN_WORLD} world]: {len(failing)} test(s) that isolation failed") + fallback_junit = WORK_DIR / f"junit{suffix}-{index}-mainworld.xml" + run_pytest( + cwd=cwd, + python=python, + args=[*common, "--last-failed", *group.targets], + junit=fallback_junit, + env={**group_env, "CI_WORLD": MAIN_WORLD}, + timeout=args.group_timeout, + ) + fallback_junits.append(fallback_junit.name) + recovered_in_main = parse_junit(fallback_junit) + recovered = {t for t in failing if recovered_in_main.get(t) == results.PASS} + for tid in recovered: + outcomes[tid] = results.PASS + fallbacks.extend(sorted(recovered)) + failing -= recovered + + # --- 3. failed in BOTH worlds: now a retry is worth paying for ----- + # + # This set is normally empty, which is exactly why the retry belongs + # here and not one phase earlier: it costs nothing on a healthy run, and + # on an unhealthy one it answers the only question still open about a + # test that no world would satisfy -- whether it is broken or merely + # flaky. Re-run in the main world, the permissive one, so a pass means + # "not reproducible" rather than "needed isolation off", which is + # already known by this point. + for attempt in range(args.retries): + if not failing: + break + log(f" retry {attempt + 1} [{MAIN_WORLD} world]: {len(failing)} test(s) that failed in both") + retry_junit = WORK_DIR / f"junit{suffix}-{index}-retry{attempt + 1}.xml" + run_pytest( + cwd=cwd, + python=python, + args=[*common, "--last-failed", *group.targets], + junit=retry_junit, + env={**group_env, "CI_WORLD": MAIN_WORLD}, + timeout=args.group_timeout, + ) + retried = parse_junit(retry_junit) + recovered = {t for t in failing if retried.get(t) == results.PASS} + for tid in recovered: + outcomes[tid] = results.PASS + if recovered: + result.note( + f"{len(recovered)} test(s) that failed in both worlds passed on retry " + "(flaky, not counted as failures)" + ) + failing -= recovered + + result.metrics["groups"] = len(ran) + + for tid, outcome in outcomes.items(): + result.record(tid, outcome) + + # Published on purpose. These tests pass, so they are invisible in the + # failure count -- but this is the isolated-world conformance gap, and the + # whole reason for running isolation first is to have a number for it that + # moves when the browser does. + result.metrics["isolated_world_failures"] = len(isolated_failures) + # Declared, not measured -- so say so rather than letting them sit inside + # the fallback count looking like something the isolated pass discovered. + result.metrics["declared_isolation_hangs"] = list(ISOLATION_HANGS) + result.metrics["main_world_fallback_count"] = len(fallbacks) + result.metrics["main_world_fallbacks"] = sorted(fallbacks) + if fallbacks: + result.note( + f"{len(fallbacks)} test(s) failed under world isolation and passed with it off. " + "They count as passes -- Camoufox honours the contract -- but the set is " + "recorded so a change in it is visible: " + + ", ".join(sorted(fallbacks)[:8]) + + (" ..." if len(fallbacks) > 8 else "") + ) + unexplained = len(isolated_failures) - len(fallbacks) + if unexplained: + result.note( + f"{unexplained} test(s) failed in BOTH worlds; those are real failures, not " + "an isolation difference." + ) + + tally = result.tally() + result.artifacts.extend( + f"junit{suffix}-{i}.xml" for i in range(len(GROUPS)) if i < len(ran) + ) + result.artifacts.extend(fallback_junits) + result.metrics["exit_code"] = last_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)" + ) + + still_failing = tally.get("fail", 0) + tally.get("error", 0) + status = results.PASS if still_failing == 0 else results.FAIL + result.finish(status).save(args.results_dir) + # Exit non-zero so the step goes red in the UI. ci/summarize.py still owns + # the run's verdict -- it is the only thing that knows what was required -- + # but a green step hiding a failed suite is how a broken pipeline goes + # unnoticed for a week. Shards are separate jobs with fail-fast disabled, so + # one going red does not cancel its siblings. + return 0 if status == results.PASS else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/run_prepare.py b/ci/run_prepare.py new file mode 100644 index 000000000..3608b1a93 --- /dev/null +++ b/ci/run_prepare.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Prepare the Firefox source tree, retrying the steps that reach the network. + +`make setup-minimal && make dir && make mozbootstrap` is the front half of every +build job. Two of those three steps download things -- the Firefox tarball, and +then the toolchains `mach bootstrap` pulls from Taskcluster -- and a download +that dies mid-stream fails the whole pull request: + + requests.exceptions.ConnectionError: + ('Connection aborted.', ConnectionResetError(104, 'Connection reset by peer')) + make: *** [Makefile:95: mozbootstrap] Error 1 + +That is not a defect in the change under review, and re-running it by hand is +the only thing anyone ever does about it. So do that here instead, and only for +failures that look transient: a compile error or a patch that will not apply +must still fail on the first try, because retrying those only wastes a runner. + +The retry lives here rather than in the Makefile so the Makefile diff stays +clean against upstream (see CLAUDE.md) and so the auto-update harness gets the +same behaviour without duplicating it in a workflow. + +Run: + python3 -m ci.run_prepare + python3 -m ci.run_prepare --attempts 4 +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import threading +import time +from typing import List, Optional, Tuple + +from ._util import REPO_ROOT, log + +# Substrings that mark a failure as "the network went away", not "this tree is +# broken". Matched against combined stdout/stderr, case-insensitively. +# +# Deliberately narrow. Anything not listed here is treated as a real failure and +# fails on the first attempt, which is the behaviour that matters: a retry loop +# that swallows a genuine breakage turns a red build into a slow red build. +_TRANSIENT = ( + "connection reset by peer", + "connection aborted", + "connectionreseterror", + "temporary failure in name resolution", + "timed out", + "timeout was reached", + "remote end closed connection", + "eof occurred in violation of protocol", + "503 service unavailable", + "502 bad gateway", + "504 gateway time-out", + "failed to establish a new connection", + "tls handshake", + "unexpected eof", +) + +# Steps in order. `retry` says whether a transient failure is worth another go; +# `make dir` applies patches and touches no network once the tarball is present, +# so a failure there is always real. +_STEPS = ( + ("setup-minimal", True), + ("dir", False), + ("mozbootstrap", True), +) + + +def is_transient(output: str) -> bool: + low = output.lower() + return any(marker in low for marker in _TRANSIENT) + + +def _summarise(output: str, limit: int = 3) -> List[str]: + """The lines that say what went wrong, for the log line above a retry.""" + hits = [ + line.strip() + for line in output.splitlines() + if is_transient(line) or re.match(r"^\s*(make|mach):.*(error|failed)", line, re.I) + ] + return hits[-limit:] + + +def _run_capturing(cmd: List[str], *, timeout: int) -> Tuple[int, str]: + """Run a command, echoing each line as it arrives and keeping a copy. + + _util.run() can stream or capture, not both -- and this needs both. Deciding + whether a failure is transient means reading the output, and a `make dir` + that prints nothing for five minutes while aria2c pulls a 500MB tarball + looks exactly like a hung job. + """ + log("$ " + " ".join(cmd) + f" (cwd={REPO_ROOT})") + proc = subprocess.Popen( + cmd, cwd=str(REPO_ROOT), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, + ) + lines: List[str] = [] + + # The reader runs on its own thread so the timeout is real. Draining the + # pipe on this thread would block in readline until EOF, and only *then* + # reach proc.wait(timeout=...) -- so a step that wedged without printing + # anything (exactly what a stalled download does) would hang forever and + # the timeout would never fire. + def drain() -> None: + assert proc.stdout is not None + for line in proc.stdout: + print(line, end="", flush=True) + lines.append(line) + + reader = threading.Thread(target=drain, daemon=True) + reader.start() + try: + code = proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + lines.append(f"\nTIMEOUT after {timeout}s\n") + code = 124 + # Give the reader a moment to flush what the process already wrote; it is a + # daemon thread, so a wedged pipe cannot hold the process open. + reader.join(timeout=10) + return code, "".join(lines) + + +def run_step(target: str, *, retry: bool, attempts: int, backoff: int, timeout: int) -> int: + # At least one attempt, always. `--attempts 0` reaching the loop bound would + # skip the step entirely and return success, which is the one answer this + # function must never invent. + tries = max(1, attempts) if retry else 1 + for attempt in range(1, tries + 1): + code, output = _run_capturing(["make", target], timeout=timeout) + if code == 0: + return 0 + if attempt == tries: + return code + if not is_transient(output): + log(f"make {target} failed and the failure does not look transient; not retrying", + level="ERROR") + return code + for line in _summarise(output): + log(f" {line}", level="WARN") + delay = backoff * attempt + log(f"make {target} hit a transient network failure " + f"(attempt {attempt}/{tries}); retrying in {delay}s", level="WARN") + time.sleep(delay) + raise AssertionError("run_step fell out of its loop without a verdict") + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--attempts", type=int, default=3, + help="tries per network-bound step (default 3)") + parser.add_argument("--backoff", type=int, default=20, + help="seconds before the first retry; grows linearly") + parser.add_argument("--timeout", type=int, default=3600) + args = parser.parse_args(argv) + + for target, retry in _STEPS: + log(f"make {target}") + code = run_step(target, retry=retry, attempts=args.attempts, + backoff=args.backoff, timeout=args.timeout) + if code != 0: + log(f"make {target} failed (exit {code})", level="ERROR") + return code + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/run_pythonlib.py b/ci/run_pythonlib.py new file mode 100644 index 000000000..fca6eedcb --- /dev/null +++ b/ci/run_pythonlib.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""pythonlib gate: the Python package's own test suite. + +Cheap, browser-free, and the first thing to break when a Firefox bump changes a +version constraint or a fingerprint preset shape. Runs early so an obvious +mistake does not cost a 40-minute build. + +Run: + python3 -m ci.run_pythonlib +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import List, Optional + +from . import results as evidence +from ._util import EVIDENCE_DIR, REPO_ROOT, WORK_DIR +from ._pytest import parse_junit, run_pytest + +PYTHONLIB = REPO_ROOT / "pythonlib" + + +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("--python", type=Path, default=Path(sys.executable)) + parser.add_argument("--timeout", type=int, default=1800) + args = parser.parse_args(argv) + + result = evidence.GateResult(gate="pythonlib") + if not (PYTHONLIB / "tests").is_dir(): + result.note("pythonlib/tests does not exist") + result.finish(evidence.ERROR).save(args.evidence_dir) + return 1 + + junit = WORK_DIR / "junit-pythonlib.xml" + proc = run_pytest( + cwd=PYTHONLIB, python=args.python, args=["tests/"], junit=junit, timeout=args.timeout + ) + outcomes = parse_junit(junit) + if not outcomes: + result.note(f"pytest 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('total', 0)} collected)" + ) + failing = tally.get("fail", 0) + tally.get("error", 0) + 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/run_skiplist_audit.py b/ci/run_skiplist_audit.py new file mode 100644 index 000000000..b84b56b3d --- /dev/null +++ b/ci/run_skiplist_audit.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Every skiplist entry must still be failing. + +`ci/skiplist.yml` deselects tests Camoufox cannot pass by design. The failure +mode of such a list is not that it grows -- it is that it stops being true. A +test gets fixed, or the behaviour it asserted changes, and the entry sits there +skipping a test that would now pass. Nothing notices, because a skipped test +looks exactly like a skipped test either way. + +That is not hypothetical here. The first version of the skiplist inherited all +nine `tests/async/*.disabled` files from the vendored suite and gave each a +plausible reason without running any of them. Of the 202 tests it skipped, 193 +passed. Seven of the nine modules failed nothing at all. + +So: run the skipped tests with the skiplist disabled, and fail if any of them +passes. A written reason is an assertion about the browser, and this is the +thing that checks it. + +Run in the **main world**, deliberately, even though the suite itself now runs +isolated-first. `ci/run_playwright.py` counts a test that needs the main world +as a fallback rather than a failure, so "this test cannot pass" has to mean +"cannot pass in either world" -- auditing under isolation would let an entry +justify itself with a failure the suite would not have counted. + +Cheap, because a correct skiplist is short -- it runs only what the list names. + +Run: + python3 -m ci.run_skiplist_audit --binary path/to/camoufox-bin +""" + +from __future__ import annotations + +import argparse +import sys +import tempfile +from pathlib import Path +from typing import List, Optional, Tuple + +from . import results +from ._pytest import parse_junit, require_binary, run_pytest +from ._util import REPO_ROOT, RESULTS_DIR, WORK_DIR, log +from .pw_camoufox_plugin import MAIN_WORLD +from .suite import prepare +from .versions import resolve + + +def targets(entries: List[dict]) -> Tuple[List[str], List[str]]: + """(pytest targets, entries we cannot turn into one). + + `module` and `test` name something pytest can select. `pattern` is a + substring match over node ids with no path in it, so it is reported rather + than audited -- and saying so is the point, because an unaudited entry that + looked audited is the bug this module exists to prevent. + """ + selectable: List[str] = [] + unresolved: List[str] = [] + for entry in entries: + if "module" in entry: + selectable.append(str(entry["module"]).lstrip("./")) + elif "test" in entry: + selectable.append(str(entry["test"]).lstrip("./")) + else: + unresolved.append(str(entry.get("pattern", entry))) + return selectable, unresolved + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path) + parser.add_argument("--browser-version", help="passed through to ci.versions") + parser.add_argument("--playwright-tag", help="pin the suite instead of resolving one") + parser.add_argument("--results-dir", type=Path, default=RESULTS_DIR) + parser.add_argument("--timeout", type=int, default=3600) + args = parser.parse_args(argv) + + result = results.GateResult(gate="skiplist_audit") + + sys.path.insert(0, str(REPO_ROOT / "ci")) + from pw_camoufox_plugin import load_skiplist # noqa: E402 + + entries = load_skiplist(REPO_ROOT / "ci" / "skiplist.yml") + if not entries: + result.note("the skiplist is empty; nothing to audit") + result.finish(results.PASS).save(args.results_dir) + return 0 + + selectable, unresolved = targets(entries) + for pattern in unresolved: + result.note(f"not audited (pattern entries name no file): {pattern!r}") + + try: + binary = args.binary or require_binary() + except FileNotFoundError as exc: + result.note(str(exc)) + result.finish(results.ERROR).save(args.results_dir) + return 1 + + versions = resolve(browser_version=args.browser_version, playwright_tag=args.playwright_tag) + manifest = prepare(versions["playwright_tag"]) + cwd = Path(manifest["checkout"]) + + # An empty skiplist, so the plugin still supplies main-world execution and + # the binary redirection while deselecting nothing. + with tempfile.TemporaryDirectory() as tmp: + empty = Path(tmp) / "skiplist.yml" + empty.write_text("schema: 1\nskip: []\n", encoding="utf-8") + junit = WORK_DIR / "junit-skiplist-audit.xml" + log(f"auditing {len(selectable)} skiplist target(s) with the skiplist disabled") + run_pytest( + cwd=cwd, + python=Path(manifest["python"]), + args=["-p", "pw_camoufox_plugin", "--browser", "firefox", *selectable], + junit=junit, + env={ + "CAMOUFOX_EXECUTABLE_PATH": str(binary.resolve()), + "CI_SKIPLIST": str(empty), + # The most permissive world, on purpose -- see the module + # docstring. An entry that survives this really is unpassable. + "CI_WORLD": MAIN_WORLD, + }, + timeout=args.timeout, + ) + outcomes = parse_junit(junit) + + if not outcomes: + result.note( + "the audit produced no junit results, so nothing was verified. Treating that " + "as a failure: an audit that did not run is not an audit that passed." + ) + result.finish(results.ERROR).save(args.results_dir) + return 1 + + stale = sorted(t for t, o in outcomes.items() if o == results.PASS) + for test in stale: + result.record(test, results.FAIL) + for test, outcome in outcomes.items(): + if outcome != results.PASS: + result.record(test, results.PASS) + + if stale: + result.note( + f"{len(stale)} skiplisted test(s) now PASS, so their entry in ci/skiplist.yml " + "is no longer true. Remove the entry (or narrow it to what still fails) rather " + "than leaving a passing test skipped: " + ", ".join(stale[:12]) + + (" ..." if len(stale) > 12 else "") + ) + result.finish(results.FAIL).save(args.results_dir) + return 1 + + result.note( + f"{len(outcomes)} skiplisted test(s) checked; every one still fails, so every " + "entry still describes something true." + ) + result.finish(results.PASS).save(args.results_dir) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/run_sundial.py b/ci/run_sundial.py new file mode 100644 index 000000000..8d2c3625c --- /dev/null +++ b/ci/run_sundial.py @@ -0,0 +1,1052 @@ +#!/usr/bin/env python3 +"""Stealth gate: drive the private sundial suite and bring back numbers only. + +Sundial is a private detection suite. Its value is that the vectors it probes +are not public, so **nothing identifying a vector may ever leave this module**: +not a name, not a description, not a measured value, not the test's source. This +repository is public, and an evidence file or a pull-request comment is +permanent. Everything downstream of `_redact()` is counts and opaque ids. + +The opaque id is `HMAC(salt, vector_key)`. That is enough to notice "the vector +that passed last release is failing now", which is the only thing the gate +needs, and it is not enough to learn what the vector was. + +Scope: only categories Camoufox actually claims to implement are gated +(`ci/sundial.yml: gated_categories`). Cross-OS rendering parity, for one, is +measured and reported but never fails a build -- Camoufox does not claim +byte-identical emulation of another platform's rasterizer. + +How it runs: + 1. Form-login to sundial, keep the `sundial_session` cookie. + 2. Start a loopback collector. + 3. Launch the built binary, seed the cookie, open `?auto=1&post=`. + Sundial runs its scan on load and POSTs `window.fullReport` back. + 4. Redact, score, write evidence. + +Run: + python3 -m ci.run_sundial --binary /path/to/camoufox-bin + python3 -m ci.run_sundial waive --key '' --reason '...' +""" + +from __future__ import annotations + +import argparse +import asyncio +import http.server +import json +import os +import sys +import threading +import urllib.error +import urllib.parse +import urllib.request +from datetime import date, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from . import results as evidence +from ._util import CI_DIR, RESULTS_DIR, WORK_DIR, log, opaque_id, run + +CONFIG_PATH = CI_DIR / "sundial.yml" +COOKIE_NAME = "sundial_session" +# The account CI logs in as. `guest` is the least-privileged role the deployment +# actually has (sundial 0.5.0): its middleware refuses `guest` the private-vector +# bundle outright, so the definitions this repository must never see are not +# served to this session at all. +# +# Two guarantees keep a vector out of a public log, and it is worth being precise +# about which is which, because only one of them is enforced by the server: +# +# server-side `guest` is answered with an empty stub for vectors-private.js +# (_middleware.js gates that path on the role). `admin` and +# `private` are not -- so the gate verifies the session's role +# against /__auth/me and refuses to scan under either of them, +# rather than trusting that the right key was configured. +# client-side this gate only ever requests `/?auto=1&score=1`, and redact() +# refuses to process anything that is not a score payload, so a +# deployment that ignored `score=1` fails the run instead of +# folding a report down and carrying on. +# +# The stricter option is sundial's score-only `ci` role, which is refused +# anything but `/?auto=1&score=1` server-side and so cannot retrieve a report +# even if this credential leaks. That role is not in sundial's master branch and +# is therefore not deployed; when it lands, set SUNDIAL_USERNAME=ci and the +# server-side half of the guarantee gets stronger with no change here. +DEFAULT_USERNAME = "guest" +DEFAULT_URL = "https://sundial.daijro.dev" + +# Report fields that may describe a private vector. Dropped without exception. +_FORBIDDEN_FIELDS = ( + "name", "brief", "src", "source", "value", "expect", "requires", + "key", "id", "cat", "elapsedMs", "entropy", +) + +# The complete set of keys allowed to leave this module. A whitelist, checked at +# runtime, because a blacklist only stops the leaks somebody already thought of: +# add a field to redact() and forget to think about it, and a blacklist ships it. +# This fails the run instead. +# +# There are deliberately no per-vector rows here, not even opaque ones. An HMAC +# does not name a vector, but a map of them is still per-vector data: it says how +# many distinct checks fail and lets a reader follow the same id across releases. +# The instruction is a score, so this is a score. +_PUBLISHABLE = frozenset({ + "grade", # a letter + "checks_total", # how many in-scope checks were scored + "checks_passed", + "pass_rate", + "out_of_scope_failed", # a single count, no attribution + "unknown_category_checks", # ditto -- how many, never which + "sundial_role", # which role CI authenticated as; names an account, not a vector + "cross_os_total", # host-OS detectors: measured, never gated + "cross_os_passed", + "score_mode", # did sundial answer in score mode? a protocol fact + "os", # which profile it was measured under; we chose it + "sundial_version", + "schema_version", + "policy_violations", # our own strings, not sundial's +}) + + +def _assert_publishable(metrics: Dict[str, Any]) -> Dict[str, Any]: + """Refuse to hand back anything not on the whitelist.""" + extra = set(metrics) - _PUBLISHABLE + if extra: + raise RuntimeError( + f"the stealth gate tried to publish {sorted(extra)}, which is not on the " + "whitelist in ci/run_sundial.py. Sundial's vectors are private and this " + "repository is public; add the key to _PUBLISHABLE only after deciding it " + "is a score and not a metric." + ) + return metrics + + +# --------------------------------------------------------------------------- +# auth +# --------------------------------------------------------------------------- + + +class SundialUnavailable(RuntimeError): + """The deployment could not be reached -- not a verdict about the browser. + + Sundial is a separate service on a separate host. When it is down, or DNS + fails, or the edge answers 502 for a minute, the browser under test has not + been measured at all -- and failing the merge gate for that makes an + unrelated outage into a block on every pull request in the repository. + + So this is raised for transport-level failures only, and `gate()` turns it + into a SKIP that `ci/summarize.py` is told to tolerate. Everything that is + an actual answer from sundial -- a rejected credential, a role that would be + served the vectors, a full report where a score was asked for, a pass rate + under the floor -- stays a hard failure, because those are statements about + this repository's configuration or this browser, and neither gets to be + waved through by an exception type. + """ + + +def _unavailable_reason(exc: BaseException) -> Optional[str]: + """Why this exception means "sundial is down", or None if it does not. + + An HTTPError IS a reply, so most of them are real answers and must fail: + 401 is a bad credential, 403 is the edge refusing us. Only 5xx (the origin + is broken) and 429 (it is refusing to serve anyone right now) are outages. + Anything that is a URLError but not an HTTPError never reached a server at + all -- DNS, refused connection, TLS, timeout. + """ + if isinstance(exc, urllib.error.HTTPError): + if exc.code >= 500: + return f"sundial returned HTTP {exc.code}" + if exc.code == 429: + return "sundial is rate-limiting every request (HTTP 429)" + return None + if isinstance(exc, urllib.error.URLError): + return f"sundial could not be reached ({exc.reason})" + if isinstance(exc, (TimeoutError, ConnectionError)): + return f"sundial could not be reached ({type(exc).__name__}: {exc})" + return None + + +# Cloudflare sits in front of sundial and refuses document requests carrying a +# non-browser User-Agent, before they reach sundial at all. Measured against the +# live host: `/automated?key=` answers **401** with a browser User-Agent +# and **403** with urllib's default. +# +# That matters here because the token route below is exactly such a request, and +# it is the route this repository's credential actually uses. Sent with the old +# `User-Agent: camoufox-harness` it would have been refused by the edge, and the +# 403 would have read like a permissions problem rather than a bot filter. +# +# On every request, not just the first: a client that authenticates with browser +# headers and then fetches with urllib's defaults logs in successfully and gets +# a confusing 403 on the very next hop. +_BROWSER_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0" + ), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Upgrade-Insecure-Requests": "1", +} + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Both auth routes answer 303 + Set-Cookie; following it hides the cookie.""" + + def redirect_request(self, *_args, **_kwargs): # noqa: D102 + return None + + +def _session_cookie(headers) -> Optional[str]: # noqa: ANN001 + for raw in headers.get_all("Set-Cookie") or []: + if raw.startswith(COOKIE_NAME + "="): + value = raw.split(";", 1)[0][len(COOKIE_NAME) + 1 :] + if value: + return value + return None + + +def login_with_key(base_url: str, key: str, *, timeout: int = 30) -> str: + """Token login: `GET /automated?key=` mints the session cookie. + + This is the route the credential in CI is actually for -- sundial's + `make pages-automation-keys` mints report-URL keys, and a key presented here + resolves to the `guest` role. Tried before the form because it needs no + username, so there is no second secret to keep in step with whatever + `GUEST_USER` was set to. + """ + url = base_url.rstrip("/") + "/automated?key=" + urllib.parse.quote(key, safe="") + req = urllib.request.Request(url, headers=_BROWSER_HEADERS) + opener = urllib.request.build_opener(_NoRedirect) + try: + resp = opener.open(req, timeout=timeout) + status, headers = resp.status, resp.headers + except urllib.error.HTTPError as exc: + outage = _unavailable_reason(exc) + if outage: + raise SundialUnavailable(outage) from None + status, headers = exc.code, exc.headers + except (urllib.error.URLError, TimeoutError, ConnectionError) as exc: + # HTTPError is a URLError, and is handled above; reaching here means no + # server answered at all. + raise SundialUnavailable(_unavailable_reason(exc) or str(exc)) from None + cookie = _session_cookie(headers) + if cookie: + log("sundial auth OK (automation key)") + return cookie + raise RuntimeError(f"the automation key route returned {status} and no session cookie") + + +def login(base_url: str, username: str, password: str, *, timeout: int = 30) -> str: + """Form-login and return the session cookie value. + + The endpoint answers 303 + Set-Cookie on success and 401 + the login page on + failure, so a redirect handler would hide the result; handle it manually. + """ + body = urllib.parse.urlencode({"username": username, "password": password}).encode() + req = urllib.request.Request( + base_url.rstrip("/") + "/__auth/login", + data=body, + method="POST", + headers={ + **_BROWSER_HEADERS, + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + + opener = urllib.request.build_opener(_NoRedirect) + try: + resp = opener.open(req, timeout=timeout) + status, headers = resp.status, resp.headers + except urllib.error.HTTPError as exc: + status, headers = exc.code, exc.headers + if status == 401: + raise RuntimeError( + "sundial rejected the credentials. Check SUNDIAL_USERNAME and " + "SUNDIAL_AUTOMATION_KEY." + ) from None + outage = _unavailable_reason(exc) + if outage: + raise SundialUnavailable(outage) from None + if status != 303: + raise RuntimeError(f"sundial login returned {status}") from None + except (urllib.error.URLError, TimeoutError, ConnectionError) as exc: + raise SundialUnavailable(_unavailable_reason(exc) or str(exc)) from None + + cookie = _session_cookie(headers) + if cookie: + log("sundial login OK (form)") + return cookie + raise RuntimeError("sundial login succeeded but returned no session cookie") + + +def scrub(text: str, secret: str) -> str: + """Remove a secret from text that is about to be published. + + Whatever goes into result.note() reaches the results artifact and the pull + request comment, and an exception raised deep inside urllib can carry the + URL that produced it -- which, for the token route, is the credential. So + nothing built from an exception is published until it has been through here. + Both the raw secret and its percent-encoded form, because the URL holds the + latter. + """ + if not secret: + return text + for form in (secret, urllib.parse.quote(secret, safe="")): + if form: + text = text.replace(form, "") + return text + + +# Roles sundial will not serve the private-vector bundle to. Everything rests on +# the session being one of these: `redact()` controls what this repository +# *publishes*, but only the role controls what the browser is *given*, and a +# public runner holding the vectors at all is the thing being prevented. +# +# `guest` is what an AUTOMATION_GUEST_KEY resolves to. `ci` is sundial's +# score-only role, for when it lands. `private` and `admin` both load the +# vectors and must never be what CI ends up as -- which is a live possibility, +# not a hypothetical: /automated?key= resolves to `private` when handed the +# private key, and the two keys are indistinguishable by looking at them. +ROLES_WITHOUT_VECTORS = frozenset({"guest", "ci"}) + + +def session_role(base_url: str, cookie: str, *, timeout: int = 30) -> Optional[str]: + """The role this session actually has, per sundial's own /__auth/me. + + Returns None if the endpoint is absent or unreadable -- an older deployment + may not have it, and the caller decides what to do about that. + """ + req = urllib.request.Request( + base_url.rstrip("/") + "/__auth/me", + headers={**_BROWSER_HEADERS, "Accept": "application/json", "Cookie": f"{COOKIE_NAME}={cookie}"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 + if resp.status != 200: + return None + return (json.loads(resp.read().decode("utf-8")) or {}).get("role") + except Exception: # noqa: BLE001 -- absence is not an error here + return None + + +def assert_role_cannot_read_vectors(base_url: str, cookie: str, *, timeout: int = 30) -> str: + """Refuse to scan under a role that would be handed the private vectors.""" + role = session_role(base_url, cookie, timeout=timeout) + if role is None: + raise RuntimeError( + "sundial did not say what role this session has (/__auth/me returned nothing " + "usable), so it cannot be confirmed that the browser will be refused the " + "private vectors. Refusing to scan: the alternative is loading them onto a " + "public runner on the assumption that the credential was the right one." + ) + if role not in ROLES_WITHOUT_VECTORS: + raise RuntimeError( + f"this credential authenticates as the {role!r} role, which sundial serves the " + f"private vectors to. CI must use one of {sorted(ROLES_WITHOUT_VECTORS)} -- set " + "SUNDIAL_AUTOMATION_KEY to the guest automation key (`make pages-automation-keys`), " + "not the private one. Refusing to scan." + ) + log(f"sundial session role is {role!r}; the private vectors are not served to it") + return role + + +def authenticate(base_url: str, username: str, secret: str, *, timeout: int = 30) -> str: + """Get a session cookie, whichever shape the stored credential is. + + SUNDIAL_AUTOMATION_KEY has been both things over this repository's life: an + automation key for `/automated?key=`, and a password for the login form. The + two are indistinguishable by looking at them, and which one a given + repository holds is not something this code can know -- so try the key route + first (it needs no username) and fall back to the form. The only cost when + the secret is a password is one extra request that 401s. + """ + errors = [] + # Only if BOTH routes failed for transport reasons is the deployment down. + # One route 401ing is an answer -- it says the secret is the other shape -- + # so a single real reply is enough to know sundial is up, and a failure + # after that is this repository's problem to fix rather than an outage. + outages = [] + try: + return login_with_key(base_url, secret, timeout=timeout) + except Exception as exc: # noqa: BLE001 -- reported below if the form fails too + detail = scrub(str(exc), secret) + errors.append(f"automation key: {detail}") + if isinstance(exc, SundialUnavailable): + outages.append(detail) + log(f"automation-key login did not take ({detail}); trying the form", level="WARN") + try: + return login(base_url, username, secret, timeout=timeout) + except Exception as exc: # noqa: BLE001 + errors.append(f"form login as {username!r}: {scrub(str(exc), secret)}") + if isinstance(exc, SundialUnavailable): + outages.append(scrub(str(exc), secret)) + + if len(outages) == len(errors): + raise SundialUnavailable( + scrub( + "sundial is unreachable; neither auth route got an answer:\n " + + "\n ".join(errors), + secret, + ) + ) + raise RuntimeError( + scrub( + "could not authenticate to sundial. Both routes were tried:\n " + + "\n ".join(errors), + secret, + ) + + "\nSUNDIAL_AUTOMATION_KEY must be either an automation key from " + "`make pages-automation-keys` or the password for the account named by " + "SUNDIAL_USERNAME (default 'guest')." + ) + + +# --------------------------------------------------------------------------- +# collector +# --------------------------------------------------------------------------- + + +class _Collector: + """Loopback endpoint that receives the one POST sundial makes.""" + + def __init__(self) -> None: + self.payload: Optional[dict] = None + self._event = threading.Event() + outer = self + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8", "replace") + try: + outer.payload = json.loads(raw) + except json.JSONDecodeError: + outer.payload = {"_parse_error": raw[:500]} + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + outer._event.set() + + def do_OPTIONS(self) -> None: # noqa: N802 + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "*") + self.end_headers() + + def log_message(self, *_args) -> None: # keep the run log readable + return + + self._server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.port = self._server.server_address[1] + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + def __enter__(self) -> "_Collector": + self._thread.start() + log(f"collector listening on 127.0.0.1:{self.port}") + return self + + def __exit__(self, *_exc) -> None: + self._server.shutdown() + self._server.server_close() + + def wait(self, timeout: float) -> Optional[dict]: + self._event.wait(timeout) + return self.payload + + +# --------------------------------------------------------------------------- +# redaction +# --------------------------------------------------------------------------- + + +def _iter_entries(report: dict) -> List[Tuple[str, str, str]]: + """(vector_key, category, status) for every vector, public and private.""" + out: List[Tuple[str, str, str]] = [] + buckets = ("failures", "succeeded", "pending", "skipped") + for scope in (report, report.get("private") or {}): + if not isinstance(scope, dict): + continue + for bucket in buckets: + grouped = scope.get(bucket) + if not isinstance(grouped, dict): + continue + for category, entries in grouped.items(): + for entry in entries or []: + if not isinstance(entry, dict): + continue + key = entry.get("key") or entry.get("id") or "" + if not key: + continue + out.append((str(key), str(category or "Uncategorised"), str(entry.get("status", "pending")))) + return out + + +_STATUS_MAP = { + "pass": evidence.PASS, + "fail": evidence.FAIL, + "error": evidence.ERROR, + "skipped": evidence.SKIP, + "pending": evidence.SKIP, +} + + +def grade(pass_rate: float) -> str: + """A single letter, which is the only stealth number that goes public.""" + for floor, letter in ((0.99, "A+"), (0.97, "A"), (0.94, "B"), (0.90, "C"), (0.80, "D")): + if pass_rate >= floor: + return letter + return "F" + + +def _from_buckets(payload: dict, gated: List[str], ungated: List[str]) -> Dict[str, int]: + """Fold sundial's score payload into the three numbers we publish. + + Buckets arrive keyed "|". Category decides scope -- whether + Camoufox claims that area at all -- and class decides whether a failure is + the browser's fault or the machine's. + + A category in neither list is counted separately. sundial's taxonomy is nine + top-level sections and ci/sundial.yml names all nine, so a tenth means + sundial grew one and nobody decided whether Camoufox claims it. Folding that + into "out of scope" would answer the question by default, in the direction + that never fails a build -- a stealth blind spot that looks like a pass. + """ + gated_set = {c.lower() for c in gated} + known = gated_set | {c.lower() for c in ungated} + scored = passed = 0 + out_of_scope_failed = 0 + unknown_scored = 0 + cross_total = cross_passed = 0 + + for key, bucket in (payload.get("buckets") or {}).items(): + category, _, cls = str(key).partition("|") + n_scored = int(bucket.get("scored", 0)) + n_passed = int(bucket.get("passed", 0)) + + if cls == "crossOs": + # Host-OS detectors read the machine underneath, not the disguise. + # Camoufox does not claim byte-identical cross-OS emulation, so + # these are reported and never gated -- counting them against the + # score would be marking it down for a promise nobody made. + cross_total += n_scored + cross_passed += n_passed + continue + + if category.lower() in gated_set: + scored += n_scored + passed += n_passed + else: + out_of_scope_failed += n_scored - n_passed + if category.lower() not in known: + unknown_scored += n_scored + + return { + "scored": scored, + "passed": passed, + "out_of_scope_failed": out_of_scope_failed, + "unknown_category_checks": unknown_scored, + "cross_os_total": cross_total, + "cross_os_passed": cross_passed, + } + + +def explain_buckets(payload: dict, gated: List[str], ungated: List[str]) -> List[str]: + """Per-category pass/fail lines from a score payload, for a local run. + + This is as far as score mode can take you. sundial's score answer carries + buckets keyed "|" holding two integers each -- there are no + check names in it, and no opaque ids either, so "which five failed" is a + question the payload cannot answer at any level of effort. What it can say + is which category they are in, which is usually enough to know where to look. + + For the names you need a full report, which means a role sundial will serve + one to and `--allow-full-report`. This never returns anything that goes into + a result file; `_PUBLISHABLE` still governs what is published. + """ + gated_set = {c.lower() for c in gated} + known = gated_set | {c.lower() for c in ungated} + + rows: List[tuple] = [] + for key, bucket in sorted((payload.get("buckets") or {}).items()): + category, _, cls = str(key).partition("|") + scored = int(bucket.get("scored", 0)) + passed = int(bucket.get("passed", 0)) + if not scored: + continue + if cls == "crossOs": + scope = "cross-OS (never gated)" + elif category.lower() in gated_set: + scope = "gated" + elif category.lower() in known: + scope = "out of scope" + else: + scope = "UNKNOWN CATEGORY -- decide whether Camoufox claims it" + rows.append((scored - passed, category, cls or "-", passed, scored, scope)) + + if not rows: + return ["no buckets in the payload -- was this a score-mode answer?"] + + lines = ["", "per-category breakdown (local only, never published):"] + width = max(len(r[1]) for r in rows) + for failed, category, cls, passed, scored, scope in sorted(rows, reverse=True): + mark = "FAIL" if failed else " ok" + lines.append( + f" {mark} {category:<{width}} {cls:<10} {passed:>4}/{scored:<4} " + + (f"{failed} failing [{scope}]" if failed else f"[{scope}]") + ) + lines.append("") + lines.append( + "Score mode carries no check names, so this is the finest detail available " + "to the credential CI uses. For the individual checks, re-run under a role " + "sundial serves full reports to and pass --allow-full-report." + ) + return lines + + +def redact( + payload: dict, + gated: List[str], + ungated: List[str], + *, + os_name: str = "", + require_score_mode: bool = True, +) -> Dict[str, Any]: + """Turn sundial's response into a score, and nothing else. + + Two shapes arrive here. `mode: "score"` is the one to want: sundial has + already aggregated, so no vector identity ever crossed the wire. A full + report is **refused** unless `require_score_mode=False`, because the `ci` + role this gate authenticates as cannot be served one -- so a report arriving + means the run is misconfigured, and folding it down would hide that while + the vectors sat in this process. The flag is for a deliberate local run + under an account sundial does allow a report. + + Either way what leaves is a grade, how many in-scope checks were scored and + passed, a count of out-of-scope failures, and the cross-OS tally kept + separate. No names, no values, no categories, and no per-vector rows -- not + even opaque ones, because a map of HMACs still publishes how many distinct + checks fail and lets a reader follow one across releases. + + Regression detection is therefore per-score, not per-vector: + `ci/sundial.yml` carries the floor (`min_pass_rate`), and the auto-update + harness -- which is not in this repository -- adds a maximum allowed drop + against its own baseline. For the per-vector view set + SUNDIAL_REPORT_AGE_RECIPIENT and read the sealed report locally. + """ + score_mode = payload.get("mode") == "score" + if not score_mode and require_score_mode: + # This gate asks for `/?auto=1&score=1` and nothing else, so a full + # report cannot legitimately arrive here. If one does, the deployment + # ignored `score=1` -- it predates score mode, or the request did not + # reach the code that honours it. Either way the vectors are now in this + # process, and the honest thing is to fail loudly rather than quietly + # fold them down and carry on as though the guarantee had held. + raise RuntimeError( + "sundial answered with a full report, not a score. This gate only ever " + "requests `?auto=1&score=1`, so the deployment did not honour it: check that " + "sundial is at 0.5.0 or later (score mode landed in f136985). Refusing to " + "process the payload. Pass --allow-full-report for a deliberate local run " + "under an account that is allowed one." + ) + if score_mode: + counts = _from_buckets(payload, gated, ungated) + else: + # Legacy path: fold a full report down to the same numbers. + gated_set = {c.lower() for c in gated} + known = gated_set | {c.lower() for c in ungated} + scored = passed = out_of_scope_failed = unknown_scored = 0 + for _key, category, status in _iter_entries(payload): + outcome = _STATUS_MAP.get(status, evidence.SKIP) + if outcome not in (evidence.PASS, evidence.FAIL, evidence.ERROR): + continue + if category.lower() in gated_set: + scored += 1 + passed += outcome == evidence.PASS + else: + if outcome != evidence.PASS: + out_of_scope_failed += 1 + if category.lower() not in known: + unknown_scored += 1 + counts = { + "scored": scored, "passed": passed, + "out_of_scope_failed": out_of_scope_failed, + "unknown_category_checks": unknown_scored, + "cross_os_total": 0, "cross_os_passed": 0, + } + + rate = round(counts["passed"] / counts["scored"], 4) if counts["scored"] else 0.0 + return _assert_publishable({ + "score_mode": score_mode, + "grade": grade(rate), + "checks_total": counts["scored"], + "checks_passed": counts["passed"], + "pass_rate": rate, + "out_of_scope_failed": counts["out_of_scope_failed"], + "unknown_category_checks": counts["unknown_category_checks"], + "cross_os_total": counts["cross_os_total"], + "cross_os_passed": counts["cross_os_passed"], + "os": os_name, + "sundial_version": payload.get("sundialVersion"), + "schema_version": payload.get("schemaVersion"), + }) + + +# --------------------------------------------------------------------------- +# the scan +# --------------------------------------------------------------------------- + + +async def scan( + *, + binary: Path, + base_url: str, + cookie: str, + os_name: str, + headless: bool, + timeout: float, +) -> dict: + """Open sundial in the built browser and collect the report it posts back.""" + from camoufox.async_api import AsyncCamoufox + + host = urllib.parse.urlparse(base_url).hostname or "sundial.daijro.dev" + + with _Collector() as collector: + # score=1 makes sundial post counts rather than the report itself, so + # the vectors never cross the wire. Without it the full report arrives + # here and the only thing keeping it private is redact() being called -- + # this makes the leak structurally impossible instead of policy-based. + target = ( + f"{base_url.rstrip('/')}/?auto=1&score=1&post=" + + urllib.parse.quote(f"http://127.0.0.1:{collector.port}/collect", safe="") + ) + async with AsyncCamoufox( + executable_path=str(binary), + headless=headless, + os=os_name, + i_know_what_im_doing=True, + ) as browser: + context = await browser.new_context() + await context.add_cookies( + [{ + "name": COOKIE_NAME, + "value": cookie, + "domain": host, + "path": "/", + "httpOnly": True, + "secure": True, + "sameSite": "Strict", + }] + ) + page = await context.new_page() + log(f"opening sundial (auto scan) as {os_name}") + await page.goto(target, wait_until="load", timeout=120_000) + + payload = await asyncio.get_running_loop().run_in_executor( + None, collector.wait, timeout + ) + await context.close() + + if payload is None: + raise TimeoutError( + f"sundial did not post a report within {timeout:.0f}s. The usual cause is an " + "expired session cookie (the page silently renders the login form instead of " + "the suite), or the browser failing to reach the loopback collector." + ) + if "_parse_error" in payload: + raise RuntimeError("sundial posted something that was not JSON") + return payload + + +def seal(report: dict, out: Path) -> Optional[Path]: + """Optionally keep an encrypted copy of the *full* report for debugging. + + Only written when SUNDIAL_REPORT_AGE_RECIPIENT names an age public key, and + only readable by whoever holds the matching private key. Without it the full + report is discarded, because there is nowhere safe to put it: workflow + artifacts on a public repository are world-readable. + """ + recipient = os.environ.get("SUNDIAL_REPORT_AGE_RECIPIENT", "").strip() + if not recipient: + log("no SUNDIAL_REPORT_AGE_RECIPIENT set -- discarding the full report unencrypted-on-disk") + return None + if not run(["which", "age"]).ok: + log("age is not installed; cannot seal the full report", level="WARN") + return None + out.parent.mkdir(parents=True, exist_ok=True) + plain = out.with_suffix(".tmp.json") + plain.write_text(json.dumps(report), encoding="utf-8") + try: + res = run(["age", "-r", recipient, "-o", str(out), str(plain)]) + finally: + plain.unlink(missing_ok=True) + if not res.ok: + log(f"sealing failed: {res.combined()[-400:]}", level="WARN") + return None + log(f"sealed full report -> {out} (only the age key holder can read it)") + return out + + +# --------------------------------------------------------------------------- + + +def _config() -> dict: + """Scope and thresholds, from ci/sundial.yml.""" + import yaml + + with open(CONFIG_PATH, encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +def is_enabled(cfg: Optional[dict] = None) -> bool: + """Whether CI may run the stealth check at all (ci/sundial.yml: enabled). + + Defaults to False when the key is absent: a check that talks to a private + suite and posts the answer into a public log should be opt-in, so a config + that predates the flag does not silently start running. + """ + return bool((cfg if cfg is not None else _config()).get("enabled", False)) + + +def status(argv: Optional[List[str]] = None) -> int: + """Print the flag for the workflow, which decides whether to schedule the job.""" + parser = argparse.ArgumentParser(description="report whether the stealth gate is enabled") + parser.parse_args(argv) + print("true" if is_enabled() else "false") + return 0 + + +def gate(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, help="camoufox-bin under test") + parser.add_argument("--os", dest="os_name", default="linux", choices=["linux", "macos", "windows"]) + parser.add_argument("--headful", action="store_true") + parser.add_argument("--timeout", type=float, default=300.0) + parser.add_argument("--evidence-dir", type=Path, default=RESULTS_DIR) + parser.add_argument( + "--explain", + action="store_true", + help=( + "print a per-category pass/fail breakdown to the terminal. Local only -- " + "refused under GITHUB_ACTIONS, where stdout is a public log." + ), + ) + parser.add_argument( + "--allow-full-report", + action="store_true", + help=( + "accept a full report instead of the score. For a deliberate local run under an " + "account sundial allows one; never in CI, where the account cannot obtain one." + ), + ) + args = parser.parse_args(argv) + + if args.explain and os.environ.get("GITHUB_ACTIONS"): + # Category-level counts are not vectors, but they are a map of where + # this browser is weak, and a public workflow log is exactly the place + # that should not carry one. Refused here rather than filtered later, + # so the flag cannot be turned on in CI by accident. + raise SystemExit( + "--explain writes a weakness breakdown to stdout and this is a public " + "log. Run it locally instead." + ) + + cfg = _config() + base_url = os.environ.get("SUNDIAL_URL") or cfg.get("url") or DEFAULT_URL + result = evidence.GateResult(gate="sundial") + + # Checked before the credential is read, and before anything is sent, so a + # disabled gate cannot reach the deployment even by accident. The workflow + # also declines to schedule the job; this is the second lock on the same + # door, for a hand-run or a caller that ignores the first. + if not is_enabled(cfg): + # Deliberately writes NO result file, so a hand-run leaves the results + # directory exactly as a CI run does -- there, the job is not scheduled + # at all. summarize.py treats every non-pass result as a problem (a suite + # that did not run has not passed), so emitting a skip here would fail a + # summary that CI would have passed. And if some caller does require + # `sundial`, the missing file is the loud failure it should be. + log( + "stealth gate disabled in ci/sundial.yml (`enabled: false`) -- not contacting " + "sundial, and writing no result. See the comment there for what has to be true " + "before turning it back on." + ) + return 0 + + # The username is not a secret -- it names an account. Only the password + # needs protecting, so this defaults rather than demanding a second secret. + username = (os.environ.get("SUNDIAL_USERNAME") or DEFAULT_USERNAME).strip() + password = os.environ.get("SUNDIAL_AUTOMATION_KEY", "").strip() + if not username or not password: + result.note( + "SUNDIAL_AUTOMATION_KEY is not set. The stealth gate is required by policy, so a " + "missing credential fails the run rather than skipping it. (SUNDIAL_USERNAME is " + f"optional and defaults to {DEFAULT_USERNAME!r}.)" + ) + result.finish(evidence.ERROR).save(args.evidence_dir) + return 1 + + from ._pytest import require_binary + + try: + binary = args.binary or require_binary() + cookie = authenticate(base_url, username, password) + # Before the browser opens sundial at all: confirm the session really is + # a role that cannot be handed the vectors, rather than trusting that + # whoever set the secret picked the right key. Held in a local because + # redact()'s output replaces result.metrics wholesale further down. + sundial_role = assert_role_cannot_read_vectors(base_url, cookie) + report = asyncio.run( + scan( + binary=binary, + base_url=base_url, + cookie=cookie, + os_name=args.os_name, + headless=not args.headful, + timeout=args.timeout, + ) + ) + except SundialUnavailable as exc: + # The service is down, so this browser was never measured. Recording a + # failure would block every merge in the repository on somebody else's + # outage, and recording a pass would claim a measurement that does not + # exist. So: a skip, with the reason attached, which the workflow tells + # ci/summarize.py to tolerate for this suite alone (--allow-skip). + # + # Scrubbed like the failure path below -- an exception raised inside + # urllib can carry the URL that produced it, and for the token route + # that URL contains the credential. + result.note( + scrub(f"stealth check skipped -- {exc}", password) + + " No stealth measurement was taken for this run." + ) + result.finish(evidence.SKIP).save(args.evidence_dir) + return 0 + except Exception as exc: # noqa: BLE001 -- any failure here is a gate failure + # Scrubbed: this note goes to the results artifact and the pull request + # comment, and an exception from deep inside urllib can carry the URL + # that raised it -- which for the token route contains the credential. + result.note(scrub(f"{type(exc).__name__}: {exc}", password)) + result.finish(evidence.ERROR).save(args.evidence_dir) + return 1 + + sealed = seal(report, WORK_DIR / "sundial-full-report.age") + if args.explain: + # Printed, never recorded: `result` is what reaches the artifact and + # the pull-request comment, and none of this goes near it. + for line in explain_buckets( + report, + cfg.get("gated_categories") or [], + cfg.get("ungated_categories") or [], + ): + print(line) + # The plaintext report is dropped here and never referenced again. + try: + metrics = redact( + report, + cfg.get("gated_categories") or [], + cfg.get("ungated_categories") or [], + os_name=args.os_name, + require_score_mode=not args.allow_full_report, + ) + except RuntimeError as exc: + del report + result.note(str(exc)) + result.finish(evidence.ERROR).save(args.evidence_dir) + return 1 + del report + + # Deliberately no per-test map. See redact(): a set of opaque ids is still + # per-vector data, and this gate is judged on the score instead. + result.metrics = metrics + # Re-added after the wholesale assignment above: which role the run + # authenticated as is part of the evidence, not a detail of it. An artifact + # that does not say makes "the vectors were never served to this session" + # unverifiable after the fact, which is most of the point of recording it. + result.metrics["sundial_role"] = sundial_role + if sealed: + result.artifacts.append(sealed.name) + + metrics = result.metrics + # This string reaches the job summary and the pull request. Grade and counts + # only -- no category, no vector, no value. The keys are the ones on + # _PUBLISHABLE; read them through it so that renaming one breaks here rather + # than silently rendering a zero. + result.note( + f"grade {metrics['grade']} -- {metrics['checks_passed']}/{metrics['checks_total']} " + f"in-scope checks passed ({metrics['pass_rate'] * 100:.1f}%). " + f"{metrics['out_of_scope_failed']} out-of-scope check(s) failed; those are measured " + "but not gated, because Camoufox does not claim them." + ) + + floor = float(cfg.get("min_pass_rate", 0) or 0) + status = evidence.PASS + # Absolute rules, independent of the baseline: a run that scores badly fails + # even if the previous release scored just as badly. + violations: List[str] = [] + if metrics["checks_total"] == 0: + result.note("no gated vectors were scored -- treating as a failure, not a pass") + violations.append( + "no gated vectors were scored; the scan produced a report with nothing in scope" + ) + status = evidence.FAIL + elif metrics.get("unknown_category_checks"): + n = metrics["unknown_category_checks"] + result.note( + f"{n} scored check(s) are in a category ci/sundial.yml does not name, so " + "nobody has decided whether Camoufox claims them" + ) + violations.append( + f"{n} scored check(s) fell outside both category lists in ci/sundial.yml. " + "sundial has grown a section; add it to gated_categories or " + "ungated_categories. Failing rather than ignoring it, because ignoring it " + "is a stealth blind spot that reads as a pass." + ) + status = evidence.FAIL + elif metrics["pass_rate"] < floor: + result.note(f"pass rate {metrics['pass_rate']:.3f} is below the policy floor {floor}") + violations.append( + f"stealth pass rate {metrics['pass_rate']:.3f} is below the policy floor {floor}" + ) + status = evidence.FAIL + result.metrics["policy_violations"] = violations + _assert_publishable(result.metrics) + + result.finish(status).save(args.evidence_dir) + # This reports the floor only. Per-test regression against a stored + # baseline is the auto-update harness's job, outside this repository. + return 0 if status == evidence.PASS else 1 + + +def waive(argv: Optional[List[str]] = None) -> int: + """Print a waiver stanza for a vector, without naming the vector in it. + + The file this is pasted into belongs to the auto-update harness and is not + in this repository; the command is here because computing the opaque id + needs the same salt the gate uses. + """ + parser = argparse.ArgumentParser(description="compute a waiver entry for a sundial vector") + parser.add_argument("--key", required=True, help="the vector key, as it appears in the report") + parser.add_argument("--reason", required=True, help="why Camoufox does not claim this") + parser.add_argument("--days", type=int, default=90, help="waiver lifetime (default 90)") + args = parser.parse_args(argv) + + print("\nAdd under gates.sundial.waivers in the auto-update harness's policy file\n" + "(that file is not part of this repository):\n") + print(f" - id: {opaque_id(args.key)}") + print(f" reason: {args.reason}") + print(f" expires: {date.today() + timedelta(days=args.days)}") + print("\n(The key itself is deliberately not written to the file.)\n") + return 0 + + +def main(argv: Optional[List[str]] = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if argv and argv[0] == "waive": + return waive(argv[1:]) + if argv and argv[0] == "status": + return status(argv[1:]) + return gate(argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/skiplist.yml b/ci/skiplist.yml new file mode 100644 index 000000000..b19cf8354 --- /dev/null +++ b/ci/skiplist.yml @@ -0,0 +1,141 @@ +# Upstream Playwright tests Camoufox cannot pass by design. +# +# This is not a list of things that are broken. It is a list of places where +# Camoufox deliberately breaks upstream's automation contract, because that +# contract is exactly what an anti-bot system looks for. Every entry names the +# behaviour and why it is intentional. +# +# The bar for adding an entry: +# +# A test fails because Camoufox does something differently ON PURPOSE, and a +# fix would mean giving up the thing the fork exists to do. +# +# "It was failing when I got here" is not a reason -- that is a regression +# wearing a disguise. `ci/summarize.py` rejects an entry with an empty reason, +# so this file cannot quietly turn into a list of unexplained failures. +# +# A reason is not evidence, though. The first version of this file inherited all +# nine `tests/async/*.disabled` files from the vendored suite and gave each a +# plausible-sounding justification without running any of them: of the 202 tests +# that skipped, 193 passed. A written reason made them look verified, which is +# worse than leaving them bare. +# +# So `ci/run_skiplist_audit.py` now runs every entry with the skiplist disabled +# and FAILS THE BUILD if a skipped test passes. An entry has to keep earning its +# place. Add one only after watching it fail. +# +# Entries are matched against pytest node ids. `module` skips a whole file; +# `test` skips one node id; `pattern` is a substring match, used sparingly. +# +# The suite runs with main-world execution enabled (see ci/pw_camoufox_plugin.py), +# which is why the list is as short as it is -- roughly 37 tests that would +# otherwise fail on "X is not defined" pass without needing an entry here. + +schema: 1 + +skip: + # --- synthesized input ----------------------------------------------------- + # + # Camoufox routes every synthesized mouse and wheel event through the + # humanization chokepoint in additions/juggler/input/MouseDispatch.js: real + # trajectories, real timings, trusted events. + # + # That breaks far less than this section used to claim. test_click.py, + # test_check.py, test_fill.py, test_focus.py, test_dispatch_event.py and + # test_element_handle.py were skipped wholesale -- 160 tests -- and 158 of + # them pass. Only the two below actually fail, and they fail for one specific + # reason rather than for "humanized input" in general. + # + # See docs/input-dispatch.md. tests/patches/humanize-mouse-trajectory.py and + # tests/patches/input-ack-backstop.py assert the behaviour that replaces them. + + - test: tests/async/test_click.py::test_wait_for_stable_position + reason: >- + Upstream animates a button over 500ms and asserts click() waits for it to + stop moving, landing at offset 300. Camoufox clicks at 100 -- mid-flight. + Playwright's stable-position wait polls the bounding box and then dispatches + instantly; the humanized path spends real time travelling to the target, so + the element has moved on by the time the press lands. Narrow and real: the + other 68 tests in this module pass. + + - test: tests/async/test_click.py::test_timeout_waiting_for_stable_position + reason: >- + The same stable-position wait, asserted from the other side -- upstream + expects a timeout error when the element never settles, and Camoufox does + not raise one. + + # --- client certificates the BROWSER has to present ------------------------ + # + # Not the whole module. Playwright offers client certs two ways, and only one + # of them goes through the browser: + # + # playwright.request.new_context(client_certificates=...) -> the Node driver + # does the TLS handshake itself. Works: both tests using it pass. + # browser.new_context(client_certificates=...) -> the BROWSER does + # the handshake, and this build has no client-cert support to do it with. + # + # Listed per test rather than per module because a module entry hid that + # distinction -- and the audit caught it, in CI, one run after it was written. + # Worth recording why it was written: all five fail on a local machine whose + # Node/OpenSSL rejects the fixture server outright ("wrong version number"), + # which looked like uniform absence of support. CI is the authority here. + + - test: tests/async/test_browsercontext_client_certificates.py::test_should_work_with_new_context + reason: >- + browser.new_context(client_certificates=...) needs the browser to present a + certificate during the TLS handshake. The Camoufox build has no client-cert + support, so the server never sees one and the page never renders the + "certificate was issued by" text the test asserts. + + - test: tests/async/test_browsercontext_client_certificates.py::test_should_work_with_new_context_passing_as_content + reason: >- + The same browser-side handshake, with the certificate passed as bytes + rather than a path. + + - test: tests/async/test_browsercontext_client_certificates.py::test_should_work_with_new_persistent_context + reason: >- + The same browser-side handshake, through a persistent context. + + - test: tests/sync/test_browsercontext_client_certificates.py::test_should_work_with_new_context + reason: >- + The sync mirror of the browser-side handshake above. Listed separately so + removing one when support lands cannot silently leave the other behind. + + - test: tests/sync/test_browsercontext_client_certificates.py::test_should_work_with_new_context_passing_as_content + reason: >- + The sync mirror, certificate passed as bytes. + + - test: tests/sync/test_browsercontext_client_certificates.py::test_should_work_with_new_persistent_context + reason: >- + The sync mirror, through a persistent context. + + # --- upstream expectations that encode a stock-Firefox quirk --------------- + # + # These two are not places Camoufox is worse than Firefox. They are places the + # upstream test bakes in something specific to the *stock* browser, which the + # fork deliberately does not reproduce. Both were reproduced against the built + # binary and read in upstream's source before being listed here -- neither is + # on this list because it was failing and looked plausible. + + - test: tests/async/test_worker.py::test_workers_should_format_number_using_context_locale + replaced-by: tests/camoufox/test_worker_locale.py + reason: >- + Asserts that a worker does NOT inherit the context locale. Upstream marks + this with a link to playwright#38919 and expects "10,000.2" -- en-US -- + from a context created with locale="ru-RU", because stock Firefox applies + Playwright's locale override to the page and not to its workers. Camoufox + sets the locale below that layer, so a worker sees it too and formats + "10 000,2", which is what a genuinely ru-RU Firefox prints. Matching + upstream here would mean reintroducing a main-thread/worker locale + disagreement -- a free signal for anything that bothers to look in both. + + - test: tests/async/test_network.py::test_request_headers_should_work + replaced-by: tests/camoufox/test_user_agent_token.py + reason: >- + Asserts "Firefox" appears in the User-Agent. The bare binary advertises its + own build token ("... Gecko/20100101 Camoufox/"); the Python + package replaces it with "Firefox/" on the wire and in + navigator.userAgent as part of injecting a fingerprint, and this suite + drives the bare binary through plain Playwright. The replacement asserts + what this layer can actually promise -- a Gecko UA carrying one of the two + tokens, and the same one in the header as in the DOM. diff --git a/ci/suite.py b/ci/suite.py new file mode 100644 index 000000000..effaa6e73 --- /dev/null +++ b/ci/suite.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Materialise the Playwright suite this run tests against. + +A clean, unmodified checkout of upstream playwright-python at the exact tag +Playwright shipped for the Firefox generation being targeted, plus a virtualenv +holding that same version of the client library. Nothing here is committed; it +is rebuilt per run, which is what "test against the suite for this browser" has +to mean if the result is going to be trustworthy. + +On top of that, `overlay()` copies `tests/camoufox/` in -- the handful of tests +for behaviour upstream has no equivalent for, or asserts the opposite of on +purpose. They run against upstream's own conftest and server rather than a +frozen copy of them, so they cannot drift out of step with the suite around +them. See tests/camoufox/README.md. + +Run: + python3 -m ci.suite --tag v1.62.0 +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +import tarfile +import urllib.request +from pathlib import Path +from typing import List, Optional + +from ._util import CI_DIR, REPO_ROOT, WORK_DIR, die, log, run, write_json + +TARBALL = "https://github.com/microsoft/playwright-python/archive/refs/tags/{tag}.tar.gz" +CAMOUFOX_TESTS = REPO_ROOT / "tests" / "camoufox" +# Names overlaid last time, so a reused checkout can tell our files from +# upstream's without guessing from the name. +OVERLAY_MANIFEST = ".camoufox-overlay.json" + + +def fetch(tag: str, dest: Path) -> Path: + """Download and extract playwright-python at `tag`.""" + dest.mkdir(parents=True, exist_ok=True) + checkout = dest / f"playwright-python-{tag}" + if (checkout / "tests").is_dir(): + log(f"reusing existing checkout {checkout}") + return checkout + + archive = dest / f"{tag}.tar.gz" + url = TARBALL.format(tag=tag) + log(f"fetching {url}") + with urllib.request.urlopen(url, timeout=180) as resp: # noqa: S310 + archive.write_bytes(resp.read()) + + staging = dest / f"_extract-{tag}" + shutil.rmtree(staging, ignore_errors=True) + staging.mkdir(parents=True) + with tarfile.open(archive) as tar: + # Refuse path traversal rather than trusting the archive. + for member in tar.getmembers(): + target = (staging / member.name).resolve() + if not str(target).startswith(str(staging.resolve())): + die(f"refusing to extract {member.name}: escapes the staging directory") + tar.extractall(staging) # noqa: S202 -- members validated above + + roots = [p for p in staging.iterdir() if p.is_dir()] + if len(roots) != 1: + die(f"unexpected archive layout for {tag}: {[p.name for p in roots]}") + shutil.rmtree(checkout, ignore_errors=True) + roots[0].rename(checkout) + shutil.rmtree(staging, ignore_errors=True) + archive.unlink(missing_ok=True) + log(f"upstream suite at {checkout}") + return checkout + + +def make_venv(checkout: Path, tag: str, *, reuse: bool = True) -> Path: + """A virtualenv holding the playwright-python release under test. + + The suite is version-locked to its own client library -- the tests and the + client ship together and a mismatch shows up as failures that look like + browser bugs -- so it gets its own environment rather than the runner's. + """ + venv = checkout / ".venv" + python = venv / "bin" / "python" + if reuse and python.exists(): + log(f"reusing venv {venv}") + return python + + run([sys.executable, "-m", "venv", str(venv)], check=True) + run([str(python), "-m", "pip", "install", "--quiet", "--upgrade", "pip"], check=True) + + # Install the suite's OWN pins, not a hand-picked list. + # + # Guessing them cost a full CI cycle: pytest-asyncio was pinned at 0.21.2 + # while v1.62.0 needs 1.4.0, and upstream's pyproject sets + # asyncio_default_fixture_loop_scope = "session" -- an option 0.21 does not + # understand. Its session-scoped browser fixtures then got a function-scoped + # event loop and every single test errored at setup with ScopeMismatch, + # which reads like the browser is broken and is not. + # + # The suite knows what it needs, and a future tag that changes its pins just + # works instead of failing the same way again. + requirements = checkout / "local-requirements.txt" + if requirements.exists(): + run([str(python), "-m", "pip", "install", "--quiet", "-r", str(requirements)], check=True) + else: + log(f"{requirements} is missing; falling back to a minimal set", level="WARN") + run([str(python), "-m", "pip", "install", "--quiet", + "pytest", "pytest-asyncio", "pytest-timeout", "Pillow", "pixelmatch"], check=True) + + # The client the suite is written against, which its own requirements file + # deliberately does not pin -- it is the package under test upstream. + version = tag.lstrip("v") + run([str(python), "-m", "pip", "install", "--quiet", f"playwright=={version}"], check=True) + + # ci/pw_camoufox_plugin.py reads ci/skiplist.yml from inside this + # interpreter, so PyYAML has to be here and not just in the outer + # environment. Without it every shard dies in pytest_configure. + run([str(python), "-m", "pip", "install", "--quiet", "PyYAML>=6.0"], check=True) + + # Playwright refuses to start if its own browser registry is empty, even + # when every launch is redirected at our binary. + run([str(venv / "bin" / "playwright"), "install", "firefox"], timeout=1800) + return python + + +def unshadow(checkout: Path) -> None: + """Stop the checkout's own `playwright/` directory shadowing the wheel. + + pytest runs with the repository root on `sys.path`, so `import playwright` + resolves to the *source* tree rather than the installed release. That copy + has no generated `_repo_version.py` and no bundled Node driver, so every + test dies at collection with + + ModuleNotFoundError: No module named 'playwright._repo_version' + + which reads like a broken install and is not. Moving it aside makes the + suite import the real, installed client -- which is what we want to be + testing against anyway. + """ + source = checkout / "playwright" + if not source.is_dir(): + return + if (source / "_repo_version.py").exists(): + return # a built checkout; leave it alone + shadowed = checkout / "_playwright_src" + shutil.rmtree(shadowed, ignore_errors=True) + source.rename(shadowed) + log("moved the checkout's playwright/ aside so the installed wheel is used") + + +def overlay(checkout: Path) -> List[str]: + """Copy Camoufox's own tests into the upstream checkout's async suite. + + They land beside upstream's modules so they pick up its `conftest.py` and + `tests/server.py` -- the same fixtures, the same test server, at the same + tag -- instead of needing a frozen copy of the harness to import from. + + A name that already exists upstream is refused rather than overwritten. The + quiet version of that mistake is shadowing an upstream module and silently + dropping every test in it, which looks like a smaller suite and nothing + else. Files this function wrote on a previous run are recorded, so reusing a + checkout overwrites our own copies and does not mistake them for upstream's. + """ + target = checkout / "tests" / "async" + if not target.is_dir(): + die(f"no tests/async/ in {checkout}; the archive layout changed") + + marker = checkout / OVERLAY_MANIFEST + try: + previous = set(json.loads(marker.read_text())) + except (OSError, ValueError): + previous = set() + + copied: List[str] = [] + for source in sorted(CAMOUFOX_TESTS.glob("test_*.py")): + destination = target / source.name + if destination.exists() and source.name not in previous: + die( + f"{source.name} already exists in upstream's suite. Rename the Camoufox " + "copy -- overwriting it would silently drop upstream's tests." + ) + shutil.copy2(source, destination) + copied.append(source.name) + + # A test renamed or deleted here must not linger in a reused checkout, + # where it would keep passing against code that no longer claims it. + for stale in sorted(previous - set(copied)): + (target / stale).unlink(missing_ok=True) + log(f"removed stale overlay {stale}") + + marker.write_text(json.dumps(sorted(copied), indent=2)) + log(f"overlaid {len(copied)} Camoufox test module(s): {', '.join(copied) or 'none'}") + return copied + + +def prepare(tag: str, *, work: Optional[Path] = None, reuse: bool = True) -> dict: + work = work or (WORK_DIR / "upstream-suite") + checkout = fetch(tag, work) + python = make_venv(checkout, tag, reuse=reuse) + unshadow(checkout) + camoufox_tests = overlay(checkout) + + # The plugin travels with the checkout so the suite can be re-run by hand. + shutil.copy2(CI_DIR / "pw_camoufox_plugin.py", checkout / "pw_camoufox_plugin.py") + + manifest = { + "tag": tag, + "checkout": str(checkout), + "python": str(python), + "tests": str(checkout / "tests"), + "camoufox_tests": camoufox_tests, + "test_files": sorted(p.name for p in (checkout / "tests" / "async").glob("test_*.py")), + } + write_json(work / "manifest.json", manifest) + log( + f"suite ready: {len(manifest['test_files'])} async test modules at {tag} " + f"({len(camoufox_tests)} of them Camoufox's own)" + ) + return manifest + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True, help="playwright-python tag, e.g. v1.62.0") + parser.add_argument("--work", type=Path) + parser.add_argument("--fresh", action="store_true", help="rebuild the venv from scratch") + args = parser.parse_args(argv) + prepare(args.tag, work=args.work, reuse=not args.fresh) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/summarize.py b/ci/summarize.py new file mode 100644 index 000000000..9bb282714 --- /dev/null +++ b/ci/summarize.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Fold every result file into one verdict and one readable table. + +Runs last in CI. Three jobs: + + * **Merge shards.** The upstream suite is split across parallel runners, so + `playwright-3of6` and its siblings are folded back into one record + before anything is judged. + * **Decide.** A required suite that produced no result file is a failure, not + a skip -- otherwise deleting a job would be the cheapest way to a green + tick. Anything that failed is a failure. + + One narrow exception, opt-in per suite via `--allow-skip`: a suite that ran + and recorded SKIP with a reason. That exists for the stealth check, which + depends on a separate service -- when sundial is down the browser was never + measured, and blocking every merge in the repository on somebody else's + outage is the wrong answer. It is still not reported as a pass. + * **Report.** Writes the Markdown that lands in the job summary and the pull + request. The stealth line is a grade and a count; it never names a vector. + +It also validates `ci/skiplist.yml`: an entry with no reason fails the run, +because a skiplist that can grow silently is a way to make any test disappear. + +Run: + python3 -m ci.summarize --results-dir .ci-work/results + python3 -m ci.summarize --require build playwright --markdown out.md +""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections import defaultdict +from pathlib import Path +from typing import Dict, List, Optional + +from . import results +from ._util import REPO_ROOT, RESULTS_DIR, SKIPLIST_PATH, log, summary, write_json + +# "playwright-3of6" -> "playwright" +_SHARD_SUFFIX = re.compile(r"-\d+of\d+$") + +# Counts every shard contributes its own share of. Summing rather than taking +# the first shard's is the difference between "6 tests fell back to the main +# world" and "1 did", on a six-way shard -- and this number is the one being +# watched over time, so a sixth of it is worse than none. +_SUMMED_METRICS = frozenset({"main_world_fallback_count", "isolated_world_failures"}) + +# Lists of test identities, which are disjoint across shards by construction. +_UNIONED_METRICS = frozenset({"main_world_fallbacks"}) + +# 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", +] + + +def merge_shards(records: Dict[str, dict]) -> Dict[str, dict]: + """Fold `-of` records back into ``.""" + grouped: Dict[str, List[dict]] = defaultdict(list) + for name, record in records.items(): + grouped[_SHARD_SUFFIX.sub("", name)].append(record) + + merged: Dict[str, dict] = {} + for name, parts in grouped.items(): + if len(parts) == 1: + merged[name] = parts[0] + continue + tests: Dict[str, str] = {} + notes: List[str] = [] + artifacts: List[str] = [] + metrics: Dict[str, object] = {} + statuses = [] + for part in sorted(parts, key=lambda p: str(p.get("gate"))): + for tid, outcome in (part.get("tests") or {}).items(): + # A test that passed on any shard passed; shards are disjoint, + # so this only matters if a retry moved one. + if tests.get(tid) != results.PASS: + tests[tid] = outcome + notes.extend(part.get("notes") or []) + artifacts.extend(part.get("artifacts") or []) + statuses.append(part.get("status")) + for key, value in (part.get("metrics") or {}).items(): + if key in _SUMMED_METRICS and isinstance(value, (int, float)): + metrics[key] = metrics.get(key, 0) + value + elif key in _UNIONED_METRICS and isinstance(value, list): + metrics[key] = sorted(set(metrics.get(key, [])) | set(value)) + else: + # Everything else is a property of the run as a whole (the + # resolved tag, the browser version) and is identical across + # shards, so the first one is the answer. + metrics.setdefault(key, value) + metrics.pop("shard", None) + metrics["shards"] = len(parts) + tally: Dict[str, int] = {} + for outcome in tests.values(): + tally[outcome] = tally.get(outcome, 0) + 1 + tally["total"] = len(tests) + metrics["tally"] = tally + merged[name] = { + "gate": name, + "status": ( + results.ERROR if results.ERROR in statuses + else results.FAIL if results.FAIL in statuses + else results.PASS + ), + "tests": tests, + "metrics": metrics, + "notes": notes, + "artifacts": artifacts, + "run_id": parts[0].get("run_id"), + } + log(f"merged {len(parts)} shards of {name}: {tally.get('total', 0)} tests") + return merged + + +def validate_skiplist(path: Optional[Path] = None) -> List[str]: + """Every skip needs a reason, and every `replaced-by` has to point at a real + file. Returns the problems found. + + The second check is what keeps the "upstream expectations that encode a + stock-Firefox quirk" section honest. Those entries claim a Camoufox-owned + test took over guarding the behaviour; if that file is renamed or deleted the + claim silently becomes false and the behaviour stops being tested by anything. + """ + path = path or SKIPLIST_PATH + if not path.exists(): + return [] + import yaml + + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + problems = [] + for index, entry in enumerate(data.get("skip") or []): + if not isinstance(entry, dict): + problems.append(f"skiplist entry {index} is not a mapping") + continue + target = entry.get("module") or entry.get("test") or entry.get("pattern") + if not target: + problems.append(f"skiplist entry {index} names no module, test or pattern") + if not str(entry.get("reason", "")).strip(): + problems.append( + f"skiplist entry {target!r} has no reason. A skip without a stated reason " + "is indistinguishable from hiding a failure." + ) + replacement = str(entry.get("replaced-by", "")).strip() + if replacement and not (REPO_ROOT / replacement).is_file(): + problems.append( + f"skiplist entry {target!r} says it is replaced by {replacement!r}, " + "which does not exist. Either restore that test or stop claiming " + "the behaviour is still covered." + ) + return problems + + +def _firefox_generation(browser_version: str) -> str: + """`152.0.4` -> `152`. A missing or malformed version is not worth failing on.""" + head = browser_version.split(".")[0].strip() + return head if head.isdigit() else "?" + + +def render(merged: Dict[str, dict], required: List[str], problems: List[str], meta: Dict[str, str]) -> str: + ok = not problems + lines = [ + "## " + ("✅ Tests passed" if ok else "❌ Tests failed"), + "", + f"Camoufox `{meta.get('browser_version', '?')}` " + f"(`{meta.get('browser_release') or 'no release tag'}`), built on Firefox " + f"`{_firefox_generation(meta.get('browser_version', ''))}`, tested against Playwright " + f"`{meta.get('playwright_tag', '?')}` — " + # Say WHY that tag, because the Firefox it pins is rarely the Firefox + # being tested and the bare pair reads like a mismatch. Playwright + # releases trail Firefox and skip generations (it went 151 -> 153, + # never pinning 152), so an exact match is the exception. + + (meta.get("version_note") or f"which targets Firefox `{meta.get('playwright_firefox', '?')}`") + + ".", + "", + "| Suite | Result | Detail |", + "| --- | --- | --- |", + ] + + ordered = [n for n in _ORDER if n in merged] + [n for n in merged if n not in _ORDER] + for name in ordered: + record = merged[name] + status = record.get("status", "error") + icon = {"pass": "✅", "fail": "❌", "error": "💥", "skip": "⏭️"}.get(status, "❓") + tally = (record.get("metrics") or {}).get("tally") or {} + if status == "skip": + # A skipped suite has a reason and no numbers; the reason is the + # only useful thing to show, and showing a grade of "?" next to it + # would read as a measurement that came back empty. + detail = (record.get("notes") or ["skipped"])[-1] + elif name == "sundial": + # Grade and counts only. Never a category, never a vector. + # Named distinctly from the shard `metrics` above so the self-test + # can hold just these reads to sundial's publishable whitelist. + sundial_metrics = record.get("metrics") or {} + detail = ( + f"grade **{sundial_metrics.get('grade', '?')}** — " + f"{sundial_metrics.get('checks_passed', 0)}" + f"/{sundial_metrics.get('checks_total', 0)} " + f"in-scope checks passed" + ) + elif tally: + failed = tally.get("fail", 0) + tally.get("error", 0) + detail = f"{tally.get('pass', 0)} passed, {failed} failed, {tally.get('total', 0)} collected" + shards = (record.get("metrics") or {}).get("shards") + if shards: + detail += f" (across {shards} shards)" + # Published deliberately. These tests pass, so they do not show up + # in the failure count -- but the number is the isolated-world + # conformance gap, and a silent change in it is exactly what this + # line exists to make visible between one run and the next. + fallbacks = (record.get("metrics") or {}).get("main_world_fallback_count") + if fallbacks is not None: + detail += f", {fallbacks} via main-world fallback" + else: + detail = (record.get("notes") or ["—"])[-1] + lines.append(f"| `{name}` | {icon} {status} | {detail} |") + + for name in required: + if name not in merged: + lines.append(f"| `{name}` | 🚫 missing | required, but produced no result |") + + if problems: + lines += ["", "### What failed", ""] + lines += [f"- {p}" for p in problems] + + lines += [ + "", + "The Playwright suite is upstream playwright-python at the tag above, " + "fetched fresh, with [`tests/camoufox/`](tests/camoufox) overlaid. It runs with " + "world isolation on — the configuration Camoufox ships — and only the failures " + "are re-run with it off; those count as passes and are reported above as " + "main-world fallbacks, which is the size of the isolated-world gap. Tests " + "Camoufox cannot pass by design are deselected via " + "[`ci/skiplist.yml`](ci/skiplist.yml) — each with a stated reason. The stealth " + "check reports a grade only; its per-vector detail is deliberately never " + "published.", + ] + return "\n".join(lines) + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--results-dir", type=Path, default=RESULTS_DIR) + parser.add_argument("--require", nargs="*", default=[], help="suites that must have reported") + parser.add_argument("--markdown", type=Path) + parser.add_argument("--out", type=Path) + parser.add_argument("--browser-version", default="") + parser.add_argument("--browser-release", default="") + parser.add_argument("--playwright-tag", default="") + parser.add_argument("--playwright-firefox", default="") + parser.add_argument("--version-note", default="", help="how ci.versions chose the suite") + parser.add_argument("--allow-failure", nargs="*", default=[], + help="suites whose failure is reported but not fatal") + parser.add_argument("--allow-skip", nargs="*", default=[], + help=( + "suites allowed to record a reasoned SKIP without failing the " + "run. For a suite that depends on something outside this " + "repository -- the stealth check needs sundial to be up." + )) + args = parser.parse_args(argv) + + records = results.load_all(args.results_dir) + merged = merge_shards(records) + problems: List[str] = [] + + problems.extend(validate_skiplist()) + + for name in args.require: + if name not in merged: + problems.append( + f"`{name}` is required but produced no result file. A suite that did not " + "run has not passed." + ) + + for name, record in sorted(merged.items()): + if record.get("status") == results.PASS: + continue + note = (record.get("notes") or ["see the job log"])[-1] + if record.get("status") == results.SKIP and name in args.allow_skip: + # Reported, never silent: it is on the summary table with its reason + # and an explicit skip icon. What it is not is a merge block. + log(f"{name} was skipped: {note}", level="WARN") + continue + if name in args.allow_failure: + log(f"{name} failed but is advisory on this run", level="WARN") + continue + problems.append(f"`{name}` reported {record.get('status')}: {note}") + + meta = { + "browser_version": args.browser_version, + "browser_release": args.browser_release, + "playwright_tag": args.playwright_tag, + "playwright_firefox": args.playwright_firefox, + "version_note": args.version_note, + } + markdown = render(merged, args.require, problems, meta) + print() + print(markdown) + print() + summary(markdown) + + if args.markdown: + args.markdown.parent.mkdir(parents=True, exist_ok=True) + args.markdown.write_text(markdown + "\n", encoding="utf-8") + if args.out: + write_json(args.out, { + "ok": not problems, + "problems": problems, + "suites": { + name: { + "status": r.get("status"), + "metrics": r.get("metrics"), + "notes": r.get("notes"), + } + for name, r in merged.items() + }, + **meta, + }) + + if problems: + log(f"{len(problems)} problem(s)", level="ERROR") + for problem in problems: + log(f" {problem}", level="ERROR") + return 1 + log("all suites passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/sundial.yml b/ci/sundial.yml new file mode 100644 index 000000000..fe42a976a --- /dev/null +++ b/ci/sundial.yml @@ -0,0 +1,73 @@ +# Scope and thresholds for the stealth check. +# +# Lives here, in the repository, because the stealth check is a repo-wide test +# that runs on every pull request; the auto-update harness (which is not part of +# this repository) is just one of its callers. Its own policy file keeps the +# things only that pipeline cares about -- baselines, regression waivers, which +# gates are mandatory. +# +# Nothing in this file is secret. What is secret is the report sundial returns, +# and that never leaves ci/run_sundial.py::redact() intact. + +schema: 1 + +# Whether CI may run the stealth check at all. +# +# ON as of 2026-09-12. sundial 0.5.0 is deployed and serving score mode -- +# verified by the version on the served page and by the successful "Deploy to +# Cloudflare Pages" run for `d024d51 Bump Sundial to 0.5.0`. master now deploys +# itself on push, so merged does mean deployed. +# +# While this was false the job was not scheduled, the credential never reached a +# runner, and `ci/run_sundial.py` refused to run by hand. It stayed false for as +# long as the live build predated score mode, because an older sundial ignores +# `?score=1` and posts the whole report -- every vector's id, name, brief, source +# and measured value -- to whatever collector asked for it, and `redact()` +# discarding it afterwards is not the same guarantee as never having been sent +# it. +# +# What holds the line now, in the order a vector would have to get past: +# 1. the run logs in as `guest`, which sundial's middleware refuses the +# private-vector bundle to -- and the gate confirms that from sundial's own +# /__auth/me before the browser opens, rather than assuming whoever set the +# secret picked the guest key rather than the private one +# 2. the only URL this gate ever requests is `/?auto=1&score=1`, so sundial +# posts counts rather than a report +# 3. `redact(require_score_mode=True)` fails the run outright if a full report +# arrives anyway, rather than folding it down and continuing +# 4. only whitelisted keys reach the summary, asserted by an AST walk over +# every consumer in ci/tests/test_ci.py +# +# Set SUNDIAL_USERNAME=ci once sundial's score-only `ci` role is merged and +# deployed; that makes step 2 server-enforced rather than a promise this repo +# keeps. Until then the job needs SUNDIAL_AUTOMATION_KEY set on every repo whose +# CI runs it; without it the job skips, which is the normal case for a fork PR. +enabled: true + +url: https://sundial.daijro.dev + +# Only gate on categories Camoufox actually claims to implement. A vector +# outside this list is still measured and counted, but cannot fail a build -- +# failing one would mean gating on a promise nobody made. +gated_categories: + - Identity + - Security + - JS Engine + - Display + - Locale + - Network + +# Measured and counted, never gated. Cross-OS rendering parity lives here: +# Camoufox does not claim byte-identical emulation of another platform's +# rasterizer, its audio stack, or its CPU timing signature. +ungated_categories: + - Graphics + - Audio + - CPU + +# A run scoring below this fails, even with no regression against the baseline. +min_pass_rate: 0.90 + +# Grade boundaries, for the single letter that goes public. +# (Defined in ci/run_sundial.py::grade; listed here for reference.) +# A+ >= 0.99 A >= 0.97 B >= 0.94 C >= 0.90 D >= 0.80 else F diff --git a/tests/__init__.py b/ci/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to ci/tests/__init__.py diff --git a/ci/tests/test_ci.py b/ci/tests/test_ci.py new file mode 100644 index 000000000..7cb88236a --- /dev/null +++ b/ci/tests/test_ci.py @@ -0,0 +1,2551 @@ +"""Self-tests for the repo-wide CI pipeline. + +The pipeline's job is to run the right suite against the right browser and +report honestly. These cover the parts where "honestly" is load-bearing: + + * nothing identifying a sundial vector may survive redaction, and the public + output is a grade rather than a breakdown of what is weak; + * a skip must carry a reason, or it is indistinguishable from hiding a test; + * sharding must be stable, so a flake does not appear to move between runners; + * version resolution must never pick a suite newer than the browser, nor one + above the Playwright ceiling pythonlib pins; + * test identities must survive being run from a different working directory. + +Run: python3 -m pytest ci/tests -q +""" + +from __future__ import annotations + +import json +import os +import pathlib +import re +import tempfile +import urllib.error +import urllib.parse + +import pytest + +from ci import results +from ci._util import CI_DIR as CI_ROOT +from ci._pytest import junit_test_id +from ci._util import bump_release, opaque_id, parse_version, read_upstream_sh, write_upstream_sh +from ci.pw_camoufox_plugin import load_skiplist, parse_shard, shard_of, skip_reason +from ci.run_sundial import _iter_entries, grade, redact +from ci.summarize import merge_shards, validate_skiplist +from ci.versions import resolve + + +# --------------------------------------------------------------------------- +# sundial redaction -- the one that must never regress +# --------------------------------------------------------------------------- + + +SECRETS = [ + "canvas.rasterizer.subpixel-drift", + "A private vector name nobody may publish", + "checks whether the FMA3 path is present", + "return Math.fround(x) !== y", + "3.14159265358979", +] + +FULL_REPORT = { + "schemaVersion": 1, + "sundialVersion": "0.3.1", + "identity": {"name": "Firefox", "os": "linux", "engine": "SpiderMonkey", "tz": "UTC"}, + "summary": {"total": 3, "pass": 1, "fail": 2}, + "failures": { + "Graphics": [{ + "key": SECRETS[0], "id": "gfx-1", "name": SECRETS[1], "brief": SECRETS[2], + "src": SECRETS[3], "source": SECRETS[3], "value": SECRETS[4], + "expect": SECRETS[4], "category": "Graphics", "status": "fail", + }], + "Identity": [{ + "key": "identity.nav.oscpu", "id": "id-9", "name": SECRETS[1], + "value": SECRETS[4], "category": "Identity", "status": "fail", + }], + }, + "succeeded": { + "Identity": [{ + "key": "identity.nav.platform", "id": "id-3", "name": SECRETS[1], + "value": SECRETS[4], "category": "Identity", "status": "pass", + }], + }, + "private": { + "failures": { + "Locale": [{ + "key": "pv-secret-vector", "id": "pv-1", "name": SECRETS[1], + "src": SECRETS[3], "category": "Locale", "status": "fail", + }], + }, + }, +} + +GATED = ["Identity", "Security", "JS Engine", "Display", "Locale", "Network"] +UNGATED = ["Graphics", "Audio", "CPU"] + + +def test_redaction_leaks_nothing_identifying(): + blob = json.dumps(redact(FULL_REPORT, GATED, UNGATED, os_name="linux", require_score_mode=False)) + for secret in SECRETS: + assert secret not in blob, f"redaction leaked: {secret!r}" + for key in ("canvas.rasterizer.subpixel-drift", "identity.nav.oscpu", "pv-secret-vector"): + assert key not in blob, f"redaction leaked the vector key {key!r}" + + +def test_only_whitelisted_keys_are_published(): + """A whitelist, not a blacklist. + + A blacklist only stops the leaks somebody already thought of: add a field to + redact() and forget to consider it, and a blacklist ships it. This fails. + """ + from ci.run_sundial import _PUBLISHABLE + + out = redact(FULL_REPORT, GATED, UNGATED, os_name="linux", require_score_mode=False) + assert set(out) <= _PUBLISHABLE, f"published beyond the whitelist: {set(out) - _PUBLISHABLE}" + assert set(out) == { + "grade", "checks_total", "checks_passed", "pass_rate", + "out_of_scope_failed", "unknown_category_checks", + "cross_os_total", "cross_os_passed", + "score_mode", "os", "sundial_version", "schema_version", + } + + +def test_publishing_an_unlisted_key_is_refused(): + """The whitelist has to actually bite, not just describe intent.""" + from ci.run_sundial import _assert_publishable + + with pytest.raises(RuntimeError, match="whitelist"): + _assert_publishable({"grade": "A", "by_category": {"Graphics": 3}}) + + +def test_no_per_vector_rows_survive(): + """Not even opaque ones. + + An HMAC names nothing, but a map of them says how many distinct checks fail + and lets a reader follow the same id across releases. The instruction was a + score, so there are no rows at all. + """ + out = redact(FULL_REPORT, GATED, UNGATED, os_name="linux", require_score_mode=False) + assert "tests" not in out and "ungated_tests" not in out + for value in out.values(): + assert not isinstance(value, dict), f"a mapping survived redaction: {value!r}" + # And nothing that looks like an opaque id. + blob = json.dumps(out) + for key in ("canvas.rasterizer.subpixel-drift", "identity.nav.oscpu", "pv-secret-vector"): + assert opaque_id(key) not in blob + + +def test_no_category_names_are_published(): + """A table reading "Graphics 3/17" is the most useful single fact an + adversary could take from a public CI log.""" + blob = json.dumps(redact(FULL_REPORT, GATED, UNGATED, os_name="linux", require_score_mode=False)) + for category in GATED + UNGATED: + assert category not in blob, f"published output names the category {category!r}" + + +def test_only_in_scope_checks_are_scored(): + out = redact(FULL_REPORT, GATED, UNGATED, os_name="linux", require_score_mode=False) + # Identity: one pass, one fail. Locale (private): one fail. -> 1/3 in scope. + assert out["checks_total"] == 3 + assert out["checks_passed"] == 1 + assert out["pass_rate"] == pytest.approx(1 / 3, abs=1e-4) + # Graphics is out of scope: counted, never scored. + assert out["out_of_scope_failed"] == 1 + + +@pytest.mark.parametrize( + "rate,expected", + [(1.0, "A+"), (0.99, "A+"), (0.975, "A"), (0.95, "B"), (0.91, "C"), (0.85, "D"), (0.5, "F")], +) +def test_grade_boundaries(rate, expected): + assert grade(rate) == expected + + +def test_iter_entries_finds_public_and_private(): + keys = {k for k, _, _ in _iter_entries(FULL_REPORT)} + assert "pv-secret-vector" in keys + assert "identity.nav.platform" in keys + + +# --------------------------------------------------------------------------- +# small pieces +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "classname,name,expected", + [ + ("tests.async.test_page", "test_foo", "async/test_page.py::test_foo"), + ("async.test_page", "test_foo", "async/test_page.py::test_foo"), + # A test inside a class: the trailing segment is the class, not a + # module, so it has to stay on the far side of the `.py`. + ( + "async.test_page_clock.TestWhileRunning", + "test_should_pause", + "async/test_page_clock.py::TestWhileRunning::test_should_pause", + ), + ( + "tests.async.test_page_clock.TestWhileRunning", + "test_should_pause", + "async/test_page_clock.py::TestWhileRunning::test_should_pause", + ), + # Nested classes keep their order. + ( + "async.test_x.TestOuter.TestInner", + "test_y", + "async/test_x.py::TestOuter::TestInner::test_y", + ), + # Nothing that looks like a test module: fall back to the old shape + # rather than inventing one. + ("some.module", "test_z", "some/module.py::test_z"), + ], +) +def test_junit_ids_are_stable_across_rootdirs(classname, name, expected): + """How many leading segments junit reports depends on where pytest ran.""" + assert junit_test_id(classname, name) == expected + + +def test_a_class_based_id_is_a_node_id_pytest_would_accept(): + """The id is fed back to pytest and matched against ci/skiplist.yml. + + Both uses need a real node id: `path/to/file.py::Class::test`. The bug this + guards against turned the class into a directory + (`test_page_clock/TestWhileRunning.py::test_should_pause`), which named no + file on disk, so `--last-failed` could not re-run it and no skiplist entry + could match it. + """ + tid = junit_test_id("async.test_page_clock.TestWhileRunning", "test_should_pause") + path, _, rest = tid.partition("::") + assert path.endswith(".py") + assert "TestWhileRunning" not in path, "the class must not become part of the path" + assert rest == "TestWhileRunning::test_should_pause" + + +@pytest.mark.parametrize( + "given,expected", + [("beta.31", "beta.32"), ("beta.9", "beta.10"), ("alpha", "alpha.1")], +) +def test_release_bump(given, expected): + assert bump_release(given) == expected + + +def test_version_parsing(): + assert parse_version("153.0.4") == (153, 0, 4) + assert parse_version("155.0") == (155, 0, 0) + + +def test_upstream_sh_roundtrip_preserves_comments(tmp_path): + path = tmp_path / "upstream.sh" + path.write_text("# a comment\nversion=152.0.4\nrelease=beta.31\nclosedsrc_rev=1.0.0\n") + write_upstream_sh({"version": "153.0.4", "release": "beta.32"}, path) + text = path.read_text() + assert "# a comment" in text + assert "closedsrc_rev=1.0.0" in text + assert read_upstream_sh(path)["version"] == "153.0.4" + + +def test_gate_result_keeps_the_best_outcome_across_retries(): + result = results.GateResult(gate="x") + result.record("t", "fail") + result.record("t", "pass") + result.record("t", "fail") + assert result.tests["t"] == "pass" + + + + +# --------------------------------------------------------------------------- +# skiplist +# --------------------------------------------------------------------------- + + +SKIPS = [ + {"module": "tests/async/test_click.py", "reason": "humanized input"}, + {"test": "tests/async/test_page.py::test_one", "reason": "specific"}, + {"pattern": "[chromium]", "reason": "not a Chromium fork"}, +] + + +@pytest.mark.parametrize( + "nodeid,expected", + [ + ("tests/async/test_click.py::test_anything", "humanized input"), + ("tests/async/test_clicker.py::test_anything", None), # prefix must not over-match + ("tests/async/test_page.py::test_one", "specific"), + ("tests/async/test_page.py::test_two", None), + ("tests/async/test_x.py::test_y[chromium]", "not a Chromium fork"), + ("tests/async/test_x.py::test_y[firefox]", None), + # Every test in the suite is parameterised by browser, so this is the + # only spelling a `test:` entry ever actually meets. + ("tests/async/test_page.py::test_one[firefox]", "specific"), + ("tests/async/test_page.py::test_two[firefox]", None), + # Stripping the parameters must not widen the match to a longer name. + ("tests/async/test_page.py::test_one_more[firefox]", None), + ], +) +def test_skip_matching(nodeid, expected): + assert skip_reason(nodeid, SKIPS) == expected + + +def test_every_shipped_test_entry_matches_a_parameterised_node_id(): + """A `test:` entry that only matches the unparameterised id is a no-op. + + The suite runs `--browser firefox`, so pytest's ids all end in `[firefox]`. + An entry compared for exact equality against that never fires: the test goes + on running and failing while the skiplist reads as though it were handled. + This asserts the shipped entries match the id shape they will really see. + """ + entries = load_skiplist() + targets = [str(e["test"]) for e in entries if "test" in e] + assert targets, "no `test:` entries -- drop this test if that is intentional" + for target in targets: + assert skip_reason(f"{target}[firefox]", entries), ( + f"{target} does not match its own parameterised node id" + ) + + +def test_the_shipped_skiplist_is_valid(): + """Every entry names something and says why.""" + assert validate_skiplist() == [] + entries = load_skiplist() + assert entries, "the shipped skiplist is empty" + for entry in entries: + assert str(entry.get("reason", "")).strip() + + +def test_an_unreasoned_skip_is_rejected(tmp_path): + path = tmp_path / "skiplist.yml" + path.write_text("schema: 1\nskip:\n - module: tests/async/test_x.py\n") + problems = validate_skiplist(path) + assert problems and "no reason" in problems[0] + + +def test_a_replaced_by_that_names_nothing_is_rejected(tmp_path): + """Two entries skip an upstream test because a Camoufox test took the job on. + + That claim is only true while the named file exists. Rename or delete it and + the skip silently becomes "nothing checks this any more" -- which is exactly + the state the skiplist's stated-reason rule exists to prevent, arrived at + from the other direction. + """ + path = tmp_path / "skiplist.yml" + path.write_text( + "schema: 1\n" + "skip:\n" + " - test: tests/async/test_x.py::test_y\n" + " replaced-by: tests/camoufox/test_nope.py\n" + " reason: superseded\n" + ) + problems = validate_skiplist(path) + assert problems and "does not exist" in problems[0] + + +def test_every_replaced_by_in_the_shipped_skiplist_resolves(): + """The shipped file, not a fixture -- this is the one that has to hold.""" + assert not validate_skiplist() + + +def test_load_skiplist_refuses_an_unreasoned_entry(tmp_path, monkeypatch): + """The plugin must refuse too -- validating only in the summary would let a + skip take effect for the whole run before anyone objected.""" + path = tmp_path / "skiplist.yml" + path.write_text("schema: 1\nskip:\n - module: tests/async/test_x.py\n") + monkeypatch.setenv("CI_SKIPLIST", str(path)) + with pytest.raises(RuntimeError, match="no reason"): + load_skiplist() + + +# --------------------------------------------------------------------------- +# sharding +# --------------------------------------------------------------------------- + + +def test_shards_partition_the_suite_exactly_once(): + nodeids = [f"tests/async/test_{i // 20}.py::test_{i}" for i in range(600)] + seen = {} + for shard in range(1, 7): + for nodeid in nodeids: + if shard_of(nodeid, 6) == shard: + assert nodeid not in seen, f"{nodeid} landed in two shards" + seen[nodeid] = shard + assert len(seen) == len(nodeids), "some tests landed in no shard" + + +def test_shards_are_roughly_even(): + nodeids = [f"tests/async/test_{i // 20}.py::test_{i}" for i in range(1500)] + counts = [sum(1 for n in nodeids if shard_of(n, 6) == s) for s in range(1, 7)] + assert min(counts) > len(nodeids) / 6 * 0.8, counts + + +def test_shard_membership_is_stable_when_a_test_is_inserted(): + """Hashed, not positional: adding a test in the middle of a file must not + reshuffle every later test, or a flake looks like it moved runners.""" + before = {n: shard_of(n, 6) for n in ("a::t1", "a::t2", "a::t3")} + after = {n: shard_of(n, 6) for n in ("a::t1", "a::t_new", "a::t2", "a::t3")} + for nodeid, shard in before.items(): + assert after[nodeid] == shard + + +@pytest.mark.parametrize("raw,expected", [("3/6", (3, 6)), ("1/1", (1, 1)), (None, None), ("", None)]) +def test_parse_shard(raw, expected): + assert parse_shard(raw) == expected + + +@pytest.mark.parametrize("raw", ["0/6", "7/6", "abc", "3/0"]) +def test_parse_shard_rejects_nonsense(raw): + with pytest.raises(RuntimeError): + parse_shard(raw) + + +# --------------------------------------------------------------------------- +# version resolution +# --------------------------------------------------------------------------- + + +PINS = [("v1.62.0", "153.0"), ("v1.61.0", "151.0"), ("v1.60.0", "150.0.2"), ("v1.58.0", "146.0.1")] + + +@pytest.fixture +def offline_pins(monkeypatch): + monkeypatch.setattr("ci.versions.pins", lambda limit=10: list(PINS)) + + +def test_never_picks_a_suite_newer_than_the_browser(offline_pins, monkeypatch): + """A newer suite assumes engine work this build does not have, so every + failure it reports would be ambiguous.""" + monkeypatch.setattr("ci.versions.read_upstream_sh", lambda: {"version": "152.0.4", "release": "beta.31"}) + out = resolve() + assert out["playwright_tag"] == "v1.61.0" + assert parse_version(out["playwright_firefox"]) <= parse_version("152.0.4") + + +def test_the_harness_can_pass_a_version_in(offline_pins, monkeypatch): + monkeypatch.setattr("ci.versions.read_upstream_sh", lambda: {"version": "152.0.4", "release": "beta.31"}) + assert resolve(browser_version="153.0.4")["playwright_tag"] == "v1.62.0" + assert resolve(browser_version="146.0.1")["playwright_tag"] == "v1.58.0" + + +def test_an_explicit_tag_wins(offline_pins, monkeypatch): + monkeypatch.setattr("ci.versions.read_upstream_sh", lambda: {"version": "146.0.1", "release": "beta.1"}) + out = resolve(playwright_tag="v1.62.0") + assert out["playwright_tag"] == "v1.62.0" + + +def test_a_browser_older_than_every_suite_still_resolves(offline_pins, monkeypatch): + """An old branch should still get tested, loudly, rather than not at all.""" + monkeypatch.setattr("ci.versions.read_upstream_sh", lambda: {"version": "120.0", "release": "old"}) + out = resolve() + assert out["playwright_tag"] == "v1.58.0" + assert "oldest available" in out["note"] + + +# --------------------------------------------------------------------------- +# shard merging +# --------------------------------------------------------------------------- + + +def test_shards_merge_into_one_record(): + records = { + "playwright_upstream-1of3": { + "gate": "playwright_upstream-1of3", "status": "pass", + "tests": {"a::t1": "pass"}, "metrics": {"shard": "1/3"}, "notes": ["ok"], + }, + "playwright_upstream-2of3": { + "gate": "playwright_upstream-2of3", "status": "fail", + "tests": {"a::t2": "fail"}, "metrics": {"shard": "2/3"}, "notes": ["one failed"], + }, + "playwright_upstream-3of3": { + "gate": "playwright_upstream-3of3", "status": "pass", + "tests": {"a::t3": "pass"}, "metrics": {"shard": "3/3"}, "notes": ["ok"], + }, + "build": {"gate": "build", "status": "pass", "tests": {}, "metrics": {}, "notes": []}, + } + merged = merge_shards(records) + assert set(merged) == {"playwright_upstream", "build"} + combined = merged["playwright_upstream"] + assert combined["tests"] == {"a::t1": "pass", "a::t2": "fail", "a::t3": "pass"} + assert combined["status"] == "fail", "one failing shard must fail the suite" + assert combined["metrics"]["shards"] == 3 + assert combined["metrics"]["tally"]["total"] == 3 + assert "shard" not in combined["metrics"] + + +def test_an_errored_shard_beats_a_failed_one(): + records = { + "s-1of2": {"gate": "s-1of2", "status": "fail", "tests": {"a::1": "fail"}, "metrics": {}, "notes": []}, + "s-2of2": {"gate": "s-2of2", "status": "error", "tests": {}, "metrics": {}, "notes": []}, + } + assert merge_shards(records)["s"]["status"] == "error" + + +def test_every_native_test_file_is_actually_run(): + """A suite that exists but is not in the runner's list is invisible. + + test_crash_recovery.py was written, passing, and unwired for a while -- the + kind of gap that looks like coverage on the filesystem and is nothing in CI. + """ + from pathlib import Path + + from ci._util import REPO_ROOT + from ci.run_native import FILES + + on_disk = {p.name for p in (REPO_ROOT / "native-tests").glob("test_*.py")} + # Every group, not a hardcoded pair -- otherwise adding a subset silently + # narrows the check, which is the same class of hole it exists to catch. + wired = {f for group in FILES.values() for f in group} + missing = on_disk - wired + assert not missing, f"native-tests files that no subset runs: {sorted(missing)}" + assert not wired - on_disk, f"runner lists files that do not exist: {sorted(wired - on_disk)}" + + +# --------------------------------------------------------------------------- +# sundial's score-only payload, with cross-OS split out +# --------------------------------------------------------------------------- + + +SCORE_PAYLOAD = { + "schemaVersion": 1, + "mode": "score", + "sundialVersion": "0.3.1", + "identity": {"name": "Firefox", "os": "linux"}, + "buckets": { + # in scope, browser's own behaviour + "Identity|core": {"scored": 40, "passed": 39, "failed": 1}, + "Network|core": {"scored": 12, "passed": 12, "failed": 0}, + # in scope by category, but reads the host machine + "Locale|crossOs": {"scored": 18, "passed": 4, "failed": 14}, + # out of scope entirely + "Graphics|core": {"scored": 9, "passed": 6, "failed": 3}, + "Graphics|crossOs": {"scored": 6, "passed": 1, "failed": 5}, + }, +} + + +def test_cross_os_failures_never_count_against_the_score(): + """A browser claiming macOS on Linux fails the host-OS detectors regardless. + + Those read the machine underneath, not the disguise, and Camoufox does not + claim byte-identical cross-OS emulation -- so counting them would mark it + down for a promise nobody made. + """ + out = redact(SCORE_PAYLOAD, GATED, UNGATED, os_name="linux") + # Identity + Network core only: 52 scored, 51 passed. + assert out["checks_total"] == 52 + assert out["checks_passed"] == 51 + assert out["pass_rate"] == pytest.approx(51 / 52, abs=1e-4) + # The 14 Locale cross-OS failures did not drag it down... + assert out["grade"] == "A" + # ...but they are still reported, from both categories. + assert out["cross_os_total"] == 24 + assert out["cross_os_passed"] == 5 + + +def test_out_of_scope_failures_are_counted_but_not_scored(): + out = redact(SCORE_PAYLOAD, GATED, UNGATED, os_name="linux") + # Graphics is not a gated category: its 3 core failures are counted only. + assert out["out_of_scope_failed"] == 3 + + +def test_the_score_payload_publishes_no_categories_or_classes(): + """Checked against keys and values, not the raw JSON text. + + A substring scan flagged "score_mode" for containing "core", which is the + kind of false positive that gets an assertion loosened until it stops + catching anything. + """ + out = redact(SCORE_PAYLOAD, GATED, UNGATED, os_name="linux") + forbidden = {"Identity", "Network", "Locale", "Graphics", "crossOs", "core", "buckets"} + + assert not (set(out) & forbidden), f"a published key names a category or class: {set(out) & forbidden}" + leaked = [v for v in out.values() if isinstance(v, str) and v in forbidden] + assert not leaked, f"a published value names a category or class: {leaked}" + # And no nested structure that could carry one. + assert all(not isinstance(v, (dict, list)) for v in out.values()) + + +def test_a_full_report_still_folds_to_the_same_shape(): + """Older sundial, or a deliberate local run, must not break the gate.""" + from ci.run_sundial import _PUBLISHABLE + + out = redact(FULL_REPORT, GATED, UNGATED, os_name="linux", require_score_mode=False) + assert set(out) <= _PUBLISHABLE + assert out["checks_total"] == 3 and out["checks_passed"] == 1 + # A full report carries no class tags, so nothing is attributed to cross-OS. + assert out["cross_os_total"] == 0 + + +def test_a_deployment_without_score_mode_is_flagged_not_silent(): + """An old sundial ignores ?score=1 and posts the whole report. + + The numbers still come out right, but nothing is classified, so every + cross-OS tally reads zero -- which looks like "no host-OS failures" rather + than "nobody sorted them". score_mode says which of those it is. + """ + assert redact(SCORE_PAYLOAD, GATED, UNGATED, os_name="linux")["score_mode"] is True + legacy = redact(FULL_REPORT, GATED, UNGATED, os_name="linux", require_score_mode=False) + assert legacy["score_mode"] is False + assert legacy["cross_os_total"] == 0 + + +# --------------------------------------------------------------------------- +# cross-profile uniqueness, judged by what each slot promises +# --------------------------------------------------------------------------- + + +def _cross(**kw): + base = {"total": 3, "uniqueAudio": 3, "uniqueCanvas": 3, "uniqueFonts": 3, + "uniqueTimezones": 3, "uniqueScreens": 3, "uniqueVoices": 3, + "uniqueWebGL": 3, "uniquePlatforms": 1} + base.update(kw) + return {"crossProfile": {"macPerContext": base}} + + +def test_a_shared_per_context_value_is_a_leak(): + """audio, canvas and timezone are derived per context. + + Two contexts sharing one is the failure this whole suite exists to catch. + """ + from ci.run_build_tester import uniqueness + + for slot in ("uniqueAudio", "uniqueTimezones"): + out = uniqueness(_cross(**{slot: 1})) + assert out["leaks"] == [f"macPerContext.{slot} (1/3 distinct)"], slot + assert not out["noise"] + + +def test_canvas_collisions_are_tracked_but_do_not_gate(): + """Canvas belongs in must-vary and does not hold there yet. + + Measured 16 distinct canvas fingerprints in 24 samples where audio gave + 24/24 -- so two contexts collide about a third of the time. Gating would + fail one run in three for a real, unfixed reason; silence would lose the + finding. It gets its own bucket and is reported every run. + """ + from ci.run_build_tester import uniqueness + + out = uniqueness(_cross(uniqueCanvas=2)) + assert out["low_entropy"] == ["macPerContext.uniqueCanvas (2/3 distinct)"] + assert not out["leaks"] and not out["noise"] + + +def test_a_shared_preset_value_is_noise_not_a_leak(): + """fonts, screens, voices and WebGL come from a pool of real devices. + + Three draws from a dozen collide regularly. Reported, never fatal. + """ + from ci.run_build_tester import uniqueness + + for slot in ("uniqueFonts", "uniqueScreens", "uniqueVoices", "uniqueWebGL"): + out = uniqueness(_cross(**{slot: 2})) + assert out["noise"] == [f"macPerContext.{slot} (2/3 distinct)"], slot + assert not out["leaks"] + + +def test_identical_platforms_are_correct_not_a_collision(): + """Every macOS context reports MacIntel. That is what macOS reports. + + The flat count that preceded this read three contexts agreeing as three + collisions and failed a run that scored 1054/1054. + """ + from ci.run_build_tester import uniqueness + + out = uniqueness(_cross(uniquePlatforms=1)) + assert not out["leaks"] and not out["not_constant"] and not out["noise"] + + +def test_a_platform_that_varies_within_one_os_is_the_bug(): + from ci.run_build_tester import uniqueness + + out = uniqueness(_cross(uniquePlatforms=3)) + assert out["not_constant"] == ["macPerContext.uniquePlatforms (3/3 distinct)"] + + +def test_zero_distinct_is_absence_not_collision(): + """Nothing collected -- no speech voices headless, say. + + Counting it as a collision reports a leak where there is no data at all. + """ + from ci.run_build_tester import uniqueness + + out = uniqueness(_cross(uniqueVoices=0)) + assert out["absent"] == ["macPerContext.uniqueVoices (0/3 distinct)"] + assert not out["leaks"] and not out["noise"] + + +def test_version_resolution_honours_pythonlibs_playwright_ceiling(): + """The resolver must not pick a client pythonlib refuses to install. + + `camoufox.server` imports `playwright._impl._driver`, a private API, so + pythonlib pins a ceiling. Without this filter a Firefox bump silently moves + the suite above that ceiling, and whatever Juggler changed in between is + reported as a browser failure in a suite nobody shipping the package could + reproduce. + + Pinned inputs, so this tests the rule and not today's release list. + """ + from ci import versions + + available = [("v1.64.0", "158.0"), ("v1.63.0", "156.0"), ("v1.62.0", "153.0")] + real_pins, real_ceiling = versions.pins, versions.client_ceiling + try: + versions.pins = lambda limit=10: list(available) + versions.client_ceiling = lambda: (1, 63, 0) + resolved = versions.resolve(browser_version="158.0.1") + finally: + versions.pins, versions.client_ceiling = real_pins, real_ceiling + + assert resolved["playwright_tag"] == "v1.62.0", ( + "resolved to a Playwright at or above pythonlib's ceiling; the suite would " + "install a client the shipped package forbids" + ) + + +def test_pythonlib_still_pins_a_playwright_ceiling(): + """The filter above is only as real as the pin it reads. + + If the ceiling is dropped from pyproject.toml, `client_ceiling()` returns + None and the filter quietly stops applying -- so assert the pin exists here + rather than discovering it from a protocol failure later. + """ + from ci.versions import client_ceiling + + assert client_ceiling() is not None, ( + "pythonlib/pyproject.toml no longer pins a playwright ceiling, so " + "ci.versions has nothing to clamp the suite to" + ) + + +# --------------------------------------------------------------------------- +# score mode is a requirement, not a preference +# --------------------------------------------------------------------------- + + +def test_a_full_report_is_refused_by_default(): + """The gate only ever asks for `?auto=1&score=1`. + + So a full report arriving here means the deployment did not honour it. That + is the one case where the vectors really are in this process, and folding + them down anyway would mean they passed through and nobody noticed -- the + failure has to be loud. + """ + with pytest.raises(RuntimeError, match="full report, not a score"): + redact(FULL_REPORT, GATED, UNGATED, os_name="linux") + + +def test_a_full_report_still_folds_when_explicitly_allowed(): + """The escape hatch is for a local run under an account allowed a report.""" + out = redact(FULL_REPORT, GATED, UNGATED, os_name="linux", require_score_mode=False) + assert out["score_mode"] is False + assert out["checks_total"] > 0 + + +def test_the_score_payload_is_accepted_without_the_flag(): + assert redact(SCORE_PAYLOAD, GATED, UNGATED, os_name="linux")["score_mode"] is True + + +def test_consumers_only_read_published_metric_keys(): + """Every `metrics[...]` read in the pipeline must name a key redact() emits. + + This is the check that was missing when `redact()` renamed `gated_passed` to + `checks_passed`: the redaction tests all passed, because they only ever + exercised redact() itself, while its two consumers went on reading the old + names -- one raising KeyError on every successful run, the other silently + rendering "0/0 in-scope checks passed". Asserting the contract from the + consumer side is what catches that, so it is done by reading the source + rather than by calling one code path. + """ + import ast + + from ci.run_sundial import _PUBLISHABLE + + # In run_sundial.py every `metrics` is sundial's. summarize.py also folds + # shard metrics for the other gates under that name, so there the sundial + # reads carry a distinct one. + holders = {"run_sundial.py": {"metrics"}, "summarize.py": {"sundial_metrics"}} + + offenders = [] + for module, names in holders.items(): + path = CI_ROOT / module + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + key = name = None + # metrics["x"] -- reads only; `metrics["shards"] = n` is a write. + if ( + isinstance(node, ast.Subscript) + and isinstance(node.ctx, ast.Load) + and isinstance(node.value, ast.Name) + and node.value.id in names + and isinstance(node.slice, ast.Constant) + and isinstance(node.slice.value, str) + ): + name, key = node.value.id, node.slice.value + # metrics.get("x", ...) + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and isinstance(node.func.value, ast.Name) + and node.func.value.id in names + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + name, key = node.func.value.id, node.args[0].value + + if key is not None and key not in _PUBLISHABLE: + offenders.append(f"{module}: {name}[{key!r}]") + + + assert not offenders, ( + "these read a metrics key redact() does not publish, so they will raise " + f"KeyError or render a zero: {offenders}" + ) + + +# --------------------------------------------------------------------------- +# the stealth-gate kill switch +# --------------------------------------------------------------------------- + + +def test_the_gate_is_off_unless_the_config_says_otherwise(): + """Absent the key, off. + + The check talks to a private suite and puts the answer in a public log, so + the safe default is not to run. A config predating the flag must not start + running by inheriting a permissive default. + """ + from ci.run_sundial import is_enabled + + assert is_enabled({}) is False + assert is_enabled({"url": "https://example.invalid"}) is False + assert is_enabled({"enabled": False}) is False + assert is_enabled({"enabled": True}) is True + + +def test_sundial_yml_declares_enabled_explicitly(): + """Whether CI may contact sundial is too important to be implicit. + + Asserting the key is present (rather than that it is false) keeps this test + valid either way, while still forcing the decision to be written down. + """ + import yaml + + cfg = yaml.safe_load((CI_ROOT / "sundial.yml").read_text(encoding="utf-8")) or {} + assert "enabled" in cfg, "ci/sundial.yml must state `enabled:` one way or the other" + assert isinstance(cfg["enabled"], bool), "`enabled:` must be a bool, not a string" + + +def test_a_disabled_gate_makes_no_request(): + """Disabled means no credential is read and no connection is opened. + + Asserted by making every route out fatal: authenticate(), both of the login + functions it can reach, and scan() all raise if called, and the credential is + put in the environment so that a gate which ignored the flag would sail past + the missing-credential check and hit them. + """ + import ci.run_sundial as rs + + calls = [] + + def explode(*args, **kwargs): + calls.append(args) + raise AssertionError("a disabled stealth gate contacted sundial") + + names = ("authenticate", "login", "login_with_key", "scan") + monkey = {name: getattr(rs, name) for name in names} + monkey["_config"] = rs._config + for name in names: + setattr(rs, name, explode) + rs._config = lambda: {"enabled": False, "url": "https://sundial.invalid"} + old_key = os.environ.get("SUNDIAL_AUTOMATION_KEY") + os.environ["SUNDIAL_AUTOMATION_KEY"] = "would-be-used-if-the-flag-were-ignored" + try: + with tempfile.TemporaryDirectory() as tmp: + code = rs.gate(["--binary", "/nonexistent", "--evidence-dir", tmp]) + produced = pathlib.Path(tmp) / "sundial.json" + written = json.loads(produced.read_text()) if produced.exists() else None + finally: + for name in names: + setattr(rs, name, monkey[name]) + rs._config = monkey["_config"] + if old_key is None: + os.environ.pop("SUNDIAL_AUTOMATION_KEY", None) + else: + os.environ["SUNDIAL_AUTOMATION_KEY"] = old_key + + assert calls == [], "the gate reached the network while disabled" + assert code == 0, "a disabled gate is a skip, not a failure" + # No result file, matching CI, where the job is never scheduled. A skip + # record here would fail a summary that CI would have passed. + assert written is None, "a disabled gate must not write a result file" + + +# --------------------------------------------------------------------------- +# the workflow's plumbing -- result files have to survive the round trip +# --------------------------------------------------------------------------- + +WORKFLOW = CI_ROOT.parent / ".github" / "workflows" / "tests.yml" + + +def _upload_blocks(): + """(name, path-lines, block-text) for every upload-artifact step.""" + text = WORKFLOW.read_text(encoding="utf-8") + blocks = [] + for match in re.finditer(r"- uses: actions/upload-artifact@v4\n", text): + block = text[match.start() : match.start() + 900] + # Stop at the next step at the same indentation. + end = re.search(r"\n( *)- (uses|name):", block[40:]) + if end: + block = block[: 40 + end.start()] + name = re.search(r"name: (\S.*)", block) + paths = re.findall(r"^\s+(\.ci-work\S*|summary\.\w+)\s*$", block, re.M) + inline = re.search(r"path: (\.ci-work\S*)", block) + if inline: + paths.append(inline.group(1)) + blocks.append((name.group(1) if name else "?", paths, block)) + return blocks + + +def test_results_artifacts_keep_their_json_at_the_top_level(): + """A `results-*` artifact must hold its JSON at the artifact root. + + The summary downloads every one of them with `merge-multiple: true` into a + single directory, and results.load_all() globs exactly one level. Give + upload-artifact a second path and its common root moves up, so the files + arrive nested under `results/` and are silently invisible -- the summary + then reports a suite that passed as "produced no result file". That is what + happened to build_tester. + """ + offenders = [ + (name, paths) + for name, paths, _ in _upload_blocks() + if name.startswith("results-") and paths != [".ci-work/results/"] + ] + assert not offenders, ( + "a results-* artifact must upload exactly `.ci-work/results/` and nothing " + f"else; put diagnostics in their own artifact: {offenders}" + ) + + +def test_every_ci_work_upload_includes_hidden_files(): + """`.ci-work` is a dotfile, and upload-artifact v4 drops those by default. + + Without this the upload silently finds nothing -- which is how a failing + suite came back as "missing" rather than as the failure it was. + """ + offenders = [ + name + for name, paths, block in _upload_blocks() + if any(p.startswith(".ci-work") for p in paths) + and "include-hidden-files: true" not in block + ] + assert not offenders, f"these upload .ci-work without include-hidden-files: {offenders}" + + +def test_required_suites_are_names_a_runner_actually_writes(): + """Requiring a name nothing produces fails every run, forever. + + `static` is a job, not a suite; requiring it meant summarize reported + "produced no result file" on runs where everything passed. + """ + text = WORKFLOW.read_text(encoding="utf-8") + required: set[str] = set() + for line in re.findall(r'required="([^"]*)"', text): + required.update(part for part in line.split() if not part.startswith("$")) + + producible = { + "build", "build_tester", "patch_guards", "pythonlib", "sundial", + "native", "native_rules", "native_browser", "native_growth", + "playwright", "skiplist_audit", + } + unknown = required - producible + assert not unknown, ( + f"required but no runner writes a result by that name: {sorted(unknown)}. " + "summarize.py will report it missing on every run." + ) + + +# --------------------------------------------------------------------------- +# build-tester: a spoofed software renderer is not a headless tell +# --------------------------------------------------------------------------- + + +def _bt_profile(webgl_renderer: str, *, noswift_passed: bool) -> dict: + return { + "profiles": [ + { + "profile": { + "os": "linux", + "mode": "per-context", + "index": 0, + "webglRenderer": webgl_renderer, + }, + "results": { + "extended": { + "headlessDetection": { + "noSwiftShader": { + "passed": noswift_passed, + "detail": "SOFTWARE RENDERER: llvmpipe (headless indicator)", + }, + "noWebdriver": {"passed": True, "detail": "false"}, + } + } + }, + } + ] + } + + +def test_a_profile_that_asked_for_llvmpipe_is_not_graded_headless(): + """camoufox's own preset pool ships "llvmpipe, or similar". + + When `generate_context_fingerprint()` draws that preset, the browser is meant + to report llvmpipe -- doing so is the WebGL spoof working. Grading it as a + headless indicator made this gate fail at random, on the runs where that + preset happened to be drawn. + """ + from ci.run_build_tester import category_failures, flatten + + full = _bt_profile("llvmpipe, or similar", noswift_passed=False) + tid = "linux-per-context-0/extended/headlessDetection/noSwiftShader" + assert flatten(full)[tid] == "pass" + assert category_failures(full, ["headlessDetection"]) == {} + + +def test_an_unasked_for_software_renderer_still_fails(): + """The case the check exists for: the spoof fell through to the host GPU.""" + from ci.run_build_tester import category_failures, flatten + + full = _bt_profile("AMD Radeon R9 200 Series", noswift_passed=False) + tid = "linux-per-context-0/extended/headlessDetection/noSwiftShader" + assert flatten(full)[tid] == "fail" + assert category_failures(full, ["headlessDetection"]) == {"headlessDetection": 1} + + +def test_the_exemption_is_confined_to_that_one_check(): + """A different headlessDetection check must not inherit the exemption.""" + from ci.run_build_tester import flatten + + full = _bt_profile("llvmpipe, or similar", noswift_passed=False) + checks = full["profiles"][0]["results"]["extended"]["headlessDetection"] + checks["noWebdriver"] = {"passed": False, "detail": "navigator.webdriver = true"} + tests = flatten(full) + assert tests["linux-per-context-0/extended/headlessDetection/noWebdriver"] == "fail" + + +# --------------------------------------------------------------------------- +# preparing the source tree: retry the network, never the real failures +# --------------------------------------------------------------------------- + + +# The exact text that failed run 34673115086, trimmed to the lines that matter. +_TASKCLUSTER_RESET = """\ + File "/home/runner/work/camoufox/camoufox/camoufox-152.0.4-beta.31/python/mach/mach/main.py", line 416, in _run + return Registrar._run_command_handler( +requests.exceptions.ConnectionError: ('Connection aborted.', \ +ConnectionResetError(104, 'Connection reset by peer')) +make: *** [Makefile:95: mozbootstrap] Error 1 +""" + +_REAL_BUILD_FAILURE = """\ +patching file browser/base/content/browser.js +Hunk #1 FAILED at 812. +1 out of 1 hunk FAILED -- saving rejects to browser/base/content/browser.js.rej +make: *** [Makefile:88: dir] Error 1 +""" + + +def test_a_taskcluster_connection_reset_is_transient(): + from ci.run_prepare import is_transient + + assert is_transient(_TASKCLUSTER_RESET) + + +def test_a_failed_patch_hunk_is_not_transient(): + """The retry must not paper over the failure mode this pipeline exists to catch.""" + from ci.run_prepare import is_transient + + assert not is_transient(_REAL_BUILD_FAILURE) + + +class _FakeRun: + """Stands in for run_prepare._run_capturing, replaying scripted outcomes.""" + + def __init__(self, outcomes): + self.outcomes = list(outcomes) + self.calls = [] + + def __call__(self, cmd, **kwargs): + self.calls.append(cmd) + return self.outcomes.pop(0) + + +def _run_step(monkeypatch, outcomes, *, target="mozbootstrap", retry=True, attempts=3): + import ci.run_prepare as rp + + fake = _FakeRun(outcomes) + monkeypatch.setattr(rp, "_run_capturing", fake) + monkeypatch.setattr(rp.time, "sleep", lambda _s: None) + code = rp.run_step(target, retry=retry, attempts=attempts, backoff=0, timeout=60) + return code, fake + + +def test_a_transient_failure_is_retried_and_can_succeed(monkeypatch): + code, fake = _run_step(monkeypatch, [(2, _TASKCLUSTER_RESET), (0, "ok")]) + assert code == 0 + assert len(fake.calls) == 2 + + +def test_a_real_failure_is_not_retried(monkeypatch): + """Retrying a broken tree only spends a runner to reach the same answer.""" + code, fake = _run_step(monkeypatch, [(2, _REAL_BUILD_FAILURE)]) + assert code == 2 + assert len(fake.calls) == 1 + + +def test_retries_are_bounded(monkeypatch): + code, fake = _run_step( + monkeypatch, [(2, _TASKCLUSTER_RESET)] * 3, attempts=3 + ) + assert code == 2 + assert len(fake.calls) == 3 + + +def test_applying_patches_is_never_retried(monkeypatch): + """`make dir` touches no network once the tarball is there, so a failure is real.""" + import ci.run_prepare as rp + + assert dict(rp._STEPS)["dir"] is False + code, fake = _run_step( + monkeypatch, [(2, _TASKCLUSTER_RESET)], target="dir", retry=False + ) + assert code == 2 + assert len(fake.calls) == 1 + + +def test_the_workflow_prepares_through_the_retrying_entry_point(): + """A step that shells straight to `make mozbootstrap` gets no retry.""" + from ci._util import REPO_ROOT + + workflow = (REPO_ROOT / ".github" / "workflows" / "tests.yml").read_text(encoding="utf-8") + assert "python3 -m ci.run_prepare" in workflow + prepare = workflow.split("Prepare the source tree", 1)[1].split("- name:", 1)[0] + # Comments in that step quote the make targets while explaining them, so + # judge the commands only. + commands = "\n".join( + line for line in prepare.splitlines() if not line.strip().startswith("#") + ) + for target in ("make setup-minimal", "make dir", "make mozbootstrap"): + assert target not in commands, ( + f"{target} is invoked directly; it would not be retried" + ) + + +def test_zero_attempts_still_runs_the_step_once(monkeypatch): + """`--attempts 0` must not report success by never running anything.""" + code, fake = _run_step(monkeypatch, [(0, "ok")], attempts=0) + assert len(fake.calls) == 1 + assert code == 0 + + +# --------------------------------------------------------------------------- +# a category nobody has ruled on is a blind spot, not an "out of scope" +# --------------------------------------------------------------------------- + + +def _score_payload(*buckets) -> dict: + """A minimal sundial score payload: (category, class, scored, passed).""" + return { + "schemaVersion": 1, + "mode": "score", + "sundialVersion": "0.5.0", + "buckets": { + f"{cat}|{cls}": {"scored": n, "passed": p, "failed": n - p} + for cat, cls, n, p in buckets + }, + } + + +def test_sundial_yml_names_every_section_sundial_has(): + """ci/sundial.yml must partition sundial's taxonomy, not sample it. + + sundial's SECTIONS are the `category` every scored entry carries. If the two + lists here do not cover all of them, whatever is missing is silently + unscored -- the gate would report a pass rate over a slice of the suite and + read exactly like a clean run. + """ + import yaml + + cfg = yaml.safe_load((CI_ROOT / "sundial.yml").read_text(encoding="utf-8")) + named = {c.lower() for c in cfg["gated_categories"]} | { + c.lower() for c in cfg["ungated_categories"] + } + # sundial 0.5.0, src/lib/engine.js SECTIONS[].label. + sundial_sections = { + "identity", "security", "js engine", "graphics", + "display", "locale", "audio", "cpu", "network", + } + assert sundial_sections <= named, ( + f"not scored by either list: {sorted(sundial_sections - named)}" + ) + assert not (named - sundial_sections), ( + f"named here but not a sundial section: {sorted(named - sundial_sections)}" + ) + + +def test_a_category_in_neither_list_is_counted_as_unknown(): + out = redact( + _score_payload( + ("Identity", "core", 10, 10), + ("Graphics", "core", 5, 3), # deliberately ungated + ("Battery", "core", 4, 1), # a section nobody has ruled on + ), + GATED, UNGATED, os_name="linux", + ) + assert out["checks_total"] == 10 + assert out["unknown_category_checks"] == 4 + # Still only a count -- the category name never reaches the metrics. + assert "Battery" not in json.dumps(out) + + +def test_a_known_ungated_category_is_not_unknown(): + out = redact( + _score_payload(("Identity", "core", 10, 10), ("Graphics", "core", 5, 3)), + GATED, UNGATED, os_name="linux", + ) + assert out["unknown_category_checks"] == 0 + assert out["out_of_scope_failed"] == 2 + + +def test_run_capturing_keeps_stdout_and_stderr(): + """The classifier reads this output, so losing stderr loses the diagnosis.""" + from ci.run_prepare import _run_capturing + + code, out = _run_capturing( + ["bash", "-c", "echo on-stdout; echo on-stderr >&2; exit 3"], timeout=30 + ) + assert code == 3 + assert "on-stdout" in out and "on-stderr" in out + + +def test_a_step_that_wedges_without_printing_is_killed(): + """The timeout has to cover a silent hang, which is what a stalled download is. + + Draining the pipe on the calling thread would block in readline until EOF + and only then reach proc.wait(timeout=...) -- so a process producing no + output would never be timed out at all. + """ + import time + + from ci.run_prepare import _run_capturing + + started = time.monotonic() + code, out = _run_capturing(["bash", "-c", "sleep 60"], timeout=2) + elapsed = time.monotonic() - started + assert code == 124, "a timed-out step must not report success" + assert "TIMEOUT" in out + assert elapsed < 15, f"the timeout did not fire promptly ({elapsed:.1f}s)" + + +# --------------------------------------------------------------------------- +# the credential must not ride out on an error message +# --------------------------------------------------------------------------- + + +def test_scrub_removes_the_secret_in_both_encodings(): + """An exception from urllib can carry the URL that raised it. + + For the token route that URL *is* the credential, percent-encoded. Whatever + reaches result.note() is published to the results artifact and the pull + request comment, so it goes through scrub() first. + """ + from ci.run_sundial import scrub + + secret = "yPsM+key/with=specials" + quoted = urllib.parse.quote(secret, safe="") + text = f"HTTPError at https://sundial.daijro.dev/automated?key={quoted} and raw {secret}" + out = scrub(text, secret) + assert secret not in out + assert quoted not in out + assert out.count("") == 2 + + +def test_scrub_is_a_no_op_without_a_secret(): + from ci.run_sundial import scrub + + assert scrub("nothing to hide", "") == "nothing to hide" + + +def test_a_failed_login_never_publishes_the_credential(): + """End to end: a bad credential fails the gate without leaking itself.""" + import ci.run_sundial as rs + + secret = "super-secret-automation-key" + + def boom(base_url, key, **kwargs): + raise urllib.error.HTTPError( + f"{base_url}/automated?key={urllib.parse.quote(secret, safe='')}", + 401, "Unauthorized", {}, None, + ) + + monkey = {"login_with_key": rs.login_with_key, "login": rs.login} + rs.login_with_key = boom + rs.login = lambda *a, **k: (_ for _ in ()).throw(RuntimeError(f"rejected {secret}")) + try: + with pytest.raises(RuntimeError) as exc_info: + rs.authenticate("https://sundial.invalid", "guest", secret) + finally: + rs.login_with_key, rs.login = monkey["login_with_key"], monkey["login"] + + message = str(exc_info.value) + assert secret not in message, "the credential reached the published error text" + assert "" in message + # Still says enough to act on. + assert "automation key" in message and "form login" in message + + +# --------------------------------------------------------------------------- +# CI must not scan under a role that is handed the vectors +# --------------------------------------------------------------------------- + + +def _with_role(monkeypatch, role): + import ci.run_sundial as rs + + monkeypatch.setattr(rs, "session_role", lambda *a, **k: role) + return rs + + +@pytest.mark.parametrize("role", ["guest", "ci"]) +def test_a_role_without_the_vectors_is_accepted(monkeypatch, role): + rs = _with_role(monkeypatch, role) + assert rs.assert_role_cannot_read_vectors("https://sundial.invalid", "cookie") == role + + +@pytest.mark.parametrize("role", ["private", "admin"]) +def test_a_role_that_can_read_the_vectors_is_refused(monkeypatch, role): + """`/automated?key=` resolves to `private` when handed the private key. + + The two keys look identical, so "we set the guest one" is an assumption + until something checks. Loading the vectors onto a public runner is the + exact outcome this gate exists to prevent, so the check has to come before + the browser opens sundial -- not after, and not in redaction, which only + governs what gets published. + """ + rs = _with_role(monkeypatch, role) + with pytest.raises(RuntimeError, match=f"{role}.*role"): + rs.assert_role_cannot_read_vectors("https://sundial.invalid", "cookie") + + +def test_an_unreadable_role_fails_closed(monkeypatch): + """Not knowing the role is not the same as the role being safe.""" + rs = _with_role(monkeypatch, None) + with pytest.raises(RuntimeError, match="did not say what role"): + rs.assert_role_cannot_read_vectors("https://sundial.invalid", "cookie") + + +def test_the_role_is_publishable_but_the_whitelist_still_bites(): + from ci.run_sundial import _PUBLISHABLE, _assert_publishable + + assert "sundial_role" in _PUBLISHABLE + _assert_publishable({"sundial_role": "guest"}) + with pytest.raises(RuntimeError): + _assert_publishable({"sundial_role": "guest", "vector_name": "pv-secret"}) + + +def test_the_evidence_records_which_role_the_run_used(monkeypatch, tmp_path): + """`sundial_role` has to survive into the saved result, not just be checked. + + redact()'s output replaces result.metrics wholesale, so a role recorded + before the scan was silently dropped on the way out -- the check still ran + and still failed closed, but the artifact did not say which role it had + confirmed, leaving "the vectors were never served to this session" + unverifiable after the fact. + """ + import ci.run_sundial as rs + + score = { + "schemaVersion": 1, + "mode": "score", + "sundialVersion": "v0.5.0", + "buckets": {"Identity|core": {"scored": 10, "passed": 10, "failed": 0}}, + } + + monkeypatch.setattr(rs, "_config", lambda: { + "enabled": True, + "url": "https://sundial.invalid", + "gated_categories": ["Identity"], + "ungated_categories": ["Graphics"], + "min_pass_rate": 0.9, + }) + monkeypatch.setattr(rs, "authenticate", lambda *a, **k: "cookie") + monkeypatch.setattr(rs, "assert_role_cannot_read_vectors", lambda *a, **k: "guest") + async def _scan(**_kwargs): + return score + + monkeypatch.setattr(rs, "scan", _scan) + monkeypatch.setattr(rs, "seal", lambda *a, **k: None) + monkeypatch.setenv("SUNDIAL_AUTOMATION_KEY", "a-key") + + code = rs.gate(["--binary", str(tmp_path / "fake-bin"), "--evidence-dir", str(tmp_path)]) + saved = json.loads((tmp_path / "sundial.json").read_text()) + + assert code == 0, saved.get("notes") + assert saved["metrics"]["sundial_role"] == "guest" + # And it is still only ever counts beside it. + assert "vector" not in json.dumps(saved).lower() + + +# --------------------------------------------------------------------------- +# the overlay: our tests must not silently replace upstream's +# --------------------------------------------------------------------------- + + +def _fake_checkout(tmp_path, upstream_modules=("test_page.py",)): + async_dir = tmp_path / "checkout" / "tests" / "async" + async_dir.mkdir(parents=True) + for name in upstream_modules: + (async_dir / name).write_text("# upstream\n", encoding="utf-8") + return tmp_path / "checkout" + + +def test_the_overlay_refuses_to_shadow_an_upstream_module(tmp_path, monkeypatch): + """Copying over an upstream file would drop every test in it, silently. + + The suite would simply be smaller, with nothing in the log to say a module + had been replaced -- so this has to be refused rather than resolved. + """ + from ci import suite + + checkout = _fake_checkout(tmp_path, upstream_modules=("test_page.py",)) + ours = tmp_path / "camoufox" + ours.mkdir() + (ours / "test_page.py").write_text("# ours\n", encoding="utf-8") + monkeypatch.setattr(suite, "CAMOUFOX_TESTS", ours) + + with pytest.raises(SystemExit): + suite.overlay(checkout) + + assert (checkout / "tests" / "async" / "test_page.py").read_text() == "# upstream\n" + + +def test_the_overlay_is_idempotent_and_clears_stale_files(tmp_path, monkeypatch): + """A reused checkout must not trip the collision check on our own files. + + `fetch()` reuses a checkout when one is already there, so the second run + finds the first run's copies already in place. It must overwrite those -- + and drop a module we have since renamed, which would otherwise linger and + keep passing against code that no longer claims it. + """ + from ci import suite + + checkout = _fake_checkout(tmp_path) + ours = tmp_path / "camoufox" + ours.mkdir() + (ours / "test_ours.py").write_text("# v1\n", encoding="utf-8") + monkeypatch.setattr(suite, "CAMOUFOX_TESTS", ours) + + assert suite.overlay(checkout) == ["test_ours.py"] + + # Second run: same file, edited, plus one renamed away. + (ours / "test_ours.py").write_text("# v2\n", encoding="utf-8") + assert suite.overlay(checkout) == ["test_ours.py"] + assert (checkout / "tests" / "async" / "test_ours.py").read_text() == "# v2\n" + + (ours / "test_ours.py").rename(ours / "test_renamed.py") + assert suite.overlay(checkout) == ["test_renamed.py"] + assert not (checkout / "tests" / "async" / "test_ours.py").exists() + assert (checkout / "tests" / "async" / "test_renamed.py").exists() + # Upstream's own module is untouched throughout. + assert (checkout / "tests" / "async" / "test_page.py").read_text() == "# upstream\n" + + +def test_every_camoufox_test_module_is_named_unlike_upstreams(): + """Names are the only thing standing between an overlay and a shadowed module. + + Checked here as well as at overlay time so a badly named file fails in the + static job in seconds, rather than forty minutes in when the browser is up. + """ + from ci.suite import CAMOUFOX_TESTS + + ours = sorted(p.name for p in CAMOUFOX_TESTS.glob("test_*.py")) + assert ours, "tests/camoufox/ has no test modules; the overlay guards nothing" + # Upstream names every module after the API it covers; ours are named after + # the Camoufox behaviour, so a collision means someone copied a file in. + generic = {"test_page.py", "test_network.py", "test_worker.py", "test_browsercontext.py"} + assert not (set(ours) & generic), ( + f"{sorted(set(ours) & generic)} shares a name with an upstream module" + ) + + +# --------------------------------------------------------------------------- +# --explain: a local diagnostic that must stay local +# --------------------------------------------------------------------------- + + +def _explain_payload(): + return { + "schemaVersion": 1, + "mode": "score", + "sundialVersion": "v0.5.0", + "buckets": { + "Identity|core": {"scored": 10, "passed": 8}, + "Graphics|core": {"scored": 4, "passed": 3}, + "Identity|crossOs": {"scored": 5, "passed": 4}, + }, + } + + +def _stub_sundial(monkeypatch, rs, payload): + monkeypatch.setattr(rs, "_config", lambda: { + "enabled": True, + "url": "https://sundial.invalid", + "gated_categories": ["Identity"], + "ungated_categories": ["Graphics"], + "min_pass_rate": 0.5, + }) + monkeypatch.setattr(rs, "authenticate", lambda *a, **k: "cookie") + monkeypatch.setattr(rs, "assert_role_cannot_read_vectors", lambda *a, **k: "guest") + + async def _scan(**_kwargs): + return payload + + monkeypatch.setattr(rs, "scan", _scan) + monkeypatch.setattr(rs, "seal", lambda *a, **k: None) + monkeypatch.setenv("SUNDIAL_AUTOMATION_KEY", "a-key") + + +def test_explain_is_refused_in_ci(monkeypatch, tmp_path): + """A per-category weakness map must not be written to a public workflow log. + + The counts are not vectors, but they do say where this browser is weak, and + the repository is public. Refused before anything is sent, so the flag cannot + be switched on in CI and discovered afterwards. + """ + import ci.run_sundial as rs + + _stub_sundial(monkeypatch, rs, _explain_payload()) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + + with pytest.raises(SystemExit, match="public"): + rs.gate(["--explain", "--binary", str(tmp_path / "bin"), "--evidence-dir", str(tmp_path)]) + + assert not (tmp_path / "sundial.json").exists(), "refused before anything ran" + + +def test_explain_prints_but_never_records(monkeypatch, tmp_path, capsys): + """The breakdown goes to the terminal; the artifact keeps only the whitelist. + + This is the boundary the whole module is built around, so assert it on the + one path that deliberately produces more detail than it publishes. + """ + import ci.run_sundial as rs + + _stub_sundial(monkeypatch, rs, _explain_payload()) + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + code = rs.gate(["--explain", "--binary", str(tmp_path / "bin"), "--evidence-dir", str(tmp_path)]) + printed = capsys.readouterr().out + saved = json.loads((tmp_path / "sundial.json").read_text()) + + assert code == 0, saved.get("notes") + assert "per-category breakdown" in printed and "Identity" in printed + # Nothing about categories reached the artifact. + assert "Identity" not in json.dumps(saved) + assert "breakdown" not in json.dumps(saved) + assert set(saved["metrics"]) <= set(rs._PUBLISHABLE) + + +def test_explain_says_the_names_are_not_available(monkeypatch): + """The output must not imply it is showing individual checks. + + Score mode carries no check names at all, so a reader who takes this for the + full answer would conclude the failing checks are unknowable rather than + that they need a different credential. + """ + from ci.run_sundial import explain_buckets + + lines = "\n".join(explain_buckets(_explain_payload(), ["Identity"], ["Graphics"])) + assert "--allow-full-report" in lines + assert "no check names" in lines + + +def test_explain_flags_a_category_nobody_has_classified(): + """Same rule as the gate: a new sundial section must not default to ignored.""" + from ci.run_sundial import explain_buckets + + payload = {"mode": "score", "buckets": {"Quantum|core": {"scored": 3, "passed": 1}}} + lines = "\n".join(explain_buckets(payload, ["Identity"], ["Graphics"])) + assert "UNKNOWN CATEGORY" in lines + + +# --------------------------------------------------------------------------- +# the skiplist audit: a reason is an assertion, and assertions get checked +# --------------------------------------------------------------------------- + + +def test_the_audit_can_name_a_target_for_every_entry_it_claims_to_check(): + """`pattern` entries name no file, so they cannot be audited by selection. + + They must be reported as unaudited rather than counted as checked -- an + entry that looks verified and is not is the exact bug this gate exists for. + """ + from ci.run_skiplist_audit import targets + + selectable, unresolved = targets([ + {"module": "tests/async/test_x.py", "reason": "r"}, + {"test": "tests/async/test_y.py::test_z", "reason": "r"}, + {"pattern": "[chromium]", "reason": "r"}, + ]) + assert selectable == ["tests/async/test_x.py", "tests/async/test_y.py::test_z"] + assert unresolved == ["[chromium]"] + + +def test_every_shipped_skiplist_entry_is_auditable(): + """Nothing currently in the file escapes the audit. + + If a pattern entry is ever added this fails, which is the prompt to decide + how it gets verified rather than letting it ride unchecked. + """ + import sys + + from ci._util import REPO_ROOT + from ci.run_skiplist_audit import targets + + sys.path.insert(0, str(REPO_ROOT / "ci")) + from pw_camoufox_plugin import load_skiplist + + selectable, unresolved = targets(load_skiplist(REPO_ROOT / "ci" / "skiplist.yml")) + assert selectable, "the skiplist audit would check nothing" + assert not unresolved, ( + f"these skiplist entries cannot be audited: {unresolved}. Either express them " + "as a module/test, or decide how their truth gets checked." + ) + + +# --------------------------------------------------------------------------- +# which of upstream's tests we run, and which we admit we do not +# --------------------------------------------------------------------------- + + +def test_the_sync_suite_is_in_the_target_set(): + """It was not, for no reason anyone had written down. + + `tests/async/` alone left out 722 of upstream's 2306 tests -- inherited from + the vendored fork, which carried no sync suite -- with nothing recorded to + say so. Pinned here so dropping it again has to be deliberate. + """ + from ci.run_playwright import TARGETS + + assert "tests/sync/" in TARGETS + assert "tests/async/" in TARGETS + + +def test_async_and_sync_are_in_separate_groups(): + """They cannot share a pytest process, and the damage does not look like it. + + Upstream's sync suite is greenlet-based and its async suite runs under + pytest-asyncio; together, whichever runs second breaks the other's loop and + the failures land in async FIXTURE SETUP. That reads as "the fetch tests are + flaky", and the retry pass hides it: 50 tests passed only on retry before + these were split. A group boundary is the fix; a skiplist entry would have + recorded a browser failure that does not exist. + """ + from ci.run_playwright import GROUPS + + home = {} + for index, group in enumerate(GROUPS): + for target in group.targets: + home[target] = index + assert home["tests/async/"] != home["tests/sync/"], ( + "async and sync share a pytest process; that produces fixture errors in async" + ) + + +def test_every_target_belongs_to_exactly_one_group(): + """A target in two groups runs twice and double-counts.""" + from ci.run_playwright import GROUPS, TARGETS + + flat = [t for g in GROUPS for t in g.targets] + assert len(flat) == len(set(flat)), f"a target appears in more than one group: {flat}" + assert tuple(flat) == TARGETS + + +def test_the_small_group_is_not_sharded(): + """Sharding six tests hands some shard an empty selection; pytest exits 5.""" + from ci.run_playwright import GROUPS + + small = [g for g in GROUPS if "tests/common/" in g.targets] + assert small and not small[0].sharded + + +def test_every_exclusion_carries_a_reason(): + """Same bar as the skiplist: not running something requires saying why.""" + from ci.run_playwright import EXCLUDED + + assert EXCLUDED, "nothing is excluded, so either the set is stale or the guard is" + for path, reason in EXCLUDED.items(): + assert len(reason.split()) >= 5, f"{path} is excluded without a real reason" + + +def test_a_new_upstream_subtree_is_reported_not_ignored(tmp_path): + """Upstream adding a test directory must fail the run, not vanish from it. + + The silent version of this is the bug: the suite gets narrower, every number + still looks healthy, and nothing says coverage moved. + """ + from ci.run_playwright import unclaimed + + tests = tmp_path / "tests" + (tests / "async").mkdir(parents=True) + (tests / "async" / "test_a.py").write_text("") + (tests / "assets").mkdir() + (tests / "assets" / "page.html").write_text("") # fixtures, not tests + (tests / "golden-firefox").mkdir() + (tests / "test_installation.py").write_text("") # excluded with a reason + + assert unclaimed(tmp_path) == [] + + (tests / "integration").mkdir() + (tests / "integration" / "test_new.py").write_text("") + (tests / "test_brand_new.py").write_text("") + assert unclaimed(tmp_path) == ["tests/integration/", "tests/test_brand_new.py"] + + +# --------------------------------------------------------------------------- +# the version under test has to be the version that gets built +# --------------------------------------------------------------------------- + + +def test_a_requested_version_the_branch_does_not_pin_is_refused(monkeypatch): + """Only suite selection follows --browser-version; the build reads upstream.sh. + + So asking for a version the branch does not pin builds the OLD browser and + judges it against the NEW suite. Green, meaningless, and silent -- which is + the worst combination available. + """ + from ci import versions + + monkeypatch.setattr(versions, "read_upstream_sh", lambda: {"version": "152.0.4"}) + + problem = versions.upstream_mismatch("155.0") + assert problem and "152.0.4" in problem and "155.0" in problem + + # The legitimate flows: no input at all, or an input that agrees. + assert versions.upstream_mismatch(None) is None + assert versions.upstream_mismatch("152.0.4") is None + assert versions.upstream_mismatch(" 152.0.4 ") is None + + +def test_the_workflow_actually_runs_that_check(): + """A guard nothing invokes is decoration.""" + text = WORKFLOW.read_text(encoding="utf-8") + assert "--check-upstream" in text, ( + "ci.versions is invoked without --check-upstream, so a browser_version " + "that disagrees with upstream.sh would silently test the wrong browser" + ) + + +def test_a_fetched_build_from_another_firefox_generation_is_refused(): + """The driver-only path downloads; the suite comes from upstream.sh. + + Those agree until an upgrade window, when upstream.sh names a Firefox nobody + has published yet. Then a driver PR fetches the old browser and is judged by + the new suite -- green, and about nothing. + """ + from ci.versions import fetched_mismatch + + # Beta drift inside a generation is expected and fine. + assert fetched_mismatch("official/prerelease/152.0.4-beta.30 (5720d45b)", "152.0.4") is None + assert fetched_mismatch("152.0.4-beta.31", "152.0.4") is None + # A generation apart is the bug. + problem = fetched_mismatch("official/prerelease/149.0-beta.1 (x)", "152.0.4") + assert problem and "149" in problem and "152" in problem + # Unreadable input fails closed rather than passing by accident. + assert fetched_mismatch("", "152.0.4") + assert fetched_mismatch("no digits here", "152.0.4") + + +def test_the_workflow_checks_the_fetched_build(): + text = WORKFLOW.read_text(encoding="utf-8") + assert "--check-fetched" in text, ( + "the fetch path installs a browser without checking it is the generation " + "the Playwright suite was chosen for" + ) + + +# --------------------------------------------------------------------------- +# what the summary is told to require +# --------------------------------------------------------------------------- + + +def _required_suites(browser_changed: str, has_sundial: str) -> set: + """Run the workflow's own `required=` assembly and report what it produced. + + Extracted and executed rather than pattern-matched, because the bug this + guards was a comparison that read as deliberate (`!= "skip"`) against a + value that is only ever `true` or `false`. Only running it says what it + does. + """ + import subprocess + + text = WORKFLOW.read_text(encoding="utf-8") + start = text.index('required="pythonlib') + end = text.index("python3 -m ci.summarize", start) + block = text[start:end] + block = block.replace("${{ needs.resolve.outputs.browser_changed }}", browser_changed) + block = block.replace("${{ needs.resolve.outputs.has_sundial }}", has_sundial) + proc = subprocess.run( + ["bash", "-c", block + '\necho "$required"'], + capture_output=True, text=True, + ) + assert proc.returncode == 0, proc.stderr + return set(proc.stdout.split()) + + +def test_build_is_required_only_when_the_browser_was_built(): + """A driver-only pull request skips `build`, so requiring it fails the gate. + + `build` writes a result file only from the build job, which is skipped + whenever the browser was fetched instead. summarize treats a required suite + with no result as a failure -- correctly -- so requiring it unconditionally + blocked every pull request that did not touch browser sources: docs, + pythonlib, ci/ and tests/ alike. That is most of them, and it is precisely + the cheap path this pipeline advertises. + """ + assert "build" in _required_suites("true", "false") + assert "build" not in _required_suites("false", "false") + + +def test_the_browser_suites_are_required_either_way(): + """Fetching instead of building narrows what was compiled, not what is tested.""" + for changed in ("true", "false"): + required = _required_suites(changed, "false") + assert { + "pythonlib", "native_rules", "patch_guards", "skiplist_audit", + "build_tester", "playwright", "native_browser", + } <= required, changed + + +def test_sundial_is_required_only_when_there_is_a_credential(): + assert "sundial" in _required_suites("true", "true") + assert "sundial" not in _required_suites("true", "false") + + +# --------------------------------------------------------------------------- +# sundial being down is not a verdict about the browser +# --------------------------------------------------------------------------- + + +def _write_result(directory, gate, status, **extra): + payload = {"gate": gate, "status": status, "tests": {}, "metrics": {}, "notes": []} + payload.update(extra) + (directory / f"{gate}.json").write_text(json.dumps(payload), encoding="utf-8") + + +def test_a_skipped_suite_fails_the_run_unless_it_is_explicitly_allowed(tmp_path): + """`--allow-skip` is the whole permission, and it is per suite. + + Without it a SKIP must stay a failure: a suite that did not run has not + passed, and "record a skip" would otherwise be the cheapest way to make any + gate disappear. + """ + from ci.summarize import main as summarize + + _write_result(tmp_path, "sundial", results.SKIP, notes=["sundial is unreachable"]) + _write_result(tmp_path, "playwright", results.SKIP, notes=["did not feel like it"]) + + # Not allowed: both are failures. + assert summarize(["--results-dir", str(tmp_path), "--require", "sundial", "playwright"]) == 1 + + # Allowing one does not allow the other. + code = summarize([ + "--results-dir", str(tmp_path), "--require", "sundial", "playwright", + "--allow-skip", "sundial", + ]) + assert code == 1, "a skip for a suite outside --allow-skip must still fail" + + (tmp_path / "playwright.json").unlink() + code = summarize([ + "--results-dir", str(tmp_path), "--require", "sundial", "--allow-skip", "sundial", + ]) + assert code == 0 + + +def test_an_allowed_skip_is_still_shown_as_a_skip(tmp_path): + """Tolerated is not the same as invisible: it keeps its icon and its reason.""" + from ci.summarize import render + + merged = {"sundial": { + "gate": "sundial", "status": results.SKIP, "tests": {}, "metrics": {}, + "notes": ["stealth check skipped -- sundial could not be reached"], + }} + markdown = render(merged, ["sundial"], [], {}) + assert "⏭️" in markdown + assert "could not be reached" in markdown + assert "✅ Tests passed" in markdown # tolerated: the run still passes + # And it must not render as a measurement that came back empty. + assert "grade **?**" not in markdown + + +def test_the_workflow_allows_a_skip_only_for_sundial(): + """A skip is tolerated because sundial is someone else's uptime. Nothing else + in this pipeline has that excuse -- every other suite runs on the runner.""" + text = WORKFLOW.read_text(encoding="utf-8") + allowed = re.findall(r"--allow-skip ([^\\\n]*)", text) + assert allowed, "the summary step no longer passes --allow-skip" + for line in allowed: + assert line.split() == ["sundial"], line + + +def test_only_a_transport_failure_counts_as_sundial_being_down(): + """An HTTP reply is an answer, and answers get judged. + + 401 means the credential is wrong and 403 means the edge refused us -- both + are this repository's problem to fix, and skipping past them would turn a + misconfigured stealth gate into a permanently green one. + """ + from ci.run_sundial import _unavailable_reason + + def http(code): + return urllib.error.HTTPError("https://sundial.invalid", code, "", {}, None) + + assert _unavailable_reason(http(500)) + assert _unavailable_reason(http(503)) + assert _unavailable_reason(http(429)) + assert _unavailable_reason(urllib.error.URLError("Name or service not known")) + assert _unavailable_reason(TimeoutError("timed out")) + assert _unavailable_reason(ConnectionResetError("reset")) + + assert _unavailable_reason(http(401)) is None + assert _unavailable_reason(http(403)) is None + assert _unavailable_reason(http(404)) is None + assert _unavailable_reason(RuntimeError("sundial rejected the credentials")) is None + + +def test_an_unreachable_sundial_is_a_skip_and_a_bad_credential_is_not(monkeypatch, tmp_path): + """The gate's two exits, and the line between them. + + Down: the browser was never measured, so neither pass nor fail is true, and + an outage on another host must not block every merge here. Rejected: sundial + answered, and the answer was about this repository's configuration. + """ + import ci.run_sundial as rs + + monkeypatch.setattr(rs, "_config", lambda: { + "enabled": True, "url": "https://sundial.invalid", + "gated_categories": ["Identity"], "ungated_categories": [], "min_pass_rate": 0.9, + }) + monkeypatch.setenv("SUNDIAL_AUTOMATION_KEY", "a-key") + binary = tmp_path / "fake-bin" + binary.write_text("") + + def down(*_a, **_k): + raise rs.SundialUnavailable("sundial could not be reached (Connection refused)") + + monkeypatch.setattr(rs, "authenticate", down) + assert rs.gate(["--binary", str(binary), "--evidence-dir", str(tmp_path)]) == 0 + saved = json.loads((tmp_path / "sundial.json").read_text()) + assert saved["status"] == results.SKIP + assert "could not be reached" in " ".join(saved["notes"]) + + def rejected(*_a, **_k): + raise RuntimeError("sundial rejected the credentials.") + + monkeypatch.setattr(rs, "authenticate", rejected) + assert rs.gate(["--binary", str(binary), "--evidence-dir", str(tmp_path)]) == 1 + saved = json.loads((tmp_path / "sundial.json").read_text()) + assert saved["status"] == results.ERROR + + +def test_a_skip_note_cannot_carry_the_credential(monkeypatch, tmp_path): + """The token route puts the secret in the URL, and a URLError carries it.""" + import ci.run_sundial as rs + + secret = "super-secret-automation-key" + monkeypatch.setattr(rs, "_config", lambda: { + "enabled": True, "url": "https://sundial.invalid", + "gated_categories": ["Identity"], "ungated_categories": [], "min_pass_rate": 0.9, + }) + monkeypatch.setenv("SUNDIAL_AUTOMATION_KEY", secret) + binary = tmp_path / "fake-bin" + binary.write_text("") + + def down(*_a, **_k): + raise rs.SundialUnavailable( + f"sundial could not be reached: https://sundial.invalid/automated?key={secret}" + ) + + monkeypatch.setattr(rs, "authenticate", down) + rs.gate(["--binary", str(binary), "--evidence-dir", str(tmp_path)]) + assert secret not in (tmp_path / "sundial.json").read_text() + + +def test_one_route_answering_means_sundial_is_up(monkeypatch): + """Both routes are tried, and the key one 401s whenever the secret is a + password. That is an answer, so the failure that follows is a real one.""" + import ci.run_sundial as rs + + def key_route(*_a, **_k): + raise RuntimeError("the automation key route returned 401 and no session cookie") + + def form_route(*_a, **_k): + raise RuntimeError("sundial rejected the credentials.") + + monkeypatch.setattr(rs, "login_with_key", key_route) + monkeypatch.setattr(rs, "login", form_route) + with pytest.raises(RuntimeError) as caught: + rs.authenticate("https://sundial.invalid", "guest", "secret") + assert not isinstance(caught.value, rs.SundialUnavailable) + + def unreachable(*_a, **_k): + raise rs.SundialUnavailable("sundial could not be reached (Connection refused)") + + monkeypatch.setattr(rs, "login_with_key", unreachable) + monkeypatch.setattr(rs, "login", unreachable) + with pytest.raises(rs.SundialUnavailable): + rs.authenticate("https://sundial.invalid", "guest", "secret") + + +# --------------------------------------------------------------------------- +# isolated world first, main world as a counted fallback +# --------------------------------------------------------------------------- + + +def test_the_isolated_world_is_what_runs_unless_asked_otherwise(monkeypatch): + """The default has to be the configuration users ship. + + The suite used to force main-world execution for every run, which made the + upstream tests pass and measured a mode nobody ships. Anyone running the + plugin by hand now gets the real thing. + """ + from ci.pw_camoufox_plugin import ISOLATED_WORLD, MAIN_WORLD, selected_world + + monkeypatch.delenv("CI_WORLD", raising=False) + assert selected_world() == ISOLATED_WORLD + monkeypatch.setenv("CI_WORLD", "") + assert selected_world() == ISOLATED_WORLD + monkeypatch.setenv("CI_WORLD", "isolated") + assert selected_world() == ISOLATED_WORLD + monkeypatch.setenv("CI_WORLD", "MAIN") + assert selected_world() == MAIN_WORLD + + +def test_an_isolated_run_clears_a_stale_main_world_flag(monkeypatch): + """Clearing matters as much as setting. + + The two passes are separate processes that inherit the same job + environment. A leftover `disableWorldIsolation: true` would make the + "isolated" pass quietly measure the main world -- and since that pass is + what produces the fallback count, the number would silently become zero + while reading as a clean result. + """ + from ci.pw_camoufox_plugin import ISOLATED_WORLD, MAIN_WORLD, _apply_world + + monkeypatch.setenv("CAMOU_CONFIG", json.dumps({ + "disableWorldIsolation": True, "webgl:renderer": "keep me", + })) + _apply_world(ISOLATED_WORLD) + config = json.loads(os.environ["CAMOU_CONFIG"]) + assert "disableWorldIsolation" not in config + assert config["webgl:renderer"] == "keep me", "unrelated config must survive" + + _apply_world(MAIN_WORLD) + assert json.loads(os.environ["CAMOU_CONFIG"])["disableWorldIsolation"] is True + + +def test_only_the_first_pass_runs_isolated(): + """Exactly one isolated pytest run per group, and it is the measurement. + + A second isolated pass is what the first real CI run showed to be pure + waste: 7m50s a shard, nothing recovered, because these failures are + deterministic and slow (a Playwright timeout each) and upstream's own + pytest-rerunfailures had already retried every one of them three times. + Guarded here because "retry it in the same world first, just to be safe" + reads as obviously correct and costs eight minutes a shard. + """ + source = (CI_ROOT / "run_playwright.py").read_text(encoding="utf-8") + assert source.count('"CI_WORLD": ISOLATED_WORLD') == 1, ( + "a second isolated pass re-runs deterministic world differences at a " + "timeout each and recovers nothing" + ) + # The declared-hang pass (1b), the fallback (2), and the retry for what + # failed in both worlds (3). Isolation is the half that must stay at one; + # a main-world run is cheap and adjudicates, an isolated one measures. + assert source.count('"CI_WORLD": MAIN_WORLD') == 3 + + +def test_only_the_isolated_pass_is_cost_bounded(): + """The tighter timeout and the rerun suppression belong to pass 1 only. + + Pass 1 asks one question -- does this pass as Camoufox ships? -- and a test + that hangs has already answered it. Passes 2 and 3 are the ones that decide + what the answer means, so they keep upstream's conditions: the full timeout, + and upstream's own reruns. + """ + from ci.run_playwright import ISOLATED_TIMEOUT, _NO_UPSTREAM_RERUNS + + source = (CI_ROOT / "run_playwright.py").read_text(encoding="utf-8") + assert source.count("per_test_timeout=ISOLATED_TIMEOUT") == 1 + assert source.count("**_NO_UPSTREAM_RERUNS") == 1 + # Both on the isolated pass, not on a main-world one. + isolated = source.index('"CI_WORLD": ISOLATED_WORLD') + first_main = source.index('"CI_WORLD": MAIN_WORLD', source.index("# --- 2.")) + for marker in ("per_test_timeout=ISOLATED_TIMEOUT", "**_NO_UPSTREAM_RERUNS"): + at = source.index(marker) + assert abs(at - isolated) < abs(at - first_main), marker + + # 30.4s was the slowest test in the whole main-world baseline, and a + # Playwright action times out at 30s. Below ~60 this starts failing honest + # tests; at 180 a hang costs three minutes. + assert 60 <= ISOLATED_TIMEOUT <= 120 + assert _NO_UPSTREAM_RERUNS == {"CI": ""} + + +def test_suppressing_reruns_survives_upstreams_conftest(): + """`--reruns 0` on the command line would not work. + + upstream's tests/conftest.py sets `config.option.reruns = 3` in + pytest_configure whenever $CI is set, which overwrites anything passed as an + argument. Clearing the variable is the only lever that holds, and $CI is the + only thing that conftest reads it for -- so this must stay an env change, + not an argument. + """ + source = (CI_ROOT / "run_playwright.py").read_text(encoding="utf-8") + assert "--reruns" not in source, ( + "upstream's conftest overwrites config.option.reruns whenever $CI is " + "set, so a --reruns argument is silently ignored" + ) + + +def test_no_rerun_pass_can_start_from_an_empty_failure_set(): + """`--last-failed` with nothing previously failed runs EVERYTHING. + + pytest declines to filter when nothing it collected previously failed, so + an unguarded rerun pass would re-run the whole group -- in the other world, + silently replacing the result it was meant to refine. Every `--last-failed` + invocation must therefore sit behind a check that the set is non-empty. + """ + source = (CI_ROOT / "run_playwright.py").read_text(encoding="utf-8") + body = source[source.index("# --- 1. isolated world"):source.index('result.metrics["groups"]')] + guards = [n for n, line in enumerate(body.splitlines()) if line.strip() == "if not failing:"] + reruns = [n for n, line in enumerate(body.splitlines()) if '"--last-failed"' in line] + assert len(reruns) == 2, reruns + for r in reruns: + assert any(g < r for g in guards), ( + f"the --last-failed at line {r} of the group body is not guarded by a " + "non-empty failure set" + ) + + +def test_every_group_gets_its_own_pytest_cache(): + """`--last-failed` reads pytest's cache, and the cache is per directory. + + All three groups run in one checkout, and pytest only drops a `lastfailed` + entry when that test is collected again and passes -- so with a shared + cache the async group's rerun selected from a set the sync group had also + written into. Depending on which groups had failed that meant re-running + the entire group or selecting nothing at all. + """ + source = (CI_ROOT / "run_playwright.py").read_text(encoding="utf-8") + assert 'cache_dir = WORK_DIR / f"pytest-cache{suffix}" / str(index)' in source + assert 'common = [*base_args, "-o", f"cache_dir={cache_dir}"]' in source + # Every rerun goes through `common`, which is what carries the redirected + # cache. A `--last-failed` that did not would silently read the shared one. + reruns = re.findall(r"args=\[([^\]]*--last-failed[^\]]*)\]", source) + assert len(reruns) == 2, f"expected the retry and the fallback, got {reruns}" + for args in reruns: + assert args.strip().startswith("*common"), args + + +def test_the_skiplist_audit_runs_in_the_most_permissive_world(): + """An entry has to mean "cannot pass in either world". + + The suite counts a test needing the main world as a fallback, not a + failure. Auditing under isolation would let an entry justify itself with a + failure the suite would never have counted -- which is the same class of + untrue-but-plausible reason the audit exists to catch. + """ + source = (CI_ROOT / "run_skiplist_audit.py").read_text(encoding="utf-8") + assert '"CI_WORLD": MAIN_WORLD' in source + + +def test_fallback_counts_are_summed_across_shards_not_sampled(): + """Six shards each report their own share; the first shard's is not the total.""" + merged = merge_shards({ + "playwright-1of3": { + "gate": "playwright-1of3", "status": "pass", + "tests": {"a.py::t1": "pass"}, + "metrics": { + "main_world_fallback_count": 2, + "main_world_fallbacks": ["a.py::t1", "a.py::t2"], + "isolated_world_failures": 3, + "playwright_tag": "v1.61.0", + }, + }, + "playwright-2of3": { + "gate": "playwright-2of3", "status": "pass", + "tests": {"b.py::t3": "pass"}, + "metrics": { + "main_world_fallback_count": 4, + "main_world_fallbacks": ["b.py::t3"], + "isolated_world_failures": 4, + "playwright_tag": "v1.61.0", + }, + }, + "playwright-3of3": { + "gate": "playwright-3of3", "status": "pass", + "tests": {"c.py::t4": "pass"}, + "metrics": { + "main_world_fallback_count": 0, + "main_world_fallbacks": [], + "isolated_world_failures": 0, + "playwright_tag": "v1.61.0", + }, + }, + }) + metrics = merged["playwright"]["metrics"] + assert metrics["main_world_fallback_count"] == 6 + assert metrics["isolated_world_failures"] == 7 + assert metrics["main_world_fallbacks"] == ["a.py::t1", "a.py::t2", "b.py::t3"] + # A property of the run as a whole is still taken once, not summed. + assert metrics["playwright_tag"] == "v1.61.0" + + +def test_the_summary_publishes_the_fallback_count(): + """It is invisible in the pass/fail totals by construction -- these tests + pass -- so if it is not on the table it is not anywhere a reviewer looks.""" + from ci.summarize import render + + merged = {"playwright": { + "gate": "playwright", "status": "pass", "tests": {}, "notes": [], + "metrics": { + "tally": {"pass": 2229, "fail": 0, "total": 2295}, + "main_world_fallback_count": 37, + }, + }} + markdown = render(merged, ["playwright"], [], {}) + assert "37 via main-world fallback" in markdown + + +def test_a_zero_fallback_count_is_still_printed(): + """Zero is the interesting value: it is the one that means the gap closed, + and an absent line reads the same as a line nobody added.""" + from ci.summarize import render + + merged = {"playwright": { + "gate": "playwright", "status": "pass", "tests": {}, "notes": [], + "metrics": {"tally": {"pass": 10, "fail": 0, "total": 10}, "main_world_fallback_count": 0}, + }} + assert "0 via main-world fallback" in render(merged, ["playwright"], [], {}) + + +# --------------------------------------------------------------------------- +# reusing a browser that is already built +# --------------------------------------------------------------------------- + + +def _build_job(): + import yaml + + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["build"] + + +def test_the_native_inputs_cover_everything_that_can_change_the_binary(): + """The cache key and the "did the browser change?" test must agree. + + `resolve` decides whether to build at all by grepping the diff for paths + that can alter the binary. The build job then reuses a cached browser keyed + on a hash of the native inputs. If the first list ever grows and the second + does not, a change to the new path would neither force a build nor + invalidate the cache -- and every suite would report on a browser that + predates it, looking perfectly healthy while doing so. + """ + from ci.browser_inputs import BROWSER_DIRS, BROWSER_FILES + + text = WORKFLOW.read_text(encoding="utf-8") + scope = re.search(r"grep -qE '\^\(([^)]*)\)'", 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 + } + hashed = set(BROWSER_DIRS) | set(BROWSER_FILES) + missing = considered - hashed + assert not missing, ( + f"{sorted(missing)} can change the binary but does not feed the native hash, " + "so a change there would be served a stale browser" + ) + + +def test_jar_mn_is_read_not_guessed(): + """Two files in one source directory land at different depths. + + This is the trap the whole overlay turns on. A prefix rule would write + JugglerFrameChild.sys.mjs one level too deep, leave the old copy in place, + and run stale Juggler while every suite went green. + """ + from ci.browser_inputs import jar_entries + + entries = jar_entries() + assert entries["additions/juggler/TargetRegistry.js"] == "chrome/juggler/content/TargetRegistry.js" + assert entries["additions/juggler/content/FrameTree.js"] == "chrome/juggler/content/content/FrameTree.js" + assert entries["additions/juggler/content/JugglerFrameChild.sys.mjs"] == "chrome/juggler/content/JugglerFrameChild.sys.mjs" + + +def _fake_repo(tmp_path): + """A miniature additions/juggler with one resource and one native file.""" + jug = tmp_path / "additions" / "juggler" + (jug / "content").mkdir(parents=True) + (jug / "screencast").mkdir() + (jug / "Helper.js").write_text("resource\n") + (jug / "content" / "FrameTree.js").write_text("resource\n") + (jug / "screencast" / "Encoder.cpp").write_text("native\n") + (jug / "jar.mn").write_text( + "juggler.jar:\n% content juggler %content/\n" + " content/Helper.js (Helper.js)\n" + " content/content/FrameTree.js (content/FrameTree.js)\n" + ) + (tmp_path / "upstream.sh").write_text("version=1\n") + return tmp_path + + +def test_a_javascript_change_does_not_move_the_native_hash(tmp_path): + """The whole point: editing packaged JavaScript must not force a rebuild.""" + from ci.browser_inputs import native_digest + + root = _fake_repo(tmp_path) + before = native_digest(root) + (root / "additions" / "juggler" / "content" / "FrameTree.js").write_text("changed\n") + assert native_digest(root) == before + + +def test_a_cpp_change_does_move_the_native_hash(tmp_path): + """...and the converse, which is the half that must never be wrong. + + additions/juggler/ holds the screencast encoder and the debugging pipe as + well as the JavaScript. Treating the directory as "all resources" would ship + a browser without a C++ change in it. + """ + from ci.browser_inputs import native_digest + + root = _fake_repo(tmp_path) + before = native_digest(root) + (root / "additions" / "juggler" / "screencast" / "Encoder.cpp").write_text("changed\n") + assert native_digest(root) != before + + +def test_an_unrecognised_file_counts_as_native(tmp_path): + """Fail closed. A file type nobody has thought about forces a build.""" + from ci.browser_inputs import native_digest + + root = _fake_repo(tmp_path) + before = native_digest(root) + (root / "additions" / "juggler" / "something.rs").write_text("who knows\n") + assert native_digest(root) != before + + +def test_jar_mn_itself_is_native(tmp_path): + """It decides the mapping and what is packaged at all. + + If changing it only re-overlaid, a resource removed from jar.mn would keep + its stale copy in the dist forever. + """ + from ci.browser_inputs import native_digest + + root = _fake_repo(tmp_path) + before = native_digest(root) + (root / "additions" / "juggler" / "jar.mn").write_text( + "juggler.jar:\n% content juggler %content/\n content/Helper.js (Helper.js)\n" + ) + assert native_digest(root) != before + + +def test_the_overlay_writes_where_jar_mn_says(tmp_path): + from ci.browser_inputs import overlay + + root = _fake_repo(tmp_path) + dist = tmp_path / "bin" + (root / "additions" / "juggler" / "content" / "FrameTree.js").write_text("new js\n") + written = overlay(dist, root) + assert sorted(written) == [ + "chrome/juggler/content/Helper.js", + "chrome/juggler/content/content/FrameTree.js", + ] + assert (dist / "chrome/juggler/content/content/FrameTree.js").read_text() == "new js\n" + + +def test_the_overlay_runs_only_on_a_hit_and_before_the_upload(): + """On a fresh build the dist already holds the right resources; overlaying + there would cost a 634 MB unpack and repack to copy files onto themselves.""" + steps = _build_job()["steps"] + names = [s.get("name") or s.get("uses") or "run" for s in steps] + overlay = next(i for i, n in enumerate(names) if "resources over the restored" in n) + upload = next(i for i, n in enumerate(names) if n == "actions/upload-artifact@v4") + assert steps[overlay]["if"].strip() == "steps.prebuilt.outputs.cache-hit == 'true'" + assert overlay < upload, "the artifact would be uploaded before the resources were laid over it" + + +def test_a_restored_browser_still_reports_a_build_result(): + """Otherwise the gate fails on a cache hit. + + `build` is required whenever the browser was built rather than fetched, and + requiring a suite that produced no result is -- correctly -- a failure. A + cache hit skips the job that writes it, so the hit path has to write one + itself. Same trap as requiring `build` on a driver-only run, reached from + the other side. + """ + steps = _build_job()["steps"] + hit = [s for s in steps if s.get("if", "").strip() == "steps.prebuilt.outputs.cache-hit == 'true'"] + assert hit, "nothing runs on a cache hit, so no build result is produced" + assert any("GateResult(gate='build')" in str(s.get("run", "")) for s in hit) + + +def test_every_expensive_build_step_is_skipped_on_a_hit(): + """A hit that still spends twenty minutes clearing disk has saved nothing.""" + steps = _build_job()["steps"] + guard = "steps.prebuilt.outputs.cache-hit != 'true'" + expensive = ("Maximize build space", "Remove unwanted tools", "Install build dependencies", + "Create swap", "Prepare the source tree", "Build", + "Package the binary for the test jobs", "Restore ccache") + for name in expensive: + step = next((s for s in steps if s.get("name") == name), None) + assert step is not None, f"{name} is gone -- update this list" + assert step.get("if", "").strip() == guard, f"{name} runs even when the browser was restored" + + +def test_the_prebuilt_cache_has_no_restore_keys(): + """A prefix match would serve a browser built from different sources. + + Everywhere else in this workflow `restore-keys` is right -- a partially warm + ccache is still warm. Here it would hand the test jobs the wrong binary + while every suite reported on it as though it were the one under review. + """ + step = next(s for s in _build_job()["steps"] if s.get("id") == "prebuilt") + assert "restore-keys" not in step["with"] + assert step["with"]["path"] == "camoufox-dist.tar.zst" + + +# --------------------------------------------------------------------------- +# declared isolation hangs +# --------------------------------------------------------------------------- + + +def test_isolation_hangs_are_deselected_from_the_isolated_pass(): + """The whole point of declaring them: they must not reach pass 1. + + A hang there is bounded by nothing. pytest-timeout's signal fires and the + sync API's greenlet never unwinds, so the process wedges with the timeout + banner already printed -- which is what burned four shards for two hours + each on run 34799668707. + """ + from ci.run_playwright import ISOLATION_HANGS + + source = (CI_ROOT / "run_playwright.py").read_text(encoding="utf-8") + assert ISOLATION_HANGS + # Built from the declared list rather than spelled out, so adding an entry + # cannot leave the isolated pass still collecting it. + assert 'args=[*common, *[f"--ignore={m}" for m in hangs], *group.targets]' in source + assert "hangs = [m for m in ISOLATION_HANGS" in source + + +def test_isolation_hangs_still_run_somewhere(): + """Deselecting is not skipping. They run in the main world, and must. + + The run has to sit above the `failing` guard: a group whose isolated pass + found nothing hits `continue`, and anything below it would quietly stop + being covered on exactly the runs that look healthiest. + """ + source = (CI_ROOT / "run_playwright.py").read_text(encoding="utf-8") + declared = source.index("# --- 1b.") + guard = source.index("if not failing:") + second = source.index("# --- 2.") + assert declared < guard < second + # And an empty result is a failure, not an empty pass. + assert "hang that stops running is how coverage disappears" in source + + +def test_declared_hangs_run_unsharded_on_one_shard(): + """Sharding a six-test module hands most shards nothing. + + pytest exits 5 on an empty selection and writes no junit, which the guard + above cannot tell from "did not run" -- so it failed every shard that owned + none of the module ("collected 6 items / 6 deselected / 0 selected"), which + was three of six on the first run that got this far. Run once, whole, the + way tests/common/ is. + + CI_SHARD is cleared rather than dropped because ci/_util.run() layers env + over os.environ, so an omitted key still inherits whatever is there. + """ + source = (CI_ROOT / "run_playwright.py").read_text(encoding="utf-8") + assert "if hangs and first_shard:" in source + assert '"CI_SHARD": ""' in source + # Cleared for the declared-hang pass only; the real passes stay sharded. + assert source.count('"CI_SHARD": ""') == 1 + + +def test_empty_shard_selection_is_not_a_shard_number(): + """parse_shard must read cleared-to-empty as 'no shard', not raise. + + That is what makes clearing CI_SHARD a working way to unshard one run; if + it raised, the declared-hang pass would die on an unparseable shard instead. + """ + assert parse_shard("") is None + assert parse_shard(None) is None + assert parse_shard("3/6") == (3, 6) + + +def test_every_isolation_hang_is_inside_a_group_target(): + """A declared module outside every target would be deselected from nothing + and then run in a main-world pass that no group reaches -- covered on + paper, run never.""" + from ci.run_playwright import GROUPS, ISOLATION_HANGS + + targets = [t for g in GROUPS for t in g.targets] + for module in ISOLATION_HANGS: + assert any(module.startswith(t) for t in targets), module + + +def test_isolation_hangs_are_not_in_the_skiplist(): + """These two lists mean different things and the audit enforces the split. + + ci/skiplist.yml means "fails in the most permissive world", and + run_skiplist_audit.py checks it by running every entry with CI_WORLD=main + and failing the build on any that PASS. A route_web_socket test passes + there -- main world is precisely where the feature works -- so an entry + would be rejected by the audit and would be untrue as written. + """ + from ci.run_playwright import ISOLATION_HANGS + + entries = load_skiplist(CI_ROOT / "skiplist.yml") + listed = {str(e.get("module") or e.get("test") or "").lstrip("./") for e in entries} + for module in ISOLATION_HANGS: + assert module not in listed, ( + f"{module} is declared as an isolation hang AND skiplisted; the audit " + "runs skiplist entries in the main world, where it passes." + ) + + +def test_group_timeout_is_shorter_than_the_job_timeout(): + """The backstop can only fire if it is reached first. + + This is the bug that made a hang cost two hours rather than twenty minutes: + the per-invocation subprocess bound defaulted to 10800s against a job capped + at 120 minutes, so GitHub hard-killed the runner before it ever ran out -- + taking the junit and diagnostics uploads with it. + """ + import yaml + + from ci.run_playwright import main as _main # noqa: F401 + + source = (CI_ROOT / "run_playwright.py").read_text(encoding="utf-8") + default = int(re.search(r'"--group-timeout", type=int, default=(\d+)', source).group(1)) + + job = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["playwright"] + job_seconds = int(job["timeout-minutes"]) * 60 + assert default < job_seconds, (default, job_seconds) + # And with enough room left over to still upload what it collected. + assert default <= job_seconds // 2 + # Four times the slowest healthy invocation measured (296s); below that it + # starts cutting slow-but-working groups short. + assert default >= 900 diff --git a/ci/tribal-rules.yml b/ci/tribal-rules.yml new file mode 100644 index 000000000..864c9a5d2 --- /dev/null +++ b/ci/tribal-rules.yml @@ -0,0 +1,310 @@ +# Decisions this repository has already made, and the evidence for each. +# +# Compiled from code comments, commit messages, and the issue/PR record -- +# specifically from what was *rejected*, which is where the reasoning usually +# lives. A rule earns a place here when someone proposed the opposite, it was +# considered on the merits, and the answer was no. Those are the decisions that +# get re-litigated by the next well-meaning contributor, or quietly undone by an +# agent rebasing patches at 3am. +# +# `native-tests/test_tribal_rules.py` asserts every entry that is mechanically +# checkable. An entry with `check: manual` is documentation only. +# +# Adding one: cite the issue, PR, or commit. "I think this is how it works" is +# not evidence, and a rule nobody can trace gets deleted the first time it is +# inconvenient. + +schema: 1 + +rules: + # ------------------------------------------------------------------------- + # Virtual display + # ------------------------------------------------------------------------- + + - id: virtual-display-default-1x1x24 + title: The Xvfb default screen is 1x1x24 and stays that way + check: automated + evidence: + - "PR #714 (rejected): 'use 1920x1080 screen size for virtual display'" + - "PR #724 (rejected): the same change, proposed again" + - "issue #458: blank screenshots, the reported motivation for both" + rationale: >- + Proposed twice, rejected twice, on two grounds. First, it does not fix + anything: rendering was measured identical at both sizes -- innerWidth + 1280, innerHeight 984, fullpage 1280x5367 either way, with only + fingerprint-rotation noise in the colour count. Second, it costs + throughput: past 30-40 concurrent browsers the framebuffer becomes the + bottleneck on a machine that handles 70-80 at 1x1. The root window is not + a fingerprint -- screen.* comes from the generated fingerprint, applied + per context in the browser. + expect: + module: camoufox.virtdisplay + attribute: DEFAULT_SCREEN + equals: "1x1x24" + + - id: virtual-display-size-override-exists + title: The screen size is overridable, just not by default + check: automated + evidence: + - "PR #714 review: 'hardcoding 1920x1080 would be the wrong default'" + rationale: >- + The rejection was of the *default*, not the capability. Anyone who needs a + real framebuffer sets CAMOUFOX_VIRTUAL_DISPLAY_SIZE. Removing the hatch + would turn a settled disagreement back into an open one. + expect: + module: camoufox.virtdisplay + attribute: SCREEN_ENV_VAR + equals: CAMOUFOX_VIRTUAL_DISPLAY_SIZE + + - id: composite-extension-off + title: The X Composite extension stays disabled by default + check: automated + evidence: + - "issue #93: no video under headless='virtual'" + - "pythonlib/camoufox/virtdisplay.py: the measurement table" + rationale: >- + Briefly enabled on the theory that disabling it caused #93. It did not -- + #93 was a Juggler bug, fixed by capturing the screencast from the + compositor instead of libwebrtc's X11 window capturer. Measured at the + time: Composite on + record_video_dir crashed the browser with SIGSEGV + inside the X11 capturer. That capturer is gone, so enabling it is no + longer dangerous, but it is no longer useful either. Off is the + long-standing default and there is now no reason to move. + expect: + module: camoufox.virtdisplay + attribute: COMPOSITE_ENV_VAR + equals: CAMOUFOX_VIRTUAL_DISPLAY_COMPOSITE + + - id: displayfd-not-lockfile-scan + title: Displays are claimed atomically with Xvfb -displayfd + check: automated + evidence: + - "issue #597 / commit 8f9ff07" + rationale: >- + The old userspace lock-file scan plus random-jitter retry raced: many + camoufox processes starting at once all saw the same free display numbers + before any of them bound. -displayfd lets Xvfb scan up from :0 and bind + the first free socket itself -- kernel-mediated, no userspace race -- then + writes the number back down an inherited pipe. + expect: + module: camoufox.virtdisplay + xvfb_args_contain: "-displayfd" + + - id: displayfd-read-timeout + title: The displayfd read is bounded + check: automated + evidence: + - "commit 8f9ff07: 'so a hung Xvfb fails fast instead of blocking forever'" + rationale: >- + A hung Xvfb with no timeout blocks the launch forever, at 0% CPU, with no + diagnostic. Ten seconds is the agreed bound. + expect: + module: camoufox.virtdisplay + attribute: DISPLAYFD_READ_TIMEOUT_S + is_positive_number: true + + - id: xvfb-cleanup-runs-even-if-it-already-died + title: The display's lock and socket are removed whether or not we killed it + check: automated + evidence: + - "found by native-tests/test_crash_recovery.py::test_xvfb_sigkill_under_a_virtual_display" + - "isolated: Xvfb alive -> socket removed; Xvfb already dead -> socket leaked" + rationale: >- + kill() used to gate its entire body on `self.proc.poll() is None`, so a + display whose Xvfb had already died -- crashed, OOM-killed, reaped with + the browser's process group -- was never cleaned up. That is backwards: a + SIGKILLed Xvfb never removes its own socket, so the crash path is the only + one where /tmp/.X11-unix/X survives, and it was the one path kill() + skipped. Stranded sockets accumulate, and -displayfd scans upward for a + free number, so each one pushes the next display higher until a + long-running host stops being able to allocate one at all. + + - id: xvfb-sigkill-and-socket-cleanup + title: Xvfb is SIGKILLed, reaped, and its X11 lock and socket removed + check: automated + evidence: + - "PR #652 / commit 71fe028" + - "PR #618 / commit ce5edf6: wait() the child or it becomes a zombie" + rationale: >- + The old terminate-wait-kill ladder left zombie Xvfb processes, and the + stale /tmp/.X{n}-lock and /tmp/.X11-unix/X{n} files it left behind blocked + later display allocation. Both halves matter: SIGKILL then wait() so + nothing is reaped later, then unlink both files. + + # ------------------------------------------------------------------------- + # Dependencies + # ------------------------------------------------------------------------- + + - id: no-third-party-camoufox-distribution + title: Nothing here depends on someone else's camoufox package + check: automated + evidence: + - "PR #521 (2026-03-15) added `cloverlabs-camoufox` to build-tester/requirements.txt" + - "the commit's author is the PyPI author of that package" + - "build-tester/scripts/presets.py already prints the correct answer in its ImportError handler" + rationale: >- + build-tester generates its fingerprints with camoufox.fingerprints, so + whichever distribution provides `camoufox` decides what is actually being + tested. Pointing that at a third-party redistribution means the binary is + graded against someone else's fingerprint generation, a divergence between + the two reads as a browser bug, and every CI run pulls an outside package + into a job holding repository credentials. The only `camoufox` this + repository may install is pythonlib/ from this tree. + expect: + no_dependency_matching: "camoufox" + except_paths: ["pythonlib/pyproject.toml"] + allowed_forms: ["-e ../pythonlib", "-e ./pythonlib", "-e pythonlib"] + + # ------------------------------------------------------------------------- + # Measurement + # ------------------------------------------------------------------------- + + - id: canvas-noise-entropy-is-lower-than-audio + title: Two contexts share a canvas fingerprint far more often than they should + check: manual # an open finding, not a settled decision + evidence: + - "24 profiles across 3 runs on v152.0.4-beta.30, once the canvas was actually hashed" + - "audio 24 distinct / 24 samples; canvas 16 / 24, values recurring across independent runs" + - "macOS 7 distinct of 12, Linux 9 of 12; one value seen three times" + - "matches the rate at which CI flagged cross-profile canvas collisions" + rationale: >- + Audio and canvas are both noised per context, by the same machinery, and + were measured in the same runs with the same hash. Audio came back + completely unique; canvas did not, and its values repeat across runs that + share nothing. So two contexts in one browser are linkable by canvas + roughly a third of the time, which is the property per-context spoofing + exists to prevent. + This was invisible until the fingerprint stopped being a 100-character + prefix of a data URL, which compared equal for almost anything. It is + recorded as an open question rather than a decision: it may be that canvas + output is deliberately tied to the device preset rather than to + canvas:seed, in which case the seed is not doing what its name suggests. + Not gated, because failing one run in three helps nobody, and not dropped, + because it is real. + + - id: canvas-fingerprint-is-hashed-not-truncated + title: The canvas check hashes pixels, not a prefix of the data URL + check: automated + evidence: + - "build-tester/src/lib/checks/collectors.ts returned canvas.toDataURL().substring(0, 100)" + - "CI reported two contexts sharing a canvas fingerprint; 8 local runs across two rendering paths never did" + rationale: >- + A hundred characters of a data URL is not the image. "data:image/png;base64," + takes 22 of them, leaving about 58 bytes of PNG: the signature, the IHDR + giving width and height, the IDAT header, and roughly fifteen bytes of + deflate stream. Cross-profile uniqueness was therefore decided by whether + the noise happened to land in the top-left of the first scanline, and two + contexts whose canvas differed everywhere else read as identical. Worse, + the stability check compared the same prefix between two collections, so + it passed on PNG headers and could not have noticed non-deterministic + noise. getImageData is the surface Camoufox noises and the one a + fingerprinter reads; hash that. + + # ------------------------------------------------------------------------- + # Teardown + # ------------------------------------------------------------------------- + + - id: launch-failure-tears-down-playwright + title: A failed launch must not leak the Playwright session + check: automated + evidence: + - "issue #82 / PR #655 / commit 0d54401" + rationale: >- + The Playwright session is started before the browser. If the launch then + raises -- bad proxy, missing binary, invalid options -- the driver process + and its connection stay alive with nothing holding them. Every launch path + wraps the launch and calls the base __aexit__ on failure. + + - id: close-failure-still-tears-down + title: browser.close() raising must not skip teardown + check: automated + evidence: + - "issue #82 / PR #655 / commit 0d54401" + - "commit fd1bb2f: the same rule for the virtual display" + rationale: >- + A browser that has already crashed makes close() raise, and the original + code let that exception skip the driver teardown -- so the failure mode + that leaks is exactly the one that happens under load. The close goes in a + try, the teardown in the finally. The virtual display is cleaned up the + same way. + + # ------------------------------------------------------------------------- + # Contexts and windows + # ------------------------------------------------------------------------- + + - id: no-viewport-when-window-is-spoofed + title: Spoofed window dimensions imply no_viewport + check: automated + evidence: + - "issue #666 / PR #673: page-recycle hang under spoofed window dims" + rationale: >- + Playwright's default viewport deadlocks Juggler when the window has been + spoofed to a different size. Both launch paths default to no_viewport when + spoofs_window_dimensions() is true, unless the caller said otherwise. + expect: + module: camoufox.utils + has_callables: [spoofs_window_dimensions, attach_no_viewport_default] + + - id: virtual-display-skips-screen-clamp + title: A virtual display never clamps the generated screen + check: automated + evidence: + - "pythonlib/camoufox/utils.py: the `not virtual_display` guards" + rationale: >- + This is what makes the 1x1 default safe. If clamping applied, a 1x1 root + window would clamp every generated screen down to 1x1 and the fingerprint + would be absurd. The guard and the 1x1 default stand or fall together -- + remove the guard and the display size stops being a free choice. + + - id: evaluate-is-isolated-by-default + title: page.evaluate runs in an isolated world; "mw:" is the opt-in hatch + check: automated + evidence: + - "PR #745 / issue #738: let init scripts reach the page world with 'mw:'" + - "commit c3d5721: evaluate in an isolated world, with an opt-in main-world hatch" + rationale: >- + Injected automation JavaScript running in the page's world is visible to + the page, which is the thing this fork exists to prevent. Isolation is the + default and the "mw:" prefix is the deliberate, per-call way out. Upstream + Playwright's suite is run with isolation off because it asserts upstream + semantics -- that flag must never become the default here. + + # ------------------------------------------------------------------------- + # Juggler frame lifecycle + # ------------------------------------------------------------------------- + + - id: per-frame-state-released-on-dispose + title: Anything reset when the document changes is also released when the frame dies + check: automated + evidence: + - "additions/juggler/content/FrameTree.js: _masterSandbox was cleared in _onGlobalObjectCleared() but not in Frame.dispose()" + - "upstream Playwright's FrameTree.js has no _masterSandbox, and dispose() was inherited from it verbatim" + rationale: >- + Frame.dispose() came from upstream, which has no Camoufox-specific + per-frame fields. So when one is added, only the navigation path tends to + learn about it, and a frame that is *destroyed* rather than navigated + keeps it -- which is the common case on any page that creates and removes + iframes. _masterSandbox made that concrete: built over the page window + with a system principal, it holds the window's global and with it the + whole document, so a retained one keeps a dead document alive. The + invariant is simply that the two teardown paths agree: a field worth + resetting when the document changes is worth releasing when the frame goes + away, and a sandbox needs nuking rather than just dropping. + + # ------------------------------------------------------------------------- + # Input + # ------------------------------------------------------------------------- + + - id: input-through-one-chokepoint + title: Every synthesized input event goes through MouseDispatch.js + check: manual # enforced by scripts/check-input-dispatch.py in CI + evidence: + - "docs/input-dispatch.md: four deadlocks between 2026-04 and 2026-09" + - "issues #225, #677, #751, #752" + rationale: >- + Four separate deadlocks, each fixed by adding one more coordinate guard at + one more call site, which does not converge -- the trigger set is not + enumerable, because whether a coordinate reaches the renderer depends on a + rounding accident in the fractional height of spoofed browser chrome. One + unbounded await for an ack that never arrives wedges input for every tab + in the process, permanently, at 0% CPU. diff --git a/ci/versions.py b/ci/versions.py new file mode 100644 index 000000000..ab8e67626 --- /dev/null +++ b/ci/versions.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Work out which browser this run tests, and which Playwright suite tests it. + +Called by every entry point, so a pull request and the auto-update harness agree +on what "the tests" means: + + pull request browser comes from upstream.sh; suite is whichever released + playwright-python tag targets that Firefox generation. + auto-update the harness passes the Firefox version it is moving to, and + the same resolution runs against that instead. + +Suite selection is "newest released tag whose pinned Firefox is not ahead of +ours". Playwright trails Firefox by weeks, so requiring an exact match would +mean no suite at all for most of a release cycle; taking a newer suite than the +browser would mean testing against an automation contract that assumes engine +work this build does not have. Newest-not-ahead is the one that is both +available and honest. + +Run: + python3 -m ci.versions --json + python3 -m ci.versions --browser-version 153.0.4 --json +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from typing import Dict, List, Optional, Tuple + +from ._util import ( + REPO_ROOT, + http_json, + log, + major, + parse_version, + read_upstream_sh, + set_output, +) + +BROWSERS_JSON = "https://raw.githubusercontent.com/microsoft/playwright/{ref}/packages/playwright-core/browsers.json" +TAGS_API = "https://api.github.com/repos/microsoft/playwright-python/tags?per_page=100" +PYPROJECT = "pythonlib/pyproject.toml" +_CEILING = re.compile(r'^playwright\s*=\s*"<\s*([0-9][0-9.]*)"', re.M) + +# Consulted only when the network is unavailable or GitHub is rate-limiting an +# unauthenticated runner. Deliberately short: it is a floor, not a source of +# truth, and a stale entry here is better than a run that cannot start. +FALLBACK_PINS: Tuple[Tuple[str, str], ...] = ( + ("v1.62.0", "153.0"), + ("v1.61.0", "151.0"), + ("v1.60.0", "150.0.2"), + ("v1.59.0", "148.0.2"), + ("v1.58.0", "146.0.1"), +) + + +def _gh_headers() -> Dict[str, str]: + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + return {"Authorization": f"Bearer {token}"} if token else {} + + +def released_tags(limit: int = 40) -> List[str]: + """Final playwright-python tags, newest first.""" + tags = http_json(TAGS_API, headers=_gh_headers()) + parsed: List[Tuple[Tuple[int, int, int], str]] = [] + for tag in tags: + name = tag.get("name", "") + if not name.startswith("v") or any(m in name for m in ("alpha", "beta", "rc", "next", "-")): + continue + try: + parsed.append((parse_version(name[1:]), name)) + except ValueError: + continue + parsed.sort(reverse=True) + return [name for _, name in parsed[:limit]] + + +def firefox_pinned_by(tag: str) -> Optional[str]: + """The Firefox browserVersion a Playwright tag ships against.""" + try: + data = http_json(BROWSERS_JSON.format(ref=tag)) + except Exception: + return None + for browser in data.get("browsers", []): + if browser.get("name") == "firefox": + return browser.get("browserVersion") + return None + + +def pins(limit: int = 10) -> List[Tuple[str, str]]: + """[(playwright tag, firefox version)], newest first, with a fallback.""" + try: + out = [] + for tag in released_tags()[:limit]: + firefox = firefox_pinned_by(tag) + if firefox: + out.append((tag, firefox)) + if out: + return out + log("no Playwright pins resolved from the network", level="WARN") + except Exception as exc: # noqa: BLE001 + log(f"could not reach the Playwright tag list ({exc}); using the built-in table", level="WARN") + return list(FALLBACK_PINS) + + +def client_ceiling() -> Optional[Tuple[int, int, int]]: + """The Playwright version pythonlib refuses to go to, or None if unpinned. + + `camoufox.server` imports `playwright._impl._driver`, a private API with no + compatibility guarantee, and every Playwright minor is free to change + Juggler. pythonlib pins a ceiling for that reason, and a suite run above it + would be testing the browser against a client its own package will not + install -- a green run that proves nothing a user can reproduce. + """ + try: + text = (REPO_ROOT / PYPROJECT).read_text(encoding="utf-8") + except OSError: + return None + found = _CEILING.search(text) + if not found: + return None + try: + return parse_version(found.group(1)) + except ValueError: + return None + + +def resolve( + *, + browser_version: Optional[str] = None, + playwright_tag: Optional[str] = None, +) -> Dict[str, str]: + """Everything a run needs to know about versions.""" + upstream = read_upstream_sh() + browser_version = browser_version or upstream.get("version", "") + release = upstream.get("release", "") + if not browser_version: + raise SystemExit("could not determine a browser version; upstream.sh has no `version`") + + available = pins() + + if playwright_tag: + pinned = dict(available).get(playwright_tag) or firefox_pinned_by(playwright_tag) or "" + chosen = (playwright_tag, pinned) + note = f"pinned explicitly to {playwright_tag}, which targets Firefox {pinned or '?'}" + else: + ours = major(browser_version) + # Newest suite that is not ahead of the browser we are testing. + eligible = [(t, f) for t, f in available if major(f) <= ours] + + # ... and not above the client ceiling pythonlib pins. Dropping this + # filter does not fail loudly: the suite installs a client the shipped + # package forbids, and whatever Juggler changed in between reads as a + # browser bug. + ceiling = client_ceiling() + if ceiling and eligible: + allowed = [(t, f) for t, f in eligible if parse_version(t.lstrip("v")) < ceiling] + if allowed and allowed != eligible: + dropped = sorted({t for t, _ in eligible} - {t for t, _ in allowed}) + log( + f"ignoring {', '.join(dropped)}: at or above pythonlib's " + f"playwright ceiling ({PYPROJECT})" + ) + eligible = allowed + elif not allowed: + log( + f"every suite not ahead of Firefox {ours} is at or above pythonlib's " + f"playwright ceiling; testing above it. Bump the ceiling in {PYPROJECT} " + "or expect protocol noise.", + level="WARN", + ) + + if eligible: + chosen = max(eligible, key=lambda tf: parse_version(tf[1])) + note = ( + f"the newest released suite not ahead of Firefox {ours} " + f"(it targets Firefox {chosen[1]})" + ) + else: + # Every known suite is newer than this browser -- an old branch, or + # a Firefox so new nothing targets it yet. Take the oldest available + # rather than refusing to test at all, and say so loudly. + chosen = min(available, key=lambda tf: parse_version(tf[1])) + note = ( + f"no suite targets Firefox {ours} or older; falling back to the oldest " + f"available ({chosen[0]}, Firefox {chosen[1]}). Expect conformance noise." + ) + log(note, level="WARN") + + ours_display = major(browser_version) + resolved = { + "browser_version": browser_version, + "browser_release": release, + "playwright_tag": chosen[0], + "playwright_firefox": chosen[1], + "note": note, + } + log( + f"testing Camoufox {browser_version} ({release or 'no release tag'}), built on " + f"Firefox {ours_display}, against Playwright {chosen[0]} -- {note}" + ) + return resolved + + +def upstream_mismatch(browser_version: Optional[str]) -> Optional[str]: + """Complain if a requested version is not the one the build will produce. + + Only suite SELECTION follows `--browser-version`. The build does not: both + `ci.run_build` and `make` read upstream.sh, and the fetch path downloads + whatever pythonlib considers current. So asking for a version the branch + does not pin gets you the old browser tested against the new suite -- the + exact inversion of the intent, and silent. + + The legitimate flow has no mismatch in it: a Firefox bump edits upstream.sh + on its branch, and resolution then reads that by default. This exists to + catch the other case before it produces a meaningless green run. + """ + if not browser_version: + return None + pinned = (read_upstream_sh().get("version") or "").strip() + if not pinned or pinned == browser_version.strip(): + return None + return ( + f"asked to test Firefox {browser_version}, but upstream.sh pins {pinned}. " + "The build follows upstream.sh, so this would compile " + f"{pinned} and judge it against the suite chosen for {browser_version}. " + "Bump upstream.sh on the branch instead -- that is what an upgrade is." + ) + + +def fetched_mismatch(fetched: str, expected: str) -> Optional[str]: + """The published build must be the Firefox generation the suite was chosen for. + + The driver-only path does not build; it downloads the current release, which + is the right browser to judge a driver change against -- it is what users + run. But the suite comes from upstream.sh, and during an upgrade window those + two part company: upstream.sh moves to the new Firefox before any build of it + is published, so a driver PR would fetch the OLD browser and test it against + the NEW suite. + + Beta drift inside a generation is fine and expected (beta.30 vs beta.31 does + not change which Playwright tag is right). A generation apart is not. + """ + found = re.search(r"(\d+(?:\.\d+)*)", fetched or "") + if not found: + return f"could not read a version out of the fetched build: {fetched!r}" + try: + got, want = major(found.group(1)), major(expected) + except ValueError: + return f"could not compare fetched {fetched!r} with expected {expected!r}" + if got == want: + return None + return ( + f"fetched Camoufox {found.group(1)} (Firefox {got}), but the suite was chosen " + f"for Firefox {want}. A driver-only run tests the published release, and no " + f"release of Firefox {want} is published yet. Wait for one, or let this run " + "build from source instead." + ) + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--browser-version", help="override the version from upstream.sh") + parser.add_argument("--playwright-tag", help="pin the suite instead of resolving one") + parser.add_argument("--json", action="store_true") + parser.add_argument( + "--check-fetched", + metavar="VERSION", + help="fail if this fetched build is a different Firefox generation than the suite", + ) + parser.add_argument( + "--check-upstream", + action="store_true", + help="fail if --browser-version disagrees with upstream.sh", + ) + args = parser.parse_args(argv) + + if args.check_fetched: + upstream = read_upstream_sh() + expected = args.browser_version or upstream.get("version", "") + problem = fetched_mismatch(args.check_fetched, expected) + if problem: + log(problem, level="ERROR") + return 1 + log(f"fetched build agrees with the suite's Firefox generation ({major(expected)})") + return 0 + + if args.check_upstream: + problem = upstream_mismatch(args.browser_version) + if problem: + log(problem, level="ERROR") + return 1 + + resolved = resolve( + browser_version=args.browser_version, playwright_tag=args.playwright_tag + ) + if args.json: + print(json.dumps(resolved, indent=2, sort_keys=True)) + for key, value in resolved.items(): + set_output(key, value) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/async/__init__.py b/native-tests/__init__.py similarity index 100% rename from tests/async/__init__.py rename to native-tests/__init__.py diff --git a/native-tests/_chaos.py b/native-tests/_chaos.py new file mode 100644 index 000000000..0a1bebd6b --- /dev/null +++ b/native-tests/_chaos.py @@ -0,0 +1,162 @@ +"""Helpers for killing things and watching what survives. + +Camoufox's real process tree, as observed rather than assumed: + + python (the test) + ├── node Playwright's driver + │ └── camoufox-bin the browser parent + │ ├── Socket Process + │ └── forkserver + │ ├── Web Content one per content process + │ ├── RDD Process media decoder + │ └── WebExtensions + ├── Xvfb only under headless="virtual" + └── python3 stdlib multiprocessing.resource_tracker + +Every one of those can die badly in production -- OOM killer, a segfault, a +container reaping a process group, an X server going away. What matters is not +that the browser survives (often it cannot) but that the failure is *bounded and +clean*: the call returns instead of hanging forever, and nothing is left behind +to accumulate. + +Targeting is always restricted to this process's own descendants. A test that +went looking for "anything called camoufox" would kill the developer's browser. +""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass +from typing import Callable, List, Optional + +import pytest + +# Names as the kernel reports them, from an observed tree. +DRIVER = "node" +BROWSER = "camoufox-bin" +SOCKET_PROCESS = "Socket Process" +FORKSERVER = "forkserver" +WEB_CONTENT = "Web Content" +RDD = "RDD Process" +WEB_EXTENSIONS = "WebExtensions" +XVFB = "Xvfb" + +_STDLIB_HELPERS = ("resource_tracker", "semaphore_tracker") + +# Short-lived probes Gecko spawns at startup and deliberately does not wait for. +# glxtest asks the GL stack what it supports; vaapitest does the same for video +# decode. Both detach, answer, and exit on their own, and on a runner with no GPU +# glxtest can outlive a browser that has already gone. Counting one as a leaked +# process is wrong twice over: it is not ours to reap, and it does not stay. +_GECKO_PROBES = ("glxtest", "vaapitest") + + +@dataclass +class Victim: + pid: int + name: str + + def __str__(self) -> str: + return f"{self.name}({self.pid})" + + +def descendants(psutil_mod, *, name: Optional[str] = None) -> List: + """Our own descendants, optionally filtered by process name.""" + out = [] + for proc in psutil_mod.Process().children(recursive=True): + try: + if any(h in " ".join(proc.cmdline()) for h in _STDLIB_HELPERS): + continue + if proc.name() in _GECKO_PROBES: + continue + if name is None or proc.name() == name: + out.append(proc) + except (psutil_mod.NoSuchProcess, psutil_mod.AccessDenied): + continue + return out + + +def driver_process(psutil_mod, *, timeout: float = 30.0): + """Playwright's driver, found by position rather than by name. + + It is `node` on a developer machine and something else on a CI runner -- + an observed tree there had MainThread, camoufox-bin, forkserver and no node + at all, so a test keyed on the name failed for a reason that had nothing to + do with what it was checking. + + Position is stable where the name is not: the driver is whatever launched + the browser, so look for the browser and walk up one. + """ + browser = wait_for_process(psutil_mod, BROWSER, timeout=timeout)[0] + me = psutil_mod.Process().pid + try: + parent = browser.parent() + except psutil_mod.Error: + parent = None + if parent is None or parent.pid == me: + return None # launched directly by us; there is no separate driver + return parent + + +def wait_for_process(psutil_mod, name: str, *, timeout: float = 30.0) -> List: + """Block until at least one process of this name is a descendant.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + found = descendants(psutil_mod, name=name) + if found: + return found + time.sleep(0.2) + have = sorted({p.name() for p in descendants(psutil_mod)}) + raise AssertionError(f"no {name!r} process appeared within {timeout}s; saw {have}") + + +def sigkill(psutil_mod, name: str, *, count: int = 1, timeout: float = 30.0) -> List[Victim]: + """SIGKILL up to `count` descendants named `name`. Returns what was killed. + + SIGKILL rather than SIGTERM on purpose: this is simulating a crash or an + OOM kill, not a polite shutdown. A process given the chance to clean up + after itself is not the case that leaks. + """ + targets = wait_for_process(psutil_mod, name, timeout=timeout)[:count] + killed = [] + for proc in targets: + try: + victim = Victim(proc.pid, proc.name()) + proc.kill() + killed.append(victim) + except (psutil_mod.NoSuchProcess, psutil_mod.AccessDenied): + continue + psutil_mod.wait_procs([p for p in targets], timeout=10) + return killed + + +async def bounded(awaitable, seconds: float, what: str): + """Run something with a deadline, and fail loudly rather than hang. + + "Does the browser hang?" is the question half this suite exists to answer, + so a hang has to be an assertion failure with a name attached -- not a job + that sits at 0% CPU until the runner's timeout kills it an hour later and + tells you nothing. + """ + try: + return await asyncio.wait_for(awaitable, timeout=seconds) + except asyncio.TimeoutError: + raise AssertionError( + f"{what} did not return within {seconds}s -- it hung. This is the failure " + "mode that wedges a long-running scraper: no error, no CPU, no diagnostic." + ) from None + except Exception: + # Raising is fine and often correct after a crash; hanging is not. + return None + + +async def close_bounded(target, seconds: float = 60.0, what: str = "close()"): + """close() something that may already be dead, within a deadline.""" + return await bounded(target.close(), seconds, what) + + +def x11_artifacts_for(display: str) -> List[str]: + """The lock and socket a given ':N' display owns.""" + index = display.lstrip(":") + return [f"/tmp/.X{index}-lock", f"/tmp/.X11-unix/X{index}"] diff --git a/native-tests/_churn.py b/native-tests/_churn.py new file mode 100644 index 000000000..3599f43a0 --- /dev/null +++ b/native-tests/_churn.py @@ -0,0 +1,153 @@ +"""A local page that churns one browser mechanism, hard, offline. + +daijro/camoufox#762 needs an ad-heavy page and a couple of minutes to reach 15 GB. +That is not a test. But the *shape* of a runaway is reproducible without the ads: +drive one mechanism thousands of times and watch whether the content process's +memory scales with the count. + +The theory this is built to test is that Camoufox adds per-something state that +stock Firefox does not have -- an isolated world, a canvas noise seed, a font +list -- and that something churning fast enough (an ad stack creating and +destroying iframes, compiling scripts, drawing to canvases) accumulates it. + +Each churn page hammers exactly one mechanism, so a leak points at a culprit +instead of just saying "memory grew". Everything is served from loopback: no +network, no ad rotation, byte-identical every run, and fast enough to sit in CI. +""" + +from __future__ import annotations + +import http.server +import threading +from typing import Dict + +_PAGE = """ + +churn: {name} + +
+ + +""" + +# Each body runs once per iteration and must clean up after itself. Anything +# that grows here is the browser's doing, not the page's. +BODIES: Dict[str, str] = { + # Ad stacks are iframe machines. Every document creates a fresh scope, and + # under Camoufox a fresh isolated world alongside it. + "iframe": """ + const f = document.createElement('iframe'); + f.srcdoc = ' diff --git a/tests/assets/client-certificates/README.md b/tests/assets/client-certificates/README.md deleted file mode 100644 index b0ee78e70..000000000 --- a/tests/assets/client-certificates/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# Client Certificate test-certificates - -## Server - -```bash -openssl req \ - -x509 \ - -newkey rsa:4096 \ - -keyout server/server_key.pem \ - -out server/server_cert.pem \ - -nodes \ - -days 365 \ - -subj "/CN=localhost/O=Client\ Certificate\ Demo" \ - -addext "subjectAltName=DNS:localhost,DNS:local.playwright" -``` - -## Trusted client-certificate (server signed/valid) - -``` -mkdir -p client/trusted -# generate server-signed (valid) certifcate -openssl req \ - -newkey rsa:4096 \ - -keyout client/trusted/key.pem \ - -out client/trusted/csr.pem \ - -nodes \ - -days 365 \ - -subj "/CN=Alice" - -# sign with server_cert.pem -openssl x509 \ - -req \ - -in client/trusted/csr.pem \ - -CA server/server_cert.pem \ - -CAkey server/server_key.pem \ - -out client/trusted/cert.pem \ - -set_serial 01 \ - -days 365 -``` - -## Self-signed certificate (invalid) - -``` -mkdir -p client/self-signed -openssl req \ - -newkey rsa:4096 \ - -keyout client/self-signed/key.pem \ - -out client/self-signed/csr.pem \ - -nodes \ - -days 365 \ - -subj "/CN=Bob" - -# sign with self-signed/key.pem -openssl x509 \ - -req \ - -in client/self-signed/csr.pem \ - -signkey client/self-signed/key.pem \ - -out client/self-signed/cert.pem \ - -days 365 -``` diff --git a/tests/assets/client-certificates/client/self-signed/cert.pem b/tests/assets/client-certificates/client/self-signed/cert.pem deleted file mode 100644 index 3c0771794..000000000 --- a/tests/assets/client-certificates/client/self-signed/cert.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEyzCCArOgAwIBAgIUYps4gh4MqFYg8zqQhHYL7zYfbLkwDQYJKoZIhvcNAQEL -BQAwDjEMMAoGA1UEAwwDQm9iMB4XDTI0MDcxOTEyNDc0MFoXDTI1MDcxOTEyNDc0 -MFowDjEMMAoGA1UEAwwDQm9iMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC -AgEA179eTsqcc1c3AOQHzCZEyYLPta2CCAscUFqcEZ9vWvjW0uzOv9TDlB33Unov -jch4CElZOBhzTadVsbmnYKpxwyVU89WCuQKvedz4k1vu7S1YryfNbmS8PWbnQ4ds -9NB7SgJNHZILvx9DXuWeFEyzRIo1984z4HheBzrkf791LqpYKaKziANUo8h8t0dm -TX/boOz8cEnQNwtTC0ZX3aD0obG/UAhr/22ZGPo/E659fh4ptyYX2LrIUHGy+Eux -nJ9Y4cTqa88Ee6K6AkDiT/AoNQNxE4X++jqLuie8j/ZYpI1Oll38GwKVOyy1msRL -toGmISNwkMIQDGABrJlxgpP4QQAQ+08v9srzXOlkdxdr7OCP81r+ccBXiSQEe7BA -kdJ8l98l5dprJ++GJ+SZcV4+/iGR0dKU2IdAG5HiKZIFn6ch9Ux+UMqeGaYCpkHr -TiietHwcXgtVBlE0jFmB/HspmI/O0abK+grMmueaH7XtTI8YHnw0mUpL8+yp7mfA -7zFusgFgyiBPXeD/NQgg8vja67k++d1VGoXm2xr+5WPQCSbgQoMkkOBMLHWJTefd -6F4Z5M+oI0VwYbf6eQW246wJgpCHSPR0Vdijd6MAGRWKUuLfDsA9+12iGbKvwJ2e -nJlStft2V2LZcjBfdIMbigW1aSVNN5w6m6YVrQPry3WPkWcCAwEAAaMhMB8wHQYD -VR0OBBYEFPxKWTFQJSg4HD2qjxL0dnXX/z4qMA0GCSqGSIb3DQEBCwUAA4ICAQBz -4H1d5eGRU9bekUvi7LbZ5CP/I6w6PL/9AlXqO3BZKxplK7fYGHd3uqyDorJEsvjV -hxwvFlEnS0JIU3nRzhJU/h4Yaivf1WLRFwGZ4TPBjX9KFU27exFWD3rppazkWybJ -i4WuEdP3TJMdKLcNTtXWUDroDOgPlS66u6oZ+mUyUROil+B+fgQgVDhjRc5fvRgZ -Lng8wuejCo3ExQyxkwn2G5guyIimgHmOQghPtLO5xlc67Z4GPUZ1m4tC+BCiFO4D -YIXl3QiIpmU7Pss39LLKMGXXAgLRqyMzqE52lsznu18v5vDLfTaRH4u/wjzULhXz -SrV1IUJmhgEXta4EeDmPH0itgKtkbwjgCOD7drrFrJq/EnvIaJ5cpxiI1pFmYD8g -VVD7/KT/CyT1Uz1dI8QaP/JX8XEgtMJaSkPfjPErIViN9rh9ECCNLgFyv7Y0Plar -A6YlvdyV1Rta/BHndf5Hqz9QWNhbFCMQRGVQNEcoKwpFyjAE9SXoKJvFIK/w5WXu -qKzIYA26QXE3p734Xu1n8QiFJIyltVHbyUlD0k06194t5a2WK+/eDeReIsk0QOI8 -FGqhyPZ7YjR5tSZTmgljtViqBO5AA23QOVFqtjOUrjXP5pTbPJel99Z/FTkqSwvB -Rt4OX7HfuokWQDTT0TMn5jVtJyi54cH7f9MmsNJ23g== ------END CERTIFICATE----- diff --git a/tests/assets/client-certificates/client/self-signed/csr.pem b/tests/assets/client-certificates/client/self-signed/csr.pem deleted file mode 100644 index 4c99e1349..000000000 --- a/tests/assets/client-certificates/client/self-signed/csr.pem +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIIEUzCCAjsCAQAwDjEMMAoGA1UEAwwDQm9iMIICIjANBgkqhkiG9w0BAQEFAAOC -Ag8AMIICCgKCAgEA179eTsqcc1c3AOQHzCZEyYLPta2CCAscUFqcEZ9vWvjW0uzO -v9TDlB33Unovjch4CElZOBhzTadVsbmnYKpxwyVU89WCuQKvedz4k1vu7S1YryfN -bmS8PWbnQ4ds9NB7SgJNHZILvx9DXuWeFEyzRIo1984z4HheBzrkf791LqpYKaKz -iANUo8h8t0dmTX/boOz8cEnQNwtTC0ZX3aD0obG/UAhr/22ZGPo/E659fh4ptyYX -2LrIUHGy+EuxnJ9Y4cTqa88Ee6K6AkDiT/AoNQNxE4X++jqLuie8j/ZYpI1Oll38 -GwKVOyy1msRLtoGmISNwkMIQDGABrJlxgpP4QQAQ+08v9srzXOlkdxdr7OCP81r+ -ccBXiSQEe7BAkdJ8l98l5dprJ++GJ+SZcV4+/iGR0dKU2IdAG5HiKZIFn6ch9Ux+ -UMqeGaYCpkHrTiietHwcXgtVBlE0jFmB/HspmI/O0abK+grMmueaH7XtTI8YHnw0 -mUpL8+yp7mfA7zFusgFgyiBPXeD/NQgg8vja67k++d1VGoXm2xr+5WPQCSbgQoMk -kOBMLHWJTefd6F4Z5M+oI0VwYbf6eQW246wJgpCHSPR0Vdijd6MAGRWKUuLfDsA9 -+12iGbKvwJ2enJlStft2V2LZcjBfdIMbigW1aSVNN5w6m6YVrQPry3WPkWcCAwEA -AaAAMA0GCSqGSIb3DQEBCwUAA4ICAQCb07d2IjUy1PeHCj/2k/z9FrZSo6K3c8y6 -b/u/MZ0AXPKLPDSo7UYpOJ8Z2cBiJ8jQapjTSEL8POUYqcvCmP55R6u68KmvINHo -+Ly7pP+xPrbA4Q0WmPnz37hQn+I1he0GuEQyjZZqUln9zwp67TsWNKxKtCH+1j8M -Ltzx6kuHCdPtDUtv291yhVRqvbjiDs+gzdQYNJtAkUbHwHFxu8oZhg8QZGyXYMN8 -TGoQ1LTezFZXJtX69K7WnrDGrjsgB6EMvwkqAFSYNH0LFvI0xo13OOgXr9mrwohA -76uZtjXL9B15EqrMce6mdUZi46QJuQ2avTi57Lz+fqvsBYdQO89VcFSmqu2nfspN -QZDrooyjHrlls8MpoBd8fde9oT4uA4/d9SJtuHUnjgGN7Qr7eTruWXL8wVMwFnvL -igWE4detO9y2gpRLq6uEqzWYMGtN9PXJCGU8C8m9E2EBUKMrT/bpNbboatLcgRrW -acj0BRVqoVzk1sRq7Sa6ejywqgARvIhTehg6DqdMdcENCPQ7rxDRu5PSDM8/mwIj -0KYl8d2PlECB4ofRyLcy17BZzjP6hSnkGzcFk0/bChZOSIRnwvKbvfXnB45hhPk8 -XwT/6UNSwC2STP3gtOmLqrWj+OE0gy0AkDMvP3UnQVGMUvgfYg+N4ROCVtlqzxe9 -W65c05Mm1g== ------END CERTIFICATE REQUEST----- diff --git a/tests/assets/client-certificates/client/self-signed/key.pem b/tests/assets/client-certificates/client/self-signed/key.pem deleted file mode 100644 index 70d5e3dd0..000000000 --- a/tests/assets/client-certificates/client/self-signed/key.pem +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDXv15OypxzVzcA -5AfMJkTJgs+1rYIICxxQWpwRn29a+NbS7M6/1MOUHfdSei+NyHgISVk4GHNNp1Wx -uadgqnHDJVTz1YK5Aq953PiTW+7tLVivJ81uZLw9ZudDh2z00HtKAk0dkgu/H0Ne -5Z4UTLNEijX3zjPgeF4HOuR/v3UuqlgporOIA1SjyHy3R2ZNf9ug7PxwSdA3C1ML -RlfdoPShsb9QCGv/bZkY+j8Trn1+Him3JhfYushQcbL4S7Gcn1jhxOprzwR7oroC -QOJP8Cg1A3EThf76Oou6J7yP9likjU6WXfwbApU7LLWaxEu2gaYhI3CQwhAMYAGs -mXGCk/hBABD7Ty/2yvNc6WR3F2vs4I/zWv5xwFeJJAR7sECR0nyX3yXl2msn74Yn -5JlxXj7+IZHR0pTYh0AbkeIpkgWfpyH1TH5Qyp4ZpgKmQetOKJ60fBxeC1UGUTSM -WYH8eymYj87Rpsr6Csya55ofte1MjxgefDSZSkvz7KnuZ8DvMW6yAWDKIE9d4P81 -CCDy+NrruT753VUahebbGv7lY9AJJuBCgySQ4EwsdYlN593oXhnkz6gjRXBht/p5 -BbbjrAmCkIdI9HRV2KN3owAZFYpS4t8OwD37XaIZsq/AnZ6cmVK1+3ZXYtlyMF90 -gxuKBbVpJU03nDqbphWtA+vLdY+RZwIDAQABAoICAETxu6J0LuDQ+xvGwxMjG5JF -wjitlMMbQdYPzpX3HC+3G3dWA4/b3xAjL1jlAPNPH8SOI/vAHICxO7pKuMk0Tpxs -/qPZFCgpSogn7CuzEjwq5I88qfJgMKNyke7LhS8KvItfBuOvOx+9Ttsxh323MQZz -IGHrPDq8XFf1IvYL6deaygesHbEWV2Lre6daIsAbXsUjVlxPykD81nHg7c0+VU6i -rZ9WwaRjkqwftC6G8UVvQCdt/erdbYv/eZDNJ5oEdfPX6I3BHw6fZs+3ilq/RSoD -yovRozS1ptc7QY/DynnzSizVJe4/ug6p7/LgTc2pyrwGRj+MNHKv73kHo/V1cbxF -fBJCpxlfcGcEP27BkENiTKyRQEF1bjStw+UUKygrRXLm3MDtAVX8TrDERta4LAeW -XvPiJbSOwWk2yYCs62RyKl+T1no7alIvc6SUy8rvKKm+AihjaTsxTeACC1cBc41m -5HMz1dqdUWcB5jbnPsV+27dNK1/zIC+e0OXtoSXvS+IbQXo/awHJyXv5ClgldbB9 -hESFTYz/uI6ftuTM6coHQfASLgmnq0fOd1gyqO6Jr9ZSvxcPNheGpyzN3I3o5i2j -LTYJdX3AoI5rQ5d7/GS2qIwWf0q8rxQnq1/34ABWD0umSa9tenCXkl7FIB4drwPB -4n7n+SL7rhmv0vFKIjepAoIBAQD19MuggpKRHicmNH2EzPOyahttuhnB7Le7j6FC -afuYUBFNcxww+L34GMRhmQZrGIYmuQ3QV4RjYh2bowEEX+F5R1V90iBtYQL1P73a -jYtTfaJn0t62EBSC//w2rtaRJPgGhbXbnyid64J0ujRFCelej8FRJdBV342ctRAL -0RazxQ/KcTRl9pncALxGhnSsBElZlDtZd/dWnWBDZ/fg/C97VV9ZQLcpyGvL516i -GpB8BQsHiIe9Jt5flZvcKB7z/KItGzPB4WK6dpV8t/FeQiUpZXkQlqO03XaZT4NP -AEGH3rKIRMpP7TORYFhbYrZwov3kzLaggax2wGPTkfMFNlTjAoIBAQDgjsYfShkz -6Dl1UTYBrDMy9pakJbC6qmd0KOKX+4XH/Dc1mOzR8NGgoY7xWXFUlozgntKKnJda -M6GfOt/dxc0Sq7moYzA7Jv4+9hNdU3jX5YrqAbcaSFj6k4yauO2BKCBahQo8qseY -a3N5f0gp+5ftTMvOTwGw3JRJFJq0/DvKWAYLIaJ0Oo77zGs0vxa1Aqob10MloXt5 -DMwjazWujntTzTJY1vsfsBHa8OEObMwiftqnmn6L4Qprd3AzQkaNlZEsvERyLfFq -1pu4EsDJJGdVfpZYfo+6vTglLXFBLEUQmh4/018Mw4O4pGgCVMj/wict/gTViQGC -qSj+IOThsTytAoIBAHu3L3nEU/8EwMJ54q0a/nW+458U3gHqlRyWCZJDhxc9Jwbj -IMoNRFj39Ef3VgAmrMvrh2RFsUTgRG5V1pwhsmNzmzAXstHx2zALaO73BZ7wcfFx -Yy8G9ZpTMsU6upj1lICLX0diTmbo4IzgYIxdiPJUsvOjZqDbOvsZJEIdYSL5u5Cj -0qx7FzdPc2SyGxuvaEnTwuqk6le5/4LIWCnmD+gksDpP0BIHSxmcfsBhRk3rp3mZ -llVxqKdBtM1PrQojCFxR833RZfzOyzCZwaIc+V5SOUw7yYqfXxmMokrpoQy72ueq -Wm1LrgWxBaCqDYSop7cftbkUoPB2o3/3SNtVUesCggEAReqOKy3R/QRf53QaoZiw -9DwsmP0XMndd8J/ONU3d0G9p7SkpCxC05BOJQwH7NEAPqtwoZ3nr8ezDdKVLEGzG -tfp7ur7vRGuWm5nYW6Viqa3Re5x/GxLNiW8pRv8vC5inwidMEamGraE++eQ0XsXz -/rF7f0fAGgYDsWFV7eXe49hWQV7+iru0yxdRhcG9WyxyNGrogC3wGLdwU9LMiwXX -xjbMZzbAR5R1arq3B9u+Dzt57tc+cWTm7qDocT1AZFLeOZSApyBA22foYf6MwdOw -zMC2JOV68MR7V6/3ZDhZZJrnsi2omXvCZlnh/F/TmTYlJr/BV47pxnnOxpkNSmv5 -nQKCAQBRqrsUVO7NOgR1sVX7YDaekQiJKS6Vq/7y2gR4FoLm/MMzNZQgGo9afmKg -F2hSv6tuoqc33Wm0FnoSEMaI8ky0qgA5kwXvhfQ6pDf/2zASFBwjwhTyJziDlhum -iwWe1F7lNaVNpxAXzJBaBTWvHznuM42cGv5bbPBSRuIRniGsyn/zYMrISWgL+h/Q -fsQ2rfPSqollPw+IUPN0mX+1zg6PFxaR4HM9UrRX7cnRKG20GIDPodsUl8IMg+SO -M5YG/UqDD10hfeEutvQIvl0oJraBWT34cqUZLVpUwJzf1be7zl9MzHGcym/ni7lX -dg6m3MAyZ1IXjHlogOdmGvnq07/w ------END PRIVATE KEY----- diff --git a/tests/assets/client-certificates/client/trusted/cert.pem b/tests/assets/client-certificates/client/trusted/cert.pem deleted file mode 100644 index 76d1e1a54..000000000 --- a/tests/assets/client-certificates/client/trusted/cert.pem +++ /dev/null @@ -1,29 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFAzCCAuugAwIBAgIBATANBgkqhkiG9w0BAQsFADA2MRIwEAYDVQQDDAlsb2Nh -bGhvc3QxIDAeBgNVBAoMF0NsaWVudCBDZXJ0aWZpY2F0ZSBEZW1vMB4XDTI0MDcx -OTEyNDczN1oXDTI1MDcxOTEyNDczN1owEDEOMAwGA1UEAwwFQWxpY2UwggIiMA0G -CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCac3+4rNmH4/N1s4HqR2X168tgS/aA -6sHW5at8mWRnq54Nm11RvnK55jHQYVAdBgJy5M07w0wakp8inxzlY95wqxBimYG6 -3Un/1p7mX9FkB4LNISCc6j/s/Ufv85MXPbn0S5rm9UcQO9cINJb1RP1YgDDLN5cx -Mz6X4nyofN8H6Lhvh4JDdBw4DfDEFERkVfF+bkZ7YW4XHEChgzm3RxCF0eeGzIXG -rkkK9AsSdJAhOvTlHPFCQKXTYZhsL5+3Ma4RnWnDWvLTHx6KzoU+twTM2mYhhQuQ -gQpnmDHxGge8kGeHGtfdgAjtVJTE57xF/shP0JU+tuIV8NNhQ/vEmhL0Wa093/Ev -pTVp0EUEuDh9ORRH5K5M4bKJyU4XX5noiht6yOn00uaoJcWduUAWsU+cDSvDTMw8 -1opWWm0QIAV3G2yuRSkumHAKqvQLeyeyiKz+OEhyEiZ7EZNExPD0TSpApSTU6aCT -UAvPYGQ59VjsMHTuJ9r4wKIYaDvfL+t72vg2vTQma5cTOBJfIdxH9blFTjEnToH3 -LX8t0XndQ2RkiRnIze2p2jUShxo/lWCjCw+2Iaw0A0fNUK1BbOrFRPq1u7AnEuMJ -t7HF50MloItM97R9vofDwgDIzlX/PzlVRcn1WCo8Fr/0EXxPPreX0YDIp1ANQ8fS -v7bKb2vQIxWuCQIDAQABo0IwQDAdBgNVHQ4EFgQUVJVRJJ2k/Z4r0M1AXe6agyD4 -uCwwHwYDVR0jBBgwFoAUEHtrxWCk96Ehr60E0HBuwLk2i+IwDQYJKoZIhvcNAQEL -BQADggIBAGEvSkxhxRKmlvKG8wCXop2OaUUAOG16+T96vd+aFYaJNlfGoPvqv4Lw -qaHztVktnRrJ//fpNWOsdxkE1uPU4uyGjl2KbyH81JvkE6A3OX0P4B01n8lcimY2 -j3oje6KjORUouYVsypD1VcwfWJgsE3U2Txv5srD8BoemVWgWbWjfyim4kk8C5zlf -tWEazVAaI4MWecqtU4P5gIEomCI7MG9ebxYp5oQhRxeOndOYdUbSzAkZj50gXFA1 -+TNkvuhTFlJF0F7qIFVJSJTmJ+6E5B4ddbkyUYwbOdO+P8mz5N5mSljE+EiIQTxo -AwbG8cSivMy/jI3h048tCUONAJzcSWCF4k1r9Qr6xbyW2ud2GmKiFCEYJkYTsMWV -fM/RujTHlGvJ2+bQK5HiNyW0tO9znW9kaoxolu1YBvTh2492v3agK7nALyGGgdo1 -/nN/ikgkQiyaCpZwFeooJv1YFU5aDhR9RjIIJ9UbJ8FdAv8Xd00E3viunLTvqqXK -RVMokw+tFQTEzjKofKWYArPDjB9LUbN+vQbumKalis3+NlJ3WolYPrCg55tqt1o3 -zXi+xv7120cJFouilRFwrafNFV6F+pRMkMmiWopMnoVJPVXcoqyJRcsmO62uslhg -BLFgAH4H/14drYrgWIMz0no78RInEz0z507zwLkWk5d9W9pJ/4Rf ------END CERTIFICATE----- diff --git a/tests/assets/client-certificates/client/trusted/csr.pem b/tests/assets/client-certificates/client/trusted/csr.pem deleted file mode 100644 index 8ead6da3d..000000000 --- a/tests/assets/client-certificates/client/trusted/csr.pem +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIIEVTCCAj0CAQAwEDEOMAwGA1UEAwwFQWxpY2UwggIiMA0GCSqGSIb3DQEBAQUA -A4ICDwAwggIKAoICAQCac3+4rNmH4/N1s4HqR2X168tgS/aA6sHW5at8mWRnq54N -m11RvnK55jHQYVAdBgJy5M07w0wakp8inxzlY95wqxBimYG63Un/1p7mX9FkB4LN -ISCc6j/s/Ufv85MXPbn0S5rm9UcQO9cINJb1RP1YgDDLN5cxMz6X4nyofN8H6Lhv -h4JDdBw4DfDEFERkVfF+bkZ7YW4XHEChgzm3RxCF0eeGzIXGrkkK9AsSdJAhOvTl -HPFCQKXTYZhsL5+3Ma4RnWnDWvLTHx6KzoU+twTM2mYhhQuQgQpnmDHxGge8kGeH -GtfdgAjtVJTE57xF/shP0JU+tuIV8NNhQ/vEmhL0Wa093/EvpTVp0EUEuDh9ORRH -5K5M4bKJyU4XX5noiht6yOn00uaoJcWduUAWsU+cDSvDTMw81opWWm0QIAV3G2yu -RSkumHAKqvQLeyeyiKz+OEhyEiZ7EZNExPD0TSpApSTU6aCTUAvPYGQ59VjsMHTu -J9r4wKIYaDvfL+t72vg2vTQma5cTOBJfIdxH9blFTjEnToH3LX8t0XndQ2RkiRnI -ze2p2jUShxo/lWCjCw+2Iaw0A0fNUK1BbOrFRPq1u7AnEuMJt7HF50MloItM97R9 -vofDwgDIzlX/PzlVRcn1WCo8Fr/0EXxPPreX0YDIp1ANQ8fSv7bKb2vQIxWuCQID -AQABoAAwDQYJKoZIhvcNAQELBQADggIBAGgf3EC8WL3RGmuGA+d/4wd1jNfrfU6n -xjnDwdEEX0TQZGGPjh5xvoCK76yZPkO6+z0IYSepEmWBS27HJKl7nuoOvS7MjQyJ -C+3Bdk3ToCeQjmNBlRBKsUw5ftTU902oMl5BptHGj1KGjYBLAkPdXb44wXSVKJ8q -ihFhWlovsva6GDoUorksU3vOwijdlGzTANQHJGFncgrRud9ATavpGS3KVxR73R3A -aBbu3Qw+QIfu8Qx5eBJp8CbMrpAmjfuq17STvqr5bC10Fnn4NegrnHOQG9JcK02+ -5Bn3+9X/n1mue7aohIdErLEiDMSqMOwFfrJeaH6YM1G4QkWyqGugtmHsWOUf0nlU -nkH1krvfw9rb6b+03c4A6GSeHnbX5ufFDSf5gaR6Wy7c0jBnoxVbtBLH2zXlrd0k -iRQG7C6XZzGMS7hb7GL7+bkRy9kWjmDL7z7Fp+EgzKhNmzuWII3E9X9va33HoQ/Q -UdK3JVToxRQg6XRKOxL9+U/+8i6U8lxObLWkWh2cypZqbz5qJxa+2u5JYO/KEoHZ -G963UX7XWezR98vZuTc1XHGZtBDMrjjDd7Kmb4/i/xBPeWwseeGtzFy9z2pnEnkL -uKE4C8wUNpzUUlsn4LneZXObIoErE7FqAAlVFujVe7iaJBmXoUXZR36drbfiaODK -vwAGyrYHaOlR ------END CERTIFICATE REQUEST----- diff --git a/tests/assets/client-certificates/client/trusted/key.pem b/tests/assets/client-certificates/client/trusted/key.pem deleted file mode 100644 index d60201e5a..000000000 --- a/tests/assets/client-certificates/client/trusted/key.pem +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIJRAIBADANBgkqhkiG9w0BAQEFAASCCS4wggkqAgEAAoICAQCac3+4rNmH4/N1 -s4HqR2X168tgS/aA6sHW5at8mWRnq54Nm11RvnK55jHQYVAdBgJy5M07w0wakp8i -nxzlY95wqxBimYG63Un/1p7mX9FkB4LNISCc6j/s/Ufv85MXPbn0S5rm9UcQO9cI -NJb1RP1YgDDLN5cxMz6X4nyofN8H6Lhvh4JDdBw4DfDEFERkVfF+bkZ7YW4XHECh -gzm3RxCF0eeGzIXGrkkK9AsSdJAhOvTlHPFCQKXTYZhsL5+3Ma4RnWnDWvLTHx6K -zoU+twTM2mYhhQuQgQpnmDHxGge8kGeHGtfdgAjtVJTE57xF/shP0JU+tuIV8NNh -Q/vEmhL0Wa093/EvpTVp0EUEuDh9ORRH5K5M4bKJyU4XX5noiht6yOn00uaoJcWd -uUAWsU+cDSvDTMw81opWWm0QIAV3G2yuRSkumHAKqvQLeyeyiKz+OEhyEiZ7EZNE -xPD0TSpApSTU6aCTUAvPYGQ59VjsMHTuJ9r4wKIYaDvfL+t72vg2vTQma5cTOBJf -IdxH9blFTjEnToH3LX8t0XndQ2RkiRnIze2p2jUShxo/lWCjCw+2Iaw0A0fNUK1B -bOrFRPq1u7AnEuMJt7HF50MloItM97R9vofDwgDIzlX/PzlVRcn1WCo8Fr/0EXxP -PreX0YDIp1ANQ8fSv7bKb2vQIxWuCQIDAQABAoICAAyXg/8rYGS6ydt7sgjGn2Jo -QeFs8ADcoscBXHTBELV/AVi8pOQIMdREFyWU+XIUTljNnInVxzuXXo/1BucQuE7Z -M3HGcBQq/GB2P+gqQaj1D83neIAyfNm2YIoIgqJvbtyi2VMhBhUlu8c4emIuqLTx -Zoj61EG3ms/JMD6QR6Keb4LwOkeDjNVpFYr22AiSFSkolmhyrgYGUKKaTzdI/Ojc -DxMnU3S6OsxAzzJG/IUpCFQxgt3S5XIRT9rqGwxVaYqYGcpKfOeHbvcEFUriouqM -l6z96s5yJsYBW3j7lUvjPf1+y8CMMq4eqi5PckMGnZAcQj6lrFL7mlAgucLyiL7w -o30seXvzoEQXlHxi/tnoZMWaBbntA6TV8t0ap7TMADPPSrXhXt+GIQt6tDTdYd8y -9VxGAQA0s6FhdURVp0zYtTGrsFTLyHZjC0TFxsvOdRrQL3XbsQxPUCH86Z3hQt9d -drgxPDJJo/4UUYOX7MAyE3H7zW7qSQ8tNSXPHewff0ItpcrUvBxa8cD95DGB3kws -0Ns1ulGqOLMPZM3/MUYlDk0PEK1ClBqC1B78mkMpJe5qTYBaFg7S540X4E5Nrq5V -5VK4QTsBGm9Xks4///psGwmstCVZAZDCyMbW3NOFtzOxsVqi027xknl7UEtfwNFf -c8tp0CaxZhW8/YTXUtnxAoIBAQDSR/Ux4tfDp84Tyf5N8JaxY1iYA1sor4SQnoSE -r0/J2UXQpZjNpCT/fOjBT19jJCWQUxUf3M6PE0i40VMcJgtQE9alTTz3iCCUokv+ -IcVxrS+7rdvQGPItoIIZDSKGlAJHoIsMnqGAHpks588ptgPC/FEiNX2nae2CrGRS -jVcPOLA+St6qGEwPyaSKXjERwSQ9bHLIuKbMDs2+YpPOSp9iLKaW11UQYxF3Uxti -pVRq5bbqlKFOxxp4PaTZRusWpdWJ1kmpmEpZg6PiUQVeOoOy+hCbLq3KW1aaTc3x -UcYrbA2hW5vP0u4x4QNPayd8MNEsGHBClObOtD64Vz3lsMFdAoIBAQC8CBoP6Tzy -1uGNmAOc9ipQwAcTAzPnOH+ouKBwB/5ji/RPrwGCOqjbapmriKtYxW2JOqbTzbze -+WvGwgfoPo16FZocDMrD90lQdFmfcgnHFZgXZe2k8zr3YTvXdkCCRkthrl9tKN94 -IuNL5K4wMIiPy08B7+dMxnKP4E8C8czzcyrXpdfy/gfu7UQGETYswjmLL1vOr1OE -WaalbJn/5GDzKKLkcx+Xr4zgHzbyCXb/K+LvawGk0MQMTtbRkphNC2yNejNjQd8F -wmccFK4LG9JqdjVhKiDiYIKe5ocWDcZ28sBuKyFxOthOywP6tnALIjQgXamsLIZj -GhCG3g3dAfidAoIBAQDQM7EhgKHztl1DmLczgmgiIORiNsh2gzp1Wo6JNW+Bwp/u -k1e1HLYJRSrL5APlDLAosypyTtUyMnzJiXCJqV2AHvRi3RPlXqIrqHonmFZ/VGOz -ptPCukBnTsohdbDeoQOU2e9zQklTqngtTyP9/5q/38WRYncUYLxqqrf2SL2Pc6iF -NOo8biw5YYSJ//MDykFQk+Ueuj1kQ7AQtlf0ZExlDyKurWwq+nwbsmymAl6QLPws -TZddgaPCs/5Zp28zEGVawZJT2labRMzqUyBGiRdHCXORwukON9uKkki7jCTzb1wb -jLG8VvPC7TCy3LzOqSMiTtwwAHB671o+eRrvJlB9AoIBAQCb2J85Vtj0cZPLFxbP -jtytxytV386yM4rjnfskQAviGErrjKLUfKgeDHHH0eQrFJ/gIOPLI3gK23Iv7/w7 -yzTZ3nO4EgYxfJGghH8P/6YJA2Xm5s2cbRkPluDRiaqYD4lFMhDX2gu2eDwqWCTj -viZCAIHAmkX8xXKIu6LhTubPVUJKMKQXO+P5bWB3IubjHCwzp5IRchHn3aKY87WE -eZa9k43HiX/C6nb6AAU7gQrHHmnehLN9FqeXh/TXCQkAuppDfOiAuUUPcfyiMqW6 -gVnacZV2rkNJPjKlX27RoaNATZ2e8lKqldpZHD11HKcrIzNPLDKIiPLtytmt3vhg -mNSlAoIBAQDMN3FoQfV+Tlky5xt87ImsajdIhf7JI35hq6Zb4+vwR7/vofbzoomS -+fuivH1+1skQIuEn41G4uwZps9NPRm5sWrjOo869DYPn5Nm8qTGqv/GD28OQQClB -3/vcwrn5limm3pbQg+z+67fFmorSyLHcZ+ky60lWeE9uXCsVjt7eH6B+Rhs9Jafg -MbWRZ1C3Gezb1J42XVZ8hczn6r+qmWFTbSY4RzNBqd83motWXIgtybJIV4LB4t06 -JkVNCotSicw0vtZk95AfjQksemAq2fFzJfASxtw8IE/WHW4jtvfZ9PPWDt9U83ll -Y+eu85cike5J4vnz8uG04yt7rXjIrUav ------END PRIVATE KEY----- diff --git a/tests/assets/client-certificates/server/server_cert.pem b/tests/assets/client-certificates/server/server_cert.pem deleted file mode 100644 index 52d8f5314..000000000 --- a/tests/assets/client-certificates/server/server_cert.pem +++ /dev/null @@ -1,32 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFdTCCA12gAwIBAgIUNPWupe2xcu8YYG1ozoqk9viqDJswDQYJKoZIhvcNAQEL -BQAwNjESMBAGA1UEAwwJbG9jYWxob3N0MSAwHgYDVQQKDBdDbGllbnQgQ2VydGlm -aWNhdGUgRGVtbzAeFw0yNDA3MTkxMjQ3MzNaFw0yNTA3MTkxMjQ3MzNaMDYxEjAQ -BgNVBAMMCWxvY2FsaG9zdDEgMB4GA1UECgwXQ2xpZW50IENlcnRpZmljYXRlIERl -bW8wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC+K5JWhlfvI47ZL/Az -L0xnOl+cMelr2BqH+7XS8187SbvluhFfFkq/7V7rwgsHI64sn8pgRCOnqKWV6jtb -651dGzn7Nby6InmyOQzF4VwfSVWQ6BYXgXuryS9Gm0gi8sOL1Ji/jV49n1gzLyIx -LNhd7NG2DCCedTHJnxyz4xq8MWhI/qI85iWJqcHhxkDb8wtH1Vd6nd/ZRVDbjgTv -PH3EDK7JqmnYG9+x4Jz0yEhvV7jL3gNu2mIyttvm7oRna9oHgaKFUJt4BCfPbT5U -3ipvcq29hdD5/5QIDzTWcExTnklolg5xpFext1+3KPSppESxcfBBNoL3h1B8ZcZa -lEMC/IoFUIDJQj5gmSn4okwMWIxgf+AL0609MKEqQ2FavOsvBmhHcQsqLk4MO/v0 -NGFv1/xGe4tUkX4han6ykf1+sqzupJT5qnUONmvghb2SpIt83o4j4KHVzZwk8JK0 -N6hN7JEjXQwSKCh3b0FFg+kPAe12d6BBcsNzEYmt2C1KNPbXMX84zIkgPN01XMg6 -kdCdjP6DH7CK+brW9qQufOqYpd3eNhJyeBm+oP3PhnhEiMTIO8X2GdSN5Rxozgxl -VIj/QWhLV64r5AqPr/Vpd1vcsxrg3aS5CASmoWQmTPuhEZptRtrkPkGw7k9NPZ34 -lnRenvKJ9e3DXhXRMqeYUY6wjwIDAQABo3sweTAdBgNVHQ4EFgQUEHtrxWCk96Eh -r60E0HBuwLk2i+IwHwYDVR0jBBgwFoAUEHtrxWCk96Ehr60E0HBuwLk2i+IwDwYD -VR0TAQH/BAUwAwEB/zAmBgNVHREEHzAdgglsb2NhbGhvc3SCEGxvY2FsLnBsYXl3 -cmlnaHQwDQYJKoZIhvcNAQELBQADggIBALP4kOAP21ZusbEH89VkZT3MkGlZuDQP -LyTYdLzT3EzN//2+lBDmJfpIPLL/K3sNEVSzNppa6tcCXiVNes/xJM7tHRhTOJ31 -HinSsib2r6DZ6SitQJWmD5FoAdkp9qdG8mA/5vOiwiVKKFV2/Z3i+3iUI/ZnEhUq -uUA1I3TI5LAQzgWLwYu1jSEM1EbH6uQiZ8AmXLVO4GQnVQdbyarWHxIy+zsg+MJN -fxIG/phDpkt1mI3SkAdpWRWjCKESQhrIcRUtu5eVk0lho6ttHODXF8bM7iWLoRc7 -rpcllI4HXHoXQqQkZHRa7KwTf0YVwwQbXTecZONWXwE9Ej5R5IcZzja5FWCSstsb -ULNW0JVxGBE7j5aOjxasYAbRexDmlfEdLvnp6bctZuvMvuBxrB+x5HSEZl6bVnbC -nvtoslylQJM1bwlZdCqJm04JXe1787HDBef2gABv27BjvG/zn89L5ipogZCrGpl6 -P9qs0eSERHuSrm3eHUVgXSQ1nbvOpk7RPFbsbp/npc1NbEDBdAMoXhLP9A+ytxLq -TF+w08nfCF6yJJ3jTkvABo10UH6zcPnfH3Ys7JYsHRbcloMfn+mc88KrTaCO+VZx -qjhFcz+zDu/AbtJkDJtxX2X7jNL0pzWS+9H8jFTrd3ta8XrJiSFq2VMxEU6R0IHk -2Ct10prMWB/3 ------END CERTIFICATE----- diff --git a/tests/assets/client-certificates/server/server_key.pem b/tests/assets/client-certificates/server/server_key.pem deleted file mode 100644 index ff6a3fc11..000000000 --- a/tests/assets/client-certificates/server/server_key.pem +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQC+K5JWhlfvI47Z -L/AzL0xnOl+cMelr2BqH+7XS8187SbvluhFfFkq/7V7rwgsHI64sn8pgRCOnqKWV -6jtb651dGzn7Nby6InmyOQzF4VwfSVWQ6BYXgXuryS9Gm0gi8sOL1Ji/jV49n1gz -LyIxLNhd7NG2DCCedTHJnxyz4xq8MWhI/qI85iWJqcHhxkDb8wtH1Vd6nd/ZRVDb -jgTvPH3EDK7JqmnYG9+x4Jz0yEhvV7jL3gNu2mIyttvm7oRna9oHgaKFUJt4BCfP -bT5U3ipvcq29hdD5/5QIDzTWcExTnklolg5xpFext1+3KPSppESxcfBBNoL3h1B8 -ZcZalEMC/IoFUIDJQj5gmSn4okwMWIxgf+AL0609MKEqQ2FavOsvBmhHcQsqLk4M -O/v0NGFv1/xGe4tUkX4han6ykf1+sqzupJT5qnUONmvghb2SpIt83o4j4KHVzZwk -8JK0N6hN7JEjXQwSKCh3b0FFg+kPAe12d6BBcsNzEYmt2C1KNPbXMX84zIkgPN01 -XMg6kdCdjP6DH7CK+brW9qQufOqYpd3eNhJyeBm+oP3PhnhEiMTIO8X2GdSN5Rxo -zgxlVIj/QWhLV64r5AqPr/Vpd1vcsxrg3aS5CASmoWQmTPuhEZptRtrkPkGw7k9N -PZ34lnRenvKJ9e3DXhXRMqeYUY6wjwIDAQABAoICABfDfxpj2EowUdHvDR+AShZe -M4Njs00AKLSUbjCpq91PRfUbjr8onHemVGW2jkU6nrHB1/q2mRQC3YpBxmAirbvs -Qo8TNH24ACgWu/NgSXA5bEFa1yPh0M/zKH60uctwNaJcEyhgpIWjy1Q+EBJADduS -09PhaRQUBgAxa1dJSlZ5ABSbCS/9/HPa7Djn2sQBd4fm73MJlmbipAuDkDdLAlZE -1XSq4GYaeZYTQNnPy0lql1OWbyxjisDWm90cMhxwXELy3pm1LHBPaKAhgRf+2SOr -G23i8m3DE778E3i2eLs8POUeVzi5NiIljYboTcaDGfhoigLEKpJ+7L5Ww3YfL85Q -xk00Y0b+cYNrlJ3vCpflDXJunZ1gJHLDTixJeVMpXnMSi01+bSb8D/PTcbG3fZ0U -y4f2G0M+gf+m3EMMD96yerPf6jhGlTqY+eMyNVwNVk4BIG+D/8nf13keAF4kVbPJ -QMidnCNbu8ZiC12HqLyv3YZlseXPIkhpbYEhsj58sbG4Tms+mG/zPlTZjroIEdAX -nwI1aoG+NAbe+WSH/P4SvIMi1o/fWoXBtb+t7uy1AG/Xbu414WED7iwvxtqJRQj5 -rhrqryWTGQKY1zVJIOxwZP0f5gSIkEITyE+rO6o6pbAZFX7N0aMIvksBkEN5mdoV -RWzxfSVNGMWooRD5d3TZAoIBAQD1dvgOsLYP8lUfkKglLTqHQe3x75BVDR9zdTIt -tQh9UIbyovPFdLcXrHHJMBVMPTRGeRNpjCT5BNSNbidrmAxYN7YXuSA4uy3bubNU -76km5kmL2Ji+5u+qMm9Xycyqn30rLH9hT+9c/MVuPW6CNmETKX9+v9zb1v//RrBS -2ZNAWjJcBYv/rS/vKsW9yH/DbM21eSeokUqpkejOk1UxVZEcb9vt8VF8p+jO1wv3 -+UgI4Gfkf3sjEL1m/hBvH5Z49RHTFj4npeK6Lko4NLLazU2904jbHxppH51UNH1j -xp8Is+iNwW2qCOve8kSUUUjxLn4n45D2d+5qOqQTtsMWXHanAoIBAQDGVQ6UZqvo -djfcULq0Jub1xpBfxIAg7jSY7aZ6H0YlG7KgpVTd2TUEEKgErxtfYufjtLjjWb/d -lMG7UpkM5B4tFnpRDmvevltCqGsM3qi3AtPnzavgz2TAQy7qd2gJc8glE965LOfb -l+mGzE4SzeFJ9WS7sUDf4WnX2xjt3OA0VCvcBRNIwCnEvXu81XLKZL6etBx6zdCt -whWHIiqa4wkjuWEwvbeH4aWsh8gFY3E5mbvDdMFtyGWvTK8OGivl3CkdQxM+MOJD -3aAEBTr0M7tSMy5IKewASlAWZEVpFFPIyiyMCTI0XcEgA7ewHw/F3c7cstgVktjm -OYZytZPF0ZvZAoIBAB5+z0aT8ap9gtHPGPS1b8YKDNO33YiTfsrLTpabHRjkfj96 -uypW28BXLjO+g4bbO7ldpWnBfX5qeTWw77jQRQhYs4iy+SvTJVlc8siklbE9fvme -ySs+aZwNdAPGEGVKNzS77H9cfPJifOy7ORV4SAsnZq2KjJfLWDaQw6snWMHv8r23 -+rKjA4eFGtf/JtBSniPjj2fD1TDH7dJsP3NHnCWaSAqBpowEGEpKMTR3hdmEd6PN -qrCqjb1T5xrHI9yXJcXBx6sJUueqhJIDCg1g4D2rIB+I97EDunoRo1pX/L4KC+RA -ma08OoGSO67pglRkYEv4W7QjJj2QV34TgJ0wk5UCggEALINom0wT5z+pN+xyiv50 -NdNUEfpzW3C7I1urUpt0Td3SkJWq34Phj0EBxNNcTGNRclzcZkJ9eojpllZqfWcx -kqMJ3ulisoJ8zxAnvqK2sSSUVOFnYzSJA1HQ1NTp570xvYihI2R9wV5uDlAKcdP9 -bXEDI9Ebo2PfMpA9Hx3EwFnn4iDNfDWM6lgwzmgFtIE5+zqnbbSF0onN9R9o+oxc -P8Val+rspzWwznFHJlZ0Uh478xlgVHh2wgpu+7ZKBfQM0kF8ryefkOXMBTr7SVXX -BBLyn0Wxbzs+kFf+8B+c0mL17pQdzX0BXGMZNhEypBEtXYFSWD02Ky3cDCDOwsZR -uQKCAQAKQtsUSO80N/kzsWuSxHhuLMTvNZfiE/qK1Mz5Rw1qXxMXfYNFZbU/MqW7 -5DLd4Kn7s3v1UlBn2tbLGLzghnHYRxT9kxF7ZnY6HZv2IrEUjE2I2YTTCQr/Q7Z5 -gRBQb5z+vJbKOYnlSHurTexKmuTjgJ/y/jRQiQABccVj1w5lIm1SPoxpdKzSFyWt -0NVmff9VetoiWKJYldPBTOmqPUytuBZyX5fJ4pPixwgAns6ZaqJtVNyMZkZ/GoDk -XP2CvB/HyMiS7vXK5QJYYumk7oyC15H6eDChITNPV3VGH2QqcdEvDLT81W+JZ2mX -8ynLaTs3oV3BjQya9pAUyzIX5L67 ------END PRIVATE KEY----- diff --git a/tests/assets/client.py b/tests/assets/client.py deleted file mode 100644 index 670242f5d..000000000 --- a/tests/assets/client.py +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import sys -from pathlib import Path - -from playwright.sync_api import Playwright, sync_playwright - - -def main(playwright: Playwright, browser_name: str) -> None: - browser = playwright[browser_name].launch() - page = browser.new_page() - page.goto("data:text/html,Foobar") - here = Path(__file__).parent.resolve() - page.screenshot(path=here / f"{browser_name}.png") - page.close() - browser.close() - - -if __name__ == "__main__": - browser_name = sys.argv[1] - with sync_playwright() as p: - main(p, browser_name) diff --git a/tests/assets/consolelog.html b/tests/assets/consolelog.html deleted file mode 100644 index 7fa1b211a..000000000 --- a/tests/assets/consolelog.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - console.log test - - - - - diff --git a/tests/assets/csp.html b/tests/assets/csp.html deleted file mode 100644 index 34fc1fc1a..000000000 --- a/tests/assets/csp.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/assets/digits/0.png b/tests/assets/digits/0.png deleted file mode 100644 index ac3c4768edfbe7bd47c436b1451938fa83483a0c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 434 zcmeAS@N?(olHy`uVBq!ia0vp^Ahr+(3y?gqul)d!Vo7)Ob!1@J*w6hZkrl{SNcITw zWnidMV_;}#VPN<`%l_%+p4TGxUM+vVxv7$R|b>IB0qra%Ul`~MX*b+&Ovv2B|q(!_F5JEB7P zed0v5sZ%E?Yh1hbjAP{`Rxy#BDWWlty*FwfV(a#?G%)Vx{1iU1`q`mv2gSp$&p2~* z-}_~cq6GO$WM?Q#u2eqHG5>*&;LICE?MK)yaHod0^GXWrZw)prU@KA-JFv3vi--cJ zxiY6feZzXKeM^rjA81T>7dtU=-Ac=*J)Oz+GF_*I;`H`Ro8h-tLFm*jTUKAWRU1F- zfBotg>*8DSc8V=VKyNdsmbgZgq$HN4S|t~y0x1R~14DCN12bJivk*g5D-%O2V@q8F mb1MUbn=hryP&DM`r(~v8;?}TY%i>c&4Gf;HelF{r5}E+JGmpjq diff --git a/tests/assets/digits/1.png b/tests/assets/digits/1.png deleted file mode 100644 index 6768222729b7a487d338fdd7691e44ca4604b216..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 346 zcmeAS@N?(olHy`uVBq!ia0vp^Ahr+(3y?gqul)d!Vo7)Ob!1@J*w6hZkrl{SNcITw zWnidMV_;}#VPN<`%l_%+p4TGxUMOY(Ga43W5;{O5cF=M0GtoN~YQ|NXb^`8@xj{o(qyZXTZh zNB`|_{xm~E;-A5pf9w0RzSOs614Wzvrz@>Y`EmY3PvY_a|9PY*vP4Qqs7R_vN=noT zzEz3h*rdQ|G)c~jg_Tj2N2{sHz90fFVdQ&MBb@0M_4YL;wH) diff --git a/tests/assets/digits/2.png b/tests/assets/digits/2.png deleted file mode 100644 index b1daa4735d8a8c8dcce6be0fc2db02beb265860b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 413 zcmeAS@N?(olHy`uVBq!ia0vp^Ahr+(3y?gqul)d!Vo7)Ob!1@J*w6hZkrl{SNcITw zWnidMV_;}#VPN<`%l_%$`QSZ$aB6k6)(;us=vdFljxE~Y{OxBK}UC0Lr@F!n?rT`(_E{}JO6 z<{7z73T*rf7DVR9aWl3E8iRZQbg$2f$a-VDO8#vl&KeD#DWG<1jqwYkJ(8lLS zB4RWT1dM<5ebWu_3N}u7Cea|(Ea3RP_Gw+vkLTX@hooODEC@-v@oNsy!Kx*$5hW>! zC8<`)MX5lF!N|bST-U%%*U&7)(A3Jr(8|O>*TCG$z`$)w_#PAux%nxXX_dG&n7@1v QRL{WR>FVdQ&MBb@0Eyv<`%l_%$`QSZ$aB6x!wK;us=vdFq6XS%(-TTK3zgoV~U1u8i>$)?1-gCGWI< z@aS2U3)ZwcEm*ubc;$ti-mV3rF6`=#s{>A4SDeUI_Oe&LoV{Fey4Mfsycbe!vf}Dh z3k0h9j%Z)+Fc6$CIUO_QmvAUQh^kMk%6JPu7R1Zp;?Hbsg;SLm5G6_ nfw`4|f!mhwJt!J-^HVa@DsgKtfB7D$o`J#B)z4*}Q$iB}nZcB~ diff --git a/tests/assets/digits/4.png b/tests/assets/digits/4.png deleted file mode 100644 index a721071e2cc4f4d3aeb9a939fb353bee19a655b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 403 zcmeAS@N?(olHy`uVBq!ia0vp^Ahr+(3y?gqul)d!Vo7)Ob!1@J*w6hZkrl{SNcITw zWnidMV_;}#VPN<`%l_%$^T+cq!(g=TxYIEF}Eo;tyhk1Daqt63f# z{2;4qbecI;$g&|gJoK~I{dMMIqQ-q^Z?t=`WG!sjc4tSKLf;*&Y1X25Kle?TG^sF6 z>D=BYXVM%qnde@pu;0vgB#G~T{mbP)S=PRcF1)|-gago-swJ)wB`Jv|saDBFsX&Us z$iUEC*T78I&@9Bz)XKyVh;$9itqcqv9FE~Y(U6;;l9^VCTf@Y8PqqLxFnGH9xvX<`%l_%$^T+cq!(h1PkxIEF}E?wz22-my@kZT1K|fI9blJ2l{+r3^|)} z*8RfYYV*z8_c%_x;UgqEd!^9Poh`*TW`=*TdCT>^*4BT*|H;1(oM7&m8^G|u$3Da% zDeBX$-lmw9w+s(E_C1K%nffs5$fStkT(0({CkN(r>}Jt>Q{|STYPMH+)2ATwXX;N9 zUY^;$J4woIp1{qfw?{sQ>->K1(PN|HYUInB{Z4ScYq_3-VJ}bo{&&hX4DrV0Symw> zhk!0uEpd$~Nl7e8wMs5Z1yT$~28QOk24=d3W+8^ARwjl(q-$VqWnl2&a0~~EhTQy= Z%(P0}8Ya$rvIVGt!PC{xWt~$(69C)6lY0OF diff --git a/tests/assets/digits/6.png b/tests/assets/digits/6.png deleted file mode 100644 index 639f38439d94e856e41a6750952b6e06c418cf3f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 445 zcmeAS@N?(olHy`uVBq!ia0vp^Ahr+(3y?gqul)d!Vo7)Ob!1@J*w6hZkrl{SNcITw zWnidMV_;}#VPN<`%l_%*enOfK#K3LW)yaSV~TJavMh*Wm;PxBK(YEu72bwVdZ}kbt`XgDKWG zHhQxkJbRS+g$`@}15O#2T~i(@MY241a<;z7)g=_)Zn4zjOK<*{ z^|H0Og+AWm-uT?SDW)^nHSdUci~p}UEv0yy-ENEcy{zt3Kiarmu2tfJsEduUXmP#3 zy#q&>em;$vqY=pZB*h^?VgbV<=ODgW9}Ojw!x+xA)XDx=DXab9=X3YU$4?nbk5(4D zIQzm0Xkxq!^4049#^7%ybRS xLJUo5~f0{rI44$rjF6*2UngAmjmB9c2 diff --git a/tests/assets/digits/7.png b/tests/assets/digits/7.png deleted file mode 100644 index 5c1150b005a9fe3c2b970617dcb8801d98408fe7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 387 zcmeAS@N?(olHy`uVBq!ia0vp^Ahr+(3y?gqul)d!Vo7)Ob!1@J*w6hZkrl{SNcITw zWnidMV_;}#VPN<`%l_%*enOfK#K3UzzBIEF}E?!B;(m&sAU?cw&>o3As9E-=s#a$@F?F}=VL zyP)8Tqo9h03QMC?e9=i(rpg_=V;%1F{Qf6sEc|5qBl*P@8;rI@9-ZW>@#KW&%vnq~ zzOl}^kHZ9KZ=-wR{MdtU&MofI@cqgkb+dDrc!nov}_HpAKo1^uZt{iyr``c~- z=k|r)r~i7)zL771W73?7Q-RJ>Epd$~Nl7e8wMs5Z1yT$~28QOk24=d3W+8^ARwjm4 pCPumj=2iv<7q#6`HRR@}WTsW(*07-LpC-@-22WQ%mvv4FO#tK!e`o*z diff --git a/tests/assets/digits/8.png b/tests/assets/digits/8.png deleted file mode 100644 index abb8b48b0b1e5ac8aabb667265d9469d48b7fa24..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 447 zcmeAS@N?(olHy`uVBq!ia0vp^Ahr+(3y?gqul)d!Vo7)Ob!1@J*w6hZkrl{SNcITw zWnidMV_;}#VPN<`%l_%*e4jUVa(g^qi=IEF}Eo;pF-zbR3qZT<755iFO44ID4;ZXsr|G6Z-3AQ~YMF#9yB;s?AMz zKS@rU5cTWfbfKwA)i>7f%FSiSf0jRY%Pf;;KrgA5xJHzuB$lLFB^RXvDF!10LvvjN zGhIWo5JOWd6GJN#V_gGtD+7b_7+*yc4Y~O#nQ4`{HLx*$oeR{!;OXk;vd$@?2>`+@ BpQr!; diff --git a/tests/assets/digits/9.png b/tests/assets/digits/9.png deleted file mode 100644 index 6a40a21c6f58545cab61ea346f1b6bb8b9300c6d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 437 zcmeAS@N?(olHy`uVBq!ia0vp^Ahr+(3y?gqul)d!Vo7)Ob!1@J*w6hZkrl{SNcITw zWnidMV_;}#VPN<`%l_%*e4jUVa(h4y;7IEF}Eo;pD>k10`t_4)k-!5MN3m>MJ651DFC-ODU@ zpZg1!gh!athOH(nj7nKY-017|7X(lneSP5`X2ea_zN%F?Q`q4 z_N=>MvQe>5^dWanr4!!~yIt-xI*MIYJ|5&1TI#UWB`1>;t5@F(qUrTrX|{*Mm$Cl}5-eYz`MaCM!?KE?X)(klhI z7Jr!kGOw6X)u&2!`$KOxphr|oTq8
Text, -more text
- - diff --git a/tests/assets/download-blob.html b/tests/assets/download-blob.html deleted file mode 100644 index 3c4a3d813..000000000 --- a/tests/assets/download-blob.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - Blob Download Example - - - -
Download - - diff --git a/tests/assets/drag-n-drop.html b/tests/assets/drag-n-drop.html deleted file mode 100644 index f43583e7c..000000000 --- a/tests/assets/drag-n-drop.html +++ /dev/null @@ -1,40 +0,0 @@ - - - - - -
-

- Select this element, drag it to the Drop Zone and then release the selection to move the element.

-
-
Drop Zone
- diff --git a/tests/assets/dummy_bad_browser_executable.js b/tests/assets/dummy_bad_browser_executable.js deleted file mode 100755 index 92f1453c5..000000000 --- a/tests/assets/dummy_bad_browser_executable.js +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node - -process.exit(1); diff --git a/tests/assets/empty.html b/tests/assets/empty.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/assets/error.html b/tests/assets/error.html deleted file mode 100644 index 0851c4df8..000000000 --- a/tests/assets/error.html +++ /dev/null @@ -1,17 +0,0 @@ - diff --git a/tests/assets/es6/.eslintrc b/tests/assets/es6/.eslintrc deleted file mode 100644 index 1fbe59209..000000000 --- a/tests/assets/es6/.eslintrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "parserOptions": { - "sourceType": "module" - } -} diff --git a/tests/assets/es6/es6import.js b/tests/assets/es6/es6import.js deleted file mode 100644 index 9aac2d4d6..000000000 --- a/tests/assets/es6/es6import.js +++ /dev/null @@ -1,2 +0,0 @@ -import num from './es6module.js'; -window.__es6injected = num; diff --git a/tests/assets/es6/es6module.js b/tests/assets/es6/es6module.js deleted file mode 100644 index 7a4e8a723..000000000 --- a/tests/assets/es6/es6module.js +++ /dev/null @@ -1 +0,0 @@ -export default 42; diff --git a/tests/assets/es6/es6pathimport.js b/tests/assets/es6/es6pathimport.js deleted file mode 100644 index eb17a9a3d..000000000 --- a/tests/assets/es6/es6pathimport.js +++ /dev/null @@ -1,2 +0,0 @@ -import num from './es6/es6module.js'; -window.__es6injected = num; diff --git a/tests/assets/file-to-upload-2.txt b/tests/assets/file-to-upload-2.txt deleted file mode 100644 index 2e2da2175..000000000 --- a/tests/assets/file-to-upload-2.txt +++ /dev/null @@ -1 +0,0 @@ -contents of the file diff --git a/tests/assets/file-to-upload.txt b/tests/assets/file-to-upload.txt deleted file mode 100644 index 2e2da2175..000000000 --- a/tests/assets/file-to-upload.txt +++ /dev/null @@ -1 +0,0 @@ -contents of the file diff --git a/tests/assets/frames/child-redirect.html b/tests/assets/frames/child-redirect.html deleted file mode 100644 index d4806e7f6..000000000 --- a/tests/assets/frames/child-redirect.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/assets/frames/frame.html b/tests/assets/frames/frame.html deleted file mode 100644 index 4fa926868..000000000 --- a/tests/assets/frames/frame.html +++ /dev/null @@ -1,15 +0,0 @@ - - - -
Hi, I'm frame
diff --git a/tests/assets/frames/frameset.html b/tests/assets/frames/frameset.html deleted file mode 100644 index 4d56f8883..000000000 --- a/tests/assets/frames/frameset.html +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/tests/assets/frames/nested-frames.html b/tests/assets/frames/nested-frames.html deleted file mode 100644 index d858d3285..000000000 --- a/tests/assets/frames/nested-frames.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - diff --git a/tests/assets/frames/one-frame.html b/tests/assets/frames/one-frame.html deleted file mode 100644 index e941d795a..000000000 --- a/tests/assets/frames/one-frame.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/assets/frames/redirect-my-parent.html b/tests/assets/frames/redirect-my-parent.html deleted file mode 100644 index 3beb0aac1..000000000 --- a/tests/assets/frames/redirect-my-parent.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/tests/assets/frames/script.js b/tests/assets/frames/script.js deleted file mode 100644 index be22256d1..000000000 --- a/tests/assets/frames/script.js +++ /dev/null @@ -1 +0,0 @@ -console.log('Cheers!'); diff --git a/tests/assets/frames/style.css b/tests/assets/frames/style.css deleted file mode 100644 index 5b5436e87..000000000 --- a/tests/assets/frames/style.css +++ /dev/null @@ -1,3 +0,0 @@ -div { - color: blue; -} diff --git a/tests/assets/frames/two-frames.html b/tests/assets/frames/two-frames.html deleted file mode 100644 index 00324f084..000000000 --- a/tests/assets/frames/two-frames.html +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/tests/assets/geolocation.html b/tests/assets/geolocation.html deleted file mode 100644 index 3d44c3f7f..000000000 --- a/tests/assets/geolocation.html +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/tests/assets/global-var.html b/tests/assets/global-var.html deleted file mode 100644 index 52eb94e55..000000000 --- a/tests/assets/global-var.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/tests/assets/grid.html b/tests/assets/grid.html deleted file mode 100644 index 0bdbb1220..000000000 --- a/tests/assets/grid.html +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/tests/assets/har-fulfill.har b/tests/assets/har-fulfill.har deleted file mode 100644 index dc6b7c679..000000000 --- a/tests/assets/har-fulfill.har +++ /dev/null @@ -1,366 +0,0 @@ -{ - "log": { - "version": "1.2", - "creator": { - "name": "Playwright", - "version": "1.23.0-next" - }, - "browser": { - "name": "chromium", - "version": "103.0.5060.33" - }, - "pages": [ - { - "startedDateTime": "2022-06-10T04:27:32.125Z", - "id": "page@b17b177f1c2e66459db3dcbe44636ffd", - "title": "Hey", - "pageTimings": { - "onContentLoad": 70, - "onLoad": 70 - } - } - ], - "entries": [ - { - "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", - "_monotonicTime": 270572145.898, - "startedDateTime": "2022-06-10T04:27:32.146Z", - "time": 8.286, - "request": { - "method": "GET", - "url": "http://no.playwright/", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "Accept", - "value": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" - }, - { - "name": "Upgrade-Insecure-Requests", - "value": "1" - }, - { - "name": "User-Agent", - "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.33 Safari/537.36" - } - ], - "queryString": [], - "headersSize": 326, - "bodySize": 0 - }, - "response": { - "status": 200, - "statusText": "OK", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "content-length", - "value": "111" - }, - { - "name": "content-type", - "value": "text/html" - } - ], - "content": { - "size": 111, - "mimeType": "text/html", - "compression": 0, - "text": "Hey
hello
" - }, - "headersSize": 65, - "bodySize": 170, - "redirectURL": "", - "_transferSize": 170 - }, - "cache": { - "beforeRequest": null, - "afterRequest": null - }, - "timings": { - "dns": -1, - "connect": -1, - "ssl": -1, - "send": 0, - "wait": 8.286, - "receive": -1 - }, - "pageref": "page@b17b177f1c2e66459db3dcbe44636ffd", - "_securityDetails": {} - }, - { - "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", - "_monotonicTime": 270572174.683, - "startedDateTime": "2022-06-10T04:27:32.172Z", - "time": 7.132, - "request": { - "method": "POST", - "url": "http://no.playwright/style.css", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "Accept", - "value": "text/css,*/*;q=0.1" - }, - { - "name": "Referer", - "value": "http://no.playwright/" - }, - { - "name": "User-Agent", - "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.33 Safari/537.36" - } - ], - "queryString": [], - "headersSize": 220, - "bodySize": 0 - }, - "response": { - "status": 200, - "statusText": "OK", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "content-length", - "value": "24" - }, - { - "name": "content-type", - "value": "text/css" - } - ], - "content": { - "size": 24, - "mimeType": "text/css", - "compression": 0, - "text": "body { background:cyan }" - }, - "headersSize": 63, - "bodySize": 81, - "redirectURL": "", - "_transferSize": 81 - }, - "cache": { - "beforeRequest": null, - "afterRequest": null - }, - "timings": { - "dns": -1, - "connect": -1, - "ssl": -1, - "send": 0, - "wait": 8.132, - "receive": -1 - }, - "pageref": "page@b17b177f1c2e66459db3dcbe44636ffd", - "_securityDetails": {} - }, - { - "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", - "_monotonicTime": 270572174.683, - "startedDateTime": "2022-06-10T04:27:32.174Z", - "time": 8.132, - "request": { - "method": "GET", - "url": "http://no.playwright/style.css", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "Accept", - "value": "text/css,*/*;q=0.1" - }, - { - "name": "Referer", - "value": "http://no.playwright/" - }, - { - "name": "User-Agent", - "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.33 Safari/537.36" - } - ], - "queryString": [], - "headersSize": 220, - "bodySize": 0 - }, - "response": { - "status": 200, - "statusText": "OK", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "content-length", - "value": "24" - }, - { - "name": "content-type", - "value": "text/css" - } - ], - "content": { - "size": 24, - "mimeType": "text/css", - "compression": 0, - "text": "body { background: red }" - }, - "headersSize": 63, - "bodySize": 81, - "redirectURL": "", - "_transferSize": 81 - }, - "cache": { - "beforeRequest": null, - "afterRequest": null - }, - "timings": { - "dns": -1, - "connect": -1, - "ssl": -1, - "send": 0, - "wait": 8.132, - "receive": -1 - }, - "pageref": "page@b17b177f1c2e66459db3dcbe44636ffd", - "_securityDetails": {} - }, - { - "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", - "_monotonicTime": 270572175.042, - "startedDateTime": "2022-06-10T04:27:32.175Z", - "time": 15.997, - "request": { - "method": "GET", - "url": "http://no.playwright/script.js", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "Accept", - "value": "*/*" - }, - { - "name": "Referer", - "value": "http://no.playwright/" - }, - { - "name": "User-Agent", - "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.33 Safari/537.36" - } - ], - "queryString": [], - "headersSize": 205, - "bodySize": 0 - }, - "response": { - "status": 301, - "statusText": "Moved Permanently", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "location", - "value": "http://no.playwright/script2.js" - } - ], - "content": { - "size": -1, - "mimeType": "x-unknown", - "compression": 0 - }, - "headersSize": 77, - "bodySize": 0, - "redirectURL": "http://no.playwright/script2.js", - "_transferSize": 77 - }, - "cache": { - "beforeRequest": null, - "afterRequest": null - }, - "timings": { - "dns": -1, - "connect": -1, - "ssl": -1, - "send": 0, - "wait": 7.673, - "receive": 8.324 - }, - "pageref": "page@b17b177f1c2e66459db3dcbe44636ffd", - "_securityDetails": {} - }, - { - "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", - "_monotonicTime": 270572181.822, - "startedDateTime": "2022-06-10T04:27:32.182Z", - "time": 6.735, - "request": { - "method": "GET", - "url": "http://no.playwright/script2.js", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "Accept", - "value": "*/*" - }, - { - "name": "Referer", - "value": "http://no.playwright/" - }, - { - "name": "User-Agent", - "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.33 Safari/537.36" - } - ], - "queryString": [], - "headersSize": 206, - "bodySize": 0 - }, - "response": { - "status": 200, - "statusText": "OK", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "content-length", - "value": "18" - }, - { - "name": "content-type", - "value": "text/javascript" - } - ], - "content": { - "size": 18, - "mimeType": "text/javascript", - "compression": 0, - "text": "window.value='foo'" - }, - "headersSize": 70, - "bodySize": 82, - "redirectURL": "", - "_transferSize": 82 - }, - "cache": { - "beforeRequest": null, - "afterRequest": null - }, - "timings": { - "dns": -1, - "connect": -1, - "ssl": -1, - "send": 0, - "wait": 6.735, - "receive": -1 - }, - "pageref": "page@b17b177f1c2e66459db3dcbe44636ffd", - "_securityDetails": {} - } - ] - } -} diff --git a/tests/assets/har-redirect.har b/tests/assets/har-redirect.har deleted file mode 100644 index b2e573310..000000000 --- a/tests/assets/har-redirect.har +++ /dev/null @@ -1,620 +0,0 @@ -{ - "log": { - "version": "1.2", - "creator": { - "name": "Playwright", - "version": "1.23.0-next" - }, - "browser": { - "name": "chromium", - "version": "103.0.5060.42" - }, - "pages": [ - { - "startedDateTime": "2022-06-16T21:41:23.901Z", - "id": "page@8f314969edc000996eb5c2ab22f0e6b3", - "title": "Microsoft", - "pageTimings": { - "onContentLoad": 8363, - "onLoad": 8896 - } - } - ], - "entries": [ - { - "_frameref": "frame@3767e074ecde4cb8372abba2f6f9bb4f", - "_monotonicTime": 110928357.437, - "startedDateTime": "2022-06-16T21:41:23.951Z", - "time": 93.99, - "request": { - "method": "GET", - "url": "https://theverge.com/", - "httpVersion": "HTTP/2.0", - "cookies": [], - "headers": [ - { - "name": ":authority", - "value": "theverge.com" - }, - { - "name": ":method", - "value": "GET" - }, - { - "name": ":path", - "value": "/" - }, - { - "name": ":scheme", - "value": "https" - }, - { - "name": "accept", - "value": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" - }, - { - "name": "accept-encoding", - "value": "gzip, deflate, br" - }, - { - "name": "accept-language", - "value": "en-US,en;q=0.9" - }, - { - "name": "sec-ch-ua", - "value": "\"Chromium\";v=\"103\", \".Not/A)Brand\";v=\"99\"" - }, - { - "name": "sec-ch-ua-mobile", - "value": "?0" - }, - { - "name": "sec-ch-ua-platform", - "value": "\"Linux\"" - }, - { - "name": "sec-fetch-dest", - "value": "document" - }, - { - "name": "sec-fetch-mode", - "value": "navigate" - }, - { - "name": "sec-fetch-site", - "value": "none" - }, - { - "name": "sec-fetch-user", - "value": "?1" - }, - { - "name": "upgrade-insecure-requests", - "value": "1" - }, - { - "name": "user-agent", - "value": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.42 Safari/537.36" - } - ], - "queryString": [], - "headersSize": 644, - "bodySize": 0 - }, - "response": { - "status": 301, - "statusText": "", - "httpVersion": "HTTP/2.0", - "cookies": [ - { - "name": "vmidv1", - "value": "9faf31ab-1415-4b90-b367-24b670205f41", - "expires": "2027-06-15T21:41:24.000Z", - "domain": "theverge.com", - "path": "/", - "sameSite": "Lax", - "secure": true - } - ], - "headers": [ - { - "name": "accept-ranges", - "value": "bytes" - }, - { - "name": "content-length", - "value": "0" - }, - { - "name": "date", - "value": "Thu, 16 Jun 2022 21:41:24 GMT" - }, - { - "name": "location", - "value": "http://www.theverge.com/" - }, - { - "name": "retry-after", - "value": "0" - }, - { - "name": "server", - "value": "Varnish" - }, - { - "name": "set-cookie", - "value": "vmidv1=9faf31ab-1415-4b90-b367-24b670205f41;Expires=Tue, 15 Jun 2027 21:41:24 GMT;Domain=theverge.com;Path=/;SameSite=Lax;Secure" - }, - { - "name": "via", - "value": "1.1 varnish" - }, - { - "name": "x-cache", - "value": "HIT" - }, - { - "name": "x-cache-hits", - "value": "0" - }, - { - "name": "x-served-by", - "value": "cache-pao17442-PAO" - }, - { - "name": "x-timer", - "value": "S1655415684.005867,VS0,VE0" - } - ], - "content": { - "size": -1, - "mimeType": "x-unknown", - "compression": 0 - }, - "headersSize": 425, - "bodySize": 0, - "redirectURL": "http://www.theverge.com/", - "_transferSize": 425 - }, - "cache": { - "beforeRequest": null, - "afterRequest": null - }, - "timings": { - "dns": 0, - "connect": 34.151, - "ssl": 28.074, - "send": 0, - "wait": 27.549, - "receive": 4.216 - }, - "pageref": "page@8f314969edc000996eb5c2ab22f0e6b3", - "serverIPAddress": "151.101.65.52", - "_serverPort": 443, - "_securityDetails": { - "protocol": "TLS 1.2", - "subjectName": "*.americanninjawarriornation.com", - "issuer": "GlobalSign Atlas R3 DV TLS CA 2022 Q1", - "validFrom": 1644853133, - "validTo": 1679153932 - } - }, - { - "_frameref": "frame@3767e074ecde4cb8372abba2f6f9bb4f", - "_monotonicTime": 110928427.603, - "startedDateTime": "2022-06-16T21:41:24.022Z", - "time": 44.39499999999999, - "request": { - "method": "GET", - "url": "http://www.theverge.com/", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "Accept", - "value": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" - }, - { - "name": "Accept-Encoding", - "value": "gzip, deflate" - }, - { - "name": "Accept-Language", - "value": "en-US,en;q=0.9" - }, - { - "name": "Connection", - "value": "keep-alive" - }, - { - "name": "Host", - "value": "www.theverge.com" - }, - { - "name": "Upgrade-Insecure-Requests", - "value": "1" - }, - { - "name": "User-Agent", - "value": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.42 Safari/537.36" - } - ], - "queryString": [], - "headersSize": 423, - "bodySize": 0 - }, - "response": { - "status": 301, - "statusText": "Moved Permanently", - "httpVersion": "HTTP/1.1", - "cookies": [ - { - "name": "_chorus_geoip_continent", - "value": "NA" - }, - { - "name": "vmidv1", - "value": "4e0c1265-10f8-4cb1-a5de-1c3cf70b531c", - "expires": "2027-06-15T21:41:24.000Z", - "domain": "www.theverge.com", - "path": "/", - "sameSite": "Lax", - "secure": true - } - ], - "headers": [ - { - "name": "Accept-Ranges", - "value": "bytes" - }, - { - "name": "Age", - "value": "2615" - }, - { - "name": "Connection", - "value": "keep-alive" - }, - { - "name": "Content-Length", - "value": "0" - }, - { - "name": "Content-Type", - "value": "text/html" - }, - { - "name": "Date", - "value": "Thu, 16 Jun 2022 21:41:24 GMT" - }, - { - "name": "Location", - "value": "https://www.theverge.com/" - }, - { - "name": "Server", - "value": "nginx" - }, - { - "name": "Set-Cookie", - "value": "_chorus_geoip_continent=NA; expires=Fri, 17 Jun 2022 21:41:24 GMT; path=/;" - }, - { - "name": "Set-Cookie", - "value": "vmidv1=4e0c1265-10f8-4cb1-a5de-1c3cf70b531c;Expires=Tue, 15 Jun 2027 21:41:24 GMT;Domain=www.theverge.com;Path=/;SameSite=Lax;Secure" - }, - { - "name": "Vary", - "value": "X-Forwarded-Proto, Cookie, X-Chorus-Unison-Testing, X-Chorus-Require-Privacy-Consent, X-Chorus-Restrict-In-Privacy-Consent-Region, Accept-Encoding" - }, - { - "name": "Via", - "value": "1.1 varnish" - }, - { - "name": "X-Cache", - "value": "HIT" - }, - { - "name": "X-Cache-Hits", - "value": "2" - }, - { - "name": "X-Served-By", - "value": "cache-pao17450-PAO" - }, - { - "name": "X-Timer", - "value": "S1655415684.035748,VS0,VE0" - } - ], - "content": { - "size": -1, - "mimeType": "text/html", - "compression": 0 - }, - "headersSize": 731, - "bodySize": 0, - "redirectURL": "https://www.theverge.com/", - "_transferSize": 731 - }, - "cache": { - "beforeRequest": null, - "afterRequest": null - }, - "timings": { - "dns": 2.742, - "connect": 10.03, - "ssl": 14.123, - "send": 0, - "wait": 15.023, - "receive": 2.477 - }, - "pageref": "page@8f314969edc000996eb5c2ab22f0e6b3", - "serverIPAddress": "151.101.189.52", - "_serverPort": 80, - "_securityDetails": {} - }, - { - "_frameref": "frame@3767e074ecde4cb8372abba2f6f9bb4f", - "_monotonicTime": 110928455.901, - "startedDateTime": "2022-06-16T21:41:24.050Z", - "time": 50.29199999999999, - "request": { - "method": "GET", - "url": "https://www.theverge.com/", - "httpVersion": "HTTP/2.0", - "cookies": [ - { - "name": "vmidv1", - "value": "9faf31ab-1415-4b90-b367-24b670205f41" - }, - { - "name": "_chorus_geoip_continent", - "value": "NA" - } - ], - "headers": [ - { - "name": ":authority", - "value": "www.theverge.com" - }, - { - "name": ":method", - "value": "GET" - }, - { - "name": ":path", - "value": "/" - }, - { - "name": ":scheme", - "value": "https" - }, - { - "name": "accept", - "value": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" - }, - { - "name": "accept-encoding", - "value": "gzip, deflate, br" - }, - { - "name": "accept-language", - "value": "en-US,en;q=0.9" - }, - { - "name": "cookie", - "value": "vmidv1=9faf31ab-1415-4b90-b367-24b670205f41; _chorus_geoip_continent=NA" - }, - { - "name": "sec-ch-ua", - "value": "\"Chromium\";v=\"103\", \".Not/A)Brand\";v=\"99\"" - }, - { - "name": "sec-ch-ua-mobile", - "value": "?0" - }, - { - "name": "sec-ch-ua-platform", - "value": "\"Linux\"" - }, - { - "name": "sec-fetch-dest", - "value": "document" - }, - { - "name": "sec-fetch-mode", - "value": "navigate" - }, - { - "name": "sec-fetch-site", - "value": "none" - }, - { - "name": "sec-fetch-user", - "value": "?1" - }, - { - "name": "upgrade-insecure-requests", - "value": "1" - }, - { - "name": "user-agent", - "value": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.42 Safari/537.36" - } - ], - "queryString": [], - "headersSize": 729, - "bodySize": 0 - }, - "response": { - "status": 200, - "statusText": "", - "httpVersion": "HTTP/2.0", - "cookies": [ - { - "name": "_chorus_geoip_continent", - "value": "NA" - }, - { - "name": "vmidv1", - "value": "40d8fd14-5ac3-4757-9e9c-efb106e82d3a", - "expires": "2027-06-15T21:41:24.000Z", - "domain": "www.theverge.com", - "path": "/", - "sameSite": "Lax", - "secure": true - } - ], - "headers": [ - { - "name": "accept-ranges", - "value": "bytes" - }, - { - "name": "age", - "value": "263" - }, - { - "name": "cache-control", - "value": "max-age=0, public, must-revalidate" - }, - { - "name": "content-encoding", - "value": "br" - }, - { - "name": "content-length", - "value": "14" - }, - { - "name": "content-security-policy", - "value": "default-src https: data: 'unsafe-inline' 'unsafe-eval'; child-src https: data: blob:; connect-src https: data: blob: ; font-src https: data:; img-src https: data: blob:; media-src https: data: blob:; object-src https:; script-src https: data: blob: 'unsafe-inline' 'unsafe-eval'; style-src https: 'unsafe-inline'; block-all-mixed-content; upgrade-insecure-requests" - }, - { - "name": "content-type", - "value": "text/html; charset=utf-8" - }, - { - "name": "date", - "value": "Thu, 16 Jun 2022 21:41:24 GMT" - }, - { - "name": "etag", - "value": "W/\"d498ef668223d015000070a66a181e85\"" - }, - { - "name": "link", - "value": "; rel=preload; as=fetch; crossorigin" - }, - { - "name": "referrer-policy", - "value": "strict-origin-when-cross-origin" - }, - { - "name": "server", - "value": "nginx" - }, - { - "name": "set-cookie", - "value": "_chorus_geoip_continent=NA; expires=Fri, 17 Jun 2022 21:41:24 GMT; path=/;" - }, - { - "name": "set-cookie", - "value": "vmidv1=40d8fd14-5ac3-4757-9e9c-efb106e82d3a;Expires=Tue, 15 Jun 2027 21:41:24 GMT;Domain=www.theverge.com;Path=/;SameSite=Lax;Secure" - }, - { - "name": "strict-transport-security", - "value": "max-age=31556952; preload" - }, - { - "name": "vary", - "value": "Accept-Encoding, X-Chorus-Unison-Testing, X-Chorus-Require-Privacy-Consent, X-Chorus-Restrict-In-Privacy-Consent-Region, Origin, X-Forwarded-Proto, Cookie, X-Chorus-Unison-Testing, X-Chorus-Require-Privacy-Consent, X-Chorus-Restrict-In-Privacy-Consent-Region" - }, - { - "name": "via", - "value": "1.1 varnish" - }, - { - "name": "x-cache", - "value": "HIT" - }, - { - "name": "x-cache-hits", - "value": "1" - }, - { - "name": "x-content-type-options", - "value": "nosniff" - }, - { - "name": "x-download-options", - "value": "noopen" - }, - { - "name": "x-frame-options", - "value": "SAMEORIGIN" - }, - { - "name": "x-permitted-cross-domain-policies", - "value": "none" - }, - { - "name": "x-request-id", - "value": "97363ad70e272e63641c0bb784fa06a01b848dfd" - }, - { - "name": "x-runtime", - "value": "0.257911" - }, - { - "name": "x-served-by", - "value": "cache-pao17436-PAO" - }, - { - "name": "x-timer", - "value": "S1655415684.075077,VS0,VE1" - }, - { - "name": "x-xss-protection", - "value": "1; mode=block" - } - ], - "content": { - "size": 14, - "mimeType": "text/html", - "compression": 0, - "text": "

hello

" - }, - "headersSize": 1742, - "bodySize": 48716, - "redirectURL": "", - "_transferSize": 48716 - }, - "cache": { - "beforeRequest": null, - "afterRequest": null - }, - "timings": { - "dns": 0.016, - "connect": 24.487, - "ssl": 17.406, - "send": 0, - "wait": 8.383, - "receive": -1 - }, - "pageref": "page@8f314969edc000996eb5c2ab22f0e6b3", - "serverIPAddress": "151.101.189.52", - "_serverPort": 443, - "_securityDetails": { - "protocol": "TLS 1.2", - "subjectName": "*.americanninjawarriornation.com", - "issuer": "GlobalSign Atlas R3 DV TLS CA 2022 Q1", - "validFrom": 1644853133, - "validTo": 1679153932 - } - } - ] - } -} diff --git a/tests/assets/har-sha1-main-response.txt b/tests/assets/har-sha1-main-response.txt deleted file mode 100644 index dbe9dba55..000000000 --- a/tests/assets/har-sha1-main-response.txt +++ /dev/null @@ -1 +0,0 @@ -Hello, world \ No newline at end of file diff --git a/tests/assets/har-sha1.har b/tests/assets/har-sha1.har deleted file mode 100644 index 850b06dd8..000000000 --- a/tests/assets/har-sha1.har +++ /dev/null @@ -1,95 +0,0 @@ -{ - "log": { - "version": "1.2", - "creator": { - "name": "Playwright", - "version": "1.23.0-next" - }, - "browser": { - "name": "chromium", - "version": "103.0.5060.33" - }, - "pages": [ - { - "startedDateTime": "2022-06-10T04:27:32.125Z", - "id": "page@b17b177f1c2e66459db3dcbe44636ffd", - "title": "Hey", - "pageTimings": { - "onContentLoad": 70, - "onLoad": 70 - } - } - ], - "entries": [ - { - "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", - "_monotonicTime": 270572145.898, - "startedDateTime": "2022-06-10T04:27:32.146Z", - "time": 8.286, - "request": { - "method": "GET", - "url": "http://no.playwright/", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "Accept", - "value": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" - }, - { - "name": "Upgrade-Insecure-Requests", - "value": "1" - }, - { - "name": "User-Agent", - "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.33 Safari/537.36" - } - ], - "queryString": [], - "headersSize": 326, - "bodySize": 0 - }, - "response": { - "status": 200, - "statusText": "OK", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "content-length", - "value": "12" - }, - { - "name": "content-type", - "value": "text/html" - } - ], - "content": { - "size": 12, - "mimeType": "text/html", - "compression": 0, - "_file": "har-sha1-main-response.txt" - }, - "headersSize": 64, - "bodySize": 71, - "redirectURL": "", - "_transferSize": 71 - }, - "cache": { - "beforeRequest": null, - "afterRequest": null - }, - "timings": { - "dns": -1, - "connect": -1, - "ssl": -1, - "send": 0, - "wait": 8.286, - "receive": -1 - }, - "pageref": "page@b17b177f1c2e66459db3dcbe44636ffd", - "_securityDetails": {} - } - ] - } -} diff --git a/tests/assets/har.html b/tests/assets/har.html deleted file mode 100644 index 054bb0eb1..000000000 --- a/tests/assets/har.html +++ /dev/null @@ -1,3 +0,0 @@ -HAR Page - -
hello, world!
diff --git a/tests/assets/headings.html b/tests/assets/headings.html deleted file mode 100644 index ba770fa4e..000000000 --- a/tests/assets/headings.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - Headings - - - -

Title

-

Subtitle

-

Subsubtitle

-

Subtitle

- - - diff --git a/tests/assets/historyapi.html b/tests/assets/historyapi.html deleted file mode 100644 index bacaf9e9a..000000000 --- a/tests/assets/historyapi.html +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/tests/assets/injectedfile.js b/tests/assets/injectedfile.js deleted file mode 100644 index 377253c78..000000000 --- a/tests/assets/injectedfile.js +++ /dev/null @@ -1,3 +0,0 @@ -window.__injected = 42; -window.injected = 123; -window.__injectedError = new Error('hi'); diff --git a/tests/assets/injectedstyle.css b/tests/assets/injectedstyle.css deleted file mode 100644 index aa1634c25..000000000 --- a/tests/assets/injectedstyle.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - background-color: red; -} diff --git a/tests/assets/input/animating-button.html b/tests/assets/input/animating-button.html deleted file mode 100644 index 2946f2e89..000000000 --- a/tests/assets/input/animating-button.html +++ /dev/null @@ -1,42 +0,0 @@ - - diff --git a/tests/assets/input/button.html b/tests/assets/input/button.html deleted file mode 100644 index 5a2702c1b..000000000 --- a/tests/assets/input/button.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - Button test - - - - - - - diff --git a/tests/assets/input/checkbox.html b/tests/assets/input/checkbox.html deleted file mode 100644 index ca56762e2..000000000 --- a/tests/assets/input/checkbox.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - Selection Test - - - - - - - diff --git a/tests/assets/input/fileupload-multi.html b/tests/assets/input/fileupload-multi.html deleted file mode 100644 index 05dd5a223..000000000 --- a/tests/assets/input/fileupload-multi.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - File upload test - - -
- - -
- - diff --git a/tests/assets/input/fileupload.html b/tests/assets/input/fileupload.html deleted file mode 100644 index 771dc4c68..000000000 --- a/tests/assets/input/fileupload.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - File upload test - - -
- - -
- - diff --git a/tests/assets/input/folderupload.html b/tests/assets/input/folderupload.html deleted file mode 100644 index b6a2693b7..000000000 --- a/tests/assets/input/folderupload.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - Folder upload test - - -
- - -
- - diff --git a/tests/assets/input/handle-locator.html b/tests/assets/input/handle-locator.html deleted file mode 100644 index f8f2111c9..000000000 --- a/tests/assets/input/handle-locator.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - Interstitial test - - - -
-
A place on the side to hover
-
-
This interstitial covers the button
- -
- - - diff --git a/tests/assets/input/keyboard.html b/tests/assets/input/keyboard.html deleted file mode 100644 index 4788b0d12..000000000 --- a/tests/assets/input/keyboard.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - Keyboard test - - - - - - diff --git a/tests/assets/input/mouse-helper.js b/tests/assets/input/mouse-helper.js deleted file mode 100644 index 3c4d57033..000000000 --- a/tests/assets/input/mouse-helper.js +++ /dev/null @@ -1,62 +0,0 @@ -// This injects a box into the page that moves with the mouse; -// Useful for debugging -(function(){ - const box = document.createElement('div'); - box.classList.add('mouse-helper'); - const styleElement = document.createElement('style'); - styleElement.innerHTML = ` - .mouse-helper { - pointer-events: none; - position: absolute; - top: 0; - left: 0; - width: 20px; - height: 20px; - background: rgba(0,0,0,.4); - border: 1px solid white; - border-radius: 10px; - margin-left: -10px; - margin-top: -10px; - transition: background .2s, border-radius .2s, border-color .2s; - } - .mouse-helper.button-1 { - transition: none; - background: rgba(0,0,0,0.9); - } - .mouse-helper.button-2 { - transition: none; - border-color: rgba(0,0,255,0.9); - } - .mouse-helper.button-3 { - transition: none; - border-radius: 4px; - } - .mouse-helper.button-4 { - transition: none; - border-color: rgba(255,0,0,0.9); - } - .mouse-helper.button-5 { - transition: none; - border-color: rgba(0,255,0,0.9); - } - `; - document.head.appendChild(styleElement); - document.body.appendChild(box); - document.addEventListener('mousemove', event => { - box.style.left = event.pageX + 'px'; - box.style.top = event.pageY + 'px'; - updateButtons(event.buttons); - }, true); - document.addEventListener('mousedown', event => { - updateButtons(event.buttons); - box.classList.add('button-' + event.which); - }, true); - document.addEventListener('mouseup', event => { - updateButtons(event.buttons); - box.classList.remove('button-' + event.which); - }, true); - function updateButtons(buttons) { - for (let i = 0; i < 5; i++) - box.classList.toggle('button-' + i, buttons & (1 << i)); - } -})(); diff --git a/tests/assets/input/rotatedButton.html b/tests/assets/input/rotatedButton.html deleted file mode 100644 index 1bce66cf5..000000000 --- a/tests/assets/input/rotatedButton.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - Rotated button test - - - - - - - - diff --git a/tests/assets/input/scrollable.html b/tests/assets/input/scrollable.html deleted file mode 100644 index 2d81882a0..000000000 --- a/tests/assets/input/scrollable.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - Scrollable test - - - - - - diff --git a/tests/assets/input/select.html b/tests/assets/input/select.html deleted file mode 100644 index 53dd06adf..000000000 --- a/tests/assets/input/select.html +++ /dev/null @@ -1,69 +0,0 @@ - - - - Selection Test - - - - - - diff --git a/tests/assets/input/textarea.html b/tests/assets/input/textarea.html deleted file mode 100644 index 86d4a58dd..000000000 --- a/tests/assets/input/textarea.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - Textarea test - - - - -
-
Plain div
- - - - diff --git a/tests/assets/input/touches.html b/tests/assets/input/touches.html deleted file mode 100644 index 6a0347dbd..000000000 --- a/tests/assets/input/touches.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - Touch test - - - - - - - diff --git a/tests/assets/mobile.html b/tests/assets/mobile.html deleted file mode 100644 index 8e94b2fe2..000000000 --- a/tests/assets/mobile.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/assets/networkidle.html b/tests/assets/networkidle.html deleted file mode 100644 index ab10c9796..000000000 --- a/tests/assets/networkidle.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/assets/networkidle.js b/tests/assets/networkidle.js deleted file mode 100644 index 9d8998458..000000000 --- a/tests/assets/networkidle.js +++ /dev/null @@ -1,12 +0,0 @@ -async function main() { - window.ws = new WebSocket('ws://localhost:' + window.location.port + '/ws'); - window.ws.addEventListener('message', message => {}); - - fetch('fetch-request-a.js'); - window.top.fetchSecond = () => { - // Do not return the promise here. - fetch('fetch-request-b.js'); - }; -} - -main(); diff --git a/tests/assets/offscreenbuttons.html b/tests/assets/offscreenbuttons.html deleted file mode 100644 index b7f381dec..000000000 --- a/tests/assets/offscreenbuttons.html +++ /dev/null @@ -1,55 +0,0 @@ - -
- - - - - - - - - - - -
- diff --git a/tests/assets/one-style.css b/tests/assets/one-style.css deleted file mode 100644 index 7b26410d8..000000000 --- a/tests/assets/one-style.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - background-color: pink; -} diff --git a/tests/assets/one-style.html b/tests/assets/one-style.html deleted file mode 100644 index 4760f2b9f..000000000 --- a/tests/assets/one-style.html +++ /dev/null @@ -1,2 +0,0 @@ - -
hello, world!
diff --git a/tests/assets/playground.html b/tests/assets/playground.html deleted file mode 100644 index de25516fe..000000000 --- a/tests/assets/playground.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - Playground - - - - -
First div
-
- Second div - Inner span -
- - diff --git a/tests/assets/popup/popup.html b/tests/assets/popup/popup.html deleted file mode 100644 index 76a33e88b..000000000 --- a/tests/assets/popup/popup.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - Popup - - - - I am a popup - - diff --git a/tests/assets/popup/window-open.html b/tests/assets/popup/window-open.html deleted file mode 100644 index d138be1d2..000000000 --- a/tests/assets/popup/window-open.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - Popup test - - - - - diff --git a/tests/assets/pptr.png b/tests/assets/pptr.png deleted file mode 100644 index 65d87c68e65902c058af18d2a595fc89f423f4ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6138 zcmZu#cQjmWw;y9L%IID6F3RYg=q(tXsDlyR=)DagT6krUFiIrRiC%*dq6>-MMT<^? zh#Z0{rl~`P72IO2SUP30ssIYdb*mXIH`pju3$pkdn)>_ z6DL5A)eO}DfQC%cTQ~u3&f%hKY6t*C@BsjEi2%TFoGNY)00@Qx00&M0fWiv^fB}i= zG*QBRAapj+(FFYc_bKTve~!}-BXw;80RU2(e+M2QAH#@K5(ViQY7u=Up~B-QgS)T2 z1pw&x^)%JY!S$ZHQO&^aI)0-lv;<>80 zWA#UG$S*B4?TsqlUBshCdEDvPHNcV*OdOyOSy@aH)WgWr#na4aKR#bVDS|H_LulpcHxOMdr_XTVAT!R8 zhP{h>9N8B}El0+vnW!71Xz}AHA^Qa~g_3$0 zM2{yfOO-^-2o%Oog%|oeW7_?Er$4z+&KEfYAyEqZ_(l9LNWkxt8Eyc1uUE%(5$VX=D%> z*~dY;KVcMRNjv^nyx<=5xGrjl-$4Bi>lp{HsFgCI_;%Qhl2+U> zm(l&Sydg~V>~ETryTDi&Svy11NHU(uCG`DjUlzw(A1_1Y;<1EK8SvaGsK0{10qEe~ z8K}cr%WxK%2*zf?V8D+%Kx5gnb6`^)IiJa7Q5_`%M9-gck6S?ads%yw98zrb?gy+e zAhPc%K@pL|fH(6f_Jxs>yrD#-#6>2EzX@o1MQT|1?Hc$ig56tHSfHB>yc*)`Wl~%D zoZH|#(VxtU`W~`z4+xG2DQoF!%7@MYX~tDPcpg)98Z!+wcJ-xQ>RDqh%vS8kBvDM zpJ&{|>UT@LIb|9tPH&A&Er}i9fMrfBkxF))g7jsX=m9NMn%Nj~?g*v=k(ZJCi+|6D z`<`ODPQ1qh%Fl4A$S~1vI)9V<&^NBmpnFyf8*lh%|V7Lv9 zEAZ0gSkfyWP@2G@!Ju^Gti)f7b_;mv<8}mFWA<5&HBKwh{7X}79H2Snp$6Zz%zs{z zn)VPXEnjR*WT=?Pm_>sb%6-XB2q<_A2(J=9m|8zC9T&eUMM+9n_Ayb;lHH>ne)ki* zWDZYwP85X~l^#881tpYVn}FP=u+*KVJcSuQKMC+85%a-Y+S&!5cMI+3MrVhjXAvz3om5A5i$g9^bN4R zyb(p3-qMi*8AOgG_KEb@ca03Ll4xr_Mg22H`evZ`$uG36%*>c_4c&M zo{&`g)%f!GzXOXk{*NgL9=o%*yCyXCkq^C%-_g`4-`$Wx-X|z0L~=>cmZirLC-A}7 zuKz~eUkhJOi^QlDVia{BDRWG9Bu}0Y*{7Uf@o?r|x)}qS)OQI&!0W!Mk%J&RAWr%W z(C !4@^Vx1sD`7N#d=xeNFoPC9oyU-cfsEl&6lomsESF+tG@eQK*Rh_E6^MP|L zo%KhZgeJ4+BV`4()-B z#Ckf_O^nXtPvaSQL8R@8XhW9+>9Po;D5BIdcIJ zb_gd{&^-S;#RkSlET{ExEEXc*B_^Oi36Cr!omp~V2NeNS%I1D}{VX^BIwctUJ2aU| zFZ*Sv!3j7i&k`(})8DEAJ@*Cw!1H4HKx>FyRQfgK*8V8tET})|>OnJ`eZ9B*Q^XB? z`w16JZ~yO-ldH^P+FdvM#!?XsG&SKlY=HirmQi($^)y&4rj3b|ro)m3Sg|weP zd~PH-3;SE`4^?nzgqJ({2uW09W5iqW*zs&cFp=_}u7~LXuP{GRo4(7Oc3wq4t0_s9 zfDbk1HTD?rrfGOz6UMzO?>xxRW{1>R%K5YPEQl`TkrOLhfZ16sDTdZ26L0#DZp>F2 z;dxHezJ-C;nZSNn-8nIGuCHUKWE8)y!nMt!CHR84%{~Xb!D-F|u6mcEqt$x_|B|rB zf_KHQpUr}8=QC-la`iV%-Kg${nLqm5-7C8yX|2`p`D=;SWiI~ZYl{$0?*6-FVbTGnGAf&_W7wTRDsr$m-+;xVxJK0?k+N4NA*T;MB8nG>oS<0q2gCBorvAyaxKELp(B zSEc84wbHG%PYwmpUgobAnr*Pf(Ea|DjDW2a!EFAr3hfR#d2lC>t7~7QA*#2Q{^+4b zSf_JOD5DWYrq&+wTmlo+3$lfNCsZEd? zQ-ng9vg->``!I+x&JvO1qvBYxML!$SH!#sHkhSco_}dDql~Pea6=zs zoJW`=fy-m&xmqG|bBsSZ9oZoM>KdC#OohY}hoZXVd*FO>+fzjgos0CdF6SoVm3R|fSV>xs37S|g*GEi=z zs?%b~Fk28}QTXh%;+X_FYDJ|xNvxloG81@}rh*C{D!L%~c_4AmB2lrg3CzOo5>F_z z+9wRD!|KCW#k}(jvZBrrH>%IGZUe%yV)spZImwcsrWpY;ec1w-n@|g1KcM#bXcwZ;5lYka^TJIF;KV1+R7U8{NY=S1$7-6R$p{;o{5m6;O(Y=95@Y{1O) zymYpbRC`BntLXWU5(s%5&bJ5!$0}=3%0uu=%f33PEb3a@`q8p#Pg7Wf0ir)anh3$t z@Q>QGUpFCH|j;?a!5nS@VjGq%KVAJ6joJ^Hu1uh zxTe656kUgcGo zVuEbUyeVWkrA^6FtCE_j9PB9~3xt1I_QozP&X-7ysH?U_HNbkkC)t&udgFYRLqodC z;+C76uB!8Xaa0xId+fC)0>x&Ebn0C7>>LbXEIFL(?u^eiRx+w)@}w0UTb}J!9XGu_ z_OI80wWJ9-5!aKqRBc$G5zUpzK>QtGx~9cf3D}tApqVNnk~W9tj^}QowW+DXXn}z4 z&${=RyP?qofvU_4Z4tR_fwY%1Unp$5M&Ve-!5 z2HS%C?uuG9rle7w2Tyi$f*x9w65@AJo4idtOxjG$Uv9YiV&&ei`9(xXZwsbE!g%Xa za-l}mQvK9+9q|bD1v(j^ZI4WrKFBReS3``4Al8p^?=%-D*JI$xuk~|$k~aHWl!GQF zd}oc(-B{Zc=J?K%-{UeGOixF}j;hLbI&z+sP#+|33)?OaZE`dQgS0fR3s^iEt6P*G z75cvan3F-m&r|h-&!od_zV*TWvjbzEEZ@jF&3{gY8%ykT+tfxhI;PIbSQHMTb%3EI zz-HwN=K%T9CoAoj+%`HAusMg612JCDZhP?P6U!;5Vc(?(R_5x`pIQ^r1kO8CM?*dL z+XH|RrCdu7X9&oFpYUIMK&uyMM};{DzU=L}&lf32I?KE~Y|Uy+NE8sklu>?YO&Kut z`l|VxSVBUAOmL@3>IH=-_^oWc|CjFPof8GJnI$D0LH0WODR4$G=adwrADU%ax|kcn zlBA|%5fh^#)_qIu7<0X2Geh#-Wvcx(D?20Og3l+6d}4C43nL*D6B8{xeW?dC0)fED z`J?ig>FO}bk+~Smy+lgZpHysYpD>Go>2Yy!im)AmeD#zcS6BQ{U4jM=50Bd)UmY8N ziKSK6)$JCBu7B-IX5*2T&cT_+oU`|S?dX^^ad2>WoXcuxS)Eg|!6CxI0bs^8Ey#<{ ziIiZFss75!3Ye6Xn}-KvKTmu{!J(vdPfSbn0f$%~(D4*{~oCUL#6(R?OKbIJb91l_;?NPW5xqEy(fg&J% zA?-B>A$Xpht?uT=C+RkhotQXwjCoJLI^^82vAa81;MX&9Fk79Hk^=v}`4OWSO5P7W zD3g7IwieFq+^3A(wR!Ytbhi5P3cl1UNtW>Ci)mH$yE~0q+$}~&nT>7V*>~k?rP2HE ze|f82oR^np=HL)Bp;A>tV4v-fSEmj!he@ zPAoLI*mMN&+P3+AoSn^?nKADS+W!zZsXSB<>5R&ay|O$`v8sc#>vU2GQ)zf!`e0h0f7>~ z^`3DYex5!B(~H_YUG(OYlzfi6e6EPCHnM(8D_i7IDkZA1QDS$pc=C0o%ILCRuVGY_ zQlVm~TYnM+qf+a}92gjA(;K6V%PNd~VA9T5Uen`Aw!w5J4IJW+pc^@nBmCN?xCC0-psr_1~xXf7Z3SR z2?_f%l{5xo4`#mSm30ay zTU-5y?|O~Y)qz_>nU6OIQoL6?=?tgA5XjDP+d3Z~Us7Ts$n6tmWOt&#rpXPG1kcLJ zv1nWO>%Gh6FOJIg-x_QPIez32zC(vY@AXmB(jMIiCk|7=I8V1?pso_vSt(%uyocv&&{2y z(4TMS@|7t#)Xy&}D%$IGn2(i{m!A#J#jy*RvpwQcXm)lML_|cxz>r+#p{c1k^;##> ztIQM9Us6_wvZJZ>&|-)rY&WBqSu(?MTkzs2{|!w_EI= zg9|w|k~k7KRyqRTpM4;$vFpfwLdvkYwWVQdN{s^@Egjw0lM^E(6517dI_TMg<67Lg zx4(Tm?;~P|Sz2m4J9AxMUx!6T(oj-jKNTucsY4?!+=J}P8ymB6H37%_!=s}gR##c8 zXP0(Oz0z{2X=yc2*Sf_ESl-2vKp+SlnG4%ACaV6uo+M*cpydReoSZNw#kaM|^78U3 z-5fQwcXWK;OcTq_%EBf=SkBJRw{VA5IvtyY!y(eZQP{JRfB7n|f=&@aHU96?y#w@h@QDj66I%lr%INnwlU|df|=k zt2LL)gIN$Ep+xMTD23nyO;=Z*SFc|ApuWJ^2Zn~mva_i@tPqH|!lI(N^l4f$7Ct_; zO3}ZY$H&LfxO{DFZ?|Sn;>;7a;ljtq--*X3a@E#OdXN07zUqgApcpq7muo~s=R;%T zyzubwjGs1UX57ZbDk9U<)41A&czsGjO1guCh^vQ3CE9fK;J`EdU`8+;N21}l%+p@p z-QFHiW_3vHd-qyD?598)`Ax!npY>Q zhii(8in0NUIBjWZ-Q7FGekd|DG&F2>+%52Rv<$kpSM|@IKf$Mk7z{4Kg4%s_b-|pR zoWZA0C`F#h%r_r*9#_)gqVRQRM+-L9QvIIaxai?KhwihVKYdU8TdS8?^?PIP_U7Le z_n%TzQ|s#LwlDhlTC_E}{%?Iy*i0=bll*&JR2Rx4(3nx2CiADR0_o(?DF} W{|p>bpZvrb0Q9tsH0#uzQ2zrM7gd%3 diff --git a/tests/assets/react.html b/tests/assets/react.html deleted file mode 100644 index 0377b489d..000000000 --- a/tests/assets/react.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - -
- - diff --git a/tests/assets/react/react-dom@16.13.1.production.min.js b/tests/assets/react/react-dom@16.13.1.production.min.js deleted file mode 100644 index 8ddde060c..000000000 --- a/tests/assets/react/react-dom@16.13.1.production.min.js +++ /dev/null @@ -1,239 +0,0 @@ -/** @license React v16.13.1 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ -/* - Modernizr 3.0.0pre (Custom Build) | MIT -*/ -'use strict';(function(I,ea){"object"===typeof exports&&"undefined"!==typeof module?ea(exports,require("react")):"function"===typeof define&&define.amd?define(["exports","react"],ea):(I=I||self,ea(I.ReactDOM={},I.React))})(this,function(I,ea){function k(a){for(var b="https://reactjs.org/docs/error-decoder.html?invariant="+a,c=1;cb}return!1}function L(a, -b,c,d,e,f){this.acceptsBooleans=2===b||3===b||4===b;this.attributeName=d;this.attributeNamespace=e;this.mustUseProperty=c;this.propertyName=a;this.type=b;this.sanitizeURL=f}function xd(a,b,c,d){var e=E.hasOwnProperty(b)?E[b]:null;var f=null!==e?0===e.type:d?!1:!(2=c.length))throw Error(k(93));c=c[0]}b=c}null==b&&(b="");c=b}a._wrapperState={initialValue:va(c)}}function Lf(a,b){var c=va(b.value),d=va(b.defaultValue);null!=c&&(c=""+c,c!==a.value&&(a.value=c),null==b.defaultValue&&a.defaultValue!==c&&(a.defaultValue=c));null!=d&&(a.defaultValue=""+d)}function Mf(a,b){b=a.textContent;b===a._wrapperState.initialValue&&""!== -b&&null!==b&&(a.value=b)}function Nf(a){switch(a){case "svg":return"http://www.w3.org/2000/svg";case "math":return"http://www.w3.org/1998/Math/MathML";default:return"http://www.w3.org/1999/xhtml"}}function Hd(a,b){return null==a||"http://www.w3.org/1999/xhtml"===a?Nf(b):"http://www.w3.org/2000/svg"===a&&"foreignObject"===b?"http://www.w3.org/1999/xhtml":a}function nc(a,b){var c={};c[a.toLowerCase()]=b.toLowerCase();c["Webkit"+a]="webkit"+b;c["Moz"+a]="moz"+b;return c}function oc(a){if(Id[a])return Id[a]; -if(!ib[a])return a;var b=ib[a],c;for(c in b)if(b.hasOwnProperty(c)&&c in Of)return Id[a]=b[c];return a}function Jd(a){var b=Pf.get(a);void 0===b&&(b=new Map,Pf.set(a,b));return b}function Na(a){var b=a,c=a;if(a.alternate)for(;b.return;)b=b.return;else{a=b;do b=a,0!==(b.effectTag&1026)&&(c=b.return),a=b.return;while(a)}return 3===b.tag?c:null}function Qf(a){if(13===a.tag){var b=a.memoizedState;null===b&&(a=a.alternate,null!==a&&(b=a.memoizedState));if(null!==b)return b.dehydrated}return null}function Rf(a){if(Na(a)!== -a)throw Error(k(188));}function vi(a){var b=a.alternate;if(!b){b=Na(a);if(null===b)throw Error(k(188));return b!==a?null:a}for(var c=a,d=b;;){var e=c.return;if(null===e)break;var f=e.alternate;if(null===f){d=e.return;if(null!==d){c=d;continue}break}if(e.child===f.child){for(f=e.child;f;){if(f===c)return Rf(e),a;if(f===d)return Rf(e),b;f=f.sibling}throw Error(k(188));}if(c.return!==d.return)c=e,d=f;else{for(var g=!1,h=e.child;h;){if(h===c){g=!0;c=e;d=f;break}if(h===d){g=!0;d=e;c=f;break}h=h.sibling}if(!g){for(h= -f.child;h;){if(h===c){g=!0;c=f;d=e;break}if(h===d){g=!0;d=f;c=e;break}h=h.sibling}if(!g)throw Error(k(189));}}if(c.alternate!==d)throw Error(k(190));}if(3!==c.tag)throw Error(k(188));return c.stateNode.current===c?a:b}function Sf(a){a=vi(a);if(!a)return null;for(var b=a;;){if(5===b.tag||6===b.tag)return b;if(b.child)b.child.return=b,b=b.child;else{if(b===a)break;for(;!b.sibling;){if(!b.return||b.return===a)return null;b=b.return}b.sibling.return=b.return;b=b.sibling}}return null}function jb(a,b){if(null== -b)throw Error(k(30));if(null==a)return b;if(Array.isArray(a)){if(Array.isArray(b))return a.push.apply(a,b),a;a.push(b);return a}return Array.isArray(b)?[a].concat(b):[a,b]}function Kd(a,b,c){Array.isArray(a)?a.forEach(b,c):a&&b.call(c,a)}function pc(a){null!==a&&(Ab=jb(Ab,a));a=Ab;Ab=null;if(a){Kd(a,wi);if(Ab)throw Error(k(95));if(hc)throw a=pd,hc=!1,pd=null,a;}}function Ld(a){a=a.target||a.srcElement||window;a.correspondingUseElement&&(a=a.correspondingUseElement);return 3===a.nodeType?a.parentNode: -a}function Tf(a){if(!wa)return!1;a="on"+a;var b=a in document;b||(b=document.createElement("div"),b.setAttribute(a,"return;"),b="function"===typeof b[a]);return b}function Uf(a){a.topLevelType=null;a.nativeEvent=null;a.targetInst=null;a.ancestors.length=0;10>qc.length&&qc.push(a)}function Vf(a,b,c,d){if(qc.length){var e=qc.pop();e.topLevelType=a;e.eventSystemFlags=d;e.nativeEvent=b;e.targetInst=c;return e}return{topLevelType:a,eventSystemFlags:d,nativeEvent:b,targetInst:c,ancestors:[]}}function Wf(a){var b= -a.targetInst,c=b;do{if(!c){a.ancestors.push(c);break}var d=c;if(3===d.tag)d=d.stateNode.containerInfo;else{for(;d.return;)d=d.return;d=3!==d.tag?null:d.stateNode.containerInfo}if(!d)break;b=c.tag;5!==b&&6!==b||a.ancestors.push(c);c=Bb(d)}while(c);for(c=0;c=b)return{node:c, -offset:b-a};a=d}a:{for(;c;){if(c.nextSibling){c=c.nextSibling;break a}c=c.parentNode}c=void 0}c=hg(c)}}function jg(a,b){return a&&b?a===b?!0:a&&3===a.nodeType?!1:b&&3===b.nodeType?jg(a,b.parentNode):"contains"in a?a.contains(b):a.compareDocumentPosition?!!(a.compareDocumentPosition(b)&16):!1:!1}function kg(){for(var a=window,b=Wd();b instanceof a.HTMLIFrameElement;){try{var c="string"===typeof b.contentWindow.location.href}catch(d){c=!1}if(c)a=b.contentWindow;else break;b=Wd(a.document)}return b} -function Xd(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return b&&("input"===b&&("text"===a.type||"search"===a.type||"tel"===a.type||"url"===a.type||"password"===a.type)||"textarea"===b||"true"===a.contentEditable)}function lg(a,b){switch(a){case "button":case "input":case "select":case "textarea":return!!b.autoFocus}return!1}function Yd(a,b){return"textarea"===a||"option"===a||"noscript"===a||"string"===typeof b.children||"number"===typeof b.children||"object"===typeof b.dangerouslySetInnerHTML&& -null!==b.dangerouslySetInnerHTML&&null!=b.dangerouslySetInnerHTML.__html}function kb(a){for(;null!=a;a=a.nextSibling){var b=a.nodeType;if(1===b||3===b)break}return a}function mg(a){a=a.previousSibling;for(var b=0;a;){if(8===a.nodeType){var c=a.data;if(c===ng||c===Zd||c===$d){if(0===b)return a;b--}else c===og&&b++}a=a.previousSibling}return null}function Bb(a){var b=a[Aa];if(b)return b;for(var c=a.parentNode;c;){if(b=c[Lb]||c[Aa]){c=b.alternate;if(null!==b.child||null!==c&&null!==c.child)for(a=mg(a);null!== -a;){if(c=a[Aa])return c;a=mg(a)}return b}a=c;c=a.parentNode}return null}function Hb(a){a=a[Aa]||a[Lb];return!a||5!==a.tag&&6!==a.tag&&13!==a.tag&&3!==a.tag?null:a}function Pa(a){if(5===a.tag||6===a.tag)return a.stateNode;throw Error(k(33));}function ae(a){return a[vc]||null}function pa(a){do a=a.return;while(a&&5!==a.tag);return a?a:null}function pg(a,b){var c=a.stateNode;if(!c)return null;var d=td(c);if(!d)return null;c=d[b];a:switch(b){case "onClick":case "onClickCapture":case "onDoubleClick":case "onDoubleClickCapture":case "onMouseDown":case "onMouseDownCapture":case "onMouseMove":case "onMouseMoveCapture":case "onMouseUp":case "onMouseUpCapture":case "onMouseEnter":(d= -!d.disabled)||(a=a.type,d=!("button"===a||"input"===a||"select"===a||"textarea"===a));a=!d;break a;default:a=!1}if(a)return null;if(c&&"function"!==typeof c)throw Error(k(231,b,typeof c));return c}function qg(a,b,c){if(b=pg(a,c.dispatchConfig.phasedRegistrationNames[b]))c._dispatchListeners=jb(c._dispatchListeners,b),c._dispatchInstances=jb(c._dispatchInstances,a)}function Ji(a){if(a&&a.dispatchConfig.phasedRegistrationNames){for(var b=a._targetInst,c=[];b;)c.push(b),b=pa(b);for(b=c.length;0this.eventPool.length&&this.eventPool.push(a)}function sg(a){a.eventPool=[];a.getPooled=Li;a.release=Mi}function tg(a,b){switch(a){case "keyup":return-1!==Ni.indexOf(b.keyCode);case "keydown":return 229!==b.keyCode;case "keypress":case "mousedown":case "blur":return!0;default:return!1}}function ug(a){a=a.detail;return"object"===typeof a&&"data"in -a?a.data:null}function Oi(a,b){switch(a){case "compositionend":return ug(b);case "keypress":if(32!==b.which)return null;vg=!0;return wg;case "textInput":return a=b.data,a===wg&&vg?null:a;default:return null}}function Pi(a,b){if(mb)return"compositionend"===a||!de&&tg(a,b)?(a=rg(),wc=ce=Ba=null,mb=!1,a):null;switch(a){case "paste":return null;case "keypress":if(!(b.ctrlKey||b.altKey||b.metaKey)||b.ctrlKey&&b.altKey){if(b.char&&1ob||(a.current=ie[ob],ie[ob]=null,ob--)}function y(a,b,c){ob++; -ie[ob]=a.current;a.current=b}function pb(a,b){var c=a.type.contextTypes;if(!c)return Ca;var d=a.stateNode;if(d&&d.__reactInternalMemoizedUnmaskedChildContext===b)return d.__reactInternalMemoizedMaskedChildContext;var e={},f;for(f in c)e[f]=b[f];d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=b,a.__reactInternalMemoizedMaskedChildContext=e);return e}function N(a){a=a.childContextTypes;return null!==a&&void 0!==a}function Fg(a,b,c){if(B.current!==Ca)throw Error(k(168));y(B,b);y(G,c)} -function Gg(a,b,c){var d=a.stateNode;a=b.childContextTypes;if("function"!==typeof d.getChildContext)return c;d=d.getChildContext();for(var e in d)if(!(e in a))throw Error(k(108,na(b)||"Unknown",e));return M({},c,{},d)}function Bc(a){a=(a=a.stateNode)&&a.__reactInternalMemoizedMergedChildContext||Ca;Ra=B.current;y(B,a);y(G,G.current);return!0}function Hg(a,b,c){var d=a.stateNode;if(!d)throw Error(k(169));c?(a=Gg(a,b,Ra),d.__reactInternalMemoizedMergedChildContext=a,q(G),q(B),y(B,a)):q(G);y(G,c)}function Cc(){switch(aj()){case Dc:return 99; -case Ig:return 98;case Jg:return 97;case Kg:return 96;case Lg:return 95;default:throw Error(k(332));}}function Mg(a){switch(a){case 99:return Dc;case 98:return Ig;case 97:return Jg;case 96:return Kg;case 95:return Lg;default:throw Error(k(332));}}function Da(a,b){a=Mg(a);return bj(a,b)}function Ng(a,b,c){a=Mg(a);return je(a,b,c)}function Og(a){null===qa?(qa=[a],Ec=je(Dc,Pg)):qa.push(a);return Qg}function ha(){if(null!==Ec){var a=Ec;Ec=null;Rg(a)}Pg()}function Pg(){if(!ke&&null!==qa){ke=!0;var a=0; -try{var b=qa;Da(99,function(){for(;a=b&&(ia=!0),a.firstContext=null)}function W(a,b){if(Gc!==a&&!1!==b&&0!==b){if("number"!==typeof b||1073741823===b)Gc=a,b=1073741823;b={context:a,observedBits:b,next:null};if(null===qb){if(null=== -Hc)throw Error(k(308));qb=b;Hc.dependencies={expirationTime:0,firstContext:b,responders:null}}else qb=qb.next=b}return a._currentValue}function ne(a){a.updateQueue={baseState:a.memoizedState,baseQueue:null,shared:{pending:null},effects:null}}function oe(a,b){a=a.updateQueue;b.updateQueue===a&&(b.updateQueue={baseState:a.baseState,baseQueue:a.baseQueue,shared:a.shared,effects:a.effects})}function Ea(a,b){a={expirationTime:a,suspenseConfig:b,tag:Tg,payload:null,callback:null,next:null};return a.next= -a}function Fa(a,b){a=a.updateQueue;if(null!==a){a=a.shared;var c=a.pending;null===c?b.next=b:(b.next=c.next,c.next=b);a.pending=b}}function Ug(a,b){var c=a.alternate;null!==c&&oe(c,a);a=a.updateQueue;c=a.baseQueue;null===c?(a.baseQueue=b.next=b,b.next=b):(b.next=c.next,c.next=b)}function Qb(a,b,c,d){var e=a.updateQueue;Ga=!1;var f=e.baseQueue,g=e.shared.pending;if(null!==g){if(null!==f){var h=f.next;f.next=g.next;g.next=h}f=g;e.shared.pending=null;h=a.alternate;null!==h&&(h=h.updateQueue,null!==h&& -(h.baseQueue=g))}if(null!==f){h=f.next;var m=e.baseState,n=0,k=null,ba=null,l=null;if(null!==h){var p=h;do{g=p.expirationTime;if(gn&&(n=g)}else{null!==l&&(l=l.next={expirationTime:1073741823,suspenseConfig:p.suspenseConfig,tag:p.tag,payload:p.payload,callback:p.callback,next:null});Vg(g,p.suspenseConfig);a:{var q=a,r=p;g=b;t=c;switch(r.tag){case 1:q= -r.payload;if("function"===typeof q){m=q.call(t,m,g);break a}m=q;break a;case 3:q.effectTag=q.effectTag&-4097|64;case Tg:q=r.payload;g="function"===typeof q?q.call(t,m,g):q;if(null===g||void 0===g)break a;m=M({},m,g);break a;case Jc:Ga=!0}}null!==p.callback&&(a.effectTag|=32,g=e.effects,null===g?e.effects=[p]:g.push(p))}p=p.next;if(null===p||p===h)if(g=e.shared.pending,null===g)break;else p=f.next=g.next,g.next=h,e.baseQueue=f=g,e.shared.pending=null}while(1)}null===l?k=m:l.next=ba;e.baseState=k;e.baseQueue= -l;Kc(n);a.expirationTime=n;a.memoizedState=m}}function Wg(a,b,c){a=b.effects;b.effects=null;if(null!==a)for(b=0;br?(C=l,l=null):C=l.sibling;var O=p(e,l,h[r],m);if(null===O){null===l&&(l=C);break}a&&l&&null===O.alternate&&b(e,l);g=f(O,g,r);null===k?n=O:k.sibling=O;k=O;l=C}if(r===h.length)return c(e,l),n;if(null===l){for(;rC?(O=r,r=null):O=r.sibling;var q=p(e,r,v.value,n);if(null===q){null===r&&(r=O);break}a&&r&&null===q.alternate&&b(e,r);g=f(q,g,C);null===l?m=q:l.sibling=q;l=q;r=O}if(v.done)return c(e,r),m; -if(null===r){for(;!v.done;C++,v=h.next())v=ba(e,v.value,n),null!==v&&(g=f(v,g,C),null===l?m=v:l.sibling=v,l=v);return m}for(r=d(e,r);!v.done;C++,v=h.next())v=t(r,e,C,v.value,n),null!==v&&(a&&null!==v.alternate&&r.delete(null===v.key?C:v.key),g=f(v,g,C),null===l?m=v:l.sibling=v,l=v);a&&r.forEach(function(a){return b(e,a)});return m}return function(a,d,f,h){var m="object"===typeof f&&null!==f&&f.type===Ma&&null===f.key;m&&(f=f.props.children);var n="object"===typeof f&&null!==f;if(n)switch(f.$$typeof){case Pc:a:{n= -f.key;for(m=d;null!==m;){if(m.key===n){switch(m.tag){case 7:if(f.type===Ma){c(a,m.sibling);d=e(m,f.props.children);d.return=a;a=d;break a}break;default:if(m.elementType===f.type){c(a,m.sibling);d=e(m,f.props);d.ref=Rb(a,m,f);d.return=a;a=d;break a}}c(a,m);break}else b(a,m);m=m.sibling}f.type===Ma?(d=Ha(f.props.children,a.mode,h,f.key),d.return=a,a=d):(h=Oc(f.type,f.key,f.props,null,a.mode,h),h.ref=Rb(a,d,f),h.return=a,a=h)}return g(a);case gb:a:{for(m=f.key;null!==d;){if(d.key===m)if(4===d.tag&&d.stateNode.containerInfo=== -f.containerInfo&&d.stateNode.implementation===f.implementation){c(a,d.sibling);d=e(d,f.children||[]);d.return=a;a=d;break a}else{c(a,d);break}else b(a,d);d=d.sibling}d=re(f,a.mode,h);d.return=a;a=d}return g(a)}if("string"===typeof f||"number"===typeof f)return f=""+f,null!==d&&6===d.tag?(c(a,d.sibling),d=e(d,f),d.return=a,a=d):(c(a,d),d=qe(f,a.mode,h),d.return=a,a=d),g(a);if(Qc(f))return q(a,d,f,h);if(zb(f))return w(a,d,f,h);n&&Nc(a,f);if("undefined"===typeof f&&!m)switch(a.tag){case 1:case 0:throw a= -a.type,Error(k(152,a.displayName||a.name||"Component"));}return c(a,d)}}function Ta(a){if(a===Sb)throw Error(k(174));return a}function se(a,b){y(Tb,b);y(Ub,a);y(ja,Sb);a=b.nodeType;switch(a){case 9:case 11:b=(b=b.documentElement)?b.namespaceURI:Hd(null,"");break;default:a=8===a?b.parentNode:b,b=a.namespaceURI||null,a=a.tagName,b=Hd(b,a)}q(ja);y(ja,b)}function tb(a){q(ja);q(Ub);q(Tb)}function bh(a){Ta(Tb.current);var b=Ta(ja.current);var c=Hd(b,a.type);b!==c&&(y(Ub,a),y(ja,c))}function te(a){Ub.current=== -a&&(q(ja),q(Ub))}function Rc(a){for(var b=a;null!==b;){if(13===b.tag){var c=b.memoizedState;if(null!==c&&(c=c.dehydrated,null===c||c.data===$d||c.data===Zd))return b}else if(19===b.tag&&void 0!==b.memoizedProps.revealOrder){if(0!==(b.effectTag&64))return b}else if(null!==b.child){b.child.return=b;b=b.child;continue}if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return null;b=b.return}b.sibling.return=b.return;b=b.sibling}return null}function ue(a,b){return{responder:a,props:b}} -function S(){throw Error(k(321));}function ve(a,b){if(null===b)return!1;for(var c=0;cf))throw Error(k(301));f+=1;J=K=null;b.updateQueue=null;Sc.current=fj;a=c(d,e)}while(b.expirationTime===Ia)}Sc.current=Tc;b=null!==K&&null!==K.next; -Ia=0;J=K=z=null;Uc=!1;if(b)throw Error(k(300));return a}function ub(){var a={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};null===J?z.memoizedState=J=a:J=J.next=a;return J}function vb(){if(null===K){var a=z.alternate;a=null!==a?a.memoizedState:null}else a=K.next;var b=null===J?z.memoizedState:J.next;if(null!==b)J=b,K=a;else{if(null===a)throw Error(k(310));K=a;a={memoizedState:K.memoizedState,baseState:K.baseState,baseQueue:K.baseQueue,queue:K.queue,next:null};null===J?z.memoizedState= -J=a:J=J.next=a}return J}function Ua(a,b){return"function"===typeof b?b(a):b}function Vc(a,b,c){b=vb();c=b.queue;if(null===c)throw Error(k(311));c.lastRenderedReducer=a;var d=K,e=d.baseQueue,f=c.pending;if(null!==f){if(null!==e){var g=e.next;e.next=f.next;f.next=g}d.baseQueue=e=f;c.pending=null}if(null!==e){e=e.next;d=d.baseState;var h=g=f=null,m=e;do{var n=m.expirationTime;if(nz.expirationTime&&(z.expirationTime=n,Kc(n))}else null!==h&&(h=h.next={expirationTime:1073741823,suspenseConfig:m.suspenseConfig,action:m.action,eagerReducer:m.eagerReducer,eagerState:m.eagerState,next:null}),Vg(n,m.suspenseConfig),d=m.eagerReducer===a?m.eagerState:a(d,m.action);m=m.next}while(null!==m&&m!==e);null===h?f=d:h.next=g;Qa(d,b.memoizedState)||(ia=!0);b.memoizedState=d;b.baseState=f;b.baseQueue=h;c.lastRenderedState=d}return[b.memoizedState, -c.dispatch]}function Wc(a,b,c){b=vb();c=b.queue;if(null===c)throw Error(k(311));c.lastRenderedReducer=a;var d=c.dispatch,e=c.pending,f=b.memoizedState;if(null!==e){c.pending=null;var g=e=e.next;do f=a(f,g.action),g=g.next;while(g!==e);Qa(f,b.memoizedState)||(ia=!0);b.memoizedState=f;null===b.baseQueue&&(b.baseState=f);c.lastRenderedState=f}return[f,d]}function xe(a){var b=ub();"function"===typeof a&&(a=a());b.memoizedState=b.baseState=a;a=b.queue={pending:null,dispatch:null,lastRenderedReducer:Ua, -lastRenderedState:a};a=a.dispatch=ch.bind(null,z,a);return[b.memoizedState,a]}function ye(a,b,c,d){a={tag:a,create:b,destroy:c,deps:d,next:null};b=z.updateQueue;null===b?(b={lastEffect:null},z.updateQueue=b,b.lastEffect=a.next=a):(c=b.lastEffect,null===c?b.lastEffect=a.next=a:(d=c.next,c.next=a,a.next=d,b.lastEffect=a));return a}function dh(a){return vb().memoizedState}function ze(a,b,c,d){var e=ub();z.effectTag|=a;e.memoizedState=ye(1|b,c,void 0,void 0===d?null:d)}function Ae(a,b,c,d){var e=vb(); -d=void 0===d?null:d;var f=void 0;if(null!==K){var g=K.memoizedState;f=g.destroy;if(null!==d&&ve(d,g.deps)){ye(b,c,f,d);return}}z.effectTag|=a;e.memoizedState=ye(1|b,c,f,d)}function eh(a,b){return ze(516,4,a,b)}function Xc(a,b){return Ae(516,4,a,b)}function fh(a,b){return Ae(4,2,a,b)}function gh(a,b){if("function"===typeof b)return a=a(),b(a),function(){b(null)};if(null!==b&&void 0!==b)return a=a(),b.current=a,function(){b.current=null}}function hh(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null; -return Ae(4,2,gh.bind(null,b,a),c)}function Be(a,b){}function ih(a,b){ub().memoizedState=[a,void 0===b?null:b];return a}function Yc(a,b){var c=vb();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&ve(b,d[1]))return d[0];c.memoizedState=[a,b];return a}function jh(a,b){var c=vb();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&ve(b,d[1]))return d[0];a=a();c.memoizedState=[a,b];return a}function Ce(a,b,c){var d=Cc();Da(98>d?98:d,function(){a(!0)});Da(97\x3c/script>",a=a.removeChild(a.firstChild)):"string"===typeof d.is?a=g.createElement(e,{is:d.is}):(a=g.createElement(e),"select"===e&&(g=a,d.multiple?g.multiple=!0:d.size&&(g.size=d.size))):a=g.createElementNS(a,e);a[Aa]=b;a[vc]=d;jj(a,b,!1,!1);b.stateNode=a;g=Vd(e,d);switch(e){case "iframe":case "object":case "embed":w("load",a);h=d;break;case "video":case "audio":for(h=0;hd.tailExpiration&& -1a?c:a;return 2>=a&&b!==a?0:a}function V(a){if(0!==a.lastExpiredTime)a.callbackExpirationTime=1073741823,a.callbackPriority=99,a.callbackNode=Og(Te.bind(null,a));else{var b=fd(a),c=a.callbackNode;if(0===b)null!==c&&(a.callbackNode=null,a.callbackExpirationTime=0,a.callbackPriority=90);else{var d=ka(); -1073741823===b?d=99:1===b||2===b?d=95:(d=10*(1073741821-b)-10*(1073741821-d),d=0>=d?99:250>=d?98:5250>=d?97:95);if(null!==c){var e=a.callbackPriority;if(a.callbackExpirationTime===b&&e>=d)return;c!==Qg&&Rg(c)}a.callbackExpirationTime=b;a.callbackPriority=d;b=1073741823===b?Og(Te.bind(null,a)):Ng(d,Lh.bind(null,a),{timeout:10*(1073741821-b)-Y()});a.callbackNode=b}}}function Lh(a,b){dd=0;if(b)return b=ka(),Ue(a,b),V(a),null;var c=fd(a);if(0!==c){b=a.callbackNode;if((p&(ca|ma))!==H)throw Error(k(327)); -xb();a===U&&c===P||$a(a,c);if(null!==t){var d=p;p|=ca;var e=Mh();do try{rj();break}catch(h){Nh(a,h)}while(1);le();p=d;gd.current=e;if(F===hd)throw b=id,$a(a,c),Ya(a,c),V(a),b;if(null===t)switch(e=a.finishedWork=a.current.alternate,a.finishedExpirationTime=c,d=F,U=null,d){case Xa:case hd:throw Error(k(345));case Oh:Ue(a,2=c){a.lastPingedTime= -c;$a(a,c);break}}f=fd(a);if(0!==f&&f!==c)break;if(0!==d&&d!==c){a.lastPingedTime=d;break}a.timeoutHandle=We(ab.bind(null,a),e);break}ab(a);break;case bd:Ya(a,c);d=a.lastSuspendedTime;c===d&&(a.nextKnownPendingLevel=Ve(e));if(jd&&(e=a.lastPingedTime,0===e||e>=c)){a.lastPingedTime=c;$a(a,c);break}e=fd(a);if(0!==e&&e!==c)break;if(0!==d&&d!==c){a.lastPingedTime=d;break}1073741823!==Yb?d=10*(1073741821-Yb)-Y():1073741823===ta?d=0:(d=10*(1073741821-ta)-5E3,e=Y(),c=10*(1073741821-c)-e,d=e-d,0>d&&(d=0),d= -(120>d?120:480>d?480:1080>d?1080:1920>d?1920:3E3>d?3E3:4320>d?4320:1960*sj(d/1960))-d,c=d?d=0:(e=g.busyDelayMs|0,f=Y()-(10*(1073741821-f)-(g.timeoutMs|0||5E3)),d=f<=e?0:e+d-f);if(10 component higher in the tree to provide a loading indicator or placeholder to display."+ -Bd(g))}F!==Xe&&(F=Oh);h=Le(h,g);k=f;do{switch(k.tag){case 3:m=h;k.effectTag|=4096;k.expirationTime=b;var A=Ih(k,m,b);Ug(k,A);break a;case 1:m=h;var u=k.type,B=k.stateNode;if(0===(k.effectTag&64)&&("function"===typeof u.getDerivedStateFromError||null!==B&&"function"===typeof B.componentDidCatch&&(null===La||!La.has(B)))){k.effectTag|=4096;k.expirationTime=b;var H=Jh(k,m,b);Ug(k,H);break a}}k=k.return}while(null!==k)}t=Sh(t)}catch(cj){b=cj;continue}break}while(1)}function Mh(a){a=gd.current;gd.current= -Tc;return null===a?Tc:a}function Vg(a,b){aXb&&(Xb=a)}function tj(){for(;null!==t;)t=Th(t)}function rj(){for(;null!==t&&!yj();)t=Th(t)}function Th(a){var b=zj(a.alternate,a,P);a.memoizedProps=a.pendingProps;null===b&&(b=Sh(a));Uh.current=null;return b}function Sh(a){t=a;do{var b=t.alternate;a=t.return;if(0===(t.effectTag&2048)){b=hj(b,t,P);if(1===P||1!==t.childExpirationTime){for(var c=0,d=t.child;null!==d;){var e=d.expirationTime, -f=d.childExpirationTime;e>c&&(c=e);f>c&&(c=f);d=d.sibling}t.childExpirationTime=c}if(null!==b)return b;null!==a&&0===(a.effectTag&2048)&&(null===a.firstEffect&&(a.firstEffect=t.firstEffect),null!==t.lastEffect&&(null!==a.lastEffect&&(a.lastEffect.nextEffect=t.firstEffect),a.lastEffect=t.lastEffect),1a?b:a}function ab(a){var b=Cc();Da(99,Aj.bind(null,a,b));return null}function Aj(a,b){do xb();while(null!==Zb);if((p&(ca|ma))!==H)throw Error(k(327));var c=a.finishedWork,d=a.finishedExpirationTime;if(null===c)return null;a.finishedWork=null;a.finishedExpirationTime=0;if(c===a.current)throw Error(k(177));a.callbackNode=null;a.callbackExpirationTime= -0;a.callbackPriority=90;a.nextKnownPendingLevel=0;var e=Ve(c);a.firstPendingTime=e;d<=a.lastSuspendedTime?a.firstSuspendedTime=a.lastSuspendedTime=a.nextKnownPendingLevel=0:d<=a.firstSuspendedTime&&(a.firstSuspendedTime=d-1);d<=a.lastPingedTime&&(a.lastPingedTime=0);d<=a.lastExpiredTime&&(a.lastExpiredTime=0);a===U&&(t=U=null,P=0);1h&&(n=h,h=g,g=n),n=ig(x,g),q=ig(x,h),n&&q&&(1!==u.rangeCount||u.anchorNode!==n.node||u.anchorOffset!==n.offset||u.focusNode!==q.node||u.focusOffset!==q.offset)&&(A=A.createRange(), -A.setStart(n.node,n.offset),u.removeAllRanges(),g>h?(u.addRange(A),u.extend(q.node,q.offset)):(A.setEnd(q.node,q.offset),u.addRange(A))))));A=[];for(u=x;u=u.parentNode;)1===u.nodeType&&A.push({element:u,left:u.scrollLeft,top:u.scrollTop});"function"===typeof x.focus&&x.focus();for(x=0;x=b&&a<=b}function Ya(a,b){var c=a.firstSuspendedTime,d=a.lastSuspendedTime; -cb||0===c)a.lastSuspendedTime=b;b<=a.lastPingedTime&&(a.lastPingedTime=0);b<=a.lastExpiredTime&&(a.lastExpiredTime=0)}function yh(a,b){b>a.firstPendingTime&&(a.firstPendingTime=b);var c=a.firstSuspendedTime;0!==c&&(b>=c?a.firstSuspendedTime=a.lastSuspendedTime=a.nextKnownPendingLevel=0:b>=a.lastSuspendedTime&&(a.lastSuspendedTime=b+1),b>a.nextKnownPendingLevel&&(a.nextKnownPendingLevel=b))}function Ue(a,b){var c=a.lastExpiredTime;if(0===c||c>b)a.lastExpiredTime=b} -function md(a,b,c,d){var e=b.current,f=ka(),g=Vb.suspense;f=Va(f,e,g);a:if(c){c=c._reactInternalFiber;b:{if(Na(c)!==c||1!==c.tag)throw Error(k(170));var h=c;do{switch(h.tag){case 3:h=h.stateNode.context;break b;case 1:if(N(h.type)){h=h.stateNode.__reactInternalMemoizedMergedChildContext;break b}}h=h.return}while(null!==h);throw Error(k(171));}if(1===c.tag){var m=c.type;if(N(m)){c=Gg(c,m,h);break a}}c=h}else c=Ca;null===b.context?b.context=c:b.pendingContext=c;b=Ea(f,g);b.payload={element:a};d=void 0=== -d?null:d;null!==d&&(b.callback=d);Fa(e,b);Ja(e,f);return f}function cf(a){a=a.current;if(!a.child)return null;switch(a.child.tag){case 5:return a.child.stateNode;default:return a.child.stateNode}}function Wh(a,b){a=a.memoizedState;null!==a&&null!==a.dehydrated&&a.retryTime