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: + +cockle-config output + +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