Skip to content

Commit 76f2107

Browse files
authored
Support git init and clone in wasm tests (#86)
1 parent 1e1c6b6 commit 76f2107

File tree

19 files changed

+307
-58
lines changed

19 files changed

+307
-58
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ __pycache__/
33
.cache/
44
compile_commands.json
55
serve.log
6+
test/test-results/

RELEASE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,6 @@ This covers making a new github release in the `git2cpp` repository, and propaga
2121

2222
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.
2323

24-
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.
24+
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.
2525

2626
Any subsequent `cockle` or JupyterLite `terminal` deployments that are rebuilt will download and use the latest `git2cpp` WebAssembly package.

test/conftest_wasm.py

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
1-
# Extra fixtures used for wasm testing.
1+
# Extra fixtures used for wasm testing, including some that override the default pytest fixtures.
22
from functools import partial
3-
from pathlib import Path
3+
import os
4+
import pathlib
45
from playwright.sync_api import Page
56
import pytest
7+
import re
68
import subprocess
79
import time
810

11+
12+
# Only include particular test files when testing wasm.
13+
# This can be removed when all tests support wasm.
14+
def pytest_ignore_collect(collection_path: pathlib.Path) -> bool:
15+
return collection_path.name not in [
16+
"test_clone.py",
17+
"test_fixtures.py",
18+
"test_git.py",
19+
"test_init.py",
20+
]
21+
22+
923
@pytest.fixture(scope="session", autouse=True)
1024
def run_web_server():
1125
with open('serve.log', 'w') as f:
12-
cwd = Path(__file__).parent.parent / 'wasm/test'
26+
cwd = pathlib.Path(__file__).parent.parent / 'wasm/test'
1327
proc = subprocess.Popen(
1428
['npm', 'run', 'serve'], stdout=f, stderr=f, cwd=cwd
1529
)
@@ -18,38 +32,113 @@ def run_web_server():
1832
yield
1933
proc.terminate()
2034

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

42+
43+
def os_chdir(dir: str):
44+
subprocess.run(["cd", str(dir)], capture_output=True, check=True, text=True)
45+
46+
47+
def os_getcwd():
48+
return subprocess.run(["pwd"], capture_output=True, check=True, text=True).stdout.strip()
49+
50+
51+
class MockPath(pathlib.Path):
52+
def __init__(self, path: str = ""):
53+
super().__init__(path)
54+
55+
def exists(self) -> bool:
56+
p = subprocess.run(['stat', str(self)])
57+
return p.returncode == 0
58+
59+
def is_dir(self) -> bool:
60+
p = subprocess.run(['stat', '-c', '%F', str(self)], capture_output=True, text=True)
61+
return p.returncode == 0 and p.stdout.strip() == 'directory'
62+
63+
def is_file(self) -> bool:
64+
p = subprocess.run(['stat', '-c', '%F', str(self)], capture_output=True, text=True)
65+
return p.returncode == 0 and p.stdout.strip() == 'regular file'
66+
67+
def iterdir(self):
68+
p = subprocess.run(["ls", str(self), '-a', '-1'], capture_output=True, text=True, check=True)
69+
for f in filter(lambda f: f not in ['', '.', '..'], re.split(r"\r?\n", p.stdout)):
70+
yield MockPath(self / f)
71+
72+
def __truediv__(self, other):
73+
if isinstance(other, str):
74+
return MockPath(f"{self}/{other}")
75+
raise RuntimeError("MockPath.__truediv__ only supports strings")
76+
77+
2778
def subprocess_run(
2879
page: Page,
2980
cmd: list[str],
3081
*,
3182
capture_output: bool = False,
32-
cwd: str | None = None,
83+
check: bool = False,
84+
cwd: str | MockPath | None = None,
3385
text: bool | None = None
3486
) -> subprocess.CompletedProcess:
87+
shell_run = "async cmd => await window.cockle.shellRun(cmd)"
88+
89+
# Set cwd.
3590
if cwd is not None:
36-
raise RuntimeError('cwd is not yet supported')
91+
proc = page.evaluate(shell_run, "pwd")
92+
if proc['returncode'] != 0:
93+
raise RuntimeError("Error getting pwd")
94+
old_cwd = proc['stdout'].strip()
95+
if old_cwd == str(cwd):
96+
# cwd is already correct.
97+
cwd = None
98+
else:
99+
proc = page.evaluate(shell_run, f"cd {cwd}")
100+
if proc['returncode'] != 0:
101+
raise RuntimeError(f"Error setting cwd to {cwd}")
102+
103+
proc = page.evaluate(shell_run, " ".join(cmd))
37104

38-
proc = page.evaluate("async cmd => window.cockle.shellRun(cmd)", cmd)
39105
# TypeScript object is auto converted to Python dict.
40106
# Want to return subprocess.CompletedProcess, consider namedtuple if this fails in future.
41107
stdout = proc['stdout'] if capture_output else ''
42108
stderr = proc['stderr'] if capture_output else ''
43109
if not text:
44110
stdout = stdout.encode("utf-8")
45111
stderr = stderr.encode("utf-8")
112+
113+
# Reset cwd.
114+
if cwd is not None:
115+
proc = page.evaluate(shell_run, "cd " + old_cwd)
116+
if proc['returncode'] != 0:
117+
raise RuntimeError(f"Error setting cwd to {old_cwd}")
118+
119+
if check and proc['returncode'] != 0:
120+
raise subprocess.CalledProcessError(proc['returncode'], cmd, stdout, stderr)
121+
46122
return subprocess.CompletedProcess(
47123
args=cmd,
48124
returncode=proc['returncode'],
49125
stdout=stdout,
50126
stderr=stderr
51127
)
52128

129+
130+
@pytest.fixture(scope="function")
131+
def tmp_path() -> MockPath:
132+
# Assumes only one tmp_path needed per test.
133+
path = MockPath('/drive/tmp0')
134+
subprocess.run(['mkdir', str(path)], check=True)
135+
assert path.exists()
136+
assert path.is_dir()
137+
return path
138+
139+
53140
@pytest.fixture(scope="function", autouse=True)
54141
def mock_subprocess_run(page: Page, monkeypatch):
55142
monkeypatch.setattr(subprocess, "run", partial(subprocess_run, page))
143+
monkeypatch.setattr(os, "chdir", os_chdir)
144+
monkeypatch.setattr(os, "getcwd", os_getcwd)

test/test_clone.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import os
21
import subprocess
3-
4-
import pytest
2+
from .conftest import GIT2CPP_TEST_WASM
53

64
url = "https://github.com/xtensor-stack/xtl.git"
75

@@ -11,18 +9,28 @@ def test_clone(git2cpp_path, tmp_path, run_in_tmp_path):
119
p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True)
1210
assert p_clone.returncode == 0
1311

14-
assert os.path.exists(os.path.join(tmp_path, "xtl"))
15-
assert os.path.exists(os.path.join(tmp_path, "xtl/include"))
12+
assert (tmp_path / "xtl").exists()
13+
assert (tmp_path / "xtl/include").exists()
1614

1715

1816
def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path):
1917
clone_cmd = [git2cpp_path, "clone", "--bare", url]
2018
p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True)
2119
assert p_clone.returncode == 0
2220

21+
assert (tmp_path / "xtl").is_dir()
22+
2323
status_cmd = [git2cpp_path, "status"]
24-
p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True)
25-
assert p_status.returncode != 0
24+
p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path / "xtl", text=True)
25+
if not GIT2CPP_TEST_WASM:
26+
# TODO: fix this in wasm build
27+
assert p_status.returncode != 0
28+
assert "This operation is not allowed against bare repositories" in p_status.stderr
29+
30+
branch_cmd = [git2cpp_path, "branch"]
31+
p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path / "xtl", text=True)
32+
assert p_branch.returncode == 0
33+
assert p_branch.stdout.strip() == "* master"
2634

2735

2836
def test_clone_shallow(git2cpp_path, tmp_path, run_in_tmp_path):

test/test_fixtures.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Test fixtures to confirm that wasm monkeypatching works correctly.
2+
3+
import re
4+
import subprocess
5+
from .conftest import GIT2CPP_TEST_WASM
6+
7+
8+
def test_run_in_tmp_path(tmp_path, run_in_tmp_path):
9+
p = subprocess.run(['pwd'], capture_output=True, text=True, check=True)
10+
assert p.stdout.strip() == str(tmp_path)
11+
12+
13+
def test_tmp_path(tmp_path):
14+
p = subprocess.run(['pwd'], capture_output=True, text=True, check=True, cwd=str(tmp_path))
15+
assert p.stdout.strip() == str(tmp_path)
16+
17+
assert tmp_path.exists()
18+
assert tmp_path.is_dir()
19+
assert not tmp_path.is_file()
20+
21+
assert sorted(tmp_path.iterdir()) == []
22+
subprocess.run(['mkdir', f"{tmp_path}/def"], capture_output=True, text=True, check=True)
23+
assert sorted(tmp_path.iterdir()) == [tmp_path / 'def']
24+
subprocess.run(['mkdir', f"{tmp_path}/abc"], capture_output=True, text=True, check=True)
25+
assert sorted(tmp_path.iterdir()) == [tmp_path / 'abc', tmp_path / 'def']
26+
27+
p = subprocess.run(['pwd'], capture_output=True, text=True, check=True, cwd=tmp_path.parent)
28+
assert p.stdout.strip() == str(tmp_path.parent)
29+
assert tmp_path in list(tmp_path.parent.iterdir())
30+
31+
32+
def test_env_vars():
33+
# By default there should be not GIT_* env vars set.
34+
p = subprocess.run(['env'], capture_output=True, text=True, check=True)
35+
git_lines = sorted(filter(lambda f: f.startswith("GIT_"), re.split(r"\r?\n", p.stdout)))
36+
if GIT2CPP_TEST_WASM:
37+
assert git_lines == ["GIT_CORS_PROXY=http://localhost:8881/"]
38+
else:
39+
assert git_lines == []

test/test_git.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import pytest
2+
import re
23
import subprocess
4+
from .conftest import GIT2CPP_TEST_WASM
35

46

57
@pytest.mark.parametrize("arg", ['-v', '--version'])
@@ -18,3 +20,26 @@ def test_error_on_unknown_option(git2cpp_path):
1820
assert p.returncode == 109
1921
assert p.stdout == b''
2022
assert p.stderr.startswith(b"The following argument was not expected: --unknown")
23+
24+
25+
@pytest.mark.skipif(not GIT2CPP_TEST_WASM, reason="Only test in WebAssembly")
26+
def test_cockle_config(git2cpp_path):
27+
# Check cockle-config shows git2cpp is available.
28+
cmd = ["cockle-config", "module", "git2cpp"]
29+
p = subprocess.run(cmd, capture_output=True, text=True)
30+
assert p.returncode == 0
31+
lines = [line for line in re.split(r"\r?\n", p.stdout) if len(line) > 0]
32+
assert len(lines) == 5
33+
assert lines[1] == "│ module │ package │ cached │"
34+
assert lines[3] == "│ git2cpp │ git2cpp │ │"
35+
36+
p = subprocess.run([git2cpp_path, "-v"], capture_output=True, text=True)
37+
assert p.returncode == 0
38+
39+
# Check git2cpp module has been cached.
40+
p = subprocess.run(cmd, capture_output=True, text=True)
41+
assert p.returncode == 0
42+
lines = [line for line in re.split(r"\r?\n", p.stdout) if len(line) > 0]
43+
assert len(lines) == 5
44+
assert lines[1] == "│ module │ package │ cached │"
45+
assert lines[3] == "│ git2cpp │ git2cpp │ yes │"

wasm/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ node_modules/
88
package-lock.json
99
.jupyterlite.doit.db
1010

11+
cockle/
12+
lite-deploy/package.json
1113
recipe/em-forge-recipes/
1214
serve/*/
1315
test/assets/*/

wasm/CMakeLists.txt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
cmake_minimum_required(VERSION 3.28)
22
project(git2cpp-wasm)
33

4+
option(USE_RECIPE_PATCHES "Use patches from emscripten-forge recipe or not" ON)
5+
option(USE_COCKLE_RELEASE "Use latest cockle release rather than repo main branch" OFF)
6+
47
add_subdirectory(recipe)
58
add_subdirectory(cockle-deploy)
69
add_subdirectory(lite-deploy)
@@ -14,3 +17,17 @@ add_custom_target(rebuild DEPENDS rebuild-recipe rebuild-cockle rebuild-lite reb
1417

1518
# Serve both cockle and JupyterLite deployments.
1619
add_custom_target(serve COMMAND npx static-handler --cors --coop --coep --corp serve)
20+
21+
if (USE_COCKLE_RELEASE)
22+
execute_process(COMMAND npm view @jupyterlite/cockle version OUTPUT_VARIABLE COCKLE_BRANCH)
23+
set(COCKLE_BRANCH "v${COCKLE_BRANCH}")
24+
else()
25+
set(COCKLE_BRANCH "main")
26+
endif()
27+
28+
add_custom_target(cockle
29+
COMMENT "Using cockle from github repository ${COCKLE_BRANCH} branch"
30+
# Don't re-clone if directory already exists - could do better here.
31+
COMMAND test -d cockle || git clone https://github.com/jupyterlite/cockle --depth 1 --branch ${COCKLE_BRANCH}
32+
COMMAND cd cockle && npm install && npm run build
33+
)

wasm/README.md

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ cmake .
3333
make
3434
```
3535

36+
The available `cmake` options are:
37+
38+
- `USE_RECIPE_PATCHES`: Use patches from emscripten-forge recipe or not, default is `ON`
39+
- `USE_COCKLE_RELEASE`: Use latest cockle release rather than repo main branch, default is `OFF`
40+
41+
For example, to run `cmake` but without using emscripten-forge recipe patches use:
42+
43+
```bash
44+
cmake . -DUSE_RECIPE_PATCHES=OFF
45+
make
46+
```
47+
3648
The built emscripten-forge package will be file named something like `git2cpp-0.0.5-h7223423_1.tar.bz2`
3749
in the directory `recipe/em-force-recipes/output/emscripten-wasm32`.
3850

@@ -53,28 +65,51 @@ Note that the `source` for the `git2cpp` package is the local filesystem rather
5365
version number of the current Emscripten-forge recipe rather than the version of the local `git2cpp`
5466
source code which can be checked using `git2cpp -v` at the `cockle`/`terminal` command line.
5567

68+
## Rebuild
69+
70+
After making changes to the local `git2cpp` source code you can rebuild the WebAssembly package,
71+
both deployments and test code using from the `wasm` directory:
72+
73+
```bash
74+
make rebuild
75+
```
76+
5677
## Test
5778

58-
To test the WebAssembly build use:
79+
To test the WebAssembly build use from the `wasm` directory:
5980

6081
```bash
6182
make test
6283
```
6384

6485
This runs (some of) the tests in the top-level `test` directory with various monkey patching so that
65-
`git2cpp` commands are executed in the browser. If there are problems running the tests then ensure
66-
you have the latest `playwright` browser installed:
67-
86+
`git2cpp` commands are executed in the browser.
87+
The tests that are run are defined in the function `pytest_ignore_collect` in `conftest_wasm.py`.
88+
If there are problems running the tests then ensure you have the latest `playwright` browser installed:
6889

6990
```bash
7091
playwright install chromium
7192
```
7293

73-
## Rebuild
94+
You can run a specific test from the top-level `test` directory (not the `wasm/test` directory)
95+
using:
7496

75-
After making changes to the local `git2cpp` source code you can rebuild the WebAssembly package,
76-
both deployments and test code using:
97+
```bash
98+
GIT2CPP_TEST_WASM=1 pytest -v test_git.py::test_version
99+
```
100+
101+
### Manually running the test servers
102+
103+
If wasm tests are failing it can be helpful to run the test servers and manually run `cockle`
104+
commands to help understand the problem. To do this use:
77105

78106
```bash
79-
make rebuild
107+
cd wasm/test
108+
npm run serve
80109
```
110+
111+
This will start both the test server on port 8000 and the CORS server on port 8881. Open a browser
112+
at http://localhost:8000/ and to run a command such as `ls -l` open the dev console and enter the
113+
following at the prompt: `await window.cockle.shellRun('ls -al')`. The generated output will appear
114+
in a new `<div>` in the web page in a format similar to that returned by Python's
115+
`subprocess.run()`.

0 commit comments

Comments
 (0)