From f040bf9321eaf4f494f2622d633d72bb09a99179 Mon Sep 17 00:00:00 2001 From: Max Mansfield Date: Tue, 27 Jan 2026 00:47:05 -0700 Subject: [PATCH 1/3] fix(js): extract framework archives during npm install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS prebuilds include frameworks (tp.framework, util.framework, curl64.framework) as .tar.gz archives, but these were not being extracted after prebuild-install downloaded them. This caused dlopen errors when loading the native module. Changes: - Update install.js to extract .framework.tar.gz files after prebuild - Add integration test to validate npm pack → install → load flow - Add test:js:integration task and CI job for the new test - Update .gitignore to allow tests/*.js files:wq --- .github/workflows/main.yml | 33 +++++++- .gitignore | 1 + Taskfile.yml | 8 +- scripts/install.js | 48 ++++++++++- tests/pack-install.test.js | 160 +++++++++++++++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 tests/pack-install.test.js diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8147cfe..ffe8f25 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -225,7 +225,7 @@ jobs: run: npm install - name: Run Node.js tests - run: npx jest tests/ + run: npx jest tests/rtms.test.ts # Test Node.js SDK on macOS using pre-built prebuilds test-nodejs-macos: @@ -252,7 +252,34 @@ jobs: run: npm install - name: Run Node.js tests - run: npx jest tests/ + run: npx jest tests/rtms.test.ts + + # Integration test: npm pack → install → load (validates end-user install flow) + test-nodejs-integration-macos: + name: Test Node.js Install Flow (macOS) + runs-on: macos-latest + needs: [build-nodejs-macos] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: '24.x' + + - name: Download prebuilds + uses: actions/download-artifact@v4 + with: + name: prebuilds-darwin + path: prebuilds/ + + - name: Install dependencies + run: npm install + + - name: Run integration test (npm pack → install → load) + run: npx jest tests/pack-install.test.js --testTimeout=120000 # Test Python SDK on Linux using pre-built wheels test-python-linux: @@ -350,7 +377,7 @@ jobs: check-version-change: name: Check for Version Changes runs-on: ubuntu-latest - needs: [test-nodejs-linux, test-nodejs-macos, test-python-linux, test-python-macos] + needs: [test-nodejs-linux, test-nodejs-macos, test-nodejs-integration-macos, test-python-linux, test-python-macos] if: | success() && github.event_name == 'push' && diff --git a/.gitignore b/.gitignore index c47a2a2..e5b0646 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ scripts/**/* !src/rtms/*.pyi !tests/**.py +!tests/**.js # MacOS .DS_Store diff --git a/Taskfile.yml b/Taskfile.yml index b041061..19a24ac 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -215,7 +215,13 @@ tasks: desc: "Run Node.js tests (local)" deps: [build:js] cmds: - - npx jest tests/ + - npx jest tests/rtms.test.ts + + test:js:integration: + desc: "Run npm pack → install → load integration test" + deps: [build:js] + cmds: + - npx jest tests/pack-install.test.js --testTimeout=120000 test:js:linux: desc: "Run Node.js tests in Linux Docker" diff --git a/scripts/install.js b/scripts/install.js index cab2959..1130af3 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -11,9 +11,10 @@ */ import { execSync } from 'child_process'; -import { existsSync } from 'fs'; +import { existsSync, readdirSync, unlinkSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; +import * as tar from 'tar'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -38,6 +39,47 @@ function warning(message) { console.log(`${colors.yellow}[RTMS Install]${colors.reset} ${message}`); } +/** + * Extract macOS framework archives (.framework.tar.gz) after prebuild-install + * + * The prebuilt binaries include macOS frameworks as tar.gz archives to reduce size. + * These must be extracted for the native module to load correctly. + */ +function extractFrameworks(buildDir) { + if (process.platform !== 'darwin') { + // Frameworks only exist on macOS + return; + } + + if (!existsSync(buildDir)) { + return; + } + + const files = readdirSync(buildDir); + const frameworkArchives = files.filter(f => f.endsWith('.framework.tar.gz')); + + if (frameworkArchives.length === 0) { + return; + } + + log(`Extracting ${frameworkArchives.length} framework archive(s)...`); + + for (const archive of frameworkArchives) { + const archivePath = join(buildDir, archive); + try { + tar.extract({ + file: archivePath, + cwd: buildDir, + sync: true + }); + // Remove the archive after extraction to save space + unlinkSync(archivePath); + } catch (err) { + warning(`Failed to extract ${archive}: ${err.message}`); + } + } +} + /** * Try to install prebuilt binary */ @@ -51,6 +93,10 @@ function installPrebuild() { env: process.env }); + // Extract framework archives on macOS + const buildDir = join(__dirname, '..', 'build', 'Release'); + extractFrameworks(buildDir); + success('Prebuilt binary installed successfully!'); return true; } catch (err) { diff --git a/tests/pack-install.test.js b/tests/pack-install.test.js new file mode 100644 index 0000000..0b9c97c --- /dev/null +++ b/tests/pack-install.test.js @@ -0,0 +1,160 @@ +/** + * Integration test: npm pack → install → load + * + * This test validates the end-user installation flow by: + * 1. Creating an npm tarball with `npm pack` + * 2. Installing the tarball in a temporary directory + * 3. Verifying the native module loads correctly + * + * This catches issues like missing files, broken install scripts, + * or unextracted framework archives that would affect users. + */ + +const { execSync } = require('child_process'); +const { mkdtempSync, rmSync, existsSync, readdirSync, writeFileSync } = require('fs'); +const { join } = require('path'); +const { tmpdir } = require('os'); + +describe('npm pack → install → load integration', () => { + let tempDir; + let tarballPath; + + beforeAll(() => { + // Create tarball from current package + const output = execSync('npm pack --json', { encoding: 'utf8' }); + const packages = JSON.parse(output); + const { filename } = packages[0]; + tarballPath = join(process.cwd(), filename); + }); + + beforeEach(() => { + // Create fresh temp directory for each test + tempDir = mkdtempSync(join(tmpdir(), 'rtms-test-')); + }); + + afterEach(() => { + // Clean up temp directory + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + afterAll(() => { + // Clean up tarball + if (tarballPath && existsSync(tarballPath)) { + rmSync(tarballPath); + } + }); + + test('tarball contains required files', () => { + const output = execSync(`tar -tzf ${tarballPath}`, { encoding: 'utf8' }); + const files = output.split('\n').filter(Boolean); + + // Core files that must be present + expect(files).toEqual( + expect.arrayContaining([ + 'package/scripts/install.js', + 'package/index.ts', + 'package/rtms.d.ts', + 'package/package.json', + 'package/CMakeLists.txt', + 'package/src/node.cpp', + 'package/src/rtms.cpp', + 'package/src/rtms.h' + ]) + ); + }); + + test('native module loads after install from tarball', () => { + // Create minimal package.json for ESM + writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ type: 'module' })); + + // Install from tarball (this runs scripts/install.js which downloads prebuilds) + execSync(`npm install ${tarballPath}`, { + cwd: tempDir, + stdio: 'pipe', + env: { ...process.env, npm_config_loglevel: 'error' } + }); + + // Verify native module loads and has expected exports (matching test.js usage patterns) + const testScript = ` + import rtms from '@zoom/rtms'; + const checks = [ + typeof rtms.Client === 'function', + typeof rtms.onWebhookEvent === 'function', + typeof rtms.MEDIA_TYPE_AUDIO !== 'undefined', + typeof rtms.MEDIA_TYPE_VIDEO !== 'undefined' + ]; + console.log(checks.every(Boolean) ? 'OK' : 'FAIL'); + `; + + const result = execSync(`node -e "${testScript}"`, { + cwd: tempDir, + encoding: 'utf8' + }); + + expect(result.trim()).toBe('OK'); + }); + + test('macOS frameworks are extracted (not left as tar.gz)', () => { + if (process.platform !== 'darwin') { + // Skip on non-macOS - frameworks only exist on darwin + return; + } + + writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ type: 'module' })); + execSync(`npm install ${tarballPath}`, { + cwd: tempDir, + stdio: 'pipe', + env: { ...process.env, npm_config_loglevel: 'error' } + }); + + const buildDir = join(tempDir, 'node_modules/@zoom/rtms/build/Release'); + + if (!existsSync(buildDir)) { + // Prebuild might not be available for this platform - skip + console.log('Build directory not found - prebuild may not be available'); + return; + } + + const files = readdirSync(buildDir); + + // Frameworks should be extracted as directories + expect(files).toContain('tp.framework'); + expect(files).toContain('util.framework'); + expect(files).toContain('curl64.framework'); + + // Archives should NOT remain (they should be extracted and deleted) + expect(files).not.toContain('tp.framework.tar.gz'); + expect(files).not.toContain('util.framework.tar.gz'); + expect(files).not.toContain('curl64.framework.tar.gz'); + + // Verify frameworks are actually directories with content + const tpFramework = join(buildDir, 'tp.framework'); + expect(existsSync(tpFramework)).toBe(true); + expect(readdirSync(tpFramework).length).toBeGreaterThan(0); + }); + + test('Client can be instantiated', () => { + writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ type: 'module' })); + execSync(`npm install ${tarballPath}`, { + cwd: tempDir, + stdio: 'pipe', + env: { ...process.env, npm_config_loglevel: 'error' } + }); + + // Test that Client class works + const testScript = ` + import rtms from '@zoom/rtms'; + const client = new rtms.Client(); + console.log(typeof client.join === 'function' ? 'OK' : 'FAIL'); + `; + + const result = execSync(`node -e "${testScript}"`, { + cwd: tempDir, + encoding: 'utf8' + }); + + expect(result.trim()).toBe('OK'); + }); +}); From 60649ad13cc23bf7207322e7e663b135d246e418 Mon Sep 17 00:00:00 2001 From: Max Mansfield Date: Tue, 27 Jan 2026 00:56:50 -0700 Subject: [PATCH 2/3] integration test fixes --- tests/pack-install.test.js | 83 ++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 39 deletions(-) diff --git a/tests/pack-install.test.js b/tests/pack-install.test.js index 0b9c97c..08ec8e1 100644 --- a/tests/pack-install.test.js +++ b/tests/pack-install.test.js @@ -4,22 +4,57 @@ * This test validates the end-user installation flow by: * 1. Creating an npm tarball with `npm pack` * 2. Installing the tarball in a temporary directory - * 3. Verifying the native module loads correctly + * 3. Copying the local build (simulating prebuild-install) + * 4. Verifying the native module loads correctly * - * This catches issues like missing files, broken install scripts, - * or unextracted framework archives that would affect users. + * This catches issues like missing files, broken package.json config, + * or module resolution problems that would affect users. + * + * Note: This test uses the local build directory instead of downloading + * prebuilds from GitHub, since we need to test BEFORE publishing. */ const { execSync } = require('child_process'); -const { mkdtempSync, rmSync, existsSync, readdirSync, writeFileSync } = require('fs'); +const { mkdtempSync, rmSync, existsSync, readdirSync, writeFileSync, cpSync } = require('fs'); const { join } = require('path'); const { tmpdir } = require('os'); +// Path to local build directory (created by `task build:js`) +const LOCAL_BUILD_DIR = join(process.cwd(), 'build', 'Release'); + +/** + * Install package from tarball and copy local build + * This simulates what prebuild-install does, but uses local build for testing + */ +function installWithLocalBuild(tempDir, tarballPath) { + // Create minimal package.json for ESM + writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ type: 'module' })); + + // Install from tarball (ignore prebuild-install failure since we'll copy local build) + execSync(`npm install ${tarballPath} --ignore-scripts`, { + cwd: tempDir, + stdio: 'pipe', + env: { ...process.env, npm_config_loglevel: 'error' } + }); + + // Copy local build to simulate prebuild-install + const targetBuildDir = join(tempDir, 'node_modules/@zoom/rtms/build/Release'); + if (!existsSync(LOCAL_BUILD_DIR)) { + throw new Error(`Local build not found at ${LOCAL_BUILD_DIR}. Run 'task build:js' first.`); + } + cpSync(LOCAL_BUILD_DIR, targetBuildDir, { recursive: true }); +} + describe('npm pack → install → load integration', () => { let tempDir; let tarballPath; beforeAll(() => { + // Verify local build exists before running tests + if (!existsSync(LOCAL_BUILD_DIR)) { + throw new Error(`Local build not found at ${LOCAL_BUILD_DIR}. Run 'task build:js' first.`); + } + // Create tarball from current package const output = execSync('npm pack --json', { encoding: 'utf8' }); const packages = JSON.parse(output); @@ -66,15 +101,7 @@ describe('npm pack → install → load integration', () => { }); test('native module loads after install from tarball', () => { - // Create minimal package.json for ESM - writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ type: 'module' })); - - // Install from tarball (this runs scripts/install.js which downloads prebuilds) - execSync(`npm install ${tarballPath}`, { - cwd: tempDir, - stdio: 'pipe', - env: { ...process.env, npm_config_loglevel: 'error' } - }); + installWithLocalBuild(tempDir, tarballPath); // Verify native module loads and has expected exports (matching test.js usage patterns) const testScript = ` @@ -96,39 +123,22 @@ describe('npm pack → install → load integration', () => { expect(result.trim()).toBe('OK'); }); - test('macOS frameworks are extracted (not left as tar.gz)', () => { + test('macOS frameworks are present in build', () => { if (process.platform !== 'darwin') { // Skip on non-macOS - frameworks only exist on darwin return; } - writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ type: 'module' })); - execSync(`npm install ${tarballPath}`, { - cwd: tempDir, - stdio: 'pipe', - env: { ...process.env, npm_config_loglevel: 'error' } - }); + installWithLocalBuild(tempDir, tarballPath); const buildDir = join(tempDir, 'node_modules/@zoom/rtms/build/Release'); - - if (!existsSync(buildDir)) { - // Prebuild might not be available for this platform - skip - console.log('Build directory not found - prebuild may not be available'); - return; - } - const files = readdirSync(buildDir); - // Frameworks should be extracted as directories + // Frameworks should be present as directories (copied from local build) expect(files).toContain('tp.framework'); expect(files).toContain('util.framework'); expect(files).toContain('curl64.framework'); - // Archives should NOT remain (they should be extracted and deleted) - expect(files).not.toContain('tp.framework.tar.gz'); - expect(files).not.toContain('util.framework.tar.gz'); - expect(files).not.toContain('curl64.framework.tar.gz'); - // Verify frameworks are actually directories with content const tpFramework = join(buildDir, 'tp.framework'); expect(existsSync(tpFramework)).toBe(true); @@ -136,12 +146,7 @@ describe('npm pack → install → load integration', () => { }); test('Client can be instantiated', () => { - writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ type: 'module' })); - execSync(`npm install ${tarballPath}`, { - cwd: tempDir, - stdio: 'pipe', - env: { ...process.env, npm_config_loglevel: 'error' } - }); + installWithLocalBuild(tempDir, tarballPath); // Test that Client class works const testScript = ` From 887d9db9b1557712858b802bbab65e5a2f688ee2 Mon Sep 17 00:00:00 2001 From: Max Mansfield Date: Tue, 27 Jan 2026 01:06:05 -0700 Subject: [PATCH 3/3] use prebuilds as build --- tests/pack-install.test.js | 155 ++++++++++++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 20 deletions(-) diff --git a/tests/pack-install.test.js b/tests/pack-install.test.js index 08ec8e1..3981820 100644 --- a/tests/pack-install.test.js +++ b/tests/pack-install.test.js @@ -4,29 +4,131 @@ * This test validates the end-user installation flow by: * 1. Creating an npm tarball with `npm pack` * 2. Installing the tarball in a temporary directory - * 3. Copying the local build (simulating prebuild-install) + * 3. Copying the local build or extracting prebuilds * 4. Verifying the native module loads correctly * * This catches issues like missing files, broken package.json config, * or module resolution problems that would affect users. * - * Note: This test uses the local build directory instead of downloading - * prebuilds from GitHub, since we need to test BEFORE publishing. + * Supports two modes: + * - Local development: Uses build/Release/ from `task build:js` + * - CI: Uses prebuilds/*.tar.gz downloaded from build artifacts */ const { execSync } = require('child_process'); const { mkdtempSync, rmSync, existsSync, readdirSync, writeFileSync, cpSync } = require('fs'); const { join } = require('path'); const { tmpdir } = require('os'); +const tar = require('tar'); // Path to local build directory (created by `task build:js`) const LOCAL_BUILD_DIR = join(process.cwd(), 'build', 'Release'); +// Path to prebuilds directory (downloaded from CI artifacts) +const PREBUILDS_DIR = join(process.cwd(), 'prebuilds'); /** - * Install package from tarball and copy local build - * This simulates what prebuild-install does, but uses local build for testing + * Find the appropriate prebuild tarball for the current platform + * Searches in prebuilds/ and prebuilds/@zoom/ directories */ -function installWithLocalBuild(tempDir, tarballPath) { +function findPrebuildTarball() { + if (!existsSync(PREBUILDS_DIR)) { + return null; + } + + const platform = process.platform; + const arch = process.arch; + + // Search directories where prebuilds might be located + const searchDirs = [ + PREBUILDS_DIR, + join(PREBUILDS_DIR, '@zoom'), + join(PREBUILDS_DIR, '@zoom', 'rtms') + ]; + + for (const dir of searchDirs) { + if (!existsSync(dir)) continue; + + const files = readdirSync(dir); + // Format: rtms-v{version}-napi-v{napi}-{platform}-{arch}.tar.gz + const tarball = files.find(f => + f.endsWith('.tar.gz') && + f.includes(platform) && + f.includes(arch) + ); + + if (tarball) { + return join(dir, tarball); + } + } + + return null; +} + +/** + * Get the build directory - either local build or extract from prebuild + */ +function prepareBuildDir() { + // First, check for local build (from task build:js) + if (existsSync(LOCAL_BUILD_DIR) && existsSync(join(LOCAL_BUILD_DIR, 'rtms.node'))) { + return LOCAL_BUILD_DIR; + } + + // Second, check for prebuild tarball (from CI artifacts) + const tarball = findPrebuildTarball(); + if (tarball) { + // Extract prebuild to project root (tarball contains build/Release/ prefix) + // This extracts to ./build/Release/ + tar.extract({ + file: tarball, + cwd: process.cwd(), + sync: true + }); + + // The tarball extracts to build/Release/ at project root + const buildDir = join(process.cwd(), 'build', 'Release'); + return buildDir; + } + + return null; +} + +/** + * Extract macOS framework archives (.framework.tar.gz) in a directory + * Same logic as scripts/install.js extractFrameworks() + */ +function extractFrameworks(buildDir) { + if (process.platform !== 'darwin') { + return; + } + + if (!existsSync(buildDir)) { + return; + } + + const files = readdirSync(buildDir); + const frameworkArchives = files.filter(f => f.endsWith('.framework.tar.gz')); + + for (const archive of frameworkArchives) { + const archivePath = join(buildDir, archive); + try { + tar.extract({ + file: archivePath, + cwd: buildDir, + sync: true + }); + // Remove the archive after extraction + rmSync(archivePath); + } catch (err) { + console.warn(`Failed to extract ${archive}: ${err.message}`); + } + } +} + +/** + * Install package from tarball and copy build artifacts + * This simulates what prebuild-install does, using local build or extracted prebuilds + */ +function installWithLocalBuild(tempDir, tarballPath, buildDir) { // Create minimal package.json for ESM writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ type: 'module' })); @@ -37,24 +139,37 @@ function installWithLocalBuild(tempDir, tarballPath) { env: { ...process.env, npm_config_loglevel: 'error' } }); - // Copy local build to simulate prebuild-install + // Copy build artifacts to simulate prebuild-install const targetBuildDir = join(tempDir, 'node_modules/@zoom/rtms/build/Release'); - if (!existsSync(LOCAL_BUILD_DIR)) { - throw new Error(`Local build not found at ${LOCAL_BUILD_DIR}. Run 'task build:js' first.`); - } - cpSync(LOCAL_BUILD_DIR, targetBuildDir, { recursive: true }); + cpSync(buildDir, targetBuildDir, { recursive: true }); + + // Extract framework archives (same as scripts/install.js does) + extractFrameworks(targetBuildDir); } describe('npm pack → install → load integration', () => { let tempDir; let tarballPath; + let buildDir; beforeAll(() => { - // Verify local build exists before running tests - if (!existsSync(LOCAL_BUILD_DIR)) { - throw new Error(`Local build not found at ${LOCAL_BUILD_DIR}. Run 'task build:js' first.`); + // Prepare build directory (local build or extracted prebuild) + buildDir = prepareBuildDir(); + if (!buildDir) { + throw new Error( + 'No build artifacts found. Either:\n' + + ' - Run "task build:js" for local development, or\n' + + ' - Ensure prebuilds/*.tar.gz exists (downloaded from CI artifacts)' + ); } + // Verify the build directory has the native module + if (!existsSync(join(buildDir, 'rtms.node'))) { + throw new Error(`Native module not found at ${buildDir}/rtms.node`); + } + + console.log(`Using build artifacts from: ${buildDir}`); + // Create tarball from current package const output = execSync('npm pack --json', { encoding: 'utf8' }); const packages = JSON.parse(output); @@ -101,7 +216,7 @@ describe('npm pack → install → load integration', () => { }); test('native module loads after install from tarball', () => { - installWithLocalBuild(tempDir, tarballPath); + installWithLocalBuild(tempDir, tarballPath, buildDir); // Verify native module loads and has expected exports (matching test.js usage patterns) const testScript = ` @@ -129,10 +244,10 @@ describe('npm pack → install → load integration', () => { return; } - installWithLocalBuild(tempDir, tarballPath); + installWithLocalBuild(tempDir, tarballPath, buildDir); - const buildDir = join(tempDir, 'node_modules/@zoom/rtms/build/Release'); - const files = readdirSync(buildDir); + const installedBuildDir = join(tempDir, 'node_modules/@zoom/rtms/build/Release'); + const files = readdirSync(installedBuildDir); // Frameworks should be present as directories (copied from local build) expect(files).toContain('tp.framework'); @@ -140,13 +255,13 @@ describe('npm pack → install → load integration', () => { expect(files).toContain('curl64.framework'); // Verify frameworks are actually directories with content - const tpFramework = join(buildDir, 'tp.framework'); + const tpFramework = join(installedBuildDir, 'tp.framework'); expect(existsSync(tpFramework)).toBe(true); expect(readdirSync(tpFramework).length).toBeGreaterThan(0); }); test('Client can be instantiated', () => { - installWithLocalBuild(tempDir, tarballPath); + installWithLocalBuild(tempDir, tarballPath, buildDir); // Test that Client class works const testScript = `