diff --git a/.gitignore b/.gitignore
index e4f7ae8..9e99726 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ build/
__pycache__/
.cache/
compile_commands.json
+serve.log
diff --git a/README.md b/README.md
index 64c6743..6a05a76 100644
--- a/README.md
+++ b/README.md
@@ -25,3 +25,13 @@ The CLI is tested using `python`. From the top-level directory:
```bash
pytest -v
```
+
+# WebAssembly build and deployment
+
+The `wasm` directory contains everything needed to build the local `git2cpp` source code as an
+WebAssembly [Emscripten-forge](https://emscripten-forge.org/) package, create local
+[cockle](https://github.com/jupyterlite/cockle) and
+[JupyterLite terminal](https://github.com/jupyterlite/terminal) deployments that run in a browser,
+and test the WebAssembly build.
+
+See the `README.md` in the `wasm` directory for further details.
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/conftest.py b/test/conftest.py
index 576b5a3..3266ce9 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -3,6 +3,10 @@
import pytest
import subprocess
+GIT2CPP_TEST_WASM = os.getenv('GIT2CPP_TEST_WASM') == "1"
+
+if GIT2CPP_TEST_WASM:
+ from .conftest_wasm import *
# Fixture to run test in current tmp_path
@pytest.fixture
@@ -14,7 +18,10 @@ def run_in_tmp_path(tmp_path):
@pytest.fixture(scope='session')
def git2cpp_path():
- return Path(__file__).parent.parent / 'build' / 'git2cpp'
+ if GIT2CPP_TEST_WASM:
+ return 'git2cpp'
+ else:
+ return Path(__file__).parent.parent / 'build' / 'git2cpp'
@pytest.fixture
def xtl_clone(git2cpp_path, tmp_path, run_in_tmp_path):
diff --git a/test/conftest_wasm.py b/test/conftest_wasm.py
new file mode 100644
index 0000000..89d5061
--- /dev/null
+++ b/test/conftest_wasm.py
@@ -0,0 +1,55 @@
+# Extra fixtures used for wasm testing.
+from functools import partial
+from pathlib import Path
+from playwright.sync_api import Page
+import pytest
+import subprocess
+import time
+
+@pytest.fixture(scope="session", autouse=True)
+def run_web_server():
+ with open('serve.log', 'w') as f:
+ cwd = 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(0.3)
+ 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 subprocess_run(
+ page: Page,
+ cmd: list[str],
+ *,
+ capture_output: bool = False,
+ cwd: str | None = None,
+ text: bool | None = None
+) -> subprocess.CompletedProcess:
+ if cwd is not None:
+ raise RuntimeError('cwd is not yet supported')
+
+ 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 ''
+ stderr = proc['stderr'] if capture_output else ''
+ if not text:
+ stdout = stdout.encode("utf-8")
+ stderr = stderr.encode("utf-8")
+ return subprocess.CompletedProcess(
+ args=cmd,
+ returncode=proc['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))
diff --git a/wasm/.gitignore b/wasm/.gitignore
new file mode 100644
index 0000000..abcddf3
--- /dev/null
+++ b/wasm/.gitignore
@@ -0,0 +1,2 @@
+serve/cockle/
+serve/lite/
diff --git a/wasm/Makefile b/wasm/Makefile
new file mode 100644
index 0000000..1500fc4
--- /dev/null
+++ b/wasm/Makefile
@@ -0,0 +1,28 @@
+default: build
+.PHONY: build clean rebuild serve test
+
+build:
+ make -C build $@
+ make -C cockle-deploy $@
+ make -C lite-deploy $@
+ make -C test $@
+
+# Rebuild after change in C++ source code.
+rebuild:
+ make -C build $@
+ make -C cockle-deploy $@
+ make -C lite-deploy $@
+ make -C test $@
+
+# Serve both cockle and JupyterLite deployments.
+serve:
+ npx static-handler serve
+
+test:
+ make -C test $@
+
+clean:
+ make -C build $@
+ make -C cockle-deploy $@
+ make -C lite-deploy $@
+ make -C test $@
diff --git a/wasm/README.md b/wasm/README.md
new file mode 100644
index 0000000..3b0a05a
--- /dev/null
+++ b/wasm/README.md
@@ -0,0 +1,70 @@
+# Building and testing git2cpp in WebAssembly
+
+This directory contains everything needed to build the local `git2cpp` source code as an
+WebAssembly [Emscripten-forge](https://emscripten-forge.org/) package, create local
+[cockle](https://github.com/jupyterlite/cockle) and
+[JupyterLite terminal](https://github.com/jupyterlite/terminal) deployments that run in a browser,
+and test the WebAssembly build.
+
+It works on Linux and macOS but not Windows.
+
+There are 5 sub-directories:
+
+- `build`: 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.
+
+## Build and deploy
+
+The build, deploy and test process uses a separate `micromamba` environment defined in
+`wasm-environment.yml`. To set this up use from within this directory:
+
+```bash
+micromamba create -f wasm-environment.yml
+micromamba activate git2cpp-wasm
+```
+
+Then to build the WebAssembly package, both deployments and the testing resources use:
+
+```bash
+make
+```
+
+The local deployments in the `serve` directory can be manually checked using:
+
+```bash
+make serve
+```
+
+and open a web browser at http://localhost:8080/. Confirm that the local build of `git2cpp` is being
+used in the two deployments by running `cockle-config package` at the command line, the output
+should be something like:
+
+
+
+Note that the `source` for the `git2cpp` package is the local filesystem rather than from
+`prefix.dev`. The version number of `git2cpp` in this table is not necessarily correct as it is the
+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.
+
+## Test
+
+To test the WebAssembly build use:
+
+```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.
+
+## Rebuild
+
+After making changes to the local `git2cpp` source code you can rebuild the WebAssembly package,
+both deployments and test code using:
+
+```bash
+make rebuild
+```
diff --git a/wasm/build/.gitignore b/wasm/build/.gitignore
new file mode 100644
index 0000000..5cb90c1
--- /dev/null
+++ b/wasm/build/.gitignore
@@ -0,0 +1 @@
+em-forge-recipes/
diff --git a/wasm/build/Makefile b/wasm/build/Makefile
new file mode 100644
index 0000000..5335964
--- /dev/null
+++ b/wasm/build/Makefile
@@ -0,0 +1,31 @@
+default: build
+.PHONY: build clean clean-output-dir modify-recipe rebuild
+
+include ../common.mk
+
+EM_FORGE_RECIPES_REPO = https://github.com/emscripten-forge/recipes
+GIT2CPP_RECIPE_DIR = recipes/recipes_emscripten/git2cpp
+RATTLER_ARGS = --package-format tar-bz2 --target-platform emscripten-wasm32 -c https://repo.prefix.dev/emscripten-forge-dev -c microsoft -c conda-forge
+
+# Only want the git2cpp recipe from emscripten-forge/recipes repo, not the whole repo.
+# Note removing the .git directory otherwise `git clean -fxd` will not remove the directory.
+$(EM_FORGE_RECIPES_DIR):
+ git clone --no-checkout ${EM_FORGE_RECIPES_REPO} --depth 1 $@
+ cd $@ && git sparse-checkout init
+ cd $@ && git sparse-checkout set $(GIT2CPP_RECIPE_DIR)
+ cd $@ && git checkout main
+ rm -rf $@/.git
+
+modify-recipe: $(EM_FORGE_RECIPES_DIR)
+ python modify-recipe.py $(EM_FORGE_RECIPES_DIR)/$(GIT2CPP_RECIPE_DIR)
+
+build: modify-recipe
+ cd $(EM_FORGE_RECIPES_DIR) && rattler-build build $(RATTLER_ARGS) --recipe $(GIT2CPP_RECIPE_DIR)
+
+rebuild: clean-output-dir build
+
+clean:
+ rm -rf $(EM_FORGE_RECIPES_DIR)
+
+clean-output-dir:
+ rm -rf $(BUILT_PACKAGE_DIR)
diff --git a/wasm/build/modify-recipe.py b/wasm/build/modify-recipe.py
new file mode 100644
index 0000000..70a3d83
--- /dev/null
+++ b/wasm/build/modify-recipe.py
@@ -0,0 +1,49 @@
+# Modify the git2cpp emscripten-forge recipe to build from the local repo.
+# This can be called repeatedly and will produce the same output.
+
+from pathlib import Path
+import shutil
+import sys
+import yaml
+
+
+def quit(msg):
+ print(msg)
+ exit(1)
+
+if len(sys.argv) < 2:
+ quit(f'Usage: {sys.argv[0]} ')
+
+input_dir = Path(sys.argv[1])
+if not input_dir.is_dir():
+ quit(f'{input_dir} should exist and be a directory')
+
+input_filename = input_dir / 'recipe.yaml'
+if not input_filename.is_file():
+ quit(f'{input_filename} should exist and be a file')
+
+# If backup does not exist create it.
+input_backup = input_dir / 'recipe_original.yaml'
+backup_exists = input_backup.exists()
+if not backup_exists:
+ shutil.copy(input_filename, input_backup)
+
+# Read and parse input backup file which is the original recipe file.
+with open(input_backup) as f:
+ recipe = yaml.safe_load(f)
+
+build_number = recipe['build']['number']
+print(f' Changing build number from {build_number} to {build_number+1}')
+recipe['build']['number'] = build_number+1
+
+source = recipe['source']
+if not ('sha256' in source and 'url' in source):
+ raise RuntimeError('Expected recipe to have both a source sha256 and url')
+del source['sha256']
+del source['url']
+print(' Changing source to point to local git2cpp repo')
+source['path'] = '../../../../../../'
+
+# Overwrite recipe file.
+with open(input_filename, 'w') as f:
+ yaml.dump(recipe, f)
diff --git a/wasm/cockle-config.png b/wasm/cockle-config.png
new file mode 100644
index 0000000..c38454d
Binary files /dev/null and b/wasm/cockle-config.png differ
diff --git a/wasm/cockle-deploy/.gitignore b/wasm/cockle-deploy/.gitignore
new file mode 100644
index 0000000..8ecb5e8
--- /dev/null
+++ b/wasm/cockle-deploy/.gitignore
@@ -0,0 +1,3 @@
+cockle_wasm_env/
+node_modules/
+package-lock.json
diff --git a/wasm/cockle-deploy/Makefile b/wasm/cockle-deploy/Makefile
new file mode 100644
index 0000000..f4ccd5d
--- /dev/null
+++ b/wasm/cockle-deploy/Makefile
@@ -0,0 +1,16 @@
+default: build
+.PHONY: build clean clean-env rebuild
+
+include ../common.mk
+
+build:
+ npm install
+ COCKLE_WASM_EXTRA_CHANNEL=$(BUILT_PACKAGE_DIR) npm run build
+
+rebuild: clean-env build
+
+clean: clean-env
+ rm -rf ../serve/cockle node_modules/
+
+clean-env:
+ rm -rf cockle_wasm_env/
diff --git a/wasm/cockle-deploy/assets/index.html b/wasm/cockle-deploy/assets/index.html
new file mode 100644
index 0000000..1b1b8cc
--- /dev/null
+++ b/wasm/cockle-deploy/assets/index.html
@@ -0,0 +1,10 @@
+
+
+
+ cockle-deployment for git2cpp
+
+
+
+
+
+
diff --git a/wasm/cockle-deploy/cockle-config-in.json b/wasm/cockle-deploy/cockle-config-in.json
new file mode 100644
index 0000000..bbb59a1
--- /dev/null
+++ b/wasm/cockle-deploy/cockle-config-in.json
@@ -0,0 +1,19 @@
+{
+ "packages": {
+ "git2cpp": {},
+ "nano": {},
+ "tree": {},
+ "vim": {}
+ },
+ "aliases": {
+ "git": "git2cpp",
+ "vi": "vim"
+ },
+ "environment": {
+ "GIT_CORS_PROXY": "https://corsproxy.io/?url=",
+ "GIT_AUTHOR_NAME": "Jane Doe",
+ "GIT_AUTHOR_EMAIL": "jane.doe@blabla.com",
+ "GIT_COMMITTER_NAME": "Jane Doe",
+ "GIT_COMMITTER_EMAIL": "jane.doe@blabla.com"
+ }
+}
diff --git a/wasm/cockle-deploy/package.json b/wasm/cockle-deploy/package.json
new file mode 100644
index 0000000..02becca
--- /dev/null
+++ b/wasm/cockle-deploy/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "cockle-deploy",
+ "scripts": {
+ "build": "rspack build",
+ "postbuild": "npm run postbuild:wasm && npm run postbuild:index",
+ "postbuild:wasm": "node node_modules/@jupyterlite/cockle/lib/tools/prepare_wasm.js --copy ../serve/cockle/",
+ "postbuild:index": "cp assets/index.html ../serve/cockle/"
+ },
+ "main": "../serve/cockle/index.js",
+ "types": "../serve/cockle/index.d.ts",
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "@rspack/cli": "^1.0.4",
+ "@rspack/core": "^1.0.4",
+ "css-loader": "^7.1.2",
+ "style-loader": "^4.0.0",
+ "ts-loader": "^9.5.1",
+ "typescript": "^5.4.5"
+ },
+ "dependencies": {
+ "@jupyterlite/cockle": "^1.2.0",
+ "@xterm/addon-fit": "^0.10.0",
+ "@xterm/xterm": "^5.5.0",
+ "deepmerge-ts": "^7.1.4"
+ }
+}
diff --git a/wasm/cockle-deploy/rspack.config.js b/wasm/cockle-deploy/rspack.config.js
new file mode 100644
index 0000000..d6e29ff
--- /dev/null
+++ b/wasm/cockle-deploy/rspack.config.js
@@ -0,0 +1,29 @@
+const path = require('path');
+
+module.exports = {
+ mode: 'development',
+ entry: './src/index.ts',
+ module: {
+ rules: [
+ {
+ test: /\.tsx?$/,
+ use: 'ts-loader',
+ exclude: /node_modules/,
+ },
+ {
+ test: /\.css$/,
+ use: [
+ 'style-loader',
+ 'css-loader'
+ ]
+ }
+ ]
+ },
+ resolve: {
+ extensions: ['.tsx', '.ts', '.js'],
+ },
+ output: {
+ filename: 'bundle.js',
+ path: path.resolve(__dirname, '../serve/cockle'),
+ }
+};
diff --git a/wasm/cockle-deploy/src/defs.ts b/wasm/cockle-deploy/src/defs.ts
new file mode 100644
index 0000000..a44acd7
--- /dev/null
+++ b/wasm/cockle-deploy/src/defs.ts
@@ -0,0 +1,10 @@
+import { IShellManager } from '@jupyterlite/cockle';
+
+export namespace IDeployment {
+ export interface IOptions {
+ baseUrl: string;
+ browsingContextId: string;
+ shellManager: IShellManager;
+ targetDiv: HTMLElement;
+ }
+}
diff --git a/wasm/cockle-deploy/src/deployment.ts b/wasm/cockle-deploy/src/deployment.ts
new file mode 100644
index 0000000..e10b1ca
--- /dev/null
+++ b/wasm/cockle-deploy/src/deployment.ts
@@ -0,0 +1,63 @@
+import { Shell } from '@jupyterlite/cockle'
+import { FitAddon } from '@xterm/addon-fit'
+import { Terminal } from '@xterm/xterm'
+import { IDeployment } from './defs'
+
+export class Deployment {
+ constructor(options: IDeployment.IOptions) {
+ this._targetDiv = options.targetDiv;
+
+ const termOptions = {
+ rows: 50,
+ theme: {
+ foreground: "ivory",
+ background: "#111111",
+ cursor: "silver"
+ },
+ }
+ this._term = new Terminal(termOptions)
+
+ this._fitAddon = new FitAddon()
+ this._term.loadAddon(this._fitAddon)
+
+ const { baseUrl, browsingContextId, shellManager } = options;
+
+ this._shell = new Shell({
+ browsingContextId,
+ baseUrl,
+ wasmBaseUrl: baseUrl,
+ shellManager,
+ outputCallback: this.outputCallback.bind(this),
+ })
+ }
+
+ async start(): Promise {
+ this._term!.onResize(async (arg: any) => await this.onResize(arg))
+ this._term!.onData(async (data: string) => await this.onData(data))
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ this._fitAddon!.fit()
+ })
+
+ this._term!.open(this._targetDiv)
+ await this._shell.start()
+ resizeObserver.observe(this._targetDiv)
+ }
+
+ async onData(data: string): Promise {
+ await this._shell.input(data)
+ }
+
+ async onResize(arg: any): Promise {
+ await this._shell.setSize(arg.rows, arg.cols)
+ }
+
+ private outputCallback(text: string): void {
+ this._term!.write(text)
+ }
+
+ private _targetDiv: HTMLElement;
+ private _term: Terminal
+ private _fitAddon: FitAddon
+ private _shell: Shell
+}
diff --git a/wasm/cockle-deploy/src/index.ts b/wasm/cockle-deploy/src/index.ts
new file mode 100644
index 0000000..ac63e63
--- /dev/null
+++ b/wasm/cockle-deploy/src/index.ts
@@ -0,0 +1,13 @@
+import { ShellManager } from '@jupyterlite/cockle';
+import "./style/deployment.css"
+import { Deployment } from "./deployment";
+
+document.addEventListener("DOMContentLoaded", async () => {
+ const baseUrl = window.location.href;
+ const shellManager = new ShellManager();
+ const browsingContextId = await shellManager.installServiceWorker(baseUrl);
+
+ const targetDiv: HTMLElement = document.getElementById('targetdiv')!;
+ const playground = new Deployment({ baseUrl, browsingContextId, shellManager, targetDiv });
+ await playground.start();
+})
diff --git a/wasm/cockle-deploy/src/style/deployment.css b/wasm/cockle-deploy/src/style/deployment.css
new file mode 100644
index 0000000..a4f26b4
--- /dev/null
+++ b/wasm/cockle-deploy/src/style/deployment.css
@@ -0,0 +1,10 @@
+@import '~@xterm/xterm/css/xterm.css';
+
+body {
+ background-color: darkslategray;
+}
+
+#targetdiv {
+ border: 2px solid black;
+ height: 100%;
+}
diff --git a/wasm/cockle-deploy/tsconfig.json b/wasm/cockle-deploy/tsconfig.json
new file mode 100644
index 0000000..89b3e89
--- /dev/null
+++ b/wasm/cockle-deploy/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "allowSyntheticDefaultImports": true,
+ "composite": true,
+ "declaration": true,
+ "esModuleInterop": true,
+ "incremental": true,
+ "lib": ["es2022", "webworker"],
+ "module": "esnext",
+ "moduleResolution": "node",
+ "noEmitOnError": true,
+ "noImplicitAny": true,
+ "noUnusedLocals": true,
+ "preserveWatchOutput": true,
+ "resolveJsonModule": true,
+ "outDir": "../serve/cockle",
+ "rootDir": "src",
+ "strict": true,
+ "strictNullChecks": true,
+ "target": "es2022",
+ "sourceMap": true
+ },
+ "include": ["src/**/*"]
+}
diff --git a/wasm/common.mk b/wasm/common.mk
new file mode 100644
index 0000000..00861b4
--- /dev/null
+++ b/wasm/common.mk
@@ -0,0 +1,5 @@
+# Relative to build directory
+EM_FORGE_RECIPES_DIR = em-forge-recipes
+
+# Relative to subdirectories
+BUILT_PACKAGE_DIR = ../build/$(EM_FORGE_RECIPES_DIR)/output
diff --git a/wasm/lite-deploy/.gitignore b/wasm/lite-deploy/.gitignore
new file mode 100644
index 0000000..4dba5b3
--- /dev/null
+++ b/wasm/lite-deploy/.gitignore
@@ -0,0 +1,4 @@
+.cockle_temp/
+.jupyterlite.doit.db
+cockle-config.json
+cockle_wasm_env/
diff --git a/wasm/lite-deploy/Makefile b/wasm/lite-deploy/Makefile
new file mode 100644
index 0000000..90464d7
--- /dev/null
+++ b/wasm/lite-deploy/Makefile
@@ -0,0 +1,15 @@
+default: build
+.PHONY: build clean rebuild
+
+include ../common.mk
+
+OUTPUT_DIR=../serve/lite
+
+build:
+ jupyter lite --version
+ COCKLE_WASM_EXTRA_CHANNEL=$(BUILT_PACKAGE_DIR) jupyter lite build --output-dir $(OUTPUT_DIR)
+
+rebuild: clean build
+
+clean:
+ rm -rf .cockle_temp/ .jupyterlite.doit.db cockle_wasm_env/ $(OUTPUT_DIR)
diff --git a/wasm/lite-deploy/cockle-config-in.json b/wasm/lite-deploy/cockle-config-in.json
new file mode 100644
index 0000000..bbb59a1
--- /dev/null
+++ b/wasm/lite-deploy/cockle-config-in.json
@@ -0,0 +1,19 @@
+{
+ "packages": {
+ "git2cpp": {},
+ "nano": {},
+ "tree": {},
+ "vim": {}
+ },
+ "aliases": {
+ "git": "git2cpp",
+ "vi": "vim"
+ },
+ "environment": {
+ "GIT_CORS_PROXY": "https://corsproxy.io/?url=",
+ "GIT_AUTHOR_NAME": "Jane Doe",
+ "GIT_AUTHOR_EMAIL": "jane.doe@blabla.com",
+ "GIT_COMMITTER_NAME": "Jane Doe",
+ "GIT_COMMITTER_EMAIL": "jane.doe@blabla.com"
+ }
+}
diff --git a/wasm/lite-deploy/jupyter-lite.json b/wasm/lite-deploy/jupyter-lite.json
new file mode 100644
index 0000000..31aaf07
--- /dev/null
+++ b/wasm/lite-deploy/jupyter-lite.json
@@ -0,0 +1,6 @@
+{
+ "jupyter-lite-schema-version": 0,
+ "jupyter-config-data": {
+ "terminalsAvailable": true
+ }
+}
diff --git a/wasm/serve/index.html b/wasm/serve/index.html
new file mode 100644
index 0000000..61743ea
--- /dev/null
+++ b/wasm/serve/index.html
@@ -0,0 +1,13 @@
+
+
+
+ git2cpp deployments
+
+
+ Deployments using WebAssembly build of git2cpp branch
+
+
+
diff --git a/wasm/test/.gitignore b/wasm/test/.gitignore
new file mode 100644
index 0000000..73d19cb
--- /dev/null
+++ b/wasm/test/.gitignore
@@ -0,0 +1,5 @@
+assets/*
+cockle_wasm_env/
+lib/
+node_modules/
+package-lock.json
diff --git a/wasm/test/Makefile b/wasm/test/Makefile
new file mode 100644
index 0000000..000d2ad
--- /dev/null
+++ b/wasm/test/Makefile
@@ -0,0 +1,20 @@
+default: build
+.PHONY: build clean clean-env rebuild test
+
+include ../common.mk
+
+build:
+ npm install
+ COCKLE_WASM_EXTRA_CHANNEL=$(BUILT_PACKAGE_DIR) npm run build
+
+rebuild: clean-env build
+
+test:
+ # A pytest fixture ensures that `npm run serve` runs for the duration of the tests.
+ cd ../../test && GIT2CPP_TEST_WASM=1 pytest -v -rP test_git.py
+
+clean-env:
+ rm -rf cockle_wasm_env/
+
+clean: clean-env
+ rm -rf lib/ node_modules/
diff --git a/wasm/test/assets/index.html b/wasm/test/assets/index.html
new file mode 100644
index 0000000..57ba584
--- /dev/null
+++ b/wasm/test/assets/index.html
@@ -0,0 +1,9 @@
+
+
+
+ Testing git2cpp in cockle
+
+
+
+
+
diff --git a/wasm/test/cockle-config-in.json b/wasm/test/cockle-config-in.json
new file mode 100644
index 0000000..bbb59a1
--- /dev/null
+++ b/wasm/test/cockle-config-in.json
@@ -0,0 +1,19 @@
+{
+ "packages": {
+ "git2cpp": {},
+ "nano": {},
+ "tree": {},
+ "vim": {}
+ },
+ "aliases": {
+ "git": "git2cpp",
+ "vi": "vim"
+ },
+ "environment": {
+ "GIT_CORS_PROXY": "https://corsproxy.io/?url=",
+ "GIT_AUTHOR_NAME": "Jane Doe",
+ "GIT_AUTHOR_EMAIL": "jane.doe@blabla.com",
+ "GIT_COMMITTER_NAME": "Jane Doe",
+ "GIT_COMMITTER_EMAIL": "jane.doe@blabla.com"
+ }
+}
diff --git a/wasm/test/package.json b/wasm/test/package.json
new file mode 100644
index 0000000..6ceef40
--- /dev/null
+++ b/wasm/test/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cockle-test",
+ "version": "1.0.0",
+ "description": "Cockle tests",
+ "license": "BSD-3-Clause",
+ "author": "Ian Thomas",
+ "main": "lib/index.js",
+ "types": "lib/index.d.ts",
+ "private": true,
+ "scripts": {
+ "build": "rspack build",
+ "postbuild": "node node_modules/@jupyterlite/cockle/lib/tools/prepare_wasm.js --copy assets",
+ "serve": "rspack serve"
+ },
+ "devDependencies": {
+ "@jupyterlite/cockle": "^1.2.0",
+ "@rspack/cli": "^0.7.5",
+ "@rspack/core": "^0.7.5",
+ "ts-loader": "^9.5.1",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.5.4"
+ }
+}
diff --git a/wasm/test/rspack.config.js b/wasm/test/rspack.config.js
new file mode 100644
index 0000000..f2fa264
--- /dev/null
+++ b/wasm/test/rspack.config.js
@@ -0,0 +1,36 @@
+const path = require('path');
+
+module.exports = {
+ mode: 'development',
+ entry: './src/index.ts',
+ module: {
+ rules: [
+ {
+ test: /\.tsx?$/,
+ use: 'ts-loader',
+ exclude: /node_modules/
+ },
+ {
+ test: /\.css$/,
+ use: ['style-loader', 'css-loader']
+ }
+ ]
+ },
+ resolve: {
+ extensions: ['.tsx', '.ts', '.js', '.wasm']
+ },
+ output: {
+ filename: 'bundle.js',
+ path: path.resolve(__dirname, 'lib')
+ },
+ devServer: {
+ static: {
+ directory: path.join(__dirname, 'assets')
+ },
+ headers: {
+ "Cross-Origin-Embedder-Policy": "require-corp",
+ "Cross-Origin-Opener-Policy": "same-origin",
+ },
+ port: 8000
+ }
+};
diff --git a/wasm/test/src/index.ts b/wasm/test/src/index.ts
new file mode 100644
index 0000000..37d4ab7
--- /dev/null
+++ b/wasm/test/src/index.ts
@@ -0,0 +1,78 @@
+import { Shell } from '@jupyterlite/cockle';
+import { MockTerminalOutput } from './utils';
+
+interface IReturn {
+ returncode: number;
+ stdout: string;
+ stderr: string;
+}
+
+async function setup() {
+ const baseUrl = 'http://localhost:8000/';
+ const output = new MockTerminalOutput();
+
+ const shell = new Shell({
+ baseUrl,
+ wasmBaseUrl: baseUrl,
+ outputCallback: output.callback,
+ color: false
+ });
+ await shell.start();
+
+ const cockle = {
+ shell,
+ shellRun: (cmd: string[]) => shellRun(shell, output, cmd)
+ };
+
+ (window as any).cockle = cockle;
+
+ // Add div to indicate setup complete which can be awaited at start of tests.
+ const div = document.createElement("div");
+ div.id = 'loaded';
+ div.innerHTML = 'loaded';
+ document.body.appendChild(div);
+}
+
+async function shellRun(
+ shell: Shell,
+ output: MockTerminalOutput,
+ cmd: string[]
+): Promise {
+ // Keep stdout and stderr separate by outputting stdout to temporary file and stderr to terminal,
+ // then read the temporary file to get stdout to return.
+ output.clear();
+ let cmdLine = cmd.join(' ') + '> .outtmp' + '\r';
+ await shell.input(cmdLine);
+
+ const stderr = stripOutput(output.textAndClear(), cmdLine);
+ const returncode = await shell.exitCode();
+
+ // Read stdout from .outtmp file.
+ cmdLine = 'cat .outtmp\r';
+ await shell.input(cmdLine);
+ const stdout = stripOutput(output.textAndClear(), cmdLine);
+
+ // Delete temporary file.
+ cmdLine = 'rm .outtmp\r';
+ await shell.input(cmdLine);
+ output.clear();
+
+ return { returncode, stdout, stderr };
+}
+
+function stripOutput(output: string, cmdLine: string): string {
+ // Remove submitted command line at start.
+ let ret = output.slice(cmdLine.length);
+
+ // Remove new prompt at end.
+ const index = ret.lastIndexOf('\n');
+ if (index >= 0) {
+ ret = ret.slice(0, index + 1);
+ } else {
+ ret = '';
+ }
+
+ return ret;
+}
+
+setup();
diff --git a/wasm/test/src/utils.ts b/wasm/test/src/utils.ts
new file mode 100644
index 0000000..07c6d63
--- /dev/null
+++ b/wasm/test/src/utils.ts
@@ -0,0 +1,28 @@
+import { IOutputCallback } from '@jupyterlite/cockle';
+
+/**
+ * Provides outputCallback to mock a terminal.
+ */
+export class MockTerminalOutput {
+ constructor() {}
+
+ callback: IOutputCallback = (output: string) => {
+ this._text = this._text + output.replace('\r\n', '\n');
+ };
+
+ clear() {
+ this._text = '';
+ }
+
+ get text(): string {
+ return this._text;
+ }
+
+ textAndClear(): string {
+ const ret = this.text;
+ this.clear();
+ return ret;
+ }
+
+ private _text: string = '';
+}
diff --git a/wasm/test/tsconfig.json b/wasm/test/tsconfig.json
new file mode 100644
index 0000000..097059d
--- /dev/null
+++ b/wasm/test/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "allowSyntheticDefaultImports": true,
+ "composite": true,
+ "declaration": true,
+ "esModuleInterop": true,
+ "incremental": true,
+ "lib": ["dom", "es2022", "webworker"],
+ "module": "esnext",
+ "moduleResolution": "node",
+ "noEmitOnError": true,
+ "noImplicitAny": true,
+ "noUnusedLocals": true,
+ "preserveWatchOutput": true,
+ "resolveJsonModule": true,
+ "outDir": "lib",
+ "rootDir": "src",
+ "skipLibCheck": true,
+ "strict": true,
+ "strictNullChecks": true,
+ "target": "es2022",
+ "sourceMap": true
+ },
+ "include": ["src/*"]
+}
diff --git a/wasm/wasm-environment.yml b/wasm/wasm-environment.yml
new file mode 100644
index 0000000..f2c323a
--- /dev/null
+++ b/wasm/wasm-environment.yml
@@ -0,0 +1,19 @@
+# Environment for building, deploying and testing WebAssembly build of git2cpp.
+name: git2cpp-wasm
+channels:
+ - conda-forge
+dependencies:
+ # To modify emscripten-forge recipe
+ - python
+ - pyyaml
+ # To build emscripten-forge recipe
+ - micromamba
+ - rattler-build
+ # For JupyterLite deployment
+ - jupyter_server
+ - jupyterlite-terminal
+ # For cockle and JupyterLite deployments
+ - nodejs
+ # For testing
+ - pytest
+ - pytest-playwright