# Extra fixtures used for wasm testing, including some that override the default pytest fixtures. from functools import partial import os import pathlib from playwright.sync_api import Page import pytest import re import subprocess import time # 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 if text.endswith("\n"): text = text[:-1] 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(map(lambda d: f"\\x{d:02x}", 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. if data.endswith("\n"): data = data[:-1] 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 spaces 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 " " in 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)