diff --git a/.github/workflows/test-wasm.yml b/.github/workflows/test-wasm.yml index e880dd7..b825baf 100644 --- a/.github/workflows/test-wasm.yml +++ b/.github/workflows/test-wasm.yml @@ -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: @@ -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 @@ -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 }} diff --git a/test/conftest.py b/test/conftest.py index 8660772..6a7b90c 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -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 diff --git a/test/conftest_cockle.py b/test/conftest_cockle.py new file mode 100644 index 0000000..d0ddef3 --- /dev/null +++ b/test/conftest_cockle.py @@ -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) diff --git a/test/conftest_common.py b/test/conftest_common.py new file mode 100644 index 0000000..f112bf7 --- /dev/null +++ b/test/conftest_common.py @@ -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 `. + 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 + ) diff --git a/test/conftest_lite.py b/test/conftest_lite.py new file mode 100644 index 0000000..1d32eec --- /dev/null +++ b/test/conftest_lite.py @@ -0,0 +1,316 @@ +# Extra fixtures used for wasm testing in a JupyterLite terminal, including some that override the +# default pytest fixtures. Commands are run by typing them into a terminal, with stdout and stderr +# written to files in the JupyterLite drive that are then read back, and the exit code read from the +# terminal output. +import atexit +import os +import pathlib +import re +import signal +import subprocess +import time +from functools import partial + +import pytest +from playwright.sync_api import Browser, ConsoleMessage, Page + +from .conftest_common import ( + MockPath, + maybe_wrap_arg, + os_chdir, + os_remove, + os_rename, + os_symlink, + tmp_path, # noqa: F401 Fixture used by tests +) + +LITE_URL = "http://localhost:8001/lab/index.html" + +# Files in the JupyterLite drive that a command's stdout and stderr are written to. +# Terminal path /drive/ corresponds to JupyterLite contents path . +STDOUT_FILE = "stdout.txt" +STDERR_FILE = "stderr.txt" + +# Command line that writes the exit code of the previous command line and the shell's cwd to the +# terminal, as lines such as '?=0' and 'PWD=/drive'. +# Quote the grep pattern as cockle treats an unquoted '?' as a glob wildcard. +EXIT_CODE_AND_PWD_LINE = "env | grep -E '^(\\?|PWD)='" + +# Collect the output of the open terminal so that we can tell when a command line has finished. +COLLECT_OUTPUT_JS = """ +() => { + const widget = Array.from(window.jupyterapp.shell.widgets('main')).find(w => w.content?.session); + window.liteTest = { output: '' }; + widget.content.session.messageReceived.connect((_, msg) => { + if (msg.type === 'stdout') { + window.liteTest.output += msg.content[0]; + } + }); +} +""" + +# Read the contents of text files. They are not deleted as the next command overwrites them. +READ_FILES_JS = """ +async paths => { + const contents = window.jupyterapp.serviceManager.contents; + return await Promise.all( + paths.map(async path => (await contents.get(path, { content: true, format: 'text' })).content) + ); +} +""" + +# Shut down the open terminal's session. +SHUTDOWN_TERMINAL_JS = """ +async () => { + const widget = Array.from(window.jupyterapp.shell.widgets('main')).find(w => w.content?.session); + await widget.content.session.shutdown(); +} +""" + +# Recursively delete everything in the JupyterLite drive. +CLEAR_DRIVE_JS = """ +async () => { + const contents = window.jupyterapp.serviceManager.contents; + async function remove(path) { + const model = await contents.get(path, { content: true }); + if (model.type === 'directory') { + for (const child of model.content) { + await remove(child.path); + } + } + if (path !== '') { + await contents.delete(path); + } + } + await remove(''); +} +""" + + +class ConsoleWatcher: + """Collect console messages from the page and its workers, as cockle logs from a worker.""" + + def __init__(self, page: Page): + self._page = page + self.messages: list[str] = [] + page.on("console", self._on_console) + page.on("worker", lambda worker: worker.on("console", self._on_console)) + + def _on_console(self, msg: ConsoleMessage): + self.messages.append(msg.text) + + def clear(self): + self.messages.clear() + + def wait_for(self, prefix: str, timeout_ms: int = 60_000): + start = time.monotonic() + while not any(m.startswith(prefix) for m in self.messages): + if (time.monotonic() - start) * 1000 > timeout_ms: + raise TimeoutError(f"Timeout waiting for console message starting '{prefix}'") + # Waiting on the page allows playwright to dispatch console events. + self._page.wait_for_timeout(100) + + +@pytest.fixture(scope="session", autouse=True) +def run_web_server(): + cwd = pathlib.Path(__file__).parent.parent / "wasm/test-lite" + with open(cwd / "serve.log", "w") as f: + # Start in a new process group so that the whole process tree (npm, npx 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 + ) + # Also kill the servers at exit in case pytest aborts without running fixture teardown, + # such as after an INTERNALERROR. + atexit.register(kill_process_group, proc) + # Wait a bit until server ready to receive connections. + time.sleep(1) + yield + kill_process_group(proc) + + +def kill_process_group(proc: subprocess.Popen): + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGTERM) + proc.wait() + + +@pytest.fixture(scope="session") +def lite_page(run_web_server, browser: Browser) -> Page: + # Load JupyterLite once per session as it is much slower than opening a new terminal. + context = browser.new_context() + page = context.new_page() + page.goto(LITE_URL) + page.locator(".jp-LauncherCard", has_text="Terminal").wait_for() + yield page + context.close() + + +@pytest.fixture(scope="session") +def lite_console_watcher(lite_page: Page) -> ConsoleWatcher: + return ConsoleWatcher(lite_page) + + +class LiteTerminal: + """The open terminal and the directories that commands are run in.""" + + def __init__(self, page: Page): + self.page = page + # Current directory of the terminal's shell. + self.shell_cwd: str = "" + # Current directory of the mocked Python process, only changed by os.chdir. Commands that + # do not specify a cwd are run here. + self.process_cwd: str = "" + + +@pytest.fixture(scope="function", autouse=True) +def load_page(lite_page: Page, lite_console_watcher: ConsoleWatcher) -> LiteTerminal: + # Open a new terminal from the launcher at the start of every test and wait until the shell + # has written its first prompt, so that each test has a new shell. + page = lite_page + lite_console_watcher.clear() + launcher_card = page.locator(".jp-LauncherCard", has_text="Terminal") + if not launcher_card.is_visible(): + # Previous test closed its terminal so open a new launcher. + page.locator('[data-command="launcher:create"]').first.click() + launcher_card.click() + page.locator(".jp-Terminal .xterm").wait_for() + # Cockle checks the terminal theme immediately before writing the first prompt. + lite_console_watcher.wait_for("Cockle theme change") + page.evaluate(COLLECT_OUTPUT_JS) + page.locator(".jp-Terminal .xterm").click() + + terminal = LiteTerminal(page) + _, terminal.shell_cwd = read_exit_code_and_pwd(page) + terminal.process_cwd = terminal.shell_cwd + + yield terminal + + # Shut down the terminal, which closes it, and remove everything from the drive. + page.evaluate(SHUTDOWN_TERMINAL_JS) + page.locator(".jp-Terminal").wait_for(state="detached") + page.evaluate(CLEAR_DRIVE_JS) + + +def type_text(page: Page, text: str): + # Type text into the terminal, using the Enter and Backspace keys for newline and delete + # characters respectively. + for chunk in re.split(r"([\n\x7f])", text): + if chunk == "\n": + page.keyboard.press("Enter") + elif chunk == "\x7f": + page.keyboard.press("Backspace") + elif chunk: + page.keyboard.type(chunk) + + +def run_line(page: Page, line: str, input: str | None = None, timeout_ms: int = 600_000) -> str: + # Type a command line into the terminal, optionally followed by stdin input for the command, + # wait for the next prompt and return the terminal output. + page.evaluate("() => { window.liteTest.output = ''; }") + page.keyboard.type(line) + page.keyboard.press("Enter") + if input is not None: + # The shell enables buffered stdin for the command immediately it starts running, after + # which typed input is buffered until the command reads it. There is no signal for this + # so wait a short time before typing. + page.wait_for_timeout(200) + if not input.endswith("\n"): + input += "\n" + type_text(page, input) + page.wait_for_function("() => window.liteTest.output.includes('js-shell:')", timeout=timeout_ms) + return page.evaluate("() => window.liteTest.output") + + +def read_exit_code_and_pwd(page: Page) -> tuple[int | None, str]: + # At the time of writing, the cockle in the JupyterLite terminal does not support '$?' and only + # sets the exit code once a whole command line has finished, so read the exit code using a + # second command line. This also reads the shell's cwd so that it can be tracked. + # The next terminal release will support 'echo $?' instead. + output = run_line(page, EXIT_CODE_AND_PWD_LINE) + # Remove ANSI escape sequences such as colours, as grep is aliased to 'grep --color=auto'. + output = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", output) + returncode = re.search(r"^\?=(-?\d+)\s*$", output, re.MULTILINE) + pwd = re.search(r"^PWD=(.*?)\s*$", output, re.MULTILINE) + if pwd is None: + raise RuntimeError(f"Unable to read PWD from terminal output: {output!r}") + # There is no exit code in a new shell before any command has been run. + return None if returncode is None else int(returncode.group(1)), pwd.group(1) + + +def lite_shell_run( + terminal: LiteTerminal, cmd: str, run_cwd: str, capture_output: bool, input: str | None = None +) -> dict: + page = terminal.page + + # Change directory as part of the same command line, if necessary. The cockle in the JupyterLite + # terminal does not support '&&' so if the cd fails the command is run in the wrong directory, + # but this is detected afterwards from the shell's PWD. + cd_prefix = "" if terminal.shell_cwd == run_cwd else f"cd {run_cwd}; " + + # If the command already redirects stdout, such as MockPath.write_text, do not redirect it + # again as the last redirect wins. + stdout_redirected = re.search(r"(^|\s)>>?(\s|$)", cmd) is not None + stdout_redirect = "" if stdout_redirected else f" > /drive/{STDOUT_FILE}" + run_line(page, f"{cd_prefix}{cmd}{stdout_redirect} 2> /drive/{STDERR_FILE}", input) + + returncode, terminal.shell_cwd = read_exit_code_and_pwd(page) + if returncode is None: + raise RuntimeError(f"Unable to read exit code of command: {cmd}") + if cd_prefix and terminal.shell_cwd != run_cwd and not cmd.startswith("cd "): + raise RuntimeError(f"Error setting cwd to {run_cwd}") + + stdout = stderr = "" + if capture_output: + paths = [STDERR_FILE] if stdout_redirected else [STDOUT_FILE, STDERR_FILE] + contents = terminal.page.evaluate(READ_FILES_JS, paths) + stderr = contents[-1] + if not stdout_redirected: + stdout = contents[0] + return {"returncode": returncode, "stdout": stdout, "stderr": stderr} + + +def subprocess_run( + terminal: LiteTerminal, + 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: + run_cwd = terminal.process_cwd if cwd is None else str(cwd) + proc = lite_shell_run( + terminal, " ".join([maybe_wrap_arg(s) for s in cmd]), run_cwd, capture_output, input + ) + + # A successful cd without a specified cwd, such as from os.chdir, changes the process cwd. + returncode = proc["returncode"] + if cmd[0] == "cd" and cwd is None and returncode == 0: + terminal.process_cwd = terminal.shell_cwd + + stdout = proc["stdout"] + stderr = proc["stderr"] + if not text: + stdout = stdout.encode("utf-8") + stderr = stderr.encode("utf-8") + + 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(load_page: LiteTerminal, monkeypatch): + monkeypatch.setattr(subprocess, "run", partial(subprocess_run, load_page)) + monkeypatch.setattr(os, "chdir", os_chdir) + # Use the tracked cwd rather than running pwd, as os.getcwd is also called by pytest such as + # when reporting a failure, when the terminal may still be running a command. + monkeypatch.setattr(os, "getcwd", lambda: load_page.process_cwd) + monkeypatch.setattr(os, "remove", os_remove) + monkeypatch.setattr(os, "rename", os_rename) + monkeypatch.setattr(os, "symlink", os_symlink) diff --git a/test/conftest_wasm.py b/test/conftest_wasm.py deleted file mode 100644 index d2bb481..0000000 --- a/test/conftest_wasm.py +++ /dev/null @@ -1,224 +0,0 @@ -# Extra fixtures used for wasm testing, including some that override the default pytest fixtures. -import os -import pathlib -import re -import subprocess -import time -from functools import partial - -import pytest -from playwright.sync_api import Page - - -# Only include particular test files when testing wasm. -# This can be removed when all tests support wasm. -def pytest_ignore_collect(collection_path: pathlib.Path) -> bool: - return collection_path.name not in [ - "test_add.py", - "test_branch.py", - "test_checkout.py", - "test_clone.py", - "test_commit.py", - "test_config.py", - "test_diff.py", - "test_fetch.py", - "test_fixtures.py", - "test_git.py", - "test_init.py", - "test_log.py", - "test_merge.py", - "test_mv.py", - "test_push.py", - "test_rebase.py", - "test_remote.py", - "test_reset.py", - "test_revlist.py", - "test_revparse.py", - "test_rm.py", - "test_showref.py", - "test_stash.py", - "test_status.py", - "test_tag.py", - ] - - -@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" - proc = subprocess.Popen(["npm", "run", "serve"], stdout=f, stderr=f, cwd=cwd) - # Wait a bit until server ready to receive connections. - time.sleep(1) - yield - proc.terminate() - - -@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() - - -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 `. - 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") - - -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 - ) - - -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}") - - 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 cockle 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 - - 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") -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 - - -@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) diff --git a/test/test_checkout.py b/test/test_checkout.py index afe8541..1934296 100644 --- a/test/test_checkout.py +++ b/test/test_checkout.py @@ -390,3 +390,68 @@ def test_checkout_tag(repo_init_with_commit, git2cpp_path, tmp_path): p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_branch.returncode == 0 assert "*" not in p_branch.stdout + + +def test_checkout_rmdir_bug(commit_env_config, git2cpp_path, tmp_path): + # Regression test for a bug in jupyterlite <= 0.8.6 that does not always handle `rmdir` + # correctly and was fixed by https://github.com/jupyterlite/jupyterlite/pull/2102. + + # git init . -b main + init_cmd = [git2cpp_path, "init", ".", "-b", "main"] + p_init = subprocess.run(init_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_init.returncode == 0 + + # mkdir dir + (tmp_path / "dir").mkdir() + + # echo Hello > dir/a.txt + (tmp_path / "dir" / "a.txt").write_text("Hello") + + # git add dir/a.txt + add_cmd = [git2cpp_path, "add", "dir/a.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_add.returncode == 0 + + # git commit -m "first" + commit_cmd = [git2cpp_path, "commit", "-m", "first"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_commit.returncode == 0 + + # echo Other > dir/b.txt + (tmp_path / "dir" / "b.txt").write_text("Other") + + # git add dir/b.txt + add_cmd2 = [git2cpp_path, "add", "dir/b.txt"] + p_add2 = subprocess.run(add_cmd2, capture_output=True, cwd=tmp_path, text=True) + assert p_add2.returncode == 0 + + # git commit -m "second" + commit_cmd2 = [git2cpp_path, "commit", "-m", "second"] + p_commit2 = subprocess.run(commit_cmd2, capture_output=True, cwd=tmp_path, text=True) + assert p_commit2.returncode == 0 + + # git status + status_cmd = [git2cpp_path, "status"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_status.returncode == 0 + assert "Nothing to commit, working tree clean" in p_status.stdout + + # git checkout HEAD~1 + checkout_cmd = [git2cpp_path, "checkout", "HEAD~1"] + p_checkout = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_checkout.returncode == 0 + + # git status + p_status2 = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_status2.returncode == 0 + assert "Nothing to commit, working tree clean" in p_status2.stdout + # The bug that is fixed in jupyterlite PR 2102 is that stdout here is the following instead of + # the above line: + # On branch HEAD + # Changes not staged for commit: + # (use "git add ..." to update what will be committed) + # deleted: dir/a.txt + # + # no changes added to commit (use "git add" and/or "git commit -a") + assert "Changes not staged" not in p_status2.stdout + assert "deleted:" not in p_status2.stdout diff --git a/test/test_status.py b/test/test_status.py index 275da0e..104eb68 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -4,7 +4,7 @@ import pytest -from .conftest import strip_ansi_colours +from .conftest import GIT2CPP_TEST_LITE, strip_ansi_colours @pytest.mark.parametrize("short_flag", ["", "-s", "--short"]) @@ -207,6 +207,7 @@ def test_status_mixed_changes(repo_init_with_commit, git2cpp_path, tmp_path, sho assert "untracked.txt" in p.stdout +@pytest.mark.skipif(GIT2CPP_TEST_LITE, reason="JupyterLite drive does not support symlinks") @pytest.mark.parametrize("short_flag", ["", "-s"]) def test_status_typechange(repo_init_with_commit, git2cpp_path, tmp_path, short_flag): """Test status shows typechange (file to symlink or vice versa)""" diff --git a/wasm/.gitignore b/wasm/.gitignore index 838f04a..d81cad1 100644 --- a/wasm/.gitignore +++ b/wasm/.gitignore @@ -12,5 +12,7 @@ cockle/ lite-deploy/package.json recipe/em-forge-recipes/ serve/dist/*/ -test/assets/*/ -test/lib/ +test-cockle/assets/*/ +test-cockle/lib/ +test-lite/dist/ +test-lite/serve.log diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt index 63d2b54..600a2ca 100644 --- a/wasm/CMakeLists.txt +++ b/wasm/CMakeLists.txt @@ -8,13 +8,14 @@ add_subdirectory(recipe) add_subdirectory(cockle-deploy) add_subdirectory(lite-deploy) add_subdirectory(serve) -add_subdirectory(test) +add_subdirectory(test-cockle) +add_subdirectory(test-lite) # Build everything (package, cockle and lite deployments, tests). -add_custom_target(build ALL DEPENDS build-recipe build-cockle build-lite build-serve build-test) +add_custom_target(build ALL DEPENDS build-recipe build-cockle build-lite build-serve build-test-cockle build-test-lite) # Rebuild after change in C++ code. -add_custom_target(rebuild DEPENDS rebuild-recipe rebuild-cockle rebuild-lite rebuild-test) +add_custom_target(rebuild DEPENDS rebuild-recipe rebuild-cockle rebuild-lite rebuild-test-cockle rebuild-test-lite) if (USE_COCKLE_RELEASE) execute_process(COMMAND npm view @jupyterlite/cockle version OUTPUT_VARIABLE COCKLE_BRANCH) diff --git a/wasm/README.md b/wasm/README.md index c24f48d..b4e78e5 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -8,13 +8,14 @@ and test the WebAssembly build. It works on Linux and macOS but not Windows. -There are 5 sub-directories: +There are 6 sub-directories: - `recipe`: build local `git2cpp` source code into an Emscripten-forge package. - `cockle-deploy`: create a `cockle` deployment in the `serve` directory. - `lite-deploy`: create a JupyterLite `terminal` deployment in the `serve` directory. - `serve`: where the two deployments are served from. -- `test`: test the WebAssembly build. +- `test-cockle`: test the WebAssembly build in a `cockle` deployment. +- `test-lite`: test the WebAssembly build in a JupyterLite `terminal` deployment. ## Build and deploy @@ -85,15 +86,20 @@ make rebuild ## Test -To test the WebAssembly build use from the `wasm` directory: +There are two separate test runs which both run the tests in the top-level `test` directory with +various monkey patching so that `git2cpp` commands are executed in the browser: + +- `test-cockle` runs each command directly in a `cockle` shell. +- `test-lite` types each command into a JupyterLite terminal using the browser UI. This is slower + than `test-cockle` but is closer to how users run `git2cpp`. + +To run them use from the `wasm` directory: ```bash -make test +make test-cockle +make test-lite ``` -This runs the tests in the top-level `test` directory with various monkey patching so that -`git2cpp` commands are executed in the browser. -The tests that are run are defined in the function `pytest_ignore_collect` in `conftest_wasm.py`. If there are problems running the tests then ensure you have the latest `playwright` browser installed: ```bash @@ -103,23 +109,25 @@ playwright install chromium To run all of the tests including those that require the `GIT2CPP_TEST_PRIVATE_TOKEN`: ```bash -GIT2CPP_TEST_PRIVATE_TOKEN= make test +GIT2CPP_TEST_PRIVATE_TOKEN= make test-cockle +GIT2CPP_TEST_PRIVATE_TOKEN= make test-lite ``` -You can run a specific test from the top-level `test` directory (not the `wasm/test` directory) -using: +You can run a specific test from the top-level `test` directory (not the `wasm/test-cockle` or +`wasm/test-lite` directories) by setting `GIT2CPP_TEST_WASM` to either `cockle` or `lite`: ```bash -GIT2CPP_TEST_WASM=1 pytest -v test_git.py::test_version +GIT2CPP_TEST_WASM=cockle pytest -v test_git.py::test_version +GIT2CPP_TEST_WASM=lite pytest -v test_git.py::test_version ``` ### Manually running the test servers If wasm tests are failing it can be helpful to run the test servers and manually run `cockle` -commands to help understand the problem. To do this use: +commands to help understand the problem. For `cockle` use: ```bash -cd wasm/test +cd test-cockle npm run serve ``` @@ -128,3 +136,13 @@ at http://localhost:8000/ and to run a command such as `ls -l` open the dev cons following at the prompt: `await window.cockle.shellRun('ls -al')`. The generated output will appear in a new `
` in the web page in a format similar to that returned by Python's `subprocess.run()`. + +For JupyterLite use: + +```bash +cd test-lite +npm run serve +``` + +This will start both the JupyterLite server on port 8001 and the CORS server on port 8881. Open a +browser at http://localhost:8001/ and open a terminal to run commands. diff --git a/wasm/lite-deploy/cockle-config-in.json b/wasm/lite-deploy/cockle-config-in.json index 95a1db1..4c9c316 100644 --- a/wasm/lite-deploy/cockle-config-in.json +++ b/wasm/lite-deploy/cockle-config-in.json @@ -10,6 +10,7 @@ "vi": "vim" }, "environment": { + "GIT_CORS_PROXY": "http://localhost:8881/", "GIT_AUTHOR_NAME": "Jane Doe", "GIT_AUTHOR_EMAIL": "jane.doe@blabla.com", "GIT_COMMITTER_NAME": "Jane Doe", diff --git a/wasm/test/CMakeLists.txt b/wasm/test-cockle/CMakeLists.txt similarity index 59% rename from wasm/test/CMakeLists.txt rename to wasm/test-cockle/CMakeLists.txt index 5700d94..e27350f 100644 --- a/wasm/test/CMakeLists.txt +++ b/wasm/test-cockle/CMakeLists.txt @@ -1,25 +1,25 @@ cmake_minimum_required(VERSION 3.28) -project(git2cpp-wasm-test) +project(git2cpp-wasm-test-cockle) include("../common.cmake") -add_custom_target(build-test +add_custom_target(build-test-cockle DEPENDS build-recipe cockle COMMAND npm install COMMAND COCKLE_WASM_EXTRA_CHANNEL=${BUILT_PACKAGE_DIR} npm run build BYPRODUCTS cockle_wasm_env lib node_modules ) -add_custom_target(rebuild-test - DEPENDS rebuild-recipe clean-env-test build-test +add_custom_target(rebuild-test-cockle + DEPENDS rebuild-recipe clean-env-test-cockle build-test-cockle ) -add_custom_target(test +add_custom_target(test-cockle # A pytest fixture ensures that `npm run serve` runs for the duration of the tests. - COMMAND GIT2CPP_TEST_WASM=1 pytest -v -rP + COMMAND GIT2CPP_TEST_WASM=cockle pytest -v -rP WORKING_DIRECTORY ../../test ) -add_custom_target(clean-env-test +add_custom_target(clean-env-test-cockle COMMAND ${CMAKE_COMMAND} -E remove_directory cockle_wasm_env ) diff --git a/wasm/test/assets/index.html b/wasm/test-cockle/assets/index.html similarity index 100% rename from wasm/test/assets/index.html rename to wasm/test-cockle/assets/index.html diff --git a/wasm/test/cockle-config-in.json b/wasm/test-cockle/cockle-config-in.json similarity index 100% rename from wasm/test/cockle-config-in.json rename to wasm/test-cockle/cockle-config-in.json diff --git a/wasm/test/package.json b/wasm/test-cockle/package.json similarity index 100% rename from wasm/test/package.json rename to wasm/test-cockle/package.json diff --git a/wasm/test/rspack.config.js b/wasm/test-cockle/rspack.config.js similarity index 100% rename from wasm/test/rspack.config.js rename to wasm/test-cockle/rspack.config.js diff --git a/wasm/test/src/index.ts b/wasm/test-cockle/src/index.ts similarity index 100% rename from wasm/test/src/index.ts rename to wasm/test-cockle/src/index.ts diff --git a/wasm/test/src/utils.ts b/wasm/test-cockle/src/utils.ts similarity index 100% rename from wasm/test/src/utils.ts rename to wasm/test-cockle/src/utils.ts diff --git a/wasm/test/tsconfig.json b/wasm/test-cockle/tsconfig.json similarity index 100% rename from wasm/test/tsconfig.json rename to wasm/test-cockle/tsconfig.json diff --git a/wasm/test-lite/CMakeLists.txt b/wasm/test-lite/CMakeLists.txt new file mode 100644 index 0000000..5021d2d --- /dev/null +++ b/wasm/test-lite/CMakeLists.txt @@ -0,0 +1,31 @@ +cmake_minimum_required(VERSION 3.28) +project(git2cpp-wasm-test-lite) + +include("../common.cmake") + +set(SERVE_DIR "dist") + +add_custom_target(build-test-lite + DEPENDS build-recipe cockle + + # Installs cockle from ../cockle so that the correct prepare_wasm script is used. + COMMAND npm install + + COMMAND jupyter lite --version + COMMAND COCKLE_WASM_EXTRA_CHANNEL=${BUILT_PACKAGE_DIR} jupyter lite build --output-dir ${SERVE_DIR} + BYPRODUCTS cockle-config.json .cockle_temp/ .jupyterlite.doit.db cockle_wasm_env/ ${SERVE_DIR} +) + +add_custom_target(rebuild-test-lite + DEPENDS rebuild-recipe clean-env-test-lite build-test-lite +) + +add_custom_target(test-lite + # A pytest fixture ensures that `npm run serve` runs for the duration of the tests. + COMMAND GIT2CPP_TEST_WASM=lite pytest -v -rP + WORKING_DIRECTORY ../../test +) + +add_custom_target(clean-env-test-lite + COMMAND ${CMAKE_COMMAND} -E remove_directory cockle_wasm_env +) diff --git a/wasm/test-lite/cockle-config-in.json b/wasm/test-lite/cockle-config-in.json new file mode 100644 index 0000000..cb777b5 --- /dev/null +++ b/wasm/test-lite/cockle-config-in.json @@ -0,0 +1,15 @@ +{ + "packages": { + "git2cpp": {}, + "nano": {}, + "tree": {}, + "vim": {} + }, + "aliases": { + "git": "git2cpp", + "vi": "vim" + }, + "environment": { + "GIT_CORS_PROXY": "http://localhost:8881/" + } +} diff --git a/wasm/test-lite/jupyter-lite.json b/wasm/test-lite/jupyter-lite.json new file mode 100644 index 0000000..c9db01f --- /dev/null +++ b/wasm/test-lite/jupyter-lite.json @@ -0,0 +1,7 @@ +{ + "jupyter-lite-schema-version": 0, + "jupyter-config-data": { + "terminalsAvailable": true, + "exposeAppInBrowser": true + } +} diff --git a/wasm/test-lite/package.json b/wasm/test-lite/package.json new file mode 100644 index 0000000..52aaa5d --- /dev/null +++ b/wasm/test-lite/package.json @@ -0,0 +1,19 @@ +{ + "name": "lite-test", + "version": "1.0.0", + "description": "JupyterLite terminal tests", + "license": "BSD-3-Clause", + "author": "Ian Thomas", + "private": true, + "scripts": { + "serve": "concurrently 'npm run serve:lite' 'npm run serve:cors'", + "serve:lite": "npx static-handler --port 8001 --cors --coop --coep --corp dist", + "serve:cors": "HOST=localhost PORT=8881 node node_modules/cors-anywhere/server.js " + }, + "devDependencies": { + "@jupyterlite/cockle": "file:../cockle", + "concurrently": "^10.0.5", + "cors-anywhere": "^0.4.4", + "static-handler": "^0.5.3" + } +} diff --git a/wasm/wasm-environment.yml b/wasm/wasm-environment.yml index a5e9d9a..fc73351 100644 --- a/wasm/wasm-environment.yml +++ b/wasm/wasm-environment.yml @@ -14,7 +14,7 @@ dependencies: - rattler-build # For JupyterLite deployment - jupyter_server - - jupyterlite-terminal + - jupyterlite-terminal>=1.7.1 # For cockle and JupyterLite deployments - nodejs <25 # For testing