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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 58 additions & 11 deletions .github/workflows/test-wasm.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Test WebAssembly build in cockle deployment
# Build WebAssembly package and test it in cockle and JupyterLite terminal deployments
name: Test WebAssembly

on:
Expand All @@ -13,7 +13,7 @@ jobs:
(github.event.action == 'labeled' && github.event.label.name == 'run-wasm-ci') ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-wasm-ci'))
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.ref }}-build
cancel-in-progress: true
runs-on: ubuntu-latest
timeout-minutes: 30
Expand All @@ -37,24 +37,71 @@ jobs:
working-directory: wasm
run: |
cmake .
make build-recipe
make build-test

- name: Install playwright chromium
shell: bash -l {0}
working-directory: wasm/test
run: |
npx playwright install chromium
make build

- name: Upload artifact containing emscripten-forge package
uses: actions/upload-pages-artifact@v5
with:
path: ./wasm/recipe/em-forge-recipes/output/

# Tar the wasm directory (except the emscripten-forge recipes checkout) to preserve
# symlinks in node_modules, for use in the test jobs.
- name: Archive wasm build
run: |
tar -czf wasm-build.tar.gz --exclude=wasm/recipe/em-forge-recipes wasm

- name: Upload artifact containing wasm build
uses: actions/upload-artifact@v7
with:
name: wasm-build
path: wasm-build.tar.gz
retention-days: 1

test:
needs: build
strategy:
fail-fast: false
matrix:
target: [test-cockle, test-lite]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.target }}
cancel-in-progress: true
runs-on: ubuntu-latest
timeout-minutes: 60
name: ${{ matrix.target }}
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: '3.14'

- name: Install mamba
uses: mamba-org/setup-micromamba@v3
with:
environment-file: wasm/wasm-environment.yml
cache-environment: true

- name: Download artifact containing wasm build
uses: actions/download-artifact@v8
with:
name: wasm-build

- name: Extract wasm build
run: |
tar -xzf wasm-build.tar.gz

- name: Install playwright chromium
shell: bash -l {0}
run: |
playwright install chromium

- name: Run WebAssembly tests
shell: bash -l {0}
working-directory: wasm
env:
GIT2CPP_TEST_PRIVATE_TOKEN: ${{ secrets.GIT2CPP_TEST_PRIVATE_TOKEN }}
run: |
make test
make ${{ matrix.target }}
14 changes: 10 additions & 4 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@

import pytest

GIT2CPP_TEST_WASM = os.getenv("GIT2CPP_TEST_WASM") == "1"

if GIT2CPP_TEST_WASM:
from .conftest_wasm import *
# GIT2CPP_TEST_WASM=cockle tests in cockle, GIT2CPP_TEST_WASM=lite tests in a JupyterLite terminal.
GIT2CPP_TEST_COCKLE = os.getenv("GIT2CPP_TEST_WASM") == "cockle"
GIT2CPP_TEST_LITE = os.getenv("GIT2CPP_TEST_WASM") == "lite"
# Testing WebAssembly build in either cockle or JupyterLite.
GIT2CPP_TEST_WASM = GIT2CPP_TEST_COCKLE or GIT2CPP_TEST_LITE

if GIT2CPP_TEST_COCKLE:
from .conftest_cockle import *
elif GIT2CPP_TEST_LITE:
from .conftest_lite import *


# Fixture to run test in current tmp_path
Expand Down
106 changes: 106 additions & 0 deletions test/conftest_cockle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Extra fixtures used for wasm testing, including some that override the default pytest fixtures.
import os
import pathlib
import signal
import subprocess
import time
from functools import partial

import pytest
from playwright.sync_api import Page

from .conftest_common import (
MockPath,
maybe_wrap_arg,
os_chdir,
os_getcwd,
os_remove,
os_rename,
os_symlink,
tmp_path, # noqa: F401 Fixture used by tests
)


@pytest.fixture(scope="session", autouse=True)
def run_web_server():
with open("serve.log", "w") as f:
cwd = pathlib.Path(__file__).parent.parent / "wasm/test-cockle"
# Start in a new process group so that the whole process tree (npm and node) can be
# killed, otherwise the node server continues running and holding the port.
proc = subprocess.Popen(
["npm", "run", "serve"], stdout=f, stderr=f, cwd=cwd, start_new_session=True
)
# Wait a bit until server ready to receive connections.
time.sleep(1)
yield
os.killpg(proc.pid, signal.SIGTERM)
proc.wait()


@pytest.fixture(scope="function", autouse=True)
def load_page(page: Page):
# Load web page at start of every test.
page.goto("http://localhost:8000")
page.locator("#loaded").wait_for()


def subprocess_run(
page: Page,
cmd: list[str],
*,
capture_output: bool = False,
check: bool = False,
cwd: str | MockPath | None = None,
input: str | None = None,
text: bool | None = None,
) -> subprocess.CompletedProcess:
shell_run = "async obj => await window.cockle.shellRun(obj.cmd, obj.input)"

# Set cwd.
if cwd is not None:
proc = page.evaluate(shell_run, {"cmd": "pwd"})
if proc["returncode"] != 0:
raise RuntimeError("Error getting pwd")
old_cwd = proc["stdout"].strip()
if old_cwd == str(cwd):
# cwd is already correct.
cwd = None
else:
proc = page.evaluate(shell_run, {"cmd": f"cd {cwd}"})
if proc["returncode"] != 0:
raise RuntimeError(f"Error setting cwd to {cwd}")

shell_run_args = {"cmd": " ".join([maybe_wrap_arg(s) for s in cmd]), "input": input}
proc = page.evaluate(shell_run, shell_run_args)

# TypeScript object is auto converted to Python dict.
# Want to return subprocess.CompletedProcess, consider namedtuple if this fails in future.
returncode = proc["returncode"]
stdout = proc["stdout"] if capture_output else ""
stderr = proc["stderr"] if capture_output else ""
if not text:
stdout = stdout.encode("utf-8")
stderr = stderr.encode("utf-8")

# Reset cwd.
if cwd is not None:
proc = page.evaluate(shell_run, {"cmd": "cd " + old_cwd})
if proc["returncode"] != 0:
raise RuntimeError(f"Error setting cwd to {old_cwd}")

if check and returncode != 0:
raise subprocess.CalledProcessError(returncode, cmd, stdout, stderr)

return subprocess.CompletedProcess(
args=cmd, returncode=returncode, stdout=stdout, stderr=stderr
)


@pytest.fixture(scope="function", autouse=True)
def mock_subprocess_run(page: Page, monkeypatch):
monkeypatch.setattr(subprocess, "run", partial(subprocess_run, page))
monkeypatch.setattr(os, "chdir", os_chdir)
monkeypatch.setattr(os, "getcwd", os_getcwd)
monkeypatch.setattr(os, "remove", os_remove)
monkeypatch.setattr(os, "rename", os_rename)
monkeypatch.setattr(os, "symlink", os_symlink)
109 changes: 109 additions & 0 deletions test/conftest_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Code shared by wasm testing in cockle (conftest_cockle.py) and JupyterLite (conftest_lite.py).
import pathlib
import re
import subprocess

import pytest


class MockPath(pathlib.Path):
def __init__(self, path: str = ""):
super().__init__(path)

def exists(self) -> bool:
p = subprocess.run(["stat", str(self)])
return p.returncode == 0

def is_dir(self) -> bool:
p = subprocess.run(["stat", "-c", "%F", str(self)], capture_output=True, text=True)
return p.returncode == 0 and p.stdout.strip() == "directory"

def is_file(self) -> bool:
p = subprocess.run(["stat", "-c", "%F", str(self)], capture_output=True, text=True)
return p.returncode == 0 and p.stdout.strip() == "regular file"

def iterdir(self):
p = subprocess.run(
["ls", str(self), "-a", "-1"], capture_output=True, text=True, check=True
)
for f in filter(lambda f: f not in ["", ".", ".."], re.split(r"\r?\n", p.stdout)):
yield MockPath(self / f)

def mkdir(self, *, parents=False):
args = [str(self)]
if parents:
args.append("-p")
subprocess.run(["mkdir"] + args, capture_output=True, text=True, check=True)

def read_bytes(self) -> bytes:
raise RuntimeError("Not implemented")

def read_text(self) -> str:
p = subprocess.run(["cat", str(self)], capture_output=True, text=True, check=True)
text = p.stdout
text = text.removesuffix("\n")
return text

def write_bytes(self, data: bytes):
# Convert binary data to a string where each element is backslash-escaped so that we can
# write to file in cockle using `echo -e <backslash-escaped data>`.
encoded_string = "".join(f"\\x{d:02x}" for d in data)
cmd = ["echo", "-e", encoded_string, ">", str(self)]
subprocess.run(cmd, capture_output=True, text=True, check=True)
return len(data)

def write_text(self, data: str):
# Note that in general it is not valid to direct output of a subprocess.run call to a file,
# but we get away with it here as the command arguments are passed straight through to
# cockle without being checked.
data = data.removesuffix("\n")
cmd = ["echo", data, ">", str(self)]
subprocess.run(cmd, capture_output=True, text=True, check=True)
return len(data)

def __truediv__(self, other):
if isinstance(other, str):
return MockPath(f"{self}/{other}")
raise RuntimeError("MockPath.__truediv__ only supports strings")


@pytest.fixture(scope="function")
def tmp_path() -> MockPath:
# Assumes only one tmp_path needed per test.
path = MockPath("/drive/tmp0")
subprocess.run(["mkdir", str(path)], check=True)
assert path.exists()
assert path.is_dir()
return path


def maybe_wrap_arg(s: str | MockPath) -> str:
# An argument containing whitespace (including newlines) needs to be wrapped in quotes if
# it is not already, due to how the command is passed to the shell as a single string.
# Could do better here.
s = str(s)
if re.search(r"\s", s) and not s.endswith("'"):
return "'" + s + "'"
return s


def os_chdir(dir: str):
subprocess.run(["cd", str(dir)], capture_output=True, check=True, text=True)


def os_getcwd():
return subprocess.run(["pwd"], capture_output=True, check=True, text=True).stdout.strip()


def os_remove(file: str):
return subprocess.run(["rm", str(file)], capture_output=True, check=True, text=True)


def os_rename(src: str | MockPath, dst: str | MockPath):
return subprocess.run(["mv", str(src), str(dst)], capture_output=True, check=True, text=True)


def os_symlink(src: str | MockPath, dst: str | MockPath):
return subprocess.run(
["ln", "-s", str(src), str(dst)], capture_output=True, check=True, text=True
)
Loading
Loading