diff --git a/.gitignore b/.gitignore index 9e99726..1061b39 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .cache/ compile_commands.json serve.log +test/test-results/ diff --git a/RELEASE.md b/RELEASE.md index 2b01a44..684930f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -21,6 +21,6 @@ This covers making a new github release in the `git2cpp` repository, and propaga The Emscripten-forge recipe at https://github.com/emscripten-forge/recipes needs to be updated with the new version number and SHA checksum. An Emscripten-forge bot runs once a day and will identify the new github release and create a PR to update the recipe. Wait for this to happen, and if the tests pass and no further changes are required, the PR can be approved and merged. -After the PR is merged to `main`, the recipe will be rebuilt and uploaded to https://prefix.dev/channels/emscripten-forge-dev/packages/git2cpp, which should only take a few minutes. +After the PR is merged to `main`, the recipe will be rebuilt and uploaded to https://prefix.dev/channels/emscripten-forge-4x/packages/git2cpp, which should only take a few minutes. Any subsequent `cockle` or JupyterLite `terminal` deployments that are rebuilt will download and use the latest `git2cpp` WebAssembly package. diff --git a/test/conftest_wasm.py b/test/conftest_wasm.py index 89d5061..1c4fae0 100644 --- a/test/conftest_wasm.py +++ b/test/conftest_wasm.py @@ -1,15 +1,29 @@ -# Extra fixtures used for wasm testing. +# Extra fixtures used for wasm testing, including some that override the default pytest fixtures. from functools import partial -from pathlib import Path +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_clone.py", + "test_fixtures.py", + "test_git.py", + "test_init.py", + ] + + @pytest.fixture(scope="session", autouse=True) def run_web_server(): with open('serve.log', 'w') as f: - cwd = Path(__file__).parent.parent / 'wasm/test' + cwd = pathlib.Path(__file__).parent.parent / 'wasm/test' proc = subprocess.Popen( ['npm', 'run', 'serve'], stdout=f, stderr=f, cwd=cwd ) @@ -18,24 +32,76 @@ def run_web_server(): 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() + +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() + + +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 __truediv__(self, other): + if isinstance(other, str): + return MockPath(f"{self}/{other}") + raise RuntimeError("MockPath.__truediv__ only supports strings") + + def subprocess_run( page: Page, cmd: list[str], *, capture_output: bool = False, - cwd: str | None = None, + check: bool = False, + cwd: str | MockPath | None = None, text: bool | None = None ) -> subprocess.CompletedProcess: + shell_run = "async cmd => await window.cockle.shellRun(cmd)" + + # Set cwd. if cwd is not None: - raise RuntimeError('cwd is not yet supported') + proc = page.evaluate(shell_run, "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, f"cd {cwd}") + if proc['returncode'] != 0: + raise RuntimeError(f"Error setting cwd to {cwd}") + + proc = page.evaluate(shell_run, " ".join(cmd)) - proc = page.evaluate("async cmd => window.cockle.shellRun(cmd)", cmd) # TypeScript object is auto converted to Python dict. # Want to return subprocess.CompletedProcess, consider namedtuple if this fails in future. stdout = proc['stdout'] if capture_output else '' @@ -43,6 +109,16 @@ def subprocess_run( 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, "cd " + old_cwd) + if proc['returncode'] != 0: + raise RuntimeError(f"Error setting cwd to {old_cwd}") + + if check and proc['returncode'] != 0: + raise subprocess.CalledProcessError(proc['returncode'], cmd, stdout, stderr) + return subprocess.CompletedProcess( args=cmd, returncode=proc['returncode'], @@ -50,6 +126,19 @@ def subprocess_run( 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) diff --git a/test/test_clone.py b/test/test_clone.py index 0461d43..57144b4 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,7 +1,5 @@ -import os import subprocess - -import pytest +from .conftest import GIT2CPP_TEST_WASM url = "https://github.com/xtensor-stack/xtl.git" @@ -11,8 +9,8 @@ def test_clone(git2cpp_path, tmp_path, run_in_tmp_path): p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_clone.returncode == 0 - assert os.path.exists(os.path.join(tmp_path, "xtl")) - assert os.path.exists(os.path.join(tmp_path, "xtl/include")) + assert (tmp_path / "xtl").exists() + assert (tmp_path / "xtl/include").exists() def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path): @@ -20,9 +18,19 @@ def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path): p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_clone.returncode == 0 + assert (tmp_path / "xtl").is_dir() + status_cmd = [git2cpp_path, "status"] - p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) - assert p_status.returncode != 0 + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path / "xtl", text=True) + if not GIT2CPP_TEST_WASM: + # TODO: fix this in wasm build + assert p_status.returncode != 0 + assert "This operation is not allowed against bare repositories" in p_status.stderr + + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path / "xtl", text=True) + assert p_branch.returncode == 0 + assert p_branch.stdout.strip() == "* master" def test_clone_shallow(git2cpp_path, tmp_path, run_in_tmp_path): diff --git a/test/test_fixtures.py b/test/test_fixtures.py new file mode 100644 index 0000000..c8c0c04 --- /dev/null +++ b/test/test_fixtures.py @@ -0,0 +1,39 @@ +# Test fixtures to confirm that wasm monkeypatching works correctly. + +import re +import subprocess +from .conftest import GIT2CPP_TEST_WASM + + +def test_run_in_tmp_path(tmp_path, run_in_tmp_path): + p = subprocess.run(['pwd'], capture_output=True, text=True, check=True) + assert p.stdout.strip() == str(tmp_path) + + +def test_tmp_path(tmp_path): + p = subprocess.run(['pwd'], capture_output=True, text=True, check=True, cwd=str(tmp_path)) + assert p.stdout.strip() == str(tmp_path) + + assert tmp_path.exists() + assert tmp_path.is_dir() + assert not tmp_path.is_file() + + assert sorted(tmp_path.iterdir()) == [] + subprocess.run(['mkdir', f"{tmp_path}/def"], capture_output=True, text=True, check=True) + assert sorted(tmp_path.iterdir()) == [tmp_path / 'def'] + subprocess.run(['mkdir', f"{tmp_path}/abc"], capture_output=True, text=True, check=True) + assert sorted(tmp_path.iterdir()) == [tmp_path / 'abc', tmp_path / 'def'] + + p = subprocess.run(['pwd'], capture_output=True, text=True, check=True, cwd=tmp_path.parent) + assert p.stdout.strip() == str(tmp_path.parent) + assert tmp_path in list(tmp_path.parent.iterdir()) + + +def test_env_vars(): + # By default there should be not GIT_* env vars set. + p = subprocess.run(['env'], capture_output=True, text=True, check=True) + git_lines = sorted(filter(lambda f: f.startswith("GIT_"), re.split(r"\r?\n", p.stdout))) + if GIT2CPP_TEST_WASM: + assert git_lines == ["GIT_CORS_PROXY=http://localhost:8881/"] + else: + assert git_lines == [] diff --git a/test/test_git.py b/test/test_git.py index 8bf5e4f..50a954e 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -1,5 +1,7 @@ import pytest +import re import subprocess +from .conftest import GIT2CPP_TEST_WASM @pytest.mark.parametrize("arg", ['-v', '--version']) @@ -18,3 +20,26 @@ def test_error_on_unknown_option(git2cpp_path): assert p.returncode == 109 assert p.stdout == b'' assert p.stderr.startswith(b"The following argument was not expected: --unknown") + + +@pytest.mark.skipif(not GIT2CPP_TEST_WASM, reason="Only test in WebAssembly") +def test_cockle_config(git2cpp_path): + # Check cockle-config shows git2cpp is available. + cmd = ["cockle-config", "module", "git2cpp"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + lines = [line for line in re.split(r"\r?\n", p.stdout) if len(line) > 0] + assert len(lines) == 5 + assert lines[1] == "│ module │ package │ cached │" + assert lines[3] == "│ git2cpp │ git2cpp │ │" + + p = subprocess.run([git2cpp_path, "-v"], capture_output=True, text=True) + assert p.returncode == 0 + + # Check git2cpp module has been cached. + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + lines = [line for line in re.split(r"\r?\n", p.stdout) if len(line) > 0] + assert len(lines) == 5 + assert lines[1] == "│ module │ package │ cached │" + assert lines[3] == "│ git2cpp │ git2cpp │ yes │" diff --git a/wasm/.gitignore b/wasm/.gitignore index a4c0e06..8f8b10f 100644 --- a/wasm/.gitignore +++ b/wasm/.gitignore @@ -8,6 +8,8 @@ node_modules/ package-lock.json .jupyterlite.doit.db +cockle/ +lite-deploy/package.json recipe/em-forge-recipes/ serve/*/ test/assets/*/ diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt index f8a6670..a6d0df5 100644 --- a/wasm/CMakeLists.txt +++ b/wasm/CMakeLists.txt @@ -1,6 +1,9 @@ cmake_minimum_required(VERSION 3.28) project(git2cpp-wasm) +option(USE_RECIPE_PATCHES "Use patches from emscripten-forge recipe or not" ON) +option(USE_COCKLE_RELEASE "Use latest cockle release rather than repo main branch" OFF) + add_subdirectory(recipe) add_subdirectory(cockle-deploy) add_subdirectory(lite-deploy) @@ -14,3 +17,17 @@ add_custom_target(rebuild DEPENDS rebuild-recipe rebuild-cockle rebuild-lite reb # Serve both cockle and JupyterLite deployments. add_custom_target(serve COMMAND npx static-handler --cors --coop --coep --corp serve) + +if (USE_COCKLE_RELEASE) + execute_process(COMMAND npm view @jupyterlite/cockle version OUTPUT_VARIABLE COCKLE_BRANCH) + set(COCKLE_BRANCH "v${COCKLE_BRANCH}") +else() + set(COCKLE_BRANCH "main") +endif() + +add_custom_target(cockle + COMMENT "Using cockle from github repository ${COCKLE_BRANCH} branch" + # Don't re-clone if directory already exists - could do better here. + COMMAND test -d cockle || git clone https://github.com/jupyterlite/cockle --depth 1 --branch ${COCKLE_BRANCH} + COMMAND cd cockle && npm install && npm run build +) diff --git a/wasm/README.md b/wasm/README.md index 899c0d2..75b828c 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -33,6 +33,18 @@ cmake . make ``` +The available `cmake` options are: + +- `USE_RECIPE_PATCHES`: Use patches from emscripten-forge recipe or not, default is `ON` +- `USE_COCKLE_RELEASE`: Use latest cockle release rather than repo main branch, default is `OFF` + +For example, to run `cmake` but without using emscripten-forge recipe patches use: + +```bash +cmake . -DUSE_RECIPE_PATCHES=OFF +make +``` + The built emscripten-forge package will be file named something like `git2cpp-0.0.5-h7223423_1.tar.bz2` in the directory `recipe/em-force-recipes/output/emscripten-wasm32`. @@ -53,28 +65,51 @@ Note that the `source` for the `git2cpp` package is the local filesystem rather version number of the current Emscripten-forge recipe rather than the version of the local `git2cpp` source code which can be checked using `git2cpp -v` at the `cockle`/`terminal` command line. +## Rebuild + +After making changes to the local `git2cpp` source code you can rebuild the WebAssembly package, +both deployments and test code using from the `wasm` directory: + +```bash +make rebuild +``` + ## Test -To test the WebAssembly build use: +To test the WebAssembly build use from the `wasm` directory: ```bash make test ``` This runs (some of) the tests in the top-level `test` directory with various monkey patching so that -`git2cpp` commands are executed in the browser. If there are problems running the tests then ensure -you have the latest `playwright` browser installed: - +`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 playwright install chromium ``` -## Rebuild +You can run a specific test from the top-level `test` directory (not the `wasm/test` directory) +using: -After making changes to the local `git2cpp` source code you can rebuild the WebAssembly package, -both deployments and test code using: +```bash +GIT2CPP_TEST_WASM=1 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: ```bash -make rebuild +cd wasm/test +npm run serve ``` + +This will start both the test server on port 8000 and the CORS server on port 8881. Open a browser +at http://localhost:8000/ and to run a command such as `ls -l` open the dev console and enter the +following at the prompt: `await window.cockle.shellRun('ls -al')`. The generated output will appear +in a new `