Skip to content

Commit 2480b77

Browse files
committed
Add iOS Harness barcode scan E2E
1 parent a77ce8b commit 2480b77

11 files changed

Lines changed: 972 additions & 26 deletions

File tree

.github/workflows/harness-ios.yml

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
name: Harness iOS
2+
3+
concurrency:
4+
group: harness-ios-${{ github.head_ref || github.run_id }}
5+
cancel-in-progress: true
6+
7+
on:
8+
workflow_dispatch:
9+
inputs:
10+
device_model:
11+
description: "Preferred iOS Simulator device model"
12+
required: false
13+
default: "iPhone 16 Pro"
14+
type: string
15+
ios_version:
16+
description: "Preferred iOS runtime version. Leave empty to use any available matching device."
17+
required: false
18+
default: ""
19+
type: string
20+
xcode_version:
21+
description: "Preferred Xcode version. Leave empty to use the runner default."
22+
required: false
23+
default: ""
24+
type: string
25+
push:
26+
branches:
27+
- master
28+
paths:
29+
- ".github/workflows/harness-ios.yml"
30+
- "apps/example/**"
31+
- "packages/react-native-data-scanner/ios/**"
32+
- "packages/react-native-data-scanner/cpp/**"
33+
- "packages/react-native-data-scanner/nitrogen/generated/shared/**"
34+
- "packages/react-native-data-scanner/nitrogen/generated/ios/**"
35+
- "packages/react-native-data-scanner/*.podspec"
36+
- "packages/react-native-data-scanner/react-native.config.js"
37+
- "packages/react-native-data-scanner/nitro.json"
38+
- "package.json"
39+
- "bun.lock"
40+
pull_request:
41+
paths:
42+
- ".github/workflows/harness-ios.yml"
43+
- "apps/example/**"
44+
- "packages/react-native-data-scanner/ios/**"
45+
- "packages/react-native-data-scanner/cpp/**"
46+
- "packages/react-native-data-scanner/nitrogen/generated/shared/**"
47+
- "packages/react-native-data-scanner/nitrogen/generated/ios/**"
48+
- "packages/react-native-data-scanner/*.podspec"
49+
- "packages/react-native-data-scanner/react-native.config.js"
50+
- "packages/react-native-data-scanner/nitro.json"
51+
- "package.json"
52+
- "bun.lock"
53+
54+
env:
55+
DEVICE_MODEL: ${{ github.event.inputs.device_model || 'iPhone 16 Pro' }}
56+
IOS_VERSION: ${{ github.event.inputs.ios_version || '' }}
57+
XCODE_VERSION: ${{ github.event.inputs.xcode_version || '' }}
58+
IOS_BUNDLE_ID: com.margelo.datascanner.example
59+
HARNESS_IOS_BUNDLE_ID: com.margelo.datascanner.example
60+
IOS_SCHEME: DataScannerExample
61+
IOS_APP_PATH: apps/example/ios/build/Build/Products/Debug-iphonesimulator/DataScannerExample.app
62+
HARNESS_DEBUG: 1
63+
64+
jobs:
65+
harness_ios:
66+
name: Harness iOS
67+
runs-on: macos-26
68+
timeout-minutes: 60
69+
steps:
70+
- uses: actions/checkout@v6
71+
with:
72+
fetch-depth: 1
73+
74+
- uses: oven-sh/setup-bun@v2
75+
76+
- name: Setup Node.js
77+
uses: actions/setup-node@v6
78+
with:
79+
node-version: "24"
80+
81+
- name: Select Xcode
82+
run: |
83+
set -euo pipefail
84+
85+
if [ -n "$XCODE_VERSION" ] && [ -d "/Applications/Xcode_${XCODE_VERSION}.app" ]; then
86+
sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer"
87+
fi
88+
89+
xcodebuild -version
90+
91+
- name: Install npm dependencies
92+
run: bun install --frozen-lockfile
93+
94+
- name: Prebuild iOS app
95+
working-directory: apps/example
96+
run: CI=1 bunx expo prebuild --platform ios --clean --no-install
97+
98+
- name: Restore CocoaPods cache
99+
uses: actions/cache@v5
100+
with:
101+
path: |
102+
apps/example/ios/Pods
103+
~/Library/Caches/CocoaPods
104+
~/.cocoapods
105+
key: ${{ runner.os }}-${{ runner.arch }}-pods-${{ hashFiles('bun.lock', 'apps/example/ios/Podfile.lock') }}
106+
restore-keys: |
107+
${{ runner.os }}-${{ runner.arch }}-pods-
108+
109+
- name: Install Pods
110+
working-directory: apps/example/ios
111+
run: pod install
112+
113+
- name: Boot iOS Simulator
114+
run: |
115+
set -euo pipefail
116+
117+
selection="$(
118+
xcrun simctl list devices available --json | node -e '
119+
const fs = require("fs");
120+
const data = JSON.parse(fs.readFileSync(0, "utf8"));
121+
const preferredModel = process.env.DEVICE_MODEL;
122+
const preferredVersion = process.env.IOS_VERSION;
123+
124+
const candidates = Object.entries(data.devices ?? {})
125+
.filter(([runtime]) => runtime.includes("iOS"))
126+
.flatMap(([runtime, devices]) =>
127+
(devices ?? [])
128+
.filter((device) => device.isAvailable !== false)
129+
.map((device) => {
130+
const runtimeMatch = runtime.match(/iOS-(\d+(?:-\d+)*)$/);
131+
const version = runtimeMatch?.[1]?.replace(/-/g, ".") ?? "";
132+
return { runtime, version, device };
133+
})
134+
);
135+
136+
const exact =
137+
preferredVersion === ""
138+
? undefined
139+
: candidates.find(({ version, device }) =>
140+
device.name === preferredModel && version === preferredVersion
141+
);
142+
const matchingModel = candidates.find(({ device }) => device.name === preferredModel);
143+
const matchingPhone = candidates.find(({ device }) => device.name.includes("iPhone"));
144+
const fallback = candidates[0];
145+
const selected = exact ?? matchingModel ?? matchingPhone ?? fallback;
146+
147+
if (selected == null) {
148+
console.error("No available iOS simulator found.");
149+
process.exit(1);
150+
}
151+
152+
process.stdout.write(JSON.stringify({
153+
udid: selected.device.udid,
154+
name: selected.device.name,
155+
version: selected.version,
156+
}));
157+
'
158+
)"
159+
160+
echo "$selection"
161+
echo "SIMULATOR_UDID=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).udid)' "$selection")" >> "$GITHUB_ENV"
162+
echo "HARNESS_IOS_DEVICE=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).name)' "$selection")" >> "$GITHUB_ENV"
163+
echo "HARNESS_IOS_VERSION=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).version)' "$selection")" >> "$GITHUB_ENV"
164+
165+
simulator_udid="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).udid)' "$selection")"
166+
xcrun simctl boot "$simulator_udid" || true
167+
168+
xcrun simctl bootstatus "$simulator_udid" -b &
169+
bootstatus_pid=$!
170+
bootstatus_timed_out=0
171+
boot_deadline=$((SECONDS + 240))
172+
173+
while kill -0 "$bootstatus_pid" >/dev/null 2>&1; do
174+
if (( SECONDS >= boot_deadline )); then
175+
echo "Timed out waiting for simulator bootstatus. Checking simulator state..."
176+
kill "$bootstatus_pid" >/dev/null 2>&1 || true
177+
wait "$bootstatus_pid" >/dev/null 2>&1 || true
178+
179+
state="$(
180+
xcrun simctl list devices available --json | node -e '
181+
const fs = require("fs");
182+
const data = JSON.parse(fs.readFileSync(0, "utf8"));
183+
const udid = process.argv[1];
184+
const devices = Object.values(data.devices ?? {}).flat();
185+
process.stdout.write(devices.find((device) => device.udid === udid)?.state ?? "");
186+
' "$simulator_udid"
187+
)"
188+
189+
echo "Simulator state after bootstatus timeout: ${state:-unknown}"
190+
if [ "$state" = "Booted" ]; then
191+
bootstatus_timed_out=1
192+
break
193+
fi
194+
195+
xcrun simctl list devices available
196+
exit 1
197+
fi
198+
199+
sleep 2
200+
done
201+
202+
if [ "$bootstatus_timed_out" = "0" ]; then
203+
wait "$bootstatus_pid"
204+
fi
205+
206+
- name: Restore DerivedData cache
207+
uses: actions/cache@v5
208+
with:
209+
path: apps/example/ios/build
210+
key: ${{ runner.os }}-${{ runner.arch }}-xcode-dd-${{ hashFiles('bun.lock', 'apps/example/ios/Podfile.lock', 'apps/example/ios/**/*.pbxproj') }}
211+
restore-keys: |
212+
${{ runner.os }}-${{ runner.arch }}-xcode-dd-
213+
214+
- name: Build iOS app
215+
working-directory: apps/example/ios
216+
run: |
217+
set -o pipefail
218+
xcodebuild \
219+
-workspace "${IOS_SCHEME}.xcworkspace" \
220+
-scheme "$IOS_SCHEME" \
221+
-configuration Debug \
222+
-sdk iphonesimulator \
223+
-destination "id=$SIMULATOR_UDID" \
224+
-derivedDataPath build \
225+
-UseModernBuildSystem=YES \
226+
ONLY_ACTIVE_ARCH=YES \
227+
CODE_SIGNING_ALLOWED=NO \
228+
build
229+
230+
- name: Install iOS app
231+
run: xcrun simctl install "$SIMULATOR_UDID" "$IOS_APP_PATH"
232+
233+
- name: Run Harness tests with serve-sim camera
234+
working-directory: apps/example
235+
run: bash ./scripts/run-harness-ios-ci.sh

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ android/keystores/debug.keystore
7070

7171
# Expo
7272
.expo/
73+
apps/example/android/
74+
apps/example/ios/
7375

7476
# Turborepo
7577
.turbo/
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from 'react-native-harness'
2+
import {
3+
DataScanner,
4+
type ScanBarcodeOptions,
5+
type ScannedBarcode,
6+
} from 'react-native-data-scanner'
7+
8+
const expectedQRCodeValue =
9+
'https://margelo.com/react-native-data-scanner/harness'
10+
11+
describe('DataScanner.scanBarcode', () => {
12+
it('scans a QR code with an explicit target format', async () => {
13+
const barcode = await scanBarcode({
14+
targetFormats: ['qr'],
15+
qualityLevel: 'fast',
16+
})
17+
18+
expect(barcode.format).toBe('qr')
19+
expect(barcode.value).toBe(expectedQRCodeValue)
20+
})
21+
22+
it('scans a QR code with high-accuracy options', async () => {
23+
const barcode = await scanBarcode({
24+
targetFormats: ['qr'],
25+
qualityLevel: 'accurate',
26+
enableHighFrameRateTracking: true,
27+
})
28+
29+
expect(barcode).toStrictEqual({
30+
format: 'qr',
31+
value: expectedQRCodeValue,
32+
})
33+
})
34+
})
35+
36+
async function scanBarcode(
37+
options: ScanBarcodeOptions
38+
): Promise<ScannedBarcode> {
39+
return await withTimeout(
40+
DataScanner.scanBarcode(options),
41+
30_000,
42+
`scan barcode with options ${JSON.stringify(options)}`
43+
)
44+
}
45+
46+
async function withTimeout<T>(
47+
promise: Promise<T>,
48+
timeoutMs: number,
49+
label: string
50+
): Promise<T> {
51+
let timeout: ReturnType<typeof setTimeout> | undefined
52+
53+
try {
54+
return await Promise.race([
55+
promise,
56+
new Promise<never>((_, reject) => {
57+
timeout = setTimeout(() => {
58+
reject(
59+
new Error(`Timed out after ${timeoutMs}ms while waiting to ${label}.`)
60+
)
61+
}, timeoutMs)
62+
}),
63+
])
64+
} finally {
65+
if (timeout != null) {
66+
clearTimeout(timeout)
67+
}
68+
}
69+
}

apps/example/assets/harness-qr.png

7.38 KB
Loading
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export default {
2+
preset: 'react-native-harness',
3+
testMatch: ['<rootDir>/**/__tests__/**/*.harness.[jt]s?(x)'],
4+
}

apps/example/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,23 @@
1111
"react-native": "0.85.3"
1212
},
1313
"devDependencies": {
14+
"@react-native-harness/platform-apple": "1.4.0-rc.1",
1415
"@types/react": "~19.2.2",
16+
"jest": "^29.7.0",
17+
"react-native-harness": "1.4.0-rc.1",
18+
"serve-sim": "0.1.39",
1519
"typescript": "~6.0.3"
1620
},
1721
"scripts": {
1822
"start": "expo start",
1923
"android": "expo run:android",
2024
"ios": "expo run:ios",
21-
"web": "expo start --web"
25+
"web": "expo start --web",
26+
"test:harness": "react-native-harness",
27+
"test:harness:ios": "react-native-harness --harnessRunner ios"
28+
},
29+
"engines": {
30+
"node": ">=20.19.4"
2231
},
2332
"private": true
2433
}

apps/example/rn-harness.config.mjs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
applePlatform,
3+
appleSimulator,
4+
} from '@react-native-harness/platform-apple'
5+
import { fileURLToPath } from 'node:url'
6+
7+
const iosBundleId =
8+
process.env.HARNESS_IOS_BUNDLE_ID ?? 'com.margelo.datascanner.example'
9+
const iosDevice = process.env.HARNESS_IOS_DEVICE ?? 'iPhone 16 Pro'
10+
const iosVersion = process.env.HARNESS_IOS_VERSION ?? '18.6'
11+
const entryPoint = fileURLToPath(new URL('./index.ts', import.meta.url))
12+
13+
const cameraDylib = process.env.HARNESS_IOS_CAMERA_DYLIB?.trim()
14+
const cameraSharedMemoryName =
15+
process.env.HARNESS_IOS_CAMERA_SHM_NAME?.trim()
16+
const cameraMirrorMode = process.env.HARNESS_IOS_CAMERA_MIRROR_MODE?.trim()
17+
18+
const iosEnvironment =
19+
cameraDylib && cameraSharedMemoryName
20+
? {
21+
DYLD_INSERT_LIBRARIES: cameraDylib,
22+
SIMCAM_SHM_NAME: cameraSharedMemoryName,
23+
...(cameraMirrorMode ? { SIMCAM_MIRROR_MODE: cameraMirrorMode } : {}),
24+
}
25+
: undefined
26+
27+
const config = {
28+
entryPoint,
29+
appRegistryComponentName: 'main',
30+
runners: [
31+
applePlatform({
32+
name: 'ios',
33+
device: appleSimulator(iosDevice, iosVersion),
34+
bundleId: iosBundleId,
35+
appLaunchOptions: iosEnvironment
36+
? {
37+
environment: iosEnvironment,
38+
}
39+
: undefined,
40+
}),
41+
],
42+
defaultRunner: 'ios',
43+
bridgeTimeout: 120_000,
44+
bundleStartTimeout: 90_000,
45+
maxAppRestarts: 3,
46+
detectNativeCrashes: true,
47+
forwardClientLogs: true,
48+
resetEnvironmentBetweenTestFiles: true,
49+
}
50+
51+
export default config

0 commit comments

Comments
 (0)