diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index bc9a8dbc..9bd897d3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -41,6 +41,60 @@ jobs: - run: npm run build - run: npm test --workspace gyp-to-cmake --workspace react-native-node-api-cmake --workspace react-native-node-api-modules + test-linux-addon-examples: + name: Run addon examples on Linux + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/jod + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: "17" + distribution: "temurin" + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + # Version here should match the one in React Native template and packages/react-native-node-api-cmake/src/cli.ts + - run: sdkmanager --install "ndk;27.1.12297006" + # Add Android NDK's clang to PATH + - run: echo "$ANDROID_HOME/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH + - run: npm ci + - run: npm run build + - run: npm run copy-node-api-headers --workspace react-native-node-api-modules + - run: npm run build-weak-node-api --workspace react-native-node-api-modules + - run: npm run generate-weak-node-api-injector --workspace react-native-node-api-modules + - run: npm test --workspace react-native-node-addon-examples + + test-windows-addon-examples: + name: Run addon examples on Windows + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/jod + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: "17" + distribution: "temurin" + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + # Version here should match the one in React Native template and packages/react-native-node-api-cmake/src/cli.ts + - run: sdkmanager --install "ndk;27.1.12297006" + - name: Install LLVM and Clang + uses: KyleMayes/install-llvm-action@v2 + with: + version: "18.1" + - run: npm ci + - run: npm run build + - run: npm run copy-node-api-headers --workspace react-native-node-api-modules + - run: npm run build-weak-node-api --workspace react-native-node-api-modules + - run: npm run generate-weak-node-api-injector --workspace react-native-node-api-modules + - run: npm test --workspace react-native-node-addon-examples + test-macos: name: Run tests which requires MacOS runs-on: macos-latest diff --git a/package.json b/package.json index 13093305..8317fadf 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "clean": "tsc --build --clean", "dev": "tsc --build --watch", "lint": "eslint .", - "test": "npm run test --workspace react-native-node-api-modules --workspace react-native-node-api-cmake --workspace gyp-to-cmake --workspace node-addon-examples" + "test": "npm run test --workspace react-native-node-api-modules --workspace react-native-node-api-cmake --workspace gyp-to-cmake --workspace react-native-node-addon-examples" }, "author": { "name": "Callstack", diff --git a/packages/node-addon-examples/package.json b/packages/node-addon-examples/package.json index e39654eb..a87dac9b 100644 --- a/packages/node-addon-examples/package.json +++ b/packages/node-addon-examples/package.json @@ -13,7 +13,8 @@ "build": "tsx scripts/build-examples.mts", "copy-and-build": "npm run copy-examples && npm run gyp-to-cmake && npm run build", "verify": "tsx scripts/verify-prebuilds.mts", - "test": "npm run copy-and-build && npm run verify" + "test": "tsx --test scripts/*.test.mts scripts/**/*.test.mts", + "test:integration": "npm run copy-and-build && npm run verify" }, "devDependencies": { "node-addon-examples": "github:nodejs/node-addon-examples#4213d4c9d07996ae68629c67926251e117f8e52a", diff --git a/packages/node-addon-examples/scripts/build-examples.mts b/packages/node-addon-examples/scripts/build-examples.mts index 1a5ed30b..eeb71c83 100644 --- a/packages/node-addon-examples/scripts/build-examples.mts +++ b/packages/node-addon-examples/scripts/build-examples.mts @@ -1,13 +1,31 @@ import { execSync } from "node:child_process"; +import { platform } from "node:os"; import { findCMakeProjects } from "./cmake-projects.mjs"; const projectDirectories = findCMakeProjects(); +// Platform-specific build command +let buildCommand: string; +switch (platform()) { + case "darwin": + // macOS: build for both Android and Apple + buildCommand = "react-native-node-api-cmake --android --apple"; + break; + case "win32": + case "linux": + // Windows and Linux: only Android + buildCommand = "react-native-node-api-cmake --android"; + break; + default: + console.error(`Unsupported platform: ${platform()}`); + process.exit(1); +} + for (const projectDirectory of projectDirectories) { - console.log(`Running "react-native-node-api-cmake" in ${projectDirectory}`); + console.log(`Running "${buildCommand}" in ${projectDirectory}`); execSync( - "react-native-node-api-cmake --android --apple", + buildCommand, // "react-native-node-api-cmake --triplet aarch64-linux-android --triplet arm64-apple-ios-sim", { cwd: projectDirectory, diff --git a/packages/node-addon-examples/scripts/cmake-projects.mts b/packages/node-addon-examples/scripts/cmake-projects.mts index a37a84d4..af4d81ae 100644 --- a/packages/node-addon-examples/scripts/cmake-projects.mts +++ b/packages/node-addon-examples/scripts/cmake-projects.mts @@ -1,7 +1,8 @@ import { readdirSync, statSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; -export const EXAMPLES_DIR = new URL("../examples", import.meta.url).pathname; +export const EXAMPLES_DIR = fileURLToPath(new URL("../examples", import.meta.url)); export function findCMakeProjects(dir = EXAMPLES_DIR): string[] { let results: string[] = []; diff --git a/packages/node-addon-examples/scripts/cmake-projects.test.mts b/packages/node-addon-examples/scripts/cmake-projects.test.mts new file mode 100644 index 00000000..c4f8ab38 --- /dev/null +++ b/packages/node-addon-examples/scripts/cmake-projects.test.mts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { describe, it, TestContext } from "node:test"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; + +import { EXAMPLES_DIR, findCMakeProjects } from "./cmake-projects.mjs"; + +function setupTempDirectory(context: TestContext, files: Record) { + const tempDirectoryPath = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "cmake-projects-test-")) + ); + + context.after(() => { + fs.rmSync(tempDirectoryPath, { recursive: true, force: true }); + }); + + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(tempDirectoryPath, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, "utf8"); + } + + return tempDirectoryPath; +} + +describe("EXAMPLES_DIR", () => { + it("should resolve to a valid platform-specific path", () => { + // Check that EXAMPLES_DIR is an absolute path + assert(path.isAbsolute(EXAMPLES_DIR), "EXAMPLES_DIR should be absolute"); + + // On Windows, should not start with / + if (process.platform === "win32") { + assert( + !EXAMPLES_DIR.startsWith("/"), + "Windows path should not start with /" + ); + // Should match Windows path pattern (e.g., C:\... or D:\...) + assert( + /^[A-Za-z]:[\\/]/.test(EXAMPLES_DIR), + "Windows path should start with drive letter" + ); + } else { + // On Unix-like systems, should start with / + assert( + EXAMPLES_DIR.startsWith("/"), + "Unix path should start with /" + ); + } + }); + + it("should work correctly with path.join operations", (context) => { + const tempDir = setupTempDirectory(context, { + "test/subdir/file.txt": "test content", + }); + + // Simulate what happens in copy-examples.mts + const relativePath = "test/subdir"; + const joinedPath = path.join(tempDir, relativePath); + + // The joined path should exist and be accessible + assert(fs.existsSync(joinedPath), "Joined path should exist"); + assert( + fs.statSync(joinedPath).isDirectory(), + "Joined path should be a directory" + ); + }); + + it("should handle URL to path conversion correctly on all platforms", () => { + // Create a test URL similar to how EXAMPLES_DIR is created + const testUrl = new URL("../examples", import.meta.url); + const convertedPath = testUrl.pathname; + + // The converted path should work with fs operations + // We can't test the actual EXAMPLES_DIR since it might not exist, + // but we can verify the conversion produces valid paths + if (process.platform === "win32") { + // On Windows, URL.pathname returns /C:/... which is invalid + // Our fix uses fileURLToPath which returns C:\... + assert( + !path.isAbsolute(convertedPath) || convertedPath.startsWith("/"), + "Direct URL.pathname on Windows produces invalid absolute paths" + ); + } + }); +}); + +describe("findCMakeProjects", () => { + it("should find CMakeLists.txt files recursively", (context) => { + const tempDir = setupTempDirectory(context, { + "project1/CMakeLists.txt": "# CMake file 1", + "project2/subdir/CMakeLists.txt": "# CMake file 2", + "project3/CMakeLists.txt": "# CMake file 3", + "not-a-project/other.txt": "not cmake", + }); + + const projects = findCMakeProjects(tempDir); + + assert.equal(projects.length, 3, "Should find 3 CMake projects"); + + // Sort for consistent comparison + const sortedProjects = projects.sort(); + const expectedProjects = [ + path.join(tempDir, "project1"), + path.join(tempDir, "project2", "subdir"), + path.join(tempDir, "project3"), + ].sort(); + + assert.deepEqual(sortedProjects, expectedProjects); + }); + + it("should handle empty directories", (context) => { + const tempDir = setupTempDirectory(context, {}); + const projects = findCMakeProjects(tempDir); + assert.equal(projects.length, 0, "Should find no projects in empty dir"); + }); + + it("should handle nested CMake projects", (context) => { + const tempDir = setupTempDirectory(context, { + "parent/CMakeLists.txt": "# Parent CMake", + "parent/child/CMakeLists.txt": "# Child CMake", + "parent/child/grandchild/CMakeLists.txt": "# Grandchild CMake", + }); + + const projects = findCMakeProjects(tempDir); + + assert.equal(projects.length, 3, "Should find all nested projects"); + assert( + projects.includes(path.join(tempDir, "parent")), + "Should include parent project" + ); + assert( + projects.includes(path.join(tempDir, "parent", "child")), + "Should include child project" + ); + assert( + projects.includes(path.join(tempDir, "parent", "child", "grandchild")), + "Should include grandchild project" + ); + }); + + it("should work with Windows-style paths", { skip: process.platform !== "win32" }, (context) => { + const tempDir = setupTempDirectory(context, { + "windows\\style\\path\\CMakeLists.txt": "# CMake file", + }); + + const projects = findCMakeProjects(tempDir); + assert.equal(projects.length, 1, "Should find project with Windows path"); + }); +}); \ No newline at end of file diff --git a/packages/react-native-node-api-modules/package.json b/packages/react-native-node-api-modules/package.json index 836f960a..c64941e0 100644 --- a/packages/react-native-node-api-modules/package.json +++ b/packages/react-native-node-api-modules/package.json @@ -43,7 +43,7 @@ "copy-node-api-headers": "tsx scripts/copy-node-api-headers.ts", "generate-weak-node-api": "tsx scripts/generate-weak-node-api.ts", "generate-weak-node-api-injector": "tsx scripts/generate-weak-node-api-injector.ts", - "build-weak-node-api": "npm run generate-weak-node-api && react-native-node-api-cmake --android --apple --no-auto-link --no-weak-node-api-linkage --xcframework-extension --source ./weak-node-api", + "build-weak-node-api": "tsx scripts/build-weak-node-api.ts", "test": "tsx --test src/node/**/*.test.ts src/node/*.test.ts" }, "keywords": [ diff --git a/packages/react-native-node-api-modules/scripts/build-weak-node-api.ts b/packages/react-native-node-api-modules/scripts/build-weak-node-api.ts new file mode 100644 index 00000000..48081f03 --- /dev/null +++ b/packages/react-native-node-api-modules/scripts/build-weak-node-api.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env tsx + +import { execSync } from "node:child_process"; +import { platform } from "node:os"; + +// First generate the weak node API +execSync("npm run generate-weak-node-api", { stdio: "inherit" }); + +// Build command with common flags +const baseCommand = "react-native-node-api-cmake --no-auto-link --no-weak-node-api-linkage --source ./weak-node-api"; + +// Platform-specific flags +let platformFlags = ""; +switch (platform()) { + case "darwin": + // macOS: build for both Android and Apple + platformFlags = "--android --apple --xcframework-extension"; + break; + case "win32": + // Windows: only Android (no Apple/Xcode support) + platformFlags = "--android"; + break; + case "linux": + // Linux: only Android + platformFlags = "--android"; + break; + default: + console.error(`Unsupported platform: ${platform()}`); + process.exit(1); +} + +const fullCommand = `${baseCommand} ${platformFlags}`; +console.log(`Running: ${fullCommand}`); +execSync(fullCommand, { stdio: "inherit" }); \ No newline at end of file diff --git a/packages/react-native-node-api-modules/scripts/generate-weak-node-api-injector.ts b/packages/react-native-node-api-modules/scripts/generate-weak-node-api-injector.ts index 0d3a61a8..f7b00511 100644 --- a/packages/react-native-node-api-modules/scripts/generate-weak-node-api-injector.ts +++ b/packages/react-native-node-api-modules/scripts/generate-weak-node-api-injector.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import cp from "node:child_process"; +import { spawnSyncCrossPlatform } from "./platform-utils.js"; import { FunctionDecl, getNodeApiFunctions } from "./node-api-functions"; @@ -20,6 +20,8 @@ export function generateSource(functions: FunctionDecl[]) { #define WEAK_NODE_API_LIBRARY_NAME "@rpath/weak-node-api.framework/weak-node-api" #elif defined(__ANDROID__) #define WEAK_NODE_API_LIBRARY_NAME "libweak-node-api.so" + #elif defined(_WIN32) + #define WEAK_NODE_API_LIBRARY_NAME "weak-node-api.dll" #else #error "WEAK_NODE_API_LIBRARY_NAME cannot be defined for this platform" #endif @@ -58,7 +60,7 @@ async function run() { const source = generateSource(nodeApiFunctions); const sourcePath = path.join(CPP_SOURCE_PATH, "WeakNodeApiInjector.cpp"); await fs.promises.writeFile(sourcePath, source, "utf-8"); - cp.spawnSync("clang-format", ["-i", sourcePath], { stdio: "inherit" }); + spawnSyncCrossPlatform("clang-format", ["-i", sourcePath], { stdio: "inherit" }); } run().catch((err) => { diff --git a/packages/react-native-node-api-modules/scripts/generate-weak-node-api.ts b/packages/react-native-node-api-modules/scripts/generate-weak-node-api.ts index 27be13db..5db22cda 100644 --- a/packages/react-native-node-api-modules/scripts/generate-weak-node-api.ts +++ b/packages/react-native-node-api-modules/scripts/generate-weak-node-api.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import cp from "node:child_process"; +import { spawnSyncCrossPlatform } from "./platform-utils.js"; import { FunctionDecl, getNodeApiFunctions } from "./node-api-functions"; @@ -74,12 +74,12 @@ async function run() { const header = generateHeader(nodeApiFunctions); const headerPath = path.join(WEAK_NODE_API_PATH, "weak_node_api.hpp"); await fs.promises.writeFile(headerPath, header, "utf-8"); - cp.spawnSync("clang-format", ["-i", headerPath], { stdio: "inherit" }); + spawnSyncCrossPlatform("clang-format", ["-i", headerPath], { stdio: "inherit" }); const source = generateSource(nodeApiFunctions); const sourcePath = path.join(WEAK_NODE_API_PATH, "weak_node_api.cpp"); await fs.promises.writeFile(sourcePath, source, "utf-8"); - cp.spawnSync("clang-format", ["-i", sourcePath], { stdio: "inherit" }); + spawnSyncCrossPlatform("clang-format", ["-i", sourcePath], { stdio: "inherit" }); } run().catch((err) => { diff --git a/packages/react-native-node-api-modules/scripts/node-api-functions.ts b/packages/react-native-node-api-modules/scripts/node-api-functions.ts index ad532ae1..bd5aef41 100644 --- a/packages/react-native-node-api-modules/scripts/node-api-functions.ts +++ b/packages/react-native-node-api-modules/scripts/node-api-functions.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import path from "node:path"; -import cp from "node:child_process"; +import { execFileSyncCrossPlatform } from "./platform-utils.js"; import { type NodeApiVersion, @@ -29,7 +29,7 @@ const clangAstDump = z.object({ * @param version */ export function getNodeApiHeaderAST(version: NodeApiVersion) { - const output = cp.execFileSync( + const output = execFileSyncCrossPlatform( "clang", [ // Declare the Node API version @@ -50,7 +50,7 @@ export function getNodeApiHeaderAST(version: NodeApiVersion) { // Emitting the AST can produce a lot of output maxBuffer: 1024 * 1024 * 10, } - ); + ) as string; const parsed = JSON.parse(output); return clangAstDump.parse(parsed); } diff --git a/packages/react-native-node-api-modules/scripts/platform-utils.ts b/packages/react-native-node-api-modules/scripts/platform-utils.ts new file mode 100644 index 00000000..7c454b71 --- /dev/null +++ b/packages/react-native-node-api-modules/scripts/platform-utils.ts @@ -0,0 +1,65 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { platform } from "node:os"; + +/** + * Get the platform-specific executable name + */ +export function getExecutableName(command: string): string { + if (platform() === "win32" && !command.endsWith(".exe")) { + return `${command}.exe`; + } + return command; +} + +/** + * Find executable in PATH + */ +export function findExecutable(command: string): string | null { + const execName = getExecutableName(command); + + if (platform() === "win32") { + try { + const result = execFileSync("where", [execName], { encoding: "utf-8" }); + return result.trim().split("\n")[0]; + } catch { + return null; + } + } else { + try { + const result = execFileSync("which", [execName], { encoding: "utf-8" }); + return result.trim(); + } catch { + return null; + } + } +} + +/** + * Execute command with cross-platform support + */ +export function execFileSyncCrossPlatform( + command: string, + args: string[], + options?: Parameters[2] +): ReturnType { + const executable = findExecutable(command); + if (!executable) { + throw new Error(`Command '${command}' not found in PATH`); + } + return execFileSync(executable, args, options); +} + +/** + * Spawn command with cross-platform support + */ +export function spawnSyncCrossPlatform( + command: string, + args: string[], + options?: Parameters[2] +) { + const executable = findExecutable(command); + if (!executable) { + throw new Error(`Command '${command}' not found in PATH`); + } + return spawnSync(executable, args, options); +} \ No newline at end of file