Skip to content

Commit 6ecad58

Browse files
committed
Run full wasm test suite in JupyterLite terminal as well as cockle
1 parent 29ba827 commit 6ecad58

23 files changed

Lines changed: 801 additions & 265 deletions

‎.github/workflows/test-wasm.yml‎

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Test WebAssembly build in cockle deployment
1+
# Build WebAssembly package and test it in cockle and JupyterLite terminal deployments
22
name: Test WebAssembly
33

44
on:
@@ -13,7 +13,7 @@ jobs:
1313
(github.event.action == 'labeled' && github.event.label.name == 'run-wasm-ci') ||
1414
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-wasm-ci'))
1515
concurrency:
16-
group: ${{ github.workflow }}-${{ github.ref }}
16+
group: ${{ github.workflow }}-${{ github.ref }}-build
1717
cancel-in-progress: true
1818
runs-on: ubuntu-latest
1919
timeout-minutes: 30
@@ -37,24 +37,71 @@ jobs:
3737
working-directory: wasm
3838
run: |
3939
cmake .
40-
make build-recipe
41-
make build-test
42-
43-
- name: Install playwright chromium
44-
shell: bash -l {0}
45-
working-directory: wasm/test
46-
run: |
47-
npx playwright install chromium
40+
make build
4841
4942
- name: Upload artifact containing emscripten-forge package
5043
uses: actions/upload-pages-artifact@v5
5144
with:
5245
path: ./wasm/recipe/em-forge-recipes/output/
5346

47+
# Tar the wasm directory (except the emscripten-forge recipes checkout) to preserve
48+
# symlinks in node_modules, for use in the test jobs.
49+
- name: Archive wasm build
50+
run: |
51+
tar -czf wasm-build.tar.gz --exclude=wasm/recipe/em-forge-recipes wasm
52+
53+
- name: Upload artifact containing wasm build
54+
uses: actions/upload-artifact@v7
55+
with:
56+
name: wasm-build
57+
path: wasm-build.tar.gz
58+
retention-days: 1
59+
60+
test:
61+
needs: build
62+
strategy:
63+
fail-fast: false
64+
matrix:
65+
target: [test-cockle, test-lite]
66+
concurrency:
67+
group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.target }}
68+
cancel-in-progress: true
69+
runs-on: ubuntu-latest
70+
timeout-minutes: 60
71+
name: ${{ matrix.target }}
72+
steps:
73+
- name: Checkout
74+
uses: actions/checkout@v7
75+
76+
- name: Setup Python
77+
uses: actions/setup-python@v7
78+
with:
79+
python-version: '3.14'
80+
81+
- name: Install mamba
82+
uses: mamba-org/setup-micromamba@v3
83+
with:
84+
environment-file: wasm/wasm-environment.yml
85+
cache-environment: true
86+
87+
- name: Download artifact containing wasm build
88+
uses: actions/download-artifact@v8
89+
with:
90+
name: wasm-build
91+
92+
- name: Extract wasm build
93+
run: |
94+
tar -xzf wasm-build.tar.gz
95+
96+
- name: Install playwright chromium
97+
shell: bash -l {0}
98+
run: |
99+
playwright install chromium
100+
54101
- name: Run WebAssembly tests
55102
shell: bash -l {0}
56103
working-directory: wasm
57104
env:
58105
GIT2CPP_TEST_PRIVATE_TOKEN: ${{ secrets.GIT2CPP_TEST_PRIVATE_TOKEN }}
59106
run: |
60-
make test
107+
make ${{ matrix.target }}

‎test/conftest.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@
55

66
import pytest
77

8-
GIT2CPP_TEST_WASM = os.getenv("GIT2CPP_TEST_WASM") == "1"
9-
10-
if GIT2CPP_TEST_WASM:
11-
from .conftest_wasm import *
8+
# GIT2CPP_TEST_WASM=cockle tests in cockle, GIT2CPP_TEST_WASM=lite tests in a JupyterLite terminal.
9+
GIT2CPP_TEST_COCKLE = os.getenv("GIT2CPP_TEST_WASM") == "cockle"
10+
GIT2CPP_TEST_LITE = os.getenv("GIT2CPP_TEST_WASM") == "lite"
11+
# Testing WebAssembly build in either cockle or JupyterLite.
12+
GIT2CPP_TEST_WASM = GIT2CPP_TEST_COCKLE or GIT2CPP_TEST_LITE
13+
14+
if GIT2CPP_TEST_COCKLE:
15+
from .conftest_cockle import *
16+
elif GIT2CPP_TEST_LITE:
17+
from .conftest_lite import *
1218

1319

1420
# Fixture to run test in current tmp_path

‎test/conftest_cockle.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Extra fixtures used for wasm testing, including some that override the default pytest fixtures.
2+
import os
3+
import pathlib
4+
import signal
5+
import subprocess
6+
import time
7+
from functools import partial
8+
9+
import pytest
10+
from playwright.sync_api import Page
11+
12+
from .conftest_common import (
13+
MockPath,
14+
maybe_wrap_arg,
15+
os_chdir,
16+
os_getcwd,
17+
os_remove,
18+
os_rename,
19+
os_symlink,
20+
tmp_path, # noqa: F401 Fixture used by tests
21+
)
22+
23+
24+
@pytest.fixture(scope="session", autouse=True)
25+
def run_web_server():
26+
with open("serve.log", "w") as f:
27+
cwd = pathlib.Path(__file__).parent.parent / "wasm/test-cockle"
28+
# Start in a new process group so that the whole process tree (npm and node) can be
29+
# killed, otherwise the node server continues running and holding the port.
30+
proc = subprocess.Popen(
31+
["npm", "run", "serve"], stdout=f, stderr=f, cwd=cwd, start_new_session=True
32+
)
33+
# Wait a bit until server ready to receive connections.
34+
time.sleep(1)
35+
yield
36+
os.killpg(proc.pid, signal.SIGTERM)
37+
proc.wait()
38+
39+
40+
@pytest.fixture(scope="function", autouse=True)
41+
def load_page(page: Page):
42+
# Load web page at start of every test.
43+
page.goto("http://localhost:8000")
44+
page.locator("#loaded").wait_for()
45+
46+
47+
def subprocess_run(
48+
page: Page,
49+
cmd: list[str],
50+
*,
51+
capture_output: bool = False,
52+
check: bool = False,
53+
cwd: str | MockPath | None = None,
54+
input: str | None = None,
55+
text: bool | None = None,
56+
) -> subprocess.CompletedProcess:
57+
shell_run = "async obj => await window.cockle.shellRun(obj.cmd, obj.input)"
58+
59+
# Set cwd.
60+
if cwd is not None:
61+
proc = page.evaluate(shell_run, {"cmd": "pwd"})
62+
if proc["returncode"] != 0:
63+
raise RuntimeError("Error getting pwd")
64+
old_cwd = proc["stdout"].strip()
65+
if old_cwd == str(cwd):
66+
# cwd is already correct.
67+
cwd = None
68+
else:
69+
proc = page.evaluate(shell_run, {"cmd": f"cd {cwd}"})
70+
if proc["returncode"] != 0:
71+
raise RuntimeError(f"Error setting cwd to {cwd}")
72+
73+
shell_run_args = {"cmd": " ".join([maybe_wrap_arg(s) for s in cmd]), "input": input}
74+
proc = page.evaluate(shell_run, shell_run_args)
75+
76+
# TypeScript object is auto converted to Python dict.
77+
# Want to return subprocess.CompletedProcess, consider namedtuple if this fails in future.
78+
returncode = proc["returncode"]
79+
stdout = proc["stdout"] if capture_output else ""
80+
stderr = proc["stderr"] if capture_output else ""
81+
if not text:
82+
stdout = stdout.encode("utf-8")
83+
stderr = stderr.encode("utf-8")
84+
85+
# Reset cwd.
86+
if cwd is not None:
87+
proc = page.evaluate(shell_run, {"cmd": "cd " + old_cwd})
88+
if proc["returncode"] != 0:
89+
raise RuntimeError(f"Error setting cwd to {old_cwd}")
90+
91+
if check and returncode != 0:
92+
raise subprocess.CalledProcessError(returncode, cmd, stdout, stderr)
93+
94+
return subprocess.CompletedProcess(
95+
args=cmd, returncode=returncode, stdout=stdout, stderr=stderr
96+
)
97+
98+
99+
@pytest.fixture(scope="function", autouse=True)
100+
def mock_subprocess_run(page: Page, monkeypatch):
101+
monkeypatch.setattr(subprocess, "run", partial(subprocess_run, page))
102+
monkeypatch.setattr(os, "chdir", os_chdir)
103+
monkeypatch.setattr(os, "getcwd", os_getcwd)
104+
monkeypatch.setattr(os, "remove", os_remove)
105+
monkeypatch.setattr(os, "rename", os_rename)
106+
monkeypatch.setattr(os, "symlink", os_symlink)

‎test/conftest_common.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Code shared by wasm testing in cockle (conftest_cockle.py) and JupyterLite (conftest_lite.py).
2+
import pathlib
3+
import re
4+
import subprocess
5+
6+
import pytest
7+
8+
9+
class MockPath(pathlib.Path):
10+
def __init__(self, path: str = ""):
11+
super().__init__(path)
12+
13+
def exists(self) -> bool:
14+
p = subprocess.run(["stat", str(self)])
15+
return p.returncode == 0
16+
17+
def is_dir(self) -> bool:
18+
p = subprocess.run(["stat", "-c", "%F", str(self)], capture_output=True, text=True)
19+
return p.returncode == 0 and p.stdout.strip() == "directory"
20+
21+
def is_file(self) -> bool:
22+
p = subprocess.run(["stat", "-c", "%F", str(self)], capture_output=True, text=True)
23+
return p.returncode == 0 and p.stdout.strip() == "regular file"
24+
25+
def iterdir(self):
26+
p = subprocess.run(
27+
["ls", str(self), "-a", "-1"], capture_output=True, text=True, check=True
28+
)
29+
for f in filter(lambda f: f not in ["", ".", ".."], re.split(r"\r?\n", p.stdout)):
30+
yield MockPath(self / f)
31+
32+
def mkdir(self, *, parents=False):
33+
args = [str(self)]
34+
if parents:
35+
args.append("-p")
36+
subprocess.run(["mkdir"] + args, capture_output=True, text=True, check=True)
37+
38+
def read_bytes(self) -> bytes:
39+
raise RuntimeError("Not implemented")
40+
41+
def read_text(self) -> str:
42+
p = subprocess.run(["cat", str(self)], capture_output=True, text=True, check=True)
43+
text = p.stdout
44+
text = text.removesuffix("\n")
45+
return text
46+
47+
def write_bytes(self, data: bytes):
48+
# Convert binary data to a string where each element is backslash-escaped so that we can
49+
# write to file in cockle using `echo -e <backslash-escaped data>`.
50+
encoded_string = "".join(f"\\x{d:02x}" for d in data)
51+
cmd = ["echo", "-e", encoded_string, ">", str(self)]
52+
subprocess.run(cmd, capture_output=True, text=True, check=True)
53+
return len(data)
54+
55+
def write_text(self, data: str):
56+
# Note that in general it is not valid to direct output of a subprocess.run call to a file,
57+
# but we get away with it here as the command arguments are passed straight through to
58+
# cockle without being checked.
59+
data = data.removesuffix("\n")
60+
cmd = ["echo", data, ">", str(self)]
61+
subprocess.run(cmd, capture_output=True, text=True, check=True)
62+
return len(data)
63+
64+
def __truediv__(self, other):
65+
if isinstance(other, str):
66+
return MockPath(f"{self}/{other}")
67+
raise RuntimeError("MockPath.__truediv__ only supports strings")
68+
69+
70+
@pytest.fixture(scope="function")
71+
def tmp_path() -> MockPath:
72+
# Assumes only one tmp_path needed per test.
73+
path = MockPath("/drive/tmp0")
74+
subprocess.run(["mkdir", str(path)], check=True)
75+
assert path.exists()
76+
assert path.is_dir()
77+
return path
78+
79+
80+
def maybe_wrap_arg(s: str | MockPath) -> str:
81+
# An argument containing whitespace (including newlines) needs to be wrapped in quotes if
82+
# it is not already, due to how the command is passed to the shell as a single string.
83+
# Could do better here.
84+
s = str(s)
85+
if re.search(r"\s", s) and not s.endswith("'"):
86+
return "'" + s + "'"
87+
return s
88+
89+
90+
def os_chdir(dir: str):
91+
subprocess.run(["cd", str(dir)], capture_output=True, check=True, text=True)
92+
93+
94+
def os_getcwd():
95+
return subprocess.run(["pwd"], capture_output=True, check=True, text=True).stdout.strip()
96+
97+
98+
def os_remove(file: str):
99+
return subprocess.run(["rm", str(file)], capture_output=True, check=True, text=True)
100+
101+
102+
def os_rename(src: str | MockPath, dst: str | MockPath):
103+
return subprocess.run(["mv", str(src), str(dst)], capture_output=True, check=True, text=True)
104+
105+
106+
def os_symlink(src: str | MockPath, dst: str | MockPath):
107+
return subprocess.run(
108+
["ln", "-s", str(src), str(dst)], capture_output=True, check=True, text=True
109+
)

0 commit comments

Comments
 (0)