Repository navigation
Expand file tree
/
Copy pathtasks.py
More file actions
2300 lines (1999 loc) · 83.5 KB
/
Copy pathtasks.py
File metadata and controls
2300 lines (1999 loc) · 83.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from contextlib import suppress
import hashlib
import json
import os
import platform
import re
import shutil
import struct
import subprocess
import sys
import sysconfig
import tarfile
import tempfile
import time
import tomllib
import traceback
import urllib.request
import zipfile
import signal
from collections.abc import Iterable
from pathlib import Path, PurePath
from typing import NamedTuple
from invoke import Exit, task
from invoke.context import Context
try:
import bs4
import pytest
import robotstatuschecker
from rellu import ReleaseNotesGenerator, Version
from robot import __version__ as rf_version
from robot import rebot_cli
from robot.libdoc import libdoc
from robot import version as robot_version_module
except ModuleNotFoundError:
traceback.print_exc()
print('Assuming that this is for "inv deps" command and ignoring error.')
ROOT_DIR = Path(os.path.dirname(__file__))
ATEST_LIB_DIR = ROOT_DIR / "atest" / "library"
ATEST_OUTPUT = ROOT_DIR / "atest" / "output"
UTEST_OUTPUT = ROOT_DIR / "utest" / "output"
COVERAGE_DIR = ROOT_DIR / "coverage"
JEST_LCOV = ROOT_DIR / "node" / "coverage" / "lcov.info"
ATEST_NODE_LCOV = ATEST_OUTPUT / "node-coverage-report" / "lcov.info"
DIST_DIR = ROOT_DIR / "dist"
BUILD_DIR = ROOT_DIR / "build"
BROWSER_BATTERIES_DIR = ROOT_DIR / "browser_batteries"
BROWSER_BATTERIES_BIN_DIR = BROWSER_BATTERIES_DIR / "BrowserBatteries" / "bin"
# The NodeJS we ship and the platform floors its wheel tags promise. They live
# in a file of their own so the bot in .github/workflows/on-schedule.yml can
# rewrite them without touching code, and nodejs_pin.toml is where each of them
# is explained.
NODE_PIN_FILE = ROOT_DIR / "nodejs_pin.toml"
NODE_PIN = tomllib.loads(NODE_PIN_FILE.read_text(encoding="utf-8"))
NODE_VERSION = NODE_PIN["version"]
NODE_SHASUMS_SHA256 = NODE_PIN["shasums_sha256"]
NODE_MIN_GLIBC = NODE_PIN["min_glibc"]
NODE_MIN_MACOS = NODE_PIN["min_macos"]
NODE_DIST_BASE = "https://nodejs.org/dist"
NODE_DIST_INDEX = f"{NODE_DIST_BASE}/index.json"
GITHUB_API = "https://api.github.com/repos/MarketSquare/robotframework-browser"
# The release the nightly wheels are downloaded from. It is created once and
# then only ever has its assets replaced, because publishing a release is what
# notifies everybody watching the repository and a build of main is not news.
NIGHTLY_TAG = "nightly"
RELEASE_PROCESS = (
"Raise an issue and add it to the release milestone so this reaches the "
"release notes, then close it once the PR is merged."
)
# Every target a BrowserBatteries wheel is published for, named the way
# nodejs.org names it. `inv node-floor-check` walks the list from one machine to
# check min_glibc and min_macos against the binaries themselves, and
# `inv node-pin-bump` walks the same list to work out what they should be.
# win-x64 is missing on purpose: a win_amd64 wheel tag carries no OS version, so
# there is nothing about it to get wrong.
NODE_FLOOR_TARGETS = ("linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64")
# Read out of the NodeJS binaries to check those constants. A versioned glibc
# symbol looks like GLIBC_2.28 in the ELF dynamic string table, and a Mach-O
# states its oldest macOS in one of two load commands.
GLIBC_SYMBOL = re.compile(rb"GLIBC_(\d+)\.(\d+)")
MACHO_MAGIC_64 = 0xFEEDFACF
LC_VERSION_MIN_MACOSX = 0x24
LC_BUILD_VERSION = 0x32
SKIP_BROWSER_DOWNLOAD = "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD"
PYTHON_SRC_DIR = ROOT_DIR / "Browser"
python_protobuf_dir = PYTHON_SRC_DIR / "generated"
WRAPPER_DIR = PYTHON_SRC_DIR / "wrapper"
node_protobuf_dir = ROOT_DIR / "node" / "playwright-wrapper" / "generated"
node_dir = ROOT_DIR / "node"
NODE_MODULES = ROOT_DIR / "node_modules"
npm_deps_timestamp_file = NODE_MODULES / ".installed"
ATEST_TIMEOUT = 900
ATEST_ROBOT_TIMEOUT = 1500
cpu_count = os.cpu_count() or 1
EXECUTOR_COUNT = str(cpu_count - 1 or 1)
IN_CI = os.getenv("GITHUB_WORKFLOW")
IS_GITPOD = "gitpod.io" in os.environ.get("GITPOD_HOST", "")
ZIP_DIR = ROOT_DIR / "zip_results"
RELEASE_NOTES_PATH = Path("docs/releasenotes/Browser-{version}.md")
RELEASE_NOTES_TITLE = "Browser library {version}"
REPOSITORY = "MarketSquare/robotframework-browser"
VERSION_PATH = Path("Browser/version.py")
RELEASE_NOTES_INTRO = """
[Browser](https://github.com/MarketSquare/robotframework-browser) is a web testing
library for [Robot Framework](http://robotframework.org) that utilizes the
[Playwright](https://github.com/microsoft/playwright) tool internally. Browser
library {version} is a new release with **UPDATE** enhancements and bug fixes.
All issues targeted for Browser library {version.milestone} can be found
from the
[issue tracker](https://github.com/MarketSquare/robotframework-browser/issues?q=state%3Aclosed%20milestone%3A{version.milestone}).
For first time installation with [pip](https://pip.pypa.io/en/stable/) and
[BrowserBatteries](https://pypi.org/project/robotframework-browser-batteries/)
just run
```bash
pip install robotframework-browser robotframework-browser-batteries
rfbrowser install
```
to install the latest available release. If you upgrading
from previous release with [pip](http://pip-installer.org), run
```bash
pip install --upgrade robotframework-browser robotframework-browser-batteries
rfbrowser clean-node
rfbrowser install
```
For first time installation with [pip](http://pip-installer.org) with Browser
library only, just run
```bash
pip install robotframework-browser
rfbrowser init
```
If you upgrading from previous release with [pip](http://pip-installer.org), run
```bash
pip install --upgrade robotframework-browser
rfbrowser clean-node
rfbrowser init
```
Alternatively you can download the source distribution from
[PyPI](https://pypi.org/project/robotframework-browser/) and
install it manually. Browser library {version} was released on {date}.
Browser supports Python 3.11+, Node 22/24 LTS and Node 26, and Robot Framework 7.1.1+.
Library was tested with Playwright REPLACE_PW_VERSION. BrowserBatteries package was
released with NodeJS REPLACE_BB_NODE_VERSION.
"""
def _node_deps(context: Context):
context.run(
"npm install --parseable true --progress false",
env={"PLAYWRIGHT_BROWSERS_PATH": "0"},
)
if os.environ.get(SKIP_BROWSER_DOWNLOAD):
print(f"{SKIP_BROWSER_DOWNLOAD} is set, skipping browser binaries.")
else:
context.run(
"npx --quiet playwright install --with-deps",
env={"PLAYWRIGHT_BROWSERS_PATH": "0"},
)
npm_deps_timestamp_file.touch()
@task
def deps(c, system=False, force=False, uv=False):
"""Install dependencies for development.
Args:
system: When set, installs packages to system Python instead of user.
force: When set, installs dependencies even there is no changes.
uv: When set, skips pip install step assuming uv is already installed.
"""
try:
c.run("pip --version", hide=True)
except Exception as error:
if "Encountered a bad command exit code" in str(error):
uv = True
if uv:
print("No pip install.")
else:
c.run("pip install -U pip")
c.run("pip install -U uv")
print("Installing dev dependencies.")
package_manager_dev_cmd = f"uv pip install -r Browser/dev-requirements.txt{' --system' * (system or IS_GITPOD)}"
package_manager_deps_cmd = (
f"uv pip install -r pyproject.toml{' --system' * (system or IS_GITPOD)}"
)
if IN_CI:
print(f"Install packages to Python found from {sys.executable}.")
package_manager_dev_cmd = f"{package_manager_dev_cmd} --python {sys.executable}"
package_manager_deps_cmd = (
f"{package_manager_deps_cmd} --python {sys.executable}"
)
c.run(package_manager_dev_cmd)
print("Install package dependencies.")
c.run(package_manager_deps_cmd)
if IN_CI:
shutil.rmtree(str(NODE_MODULES), ignore_errors=True)
if _sources_changed([ROOT_DIR / "./package-lock.json"], npm_deps_timestamp_file):
print("Installing node dependencies.")
_node_deps(c)
elif force:
print("Forcing to install node dependencies.")
_node_deps(c)
else:
print("no changes in package-lock.json, skipping npm install")
@task
def clean_mini(c):
"""Cleans only build and test artifacts."""
for target in [
DIST_DIR,
BUILD_DIR,
UTEST_OUTPUT,
ATEST_OUTPUT,
ZIP_DIR,
BROWSER_BATTERIES_BIN_DIR,
BROWSER_BATTERIES_DIR / "dist",
BROWSER_BATTERIES_DIR / "build",
Path("./playwright-log.txt"),
PYTHON_SRC_DIR / "rfbrowser.log",
]:
if target.exists():
shutil.rmtree(target, ignore_errors=True)
@task(clean_mini)
def clean(c):
"""Cleans build artifacts and temporary files."""
for target in [
python_protobuf_dir,
node_protobuf_dir,
Path("./htmlcov"),
COVERAGE_DIR,
Path("./.mypy_cache"),
PYTHON_SRC_DIR / "wrapper",
BROWSER_BATTERIES_DIR / "build",
]:
if target.exists():
shutil.rmtree(target)
for file in [
npm_deps_timestamp_file,
Path("./.coverage"),
*ROOT_DIR.glob(".coverage.*"),
Path("./.ruff_cache"),
Path("./.pytest_cache"),
]:
with suppress(OSError):
file.unlink()
@task
def protobuf(c):
"""Compile grpc protobuf files."""
if not python_protobuf_dir.exists():
python_protobuf_dir.mkdir()
(python_protobuf_dir / "__init__.py").touch()
if not node_protobuf_dir.exists():
node_protobuf_dir.mkdir()
_python_protobuf_gen(c)
_node_protobuf_gen(c)
def _python_protobuf_gen(c):
c.run(
f"python -m grpc_tools.protoc -I protobuf --python_out=Browser/generated --grpc_python_out={python_protobuf_dir} --mypy_out={python_protobuf_dir} protobuf/*.proto"
)
genfile = python_protobuf_dir / "playwright_pb2_grpc.py"
content = (
open(genfile)
.read()
.replace(
"import playwright_pb2 as playwright__pb2",
"from Browser.generated import playwright_pb2 as playwright__pb2",
)
)
with open(genfile, "w") as outfile:
outfile.write(content)
def _node_protobuf_gen(c):
plugin_suffix = ".cmd" if platform.platform().startswith("Windows") else ""
protoc_ts_proto_plugin = (
NODE_MODULES / ".bin" / f"protoc-gen-ts_proto{plugin_suffix}"
)
cmd = (
"npm run grpc_tools_node_protoc -- "
f"--plugin=protoc-gen-ts_proto={protoc_ts_proto_plugin} "
f"--ts_proto_out={node_protobuf_dir} "
"--ts_proto_opt=outputServices=grpc-js,env=node "
"-I ./protobuf protobuf/*.proto"
)
c.run(cmd)
def _gen_stub(c: Context):
shutil.rmtree("mypy_stub/", ignore_errors=True)
Path("Browser/browser.pyi").unlink(missing_ok=True)
c.run("stubgen --output mypy_stub Browser")
c.run("python -m Browser.gen_stub")
@task(protobuf)
def node_build(c: Context):
c.run("npm run build")
shutil.rmtree(WRAPPER_DIR / "static", ignore_errors=True)
shutil.copytree(node_dir / "playwright-wrapper" / "static", WRAPPER_DIR / "static")
_gen_stub(c)
@task
def create_test_app(c):
c.run("npm run build-test-app")
@task(deps, protobuf, node_build, create_test_app)
def build(c: Context):
_gen_stub(c)
def _os_platform() -> str:
pl = platform.system().lower()
if pl == "darwin":
return "macos"
if pl == "windows":
return "win"
return "linux"
def _node_dist_infix() -> str:
"""Name nodejs.org uses for this machine, as in node-v<version>-<infix>.
Always the machine running the build. NodeJS is downloaded prebuilt and the
wheel is tagged from `sysconfig.get_platform()`, so building for anything
but the host would produce a wheel whose own tag says it fits where its
NodeJS cannot run. Every published target has its own CI runner.
`platform.machine()` reports the machine name, which is not what nodejs.org
calls the same architecture.
"""
machine = platform.machine().lower()
arch = {"x86_64": "x64", "amd64": "x64", "aarch64": "arm64"}.get(machine, machine)
os_platform = _os_platform()
if os_platform == "macos":
return f"darwin-{arch}"
if os_platform == "win":
return f"win-{arch}"
return f"linux-{arch}"
DOWNLOAD_ATTEMPTS = 5
DOWNLOAD_TIMEOUT = 60
def _download(url: str, destination: Path):
"""Download url to destination, retrying a few times before giving up.
Every build fetches the NodeJS archive and its checksums from nodejs.org,
on each platform in the matrix, so a single hiccup there would otherwise
fail a build for reasons that have nothing to do with the change.
"""
last_error: Exception = RuntimeError("no attempt made")
for attempt in range(1, DOWNLOAD_ATTEMPTS + 1):
try:
print(f"Downloading {url}")
with (
urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT) as response,
destination.open("wb") as out,
):
shutil.copyfileobj(response, out)
except Exception as error:
last_error = error
print(f" attempt {attempt} of {DOWNLOAD_ATTEMPTS} failed: {error}")
if attempt < DOWNLOAD_ATTEMPTS:
time.sleep(attempt)
else:
return
raise Exit(f"Failed to download {url}: {last_error}")
def _shasums_digest(version: str) -> str:
"""sha256 of the SHASUMS256.txt nodejs.org publishes for a release."""
with tempfile.TemporaryDirectory() as tmp:
checksums = Path(tmp) / "SHASUMS256.txt"
_download(f"{NODE_DIST_BASE}/v{version}/SHASUMS256.txt", checksums)
return hashlib.sha256(checksums.read_bytes()).hexdigest()
def _verify_shasums(checksums: Path, version: str, expected: str):
"""Check SHASUMS256.txt itself against the digest we expect for a release.
Has to happen before anything trusts the manifest, because on its own the
manifest only proves the archive arrived intact from whoever served it.
Everything but `inv node-pin-bump` passes the pinned version and digest;
the bump passes the candidate it is about to propose, so a release it has
never shipped is held to the same standard.
"""
digest = hashlib.sha256(checksums.read_bytes()).hexdigest()
if digest == expected:
print(f"SHASUMS256.txt matches the expected digest for NodeJS {version}")
return
raise Exit(
f"SHASUMS256.txt for NodeJS {version} hashes to {digest}, but "
f"{expected} was expected.\n"
f"If the version in {NODE_PIN_FILE.name} was just changed, set "
f"shasums_sha256 to {digest} there.\n"
"If it was not, stop: the manifest is not the one nodejs.org published "
"for this version, and the archive it vouches for must not be shipped "
"until that is explained."
)
def _verify_checksum(archive: Path, checksums: str):
"""Check the archive against nodejs.org's SHASUMS256.txt."""
expected = None
for line in checksums.splitlines():
digest, _, name = line.partition(" ")
if name.strip() == archive.name:
expected = digest
break
if not expected:
raise Exit(f"{archive.name} is not listed in SHASUMS256.txt")
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
if digest != expected:
raise Exit(f"Checksum mismatch for {archive.name}: {digest} != {expected}")
print(f"Checksum verified for {archive.name}")
def _extract_member(source, destination: Path):
if source is None:
raise Exit(f"Could not read {destination.name} from the NodeJS archive")
with source, destination.open("wb") as out:
shutil.copyfileobj(source, out)
def _elf_section(data: bytes, wanted: str) -> bytes:
"""Contents of a named section of an ELF64 file."""
sh_offset, sh_entry_size, sh_count, sh_str_index = struct.unpack_from(
"<Q10xHHH", data, 0x28
)
headers = [
struct.unpack_from("<IIQQQQ", data, sh_offset + index * sh_entry_size)
for index in range(sh_count)
]
names_offset = headers[sh_str_index][4]
for name_offset, _type, _flags, _addr, offset, size in headers:
start = names_offset + name_offset
if data[start : data.index(b"\0", start)].decode() == wanted:
return data[offset : offset + size]
raise Exit(f"Found no {wanted} section in the ELF file")
def _glibc_floor(binary: Path) -> tuple:
"""Oldest glibc an ELF binary can load against, from its symbol versions."""
# The dynamic string table only, never the whole file. NodeJS carries a copy
# of its own JavaScript inside the executable, and a stray "GLIBC_2.99" in
# there would fail a build over nothing.
table = _elf_section(binary.read_bytes(), ".dynstr")
versions = {
(int(major), int(minor)) for major, minor in GLIBC_SYMBOL.findall(table)
}
if not versions:
raise Exit(f"{binary.name} references no versioned glibc symbols")
return max(versions)
def _macos_floor(binary: Path) -> tuple:
"""Oldest macOS a Mach-O binary declares it runs on, as (major, minor)."""
data = binary.read_bytes()
(magic,) = struct.unpack_from("<I", data, 0)
if magic != MACHO_MAGIC_64:
raise Exit(f"{binary.name} is not a thin 64 bit Mach-O file")
(command_count,) = struct.unpack_from("<I", data, 16)
offset = 32
for _ in range(command_count):
command, size = struct.unpack_from("<II", data, offset)
# Both commands carry the same packed xxxx.yy.zz version, the newer one
# behind a platform field.
if command in (LC_BUILD_VERSION, LC_VERSION_MIN_MACOSX):
version_at = offset + (12 if command == LC_BUILD_VERSION else 8)
(version,) = struct.unpack_from("<I", data, version_at)
return (version >> 16, (version >> 8) & 0xFF)
offset += size
raise Exit(f"{binary.name} declares no minimum macOS version")
def _check_node_floor(binary: Path, infix: str) -> tuple:
"""Check a NodeJS binary against the floor its wheel tag will promise.
That tag is all that stands between a user on too old a platform and a
loader error after an install that looked fine, and nothing else in the
build reads what the binary actually needs. Returns a line for the report
and a problem, the problem being empty when the declared floor is right.
"""
if infix.startswith("win-"):
return f"* {infix}: wheel tag makes no OS version claim, nothing to check", ""
if infix.startswith("linux-"):
actual = _glibc_floor(binary)
declared = tuple(int(part) for part in NODE_MIN_GLIBC.split("_"))
if actual > declared:
return "", (
f"{infix}: NodeJS needs glibc {_as_version_string(actual)} but "
f"min_glibc promises {_as_version_string(declared)}. Everyone "
"in between installs the wheel and then cannot start it. Raise "
f"min_glibc in {NODE_PIN_FILE.name} and fix the platform table "
"in browser_batteries/README.md to match."
)
if actual < declared:
return "", (
f"{infix}: NodeJS needs no more than glibc "
f"{_as_version_string(actual)}, but min_glibc promises "
f"{_as_version_string(declared)}, so pip refuses the wheel on "
f"platforms it would have run on. Lower min_glibc in "
f"{NODE_PIN_FILE.name} and fix the platform table in "
"browser_batteries/README.md to match."
)
return f"* {infix}: needs glibc {_as_version_string(actual)}, matches", ""
actual = _macos_floor(binary)
declared = tuple(int(part) for part in NODE_MIN_MACOS.split("_"))
if actual[0] != declared[0] or declared[1] != 0:
return "", (
f"{infix}: NodeJS declares macOS {_as_version_string(actual)} but "
f"min_macos says {_as_version_string(declared)}. Set it to "
f"{actual[0]}_0 in {NODE_PIN_FILE.name} and fix the platform table "
"in browser_batteries/README.md to match."
)
covers = f"min_macos {NODE_MIN_MACOS} covers it"
return f"* {infix}: needs macOS {_as_version_string(actual)}, {covers}", ""
def _fetch_node(
destination: Path,
infix: str | None = None,
version: str | None = None,
shasums_sha256: str | None = None,
) -> Path:
"""Put the official NodeJS binary and its licence into destination.
Defaults to the machine this runs on and to the pinned release. `inv
node-floor-check` passes an infix explicitly, because reading a binary works
from any platform even though running one does not, and `inv node-pin-bump`
also passes a version, because it reads releases we do not ship yet.
"""
infix = infix or _node_dist_infix()
version = version or NODE_VERSION
shasums_sha256 = shasums_sha256 or NODE_SHASUMS_SHA256
windows = infix.startswith("win-")
suffix = "zip" if windows else "tar.xz"
archive_name = f"node-v{version}-{infix}.{suffix}"
base_url = f"{NODE_DIST_BASE}/v{version}"
node_name = "node.exe" if windows else "node"
with tempfile.TemporaryDirectory() as tmp:
archive = Path(tmp) / archive_name
_download(f"{base_url}/{archive_name}", archive)
checksums = Path(tmp) / "SHASUMS256.txt"
_download(f"{base_url}/SHASUMS256.txt", checksums)
_verify_shasums(checksums, version, shasums_sha256)
_verify_checksum(archive, checksums.read_text(encoding="utf-8"))
root = f"node-v{version}-{infix}"
node_member = f"{root}/{node_name}" if windows else f"{root}/bin/{node_name}"
node_binary = destination / node_name
if windows:
with zipfile.ZipFile(archive) as zip_file:
_extract_member(zip_file.open(node_member), node_binary)
_extract_member(
zip_file.open(f"{root}/LICENSE"), destination / "NODE_LICENSE"
)
else:
with tarfile.open(archive) as tar:
_extract_member(tar.extractfile(node_member), node_binary)
_extract_member(
tar.extractfile(f"{root}/LICENSE"), destination / "NODE_LICENSE"
)
node_binary.chmod(0o755)
return node_binary
def _assemble_wrapper(c: Context, destination: Path):
"""Stage the JS the GRPC server runs, with its production dependencies."""
destination.mkdir(parents=True, exist_ok=True)
# Named explicitly: WRAPPER_DIR is also where a development checkout runs
# `rfbrowser init`, so it can hold node_modules, downloaded browsers and log
# files that must not be shipped.
shutil.copy(WRAPPER_DIR / "index.js", destination)
shutil.copytree(WRAPPER_DIR / "static", destination / "static", dirs_exist_ok=True)
shutil.copy(ROOT_DIR / "package.json", destination)
shutil.copy(ROOT_DIR / "package-lock.json", destination)
print(f"Installing production node dependencies to {destination}")
with c.cd(str(destination)):
# The browsers are installed by `rfbrowser install` on the user's
# machine, they must not end up in the wheel.
c.run(
"npm ci --omit=dev --no-audit --no-fund",
env={SKIP_BROWSER_DOWNLOAD: "1"},
)
# npm leaves behind CLI shims and its own lock file that nothing needs at
# runtime, and setuptools does not pick up dot-directories anyway.
for leftover in [
destination / "node_modules" / ".bin",
destination / "node_modules" / ".package-lock.json",
]:
if leftover.is_dir():
shutil.rmtree(leftover, ignore_errors=True)
elif leftover.exists():
leftover.unlink()
def _directory_size(path: Path) -> int:
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
def _as_version(version: str) -> tuple:
return tuple(int(part) for part in version.lstrip("v").split("."))
def _as_version_string(version: tuple) -> str:
return ".".join(str(part) for part in version)
def _report(lines: list, heading: str):
"""Print, and on GitHub Actions also show on the run's summary page."""
report = "\n".join(lines)
print(report)
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as handle:
handle.write(f"### {heading}\n{report}\n")
def _step_output(key: str, value: str):
"""Hand a value to later steps of the same GitHub Actions job."""
output = os.environ.get("GITHUB_OUTPUT")
if output:
with open(output, "a", encoding="utf-8") as handle:
handle.write(f"{key}={value}\n")
def _node_release_state() -> tuple:
"""What nodejs.org offers now, next to what we pin.
Returns the pinned version, the newest release in the line we ship and the
newest LTS line, all as comparable tuples. `inv node-version-check` and
`inv node-pin-bump` both work from this, so they cannot come to different
conclusions about the same day's releases.
"""
with tempfile.TemporaryDirectory() as tmp:
index = Path(tmp) / "index.json"
_download(NODE_DIST_INDEX, index)
listed = json.loads(index.read_text(encoding="utf-8"))
releases = [r for r in listed if "-" not in r["version"]]
pinned = _as_version(NODE_VERSION)
same_line = [r for r in releases if _as_version(r["version"])[0] == pinned[0]]
if not same_line:
raise Exit(f"nodejs.org lists no releases for NodeJS {pinned[0]}.x")
newest = max(_as_version(r["version"]) for r in same_line)
lts_releases = [_as_version(r["version"]) for r in releases if r["lts"]]
return pinned, newest, max(lts_releases) if lts_releases else None
@task
def node_version_check(c):
"""Fail when the NodeJS we ship is no longer the one we should ship.
Two things make the pin stale. A newer release in the line we already ship
is a plain version bump, and the daily `inv node-pin-bump` proposes those as
a pull request rather than leaving them to be noticed. A new LTS line is the
moment to move over, and that is not a bump: the platforms the official
builds run on change with the line, so min_glibc, min_macos and the wheel
tags they produce all have to be worked out again. Both fail here, because
both are work we want to hear about on the day it appears rather than the
next time somebody happens to look.
Always exits non-zero on a stale pin, on every run and everywhere. That
makes it the right check for the release wheel build, where it is the last
step of the job on purpose, so the wheels are built and uploaded before it
can fire. The daily workflow runs `inv node-pin-bump` instead, which is the
same reasoning without the part that would keep a nightly permanently red
while a bump waits to be merged.
"""
pinned, newest, newest_lts = _node_release_state()
lts_report = _as_version_string(newest_lts) if newest_lts else "none listed"
lines = [
f"* shipped in BrowserBatteries: {NODE_VERSION}",
f"* newest NodeJS {pinned[0]}.x: {_as_version_string(newest)}",
f"* newest LTS on nodejs.org: {lts_report}",
]
stale = []
move_to = newest if newest > pinned else None
if newest_lts and newest_lts[0] > pinned[0]:
move_to = max(move_to or newest_lts, newest_lts)
if newest > pinned:
behind = _as_version_string(newest)
lines.append(
f"* version in {NODE_PIN_FILE.name} is behind, update it to {behind} "
"and let CI rebuild the wheels."
)
stale.append(f"NodeJS {NODE_VERSION} is behind {behind}.")
if newest_lts and newest_lts[0] > pinned[0]:
lines.append(
f"* NodeJS {newest_lts[0]}.x is the new LTS line and BrowserBatteries "
f"still ships {pinned[0]}.x. Moving over is more than a version "
f"bump: check which platforms the official {newest_lts[0]}.x builds "
"still run on, then update min_glibc, min_macos and the table in "
"browser_batteries/README.md to match, because those decide who "
"gets a wheel. `inv node-pin-bump` deliberately leaves this alone."
)
stale.append(f"NodeJS {newest_lts[0]}.x is the new LTS line.")
if move_to:
target = _as_version_string(move_to)
lines.append(
f"* shasums_sha256 for {target} is {_shasums_digest(target)}, set "
f"that in {NODE_PIN_FILE.name} in the same commit as version."
)
if stale:
lines.append(f"* {RELEASE_PROCESS}")
_report(lines, "NodeJS version check")
raise Exit(f"{' '.join(stale)} {RELEASE_PROCESS}")
lines.append("* up to date, nothing to do.")
_report(lines, "NodeJS version check")
def _derive_pin(version: str) -> dict:
"""Work out the whole pin for a NodeJS release nobody has shipped yet.
Downloads every target a wheel is published for and reads the floors out of
the binaries, which is the same thing `inv node-floor-check` does to the
release we already ship. The digest comes first because every download is
checked against it, so a proposed bump is held to the standard the pinned
version is held to rather than trusting whatever nodejs.org served today.
"""
digest = _shasums_digest(version)
floors = {}
with tempfile.TemporaryDirectory() as tmp:
for infix in NODE_FLOOR_TARGETS:
target = Path(tmp) / infix
target.mkdir()
binary = _fetch_node(target, infix, version, digest)
floors[infix] = (
_glibc_floor(binary)
if infix.startswith("linux-")
else _macos_floor(binary)
)
# One value has to cover every architecture, because that is all the wheel
# tags can say. They agree today, and _check_node_floor demands each target
# match exactly, so a release that split them could not be expressed at all.
# Saying so is better than picking one and shipping a tag that is wrong for
# somebody: fixing it properly means per-architecture values here and in
# package-nodejs, which is not work to do speculatively.
linux = {i: f for i, f in floors.items() if i.startswith("linux-")}
darwin = {i: f[0] for i, f in floors.items() if i.startswith("darwin-")}
for name, derived in (("min_glibc", linux), ("min_macos", darwin)):
if len(set(derived.values())) > 1:
spread = ", ".join(f"{i} needs {f}" for i, f in sorted(derived.items()))
raise Exit(
f"NodeJS {version} does not have one {name} across its targets: "
f"{spread}. A single wheel-tag value cannot describe that, so "
f"this bump needs {NODE_PIN_FILE.name} and package_nodejs to "
f"carry a value per architecture first. {RELEASE_PROCESS}"
)
return {
"version": version,
"shasums_sha256": digest,
"min_glibc": "_".join(str(part) for part in next(iter(linux.values()))),
# NodeJS targets 13.5, pip only generates macosx_<major>_0_* tags.
"min_macos": f"{next(iter(darwin.values()))}_0",
}
def _write_pin(pin: dict) -> list:
"""Set the derived values in the pin file, leaving everything else alone.
Substitutes the assignments line by line rather than re-serialising the
parsed document, because the comments in that file are the only explanation
of what the values mean and no stdlib writer preserves them.
"""
text = NODE_PIN_FILE.read_text(encoding="utf-8")
changed = []
for key, value in pin.items():
if NODE_PIN[key] == value:
continue
text, count = re.subn(
rf'^{key} = ".*"$',
lambda _match, k=key, v=value: f'{k} = "{v}"',
text,
flags=re.MULTILINE,
)
if count != 1:
raise Exit(
f"Expected exactly one '{key} = ...' line in "
f"{NODE_PIN_FILE.name}, found {count}. Not writing anything."
)
changed.append(f"* {key}: {NODE_PIN[key]} -> {value}")
if changed:
NODE_PIN_FILE.write_text(text, encoding="utf-8")
return changed
@task
def node_pin_bump(c, write=False, report=None):
"""Propose the newest NodeJS in the line we already ship.
The daily workflow runs this with --write and turns the result into a pull
request, so a bump arrives already carrying its digest and platform floors,
all of them read off the official binaries rather than typed in. Without
--write it only reports, which is what the release checklist wants.
A stale pin is not a failure here, because the pull request is the signal
and a nightly that stays red until somebody merges it teaches people to
ignore red. A new LTS line still fails: that changes which platforms get a
wheel at all, and it is not a thing to rubber-stamp.
Args:
write: Write the derived values to nodejs_pin.toml.
report: Also write the report as markdown to this path, for a PR body.
"""
pinned, newest, newest_lts = _node_release_state()
lines = [
f"* shipped in BrowserBatteries: {NODE_VERSION}",
f"* newest NodeJS {pinned[0]}.x: {_as_version_string(newest)}",
]
changed = []
if newest > pinned:
candidate = _as_version_string(newest)
lines.append(f"* deriving the pin for NodeJS {candidate}")
changed = _write_pin(_derive_pin(candidate)) if write else []
lines.extend(changed or [f"* run with --write to update {NODE_PIN_FILE.name}"])
_step_output("version", candidate)
else:
lines.append("* up to date, nothing to bump.")
lts_stale = newest_lts and newest_lts[0] > pinned[0]
if lts_stale:
lines.append(
f"* NodeJS {newest_lts[0]}.x is the new LTS line and BrowserBatteries "
f"still ships {pinned[0]}.x. That is left to a human: it changes the "
"wheel tags, so min_glibc, min_macos and the platform table in "
"browser_batteries/README.md all have to be worked out again."
)
lines.append(f"* {RELEASE_PROCESS}")
_report(lines, "NodeJS pin bump")
if report:
Path(report).write_text("\n".join(lines) + "\n", encoding="utf-8")
if lts_stale:
raise Exit(f"NodeJS {newest_lts[0]}.x is the new LTS line. {RELEASE_PROCESS}")
@task
def node_floor_check(c):
"""Check min_glibc and min_macos against every NodeJS we ship.
Runs anywhere, Linux or macOS, because it reads the downloaded binaries
rather than running them. A platform's own build only ever checks its own
binary, so a value that is wrong for some other platform would otherwise
surface as a broken install for whoever is on it. The daily workflow runs
this, and so does the release checklist. Windows is not in the list, its
wheel tag promises no OS version.
"""
lines = [f"* NodeJS {NODE_VERSION}"]
problems = []
with tempfile.TemporaryDirectory() as tmp:
for infix in NODE_FLOOR_TARGETS:
target = Path(tmp) / infix
target.mkdir()
line, problem = _check_node_floor(_fetch_node(target, infix), infix)
lines.append(line or f"* {infix}: WRONG, see below")
if problem:
problems.append(problem)
lines.extend(f"* {problem}" for problem in problems)
if problems:
lines.append(f"* {RELEASE_PROCESS}")
_report(lines, "NodeJS platform floor check")
if problems:
raise Exit(
f"{len(problems)} of {len(NODE_FLOOR_TARGETS)} targets disagree. "
f"{RELEASE_PROCESS}"
)
def _build_nodejs(c: Context):
"""Put the official NodeJS binary next to the wrapper sources.
This is the layout Playwright itself uses for its Python, Java and .NET
drivers. Playwright is written against ordinary NodeJS, so anything it does
at run time - loading builtins, resolving paths, forking helper processes -
works the way its own tests expect.
"""
print(f"Assemble NodeJS side to '{BROWSER_BATTERIES_BIN_DIR}'.")
_copy_package_files()
BROWSER_BATTERIES_BIN_DIR.mkdir(parents=True, exist_ok=True)
node_binary = _fetch_node(BROWSER_BATTERIES_BIN_DIR)
c.run(f'"{node_binary}" --version')
line, problem = _check_node_floor(node_binary, _node_dist_infix())
print(line or problem)
if problem:
raise Exit(f"{problem} {RELEASE_PROCESS}")
wrapper = BROWSER_BATTERIES_BIN_DIR / "wrapper"
_assemble_wrapper(c, wrapper)
with c.cd(str(wrapper)):
c.run(f'"{node_binary}" --check index.js')
c.run(
f'"{node_binary}" -e "require.resolve(\'./node_modules/playwright-core\')"'
)
print(
f"NodeJS binary at '{node_binary}' ({node_binary.stat().st_size / 1e6:.1f} MB)"
)
print(f"Wrapper payload at '{wrapper}' ({_directory_size(wrapper) / 1e6:.1f} MB)")
@task(clean, build)
def build_nodejs(c: Context):
"""Assemble the NodeJS side of BrowserBatteries.
Downloads the NodeJS binary for the machine this runs on and stages it with
the wrapper sources and their production dependencies. Run `inv
package-nodejs` to turn the result into a wheel.
"""
_build_nodejs(c)
def _sources_changed(source_files: Iterable[Path], timestamp_file: Path):
if timestamp_file.exists():
last_built = timestamp_file.lstat().st_mtime
src_last_modified = [f.lstat().st_mtime for f in source_files]
return not all(last_built >= modified for modified in src_last_modified)
return True
@task
def utest(c, reporter=None, suite=None):
"""Run utest.
Args:
reporter: Defines which approval test reporter to use.
Must be full path to the diff program.
For more details see:
https://pypi.org/project/pytest-approvaltests/
https://github.com/approvals/ApprovalTests.Python
suite: Defines which test suite file to run. Same as: pytest path/to/test.py
Must be path to the test suite file
To create coverage use: coverage run -m invoke utest
"""
args = [
"--showlocals",
"--junitxml=utest/output/pytest_xunit.xml",
"--tb=long",
"-o",
"log_cli=True",
"-o",
"log_cli_level=INFO",
]
if reporter:
args.append(f"--approvaltests-add-reporter={reporter}")
if suite:
args.append(suite)
status = pytest.main(args)
raise Exit(status)
@task
def utest_watch(c):
c.run("ptw --ignore ./node_modules --ignore ./.venv")
@task
def utest_node(c, coverage=False):
"""Run Node.js unit tests with Jest.
Args:
coverage: When set, runs with Istanbul coverage report (output: node/coverage/).
"""
script = "test:coverage" if coverage else "test"
c.run(f"npm run {script}")
if coverage:
report = ROOT_DIR / "node" / "coverage" / "index.html"
print(f"\nCoverage report: {report}")
@task
def clean_atest(c):
if ATEST_OUTPUT.exists():
shutil.rmtree(ATEST_OUTPUT)
_clean_zip_dir()
def _batteries(batteries: bool):
batteries_dir = str(BROWSER_BATTERIES_DIR)
if batteries:
print("Running with BrowserBatteries")
sys.path.append(batteries_dir)
browser_path = NODE_MODULES / "playwright-core" / ".local-browsers"
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(browser_path)
return batteries_dir