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, + ) +}