mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-03 16:00:19 +00:00
* ci: split the patch guards by kind, and run memory growth on every PR Patch guards: one 11-minute job ran all 26 guards and the Playwright skiplist audit. Whether the patches apply is the build's check; the guards test that what they do still works, and fall into three kinds, now three jobs beside the skiplist audit: spoofing a spoofed value still reaches the page and holds together automation Playwright stays invisible to the page and never deadlocks it parity what a page, or the OS, can observe matches stock Firefox Each writes its own suite (patch_guards_<group>), so a failure names the kind that broke. GROUPS in ci/run_patch_guards.py assigns every guard to exactly one, and a self-test fails on a guard in none. One job id with a matrix, so everything that needs patch-guards is unchanged. Memory growth: ~38 minutes in one process kept it on the schedule and out of the gate. ci.run_native --shard i/n runs every n-th collected test, and the growth job is a 7-way matrix -- one test per runner, about six minutes each -- on every pull request, required by the summary and the gate. summarize.py already folds <suite>-<i>of<n> results back into one suite, as it does for Playwright. CONTRIBUTING.md now says why the stealth check skips on a fork pull request: GitHub gives secrets only to branches in this repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(release): publish to npm after PyPI, from the same commit The two launchers ship at one version, but were released by two unrelated, hand-started workflows, so npm could get a release PyPI did not. "Publish to pypi" is now the one place a release starts: 1. it calls publish-npm.yml as a dry run -- every check, the build, the pack check and `npm publish --dry-run` -- so a broken npm package stops the release before anything is uploaded; 2. it uploads to PyPI; 3. its success triggers publish-npm.yml (workflow_run), which publishes the commit PyPI was released from. publish-npm.yml stays the file that publishes, because npm's trusted publisher is tied to its name. Started by hand it only retries the npm half, and refuses unless PyPI already has the version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(guards): contentaccessible-parity reads its probe's marked result line The live probe runs in a child process and the parent parsed its whole stdout as JSON. On a machine whose cache has no addons yet, the first Camoufox launch downloads uBlock Origin and prints its progress to stdout first, so the parse failed ("Expecting value: line 2 column 1"). It only ever passed because another guard launched Camoufox earlier in the same job; split into its own leg, it ran first on a fresh runner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(guards): group the three guards main added since the split addons-install-once, viewport-no-rdm and worker-config-reads landed on main after the groups were drawn; the one-group-each self-test caught them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(guards): gfx-probes gives a blocklisted launch a second try The blocklist signature means gfxInfo is empty. Missing probes cause that on every launch; a present glxtest that fails or times out on a loaded runner causes it once in a while, which failed stock parity on this PR. Relaunch once before failing, and print what the probes wrote to stderr. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
#!/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 Dict, List, Optional, Tuple
|
|
|
|
from . import results
|
|
from ._pytest import parse_junit, run_pytest
|
|
from ._util import REPO_ROOT, RESULTS_DIR, WORK_DIR, run
|
|
|
|
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 -- ~38 minutes in one
|
|
# process. Kept out of "browser" and run with --shard, one test per
|
|
# runner, so a pull request waits minutes for it rather than most of an hour.
|
|
"growth": ["test_memory_growth.py"],
|
|
}
|
|
|
|
|
|
def parse_shard(text: str) -> Tuple[int, int]:
|
|
"""'3/7' -> (3, 7), refusing anything that would silently run nothing."""
|
|
index, _, count = text.partition("/")
|
|
i, n = int(index), int(count)
|
|
if not 1 <= i <= n:
|
|
raise SystemExit(f"--shard {text}: want i/n with 1 <= i <= n")
|
|
return i, n
|
|
|
|
|
|
def collect(files: List[str], env: Dict[str, str]) -> List[str]:
|
|
"""The node ids pytest would run for `files`, in its order."""
|
|
proc = run(
|
|
[sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider", *files],
|
|
cwd=SUITE_DIR, env=env, timeout=300,
|
|
)
|
|
return [line.strip() for line in proc.stdout.splitlines() if "::" in line]
|
|
|
|
|
|
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)
|
|
parser.add_argument("--shard", help="run every n-th collected test, e.g. 3/7")
|
|
args = parser.parse_args(argv)
|
|
|
|
name = "native" if args.subset == "all" else f"native_{args.subset}"
|
|
shard = parse_shard(args.shard) if args.shard else None
|
|
if shard:
|
|
# summarize.py folds <name>-<i>of<n> back into <name>.
|
|
name = f"{name}-{shard[0]}of{shard[1]}"
|
|
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)
|
|
|
|
if shard:
|
|
ids = collect(files, env)
|
|
if not ids:
|
|
result.note("collected no tests; the suite did not run")
|
|
result.finish(results.ERROR).save(args.results_dir)
|
|
return 1
|
|
files = ids[shard[0] - 1 :: shard[1]]
|
|
result.metrics["shard"] = f"{shard[0]}/{shard[1]}"
|
|
if not files:
|
|
result.note(f"shard {shard[0]}/{shard[1]} has no tests of the {len(ids)} collected")
|
|
result.finish(results.PASS).save(args.results_dir)
|
|
return 0
|
|
|
|
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())
|