diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml
new file mode 100644
index 00000000..55015f57
--- /dev/null
+++ b/.github/workflows/build-macos.yml
@@ -0,0 +1,114 @@
+name: Build macOS
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - ".github/workflows/build-macos.yml"
+ - "example/macos/**"
+ - "example/src/**"
+ - "example/app.json"
+ - "example/index.js"
+ - "example/babel.config*.js"
+ - "packages/react-native-nitro-sqlite/**"
+ - "packages/react-native-nitro-sqlite-vec/**"
+ - "patches/**"
+ - "**/Podfile.lock"
+ - "**/Gemfile.lock"
+ - "**/bun.lock"
+ - "**/package.json"
+ - "**/react-native.config.js"
+ - "**/nitro.json"
+ pull_request:
+ paths:
+ - ".github/workflows/build-macos.yml"
+ - "example/macos/**"
+ - "example/src/**"
+ - "example/app.json"
+ - "example/index.js"
+ - "example/babel.config*.js"
+ - "packages/react-native-nitro-sqlite/**"
+ - "packages/react-native-nitro-sqlite-vec/**"
+ - "patches/**"
+ - "**/Podfile.lock"
+ - "**/Gemfile.lock"
+ - "**/bun.lock"
+ - "**/package.json"
+ - "**/react-native.config.js"
+ - "**/nitro.json"
+
+env:
+ USE_CCACHE: 1
+
+jobs:
+ build:
+ name: Build macOS Example App
+ runs-on: macos-26
+ steps:
+ - uses: actions/checkout@v7
+ - uses: oven-sh/setup-bun@v2
+
+ - name: Install npm dependencies (bun)
+ run: bun install
+
+ - name: Install Ccache
+ uses: hendrikmuhs/ccache-action@v1.2
+ with:
+ max-size: 1.5G
+ key: ${{ runner.os }}-ccache-example-macos
+ create-symlink: true
+ - name: Setup ccache behavior
+ run: |
+ {
+ echo "CCACHE_SLOPPINESS=clang_index_store,file_stat_matches,include_file_ctime,include_file_mtime,ivfsoverlay,pch_defines,modules,system_headers,time_macros"
+ echo "CCACHE_FILECLONE=true"
+ echo "CCACHE_DEPEND=true"
+ echo "CCACHE_INODECACHE=true"
+ } >> "$GITHUB_ENV"
+
+ - name: Setup Ruby (bundle)
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: 3.3.0
+ bundler-cache: true
+ working-directory: example
+
+ - name: Select Xcode 26.5
+ run: sudo xcode-select -s "/Applications/Xcode_26.5.app/Contents/Developer"
+
+ - name: Restore Pods cache
+ uses: actions/cache@v6
+ with:
+ path: example/macos/Pods
+ key: ${{ runner.os }}-pods-macos-${{ hashFiles('example/macos/Podfile.lock', 'example/Gemfile.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-pods-macos-
+ - name: Clean generated macOS codegen
+ run: rm -rf example/macos/build/generated
+ - name: Install Pods
+ run: bun --cwd example pods:macos
+
+ - name: Restore DerivedData cache
+ uses: actions/cache@v6
+ with:
+ path: example/macos/build/DerivedData
+ key: ${{ runner.os }}-dd-macos-${{ hashFiles('example/macos/Podfile.lock', 'example/Gemfile.lock', '**/package.json', '**/bun.lock') }}-xcode26.5
+ restore-keys: |
+ ${{ runner.os }}-dd-macos-${{ hashFiles('example/macos/Podfile.lock', 'example/Gemfile.lock', '**/package.json', '**/bun.lock') }}-xcode26.5
+
+ - name: Build App
+ working-directory: example/macos
+ run: |
+ set -o pipefail
+ xcodebuild \
+ CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \
+ -derivedDataPath build/DerivedData -UseModernBuildSystem=YES \
+ -workspace NitroSQLiteExample.xcworkspace \
+ -scheme NitroSQLiteExample-macOS \
+ -configuration Debug \
+ -destination 'platform=macOS,arch=arm64' \
+ -showBuildTimingSummary \
+ ONLY_ACTIVE_ARCH=YES \
+ build \
+ CODE_SIGNING_ALLOWED=NO | xcbeautify --renderer github-actions
diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml
new file mode 100644
index 00000000..f9e08b0d
--- /dev/null
+++ b/.github/workflows/test-macos.yml
@@ -0,0 +1,68 @@
+name: Test macOS
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - ".github/workflows/test-macos.yml"
+ - "example/macos/**"
+ - "example/src/**"
+ - "example/tests/**"
+ - "example/app.json"
+ - "example/index.js"
+ - "example/babel.config*.js"
+ - "packages/react-native-nitro-sqlite/**"
+ - "packages/react-native-nitro-sqlite-vec/**"
+ - "patches/**"
+ - "**/Podfile.lock"
+ - "**/Gemfile.lock"
+ - "**/bun.lock"
+ - "**/package.json"
+ - "**/react-native.config.js"
+ - "**/nitro.json"
+
+jobs:
+ test:
+ name: macOS Integration Tests
+ runs-on: macos-26
+
+ steps:
+ - uses: actions/checkout@v7
+ - uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies (bun)
+ run: bun install
+
+ - name: Test desktop runner failure handling
+ run: node --test example/macos/scripts/test-macos.test.mjs
+
+ - name: Setup Ruby (bundle)
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: 3.3.0
+ bundler-cache: true
+ working-directory: example
+
+ - name: Select Xcode 26.5
+ run: sudo xcode-select -s "/Applications/Xcode_26.5.app/Contents/Developer"
+
+ - name: Install Pods
+ run: bun --cwd example pods:macos
+
+ - name: Build debug app
+ working-directory: example/macos
+ run: |
+ set -euo pipefail
+ xcodebuild \
+ CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \
+ -derivedDataPath build -UseModernBuildSystem=YES \
+ -workspace NitroSQLiteExample.xcworkspace \
+ -scheme NitroSQLiteExample-macOS \
+ -configuration Debug \
+ -destination 'platform=macOS,arch=arm64' \
+ build \
+ CODE_SIGNING_ALLOWED=NO
+
+ - name: Run macOS integration tests
+ working-directory: example/macos
+ run: bun run test
diff --git a/example/macos/NitroSQLiteExample-macOS/AppDelegate.mm b/example/macos/NitroSQLiteExample-macOS/AppDelegate.mm
index 0068211e..5fdbad6f 100644
--- a/example/macos/NitroSQLiteExample-macOS/AppDelegate.mm
+++ b/example/macos/NitroSQLiteExample-macOS/AppDelegate.mm
@@ -8,7 +8,8 @@ @implementation AppDelegate
- (void)applicationDidFinishLaunching:(NSNotification *)notification
{
self.moduleName = @"NitroSQLiteExample";
- self.initialProps = @{};
+ NSString *testReportURL = NSProcessInfo.processInfo.environment[@"NITRO_SQLITE_TEST_REPORT_URL"];
+ self.initialProps = testReportURL.length > 0 ? @{ @"macosTestReportUrl" : testReportURL } : @{};
self.dependencyProvider = [RCTAppDependencyProvider new];
return [super applicationDidFinishLaunching:notification];
@@ -22,6 +23,15 @@ - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
- (NSURL *)bundleURL
{
#if DEBUG
+ NSString *testMetroPort = NSProcessInfo.processInfo.environment[@"NITRO_SQLITE_TEST_METRO_PORT"];
+ if (testMetroPort.length > 0) {
+ return [RCTBundleURLProvider jsBundleURLForBundleRoot:@"index"
+ packagerHost:[NSString stringWithFormat:@"127.0.0.1:%@", testMetroPort]
+ enableDev:YES
+ enableMinification:NO
+ inlineSourceMap:NO];
+ }
+
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
diff --git a/example/macos/index.js b/example/macos/index.js
index f075fadb..49358824 100644
--- a/example/macos/index.js
+++ b/example/macos/index.js
@@ -1,6 +1,6 @@
import '../src/initGlobals.macos'
import { AppRegistry } from 'react-native'
-import App from '../src/App'
+import MacOSApp from '../src/MacOSApp'
import { name as appName } from '../app.json'
-AppRegistry.registerComponent(appName, () => App)
+AppRegistry.registerComponent(appName, () => MacOSApp)
diff --git a/example/macos/package.json b/example/macos/package.json
index bfb0ddb3..5db9bec2 100644
--- a/example/macos/package.json
+++ b/example/macos/package.json
@@ -3,7 +3,8 @@
"private": true,
"scripts": {
"macos": "node scripts/react-native-macos.js run-macos --project-path .",
- "start": "node scripts/react-native-macos.js start"
+ "start": "node scripts/react-native-macos.js start",
+ "test": "node scripts/test-macos.mjs"
},
"dependencies": {
"@react-native-clipboard/clipboard": "^1.16.3",
diff --git a/example/macos/scripts/test-macos.mjs b/example/macos/scripts/test-macos.mjs
new file mode 100644
index 00000000..f6c9c4cc
--- /dev/null
+++ b/example/macos/scripts/test-macos.mjs
@@ -0,0 +1,347 @@
+import { spawn } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
+import { access } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import path from 'node:path'
+import process from 'node:process'
+import { setTimeout as delay } from 'node:timers/promises'
+
+const testTimeoutMs = Number(process.env.MACOS_TEST_TIMEOUT_MS ?? 300_000)
+const appPath = path.resolve(
+ process.env.MACOS_APP_PATH ??
+ 'build/Build/Products/Debug/NitroSQLiteExample.app',
+)
+const appExecutable = path.join(
+ appPath,
+ 'Contents',
+ 'MacOS',
+ 'NitroSQLiteExample',
+)
+
+function formatError(error) {
+ return error instanceof Error ? error.message : String(error)
+}
+
+function captureOutput(child) {
+ let output = ''
+ const append = (chunk) => {
+ output = `${output}${chunk}`.slice(-16_000)
+ }
+
+ child.stdout?.on('data', append)
+ child.stderr?.on('data', append)
+ return () => output
+}
+
+async function getAvailablePort() {
+ return new Promise((resolve, reject) => {
+ const server = createServer()
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', () => {
+ const address = server.address()
+ if (address == null || typeof address === 'string') {
+ server.close(() =>
+ reject(new Error('Could not determine an available Metro port')),
+ )
+ return
+ }
+
+ server.close(() => resolve(address.port))
+ })
+ })
+}
+
+async function waitForMetro(metro, metroPort, getMetroOutput, signal) {
+ const deadline = Date.now() + 60_000
+ const metroStatusUrl = `http://127.0.0.1:${metroPort}/status`
+
+ while (Date.now() < deadline) {
+ if (metro.exitCode != null) {
+ throw new Error(
+ `Metro exited before becoming ready.\n${getMetroOutput()}`,
+ )
+ }
+
+ try {
+ const response = await fetchMetroStatus(metroStatusUrl, signal)
+ if (
+ response.ok &&
+ (await response.text()).includes('packager-status:running')
+ ) {
+ return
+ }
+ } catch {
+ signal.throwIfAborted()
+ // Metro has not started listening yet.
+ }
+
+ await delay(250, undefined, { signal })
+ }
+
+ throw new Error(`Timed out waiting for Metro.\n${getMetroOutput()}`)
+}
+
+async function fetchMetroStatus(url, signal) {
+ signal.throwIfAborted()
+
+ const request = new AbortController()
+ const abortRequest = () => request.abort(signal.reason)
+ const timeout = setTimeout(() => request.abort(), 1_000)
+ signal.addEventListener('abort', abortRequest, { once: true })
+
+ try {
+ return await fetch(url, { signal: request.signal })
+ } finally {
+ clearTimeout(timeout)
+ signal.removeEventListener('abort', abortRequest)
+ }
+}
+
+async function createResultServer() {
+ let resolveReport
+ const report = new Promise((resolve) => {
+ resolveReport = resolve
+ })
+ const reportPath = `/report/${randomUUID()}`
+ const server = createServer((request, response) => {
+ if (request.method !== 'POST' || request.url !== reportPath) {
+ response.writeHead(404).end()
+ return
+ }
+
+ let body = ''
+ request.setEncoding('utf8')
+ request.on('data', (chunk) => {
+ body += chunk
+ if (body.length > 1_000_000) {
+ response.writeHead(413).end()
+ request.destroy()
+ }
+ })
+ request.on('end', () => {
+ try {
+ const parsed = JSON.parse(body)
+ response.writeHead(204, { Connection: 'close' }).end()
+ resolveReport(parsed)
+ } catch {
+ response.writeHead(400).end()
+ }
+ })
+ })
+
+ await new Promise((resolve, reject) => {
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', resolve)
+ })
+
+ const address = server.address()
+ if (address == null || typeof address === 'string') {
+ throw new Error('Could not determine the macOS test result server address')
+ }
+
+ return {
+ report,
+ reportUrl: `http://127.0.0.1:${address.port}${reportPath}`,
+ server,
+ }
+}
+
+function watchProcessFailure(child, name, getOutput) {
+ let rejectFailure
+ const onError = (error) => {
+ rejectFailure(new Error(`${name} could not start: ${formatError(error)}`))
+ }
+ const onExit = (code, signal) => {
+ const reason = signal == null ? `exit code ${code}` : `signal ${signal}`
+ const output = getOutput()
+ rejectFailure(
+ new Error(
+ `${name} exited before the macOS test report was received (${reason}).${
+ output.length > 0 ? `\n${output}` : ''
+ }`,
+ ),
+ )
+ }
+
+ const failure = new Promise((_, reject) => {
+ rejectFailure = reject
+ })
+
+ child.once('error', onError)
+ child.once('exit', onExit)
+
+ if (child.exitCode != null || child.signalCode != null) {
+ onExit(child.exitCode, child.signalCode)
+ }
+
+ return {
+ failure,
+ stopWatching() {
+ child.removeListener('error', onError)
+ child.removeListener('exit', onExit)
+ },
+ }
+}
+
+async function waitForReport(report, failures) {
+ let timeout
+ try {
+ return await Promise.race([
+ report,
+ ...failures,
+ new Promise((_, reject) => {
+ timeout = setTimeout(
+ () =>
+ reject(
+ new Error(
+ `Timed out waiting for macOS test results after ${testTimeoutMs}ms`,
+ ),
+ ),
+ testTimeoutMs,
+ )
+ }),
+ ])
+ } finally {
+ clearTimeout(timeout)
+ }
+}
+
+function validateReport(report) {
+ if (report == null || typeof report !== 'object') {
+ throw new Error('The macOS app returned an invalid test report')
+ }
+
+ if (typeof report.error === 'string' && report.error.length > 0) {
+ throw new Error(`The macOS test runner failed: ${report.error}`)
+ }
+
+ if (!Array.isArray(report.results) || report.results.length === 0) {
+ throw new Error('The macOS app returned no test results')
+ }
+
+ const passed = report.results.filter((result) => result.type === 'correct')
+ const failures = report.results.filter(
+ (result) => result.type === 'incorrect',
+ )
+ console.log(`macOS tests: ${passed.length} passed, ${failures.length} failed`)
+
+ if (passed.length === 0) {
+ throw new Error('The macOS app did not run any passing tests')
+ }
+
+ if (failures.length > 0) {
+ const details = failures
+ .map(
+ (failure) =>
+ `- ${failure.description}: ${failure.errorMsg ?? 'Unknown failure'}`,
+ )
+ .join('\n')
+ throw new Error(`macOS tests failed:\n${details}`)
+ }
+}
+
+async function stopProcess(child) {
+ if (child == null || child.pid == null) return
+
+ const signalProcessGroup = (signal) => {
+ try {
+ process.kill(-child.pid, signal)
+ } catch (error) {
+ if (error.code !== 'ESRCH') throw error
+ }
+ }
+
+ signalProcessGroup('SIGTERM')
+ await delay(1_000)
+ signalProcessGroup('SIGKILL')
+}
+
+async function closeResultServer(server) {
+ server.closeAllConnections?.()
+
+ await new Promise((resolve, reject) => {
+ server.close((error) => {
+ if (error != null) reject(error)
+ else resolve()
+ })
+ })
+}
+
+async function main() {
+ await access(appExecutable)
+
+ const resultServer = await createResultServer()
+ const metroPort = await getAvailablePort()
+ let metro
+ let app
+ let metroFailure
+ let appFailure
+
+ try {
+ metro = spawn(
+ process.execPath,
+ ['scripts/react-native-macos.js', 'start', '--port', String(metroPort)],
+ {
+ cwd: process.cwd(),
+ detached: true,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ )
+ const getMetroOutput = captureOutput(metro)
+ metroFailure = watchProcessFailure(metro, 'Metro', getMetroOutput)
+ const startup = new AbortController()
+ try {
+ await Promise.race([
+ waitForMetro(metro, metroPort, getMetroOutput, startup.signal),
+ metroFailure.failure,
+ ])
+ } finally {
+ startup.abort()
+ }
+
+ app = spawn(appExecutable, [], {
+ detached: true,
+ env: {
+ ...process.env,
+ NITRO_SQLITE_TEST_METRO_PORT: String(metroPort),
+ NITRO_SQLITE_TEST_REPORT_URL: resultServer.reportUrl,
+ NO_PROXY: '127.0.0.1,localhost',
+ no_proxy: '127.0.0.1,localhost',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ })
+ const getAppOutput = captureOutput(app)
+ const getAppAndMetroOutput = () => {
+ const appOutput = getAppOutput()
+ const metroOutput = getMetroOutput()
+
+ return [
+ appOutput.length > 0 && `App output:\n${appOutput}`,
+ metroOutput.length > 0 && `Metro output:\n${metroOutput}`,
+ ]
+ .filter(Boolean)
+ .join('\n\n')
+ }
+ appFailure = watchProcessFailure(app, 'The macOS app', getAppAndMetroOutput)
+ const result = await waitForReport(resultServer.report, [
+ appFailure.failure,
+ metroFailure.failure,
+ ])
+ appFailure.stopWatching()
+ appFailure = undefined
+ metroFailure.stopWatching()
+ metroFailure = undefined
+ validateReport(result)
+ } finally {
+ appFailure?.stopWatching()
+ metroFailure?.stopWatching()
+ await stopProcess(app)
+ await stopProcess(metro)
+ await closeResultServer(resultServer.server)
+ }
+}
+
+main().catch((error) => {
+ console.error(formatError(error))
+ process.exitCode = 1
+})
diff --git a/example/macos/scripts/test-macos.test.mjs b/example/macos/scripts/test-macos.test.mjs
new file mode 100644
index 00000000..3bbeac98
--- /dev/null
+++ b/example/macos/scripts/test-macos.test.mjs
@@ -0,0 +1,176 @@
+import assert from 'node:assert/strict'
+import { spawn } from 'node:child_process'
+import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { after, before, test } from 'node:test'
+import { fileURLToPath } from 'node:url'
+
+const runnerPath = path.join(
+ path.dirname(fileURLToPath(import.meta.url)),
+ 'test-macos.mjs',
+)
+
+let fixtureRoot
+let appPath
+
+before(async () => {
+ fixtureRoot = await mkdtemp(path.join(os.tmpdir(), 'nitro-sqlite-macos-'))
+ appPath = path.join(fixtureRoot, 'Fake.app')
+
+ await mkdir(path.join(fixtureRoot, 'scripts'), { recursive: true })
+ await mkdir(path.join(appPath, 'Contents', 'MacOS'), { recursive: true })
+ await writeFile(
+ path.join(fixtureRoot, 'scripts', 'react-native-macos.js'),
+ fakeMetroSource,
+ )
+
+ const appExecutable = path.join(
+ appPath,
+ 'Contents',
+ 'MacOS',
+ 'NitroSQLiteExample',
+ )
+ await writeFile(appExecutable, fakeAppSource)
+ await chmod(appExecutable, 0o755)
+})
+
+after(async () => {
+ await rm(fixtureRoot, { recursive: true, force: true })
+})
+
+test('completes after receiving a passing report', async () => {
+ const result = await runRunner({ appScenario: 'success' })
+
+ assert.equal(result.code, 0, result.output)
+ assert.match(result.output, /macOS tests: 1 passed, 0 failed/)
+})
+
+test('fails when the app reports a failed test', async () => {
+ const result = await runRunner({ appScenario: 'test-failure' })
+
+ assert.equal(result.code, 1, result.output)
+ assert.match(result.output, /macOS tests: 1 passed, 1 failed/)
+ assert.match(result.output, /query fails: Expected one row/)
+})
+
+test('times out when the app does not report results', async () => {
+ const result = await runRunner({
+ appScenario: 'no-report',
+ testTimeoutMs: 100,
+ })
+
+ assert.equal(result.code, 1, result.output)
+ assert.match(
+ result.output,
+ /Timed out waiting for macOS test results after 100ms/,
+ )
+})
+
+test('fails promptly when Metro exits during startup', async () => {
+ const result = await runRunner({
+ appScenario: 'success',
+ metroScenario: 'early-exit',
+ })
+
+ assert.equal(result.code, 1, result.output)
+ assert.match(result.output, /Metro exited/)
+ assert.match(result.output, /fake Metro failed/)
+})
+
+test('fails when the app exits before reporting results', async () => {
+ const result = await runRunner({ appScenario: 'early-exit' })
+
+ assert.equal(result.code, 1, result.output)
+ assert.match(
+ result.output,
+ /The macOS app exited before the macOS test report was received \(exit code 23\)/,
+ )
+ assert.match(result.output, /fake app failed/)
+})
+
+async function runRunner({
+ appScenario,
+ metroScenario = 'ready',
+ testTimeoutMs = 1_000,
+}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [runnerPath], {
+ cwd: fixtureRoot,
+ env: {
+ ...process.env,
+ FAKE_APP_SCENARIO: appScenario,
+ FAKE_METRO_SCENARIO: metroScenario,
+ MACOS_APP_PATH: appPath,
+ MACOS_TEST_TIMEOUT_MS: String(testTimeoutMs),
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ })
+ let output = ''
+
+ child.stdout.on('data', (chunk) => {
+ output += chunk
+ })
+ child.stderr.on('data', (chunk) => {
+ output += chunk
+ })
+ child.once('error', reject)
+ child.once('close', (code, signal) => {
+ resolve({ code, output, signal })
+ })
+ })
+}
+
+const fakeMetroSource = `
+import { createServer } from 'node:http'
+
+if (process.env.FAKE_METRO_SCENARIO === 'early-exit') {
+ console.error('fake Metro failed')
+ process.exit(17)
+}
+
+const portFlag = process.argv.indexOf('--port')
+const port = Number(process.argv[portFlag + 1])
+const server = createServer((_request, response) => {
+ response.writeHead(200, { 'Content-Type': 'text/plain' })
+ response.end('packager-status:running')
+})
+
+server.listen(port, '127.0.0.1')
+process.on('SIGTERM', () => server.close(() => process.exit(0)))
+`
+
+const fakeAppSource = `#!/usr/bin/env node
+if (process.env.FAKE_APP_SCENARIO === 'early-exit') {
+ console.error('fake app failed')
+ process.exit(23)
+}
+
+if (process.env.FAKE_APP_SCENARIO !== 'no-report') {
+ const results = process.env.FAKE_APP_SCENARIO === 'test-failure'
+ ? [
+ { type: 'correct', description: 'query succeeds' },
+ {
+ type: 'incorrect',
+ description: 'query fails',
+ errorMsg: 'Expected one row',
+ },
+ ]
+ : [
+ { type: 'correct', description: 'query succeeds' },
+ { type: 'pending', description: 'query is skipped' },
+ ]
+
+ const response = await fetch(process.env.NITRO_SQLITE_TEST_REPORT_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ results }),
+ })
+
+ if (!response.ok) {
+ throw new Error(\`Could not submit fake report: \${response.status}\`)
+ }
+}
+
+setInterval(() => {}, 1_000)
+`
diff --git a/example/src/MacOSApp.macos.tsx b/example/src/MacOSApp.macos.tsx
new file mode 100644
index 00000000..2a02d459
--- /dev/null
+++ b/example/src/MacOSApp.macos.tsx
@@ -0,0 +1,111 @@
+import React, { useEffect, useState } from 'react'
+import { StyleSheet, Text, View } from 'react-native'
+import App from './App'
+import type { MochaTestResult } from '@tests/MochaSetup'
+import { runAllTests } from '@tests/runAll'
+
+type MacOSTestReport = {
+ error?: string
+ results: MochaTestResult[]
+}
+
+type MacOSAppProps = {
+ macosTestReportUrl?: string
+}
+
+export default function MacOSApp({ macosTestReportUrl }: MacOSAppProps) {
+ if (macosTestReportUrl != null) {
+ return
+ }
+
+ return
+}
+
+function MacOSTestApp({ reportUrl }: { reportUrl: string }) {
+ const [status, setStatus] = useState('Starting tests…')
+
+ useEffect(() => {
+ let cancelled = false
+
+ async function run() {
+ let report: MacOSTestReport
+
+ try {
+ const results = await runAllTests()
+ const passed = results.filter((result) => result.type === 'correct')
+ const failures = results.filter((result) => result.type === 'incorrect')
+ report = { results }
+ setStatus(`${passed.length} passed, ${failures.length} failed`)
+ } catch (error) {
+ const errorMessage = toErrorMessage(error)
+ report = { error: errorMessage, results: [] }
+ setStatus(`Test runner failed: ${errorMessage}`)
+ }
+
+ if (cancelled) return
+
+ try {
+ await postReport(reportUrl, report)
+ } catch (error) {
+ console.error('Unable to report macOS test results', error)
+ if (!cancelled) {
+ setStatus(`Unable to report results: ${toErrorMessage(error)}`)
+ }
+ }
+ }
+
+ run().catch((error) => {
+ console.error('Unexpected macOS test runner error', error)
+ if (!cancelled) {
+ setStatus(`Test runner failed: ${toErrorMessage(error)}`)
+ }
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [reportUrl])
+
+ return (
+
+ Running macOS tests
+ {status}
+
+ )
+}
+
+async function postReport(reportUrl: string, report: MacOSTestReport) {
+ const response = await fetch(reportUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(report),
+ })
+
+ if (!response.ok) {
+ throw new Error(`Unable to report test results: HTTP ${response.status}`)
+ }
+}
+
+function toErrorMessage(error: unknown) {
+ return error instanceof Error ? error.message : String(error)
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ flex: 1,
+ justifyContent: 'center',
+ padding: 24,
+ },
+ title: {
+ color: '#111',
+ fontSize: 20,
+ fontWeight: '600',
+ marginBottom: 12,
+ },
+ status: {
+ color: '#333',
+ fontSize: 16,
+ textAlign: 'center',
+ },
+})
diff --git a/example/src/screens/UnitTestScreen.tsx b/example/src/screens/UnitTestScreen.tsx
index 0e770454..eeffc4cb 100644
--- a/example/src/screens/UnitTestScreen.tsx
+++ b/example/src/screens/UnitTestScreen.tsx
@@ -1,23 +1,14 @@
import React, { useEffect, useState } from 'react'
-import { FlatList, StyleSheet, Text } from 'react-native'
+import { FlatList, StyleSheet, Text, View } from 'react-native'
import type { MochaTestResult } from '@tests/MochaSetup'
-import { runTests } from '@tests/MochaSetup'
-import {
- registerUnitTests,
- registerTypeORMUnitTests,
- registerSqliteVecUnitTests,
-} from '@tests/unit'
+import { runAllTests } from '@tests/runAll'
export function UnitTestScreen() {
const [results, setResults] = useState([])
useEffect(() => {
setResults([])
- runTests(
- registerUnitTests,
- registerTypeORMUnitTests,
- registerSqliteVecUnitTests,
- ).then(setResults)
+ runAllTests().then(setResults)
}, [])
return (
@@ -26,17 +17,23 @@ export function UnitTestScreen() {
contentContainerStyle={styles.contentContainer}
data={results}
renderItem={({ item }) => {
- if (item.type === 'grouping') return {item.description}
-
- if (item.type === 'incorrect') {
- return (
-
- 🔴 {item.description}: {item.errorMsg}
-
- )
+ if (item.type === 'grouping') {
+ return {item.description}
}
- return 🟢 {item.description}
+ const didFail = item.type === 'incorrect'
+ const details = didFail
+ ? `${item.description}: ${item.errorMsg}`
+ : item.description
+
+ return (
+
+ {didFail ? '🔴' : '🟢'}
+
+ {details}
+
+
+ )
}}
/>
)
@@ -50,4 +47,27 @@ const styles = StyleSheet.create({
padding: 20,
paddingBottom: 50,
},
+ grouping: {
+ color: '#111',
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 8,
+ marginTop: 12,
+ },
+ result: {
+ alignItems: 'flex-start',
+ flexDirection: 'row',
+ marginBottom: 6,
+ },
+ status: {
+ marginRight: 6,
+ },
+ success: {
+ color: '#111',
+ flex: 1,
+ },
+ failure: {
+ color: '#b00020',
+ flex: 1,
+ },
})
diff --git a/example/tests/runAll.ts b/example/tests/runAll.ts
new file mode 100644
index 00000000..44c26e92
--- /dev/null
+++ b/example/tests/runAll.ts
@@ -0,0 +1,14 @@
+import { runTests } from './MochaSetup'
+import {
+ registerSqliteVecUnitTests,
+ registerTypeORMUnitTests,
+ registerUnitTests,
+} from './unit'
+
+export function runAllTests() {
+ return runTests(
+ registerUnitTests,
+ registerTypeORMUnitTests,
+ registerSqliteVecUnitTests,
+ )
+}