diff --git a/.github/RELEASING.md b/.github/RELEASING.md index 2814aae..3452ff4 100644 --- a/.github/RELEASING.md +++ b/.github/RELEASING.md @@ -26,10 +26,11 @@ retains embedded Metal; Linux and Windows are CPU-only. The `libraries-` artifacts contain `clap_cpp/` with the shared library, licenses and any runtime companions. Python CPU wheels and Node optional packages consume the same external build, independently. Node downloads these artifacts to -`node/runtime//`, stages packages with `.github/scripts/pack-node.py`, -and tests actual npm selection against a temporary local registry on all four -platforms before publishing. No Python wheel extraction is involved. All native -packages publish before the wrapper. +`node/runtime/`, copies them into the tracked `node/packages//` workspaces, +and packs with `npm pack --workspaces --include-workspace-root`. The release +validates archive contents, platform constraints and versions before publishing. +Node inference tests run against the checkout and its platform workspace in CI. +No Python wheel extraction is involved. All native packages publish before the wrapper. Python publishes canonical wrapper-only source archives including `stage_runtime.py`; they contain neither native sources nor compiled libraries. CPU wheel jobs extract @@ -93,22 +94,18 @@ tar -xzf sdists/clap_cpp-*.tar.gz --strip-components=1 -C python-dist python python-dist/stage_runtime.py python-dist libraries/linux-x64 python -m build python-dist --wheel --outdir wheels # Host builds above are not a substitute for the release's manylinux Docker build/repair. -mkdir -p node/runtime/linux-x64 -cp -R libraries/linux-x64/. node/runtime/linux-x64/ +mkdir -p node/packages/linux-x64/runtime +cp -R libraries/linux-x64/. node/packages/linux-x64/runtime/ cd node -npm ci --ignore-scripts -python ../.github/scripts/pack-node.py --target linux-x64 -mkdir -p artifacts -for package in dist/*; do npm pack "./$package" --pack-destination artifacts; done -npm install --no-save --package-lock=false --ignore-scripts ./artifacts/gorse-clap-cpp-*.tgz -npm run test:fixtures -npm test -npm run test:types -npm run test:packaging -python ../.github/scripts/test-node-install.py --artifacts artifacts --prefix installed -cd installed/node_modules/clap-cpp -npm run test:fixtures +cp ../LICENSE LICENSE +cp ../LICENSE packages/linux-x64/LICENSE +cp ../LICENSE packages/linux-x64/runtime/clap_cpp/licenses/clap.txt +# --force permits linking workspaces for other platforms in the checkout. +npm ci --force --ignore-scripts npm test +mkdir -p artifacts +npm pack --workspace packages/linux-x64 --include-workspace-root --pack-destination artifacts +python ../.github/scripts/validate-node-packages.py --artifacts artifacts ``` The checkout must contain the pinned initialized submodules. The repository-root @@ -116,8 +113,8 @@ Rust package includes original native sources and upstream licenses directly. Node platform packages include only staged prebuilt runtimes and licenses, not native sources or build scripts. The main package contains no shared libraries. Do not commit generated runtimes, fixture copies or archives. See `node/README.md` for external runtime staging and platform packages; -release packaging requires all four runtimes (omit `--target` when staging). -Registry selection tests use a local test registry with real tarballs, not a live -npm publication. CMake and a C++20 compiler are needed for the separate native +release packaging requires all four runtimes and uses `npm pack --workspaces +--include-workspace-root`. Archive validation uses `--require-all`. +CMake and a C++20 compiler are needed for the separate native runtime build, not Python wheel packaging; Python is needed for maintainer tests, not npm installation. diff --git a/.github/scripts/pack-node.py b/.github/scripts/pack-node.py deleted file mode 100644 index 05f508c..0000000 --- a/.github/scripts/pack-node.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Stage npm optional platform packages from externally built shared runtimes.""" -import argparse -import json -from pathlib import Path -import shutil - -ROOT = Path(__file__).resolve().parents[2] -TARGETS = { - 'linux-x64': 'libclap.so', - 'linux-arm64': 'libclap.so', - 'darwin-arm64': 'libclap.dylib', - 'win32-x64': 'clap.dll', -} - - -def stage(runtime_root, output, targets, root=ROOT): - source = root / 'node' - main = json.loads((source / 'package.json').read_text()) - version = main['version'] - main['optionalDependencies'] = { - f'@gorse/clap-cpp-{target}': version for target in TARGETS - } - # Validate the complete requested input before replacing any staged package. - for target in targets: - runtime = runtime_root / target / 'clap_cpp' - for name in [TARGETS[target], *[f'licenses/{name}.txt' for name in - ['ggml', 'icu', 'json', 'miniaudio']]]: - if not (runtime / name).is_file(): - raise ValueError(f'Missing {target} runtime input: {runtime / name}') - output.mkdir(parents=True, exist_ok=True) - for name in ['clap-cpp', *[f'clap-cpp-{target}' for target in TARGETS]]: - destination = output / name - if destination.is_symlink(): - raise ValueError(f'Refusing symlink package destination: {destination}') - if destination.exists(): - shutil.rmtree(destination) - package = output / 'clap-cpp' - package.mkdir() - for filename in ['index.js', 'index.d.ts', 'README.md']: - shutil.copy2(source / filename, package / filename) - shutil.copy2(root / 'LICENSE', package / 'LICENSE') - (package / 'package.json').write_text(json.dumps(main, indent=2) + '\n') - tests = package / 'test' - tests.mkdir() - for entry in (source / 'test').iterdir(): - if entry.is_file() and (entry.name.endswith('.test.js') or entry.name in - ['generate-fixtures.py', 'types.ts', 'tsconfig.json']): - shutil.copy2(entry, tests / entry.name) - shutil.copy2(root / 'python/tests/tiny_models.py', tests / 'tiny_models.py') - for target in targets: - package = output / f'clap-cpp-{target}' - package.mkdir() - runtime = package / 'runtime' - # Retain the shared runtime's library-relative companion directories. - shutil.copytree(runtime_root / target, runtime) - shutil.copy2(root / 'LICENSE', runtime / 'clap_cpp/licenses/clap.txt') - shutil.copy2(root / 'LICENSE', package / 'LICENSE') - (package / 'README.md').write_text( - f'# @gorse/clap-cpp-{target}\n\n' - 'Prebuilt shared runtime for clap-cpp. Installed automatically as an optional\n' - 'dependency on the matching platform. Use the clap-cpp package API.\n') - os_name, cpu = target.split('-') - manifest = { - 'name': f'@gorse/clap-cpp-{target}', 'version': version, - 'description': f'Prebuilt clap.cpp shared runtime for {target}', - 'license': main['license'], 'repository': main['repository'], - 'publishConfig': {'access': 'public'}, - 'os': [os_name], 'cpu': [cpu], 'files': ['runtime', 'LICENSE', 'README.md'], - } - if os_name == 'linux': - manifest['libc'] = ['glibc'] - (package / 'package.json').write_text(json.dumps(manifest, indent=2) + '\n') - return [output / f'clap-cpp-{target}' for target in targets] + [output / 'clap-cpp'] - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--runtime-root', type=Path, default=ROOT / 'node/runtime') - parser.add_argument('--output', type=Path, default=ROOT / 'node/dist') - parser.add_argument('--target', action='append', choices=TARGETS) - args = parser.parse_args() - for package in stage(args.runtime_root.resolve(), args.output.resolve(), args.target or list(TARGETS)): - print(package) - - -if __name__ == '__main__': - main() diff --git a/.github/scripts/release-version.py b/.github/scripts/release-version.py index e694144..c6443e0 100644 --- a/.github/scripts/release-version.py +++ b/.github/scripts/release-version.py @@ -35,14 +35,18 @@ def update(root, tag): if count != 1: raise ValueError("Expected exactly one Rust package version") files = {} - for name in ("node/package.json", "node/package-lock.json"): - path = root / name + paths = [root / "node/package.json", root / "node/package-lock.json"] + paths.extend(sorted((root / "node/packages").glob("*/package.json"))) + for path in paths: data = json.loads(path.read_text()) data["version"] = version package = data - if name.endswith("package-lock.json"): + if path.name == "package-lock.json": package = data["packages"][""] package["version"] = version + for location, workspace in data["packages"].items(): + if location.startswith("packages/"): + workspace["version"] = version for dependency in package.get("optionalDependencies", {}): if dependency.startswith("@gorse/clap-cpp-"): package["optionalDependencies"][dependency] = version diff --git a/.github/scripts/test-node-install.py b/.github/scripts/test-node-install.py deleted file mode 100644 index 3250681..0000000 --- a/.github/scripts/test-node-install.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Exercise npm's optional platform selection against actual staged tarballs. - -Only @gorse packages use the temporary local registry; Koffi comes from the normal -registry. This is a packaging test, not publication to the public npm registry. -""" -import argparse -import base64 -from contextlib import contextmanager -import hashlib -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -import json -from pathlib import Path -import shutil -import subprocess -import sys -import tarfile -import tempfile -import threading -from urllib.parse import unquote, urlparse - - -def manifest(archive): - with tarfile.open(archive) as contents: - source = contents.extractfile('package/package.json') - if source is None: - raise ValueError(f'Missing package manifest: {archive}') - return json.load(source) - - -@contextmanager -def registry(packages): - downloads = [] - - class Handler(BaseHTTPRequestHandler): - def log_message(self, format, *args): - pass - - def do_GET(self): - assert isinstance(self.server, ThreadingHTTPServer) - name = unquote(urlparse(self.path).path).lstrip('/') - if name.startswith('tarballs/'): - filename = name.removeprefix('tarballs/') - matches = [(m, p) for m, p in packages.values() if p.name == filename] - if not matches: - self.send_error(404) - return - metadata, archive = matches[0] - downloads.append(metadata['name']) - payload = archive.read_bytes() - elif name in packages: - metadata, archive = packages[name] - version = metadata['version'] - data = archive.read_bytes() - version_data = dict(metadata, dist={ - 'tarball': f'http://127.0.0.1:{self.server.server_address[1]}/tarballs/{archive.name}', - 'shasum': hashlib.sha1(data).hexdigest(), - 'integrity': 'sha512-' + base64.b64encode(hashlib.sha512(data).digest()).decode(), - }) - payload = json.dumps({'name': name, 'dist-tags': {'latest': version}, - 'versions': {version: version_data}}).encode() - else: - self.send_error(404) - return - self.send_response(200) - self.send_header('Content-Type', 'application/octet-stream' if name.startswith('tarballs/') else 'application/json') - self.send_header('Content-Length', str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield f'http://127.0.0.1:{server.server_port}', downloads - finally: - server.shutdown() - server.server_close() - thread.join() - - -def verify(artifacts, prefix): - main = None - packages = {} - for archive in artifacts.glob('*.tgz'): - metadata = manifest(archive) - if metadata['name'] == 'clap-cpp': - if main is not None: - raise ValueError('Multiple main tarballs') - main = archive - elif metadata['name'].startswith('@gorse/clap-cpp-'): - if metadata['name'] in packages: - raise ValueError(f'Duplicate runtime package: {metadata["name"]}') - packages[metadata['name']] = metadata, archive - if main is None: - raise ValueError('Missing main clap-cpp tarball') - host = subprocess.check_output(['node', '-p', 'process.platform + "-" + process.arch'], text=True).strip() - expected = f'@gorse/clap-cpp-{host}' - if expected not in packages: - raise ValueError(f'Missing real host runtime archive: {expected}') - main_manifest = manifest(main) - for name, (metadata, _) in packages.items(): - if main_manifest['optionalDependencies'].get(name) != metadata['version']: - raise ValueError(f'Unpinned/mismatched optional runtime version: {name}') - prefix.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix='clap-npm-registry-') as scratch, registry(packages) as (url, downloads): - config = Path(scratch) / '.npmrc' - config.write_text(f'@gorse:registry={url}\n') - command = [shutil.which('npm'), 'install', '--prefix', str(prefix), - '--userconfig', str(config), '--cache', str(Path(scratch) / 'cache'), - '--ignore-scripts', '--no-audit', '--no-fund', str(main)] - subprocess.run(command, check=True, shell=sys.platform == 'win32') - if downloads != [expected]: - raise ValueError(f'Expected only {expected} tarball download, got {downloads}') - installed = prefix / 'node_modules' - for name in main_manifest['optionalDependencies']: - if (installed / name).exists() != (name == expected): - raise ValueError(f'Incorrect installed optional dependency: {name}') - subprocess.run(['node', '-e', 'const m = require(process.argv[1]); if (typeof m.CLAP !== "function") process.exit(1)', - str(installed / 'clap-cpp')], check=True) - print(f'Only the host runtime was downloaded and installed: {expected}; native library loaded successfully') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--artifacts', type=Path, required=True) - parser.add_argument('--prefix', type=Path, required=True) - args = parser.parse_args() - verify(args.artifacts.resolve(), args.prefix.resolve()) diff --git a/.github/scripts/test_pack_node.py b/.github/scripts/test_pack_node.py deleted file mode 100644 index 6a1d95c..0000000 --- a/.github/scripts/test_pack_node.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Unit-test staging metadata; the payload bytes here are not executable binaries.""" -import importlib.util -import json -from pathlib import Path -import tempfile -import unittest - -SPEC = importlib.util.spec_from_file_location('pack_node', Path(__file__).with_name('pack-node.py')) -assert SPEC is not None and SPEC.loader is not None -PACK = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(PACK) - - -class NodePackageTest(unittest.TestCase): - def test_platform_packages_and_exact_optional_versions(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - node = root / 'node' - (node / 'test').mkdir(parents=True) - (root / 'python/tests').mkdir(parents=True) - (node / 'package.json').write_text(json.dumps({ - 'name': 'clap-cpp', 'version': '1.2.3-rc.1', 'license': 'Apache-2.0', - 'repository': {'url': 'https://github.com/gorse-io/clap.cpp'}, - 'files': ['index.js', 'index.d.ts', 'test'], - })) - for filename in ['index.js', 'index.d.ts', 'README.md']: - (node / filename).write_text('unit fixture') - (node / 'test/probe.test.js').write_text('unit fixture') - (node / 'test/probe.log').write_text('must not ship') - (root / 'LICENSE').write_text('unit license') - (root / 'python/tests/tiny_models.py').write_text('unit fixture generator') - runtimes = root / 'runtimes' - for target, library in PACK.TARGETS.items(): - runtime = runtimes / target / 'clap_cpp' - (runtime / 'licenses').mkdir(parents=True) - (runtime / library).write_bytes(b'non-executable unit fixture') - for name in ['ggml', 'icu', 'json', 'miniaudio']: - (runtime / f'licenses/{name}.txt').write_text('unit license') - output = root / 'packages' - staged = PACK.stage(runtimes, output, list(PACK.TARGETS), root) - self.assertEqual(len(staged), 5) - main = json.loads((output / 'clap-cpp/package.json').read_text()) - self.assertEqual(main['optionalDependencies'], { - f'@gorse/clap-cpp-{target}': '1.2.3-rc.1' for target in PACK.TARGETS - }) - self.assertFalse((output / 'clap-cpp/runtime').exists()) - self.assertTrue((output / 'clap-cpp/test/tiny_models.py').is_file()) - self.assertFalse((output / 'clap-cpp/test/probe.log').exists()) - for target, library in PACK.TARGETS.items(): - package = output / f'clap-cpp-{target}' - metadata = json.loads((package / 'package.json').read_text()) - os_name, arch = target.split('-') - self.assertEqual(metadata['os'], [os_name]) - self.assertEqual(metadata['cpu'], [arch]) - self.assertEqual(metadata['version'], main['version']) - self.assertEqual(metadata.get('libc'), ['glibc'] if os_name == 'linux' else None) - self.assertTrue((package / f'runtime/clap_cpp/{library}').is_file()) - self.assertFalse((package / 'index.js').exists()) - # An incomplete input must fail before replacing reviewed output. - (runtimes / 'linux-x64/clap_cpp/libclap.so').unlink() - with self.assertRaisesRegex(ValueError, 'Missing linux-x64 runtime input'): - PACK.stage(runtimes, output, ['linux-x64'], root) - self.assertTrue((output / 'clap-cpp/package.json').is_file()) - - -if __name__ == '__main__': - unittest.main() diff --git a/.github/scripts/test_release_version.py b/.github/scripts/test_release_version.py index 50c3508..cfa0379 100644 --- a/.github/scripts/test_release_version.py +++ b/.github/scripts/test_release_version.py @@ -28,7 +28,15 @@ def test_updates_manifests_and_lockfile(self): cargo.write_text('[package]\nname = "clap-cpp"\nversion = "0.1.0"\n') optional = {"@gorse/clap-cpp-linux-x64": "0.1.0"} (root / "node/package.json").write_text(json.dumps({"name": "clap-cpp", "version": "0.1.0", "optionalDependencies": optional})) - lock = {"version": "0.1.0", "packages": {"": {"version": "0.1.0", "optionalDependencies": optional}, "node_modules/example": {"version": "2.0.0"}}} + workspace = root / "node/packages/linux-x64/package.json" + workspace.parent.mkdir(parents=True) + workspace.write_text(json.dumps({"name": "@gorse/clap-cpp-linux-x64", "version": "0.1.0", "os": ["linux"], "cpu": ["x64"]})) + lock = {"version": "0.1.0", "packages": { + "": {"version": "0.1.0", "optionalDependencies": optional}, + "packages/linux-x64": json.loads(workspace.read_text()), + "node_modules/@gorse/clap-cpp-linux-x64": {"resolved": "packages/linux-x64", "link": True}, + "node_modules/example": {"version": "2.0.0"}, + }} (root / "node/package-lock.json").write_text(json.dumps(lock)) self.assertEqual(release.update(root, "v1.2.3-rc.1"), "1.2.3-rc.1") self.assertIn('version = "1.2.3-rc.1"', cargo.read_text()) @@ -40,6 +48,13 @@ def test_updates_manifests_and_lockfile(self): self.assertEqual(lock["version"], manifest["version"]) self.assertEqual(lock["packages"][""]["version"], manifest["version"]) self.assertEqual(lock["packages"]["node_modules/example"]["version"], "2.0.0") + platform = json.loads(workspace.read_text()) + self.assertEqual(platform["version"], manifest["version"]) + self.assertEqual(platform["os"], ["linux"]) + self.assertEqual(platform["cpu"], ["x64"]) + self.assertEqual(lock["packages"]["packages/linux-x64"], platform) + self.assertEqual(lock["packages"]["node_modules/@gorse/clap-cpp-linux-x64"], + {"resolved": "packages/linux-x64", "link": True}) if __name__ == "__main__": diff --git a/.github/scripts/validate-node-packages.py b/.github/scripts/validate-node-packages.py new file mode 100644 index 0000000..05a0b03 --- /dev/null +++ b/.github/scripts/validate-node-packages.py @@ -0,0 +1,86 @@ +"""Validate npm package archives without installing or executing them.""" +import argparse +import json +from pathlib import Path +import tarfile + +TARGETS = { + 'linux-x64': 'libclap.so', + 'linux-arm64': 'libclap.so', + 'darwin-arm64': 'libclap.dylib', + 'win32-x64': 'clap.dll', +} + + +def validate_runtime(metadata, archive): + target = metadata['name'].removeprefix('@gorse/clap-cpp-') + if target not in TARGETS: + raise ValueError(f'Unknown runtime target: {target}') + os_name, cpu = target.split('-') + if metadata.get('os') != [os_name] or metadata.get('cpu') != [cpu]: + raise ValueError(f'Incorrect platform constraints: {target}') + if metadata.get('libc') != (['glibc'] if os_name == 'linux' else None): + raise ValueError(f'Incorrect libc constraint: {target}') + required = ['LICENSE', f'runtime/clap_cpp/{TARGETS[target]}'] + required.extend(f'runtime/clap_cpp/licenses/{name}.txt' + for name in ['clap', 'ggml', 'icu', 'json', 'miniaudio']) + with tarfile.open(archive) as contents: + for name in required: + try: + entry = contents.getmember(f'package/{name}') + except KeyError: + raise ValueError(f'Missing {name} in {archive}') from None + if not entry.isfile() or not entry.size: + raise ValueError(f'Empty or invalid {name} in {archive}') + + +def manifest(archive): + with tarfile.open(archive) as contents: + source = contents.extractfile('package/package.json') + if source is None: + raise ValueError(f'Missing package manifest: {archive}') + return json.load(source) + + +def verify(artifacts, require_all=False): + main = None + packages = {} + for archive in artifacts.glob('*.tgz'): + metadata = manifest(archive) + if metadata['name'] == 'clap-cpp': + if main is not None: + raise ValueError('Multiple main tarballs') + main = archive + elif metadata['name'].startswith('@gorse/clap-cpp-'): + if metadata['name'] in packages: + raise ValueError(f'Duplicate runtime package: {metadata["name"]}') + validate_runtime(metadata, archive) + packages[metadata['name']] = metadata + if main is None: + raise ValueError('Missing main clap-cpp tarball') + expected = {f'@gorse/clap-cpp-{target}' for target in TARGETS} + if not packages or (require_all and set(packages) != expected): + raise ValueError('Expected all four platform runtime archives' if require_all + else 'Missing platform runtime archive') + main_manifest = manifest(main) + version = main_manifest['version'] + if main_manifest.get('optionalDependencies') != {name: version for name in expected}: + raise ValueError('Expected exact matching versions for all optional runtime packages') + for name, metadata in packages.items(): + if metadata['version'] != version: + raise ValueError(f'Mismatched runtime version: {name}') + with tarfile.open(main) as contents: + files = {entry.name for entry in contents if not entry.isdir()} + required = {f'package/{name}' for name in + ['package.json', 'index.js', 'index.d.ts', 'README.md', 'LICENSE']} + if files != required: + raise ValueError(f'Unexpected main package contents: {files ^ required}') + print(f'Validated main package and {len(packages)} platform runtime archive(s)') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--artifacts', type=Path, required=True) + parser.add_argument('--require-all', action='store_true') + args = parser.parse_args() + verify(args.artifacts.resolve(), args.require_all) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33554ce..f7bf9af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,30 +111,24 @@ jobs: shell: bash working-directory: node run: | - npm ci --ignore-scripts - python ../.github/scripts/pack-node.py --runtime-root ../libraries --target "${{ matrix.runtime }}" - mkdir -p artifacts - for package in dist/*; do npm pack "./$package" --pack-destination artifacts; done - npm install --no-save --package-lock=false --ignore-scripts ./artifacts/gorse-clap-cpp-*.tgz + npm ci --force --ignore-scripts + cp ../LICENSE LICENSE + package="packages/${{ matrix.runtime }}" + mkdir -p "$package/runtime" artifacts + cp -R "../libraries/${{ matrix.runtime }}/." "$package/runtime/" + cp ../LICENSE "$package/LICENSE" + cp ../LICENSE "$package/runtime/clap_cpp/licenses/clap.txt" + npm pack --workspace "$package" --include-workspace-root --pack-destination artifacts - name: Test Node if: matrix.os != 'windows-11-arm' shell: bash working-directory: node - run: | - npm run test:fixtures - npm test - npm run test:types - npm run test:packaging - - name: Test npm platform selection and installed package + run: npm test + - name: Validate npm package archives if: matrix.os != 'windows-11-arm' shell: bash working-directory: node - run: | - python ../.github/scripts/test-node-install.py --artifacts artifacts --prefix installed - CLAP_CPP_MODULE="$PWD/installed/node_modules/clap-cpp" npm test - cd installed/node_modules/clap-cpp - npm run test:fixtures - npm test + run: python ../.github/scripts/validate-node-packages.py --artifacts artifacts rust: strategy: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01467fb..1167b1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -791,73 +791,31 @@ jobs: env: RELEASE_TAG: ${{ github.event.release.tag_name }} run: python ../.github/scripts/release-version.py "$RELEASE_TAG" - - name: Arrange external runtime artifacts by npm target + - name: Copy external runtimes into platform workspaces run: | set -euo pipefail + cp ../LICENSE LICENSE for target in linux-x64 linux-arm64 darwin-arm64 win32-x64; do - mv "runtime/libraries-$target" "runtime/$target" + package="packages/$target" + mkdir -p "$package/runtime" + cp -R "runtime/libraries-$target/." "$package/runtime/" + cp ../LICENSE "$package/LICENSE" + cp ../LICENSE "$package/runtime/clap_cpp/licenses/clap.txt" done - - name: Pack main and all optional runtime packages + - name: Pack main and all optional runtime workspaces run: | - python ../.github/scripts/pack-node.py mkdir -p artifacts - for package in dist/*; do npm pack "./$package" --pack-destination artifacts; done + npm pack --workspaces --include-workspace-root --pack-destination artifacts - name: Validate all five package archives - env: - CLAP_CPP_REQUIRE_ALL_RUNTIMES: '1' - run: | - npm ci --ignore-scripts - npm install --no-save --package-lock=false --ignore-scripts ./artifacts/gorse-clap-cpp-linux-x64-*.tgz - npm run test:packaging + run: python ../.github/scripts/validate-node-packages.py --artifacts artifacts --require-all - uses: actions/upload-artifact@v4 with: name: node-package path: node/artifacts/*.tgz if-no-files-found: error - test-node: - needs: build-node - runs-on: ${{ matrix.os }} - permissions: - contents: read - strategy: - fail-fast: false - matrix: - os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15, windows-2022] - env: - CMAKE_BUILD_PARALLEL_LEVEL: 4 - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v4 - with: - sparse-checkout: .github/scripts - - uses: actions/download-artifact@v4 - with: - name: node-package - path: dist - - uses: actions/setup-node@v4 - with: - node-version: '24' - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install numerical oracle dependency - run: python -m pip install numpy - - name: Verify npm selects and downloads only the host runtime - run: python .github/scripts/test-node-install.py --artifacts dist --prefix installed - - name: Test installed platform distribution - run: | - cd installed/node_modules/clap-cpp - npm install --include=dev --ignore-scripts - npm run test:fixtures - npm test - npm run test:types - npm run test:packaging - publish-npm: - needs: [build-node, test-node] + needs: build-node runs-on: ubuntu-24.04 environment: name: npm diff --git a/node/README.md b/node/README.md index 5bbde72..193e227 100644 --- a/node/README.md +++ b/node/README.md @@ -10,7 +10,7 @@ npm install clap-cpp ``` Requires Node.js 22+. The `clap-cpp` package contains JavaScript, TypeScript -declarations and tests, not native runtimes. npm selects a platform package from +declarations, README and license, not native runtimes or tests. npm selects a platform package from its exact-version `optionalDependencies`: | Operating system | Architecture | Optional native package | @@ -57,58 +57,61 @@ using the repository-root CMake graph. Python and npm package the same artifact; neither language's packaging step compiles C++. Ordinary CI runs the C++, Python and Node tests together in one platform matrix job. +The four platform packages are defined in `node/packages//package.json` +and registered by the root `workspaces` field. Their manifests are tracked in Git; +compiled runtimes and copied licenses are staged before packing. + For example, on a Linux x64 host, run these commands from the repository root: ```sh git submodule update --init --recursive cmake --preset libraries cmake --build build/libraries --target clap --parallel 4 -cmake --install build/libraries --component runtime --prefix "$PWD/libraries/linux-x64" -python3 .github/scripts/pack-node.py --runtime-root libraries --output node/dist --target linux-x64 +cmake --install build/libraries --component runtime --prefix "$PWD/node/packages/linux-x64/runtime" cd node -npm ci --ignore-scripts -mkdir -p artifacts -for package in dist/*; do npm pack "./$package" --pack-destination artifacts; done -npm install --no-save --package-lock=false --ignore-scripts ./artifacts/gorse-clap-cpp-linux-x64-*.tgz -npm run test:fixtures +cp ../LICENSE LICENSE +cp ../LICENSE packages/linux-x64/LICENSE +cp ../LICENSE packages/linux-x64/runtime/clap_cpp/licenses/clap.txt +npm ci --force --ignore-scripts npm test -npm run test:types -npm run test:packaging +mkdir -p artifacts +npm pack --workspace packages/linux-x64 --include-workspace-root --pack-destination artifacts +python3 ../.github/scripts/validate-node-packages.py --artifacts artifacts ``` -For macOS, configure with `-DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON` as the -shared CI action does. Windows builds use a Visual Studio developer environment. -The common install puts the C library and dependency notices under `clap_cpp/`; -that namespace does not require Python. Python wheel staging and npm native -package staging each copy these same native bytes into their own distribution. - -The external `.github/scripts/pack-node.py` stages the main package in -`node/dist/clap-cpp` and each native package in -`node/dist/clap-cpp--`. Omit `--target` to require all four runtime -directories; stage packages again after source changes. Publish all native -packages first, then publish `clap-cpp` at the same version. Maintainer library -builds need CMake 3.20+ and a C++20 compiler; npm consumers do not. -Keep Koffi's optional prebuilt dependencies enabled, even in maintainer installs; +For macOS, use `darwin-arm64` and configure with +`-DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON`. Windows builds use a Visual Studio +developer environment and the `win32-x64` workspace. The common install puts the +C library and dependency notices under `runtime/clap_cpp/` in each workspace. +An existing `libraries//` artifact can also be copied into that directory. + +Maintainer installs use `--force` because npm otherwise rejects workspaces for +other operating systems or architectures. This links the local packages without +requiring them to exist on the npm registry. Consumers use ordinary +`npm install clap-cpp`; the published optional dependencies retain their platform +constraints. Keep Koffi's optional prebuilt dependencies enabled; `--omit=optional` would also remove its platform binaries. -Packaging tests inspect real main and native tarballs, install both explicitly -outside the checkout, generate fixtures using the installed -`test/tiny_models.py`, and run installed runtime tests. They require Python 3 -(override the interpreter with `PYTHON`). Stage packages again after source -changes. `CLAP_CPP_PACKAGE_DIR` and `CLAP_CPP_NATIVE_PACKAGE_DIR` can override the -staged main and host-native directories. In an installed package, the defaults -are the installed main package and its resolved native dependency, so repacking -does not require the checkout's staging script. - -By default packaging tests require the host native package. Set -`CLAP_CPP_REQUIRE_ALL_RUNTIMES=1` for release validation to inspect all four -native packages and their licenses; each runtime still needs matching-host -inference testing. Local explicit-tarball installation does **not** prove registry -selection. `.github/scripts/test-node-install.py` instead serves the actual -platform tarballs through a temporary local scoped registry and asserts that npm -downloads only the host runtime; release CI runs it against all four real native -packages. This is not a publication to the public npm registry. Tests and fixture -generation are maintainer operations only; npm consumers do not need Python. +For a release, stage all four runtimes and licenses, then run +`npm pack --workspaces --include-workspace-root --pack-destination artifacts`. +The release version script updates the root, workspace manifests and lockfile +together. Publish all native packages before publishing `clap-cpp` at the same +version. Maintainer library builds need CMake 3.20+ and a C++20 compiler; +npm consumers do not. + +`.github/scripts/validate-node-packages.py` checks runtime payloads, dependency +licenses, platform constraints and matching versions without installing the +archives. It also verifies that the main package contains only the binding, +type declarations, README, license and package manifest. Pass `--require-all` +to require all four platform archives. + +CI runs the CLAP inference test directly against the checkout and its platform +workspace. Tests and audio fixtures are not included in published packages. +The CLAP test downloads +`larger_clap_general-q4_k_m.gguf` from Hugging Face into the repository root. +It compares `dog.wav` with dog and vacuum-cleaner descriptions, using the same +softmax scores and thresholds as `rust/tests/clap.rs`. +No test publishes to the public npm registry. ## Usage diff --git a/node/package-lock.json b/node/package-lock.json index c82d6c9..f58ed28 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -8,21 +8,86 @@ "name": "clap-cpp", "version": "0.1.0", "license": "Apache-2.0", + "workspaces": [ + "packages/*" + ], "dependencies": { "koffi": "^3.3.6" }, "devDependencies": { + "@huggingface/hub": "^2.17.10", "@types/node": "^22.0.0", "typescript": "^5.9.3" }, + "engines": { + "node": ">=22.0.0" + }, "optionalDependencies": { - "@gorse/clap-cpp-linux-x64": "0.1.0", - "@gorse/clap-cpp-linux-arm64": "0.1.0", "@gorse/clap-cpp-darwin-arm64": "0.1.0", + "@gorse/clap-cpp-linux-arm64": "0.1.0", + "@gorse/clap-cpp-linux-x64": "0.1.0", "@gorse/clap-cpp-win32-x64": "0.1.0" + } + }, + "node_modules/@gorse/clap-cpp-darwin-arm64": { + "resolved": "packages/darwin-arm64", + "link": true + }, + "node_modules/@gorse/clap-cpp-linux-arm64": { + "resolved": "packages/linux-arm64", + "link": true + }, + "node_modules/@gorse/clap-cpp-linux-x64": { + "resolved": "packages/linux-x64", + "link": true + }, + "node_modules/@gorse/clap-cpp-win32-x64": { + "resolved": "packages/win32-x64", + "link": true + }, + "node_modules/@huggingface/blake3-jit": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@huggingface/blake3-jit/-/blake3-jit-0.0.2.tgz", + "integrity": "sha512-Bq7B5qabyjrJfhBsl85Jd2QBtf+HzRD7h7A9GfN2lzrrsABhOa5evVPgzoCTxR7Ub0QFj7YDK1YkYRWBU25+2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@huggingface/hub": { + "version": "2.17.10", + "resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.17.10.tgz", + "integrity": "sha512-+2W2KKBMEMmfgFEgaWknCytsCQlyQ9Xpghv9BN5qOqkjb0l5f6mVhXBNyAVOmt/Gjny4I3Cb1ZKaUv28+0+17A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@huggingface/tasks": "^0.22.1", + "@huggingface/xetchunk-wasm": "^0.2.0" + }, + "bin": { + "hfjs": "dist/cli.js" }, "engines": { - "node": ">=22.0.0" + "node": ">=18" + }, + "optionalDependencies": { + "cli-progress": "^3.12.0" + } + }, + "node_modules/@huggingface/tasks": { + "version": "0.22.2", + "resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.22.2.tgz", + "integrity": "sha512-m+LqKsOQPlG3UYolaQo9q154VlCV9cE26GL2bdqkhNVOeeeQYCzqZG7C9JvVl6+rwgupvmFIFxPrkTDbFp8U9w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@huggingface/xetchunk-wasm": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@huggingface/xetchunk-wasm/-/xetchunk-wasm-0.2.0.tgz", + "integrity": "sha512-cmp7oAAWeCpZGrVQTP/LxDtJdzGMfpxwkEVsZsk5jZQ2FwP2njm8LnyobhnQvLpkSAcJJwUzFJC0PYS9ZsqeTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@huggingface/blake3-jit": "0.0.2", + "gearhash-jit": "1.0.2" } }, "node_modules/@koromix/koffi-android-arm64": { @@ -355,6 +420,57 @@ "undici-types": "~6.21.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/gearhash-jit": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/gearhash-jit/-/gearhash-jit-1.0.2.tgz", + "integrity": "sha512-UhzJL4KXSdqAKepy/tZwmi2Rcy0YMmtiC4DQS4SURCuIWdh8ECZtnXK2ePRMLigfB61hRKdLK/Vgg2bSw73izQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/koffi": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.3.6.tgz", @@ -387,6 +503,36 @@ "@koromix/koffi-win32-x64": "3.3.6" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -407,6 +553,56 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "packages/darwin-arm64": { + "name": "@gorse/clap-cpp-darwin-arm64", + "version": "0.1.0", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "os": [ + "darwin" + ] + }, + "packages/linux-arm64": { + "name": "@gorse/clap-cpp-linux-arm64", + "version": "0.1.0", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "os": [ + "linux" + ] + }, + "packages/linux-x64": { + "name": "@gorse/clap-cpp-linux-x64", + "version": "0.1.0", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "os": [ + "linux" + ] + }, + "packages/win32-x64": { + "name": "@gorse/clap-cpp-win32-x64", + "version": "0.1.0", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "os": [ + "win32" + ] } } } diff --git a/node/package.json b/node/package.json index b59cf37..c3497e5 100644 --- a/node/package.json +++ b/node/package.json @@ -17,30 +17,26 @@ "index.js", "index.d.ts", "README.md", - "LICENSE", - "test/*.test.js", - "test/generate-fixtures.py", - "test/tiny_models.py", - "test/types.ts", - "test/tsconfig.json" + "LICENSE" ], "scripts": { - "test:fixtures": "python3 test/generate-fixtures.py", - "test": "node --test test/addon.test.js test/surface.test.js test/ffi.test.js test/runtime.test.js", - "test:packaging": "node --test test/packaging.test.js", - "test:types": "tsc -p test/tsconfig.json" + "test": "node --test test/*.test.js" }, "dependencies": { "koffi": "^3.3.6" }, "optionalDependencies": { - "@gorse/clap-cpp-linux-x64": "0.1.0", - "@gorse/clap-cpp-linux-arm64": "0.1.0", "@gorse/clap-cpp-darwin-arm64": "0.1.0", + "@gorse/clap-cpp-linux-arm64": "0.1.0", + "@gorse/clap-cpp-linux-x64": "0.1.0", "@gorse/clap-cpp-win32-x64": "0.1.0" }, "devDependencies": { - "typescript": "^5.9.3", - "@types/node": "^22.0.0" - } + "@huggingface/hub": "^2.17.10", + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + }, + "workspaces": [ + "packages/*" + ] } diff --git a/node/packages/darwin-arm64/README.md b/node/packages/darwin-arm64/README.md new file mode 100644 index 0000000..45e855a --- /dev/null +++ b/node/packages/darwin-arm64/README.md @@ -0,0 +1,4 @@ +# @gorse/clap-cpp-darwin-arm64 + +Prebuilt shared runtime for clap-cpp. Installed automatically as an optional +dependency on the matching platform. Use the `clap-cpp` package API. diff --git a/node/packages/darwin-arm64/package.json b/node/packages/darwin-arm64/package.json new file mode 100644 index 0000000..70d7708 --- /dev/null +++ b/node/packages/darwin-arm64/package.json @@ -0,0 +1,25 @@ +{ + "name": "@gorse/clap-cpp-darwin-arm64", + "version": "0.1.0", + "description": "Prebuilt clap.cpp shared runtime for darwin-arm64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/gorse-io/clap.cpp.git", + "directory": "node/packages/darwin-arm64" + }, + "publishConfig": { + "access": "public" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "runtime", + "LICENSE", + "README.md" + ] +} diff --git a/node/packages/linux-arm64/README.md b/node/packages/linux-arm64/README.md new file mode 100644 index 0000000..6f994f8 --- /dev/null +++ b/node/packages/linux-arm64/README.md @@ -0,0 +1,4 @@ +# @gorse/clap-cpp-linux-arm64 + +Prebuilt shared runtime for clap-cpp. Installed automatically as an optional +dependency on the matching platform. Use the `clap-cpp` package API. diff --git a/node/packages/linux-arm64/package.json b/node/packages/linux-arm64/package.json new file mode 100644 index 0000000..16baa9f --- /dev/null +++ b/node/packages/linux-arm64/package.json @@ -0,0 +1,28 @@ +{ + "name": "@gorse/clap-cpp-linux-arm64", + "version": "0.1.0", + "description": "Prebuilt clap.cpp shared runtime for linux-arm64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/gorse-io/clap.cpp.git", + "directory": "node/packages/linux-arm64" + }, + "publishConfig": { + "access": "public" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "files": [ + "runtime", + "LICENSE", + "README.md" + ], + "libc": [ + "glibc" + ] +} diff --git a/node/packages/linux-x64/README.md b/node/packages/linux-x64/README.md new file mode 100644 index 0000000..06be6d1 --- /dev/null +++ b/node/packages/linux-x64/README.md @@ -0,0 +1,4 @@ +# @gorse/clap-cpp-linux-x64 + +Prebuilt shared runtime for clap-cpp. Installed automatically as an optional +dependency on the matching platform. Use the `clap-cpp` package API. diff --git a/node/packages/linux-x64/package.json b/node/packages/linux-x64/package.json new file mode 100644 index 0000000..162be0b --- /dev/null +++ b/node/packages/linux-x64/package.json @@ -0,0 +1,28 @@ +{ + "name": "@gorse/clap-cpp-linux-x64", + "version": "0.1.0", + "description": "Prebuilt clap.cpp shared runtime for linux-x64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/gorse-io/clap.cpp.git", + "directory": "node/packages/linux-x64" + }, + "publishConfig": { + "access": "public" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "runtime", + "LICENSE", + "README.md" + ], + "libc": [ + "glibc" + ] +} diff --git a/node/packages/win32-x64/README.md b/node/packages/win32-x64/README.md new file mode 100644 index 0000000..f789275 --- /dev/null +++ b/node/packages/win32-x64/README.md @@ -0,0 +1,4 @@ +# @gorse/clap-cpp-win32-x64 + +Prebuilt shared runtime for clap-cpp. Installed automatically as an optional +dependency on the matching platform. Use the `clap-cpp` package API. diff --git a/node/packages/win32-x64/package.json b/node/packages/win32-x64/package.json new file mode 100644 index 0000000..196bdaa --- /dev/null +++ b/node/packages/win32-x64/package.json @@ -0,0 +1,25 @@ +{ + "name": "@gorse/clap-cpp-win32-x64", + "version": "0.1.0", + "description": "Prebuilt clap.cpp shared runtime for win32-x64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/gorse-io/clap.cpp.git", + "directory": "node/packages/win32-x64" + }, + "publishConfig": { + "access": "public" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "runtime", + "LICENSE", + "README.md" + ] +} diff --git a/node/test/addon.test.js b/node/test/addon.test.js deleted file mode 100644 index da8165b..0000000 --- a/node/test/addon.test.js +++ /dev/null @@ -1,196 +0,0 @@ -'use strict'; -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const fs = require('node:fs'); -const { spawnSync } = require('node:child_process'); -const root = process.env.CLAP_CPP_MODULE || '..'; -const { CLAP } = require(root); -const fixtures = path.join(__dirname, 'fixtures'); -const modelPath = path.join(fixtures, 'clap.gguf'); -const mertPath = path.join(fixtures, 'mert.gguf'); -function vector(v, n) { - assert.ok(v instanceof Float32Array); - assert.equal(v.length, n); - assert.ok(v.every(Number.isFinite)); - assert.ok(v.some(x => x !== 0)); -} -function near(a, b, tolerance = 2e-5) { - assert.equal(a.length, b.length); - a.forEach((x, i) => assert.ok(Math.abs(x - b[i]) < tolerance, `${i}: ${x} != ${b[i]}`)); -} -const pcm = Float32Array.from({ length: 24000 }, (_, i) => 0.2 * Math.sin(i * 0.031)); -test('strict constructor validation', () => { - assert.equal(typeof CLAP, 'function'); - for (const p of [undefined, null, 1, '', 'a\0b']) assert.throws(() => new CLAP(p)); - for (const n of [-1, 0.5, NaN, Infinity, 2147483648, '1', null]) assert.throws(() => new CLAP(modelPath, n)); - assert.throws(() => CLAP(modelPath)); - assert.throws(() => new CLAP(path.join(fixtures, 'missing.gguf'))); -}); -test('real CLAP text inference with owned outputs and scalar oracle', () => { - const m = new CLAP(modelPath, 1); - const a = m.encodeText('hello'); - vector(a, 3); - const oracle = path.join(fixtures, 'reference.json'); - if (fs.existsSync(oracle)) { - const ref = JSON.parse(fs.readFileSync(oracle, 'utf8')); - near(a, m.encodeText(ref.ids)); - near(a, m.encodeText(Int32Array.from(ref.ids))); - near(a, ref.values); - } - vector(m.encodeText('你好 🎵'), 3); - assert.ok(Math.abs(Math.hypot(...a) - 1) < 1e-5); - const b = m.encodeText('hello'); - a.fill(99); - near(b, m.encodeText('hello')); - m.close(); m.close(); - vector(b, 3); - for (const f of [() => m.encodeText('x'), () => m.encodeAudio(pcm), - () => m.encodeAudio(path.join(fixtures, 'stereo.wav'))]) assert.throws(f, /closed/i); -}); -test('encodeText rejects a model closed by a reentrant token array getter', () => { - const child = spawnSync(process.execPath, ['-e', ` - const assert = require('node:assert/strict'); - const { CLAP } = require(${JSON.stringify(require.resolve(root))}); - const m = new CLAP(${JSON.stringify(modelPath)}, 1); - const ids = [0, 2]; - let calls = 0; - Object.defineProperty(ids, 0, { get() { ++calls; m.close(); return 0; } }); - assert.throws(() => m.encodeText(ids), /closed/i); - assert.equal(calls, 1); - assert.throws(() => m.encodeText('hello'), /closed/i); - m.close(); - console.log('reentrant close rejected safely'); - `], { encoding: 'utf8', timeout: 120000 }); - assert.equal(child.error, undefined); - assert.equal(child.signal, null, `child signal: ${child.signal}\n${child.stderr}`); - assert.equal(child.status, 0, child.stderr); - assert.match(child.stdout, /reentrant close rejected safely/); -}); -test('token Proxy length and index traps finish before borrowing a handle', () => { - for (const key of ['length', '0']) { - const m = new CLAP(modelPath, 1); - const ids = new Proxy([0, 2], { get(target, property, receiver) { - if (property === key) m.close(); - return Reflect.get(target, property, receiver); - } }); - assert.throws(() => m.encodeText(ids), /closed/i); - m.close(); - } -}); -test('typed array conversion ignores user getters, iterators and species', () => { - const m = new CLAP(modelPath, 1); - try { - const ids = new Int32Array([0, 2]); - const waveform = new Float32Array(pcm); - for (const value of [ids, waveform]) { - for (const key of ['length', 'buffer', 'byteOffset', 'constructor', Symbol.iterator]) { - Object.defineProperty(value, key, { get() { throw new Error('must not run a user getter'); } }); - } - } - vector(m.encodeText(ids), 3); - vector(m.encodeAudio(waveform, 24000), 3); - assert.throws(() => m.encodeText(new Proxy(ids, {})), TypeError); - assert.throws(() => m.encodeAudio(new Proxy(waveform, {})), TypeError); - } finally { m.close(); } -}); -test('instances cannot be copied or impersonated to borrow native ownership', () => { - const m = new CLAP(modelPath, 1); - try { - assert.deepEqual(Reflect.ownKeys(m), []); - for (const receiver of [{ ...m }, Object.create(m), Object.create(CLAP.prototype), - new Proxy(m, {}), {}, null]) { - for (const [method, args] of [['close', []], ['encodeText', ['hello']], ['encodeAudio', [pcm]]]) { - assert.throws(() => Reflect.apply(CLAP.prototype[method], receiver, args), TypeError); - } - } - vector(m.encodeText('hello'), 3); - } finally { m.close(); } -}); -test('explicit close and garbage collection keep separate owners and returned vectors safe', () => { - const entry = require.resolve(root); - const koffiPath = require.resolve('koffi', { paths: [path.dirname(entry)] }); - const child = spawnSync(process.execPath, ['--expose-gc', '-e', ` - const assert = require('node:assert/strict'); - const koffi = require(${JSON.stringify(koffiPath)}); - const load = koffi.load; - let destroys = 0; - koffi.load = (...args) => { - const lib = load(...args); - return { func(...declaration) { - const fn = lib.func(...declaration); - if (declaration[0] !== 'clap_destroy') return fn; - return pointer => { ++destroys; return fn(pointer); }; - } }; - }; - const { CLAP } = require(${JSON.stringify(entry)}); - (async () => { - const survivor = new CLAP(${JSON.stringify(modelPath)}, 1); - const vectors = []; - const weak = []; - for (let i = 0; i < 12; ++i) { - let model = new CLAP(${JSON.stringify(modelPath)}, 1); - vectors.push(model.encodeText('hello')); - weak.push(new WeakRef(model)); - if (i % 2 === 0) { model.close(); model.close(); } - model = null; - } - assert.equal(destroys, 6, 'close destroys each explicitly closed owner exactly once'); - let collected = false; - for (let i = 0; i < 100; ++i) { - await new Promise(resolve => setImmediate(resolve)); - global.gc(); - await new Promise(resolve => setImmediate(resolve)); - if (weak.every(ref => ref.deref() === undefined)) { collected = true; break; } - } - assert.ok(collected, 'models should be collectible (registry must not retain owners)'); - global.gc(); - for (let i = 0; i < 100 && destroys !== 12; ++i) { - await new Promise(resolve => setImmediate(resolve)); - } - assert.equal(destroys, 12, 'GC destroys only the remaining unclosed owners'); - assert.deepEqual(survivor.encodeText('hello'), vectors[0]); - survivor.close(); survivor.close(); - assert.equal(destroys, 13); - vectors.forEach(v => assert.ok(v instanceof Float32Array && v.every(Number.isFinite))); - console.log('GC and close owners remain independent'); - })().catch(error => { console.error(error); process.exitCode = 1; }); - `], { encoding: 'utf8', timeout: 120000 }); - assert.equal(child.error, undefined); - assert.equal(child.signal, null, child.stderr); - assert.equal(child.status, 0, child.stderr); - assert.match(child.stdout, /GC and close owners remain independent/); -}); -test('CLAP encodes waveforms and audio paths with automatic resampling', () => { - const m = new CLAP(modelPath, 1); - vector(m.encodeAudio(pcm, 24000), 3); - near(m.encodeAudio(pcm, 24000), m.encodeAudio(pcm, 24000, 0)); - const filename = path.join(fixtures, 'stereo.wav'); - const expected = m.encodeAudio(new Float32Array(24000).fill(0.25), 24000); - near(m.encodeAudio(filename), expected); - near(m.encodeAudio(filename, 0), expected); - m.close(); -}); -test('MERT exposes pooled audio through encodeAudio', () => { - const m = new CLAP(mertPath, 1); - vector(m.encodeAudio(pcm, 24000), 4); - assert.throws(() => m.encodeText('hello')); - m.close(); -}); -test('strict method input validation and native exceptions', () => { - const m = new CLAP(modelPath, 1); - for (const x of [undefined, null, {}, [], new Uint8Array([1]), new Float32Array(), - new Float32Array([NaN]), new Float32Array([Infinity])]) assert.throws(() => m.encodeAudio(x)); - for (const rate of [0, -1, 0.5, NaN, Infinity, 2147483648, '24000', null]) { - assert.throws(() => m.encodeAudio(pcm, rate)); - } - for (const offset of [-1, 0.5, Infinity, NaN, 2 ** 53, '0', null]) { - assert.throws(() => m.encodeAudio(pcm, 24000, offset)); - assert.throws(() => m.encodeAudio(path.join(fixtures, 'stereo.wav'), offset)); - } - for (const x of [null, undefined, {}, 3, 'a\0b', [], [-1], [0.5], [NaN], [Infinity], [2147483648], - ['1'], new Float32Array([1]), new Int32Array([-1])]) assert.throws(() => m.encodeText(x)); - assert.throws(() => m.encodeText([999999])); - for (const p of ['', 'a\0b', path.join(fixtures, 'missing.wav'), modelPath]) assert.throws(() => m.encodeAudio(p)); - m.close(); -}); diff --git a/node/test/clap.test.js b/node/test/clap.test.js new file mode 100644 index 0000000..f37cbbd --- /dev/null +++ b/node/test/clap.test.js @@ -0,0 +1,64 @@ +/* + * Copyright 2026 clap.cpp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createWriteStream, existsSync, statSync } = require('node:fs'); +const path = require('node:path'); +const { pipeline } = require('node:stream/promises'); +const { downloadFile } = require('@huggingface/hub'); +const { CLAP } = require('..'); + +function cosine(a, b) { + const dot = a.reduce((sum, x, i) => sum + x * b[i], 0); + const normA = Math.sqrt(a.reduce((sum, x) => sum + x * x, 0)); + const normB = Math.sqrt(b.reduce((sum, x) => sum + x * x, 0)); + return dot / (normA * normB); +} + +test('CLAP', async (t) => { + const filename = 'larger_clap_general-q4_k_m.gguf'; + const modelPath = path.join(__dirname, '..', '..', filename); + const file = await downloadFile({ + repo: 'gorse-io/larger_clap_general-GGUF', + path: filename, + xet: false, + }); + if (!existsSync(modelPath) || statSync(modelPath).size !== file.size) { + await pipeline(file.stream(), createWriteStream(modelPath)); + } + + const clap = new CLAP(modelPath, 1); + t.after(() => clap.close()); + const audioEmbedding = clap.encodeAudio(path.join(__dirname, '..', '..', 'assets', 'dog.wav'), 0); + const textEmbedding1 = clap.encodeText('Sound of a dog'); + const textEmbedding2 = clap.encodeText('Sound of vaccum cleaner'); + + const distance1 = cosine(audioEmbedding, textEmbedding1); + const distance2 = cosine(audioEmbedding, textEmbedding2); + // softmax(cosine * exp(logit_scale_a)), using the learned scale from + // laion/larger_clap_general, as in rust/tests/clap.rs. + const logitScale = Math.exp(3.6549275); + const logit1 = distance1 * logitScale; + const logit2 = distance2 * logitScale; + const maxLogit = Math.max(logit1, logit2); + const exp1 = Math.exp(logit1 - maxLogit); + const exp2 = Math.exp(logit2 - maxLogit); + const score1 = exp1 / (exp1 + exp2); + const score2 = exp2 / (exp1 + exp2); + assert.ok(score1 > 0.99, `Expected dog score > 0.99, got ${score1}`); + assert.ok(score2 < 0.01, `Expected vacuum cleaner score < 0.01, got ${score2}`); +}); diff --git a/node/test/ffi.test.js b/node/test/ffi.test.js deleted file mode 100644 index fbb9d1b..0000000 --- a/node/test/ffi.test.js +++ /dev/null @@ -1,54 +0,0 @@ -'use strict'; -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { spawnSync } = require('node:child_process'); - -test('package entry is a JavaScript Koffi wrapper, not a project addon', () => { - const filename = require.resolve(process.env.CLAP_CPP_MODULE || '..'); - const entry = fs.readFileSync(filename, 'utf8'); - assert.match(entry, /require\(['"]koffi['"]\)/); - assert.doesNotMatch(entry, /clap_node\.node/); - assert.equal(fs.existsSync(path.join(path.dirname(filename), 'bindings.cpp')), false); -}); - -test('real native buffers are freed when JavaScript output decoding throws', () => { - const entry = require.resolve(process.env.CLAP_CPP_MODULE || '..'); - const koffiPath = require.resolve('koffi', { paths: [path.dirname(entry)] }); - const model = path.join(__dirname, 'fixtures', 'clap.gguf'); - const child = spawnSync(process.execPath, ['-e', ` - const assert = require('node:assert/strict'); - const koffi = require(${JSON.stringify(koffiPath)}); - const load = koffi.load; - let frees = 0; - // Observe the actual C deallocator, without substituting native inference. - koffi.load = (...args) => { - const lib = load(...args); - return { func(...declaration) { - const fn = lib.func(...declaration); - if (declaration[0] !== 'clap_free') return fn; - return pointer => { assert.notEqual(pointer, null); ++frees; return fn(pointer); }; - } }; - }; - const { CLAP } = require(${JSON.stringify(entry)}); - const m = new CLAP(${JSON.stringify(model)}, 1); - const decode = koffi.decode; - const failure = new RangeError('injected JS allocation failure'); - koffi.decode = () => { throw failure; }; - assert.throws(() => m.encodeText('hello'), error => error === failure); - assert.equal(frees, 1); - koffi.decode = decode; - const result = m.encodeText('hello'); - assert.ok(result instanceof Float32Array); - assert.equal(result.length, 3); - assert.equal(frees, 2); - m.close(); m.close(); - assert.ok(result.every(Number.isFinite)); - console.log('real buffers freed on both decode failure and success'); - `], { encoding: 'utf8', timeout: 120000 }); - assert.equal(child.error, undefined); - assert.equal(child.signal, null, child.stderr); - assert.equal(child.status, 0, child.stderr); - assert.match(child.stdout, /real buffers freed/); -}); diff --git a/node/test/generate-fixtures.py b/node/test/generate-fixtures.py deleted file mode 100644 index 3a7710a..0000000 --- a/node/test/generate-fixtures.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Generate deterministic real models/audio using the shared stdlib fixture.""" -import importlib.util -import json -from pathlib import Path -import struct -import wave - -root = Path(__file__).resolve().parents[1] -source = root.parent / 'python' / 'tests' / 'tiny_models.py' -if not source.exists(): - source = root / 'test' / 'tiny_models.py' -spec = importlib.util.spec_from_file_location('tiny_models', source) -fixtures = importlib.util.module_from_spec(spec) -spec.loader.exec_module(fixtures) -out = root / 'test' / 'fixtures' -out.mkdir(parents=True, exist_ok=True) -fixtures.clap_fixture(out / 'clap.gguf') -fixtures.mert_fixture(out / 'mert.gguf') -with wave.open(str(out / 'stereo.wav'), 'wb') as wav: - wav.setparams((2, 2, 24000, 0, 'NONE', 'not compressed')) - wav.writeframes(struct.pack('=22.0.0'); - assert.deepEqual(manifest.optionalDependencies, Object.fromEntries( - Object.keys(platforms).map(target => [`@gorse/clap-cpp-${target}`, manifest.version]))); - for (const hook of lifecycle) assert.equal(manifest.scripts[hook], undefined, `no ${hook} hook`); - for (const name of ['runtime', 'scripts', 'native', 'native-tests', 'build']) { - assert.ok(!manifest.files.some(p => p === name || p.startsWith(`${name}/`)), `no ${name} allowlist`); - } - assert.ok(manifest.files.includes('test/tiny_models.py')); -} -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - encoding: 'utf8', timeout: 180000, maxBuffer: 10 * 1024 * 1024, - shell: command === 'npm' && process.platform === 'win32', ...options, - }); - assert.equal(result.error, undefined); - assert.equal(result.status, 0, result.stdout + result.stderr); - return result.stdout; -} -function pack(source, destination) { - const output = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', destination], { cwd: source })); - const archive = path.join(destination, output[0].filename); - // Inspect the real archive, not an npm dry-run file list. - const inspected = JSON.parse(run(python, ['-c', ` -import json, sys, tarfile -with tarfile.open(sys.argv[1]) as archive: - files = [m.name.removeprefix('package/') for m in archive.getmembers() if m.isfile()] - manifest = json.load(archive.extractfile('package/package.json')) -print(json.dumps(dict(files=files, manifest=manifest))) -`, archive])); - return { archive, ...inspected }; -} -function checkNative(packed, target, version) { - const { files, manifest } = packed; - const [osName, cpu] = target.split('-'); - assert.equal(manifest.name, `@gorse/clap-cpp-${target}`); - assert.equal(manifest.version, version); - assert.deepEqual(manifest.os, [osName]); - assert.deepEqual(manifest.cpu, [cpu]); - assert.deepEqual(manifest.libc, osName === 'linux' ? ['glibc'] : undefined); - for (const hook of lifecycle) assert.equal(manifest.scripts?.[hook], undefined); - const expected = ['package.json', 'README.md', 'LICENSE', `runtime/clap_cpp/${platforms[target]}`, - ...['clap', 'ggml', 'icu', 'json', 'miniaudio'].map(name => `runtime/clap_cpp/licenses/${name}.txt`)]; - for (const filename of expected) assert.ok(files.includes(filename), `missing ${target}: ${filename}`); - // Wheel-repair companion libraries retain their original relative layout. - assert.ok(files.every(filename => expected.includes(filename) || filename.startsWith('runtime/'))); - assert.ok(!files.some(filename => /\.(py|pyi|pyc)$/.test(filename)), 'no Python dependency in native npm packages'); -} - -test('manifest declares exact-version optional packages and no native build hooks', () => { - const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); - checkManifest(manifest); - const lock = path.join(root, 'package-lock.json'); - if (fs.existsSync(lock)) { - assert.deepEqual(JSON.parse(fs.readFileSync(lock, 'utf8')).packages[''].optionalDependencies, - manifest.optionalDependencies); - } - assert.ok(!fs.existsSync(path.join(root, 'scripts')), 'scripts directory is removed'); -}); - -test('real main and native tarballs install and run outside the checkout', { timeout: 600000 }, t => { - const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'clap-package-test-'))); - t.after(() => fs.rmSync(dir, { recursive: true, force: true })); - assert.ok(platforms[host], `unsupported test host ${host}`); - const checkout = fs.existsSync(path.join(root, '..', '.github', 'scripts', 'pack-node.py')); - const source = process.env.CLAP_CPP_PACKAGE_DIR || (checkout ? path.join(root, 'dist/clap-cpp') : root); - assert.ok(fs.existsSync(source), `Stage packages externally first: missing ${source}`); - const packed = pack(source, dir); - const { files, manifest } = packed; - checkManifest(manifest); - for (const filename of ['index.js', 'index.d.ts', 'README.md', 'LICENSE', 'package.json', - 'test/generate-fixtures.py', 'test/tiny_models.py', 'test/types.ts', 'test/tsconfig.json']) { - assert.ok(files.includes(filename), `missing ${filename}`); - } - for (const name of fs.readdirSync(path.join(root, 'test')).filter(n => n.endsWith('.test.js'))) { - assert.ok(files.includes(`test/${name}`), `missing test/${name}`); - } - assert.ok(!files.some(p => /^(runtime|scripts|native|native-tests|build|src|include|cmake|ggml|thirdparty|node_modules)\//.test(p) || - /\.(node|so|dylib|dll|tgz)$/.test(p) || p.includes('/.git/') || p.startsWith('test/fixtures/')), - 'main package has no runtimes, build scripts, native sources or generated test artifacts'); - for (const filename of ['index.js', 'index.d.ts', 'package.json', 'README.md', ...files.filter(p => p.startsWith('test/'))]) { - const original = checkout && filename === 'test/tiny_models.py' - ? path.join(root, '..', 'python/tests/tiny_models.py') : path.join(root, filename); - assert.deepEqual(fs.readFileSync(path.join(source, filename)), fs.readFileSync(original), - `staged source must match checkout: ${filename}`); - } - const targets = process.env.CLAP_CPP_REQUIRE_ALL_RUNTIMES === '1' ? Object.keys(platforms) : [host]; - let hostArchive; - for (const target of targets) { - let nativeSource; - if (target === host && process.env.CLAP_CPP_NATIVE_PACKAGE_DIR) { - nativeSource = process.env.CLAP_CPP_NATIVE_PACKAGE_DIR; - } else if (checkout) { - nativeSource = path.join(root, `dist/clap-cpp-${target}`); - } else { - nativeSource = path.dirname(require.resolve(`@gorse/clap-cpp-${target}/package.json`, { paths: [root] })); - } - assert.ok(fs.existsSync(nativeSource), `missing externally staged native package: ${nativeSource}`); - const native = pack(nativeSource, dir); - checkNative(native, target, manifest.version); - if (target === host) hostArchive = native.archive; - } - // Explicit tarballs prove local installation/inference, not registry platform - // selection; the external test-node-install.py exercises that via a local registry. - const installedRoot = path.join(dir, 'installed'); - run('npm', ['install', '--prefix', installedRoot, '--omit=dev', '--no-audit', '--no-fund', - hostArchive, packed.archive]); - const pkg = path.join(installedRoot, 'node_modules', manifest.name); - const installedNative = path.join(installedRoot, 'node_modules', `@gorse/clap-cpp-${host}`); - assert.equal(JSON.parse(fs.readFileSync(path.join(installedNative, 'package.json'))).version, manifest.version); - run(process.execPath, ['-e', ` - const assert = require('node:assert/strict'); - const binding = require(${JSON.stringify(pkg)}); - assert.deepEqual(Object.keys(binding), ['CLAP']); - assert.throws(() => new binding.CLAP('missing-model.gguf'), /.+/); - `]); - for (const filename of ['index.js', 'index.d.ts', 'package.json', 'test/generate-fixtures.py', 'test/tiny_models.py']) { - const original = checkout && filename === 'test/tiny_models.py' - ? path.join(root, '..', 'python/tests/tiny_models.py') : path.join(root, filename); - assert.deepEqual(fs.readFileSync(path.join(pkg, filename)), fs.readFileSync(original)); - } - run(python, ['test/generate-fixtures.py'], { cwd: pkg }); - run(process.execPath, ['--test', 'test/addon.test.js', 'test/surface.test.js', 'test/ffi.test.js', - 'test/runtime.test.js'], { cwd: pkg, env: { ...process.env, CLAP_CPP_MODULE: pkg } }); -}); diff --git a/node/test/runtime.test.js b/node/test/runtime.test.js deleted file mode 100644 index 9c90686..0000000 --- a/node/test/runtime.test.js +++ /dev/null @@ -1,106 +0,0 @@ -'use strict'; -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { spawnSync } = require('node:child_process'); -const entry = require.resolve(process.env.CLAP_CPP_MODULE || '..'); -const manifest = require(path.join(path.dirname(entry), 'package.json')); -const platforms = { - 'linux-x64': 'libclap.so', - 'linux-arm64': 'libclap.so', - 'darwin-arm64': 'libclap.dylib', - 'win32-x64': 'clap.dll', -}; - -function fixture(t, version = manifest.version) { - const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'clap-runtime-test-'))); - t.after(() => fs.rmSync(dir, { recursive: true, force: true })); - fs.copyFileSync(entry, path.join(dir, 'index.js')); - fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(manifest)); - fs.mkdirSync(path.join(dir, 'native')); - fs.writeFileSync(path.join(dir, 'native/package.json'), JSON.stringify({ version })); - return dir; -} -function load(dir, target, { missing = false, binary = true } = {}) { - const [platform, arch] = target.split('-'); - const runtime = path.join(dir, 'native/runtime/clap_cpp', platforms[target] || 'unused'); - // Mock only resolution/existence at the selection boundary. No fake binary is - // created or loaded; real ABI and inference are exercised by packaging tests. - return spawnSync(process.execPath, ['-e', ` - Object.defineProperty(process, 'platform', { value: ${JSON.stringify(platform)} }); - Object.defineProperty(process, 'arch', { value: ${JSON.stringify(arch)} }); - const Module = require('node:module'); - const resolve = Module._resolveFilename; - Module._resolveFilename = function(name, ...args) { - if (name.startsWith('@gorse/clap-cpp-')) { - if (name !== ${JSON.stringify(`@gorse/clap-cpp-${target}/package.json`)}) throw Error('wrong optional package'); - if (${missing}) { const e = Error('not installed'); e.code = 'MODULE_NOT_FOUND'; throw e; } - return ${JSON.stringify(path.join(dir, 'native/package.json'))}; - } - return resolve.call(this, name, ...args); - }; - const fs = require('node:fs'); - const exists = fs.existsSync; - fs.existsSync = filename => filename === ${JSON.stringify(runtime)} ? ${binary} : exists(filename); - const original = Module._load; - Module._load = function(name, ...args) { - if (name === 'koffi') return { load(filename) { - console.log(filename); - throw new Error('selected runtime'); - } }; - return original.call(this, name, ...args); - }; - require(${JSON.stringify(path.join(dir, 'index.js'))}); - `], { encoding: 'utf8' }); -} - -for (const target of Object.keys(platforms)) { - test(`selects only the ${target} optional package runtime`, t => { - const dir = fixture(t); - const result = load(dir, target); - assert.equal(result.status, 1); - assert.match(result.stderr, /selected runtime/); - assert.equal(result.stdout.trim(), path.join(dir, 'native/runtime/clap_cpp', platforms[target])); - }); -} - -test('rejects unsupported operating systems and architectures before resolution', t => { - const dir = fixture(t); - for (const target of ['freebsd-x64', 'linux-ia32', 'darwin-x64', 'win32-arm64']) { - const result = load(dir, target); - assert.equal(result.status, 1); - assert.ok(result.stderr.includes(`Unsupported platform or architecture: ${target}`)); - assert.equal(result.stdout, ''); - } -}); - -test('missing optional package gives installation guidance without a legacy fallback', t => { - const dir = fixture(t); - // Even a real library in the old layout must not be used. - const result = load(dir, 'linux-x64', { missing: true }); - assert.equal(result.status, 1); - assert.match(result.stderr, /Missing optional native package @gorse\/clap-cpp-linux-x64/); - assert.match(result.stderr, /optional dependencies/); - assert.equal(result.stdout, ''); -}); - -test('rejects a native package with a different version before loading', t => { - const dir = fixture(t, '0.0.0-mismatched'); - const result = load(dir, 'linux-x64'); - assert.equal(result.status, 1); - assert.match(result.stderr, /Native package version mismatch/); - assert.ok(result.stderr.includes(manifest.version)); - assert.match(result.stderr, /0\.0\.0-mismatched/); - assert.equal(result.stdout, ''); -}); - -test('missing native library reports its exact path without falling back', t => { - const dir = fixture(t); - const result = load(dir, 'linux-x64', { binary: false }); - assert.equal(result.status, 1); - assert.match(result.stderr, /Missing prebuilt clap runtime/); - assert.ok(result.stderr.includes(path.join(dir, 'native/runtime/clap_cpp/libclap.so'))); - assert.equal(result.stdout, ''); -}); diff --git a/node/test/surface.test.js b/node/test/surface.test.js deleted file mode 100644 index d13a968..0000000 --- a/node/test/surface.test.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const binding = require(process.env.CLAP_CPP_MODULE || '..'); - -test('only CLAP and its encoding/lifecycle methods are public', () => { - assert.deepEqual(Object.keys(binding), ['CLAP']); - assert.equal(binding.CLAP.name, 'CLAP'); - assert.deepEqual(Object.getOwnPropertyNames(binding.CLAP.prototype).sort(), - ['constructor', 'encodeAudio', 'encodeText', 'close'].sort()); -}); - -test('CLAP is a JavaScript class with no callable constructor', () => { - assert.match(Function.prototype.toString.call(binding.CLAP), /^class CLAP\s*\{/); - assert.throws(() => binding.CLAP('missing.gguf'), TypeError); -}); diff --git a/node/test/tsconfig.json b/node/test/tsconfig.json deleted file mode 100644 index c6044e8..0000000 --- a/node/test/tsconfig.json +++ /dev/null @@ -1 +0,0 @@ -{"compilerOptions":{"strict":true,"noEmit":true,"target":"ES2020","module":"commonjs","moduleResolution":"node","skipLibCheck":true},"files":["types.ts"]} diff --git a/node/test/types.ts b/node/test/types.ts deleted file mode 100644 index b8aa54b..0000000 --- a/node/test/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CLAP } from '..'; -const model = new CLAP('model.gguf', 1); -const embedding: Float32Array = model.encodeText('hello'); -model.encodeText([0, 2]); -model.encodeText(new Int32Array([0, 2])); -model.encodeAudio(new Float32Array(24000), 24000, 0); -model.encodeAudio('clip.wav'); -model.encodeAudio('clip.flac', 0); -model.close(); -// @ts-expect-error PCM must have F32 storage -model.encodeAudio(new Float64Array(1)); -// @ts-expect-error tokens cannot be strings -model.encodeText(['hello']); -// @ts-expect-error old method is not public -model.text('hello'); -// @ts-expect-error tokenizer is not public -model.tokenize('hello'); -// @ts-expect-error model metadata is not public -model.projectionDimension; -// @ts-expect-error frame encodings are not public -model.audioFrames(new Float32Array(1)); -// @ts-expect-error utility is not exported -import { decodeAudio } from '..'; -// @ts-expect-error old class is not exported -import { Model } from '..'; -void embedding; diff --git a/rust/README.md b/rust/README.md index d206a3d..98120f5 100644 --- a/rust/README.md +++ b/rust/README.md @@ -1,38 +1,25 @@ # clap.cpp Rust bindings [![crates.io](https://img.shields.io/crates/v/clap-cpp)](https://crates.io/crates/clap-cpp) -[![crates.io downloads](https://img.shields.io/crates/dm/clap-cpp)](https://crates.io/crates/clap-cpp) +[![Crates.io Total Downloads](https://img.shields.io/crates/d/clap-cpp)](https://crates.io/crates/clap-cpp) +[![docs.rs](https://img.shields.io/docsrs/clap-cpp)](https://docs.rs/clap-cpp) + ## Install +Rust bindings require CMake 3.20 or newer and a C++20 compiler (MSVC on Windows). + ```sh cargo add clap-cpp ``` -Requires CMake 3.20 or newer and a C++20 compiler (MSVC on Windows). The -published crate bundles clap.cpp, ggml, JSON, ICU Unicode/regex, -and miniaudio sources. Default builds enable Metal for macOS targets and remain -CPU-only on Linux and Windows. macOS builds require the Apple SDK and Metal -compiler. Default Linux and Windows builds need no GPU SDK. Builds do not download -dependencies or require Git, Python, shared ICU libraries, or the parent repository. -Cross-compiling is not currently supported. Python 3 is required only for -integration-test fixture generation and maintainer checks. - -Rust declares the public C API directly with `unsafe extern "C"` and wraps model -handles and returned buffers in Rust RAII owners. There is no C++ Rust bridge or -separate `rust/native/CMakeLists.txt` build graph. Both checkout and packaged -builds configure the root `CMakeLists.txt` in core-only mode, without CLI or test -dependencies, and link the static clap/ggml/ICU archives. CMake supplies the -resolved transitive SDK link dependencies for enabled GPU features. - ## GPU features Enable an optional backend when adding the dependency (Metal is already enabled by default on macOS): ```sh -cargo add clap-cpp --features cuda -# Or: --features vulkan, --features rocm, --features metal +cargo add clap-cpp --features cuda|vulkan|rocm|metal ``` | Feature | Native backend | Build requirements |