From 39b77eaf90450da0b1fce43dd7092b5ac489b137 Mon Sep 17 00:00:00 2001 From: Oliver Blanthorn Date: Mon, 13 Jul 2026 17:01:56 +0200 Subject: [PATCH] Add disposable mac arm testing --- .github/workflows/compile.yml | 42 +++- .github/workflows/diagnose_macos_arm64.yml | 33 +++ .github/workflows/release.yml | 41 +++- .gitignore | 1 + README.md | 8 +- compile_and_test.sh | 14 +- test_native_main.py | 252 +++++++++++++++++++++ 7 files changed, 363 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/diagnose_macos_arm64.yml create mode 100644 test_native_main.py diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index 83c711e..68c56b2 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -19,10 +19,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Cache nimble id: cache-nimble - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.nimble key: ${{ runner.os }}-${{ runner.arch }}-nimble-v2-${{ hashFiles('*.nimble') }} @@ -51,6 +51,8 @@ jobs: - run: nimble build -d:debug -Y --verbose if: runner.os != 'Linux' + env: + MACOSX_DEPLOYMENT_TARGET: '11.0' - run: nimble build -d:debug --verbose --passL:-static if: runner.os == 'Linux' @@ -62,29 +64,53 @@ jobs: - run: | mv native_main native_main-x86 - nimble build --verbose -d:debug -Y '--passC:"-target arm64-apple-macos11"' '--passL:"-target arm64-apple-macos11"' + nimble build --verbose -d:debug -Y --cpu:arm64 '--passC:"-target arm64-apple-macos11"' '--passL:"-target arm64-apple-macos11"' mv native_main native_main-arm64 lipo -create -output native_main native_main-x86 native_main-arm64 if: runner.os == 'macOS' - # - run: nimble test -Y - + - name: Test native messenger + shell: bash + run: | + if [ "$RUNNER_OS" = Windows ]; then + python test_native_main.py ./native_main + else + python3 test_native_main.py ./native_main + fi + - name: Archive production artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: native_main-${{ runner.os }} path: native_main - name: Archive production artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: runner.os == 'Linux' with: name: native_main-armhf-${{ runner.os }} path: native_main-armhf - name: Archive production artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: runner.os == 'Linux' with: name: native_main-arm64-${{ runner.os }} path: native_main-arm64 + + test_macos_arm64: + name: test (macos-15 arm64) + needs: compile + runs-on: macos-15 + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + name: native_main-macOS + - name: Test universal artifact natively + run: | + test "$(uname -m)" = arm64 + file native_main + lipo -archs native_main + chmod +x native_main + python3 test_native_main.py ./native_main diff --git a/.github/workflows/diagnose_macos_arm64.yml b/.github/workflows/diagnose_macos_arm64.yml new file mode 100644 index 0000000..29d475a --- /dev/null +++ b/.github/workflows/diagnose_macos_arm64.yml @@ -0,0 +1,33 @@ +name: diagnose macOS arm64 releases + +on: + pull_request: + branches: + - '*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test_release: + name: test release ${{ matrix.version }} + strategy: + fail-fast: false + matrix: + version: ['0.4.1', '0.5.0'] + runs-on: macos-15 + steps: + - uses: actions/checkout@v7 + - name: Download release + run: | + curl --fail --location --show-error \ + --output native_main \ + "https://github.com/tridactyl/native_messenger/releases/download/${{ matrix.version }}/native_main-macOS" + - name: Test release natively + run: | + test "$(uname -m)" = arm64 + file native_main + lipo -archs native_main + chmod +x native_main + python3 test_native_main.py ./native_main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63321e3..3781023 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,10 +18,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Cache nimble id: cache-nimble - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.nimble key: ${{ runner.os }}-${{ runner.arch }}-nimble-v2-${{ hashFiles('*.nimble') }} @@ -50,6 +50,8 @@ jobs: - run: nimble build -d:danger -d:release --opt:speed -Y --verbose if: runner.os != 'Linux' + env: + MACOSX_DEPLOYMENT_TARGET: '11.0' - run: nimble build -d:danger -d:release --opt:speed -Y --verbose --passL:-static if: runner.os == 'Linux' @@ -61,11 +63,20 @@ jobs: - run: | mv native_main native_main-x86 - nimble build --verbose -d:danger -d:release --opt:speed -Y '--passC:"-target arm64-apple-macos11"' '--passL:"-target arm64-apple-macos11"' + nimble build --verbose -d:danger -d:release --opt:speed -Y --cpu:arm64 '--passC:"-target arm64-apple-macos11"' '--passL:"-target arm64-apple-macos11"' mv native_main native_main-arm64 lipo -create -output native_main native_main-x86 native_main-arm64 if: runner.os == 'macOS' + - name: Test native messenger + shell: bash + run: | + if [ "$RUNNER_OS" = Windows ]; then + python test_native_main.py ./native_main + else + python3 test_native_main.py ./native_main + fi + - name: Stage release assets shell: bash run: | @@ -77,20 +88,38 @@ jobs: fi - name: Upload release artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: release-${{ runner.os }} path: release/ if-no-files-found: error - release: + test_macos_arm64: + name: test release (macos-15 arm64) needs: compile + runs-on: macos-15 + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + name: release-macOS + path: release + - name: Test universal artifact natively + run: | + test "$(uname -m)" = arm64 + file release/native_main-macOS + lipo -archs release/native_main-macOS + chmod +x release/native_main-macOS + python3 test_native_main.py release/native_main-macOS + + release: + needs: [compile, test_macos_arm64] runs-on: ubuntu-latest permissions: contents: write steps: - name: Download release artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: release-* path: release diff --git a/.gitignore b/.gitignore index 5ab3852..12c90b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ native_main.py native_main native_main.exe +__pycache__/ diff --git a/README.md b/README.md index c074e4a..62b55c0 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ Three options: # Testing -DIY testing: +Build the messenger and run the native messaging protocol tests: -``` -./gen_native_message.py cmd..getconfigpath | ./native_main | cut -b4- | jq 'walk( if type == "object" then with_entries(select(.value != null)) else . end)' +```sh +./compile_and_test.sh ``` -Swap `native_main` for the old `native_main.py` messenger to check compat. +The tests can also be run against an existing binary with `python3 test_native_main.py PATH_TO_NATIVE_MAIN`. diff --git a/compile_and_test.sh b/compile_and_test.sh index 1a00cf6..38d9ed1 100755 --- a/compile_and_test.sh +++ b/compile_and_test.sh @@ -1,11 +1,5 @@ #!/usr/bin/env bash -set -o pipefail -set -e -nimble build -d:debug -chmod +x native_main -cp native_main ~/.local/share/tridactyl/native_main -time printf '%c\0\0\0{"cmd": "run", "command": "echo $PATH"}' 39 | ./native_main -time printf '%c\0\0\0{"cmd": "version"}' 39 | ./native_main -# time printf '%c\0\0\0{"cmd": "read", "file": "$HOME/test"}' 39 | ./native_main -# time printf '%c\0\0\0{"cmd": "version"}' 39 | ~/.local/share/tridactyl/native_main.py # approx 100ms -# time printf '%c\0\0\0{"cmd": "run", "command": "echo $PATH"}' 39 | ~/.local/share/tridactyl/native_main.py # approx 100ms +set -euo pipefail + +nimble build -d:debug -Y +python3 test_native_main.py ./native_main diff --git a/test_native_main.py b/test_native_main.py new file mode 100644 index 0000000..8b3609c --- /dev/null +++ b/test_native_main.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 + +import concurrent.futures +import io +import json +import os +from pathlib import Path +import queue +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +try: + import resource +except ImportError: + resource = None + + +BINARY = "" +TIMEOUT = 5 + + +class ProtocolError(Exception): + pass + + +def encode_message(message): + payload = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode() + return len(payload).to_bytes(4, sys.byteorder) + payload + + +def read_exact(stream, size): + data = b"" + while len(data) < size: + chunk = stream.read(size - len(data)) + if not chunk: + raise ProtocolError( + f"native host closed stdout after {len(data)} of {size} bytes" + ) + data += chunk + return data + + +def read_response(stream): + size = int.from_bytes(read_exact(stream, 4), sys.byteorder) + return json.loads(read_exact(stream, size).decode()) + + +def parse_output(output): + stream = io.BytesIO(output) + response = read_response(stream) + trailing = stream.read() + if trailing: + raise ProtocolError(f"response has {len(trailing)} trailing bytes") + return response + + +def process_diagnostics(process): + details = [] + if os.name == "posix" and shutil.which("ps"): + result = subprocess.run( + ["ps", "-o", "pid=,ppid=,state=,time=,command=", "-p", str(process.pid)], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + details.append(result.stdout) + if sys.platform == "darwin" and shutil.which("sample"): + result = subprocess.run( + ["sample", str(process.pid), "2", "-file", "-"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + details.extend([result.stdout, result.stderr]) + return "".join(details).strip() + + +def one_shot(message, env=None): + process = subprocess.Popen( + [BINARY], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + try: + stdout, stderr = process.communicate(encode_message(message), timeout=TIMEOUT) + except subprocess.TimeoutExpired: + details = process_diagnostics(process) + process.kill() + process.communicate() + raise ProtocolError(f"native host {process.pid} did not exit\n{details}") + if process.returncode != 0: + raise ProtocolError( + f"native host exited with {process.returncode}: {stderr.decode(errors='replace')}" + ) + return parse_output(stdout) + + +class NativeHost: + def __init__(self, env=None): + self.process = subprocess.Popen( + [BINARY], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + assert self.process.stdin is not None + assert self.process.stdout is not None + assert self.process.stderr is not None + self.stdin = self.process.stdin + self.stdout = self.process.stdout + self.stderr = self.process.stderr + + def request(self, message): + self.stdin.write(encode_message(message)) + self.stdin.flush() + result = queue.Queue() + + def read(): + try: + result.put(read_response(self.stdout)) + except Exception as error: + result.put(error) + + thread = threading.Thread(target=read, daemon=True) + thread.start() + try: + response = result.get(timeout=TIMEOUT) + except queue.Empty: + details = process_diagnostics(self.process) + self.kill() + raise ProtocolError( + f"native host {self.process.pid} did not respond to {message!r}\n{details}" + ) + if isinstance(response, Exception): + raise response + return response + + def close(self): + if self.process.poll() is not None: + raise ProtocolError( + f"native host exited before stdin closed with {self.process.returncode}" + ) + self.stdin.close() + try: + returncode = self.process.wait(timeout=TIMEOUT) + except subprocess.TimeoutExpired: + details = process_diagnostics(self.process) + self.kill() + raise ProtocolError( + f"native host {self.process.pid} did not exit after EOF\n{details}" + ) + if returncode != 0: + stderr = self.stderr.read().decode(errors="replace") + raise ProtocolError(f"native host exited with {returncode}: {stderr}") + + def kill(self): + if self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=TIMEOUT) + + +def child_cpu_time(): + if resource is None: + raise RuntimeError("CPU accounting is unavailable") + usage = resource.getrusage(resource.RUSAGE_CHILDREN) + return usage.ru_utime + usage.ru_stime + + +class NativeMessengerTests(unittest.TestCase): + def test_version_and_eof(self): + response = one_shot({"cmd": "version"}) + self.assertEqual(response["cmd"], "version") + self.assertEqual(response["code"], 0) + self.assertTrue(response["version"]) + + def test_config_file(self): + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + config = home / ".tridactylrc" + content = "bind --mode=normal j scrollline 1\n# snowman: \u2603\n" + config.write_text(content, encoding="utf-8") + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "USERPROFILE": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "APPDATA": str(home / ".config"), + "LOCALAPPDATA": str(home / ".config"), + } + ) + path_response = one_shot({"cmd": "getconfigpath"}, env) + self.assertEqual(path_response["code"], 0) + self.assertEqual(Path(path_response["content"]).resolve(), config.resolve()) + + config_response = one_shot({"cmd": "getconfig"}, env) + self.assertEqual(config_response["code"], 0) + self.assertEqual(config_response["content"], content) + + read_result = one_shot( + {"cmd": "read", "file": "~/.tridactylrc"}, env + ) + self.assertEqual(read_result["code"], 0) + self.assertEqual(read_result["content"], content) + + def test_process_commands(self): + ppid = one_shot({"cmd": "ppid"}) + self.assertGreater(int(ppid["content"]), 0) + command = "echo native-run" if os.name == "nt" else "printf native-run" + response = one_shot({"cmd": "run", "command": command, "content": ""}) + self.assertEqual(response["code"], 0) + self.assertEqual(response["content"].strip(), "native-run") + + def test_concurrent_one_shot_hosts(self): + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + responses = list( + executor.map(lambda _: one_shot({"cmd": "version"}), range(4)) + ) + self.assertTrue(all(response["code"] == 0 for response in responses)) + + @unittest.skipIf(resource is None, "CPU accounting is unavailable") + def test_persistent_host_is_idle(self): + before_cpu = child_cpu_time() + host = NativeHost() + self.addCleanup(host.kill) + self.assertEqual(host.request({"cmd": "version"})["code"], 0) + self.assertTrue(host.request({"cmd": "env", "var": "HOME"})["content"]) + time.sleep(2) + host.close() + cpu_time = child_cpu_time() - before_cpu + self.assertLess(cpu_time, 0.5, f"idle native host used {cpu_time:.2f}s of CPU") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(f"usage: {sys.argv[0]} PATH_TO_NATIVE_MAIN") + BINARY = str(Path(sys.argv[1]).resolve()) + if not Path(BINARY).is_file(): + sys.exit(f"native host does not exist: {BINARY}") + suite = unittest.defaultTestLoader.loadTestsFromTestCase(NativeMessengerTests) + result = unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(not result.wasSuccessful())