vxrn 2.0.0-beta.98.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backgroundComputation.mjs +145 -0
- package/dist/backgroundComputation.mjs.map +1 -0
- package/dist/backgroundComputation.native.js +163 -0
- package/dist/backgroundComputation.native.js.map +1 -0
- package/dist/backgroundComputationNative.mjs +16 -0
- package/dist/backgroundComputationNative.mjs.map +1 -0
- package/dist/backgroundComputationNative.native.js +18 -0
- package/dist/backgroundComputationNative.native.js.map +1 -0
- package/dist/config/getBaseViteConfigOnly.mjs +6 -10
- package/dist/config/getBaseViteConfigOnly.mjs.map +1 -1
- package/dist/config/getBaseViteConfigOnly.native.js +6 -10
- package/dist/config/getBaseViteConfigOnly.native.js.map +1 -1
- package/dist/config/getOptimizeDeps.mjs +2 -1
- package/dist/config/getOptimizeDeps.mjs.map +1 -1
- package/dist/config/getOptimizeDeps.native.js +2 -1
- package/dist/config/getOptimizeDeps.native.js.map +1 -1
- package/dist/config/getViteServerConfig.mjs +13 -0
- package/dist/config/getViteServerConfig.mjs.map +1 -1
- package/dist/config/getViteServerConfig.native.js +15 -1
- package/dist/config/getViteServerConfig.native.js.map +1 -1
- package/dist/constants/defaults.mjs +4 -0
- package/dist/constants/defaults.mjs.map +1 -1
- package/dist/constants/defaults.native.js +4 -0
- package/dist/constants/defaults.native.js.map +1 -1
- package/dist/exports/build.native.js +1 -1
- package/dist/exports/build.native.js.map +1 -1
- package/dist/exports/clean.native.js +1 -1
- package/dist/exports/clean.native.js.map +1 -1
- package/dist/exports/createServer.native.js +2 -2
- package/dist/exports/createServer.native.js.map +1 -1
- package/dist/exports/dev.mjs +3 -0
- package/dist/exports/dev.mjs.map +1 -1
- package/dist/exports/dev.native.js +6 -3
- package/dist/exports/dev.native.js.map +1 -1
- package/dist/exports/loadEnv.native.js +1 -1
- package/dist/exports/loadEnv.native.js.map +1 -1
- package/dist/exports/patch.native.js +1 -1
- package/dist/exports/patch.native.js.map +1 -1
- package/dist/exports/prebuild.mjs +0 -22
- package/dist/exports/prebuild.mjs.map +1 -1
- package/dist/exports/prebuild.native.js +1 -23
- package/dist/exports/prebuild.native.js.map +1 -1
- package/dist/exports/prebuildWithoutExpo.mjs +1133 -182
- package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
- package/dist/exports/prebuildWithoutExpo.native.js +1606 -244
- package/dist/exports/prebuildWithoutExpo.native.js.map +1 -1
- package/dist/exports/runAndroid.native.js +1 -1
- package/dist/exports/runAndroid.native.js.map +1 -1
- package/dist/exports/runIos.native.js +1 -1
- package/dist/exports/runIos.native.js.map +1 -1
- package/dist/exports/serve.native.js +1 -1
- package/dist/exports/serve.native.js.map +1 -1
- package/dist/exports/serveStaticAssets.native.js +1 -1
- package/dist/exports/serveStaticAssets.native.js.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.mjs +4 -1
- package/dist/index.native.js +4 -1
- package/dist/native-source-contract.mjs +3 -0
- package/dist/native-source-contract.native.js +3 -0
- package/dist/patches/builtInDepPatches.mjs +0 -13
- package/dist/patches/builtInDepPatches.mjs.map +1 -1
- package/dist/patches/builtInDepPatches.native.js +0 -13
- package/dist/patches/builtInDepPatches.native.js.map +1 -1
- package/dist/plugins/autoDepOptimizePlugin.mjs +3 -2
- package/dist/plugins/autoDepOptimizePlugin.mjs.map +1 -1
- package/dist/plugins/autoDepOptimizePlugin.native.js +6 -3
- package/dist/plugins/autoDepOptimizePlugin.native.js.map +1 -1
- package/dist/plugins/backgroundComputationPlugin.mjs +27 -0
- package/dist/plugins/backgroundComputationPlugin.mjs.map +1 -0
- package/dist/plugins/backgroundComputationPlugin.native.js +32 -0
- package/dist/plugins/backgroundComputationPlugin.native.js.map +1 -0
- package/dist/plugins/reactNativeCommonJsPlugin.native.js +3 -3
- package/dist/plugins/reactNativeCommonJsPlugin.native.js.map +1 -1
- package/dist/plugins/reactNativeDevAssetPlugin.mjs +2 -1
- package/dist/plugins/reactNativeDevAssetPlugin.mjs.map +1 -1
- package/dist/plugins/reactNativeDevAssetPlugin.native.js +3 -2
- package/dist/plugins/reactNativeDevAssetPlugin.native.js.map +1 -1
- package/dist/plugins/reactNativeDevServer.mjs +87 -24
- package/dist/plugins/reactNativeDevServer.mjs.map +1 -1
- package/dist/plugins/reactNativeDevServer.native.js +162 -45
- package/dist/plugins/reactNativeDevServer.native.js.map +1 -1
- package/dist/plugins/workletImportsPlugin.mjs +9 -30
- package/dist/plugins/workletImportsPlugin.mjs.map +1 -1
- package/dist/plugins/workletImportsPlugin.native.js +10 -35
- package/dist/plugins/workletImportsPlugin.native.js.map +1 -1
- package/dist/rn-commands/bundle/buildBundle.mjs +3 -3
- package/dist/rn-commands/bundle/buildBundle.mjs.map +1 -1
- package/dist/rn-commands/bundle/buildBundle.native.js +3 -3
- package/dist/rn-commands/bundle/buildBundle.native.js.map +1 -1
- package/dist/runtime/hmr-client.native.js +3 -7
- package/dist/runtime/hmr-client.native.js.map +1 -1
- package/dist/runtime/hmr-runtime.native.js +32 -40
- package/dist/runtime/hmr-runtime.native.js.map +1 -1
- package/dist/serve/node.native.js +2 -2
- package/dist/serve/node.native.js.map +1 -1
- package/dist/swift-package-id.mjs +3 -0
- package/dist/swift-package-id.native.js +3 -0
- package/dist/user-interface/index.native.js +6 -6
- package/dist/user-interface/index.native.js.map +1 -1
- package/dist/utils/compileNativeDevBytecode.mjs +93 -0
- package/dist/utils/compileNativeDevBytecode.mjs.map +1 -0
- package/dist/utils/compileNativeDevBytecode.native.js +98 -0
- package/dist/utils/compileNativeDevBytecode.native.js.map +1 -0
- package/dist/utils/createNativeDevEngine.mjs +227 -20
- package/dist/utils/createNativeDevEngine.mjs.map +1 -1
- package/dist/utils/createNativeDevEngine.native.js +249 -37
- package/dist/utils/createNativeDevEngine.native.js.map +1 -1
- package/dist/utils/execSync.native.js +1 -1
- package/dist/utils/execSync.native.js.map +1 -1
- package/dist/utils/getServerEntry.native.js +2 -2
- package/dist/utils/getServerEntry.native.js.map +1 -1
- package/dist/utils/nativeRun.mjs +32 -16
- package/dist/utils/nativeRun.mjs.map +1 -1
- package/dist/utils/nativeRun.native.js +43 -18
- package/dist/utils/nativeRun.native.js.map +1 -1
- package/dist/utils/nativeSourceContract.mjs +1823 -0
- package/dist/utils/nativeSourceContract.mjs.map +1 -0
- package/dist/utils/nativeSourceContract.native.js +2425 -0
- package/dist/utils/nativeSourceContract.native.js.map +1 -0
- package/dist/utils/patches.native.js +15 -24
- package/dist/utils/patches.native.js.map +1 -1
- package/dist/utils/printServerUrls.native.js +1 -1
- package/dist/utils/printServerUrls.native.js.map +1 -1
- package/dist/utils/scanDepsToOptimize.mjs +7 -1
- package/dist/utils/scanDepsToOptimize.mjs.map +1 -1
- package/dist/utils/scanDepsToOptimize.native.js +27 -2
- package/dist/utils/scanDepsToOptimize.native.js.map +1 -1
- package/dist/utils/swiftPackageId.mjs +31 -2
- package/dist/utils/swiftPackageId.mjs.map +1 -1
- package/dist/utils/swiftPackageId.native.js +50 -2
- package/dist/utils/swiftPackageId.native.js.map +1 -1
- package/dist/vxrn-vite-plugin.native.js +1 -1
- package/dist/vxrn-vite-plugin.native.js.map +1 -1
- package/expo-plugin.cjs +792 -2
- package/native-project-patches.cjs +201 -10
- package/package.json +34 -15
- package/src/backgroundComputation.ts +220 -0
- package/src/backgroundComputationNative.ts +23 -0
- package/src/config/getBaseViteConfigOnly.ts +8 -9
- package/src/config/getOptimizeDeps.ts +3 -1
- package/src/config/getViteServerConfig.ts +16 -0
- package/src/config/glbAssets.test.ts +228 -0
- package/src/constants/defaults.ts +6 -0
- package/src/exports/dev.ts +5 -1
- package/src/exports/expoPlugin.test.mjs +337 -15
- package/src/exports/prebuild.ts +0 -31
- package/src/exports/prebuildWithoutExpo.test.ts +1623 -46
- package/src/exports/prebuildWithoutExpo.ts +1606 -216
- package/src/index.ts +8 -0
- package/src/native-source-contract.ts +16 -0
- package/src/patches/builtInDepPatches.ts +0 -21
- package/src/plugins/autoDepOptimizePlugin.ts +7 -2
- package/src/plugins/backgroundComputationPlugin.test.ts +77 -0
- package/src/plugins/backgroundComputationPlugin.ts +29 -0
- package/src/plugins/reactNativeDevAssetPlugin.ts +5 -1
- package/src/plugins/reactNativeDevServer.test.ts +74 -0
- package/src/plugins/reactNativeDevServer.ts +145 -28
- package/src/plugins/rnCodegenPlugin.test.ts +30 -0
- package/src/plugins/workletImportsPlugin.ts +19 -37
- package/src/rn-commands/bundle/buildBundle.ts +5 -3
- package/src/swift-package-id.ts +1 -0
- package/src/utils/compileNativeDevBytecode.test.ts +168 -0
- package/src/utils/compileNativeDevBytecode.ts +117 -0
- package/src/utils/createNativeDevEngine.test.ts +643 -18
- package/src/utils/createNativeDevEngine.ts +278 -48
- package/src/utils/nativeRun.test.ts +125 -23
- package/src/utils/nativeRun.ts +51 -23
- package/src/utils/nativeSourceArtifacts.test.ts +39 -0
- package/src/utils/nativeSourceContract.test.ts +600 -0
- package/src/utils/nativeSourceContract.ts +1897 -0
- package/src/utils/scanDepsToOptimize.test.ts +80 -0
- package/src/utils/scanDepsToOptimize.ts +18 -7
- package/src/utils/swiftPackageId.ts +25 -1
- package/types/backgroundComputation.d.ts +28 -0
- package/types/backgroundComputation.d.ts.map +1 -0
- package/types/backgroundComputationNative.d.ts +2 -0
- package/types/backgroundComputationNative.d.ts.map +1 -0
- package/types/config/getBaseViteConfigOnly.d.ts.map +1 -1
- package/types/config/getOptimizeDeps.d.ts.map +1 -1
- package/types/config/getViteServerConfig.d.ts.map +1 -1
- package/types/constants/defaults.d.ts.map +1 -1
- package/types/exports/dev.d.ts.map +1 -1
- package/types/exports/prebuild.d.ts.map +1 -1
- package/types/exports/prebuildWithoutExpo.d.ts +6 -0
- package/types/exports/prebuildWithoutExpo.d.ts.map +1 -1
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -1
- package/types/native-source-contract.d.ts +2 -0
- package/types/native-source-contract.d.ts.map +1 -0
- package/types/patches/builtInDepPatches.d.ts.map +1 -1
- package/types/plugins/autoDepOptimizePlugin.d.ts.map +1 -1
- package/types/plugins/backgroundComputationPlugin.d.ts +3 -0
- package/types/plugins/backgroundComputationPlugin.d.ts.map +1 -0
- package/types/plugins/reactNativeDevAssetPlugin.d.ts.map +1 -1
- package/types/plugins/reactNativeDevServer.d.ts.map +1 -1
- package/types/plugins/workletImportsPlugin.d.ts.map +1 -1
- package/types/rn-commands/bundle/buildBundle.d.ts.map +1 -1
- package/types/swift-package-id.d.ts +2 -0
- package/types/swift-package-id.d.ts.map +1 -0
- package/types/utils/compileNativeDevBytecode.d.ts +3 -0
- package/types/utils/compileNativeDevBytecode.d.ts.map +1 -0
- package/types/utils/createNativeDevEngine.d.ts +7 -22
- package/types/utils/createNativeDevEngine.d.ts.map +1 -1
- package/types/utils/nativeRun.d.ts.map +1 -1
- package/types/utils/nativeSourceContract.d.ts +121 -0
- package/types/utils/nativeSourceContract.d.ts.map +1 -0
- package/types/utils/scanDepsToOptimize.d.ts +1 -0
- package/types/utils/scanDepsToOptimize.d.ts.map +1 -1
- package/types/utils/swiftPackageId.d.ts +1 -0
- package/types/utils/swiftPackageId.d.ts.map +1 -1
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
2
3
|
import {
|
|
3
4
|
mkdtempSync,
|
|
4
5
|
mkdirSync,
|
|
5
6
|
readdirSync,
|
|
7
|
+
existsSync,
|
|
6
8
|
readFileSync,
|
|
7
9
|
realpathSync,
|
|
10
|
+
rmSync,
|
|
8
11
|
statSync,
|
|
9
12
|
writeFileSync,
|
|
10
13
|
} from 'node:fs'
|
|
11
14
|
import { tmpdir } from 'node:os'
|
|
12
|
-
import { join, relative } from 'node:path'
|
|
15
|
+
import { dirname, join, relative } from 'node:path'
|
|
13
16
|
import { fileURLToPath } from 'node:url'
|
|
14
17
|
import { describe, expect, it } from 'vitest'
|
|
15
18
|
import sharp from 'sharp'
|
|
19
|
+
import { swiftPackageDirectories } from '../utils/swiftPackageId'
|
|
16
20
|
import {
|
|
17
21
|
applyAndroidDependencyPatches,
|
|
22
|
+
enableAppComposeIntegration,
|
|
18
23
|
generateForPlatform,
|
|
24
|
+
generateKotlinSources,
|
|
19
25
|
getNativeDependencyInventory,
|
|
20
26
|
renderPrebuildFile,
|
|
21
27
|
renderSceneDelegateSwift,
|
|
@@ -29,6 +35,7 @@ const app = {
|
|
|
29
35
|
scheme: ['myapp', 'myapp-dev'],
|
|
30
36
|
notifications: {},
|
|
31
37
|
imagePicker: { camera: 'Capture photos & videos' },
|
|
38
|
+
speech: { recognition: 'Dictate notes', microphone: 'Record dictation' },
|
|
32
39
|
ios: {
|
|
33
40
|
bundleId: 'dev.one.myapp',
|
|
34
41
|
tablet: true,
|
|
@@ -42,6 +49,16 @@ const app = {
|
|
|
42
49
|
android: { applicationId: 'dev.one.myapp', minSdk: 28 },
|
|
43
50
|
} satisfies PrebuildAppConfig
|
|
44
51
|
|
|
52
|
+
const templateRnDelegate = readFileSync(
|
|
53
|
+
fileURLToPath(
|
|
54
|
+
new URL(
|
|
55
|
+
'../../../../node_modules/@react-native-community/template/template/ios/HelloWorld/AppDelegate.swift',
|
|
56
|
+
import.meta.url
|
|
57
|
+
)
|
|
58
|
+
),
|
|
59
|
+
'utf8'
|
|
60
|
+
).split('class ReactNativeDelegate:')[1]
|
|
61
|
+
|
|
45
62
|
describe('native.app prebuild validation', () => {
|
|
46
63
|
// smoke for the shared definition re-export; the full cases live beside
|
|
47
64
|
// the canonical definition in @vxrn/utils.
|
|
@@ -113,6 +130,12 @@ describe('native.app prebuild validation', () => {
|
|
|
113
130
|
expect(() => validatePrebuildApp({ ...app, imagePicker: { camera: '' } })).toThrow(
|
|
114
131
|
/imagePicker\.camera/
|
|
115
132
|
)
|
|
133
|
+
expect(() =>
|
|
134
|
+
validatePrebuildApp({
|
|
135
|
+
...app,
|
|
136
|
+
imagePicker: { camera: 'Capture photos', microphone: '' },
|
|
137
|
+
})
|
|
138
|
+
).toThrow(/imagePicker\.microphone/)
|
|
116
139
|
expect(() =>
|
|
117
140
|
validatePrebuildApp({ ...app, notifications: { push: 'yes' } } as any)
|
|
118
141
|
).toThrow(/notifications\.push/)
|
|
@@ -126,11 +149,15 @@ describe('native.app prebuild validation', () => {
|
|
|
126
149
|
})
|
|
127
150
|
})
|
|
128
151
|
|
|
129
|
-
// the template's AppDelegate entries, which the scene delegate patch anchors
|
|
152
|
+
// the template's AppDelegate entries, which the scene delegate patch anchors
|
|
153
|
+
// on, and its two app-target plist settings, beside which the bridging header
|
|
154
|
+
// setting lands
|
|
130
155
|
const APP_DELEGATE_PBXPROJ = `\t\t761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
|
|
131
156
|
\t\t761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HelloWorld/AppDelegate.swift; sourceTree = "<group>"; };
|
|
132
157
|
\t\t\t\t761780EC2CA45674006654EE /* AppDelegate.swift */,
|
|
133
|
-
\t\t\t\t761780ED2CA45674006654EE /* AppDelegate.swift in Sources
|
|
158
|
+
\t\t\t\t761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,
|
|
159
|
+
\t\t\t\tINFOPLIST_FILE = HelloWorld/Info.plist;
|
|
160
|
+
\t\t\t\tINFOPLIST_FILE = HelloWorld/Info.plist;`
|
|
134
161
|
|
|
135
162
|
describe('template rendering', () => {
|
|
136
163
|
it('applies names, ids, and platform versions', () => {
|
|
@@ -164,7 +191,10 @@ ${APP_DELEGATE_PBXPROJ}`,
|
|
|
164
191
|
})
|
|
165
192
|
expect(podfile.content).toContain("platform :ios, '17.0'")
|
|
166
193
|
expect(podfile.content).toContain(
|
|
167
|
-
|
|
194
|
+
`config = use_native_modules!(['node', '-e', "process.argv=['', '', 'config'];require(require('module').createRequire(require.resolve('vxrn/package.json')).resolve('@react-native-community/cli')).run()"])`
|
|
195
|
+
)
|
|
196
|
+
expect(podfile.content).toContain(
|
|
197
|
+
" # [vxrn/one] nitro web image modular header\n pod 'SDWebImage', :modular_headers => true\n # [vxrn/one] swift packages\n Dir[File.join(__dir__, 'OneSwiftPackages'"
|
|
168
198
|
)
|
|
169
199
|
expect(podfile.content).toContain(
|
|
170
200
|
"config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0'"
|
|
@@ -188,6 +218,7 @@ ${APP_DELEGATE_PBXPROJ}`,
|
|
|
188
218
|
expect(infoPlist.content).toContain('<key>UIFileSharingEnabled</key>')
|
|
189
219
|
expect(infoPlist.content).toContain('<key>LSSupportsOpeningDocumentsInPlace</key>')
|
|
190
220
|
expect(infoPlist.content).toContain('<key>OneNativeNotificationsEnabled</key>')
|
|
221
|
+
expect(infoPlist.content).not.toContain('OneNativeNotificationsPush')
|
|
191
222
|
expect(() =>
|
|
192
223
|
renderPrebuildFile({
|
|
193
224
|
relativePath: 'HelloWorld/Info.plist',
|
|
@@ -198,6 +229,158 @@ ${APP_DELEGATE_PBXPROJ}`,
|
|
|
198
229
|
).toThrow('lost its LSRequiresIPhoneOS anchor')
|
|
199
230
|
expect(infoPlist.content).toContain('<key>NSCameraUsageDescription</key>')
|
|
200
231
|
expect(infoPlist.content).toContain('<string>Capture photos & videos</string>')
|
|
232
|
+
expect(infoPlist.content).toContain('<key>NSSpeechRecognitionUsageDescription</key>')
|
|
233
|
+
expect(infoPlist.content).toContain('<key>NSMicrophoneUsageDescription</key>')
|
|
234
|
+
|
|
235
|
+
const audioPlist = renderPrebuildFile({
|
|
236
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
237
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
238
|
+
platform: 'ios',
|
|
239
|
+
app: { ...app, speech: undefined, audio: { microphone: 'Record notes & ideas' } },
|
|
240
|
+
})
|
|
241
|
+
expect(audioPlist.content).toContain(
|
|
242
|
+
'<key>NSMicrophoneUsageDescription</key>\n\t<string>Record notes & ideas</string>'
|
|
243
|
+
)
|
|
244
|
+
expect(audioPlist.content).not.toContain('NSSpeechRecognitionUsageDescription')
|
|
245
|
+
|
|
246
|
+
const backgroundAudioPlist = renderPrebuildFile({
|
|
247
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
248
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
249
|
+
platform: 'ios',
|
|
250
|
+
app: { ...app, speech: undefined, audio: { background: true } },
|
|
251
|
+
})
|
|
252
|
+
expect(backgroundAudioPlist.content?.match(/<key>UIBackgroundModes<\/key>/g)).toHaveLength(1)
|
|
253
|
+
expect(backgroundAudioPlist.content?.match(/<string>audio<\/string>/g)).toHaveLength(1)
|
|
254
|
+
expect(backgroundAudioPlist.content).not.toContain('NSMicrophoneUsageDescription')
|
|
255
|
+
|
|
256
|
+
const pipAndAudioPlist = renderPrebuildFile({
|
|
257
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
258
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
259
|
+
platform: 'ios',
|
|
260
|
+
app: { ...app, pictureInPicture: true, audio: { background: true } },
|
|
261
|
+
})
|
|
262
|
+
expect(pipAndAudioPlist.content?.match(/<key>UIBackgroundModes<\/key>/g)).toHaveLength(1)
|
|
263
|
+
|
|
264
|
+
const speechAndAudioPlist = renderPrebuildFile({
|
|
265
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
266
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
267
|
+
platform: 'ios',
|
|
268
|
+
app: { ...app, audio: { microphone: 'Record notes & ideas' } },
|
|
269
|
+
})
|
|
270
|
+
expect(speechAndAudioPlist.content?.match(/<key>NSMicrophoneUsageDescription<\/key>/g))
|
|
271
|
+
.toHaveLength(1)
|
|
272
|
+
expect(speechAndAudioPlist.content).toContain(
|
|
273
|
+
'<key>NSMicrophoneUsageDescription</key>\n\t<string>Record notes & ideas</string>'
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const locationPlist = renderPrebuildFile({
|
|
277
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
278
|
+
content:
|
|
279
|
+
'<dict>\n\t<key>LSRequiresIPhoneOS</key>\n\t<key>NSLocationWhenInUseUsageDescription</key>\n\t<string></string>\n</dict>',
|
|
280
|
+
platform: 'ios',
|
|
281
|
+
app: { ...app, location: { whenInUse: 'Find nearby cafés & parks' } },
|
|
282
|
+
})
|
|
283
|
+
if (locationPlist.content === null) throw new Error('location Info.plist was not rendered')
|
|
284
|
+
expect(
|
|
285
|
+
locationPlist.content.match(/<key>NSLocationWhenInUseUsageDescription<\/key>/g)
|
|
286
|
+
).toHaveLength(1)
|
|
287
|
+
expect(locationPlist.content).toContain(
|
|
288
|
+
'<key>NSLocationWhenInUseUsageDescription</key>\n\t<string>Find nearby cafés & parks</string>'
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
const noLocationPlist = renderPrebuildFile({
|
|
292
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
293
|
+
content:
|
|
294
|
+
'<dict>\n\t<key>LSRequiresIPhoneOS</key>\n\t<key>NSLocationWhenInUseUsageDescription</key>\n\t<string></string>\n</dict>',
|
|
295
|
+
platform: 'ios',
|
|
296
|
+
app,
|
|
297
|
+
})
|
|
298
|
+
expect(noLocationPlist.content).not.toContain('NSLocationWhenInUseUsageDescription')
|
|
299
|
+
|
|
300
|
+
const alwaysLocationPlist = renderPrebuildFile({
|
|
301
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
302
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
303
|
+
platform: 'ios',
|
|
304
|
+
app: {
|
|
305
|
+
...app,
|
|
306
|
+
location: { whenInUse: 'Find nearby cafés & parks', alwaysAndWhenInUse: 'Monitor regions' },
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
if (alwaysLocationPlist.content === null) throw new Error('always location plist was not rendered')
|
|
310
|
+
expect(
|
|
311
|
+
alwaysLocationPlist.content.match(/<key>NSLocationAlwaysAndWhenInUseUsageDescription<\/key>/g)
|
|
312
|
+
).toHaveLength(1)
|
|
313
|
+
expect(alwaysLocationPlist.content).toContain(
|
|
314
|
+
'<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>\n\t<string>Monitor regions</string>'
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
const backgroundLocationPlist = renderPrebuildFile({
|
|
318
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
319
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
320
|
+
platform: 'ios',
|
|
321
|
+
app: { ...app, location: { whenInUse: 'Track a hike.', background: true },
|
|
322
|
+
audio: { background: true } },
|
|
323
|
+
})
|
|
324
|
+
expect(backgroundLocationPlist.content?.match(/<key>UIBackgroundModes<\/key>/g)).toHaveLength(1)
|
|
325
|
+
expect(backgroundLocationPlist.content).toContain('<string>audio</string>')
|
|
326
|
+
expect(backgroundLocationPlist.content).toContain('<string>location</string>')
|
|
327
|
+
|
|
328
|
+
const photoPlist = renderPrebuildFile({
|
|
329
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
330
|
+
content:
|
|
331
|
+
'<dict>\n\t<key>LSRequiresIPhoneOS</key>\n\t<key>NSPhotoLibraryAddUsageDescription</key>\n\t<string></string>\n</dict>',
|
|
332
|
+
platform: 'ios',
|
|
333
|
+
app: { ...app, photoLibrary: {
|
|
334
|
+
addOnly: 'Save photos & videos', readWrite: 'Browse photos & videos',
|
|
335
|
+
} },
|
|
336
|
+
})
|
|
337
|
+
expect(photoPlist.content?.match(/<key>NSPhotoLibraryAddUsageDescription<\/key>/g))
|
|
338
|
+
.toHaveLength(1)
|
|
339
|
+
expect(photoPlist.content).toContain(
|
|
340
|
+
'<key>NSPhotoLibraryAddUsageDescription</key>\n\t<string>Save photos & videos</string>'
|
|
341
|
+
)
|
|
342
|
+
expect(photoPlist.content?.match(/<key>NSPhotoLibraryUsageDescription<\/key>/g))
|
|
343
|
+
.toHaveLength(1)
|
|
344
|
+
expect(photoPlist.content).toContain(
|
|
345
|
+
'<key>NSPhotoLibraryUsageDescription</key>\n\t<string>Browse photos & videos</string>'
|
|
346
|
+
)
|
|
347
|
+
expect(photoPlist.content).toContain(
|
|
348
|
+
'<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>\n\t<true/>'
|
|
349
|
+
)
|
|
350
|
+
const readWritePhotoPlist = renderPrebuildFile({
|
|
351
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
352
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n\t<true/>\n</dict>',
|
|
353
|
+
platform: 'ios',
|
|
354
|
+
app: { ...app, photoLibrary: { readWrite: 'Browse photos' } },
|
|
355
|
+
})
|
|
356
|
+
expect(readWritePhotoPlist.content).toContain('<key>NSPhotoLibraryUsageDescription</key>')
|
|
357
|
+
expect(readWritePhotoPlist.content).toContain(
|
|
358
|
+
'<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>\n\t<true/>'
|
|
359
|
+
)
|
|
360
|
+
expect(readWritePhotoPlist.content).not.toContain('NSPhotoLibraryAddUsageDescription')
|
|
361
|
+
|
|
362
|
+
const contactsPlist = renderPrebuildFile({
|
|
363
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
364
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
365
|
+
platform: 'ios',
|
|
366
|
+
app: { ...app, contacts: { usage: 'Find people & friends' } },
|
|
367
|
+
})
|
|
368
|
+
expect(contactsPlist.content).toContain(
|
|
369
|
+
'<key>NSContactsUsageDescription</key>\n\t<string>Find people & friends</string>'
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
const calendarPlist = renderPrebuildFile({
|
|
373
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
374
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
375
|
+
platform: 'ios',
|
|
376
|
+
app: { ...app, calendar: { usage: 'Show events & meetings', remindersUsage: 'Manage tasks & plans' } },
|
|
377
|
+
})
|
|
378
|
+
expect(calendarPlist.content).toContain(
|
|
379
|
+
'<key>NSCalendarsFullAccessUsageDescription</key>\n\t<string>Show events & meetings</string>'
|
|
380
|
+
)
|
|
381
|
+
expect(calendarPlist.content).toContain(
|
|
382
|
+
'<key>NSRemindersFullAccessUsageDescription</key>\n\t<string>Manage tasks & plans</string>'
|
|
383
|
+
)
|
|
201
384
|
|
|
202
385
|
const androidManifest = renderPrebuildFile({
|
|
203
386
|
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
@@ -209,6 +392,12 @@ ${APP_DELEGATE_PBXPROJ}`,
|
|
|
209
392
|
expect(androidManifest.content).toContain(
|
|
210
393
|
'<uses-permission android:name="android.permission.CAMERA" />'
|
|
211
394
|
)
|
|
395
|
+
expect(androidManifest.content).toContain(
|
|
396
|
+
'<uses-permission android:name="android.permission.RECORD_AUDIO" />'
|
|
397
|
+
)
|
|
398
|
+
expect(androidManifest.content).toContain(
|
|
399
|
+
'<action android:name="android.speech.RecognitionService" />'
|
|
400
|
+
)
|
|
212
401
|
expect(androidManifest.content).toContain(
|
|
213
402
|
'<action android:name="android.intent.action.VIEW" />'
|
|
214
403
|
)
|
|
@@ -240,7 +429,8 @@ ${APP_DELEGATE_PBXPROJ}`,
|
|
|
240
429
|
const androidSettings = renderPrebuildFile({
|
|
241
430
|
relativePath: 'settings.gradle',
|
|
242
431
|
content: `pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
|
|
243
|
-
includeBuild('../node_modules/@react-native/gradle-plugin')
|
|
432
|
+
includeBuild('../node_modules/@react-native/gradle-plugin')
|
|
433
|
+
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }`,
|
|
244
434
|
platform: 'android',
|
|
245
435
|
app,
|
|
246
436
|
})
|
|
@@ -253,6 +443,48 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
253
443
|
expect(androidSettings.content).not.toContain('../node_modules')
|
|
254
444
|
})
|
|
255
445
|
|
|
446
|
+
it('stamps the pedometer motion permission from native.app', () => {
|
|
447
|
+
const pedometerPlist = renderPrebuildFile({
|
|
448
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
449
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
450
|
+
platform: 'ios',
|
|
451
|
+
app: { ...app, pedometer: { usage: 'Count steps & distance' } },
|
|
452
|
+
})
|
|
453
|
+
expect(pedometerPlist.content?.match(/<key>NSMotionUsageDescription<\/key>/g))
|
|
454
|
+
.toHaveLength(1)
|
|
455
|
+
expect(pedometerPlist.content).toContain(
|
|
456
|
+
'<key>NSMotionUsageDescription</key>\n\t<string>Count steps & distance</string>'
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
const noPedometerPlist = renderPrebuildFile({
|
|
460
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
461
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
462
|
+
platform: 'ios',
|
|
463
|
+
app,
|
|
464
|
+
})
|
|
465
|
+
expect(noPedometerPlist.content).not.toContain('NSMotionUsageDescription')
|
|
466
|
+
|
|
467
|
+
const pedometerManifest = renderPrebuildFile({
|
|
468
|
+
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
469
|
+
content:
|
|
470
|
+
'<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <activity>\n </activity>\n </application>',
|
|
471
|
+
platform: 'android',
|
|
472
|
+
app: { ...app, pedometer: { usage: 'Count your steps.' } },
|
|
473
|
+
})
|
|
474
|
+
expect(pedometerManifest.content).toContain(
|
|
475
|
+
'<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />'
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
const noPedometerManifest = renderPrebuildFile({
|
|
479
|
+
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
480
|
+
content:
|
|
481
|
+
'<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <activity>\n </activity>\n </application>',
|
|
482
|
+
platform: 'android',
|
|
483
|
+
app,
|
|
484
|
+
})
|
|
485
|
+
expect(noPedometerManifest.content).not.toContain('ACTIVITY_RECOGNITION')
|
|
486
|
+
})
|
|
487
|
+
|
|
256
488
|
it('stamps marketing and build versions from the manifest', () => {
|
|
257
489
|
const stamped = {
|
|
258
490
|
...app,
|
|
@@ -307,8 +539,37 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
307
539
|
expect(android.content).toContain('versionName "1.0"')
|
|
308
540
|
})
|
|
309
541
|
|
|
542
|
+
it('stamps the imagePicker microphone string for camera video', () => {
|
|
543
|
+
const video = {
|
|
544
|
+
...app,
|
|
545
|
+
speech: undefined,
|
|
546
|
+
notifications: undefined,
|
|
547
|
+
imagePicker: { camera: 'Capture photos', microphone: 'Record video & audio' },
|
|
548
|
+
}
|
|
549
|
+
const infoPlist = renderPrebuildFile({
|
|
550
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
551
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
552
|
+
platform: 'ios',
|
|
553
|
+
app: video,
|
|
554
|
+
})
|
|
555
|
+
expect(infoPlist.content).toContain('<key>NSCameraUsageDescription</key>')
|
|
556
|
+
expect(infoPlist.content).toContain(
|
|
557
|
+
'<key>NSMicrophoneUsageDescription</key>\n\t<string>Record video & audio</string>'
|
|
558
|
+
)
|
|
559
|
+
// the camera app records: android gains no microphone permission.
|
|
560
|
+
const androidManifest = renderPrebuildFile({
|
|
561
|
+
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
562
|
+
content:
|
|
563
|
+
'<manifest>\n <uses-permission android:name="android.permission.INTERNET" />',
|
|
564
|
+
platform: 'android',
|
|
565
|
+
app: video,
|
|
566
|
+
})
|
|
567
|
+
expect(androidManifest.content).toContain('android.permission.CAMERA')
|
|
568
|
+
expect(androidManifest.content).not.toContain('android.permission.RECORD_AUDIO')
|
|
569
|
+
})
|
|
570
|
+
|
|
310
571
|
it('omits camera entries when imagePicker.camera is unset', () => {
|
|
311
|
-
const bare = { ...app, imagePicker: undefined, notifications: undefined }
|
|
572
|
+
const bare = { ...app, imagePicker: undefined, speech: undefined, notifications: undefined }
|
|
312
573
|
const infoPlist = renderPrebuildFile({
|
|
313
574
|
relativePath: 'HelloWorld/Info.plist',
|
|
314
575
|
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
@@ -316,6 +577,7 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
316
577
|
app: bare,
|
|
317
578
|
})
|
|
318
579
|
expect(infoPlist.content).not.toContain('NSCameraUsageDescription')
|
|
580
|
+
expect(infoPlist.content).not.toContain('NSMicrophoneUsageDescription')
|
|
319
581
|
const androidManifest = renderPrebuildFile({
|
|
320
582
|
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
321
583
|
content:
|
|
@@ -324,6 +586,90 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
324
586
|
app: bare,
|
|
325
587
|
})
|
|
326
588
|
expect(androidManifest.content).not.toContain('android.permission.CAMERA')
|
|
589
|
+
expect(androidManifest.content).not.toContain('android.permission.RECORD_AUDIO')
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('stamps Android media permissions and markers from native.app', () => {
|
|
593
|
+
const media = {
|
|
594
|
+
...app,
|
|
595
|
+
audio: { microphone: 'Record audio', background: true },
|
|
596
|
+
photoLibrary: { addOnly: 'Save photos', readWrite: 'Read photos' },
|
|
597
|
+
contacts: { usage: 'Find people' },
|
|
598
|
+
calendar: { usage: 'Show events' },
|
|
599
|
+
} satisfies PrebuildAppConfig
|
|
600
|
+
const manifest = renderPrebuildFile({
|
|
601
|
+
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
602
|
+
content:
|
|
603
|
+
'<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <activity>\n </activity>\n </application>',
|
|
604
|
+
platform: 'android',
|
|
605
|
+
app: media,
|
|
606
|
+
})
|
|
607
|
+
for (const permission of [
|
|
608
|
+
'android.permission.RECORD_AUDIO',
|
|
609
|
+
'android.permission.READ_MEDIA_IMAGES',
|
|
610
|
+
'android.permission.READ_MEDIA_VIDEO',
|
|
611
|
+
'android.permission.READ_CONTACTS',
|
|
612
|
+
'android.permission.WRITE_CONTACTS',
|
|
613
|
+
'android.permission.READ_CALENDAR',
|
|
614
|
+
'android.permission.WRITE_CALENDAR',
|
|
615
|
+
'android.permission.FOREGROUND_SERVICE',
|
|
616
|
+
'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK',
|
|
617
|
+
]) {
|
|
618
|
+
expect(manifest.content).toContain(permission)
|
|
619
|
+
}
|
|
620
|
+
expect(manifest.content).toContain(
|
|
621
|
+
'<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />'
|
|
622
|
+
)
|
|
623
|
+
expect(manifest.content).toContain(
|
|
624
|
+
'<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />'
|
|
625
|
+
)
|
|
626
|
+
expect(manifest.content).toContain('one.photoLibrary.addOnly')
|
|
627
|
+
expect(manifest.content).toContain('one.audio.background')
|
|
628
|
+
expect(manifest.content).toContain('com.margelo.nitro.one.OneAudioService')
|
|
629
|
+
expect(manifest.content).toContain('android:foregroundServiceType="mediaPlayback"')
|
|
630
|
+
// speech stamps RECORD_AUDIO first; the media block must not duplicate it.
|
|
631
|
+
expect(
|
|
632
|
+
(manifest.content ?? '').split('android.permission.RECORD_AUDIO').length - 1
|
|
633
|
+
).toBe(1)
|
|
634
|
+
|
|
635
|
+
const bare = renderPrebuildFile({
|
|
636
|
+
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
637
|
+
content:
|
|
638
|
+
'<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <activity>\n </activity>\n </application>',
|
|
639
|
+
platform: 'android',
|
|
640
|
+
app: {
|
|
641
|
+
...app,
|
|
642
|
+
audio: undefined,
|
|
643
|
+
photoLibrary: undefined,
|
|
644
|
+
contacts: undefined,
|
|
645
|
+
calendar: undefined,
|
|
646
|
+
},
|
|
647
|
+
})
|
|
648
|
+
for (const permission of [
|
|
649
|
+
'android.permission.READ_MEDIA_IMAGES',
|
|
650
|
+
'android.permission.READ_CONTACTS',
|
|
651
|
+
'android.permission.READ_CALENDAR',
|
|
652
|
+
'android.permission.FOREGROUND_SERVICE',
|
|
653
|
+
]) {
|
|
654
|
+
expect(bare.content).not.toContain(permission)
|
|
655
|
+
}
|
|
656
|
+
expect(bare.content).not.toContain('one.photoLibrary.addOnly')
|
|
657
|
+
expect(bare.content).not.toContain('one.audio.background')
|
|
658
|
+
expect(bare.content).not.toContain('OneAudioService')
|
|
659
|
+
|
|
660
|
+
// reminders-only calendar needs no Android event permissions.
|
|
661
|
+
const remindersOnly = renderPrebuildFile({
|
|
662
|
+
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
663
|
+
content:
|
|
664
|
+
'<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <activity>\n </activity>\n </application>',
|
|
665
|
+
platform: 'android',
|
|
666
|
+
app: { ...app, calendar: { remindersUsage: 'Manage tasks' } },
|
|
667
|
+
})
|
|
668
|
+
expect(remindersOnly.content).not.toContain('android.permission.READ_CALENDAR')
|
|
669
|
+
expect(remindersOnly.content).toContain('android.permission.POST_NOTIFICATIONS')
|
|
670
|
+
expect(remindersOnly.content).toContain('android.permission.RECEIVE_BOOT_COMPLETED')
|
|
671
|
+
expect(remindersOnly.content).toContain('one.calendar.reminders')
|
|
672
|
+
expect(bare.content).not.toContain('one.calendar.reminders')
|
|
327
673
|
})
|
|
328
674
|
|
|
329
675
|
it('stamps the maps key and flag only when googleMapsApiKey is set', () => {
|
|
@@ -403,7 +749,7 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
403
749
|
})
|
|
404
750
|
|
|
405
751
|
it('fails loudly when a notification anchor is missing', () => {
|
|
406
|
-
const noCamera = { ...app, imagePicker: undefined }
|
|
752
|
+
const noCamera = { ...app, imagePicker: undefined, speech: undefined }
|
|
407
753
|
expect(() =>
|
|
408
754
|
renderPrebuildFile({
|
|
409
755
|
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
@@ -487,17 +833,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
|
487
833
|
|
|
488
834
|
return true
|
|
489
835
|
}
|
|
490
|
-
}
|
|
836
|
+
}
|
|
837
|
+
class ReactNativeDelegate:${templateRnDelegate}`,
|
|
491
838
|
platform: 'ios',
|
|
492
839
|
app: push,
|
|
493
840
|
})
|
|
494
|
-
|
|
841
|
+
// one hooks the delegate's push callbacks at launch; the template
|
|
842
|
+
// carries no forward of its own.
|
|
843
|
+
expect(appDelegate.content).not.toContain(
|
|
495
844
|
'didRegisterForRemoteNotificationsWithDeviceToken'
|
|
496
845
|
)
|
|
497
|
-
expect(appDelegate.content).toContain('OneNativePushTokenDidRegister')
|
|
498
|
-
expect(appDelegate.content).toContain(
|
|
499
|
-
'didFailToRegisterForRemoteNotificationsWithError'
|
|
500
|
-
)
|
|
501
846
|
|
|
502
847
|
const bareProps = renderPrebuildFile({
|
|
503
848
|
relativePath: 'gradle.properties',
|
|
@@ -565,11 +910,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
|
565
910
|
app,
|
|
566
911
|
})
|
|
567
912
|
).toThrow(
|
|
568
|
-
'[vxrn] cannot stamp the camera permission: expected the INTERNET permission in app/src/main/AndroidManifest.xml'
|
|
913
|
+
'[vxrn] cannot stamp the camera or microphone permission: expected the INTERNET permission in app/src/main/AndroidManifest.xml'
|
|
569
914
|
)
|
|
570
915
|
})
|
|
571
916
|
|
|
572
|
-
it('resolves
|
|
917
|
+
it('resolves build tools from their owning packages without hoisting', () => {
|
|
573
918
|
const root = mkdtempSync(join(tmpdir(), 'vxrn-gradle-plugin-'))
|
|
574
919
|
const settingsDir = join(root, 'android')
|
|
575
920
|
mkdirSync(settingsDir, { recursive: true })
|
|
@@ -592,10 +937,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
|
592
937
|
JSON.stringify({ name: '@react-native/gradle-plugin', version: '0.0.0' })
|
|
593
938
|
)
|
|
594
939
|
|
|
940
|
+
const cliPath = join(
|
|
941
|
+
root,
|
|
942
|
+
'node_modules',
|
|
943
|
+
'vxrn',
|
|
944
|
+
'node_modules',
|
|
945
|
+
'@react-native-community',
|
|
946
|
+
'cli',
|
|
947
|
+
'build',
|
|
948
|
+
'bin.js'
|
|
949
|
+
)
|
|
950
|
+
mkdirSync(dirname(cliPath), { recursive: true })
|
|
951
|
+
writeFileSync(
|
|
952
|
+
join(root, 'node_modules', 'vxrn', 'package.json'),
|
|
953
|
+
JSON.stringify({ name: 'vxrn', version: '0.0.0' })
|
|
954
|
+
)
|
|
955
|
+
writeFileSync(
|
|
956
|
+
cliPath,
|
|
957
|
+
"if (process.argv[2] !== 'config') process.exit(1); console.log(JSON.stringify({root: process.cwd(), dependencies: {}}))"
|
|
958
|
+
)
|
|
959
|
+
|
|
595
960
|
const rendered = renderPrebuildFile({
|
|
596
961
|
relativePath: 'settings.gradle',
|
|
597
962
|
content: `pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
|
|
598
|
-
includeBuild('../node_modules/@react-native/gradle-plugin')
|
|
963
|
+
includeBuild('../node_modules/@react-native/gradle-plugin')
|
|
964
|
+
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }`,
|
|
599
965
|
platform: 'android',
|
|
600
966
|
app,
|
|
601
967
|
})
|
|
@@ -608,6 +974,98 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
608
974
|
encoding: 'utf8',
|
|
609
975
|
}).trim()
|
|
610
976
|
expect(resolved).toBe(realpathSync(join(nested, 'package.json')))
|
|
977
|
+
const cliScript = [...(rendered.content ?? '').matchAll(/"--print", "([^"]+)"/g)]
|
|
978
|
+
.map((match) => match[1])
|
|
979
|
+
.find((script) => script.includes('@react-native-community/cli/build/bin.js'))
|
|
980
|
+
if (!cliScript) throw new Error('expected installed CLI resolution script')
|
|
981
|
+
const resolvedCli = execFileSync(process.execPath, ['--print', cliScript], {
|
|
982
|
+
cwd: settingsDir,
|
|
983
|
+
encoding: 'utf8',
|
|
984
|
+
}).trim()
|
|
985
|
+
const cliConfig = JSON.parse(
|
|
986
|
+
execFileSync(process.execPath, [resolvedCli, 'config'], {
|
|
987
|
+
cwd: root,
|
|
988
|
+
encoding: 'utf8',
|
|
989
|
+
})
|
|
990
|
+
)
|
|
991
|
+
expect(cliConfig).toEqual({ root: realpathSync(root), dependencies: {} })
|
|
992
|
+
|
|
993
|
+
const patches = createRequire(import.meta.url)('../../native-project-patches.cjs')
|
|
994
|
+
const iosPhase = patches.addSetCliPathToBundleReactNativeShellScript(
|
|
995
|
+
'`"$NODE_BINARY" --print "react-native-xcode.sh"`'
|
|
996
|
+
)
|
|
997
|
+
const cliExport = iosPhase
|
|
998
|
+
.split('\n')
|
|
999
|
+
.find((line) => line.startsWith('export CLI_PATH='))
|
|
1000
|
+
if (!cliExport) throw new Error('expected iOS CLI export')
|
|
1001
|
+
const iosConfig = JSON.parse(
|
|
1002
|
+
execFileSync('bash', ['-c', `${cliExport}\n"$NODE_BINARY" "$CLI_PATH" config`], {
|
|
1003
|
+
cwd: root,
|
|
1004
|
+
env: { ...process.env, NODE_BINARY: process.execPath },
|
|
1005
|
+
encoding: 'utf8',
|
|
1006
|
+
})
|
|
1007
|
+
)
|
|
1008
|
+
expect(iosConfig).toEqual(cliConfig)
|
|
1009
|
+
|
|
1010
|
+
writeFileSync(
|
|
1011
|
+
join(dirname(dirname(cliPath)), 'package.json'),
|
|
1012
|
+
JSON.stringify({ name: '@react-native-community/cli', main: 'index.js' })
|
|
1013
|
+
)
|
|
1014
|
+
writeFileSync(
|
|
1015
|
+
join(dirname(dirname(cliPath)), 'index.js'),
|
|
1016
|
+
"exports.run = () => require('./build/bin.js')"
|
|
1017
|
+
)
|
|
1018
|
+
const podfile = renderPrebuildFile({
|
|
1019
|
+
relativePath: 'Podfile',
|
|
1020
|
+
content:
|
|
1021
|
+
"target 'HelloWorld' do\n config = use_native_modules!\n post_install do |installer|\n react_native_post_install(installer)\n end\nend",
|
|
1022
|
+
platform: 'ios',
|
|
1023
|
+
app,
|
|
1024
|
+
})
|
|
1025
|
+
const podScript = podfile.content?.match(
|
|
1026
|
+
/use_native_modules!\(\['node', '-e', "([^"\n]+)"\]\)/
|
|
1027
|
+
)?.[1]
|
|
1028
|
+
if (!podScript) throw new Error('expected iOS pod discovery command')
|
|
1029
|
+
expect(
|
|
1030
|
+
JSON.parse(
|
|
1031
|
+
execFileSync(process.execPath, ['-e', podScript], {
|
|
1032
|
+
cwd: root,
|
|
1033
|
+
encoding: 'utf8',
|
|
1034
|
+
})
|
|
1035
|
+
)
|
|
1036
|
+
).toEqual(cliConfig)
|
|
1037
|
+
expect(() =>
|
|
1038
|
+
execFileSync(
|
|
1039
|
+
process.execPath,
|
|
1040
|
+
[
|
|
1041
|
+
'-e',
|
|
1042
|
+
"process.argv=['', '', 'config'];require('@react-native-community/cli').run()",
|
|
1043
|
+
],
|
|
1044
|
+
{ cwd: root, stdio: 'pipe' }
|
|
1045
|
+
)
|
|
1046
|
+
).toThrow()
|
|
1047
|
+
|
|
1048
|
+
const androidReact = patches.replaceAppBuildGradleReactBlock('react {\n}\n')
|
|
1049
|
+
const bundleResolver = [
|
|
1050
|
+
...androidReact.matchAll(/commandLine\("node", "--print", "([^"\n]+)"\)/g),
|
|
1051
|
+
]
|
|
1052
|
+
.map((match) => match[1])
|
|
1053
|
+
.find((script) => script.includes('@react-native-community/cli/build/bin.js'))
|
|
1054
|
+
if (!bundleResolver) throw new Error('expected Android bundle CLI resolver')
|
|
1055
|
+
expect(
|
|
1056
|
+
execFileSync(process.execPath, ['--print', bundleResolver], {
|
|
1057
|
+
cwd: settingsDir,
|
|
1058
|
+
encoding: 'utf8',
|
|
1059
|
+
}).trim()
|
|
1060
|
+
).toBe(resolvedCli)
|
|
1061
|
+
|
|
1062
|
+
expect(() =>
|
|
1063
|
+
execFileSync(
|
|
1064
|
+
process.execPath,
|
|
1065
|
+
['--print', "require.resolve('@react-native-community/cli/build/bin.js')"],
|
|
1066
|
+
{ cwd: root, stdio: 'pipe' }
|
|
1067
|
+
)
|
|
1068
|
+
).toThrow()
|
|
611
1069
|
|
|
612
1070
|
// negative control: a bare top-level resolution fails here, proving the
|
|
613
1071
|
// fixture is genuinely non-hoisted and the old snippet would break
|
|
@@ -716,8 +1174,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
|
716
1174
|
const rendered = renderPrebuildFile({
|
|
717
1175
|
relativePath: 'HelloWorld/AppDelegate.swift',
|
|
718
1176
|
content: `${templateAppDelegate}
|
|
719
|
-
class ReactNativeDelegate
|
|
720
|
-
}`,
|
|
1177
|
+
class ReactNativeDelegate:${templateRnDelegate}`,
|
|
721
1178
|
platform: 'ios',
|
|
722
1179
|
app,
|
|
723
1180
|
})
|
|
@@ -726,10 +1183,18 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
|
|
726
1183
|
expect(rendered.content).not.toContain('startReactNative')
|
|
727
1184
|
expect(rendered.content).not.toContain('UIWindow(frame:')
|
|
728
1185
|
expect(rendered.content).not.toContain('var window')
|
|
729
|
-
expect(rendered.content).
|
|
730
|
-
expect(rendered.content).toContain('
|
|
731
|
-
expect(rendered.content).toContain('
|
|
732
|
-
|
|
1186
|
+
expect(rendered.content).toContain('var reactNativeFactory')
|
|
1187
|
+
expect(rendered.content).toContain('override func customize(_ rootView: RCTRootView)')
|
|
1188
|
+
expect(rendered.content).toContain('OneHoldLaunchScreen(rootView)')
|
|
1189
|
+
})
|
|
1190
|
+
|
|
1191
|
+
it('rejects a template that cannot request dev bytecode', () => {
|
|
1192
|
+
expect(() => renderPrebuildFile({
|
|
1193
|
+
relativePath: 'HelloWorld/AppDelegate.swift',
|
|
1194
|
+
content: `${templateAppDelegate}class ReactNativeDelegate:${templateRnDelegate.replace('forBundleRoot: "index"', 'forBundleRoot: "changed"')}`,
|
|
1195
|
+
platform: 'ios',
|
|
1196
|
+
app,
|
|
1197
|
+
})).toThrow('expected the template bundle URL provider')
|
|
733
1198
|
})
|
|
734
1199
|
|
|
735
1200
|
it('throws instead of shipping a non-scene AppDelegate', () => {
|
|
@@ -764,6 +1229,7 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
|
|
764
1229
|
expect(sceneDelegate).toContain('openURLContexts')
|
|
765
1230
|
expect(sceneDelegate).toContain('continue userActivity')
|
|
766
1231
|
expect(sceneDelegate).toContain('RCTLinkingManager')
|
|
1232
|
+
expect(sceneDelegate).toContain('appDelegate.reactNativeFactory')
|
|
767
1233
|
})
|
|
768
1234
|
|
|
769
1235
|
it('throws when the template loses a scene anchor', () => {
|
|
@@ -785,10 +1251,79 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
|
|
785
1251
|
).toThrow('AppDelegate.swift anchor')
|
|
786
1252
|
})
|
|
787
1253
|
|
|
1254
|
+
it('bundles native.app fonts into both platforms from the real template', async () => {
|
|
1255
|
+
const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
|
1256
|
+
const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-fonts-'))
|
|
1257
|
+
const fonts = ['tests/native-features/assets/OneNativeTestFont-BlockB.ttf']
|
|
1258
|
+
await generateForPlatform(workspaceRoot, 'ios', { ...app, fonts }, join(output, 'ios'))
|
|
1259
|
+
await generateForPlatform(workspaceRoot, 'android', { ...app, fonts }, join(output, 'android'))
|
|
1260
|
+
|
|
1261
|
+
expect(existsSync(join(output, 'ios', 'MyApp', 'OneNativeTestFont-BlockB.ttf'))).toBe(true)
|
|
1262
|
+
const infoPlist = readFileSync(join(output, 'ios', 'MyApp', 'Info.plist'), 'utf8')
|
|
1263
|
+
expect(infoPlist).toContain(
|
|
1264
|
+
'<key>UIAppFonts</key>\n\t<array>\n\t\t<string>OneNativeTestFont-BlockB.ttf</string>'
|
|
1265
|
+
)
|
|
1266
|
+
const pbxproj = readFileSync(
|
|
1267
|
+
join(output, 'ios', 'MyApp.xcodeproj', 'project.pbxproj'),
|
|
1268
|
+
'utf8'
|
|
1269
|
+
)
|
|
1270
|
+
// file reference, group child, build file and resources phase entry.
|
|
1271
|
+
expect(pbxproj.split('/* OneNativeTestFont-BlockB.ttf */').length - 1).toBe(3)
|
|
1272
|
+
expect(pbxproj.split('/* OneNativeTestFont-BlockB.ttf in Resources */').length - 1).toBe(2)
|
|
1273
|
+
expect(pbxproj).toContain('path = MyApp/OneNativeTestFont-BlockB.ttf;')
|
|
1274
|
+
expect(
|
|
1275
|
+
existsSync(
|
|
1276
|
+
join(output, 'android', 'app', 'src', 'main', 'assets', 'fonts', 'OneNativeTestFont-BlockB.ttf')
|
|
1277
|
+
)
|
|
1278
|
+
).toBe(true)
|
|
1279
|
+
})
|
|
1280
|
+
|
|
1281
|
+
it('bundles firebase config files and applies the google-services plugin', async () => {
|
|
1282
|
+
const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
|
1283
|
+
const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-firebase-'))
|
|
1284
|
+
const plist = join(output, 'firebase-ios.plist')
|
|
1285
|
+
const json = join(output, 'firebase-android.json')
|
|
1286
|
+
writeFileSync(plist, '<plist><dict/></plist>\n')
|
|
1287
|
+
writeFileSync(json, '{}\n')
|
|
1288
|
+
const firebase = {
|
|
1289
|
+
...app,
|
|
1290
|
+
ios: { ...app.ios, googleServicesFile: plist },
|
|
1291
|
+
android: { ...app.android, googleServicesFile: json },
|
|
1292
|
+
}
|
|
1293
|
+
await generateForPlatform(workspaceRoot, 'ios', firebase, join(output, 'ios'))
|
|
1294
|
+
await generateForPlatform(workspaceRoot, 'android', firebase, join(output, 'android'))
|
|
1295
|
+
|
|
1296
|
+
expect(readFileSync(join(output, 'ios', 'MyApp', 'GoogleService-Info.plist'), 'utf8')).toBe(
|
|
1297
|
+
'<plist><dict/></plist>\n'
|
|
1298
|
+
)
|
|
1299
|
+
const pbxproj = readFileSync(
|
|
1300
|
+
join(output, 'ios', 'MyApp.xcodeproj', 'project.pbxproj'),
|
|
1301
|
+
'utf8'
|
|
1302
|
+
)
|
|
1303
|
+
expect(pbxproj).toContain(
|
|
1304
|
+
'lastKnownFileType = text.plist.xml; name = GoogleService-Info.plist; path = MyApp/GoogleService-Info.plist;'
|
|
1305
|
+
)
|
|
1306
|
+
expect(pbxproj.split('/* GoogleService-Info.plist in Resources */').length - 1).toBe(2)
|
|
1307
|
+
expect(readFileSync(join(output, 'android', 'app', 'google-services.json'), 'utf8')).toBe(
|
|
1308
|
+
'{}\n'
|
|
1309
|
+
)
|
|
1310
|
+
expect(readFileSync(join(output, 'android', 'build.gradle'), 'utf8')).toContain(
|
|
1311
|
+
'classpath("com.google.gms:google-services:4.4.4")'
|
|
1312
|
+
)
|
|
1313
|
+
expect(readFileSync(join(output, 'android', 'app', 'build.gradle'), 'utf8')).toContain(
|
|
1314
|
+
'apply plugin: "com.facebook.react"\napply plugin: "com.google.gms.google-services"'
|
|
1315
|
+
)
|
|
1316
|
+
})
|
|
1317
|
+
|
|
788
1318
|
it('generates a scene project from the real template', async () => {
|
|
789
1319
|
const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
|
790
1320
|
const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-scene-'))
|
|
791
|
-
await generateForPlatform(
|
|
1321
|
+
await generateForPlatform(
|
|
1322
|
+
workspaceRoot,
|
|
1323
|
+
'ios',
|
|
1324
|
+
{ ...app, orientation: 'default' },
|
|
1325
|
+
join(output, 'ios')
|
|
1326
|
+
)
|
|
792
1327
|
|
|
793
1328
|
const sceneDelegate = readFileSync(
|
|
794
1329
|
join(output, 'ios', 'MyApp', 'SceneDelegate.swift'),
|
|
@@ -807,6 +1342,21 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
|
|
807
1342
|
const infoPlist = readFileSync(join(output, 'ios', 'MyApp', 'Info.plist'), 'utf8')
|
|
808
1343
|
expect(infoPlist).toContain('<key>UIApplicationSceneManifest</key>')
|
|
809
1344
|
expect(infoPlist).toContain('<key>OneNativeNotificationsEnabled</key>')
|
|
1345
|
+
// orientation default opens the phone list to all four; ipad keeps its own.
|
|
1346
|
+
const phoneOrientations = infoPlist
|
|
1347
|
+
.split('<key>UISupportedInterfaceOrientations</key>')[1]
|
|
1348
|
+
.split('</array>')[0]
|
|
1349
|
+
for (const orientation of [
|
|
1350
|
+
'Portrait',
|
|
1351
|
+
'PortraitUpsideDown',
|
|
1352
|
+
'LandscapeLeft',
|
|
1353
|
+
'LandscapeRight',
|
|
1354
|
+
]) {
|
|
1355
|
+
expect(phoneOrientations).toContain(
|
|
1356
|
+
`<string>UIInterfaceOrientation${orientation}</string>`
|
|
1357
|
+
)
|
|
1358
|
+
}
|
|
1359
|
+
expect(infoPlist).toContain('<key>UISupportedInterfaceOrientations~ipad</key>')
|
|
810
1360
|
|
|
811
1361
|
const project = readFileSync(
|
|
812
1362
|
join(output, 'ios', 'MyApp.xcodeproj', 'project.pbxproj'),
|
|
@@ -856,28 +1406,23 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
|
|
856
1406
|
expect(entitlements).toContain('group.dev.one.myapp')
|
|
857
1407
|
expect(entitlements).toContain('<key>aps-environment</key>')
|
|
858
1408
|
expect(() => statSync(join(output, 'ios', 'MyApp', 'MyApp.entitlements'))).toThrow()
|
|
1409
|
+
expect(readFileSync(join(output, 'ios', 'MyApp', 'Info.plist'), 'utf8')).toContain(
|
|
1410
|
+
'<key>OneNativeNotificationsPush</key>'
|
|
1411
|
+
)
|
|
859
1412
|
}, 180000)
|
|
860
1413
|
})
|
|
861
1414
|
|
|
862
1415
|
describe('community autolink inventory', () => {
|
|
863
|
-
it('discovers
|
|
1416
|
+
it('generates both projects and discovers native packages without an app cli', async () => {
|
|
864
1417
|
const root = mkdtempSync(join(tmpdir(), 'vxrn-autolink-'))
|
|
865
|
-
//
|
|
866
|
-
//
|
|
1418
|
+
// the app owns its template and native packages, while vxrn owns the cli.
|
|
1419
|
+
// link installed inputs so this strict app layout needs no network.
|
|
867
1420
|
const workspaceModules = fileURLToPath(
|
|
868
1421
|
new URL('../../../../node_modules', import.meta.url)
|
|
869
1422
|
)
|
|
870
1423
|
mkdirSync(join(root, 'node_modules', '@react-native-community'), { recursive: true })
|
|
871
1424
|
const { symlinkSync } = await import('node:fs')
|
|
872
|
-
for (const name of [
|
|
873
|
-
'cli',
|
|
874
|
-
'cli-config',
|
|
875
|
-
'cli-config-android',
|
|
876
|
-
'cli-config-apple',
|
|
877
|
-
'cli-tools',
|
|
878
|
-
'cli-types',
|
|
879
|
-
'template',
|
|
880
|
-
]) {
|
|
1425
|
+
for (const name of ['template']) {
|
|
881
1426
|
symlinkSync(
|
|
882
1427
|
join(workspaceModules, '@react-native-community', name),
|
|
883
1428
|
join(root, 'node_modules', '@react-native-community', name)
|
|
@@ -987,8 +1532,22 @@ describe('generateForPlatform determinism', () => {
|
|
|
987
1532
|
const appWithIcon = {
|
|
988
1533
|
...app,
|
|
989
1534
|
version: '9.9.9',
|
|
990
|
-
ios: { ...app.ios, buildNumber: '4242'
|
|
991
|
-
|
|
1535
|
+
ios: { ...app.ios, buildNumber: '4242', tablet: true, alternateIcons: {
|
|
1536
|
+
TestAlternate: {
|
|
1537
|
+
source: fileURLToPath(new URL('../../../../examples/one-basic/public/app-icon.png', import.meta.url)),
|
|
1538
|
+
backgroundColor: '#123456',
|
|
1539
|
+
},
|
|
1540
|
+
} },
|
|
1541
|
+
android: {
|
|
1542
|
+
...app.android,
|
|
1543
|
+
versionCode: 4242,
|
|
1544
|
+
adaptiveIcon: {
|
|
1545
|
+
foreground: fileURLToPath(
|
|
1546
|
+
new URL('../../../../examples/one-basic/public/app-icon.png', import.meta.url)
|
|
1547
|
+
),
|
|
1548
|
+
backgroundColor: '#123456',
|
|
1549
|
+
},
|
|
1550
|
+
},
|
|
992
1551
|
icon: {
|
|
993
1552
|
source: fileURLToPath(
|
|
994
1553
|
new URL('../../../../examples/one-basic/public/app-icon.png', import.meta.url)
|
|
@@ -1040,6 +1599,8 @@ describe('generateForPlatform determinism', () => {
|
|
|
1040
1599
|
expect(generatedPbxproj).toContain('CURRENT_PROJECT_VERSION = 4242;')
|
|
1041
1600
|
expect(generatedPbxproj).not.toContain('MARKETING_VERSION = 1.0;')
|
|
1042
1601
|
expect(generatedPbxproj).not.toContain('CURRENT_PROJECT_VERSION = 1;')
|
|
1602
|
+
expect(generatedPbxproj.match(/ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "TestAlternate";/g))
|
|
1603
|
+
.toHaveLength(2)
|
|
1043
1604
|
const generatedGradle = readFileSync(
|
|
1044
1605
|
join(output, 'android', 'app', 'build.gradle'),
|
|
1045
1606
|
'utf8'
|
|
@@ -1057,10 +1618,23 @@ describe('generateForPlatform determinism', () => {
|
|
|
1057
1618
|
const iosContents: { images: Array<{ filename?: string }> } = JSON.parse(
|
|
1058
1619
|
readFileSync(join(iosIconDir, 'Contents.json'), 'utf8')
|
|
1059
1620
|
)
|
|
1060
|
-
expect(iosContents.images).toHaveLength(
|
|
1621
|
+
expect(iosContents.images).toHaveLength(18)
|
|
1061
1622
|
expect(iosContents.images.every((image) => image.filename)).toBe(true)
|
|
1623
|
+
// a tablet build ships the iPad sizes App Store upload requires
|
|
1624
|
+
expect(await sharp(join(iosIconDir, 'icon-76@2x.png')).metadata())
|
|
1625
|
+
.toMatchObject({ width: 152, height: 152 })
|
|
1626
|
+
expect(await sharp(join(iosIconDir, 'icon-83.5@2x.png')).metadata())
|
|
1627
|
+
.toMatchObject({ width: 167, height: 167 })
|
|
1062
1628
|
const iosMarketing = await sharp(join(iosIconDir, 'icon-1024.png')).metadata()
|
|
1063
1629
|
expect(iosMarketing).toMatchObject({ width: 1024, height: 1024, hasAlpha: false })
|
|
1630
|
+
const alternateIconDir = join(output, 'ios', 'MyApp', 'Images.xcassets', 'TestAlternate.appiconset')
|
|
1631
|
+
const alternateContents: { images: Array<{ filename?: string }> } = JSON.parse(
|
|
1632
|
+
readFileSync(join(alternateIconDir, 'Contents.json'), 'utf8')
|
|
1633
|
+
)
|
|
1634
|
+
expect(alternateContents.images).toHaveLength(18)
|
|
1635
|
+
expect(alternateContents.images.every((image) => image.filename)).toBe(true)
|
|
1636
|
+
expect(await sharp(join(alternateIconDir, 'icon-1024.png')).metadata())
|
|
1637
|
+
.toMatchObject({ width: 1024, height: 1024, hasAlpha: false })
|
|
1064
1638
|
|
|
1065
1639
|
const androidIcon = await sharp(
|
|
1066
1640
|
join(
|
|
@@ -1076,11 +1650,16 @@ describe('generateForPlatform determinism', () => {
|
|
|
1076
1650
|
).metadata()
|
|
1077
1651
|
expect(androidIcon).toMatchObject({ width: 192, height: 192 })
|
|
1078
1652
|
const iosApp = join(output, 'ios', 'MyApp')
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1653
|
+
// contained artwork is drawn at its launch width in points, at 1x to 3x
|
|
1654
|
+
for (const [filename, width] of [
|
|
1655
|
+
['splash.png', 200],
|
|
1656
|
+
['splash@2x.png', 400],
|
|
1657
|
+
['splash@3x.png', 600],
|
|
1658
|
+
] as const) {
|
|
1659
|
+
expect(
|
|
1660
|
+
await sharp(join(iosApp, 'Images.xcassets', 'Splash.imageset', filename)).metadata()
|
|
1661
|
+
).toMatchObject({ width })
|
|
1662
|
+
}
|
|
1084
1663
|
const launchStoryboard = readFileSync(join(iosApp, 'LaunchScreen.storyboard'), 'utf8')
|
|
1085
1664
|
expect(launchStoryboard).toContain('image="Splash"')
|
|
1086
1665
|
expect(launchStoryboard).toContain('firstAttribute="centerX"')
|
|
@@ -1113,6 +1692,21 @@ describe('generateForPlatform determinism', () => {
|
|
|
1113
1692
|
expect(readFileSync(join(androidRes, 'values', 'styles.xml'), 'utf8')).toContain(
|
|
1114
1693
|
'@drawable/launch_screen'
|
|
1115
1694
|
)
|
|
1695
|
+
// the adaptive launcher icon: 108dp layers per density, a color background
|
|
1696
|
+
// in its own resource, and both launcher xmls wired to them
|
|
1697
|
+
expect(
|
|
1698
|
+
await sharp(join(androidRes, 'mipmap-xxxhdpi', 'ic_launcher_foreground.png')).metadata()
|
|
1699
|
+
).toMatchObject({ width: 432, height: 432 })
|
|
1700
|
+
expect(() => statSync(join(androidRes, 'mipmap-mdpi', 'ic_launcher_background.png'))).toThrow()
|
|
1701
|
+
expect(readFileSync(join(androidRes, 'values', 'ic_launcher_background.xml'), 'utf8')).toContain(
|
|
1702
|
+
'<color name="ic_launcher_background">#123456</color>'
|
|
1703
|
+
)
|
|
1704
|
+
for (const filename of ['ic_launcher.xml', 'ic_launcher_round.xml']) {
|
|
1705
|
+
const adaptive = readFileSync(join(androidRes, 'mipmap-anydpi-v26', filename), 'utf8')
|
|
1706
|
+
expect(adaptive).toContain('<background android:drawable="@color/ic_launcher_background"/>')
|
|
1707
|
+
expect(adaptive).toContain('<foreground android:drawable="@mipmap/ic_launcher_foreground"/>')
|
|
1708
|
+
expect(adaptive).not.toContain('monochrome')
|
|
1709
|
+
}
|
|
1116
1710
|
// android 12+ uses the configured splash image, never the launcher icon
|
|
1117
1711
|
const stylesV31 = readFileSync(join(androidRes, 'values-v31', 'styles.xml'), 'utf8')
|
|
1118
1712
|
expect(stylesV31).toContain('@color/splash_background')
|
|
@@ -1122,6 +1716,188 @@ describe('generateForPlatform determinism', () => {
|
|
|
1122
1716
|
expect(stylesV31).not.toContain('ic_launcher')
|
|
1123
1717
|
}, 180000)
|
|
1124
1718
|
|
|
1719
|
+
it('fills the ios launch screen with an untrimmed cover splash', async () => {
|
|
1720
|
+
const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
|
1721
|
+
const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-cover-'))
|
|
1722
|
+
const source = fileURLToPath(
|
|
1723
|
+
new URL('../../../../examples/one-basic/public/splash.png', import.meta.url)
|
|
1724
|
+
)
|
|
1725
|
+
await generateForPlatform(
|
|
1726
|
+
workspaceRoot,
|
|
1727
|
+
'ios',
|
|
1728
|
+
{ ...app, splash: { source, backgroundColor: '#000000', resizeMode: 'cover' } },
|
|
1729
|
+
join(output, 'ios')
|
|
1730
|
+
)
|
|
1731
|
+
const iosApp = join(output, 'ios', 'MyApp')
|
|
1732
|
+
const { width, height } = await sharp(source).metadata()
|
|
1733
|
+
expect(
|
|
1734
|
+
await sharp(join(iosApp, 'Images.xcassets', 'Splash.imageset', 'splash.png')).metadata()
|
|
1735
|
+
).toMatchObject({ width, height })
|
|
1736
|
+
const launchStoryboard = readFileSync(join(iosApp, 'LaunchScreen.storyboard'), 'utf8')
|
|
1737
|
+
expect(launchStoryboard).toContain('contentMode="scaleAspectFill" image="Splash"')
|
|
1738
|
+
for (const edge of ['leading', 'trailing', 'top', 'bottom']) {
|
|
1739
|
+
expect(launchStoryboard).toContain(`firstAttribute="${edge}" secondItem="launch-view"`)
|
|
1740
|
+
}
|
|
1741
|
+
expect(launchStoryboard).not.toContain('firstAttribute="width" constant')
|
|
1742
|
+
}, 180000)
|
|
1743
|
+
|
|
1744
|
+
it('gives a dark launch its own background and artwork on both platforms', async () => {
|
|
1745
|
+
const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
|
1746
|
+
const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-dark-'))
|
|
1747
|
+
const source = fileURLToPath(
|
|
1748
|
+
new URL('../../../../examples/one-basic/public/splash.png', import.meta.url)
|
|
1749
|
+
)
|
|
1750
|
+
const splash = {
|
|
1751
|
+
source,
|
|
1752
|
+
backgroundColor: '#ffffff',
|
|
1753
|
+
dark: { source, backgroundColor: '#000000' },
|
|
1754
|
+
}
|
|
1755
|
+
await generateForPlatform(workspaceRoot, 'ios', { ...app, splash }, join(output, 'ios'))
|
|
1756
|
+
await generateForPlatform(workspaceRoot, 'android', { ...app, splash }, join(output, 'android'))
|
|
1757
|
+
|
|
1758
|
+
const assets = join(output, 'ios', 'MyApp', 'Images.xcassets')
|
|
1759
|
+
const imageset = JSON.parse(
|
|
1760
|
+
readFileSync(join(assets, 'Splash.imageset', 'Contents.json'), 'utf8')
|
|
1761
|
+
)
|
|
1762
|
+
expect(imageset.images).toContainEqual({
|
|
1763
|
+
appearances: [{ appearance: 'luminosity', value: 'dark' }],
|
|
1764
|
+
filename: 'splash-dark@3x.png',
|
|
1765
|
+
idiom: 'universal',
|
|
1766
|
+
scale: '3x',
|
|
1767
|
+
})
|
|
1768
|
+
expect(existsSync(join(assets, 'Splash.imageset', 'splash-dark.png'))).toBe(true)
|
|
1769
|
+
const colorset = JSON.parse(
|
|
1770
|
+
readFileSync(join(assets, 'SplashBackground.colorset', 'Contents.json'), 'utf8')
|
|
1771
|
+
)
|
|
1772
|
+
expect(colorset.colors[0].color.components).toMatchObject({ red: '1.000' })
|
|
1773
|
+
expect(colorset.colors[1]).toMatchObject({
|
|
1774
|
+
appearances: [{ appearance: 'luminosity', value: 'dark' }],
|
|
1775
|
+
color: { components: { red: '0.000', green: '0.000', blue: '0.000' } },
|
|
1776
|
+
})
|
|
1777
|
+
const storyboard = readFileSync(
|
|
1778
|
+
join(output, 'ios', 'MyApp', 'LaunchScreen.storyboard'),
|
|
1779
|
+
'utf8'
|
|
1780
|
+
)
|
|
1781
|
+
expect(storyboard).toContain('<color key="backgroundColor" name="SplashBackground"/>')
|
|
1782
|
+
expect(storyboard).toContain('<namedColor name="SplashBackground">')
|
|
1783
|
+
|
|
1784
|
+
const res = join(output, 'android', 'app', 'src', 'main', 'res')
|
|
1785
|
+
expect(readFileSync(join(res, 'values-night', 'colors.xml'), 'utf8')).toContain(
|
|
1786
|
+
'<color name="splash_background">#000000</color>'
|
|
1787
|
+
)
|
|
1788
|
+
expect(
|
|
1789
|
+
await sharp(join(res, 'drawable-night-xxxhdpi', 'splash.png')).metadata()
|
|
1790
|
+
).toMatchObject({ width: 1152, height: 1152 })
|
|
1791
|
+
}, 180000)
|
|
1792
|
+
|
|
1793
|
+
it('lays a full-bleed background image under the ios launch artwork', async () => {
|
|
1794
|
+
const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
|
1795
|
+
const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-splash-bg-'))
|
|
1796
|
+
const source = fileURLToPath(
|
|
1797
|
+
new URL('../../../../examples/one-basic/public/splash.png', import.meta.url)
|
|
1798
|
+
)
|
|
1799
|
+
const backgroundImage = join(output, 'ground.png')
|
|
1800
|
+
await sharp({
|
|
1801
|
+
create: { width: 30, height: 60, channels: 3, background: '#ececec' },
|
|
1802
|
+
})
|
|
1803
|
+
.png()
|
|
1804
|
+
.toFile(backgroundImage)
|
|
1805
|
+
const splash = {
|
|
1806
|
+
source,
|
|
1807
|
+
backgroundColor: '#ececec',
|
|
1808
|
+
width: 60,
|
|
1809
|
+
backgroundImage,
|
|
1810
|
+
dark: { backgroundColor: '#111111', backgroundImage },
|
|
1811
|
+
}
|
|
1812
|
+
await generateForPlatform(workspaceRoot, 'ios', { ...app, splash }, join(output, 'ios'))
|
|
1813
|
+
const assets = join(output, 'ios', 'MyApp', 'Images.xcassets')
|
|
1814
|
+
const background = JSON.parse(
|
|
1815
|
+
readFileSync(join(assets, 'SplashBackgroundImage.imageset', 'Contents.json'), 'utf8')
|
|
1816
|
+
)
|
|
1817
|
+
expect(background.images.map((image: { filename: string }) => image.filename)).toEqual([
|
|
1818
|
+
'splashbackgroundimage.png',
|
|
1819
|
+
'splashbackgroundimage-dark.png',
|
|
1820
|
+
])
|
|
1821
|
+
const storyboard = readFileSync(
|
|
1822
|
+
join(output, 'ios', 'MyApp', 'LaunchScreen.storyboard'),
|
|
1823
|
+
'utf8'
|
|
1824
|
+
)
|
|
1825
|
+
// the background paints first, so the artwork sits above it
|
|
1826
|
+
expect(storyboard.indexOf('image="SplashBackgroundImage"')).toBeGreaterThan(-1)
|
|
1827
|
+
expect(storyboard.indexOf('image="SplashBackgroundImage"')).toBeLessThan(
|
|
1828
|
+
storyboard.indexOf('image="Splash"')
|
|
1829
|
+
)
|
|
1830
|
+
expect(storyboard).toContain('firstAttribute="top" secondItem="launch-view" secondAttribute="top" id="splash-background-top"')
|
|
1831
|
+
expect(storyboard).toContain('<image name="SplashBackgroundImage" width="30" height="60"/>')
|
|
1832
|
+
expect(storyboard).toContain('firstAttribute="width" constant="60"')
|
|
1833
|
+
}, 180000)
|
|
1834
|
+
|
|
1835
|
+
it('lays a full-bleed background image under the android launch artwork', async () => {
|
|
1836
|
+
const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
|
1837
|
+
const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-splash-bg-android-'))
|
|
1838
|
+
const source = fileURLToPath(
|
|
1839
|
+
new URL('../../../../examples/one-basic/public/splash.png', import.meta.url)
|
|
1840
|
+
)
|
|
1841
|
+
const backgroundImage = join(output, 'ground.png')
|
|
1842
|
+
await sharp({
|
|
1843
|
+
create: { width: 30, height: 60, channels: 3, background: '#ececec' },
|
|
1844
|
+
})
|
|
1845
|
+
.png()
|
|
1846
|
+
.toFile(backgroundImage)
|
|
1847
|
+
const splash = {
|
|
1848
|
+
source,
|
|
1849
|
+
backgroundColor: '#ececec',
|
|
1850
|
+
width: 60,
|
|
1851
|
+
backgroundImage,
|
|
1852
|
+
dark: { backgroundColor: '#111111', backgroundImage },
|
|
1853
|
+
}
|
|
1854
|
+
await generateForPlatform(
|
|
1855
|
+
workspaceRoot,
|
|
1856
|
+
'android',
|
|
1857
|
+
{ ...app, splash },
|
|
1858
|
+
join(output, 'android')
|
|
1859
|
+
)
|
|
1860
|
+
const res = join(output, 'android', 'app', 'src', 'main', 'res')
|
|
1861
|
+
// one nodpi asset per appearance at the source resolution
|
|
1862
|
+
for (const prefix of ['drawable-nodpi', 'drawable-night-nodpi']) {
|
|
1863
|
+
expect(
|
|
1864
|
+
await sharp(join(res, prefix, 'splash_background.png')).metadata()
|
|
1865
|
+
).toMatchObject({ width: 30, height: 60 })
|
|
1866
|
+
}
|
|
1867
|
+
const launchScreen = readFileSync(join(res, 'drawable', 'launch_screen.xml'), 'utf8')
|
|
1868
|
+
// the background paints first and fills, so the centered mark sits above it
|
|
1869
|
+
expect(launchScreen.indexOf('@drawable/splash_background')).toBeGreaterThan(-1)
|
|
1870
|
+
expect(launchScreen.indexOf('@drawable/splash_background')).toBeLessThan(
|
|
1871
|
+
launchScreen.indexOf('@drawable/splash"')
|
|
1872
|
+
)
|
|
1873
|
+
expect(launchScreen).toContain(
|
|
1874
|
+
'<bitmap android:gravity="fill" android:src="@drawable/splash_background" />'
|
|
1875
|
+
)
|
|
1876
|
+
expect(launchScreen).toContain(
|
|
1877
|
+
'<bitmap android:gravity="center" android:src="@drawable/splash" />'
|
|
1878
|
+
)
|
|
1879
|
+
}, 180000)
|
|
1880
|
+
|
|
1881
|
+
it('writes the accent color asset and names it in the Info.plist', async () => {
|
|
1882
|
+
const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
|
1883
|
+
const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-accent-'))
|
|
1884
|
+
const accented = {
|
|
1885
|
+
...app,
|
|
1886
|
+
ios: { ...app.ios, accentColor: { light: '#867286', dark: '#c8bfc8' } },
|
|
1887
|
+
}
|
|
1888
|
+
await generateForPlatform(workspaceRoot, 'ios', accented, join(output, 'ios'))
|
|
1889
|
+
const colorset = JSON.parse(
|
|
1890
|
+
readFileSync(
|
|
1891
|
+
join(output, 'ios', 'MyApp', 'Images.xcassets', 'AccentColor.colorset', 'Contents.json'),
|
|
1892
|
+
'utf8'
|
|
1893
|
+
)
|
|
1894
|
+
)
|
|
1895
|
+
expect(colorset.colors).toHaveLength(2)
|
|
1896
|
+
expect(colorset.colors[1].appearances).toEqual([{ appearance: 'luminosity', value: 'dark' }])
|
|
1897
|
+
const plist = readFileSync(join(output, 'ios', 'MyApp', 'Info.plist'), 'utf8')
|
|
1898
|
+
expect(plist).toContain('<key>NSAccentColorName</key>\n\t<string>AccentColor</string>')
|
|
1899
|
+
}, 180000)
|
|
1900
|
+
|
|
1125
1901
|
it('regenerates byte-identical projects from the same manifest', async () => {
|
|
1126
1902
|
// root stays the workspace so the installed community template resolves;
|
|
1127
1903
|
// output goes to isolated temp dirs, never the repo.
|
|
@@ -1203,6 +1979,8 @@ describe('generateForPlatform determinism', () => {
|
|
|
1203
1979
|
'utf8'
|
|
1204
1980
|
)
|
|
1205
1981
|
expect(mainActivity).toContain('RNScreensFragmentFactory')
|
|
1982
|
+
expect(mainActivity).toContain('import com.margelo.nitro.one.OneLaunchScreen')
|
|
1983
|
+
expect(mainActivity).toMatch(/super\.onCreate\(null\)\n.*\n\s*OneLaunchScreen\.hold\(this\)/)
|
|
1206
1984
|
}, 180000)
|
|
1207
1985
|
})
|
|
1208
1986
|
|
|
@@ -1276,7 +2054,7 @@ describe('ios widgets', () => {
|
|
|
1276
2054
|
const readGenerated = (relativePath: string) =>
|
|
1277
2055
|
readFileSync(join(output, 'ios', relativePath), 'utf8')
|
|
1278
2056
|
const rendered = readGenerated('OneWidgets/OneWidget.swift')
|
|
1279
|
-
// every node type packages/
|
|
2057
|
+
// every node type packages/one/src/platform/widgets/view.ts can emit needs a
|
|
1280
2058
|
// Swift decode path, or the extension renders a blank subtree
|
|
1281
2059
|
for (const type of [
|
|
1282
2060
|
'text',
|
|
@@ -1359,7 +2137,7 @@ describe('ios widgets', () => {
|
|
|
1359
2137
|
await generateForPlatform(
|
|
1360
2138
|
workspaceRoot,
|
|
1361
2139
|
'ios',
|
|
1362
|
-
{ ...widgetsApp, notifications: { push: true } },
|
|
2140
|
+
{ ...widgetsApp, notifications: { push: true, apsEnvironment: 'production' } },
|
|
1363
2141
|
join(output, 'ios')
|
|
1364
2142
|
)
|
|
1365
2143
|
|
|
@@ -1377,7 +2155,806 @@ describe('ios widgets', () => {
|
|
|
1377
2155
|
join(output, 'ios', 'MyApp', 'OneAppWidgets.entitlements'),
|
|
1378
2156
|
'utf8'
|
|
1379
2157
|
)
|
|
1380
|
-
expect(appEntitlements).toContain('<key>aps-environment</key>')
|
|
2158
|
+
expect(appEntitlements).toContain('<key>aps-environment</key>\n\t<string>production</string>')
|
|
1381
2159
|
expect(appEntitlements).toContain('group.dev.one.myapp')
|
|
1382
2160
|
}, 180000)
|
|
1383
2161
|
})
|
|
2162
|
+
|
|
2163
|
+
describe('app kotlin source discovery', () => {
|
|
2164
|
+
it('generates app sources without harvesting nested javascript packages', async () => {
|
|
2165
|
+
const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-'))
|
|
2166
|
+
const workspaceModules = fileURLToPath(
|
|
2167
|
+
new URL('../../../../node_modules', import.meta.url)
|
|
2168
|
+
)
|
|
2169
|
+
const { symlinkSync } = await import('node:fs')
|
|
2170
|
+
mkdirSync(join(root, 'node_modules', '@react-native-community'), { recursive: true })
|
|
2171
|
+
for (const name of ['cli', 'template']) {
|
|
2172
|
+
symlinkSync(
|
|
2173
|
+
join(workspaceModules, '@react-native-community', name),
|
|
2174
|
+
join(root, 'node_modules', '@react-native-community', name)
|
|
2175
|
+
)
|
|
2176
|
+
}
|
|
2177
|
+
writeFileSync(join(root, 'package.json'), '{"name":"app"}')
|
|
2178
|
+
const appDir = join(root, 'features', 'counter')
|
|
2179
|
+
const dependencyDir = join(root, 'packages', 'vendored')
|
|
2180
|
+
mkdirSync(appDir, { recursive: true })
|
|
2181
|
+
mkdirSync(join(dependencyDir, 'fixtures'), { recursive: true })
|
|
2182
|
+
const source = 'package app.counter\nclass Counter\n'
|
|
2183
|
+
writeFileSync(join(appDir, 'Counter.kt'), source)
|
|
2184
|
+
writeFileSync(join(dependencyDir, 'package.json'), '{"name":"vendored"}')
|
|
2185
|
+
writeFileSync(
|
|
2186
|
+
join(dependencyDir, 'fixtures', 'Browser.kt'),
|
|
2187
|
+
'package browser.fixture\nclass Browser\n'
|
|
2188
|
+
)
|
|
2189
|
+
await generateForPlatform(root, 'android', app)
|
|
2190
|
+
const generated = join(root, 'android', 'app', 'src', 'main', 'java', 'one', 'source')
|
|
2191
|
+
const ids = readdirSync(generated)
|
|
2192
|
+
expect(ids).toHaveLength(1)
|
|
2193
|
+
expect(readFileSync(join(generated, ids[0], 'Counter.kt'), 'utf8')).toBe(source)
|
|
2194
|
+
}, 180000)
|
|
2195
|
+
|
|
2196
|
+
it('compiles declared workspace sources with the bundle root source ids', async () => {
|
|
2197
|
+
const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-workspace-'))
|
|
2198
|
+
const dest = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-workspace-dest-'))
|
|
2199
|
+
const project = join(root, 'project')
|
|
2200
|
+
mkdirSync(join(project, 'native'), { recursive: true })
|
|
2201
|
+
mkdirSync(join(project, 'vendor'), { recursive: true })
|
|
2202
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ workspaces: ['project'] }))
|
|
2203
|
+
writeFileSync(join(project, 'package.json'), '{"name":"payload"}')
|
|
2204
|
+
writeFileSync(join(project, 'vendor', 'package.json'), '{"name":"vendor"}')
|
|
2205
|
+
writeFileSync(join(project, 'vendor', 'Fixture.kt'), 'class Fixture')
|
|
2206
|
+
const source = 'import dev.onejs.one.source.OneModule\nobject Counter : OneModule { fun value(): Int = 42 }'
|
|
2207
|
+
const file = join(project, 'native', 'Counter.kt')
|
|
2208
|
+
writeFileSync(file, source)
|
|
2209
|
+
await generateKotlinSources({ root, dest })
|
|
2210
|
+
const { kotlinSourceId } = await import('../utils/nativeSourceContract')
|
|
2211
|
+
const output = join(dest, 'app/src/main/java/one/source')
|
|
2212
|
+
const id = kotlinSourceId(root, file)
|
|
2213
|
+
expect(readdirSync(output)).toEqual([id])
|
|
2214
|
+
expect(readFileSync(join(output, id, 'Counter.kt'), 'utf8')).toBe(source)
|
|
2215
|
+
expect(readFileSync(join(output, id, `OneNativeSource_${id}.kt`), 'utf8')).toContain(`${id}.Counter`)
|
|
2216
|
+
})
|
|
2217
|
+
|
|
2218
|
+
it('skips evidence directories holding preserved proof sources', async () => {
|
|
2219
|
+
const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-evidence-'))
|
|
2220
|
+
const dest = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-evidence-dest-'))
|
|
2221
|
+
const live = 'package app.counter\nclass Counter\n'
|
|
2222
|
+
writeFileSync(join(root, 'Counter.kt'), live)
|
|
2223
|
+
mkdirSync(join(root, 'proofs', 'evidence'), { recursive: true })
|
|
2224
|
+
const duplicate = 'class Preserved\n'
|
|
2225
|
+
writeFileSync(join(root, 'proofs', 'evidence', 'source-before-build.kt'), duplicate)
|
|
2226
|
+
writeFileSync(join(root, 'proofs', 'evidence', 'fault-source.kt'), duplicate)
|
|
2227
|
+
await generateKotlinSources({ root, dest })
|
|
2228
|
+
const generated = join(dest, 'app', 'src', 'main', 'java', 'one', 'source')
|
|
2229
|
+
const ids = readdirSync(generated)
|
|
2230
|
+
expect(ids).toHaveLength(1)
|
|
2231
|
+
expect(readFileSync(join(generated, ids[0], 'Counter.kt'), 'utf8')).toBe(live)
|
|
2232
|
+
})
|
|
2233
|
+
|
|
2234
|
+
it('drops sources an earlier prebuild generated that are gone now', async () => {
|
|
2235
|
+
const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-stale-'))
|
|
2236
|
+
const dest = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-stale-dest-'))
|
|
2237
|
+
const live = 'package app.counter\nclass Counter\n'
|
|
2238
|
+
writeFileSync(join(root, 'Counter.kt'), live)
|
|
2239
|
+
mkdirSync(join(root, 'proofs'), { recursive: true })
|
|
2240
|
+
writeFileSync(join(root, 'proofs', 'Removed.kt'), 'class Removed\n')
|
|
2241
|
+
await generateKotlinSources({ root, dest })
|
|
2242
|
+
const generated = join(dest, 'app', 'src', 'main', 'java', 'one', 'source')
|
|
2243
|
+
expect(readdirSync(generated)).toHaveLength(2)
|
|
2244
|
+
rmSync(join(root, 'proofs'), { recursive: true })
|
|
2245
|
+
await generateKotlinSources({ root, dest })
|
|
2246
|
+
const ids = readdirSync(generated)
|
|
2247
|
+
expect(ids).toHaveLength(1)
|
|
2248
|
+
expect(readFileSync(join(generated, ids[0], 'Counter.kt'), 'utf8')).toBe(live)
|
|
2249
|
+
rmSync(join(root, 'Counter.kt'))
|
|
2250
|
+
await generateKotlinSources({ root, dest })
|
|
2251
|
+
expect(existsSync(generated)).toBe(false)
|
|
2252
|
+
})
|
|
2253
|
+
})
|
|
2254
|
+
|
|
2255
|
+
describe('swift cxx interop', () => {
|
|
2256
|
+
it('maps the same package names as prebuild and rejects collisions', () => {
|
|
2257
|
+
const root = mkdtempSync(join(tmpdir(), 'vxrn-swift-package-map-'))
|
|
2258
|
+
const first = join(root, 'features', 'my-badge')
|
|
2259
|
+
const second = join(root, 'other', 'counter')
|
|
2260
|
+
mkdirSync(first, { recursive: true })
|
|
2261
|
+
mkdirSync(second, { recursive: true })
|
|
2262
|
+
writeFileSync(join(first, 'Package.swift'), '')
|
|
2263
|
+
writeFileSync(join(second, 'Package.swift'), '')
|
|
2264
|
+
const packages = swiftPackageDirectories(root)
|
|
2265
|
+
expect(packages.size).toBe(2)
|
|
2266
|
+
expect(packages.get('my_badge')).toBe(first)
|
|
2267
|
+
expect(packages.get('counter')).toBe(second)
|
|
2268
|
+
const duplicate = join(root, 'other', 'my_badge')
|
|
2269
|
+
mkdirSync(duplicate)
|
|
2270
|
+
writeFileSync(join(duplicate, 'Package.swift'), '')
|
|
2271
|
+
expect(() => swiftPackageDirectories(root)).toThrow(/both have name my_badge/)
|
|
2272
|
+
})
|
|
2273
|
+
|
|
2274
|
+
it('emits cxx flags for swift packages', async () => {
|
|
2275
|
+
const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-swift-cxx-'))
|
|
2276
|
+
const workspaceModules = fileURLToPath(
|
|
2277
|
+
new URL('../../../../node_modules', import.meta.url)
|
|
2278
|
+
)
|
|
2279
|
+
mkdirSync(join(root, 'node_modules', '@react-native-community'), { recursive: true })
|
|
2280
|
+
const { symlinkSync } = await import('node:fs')
|
|
2281
|
+
for (const name of ['cli', 'template']) {
|
|
2282
|
+
symlinkSync(
|
|
2283
|
+
join(workspaceModules, '@react-native-community', name),
|
|
2284
|
+
join(root, 'node_modules', '@react-native-community', name)
|
|
2285
|
+
)
|
|
2286
|
+
}
|
|
2287
|
+
const pkgDir = join(root, 'MyFeature')
|
|
2288
|
+
mkdirSync(join(pkgDir, 'Sources'), { recursive: true })
|
|
2289
|
+
writeFileSync(join(pkgDir, 'Package.swift'), '// swift-tools-version: 5.9\n')
|
|
2290
|
+
writeFileSync(
|
|
2291
|
+
join(pkgDir, 'Sources', 'Feature.swift'),
|
|
2292
|
+
'import One\npublic func probe() { OneNativeRegisteredValue.register("x", for: "x") }\n'
|
|
2293
|
+
)
|
|
2294
|
+
await generateForPlatform(root, 'ios', app)
|
|
2295
|
+
const podspec = readFileSync(
|
|
2296
|
+
join(root, 'ios', 'OneSwiftPackages', 'MyFeature', 'MyFeature.podspec'),
|
|
2297
|
+
'utf8'
|
|
2298
|
+
)
|
|
2299
|
+
expect(podspec).toContain(
|
|
2300
|
+
'$(inherited) -cxx-interoperability-mode=default -Xcc -std=c++20'
|
|
2301
|
+
)
|
|
2302
|
+
expect(podspec).toContain('-Xfrontend -import-module -Xfrontend One')
|
|
2303
|
+
}, 180000)
|
|
2304
|
+
})
|
|
2305
|
+
|
|
2306
|
+
describe('one updates prebuild', () => {
|
|
2307
|
+
const updatesApp = {
|
|
2308
|
+
...app,
|
|
2309
|
+
updates: { url: 'https://updates.example.com', runtimeVersion: 'test-1' },
|
|
2310
|
+
} satisfies PrebuildAppConfig
|
|
2311
|
+
|
|
2312
|
+
const templateAppDelegate = `import UIKit
|
|
2313
|
+
import React
|
|
2314
|
+
import React_RCTAppDelegate
|
|
2315
|
+
import ReactAppDependencyProvider
|
|
2316
|
+
|
|
2317
|
+
@main
|
|
2318
|
+
class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
2319
|
+
var window: UIWindow?
|
|
2320
|
+
|
|
2321
|
+
var reactNativeDelegate: ReactNativeDelegate?
|
|
2322
|
+
var reactNativeFactory: RCTReactNativeFactory?
|
|
2323
|
+
|
|
2324
|
+
func application(
|
|
2325
|
+
_ application: UIApplication,
|
|
2326
|
+
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
|
2327
|
+
) -> Bool {
|
|
2328
|
+
let delegate = ReactNativeDelegate()
|
|
2329
|
+
let factory = RCTReactNativeFactory(delegate: delegate)
|
|
2330
|
+
delegate.dependencyProvider = RCTAppDependencyProvider()
|
|
2331
|
+
|
|
2332
|
+
reactNativeDelegate = delegate
|
|
2333
|
+
reactNativeFactory = factory
|
|
2334
|
+
|
|
2335
|
+
window = UIWindow(frame: UIScreen.main.bounds)
|
|
2336
|
+
|
|
2337
|
+
factory.startReactNative(
|
|
2338
|
+
withModuleName: "HelloWorld",
|
|
2339
|
+
in: window,
|
|
2340
|
+
launchOptions: launchOptions
|
|
2341
|
+
)
|
|
2342
|
+
|
|
2343
|
+
return true
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
|
2348
|
+
override func sourceURL(for bridge: RCTBridge) -> URL? {
|
|
2349
|
+
self.bundleURL()
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
override func bundleURL() -> URL? {
|
|
2353
|
+
#if DEBUG
|
|
2354
|
+
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
|
|
2355
|
+
#else
|
|
2356
|
+
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
|
|
2357
|
+
#endif
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
`
|
|
2361
|
+
|
|
2362
|
+
it('validates the updates config', () => {
|
|
2363
|
+
expect(() => validatePrebuildApp(updatesApp)).not.toThrow()
|
|
2364
|
+
expect(() =>
|
|
2365
|
+
validatePrebuildApp({
|
|
2366
|
+
...app,
|
|
2367
|
+
updates: { runtimeVersion: 'test-1' },
|
|
2368
|
+
})
|
|
2369
|
+
).not.toThrow()
|
|
2370
|
+
expect(() =>
|
|
2371
|
+
validatePrebuildApp({ ...app, updates: {} } as any)
|
|
2372
|
+
).toThrow(/updates\.runtimeVersion/)
|
|
2373
|
+
expect(() =>
|
|
2374
|
+
validatePrebuildApp({ ...app, updates: { runtimeVersion: '' } })
|
|
2375
|
+
).toThrow(/updates\.runtimeVersion/)
|
|
2376
|
+
expect(() =>
|
|
2377
|
+
validatePrebuildApp({
|
|
2378
|
+
...app,
|
|
2379
|
+
updates: { url: '', runtimeVersion: 'test-1' },
|
|
2380
|
+
})
|
|
2381
|
+
).toThrow(/updates\.url/)
|
|
2382
|
+
})
|
|
2383
|
+
|
|
2384
|
+
it('points the release bundleURL at the launcher', () => {
|
|
2385
|
+
const rendered = renderPrebuildFile({
|
|
2386
|
+
relativePath: 'HelloWorld/AppDelegate.swift',
|
|
2387
|
+
content: templateAppDelegate,
|
|
2388
|
+
platform: 'ios',
|
|
2389
|
+
app: updatesApp,
|
|
2390
|
+
})
|
|
2391
|
+
expect(rendered.content).toContain('OneUpdatesBundleURL()')
|
|
2392
|
+
expect(rendered.content).not.toContain('import One')
|
|
2393
|
+
expect(rendered.content).not.toContain('Bundle.main.url(forResource: "main"')
|
|
2394
|
+
expect(rendered.content).toContain('#if DEBUG')
|
|
2395
|
+
|
|
2396
|
+
const plain = renderPrebuildFile({
|
|
2397
|
+
relativePath: 'HelloWorld/AppDelegate.swift',
|
|
2398
|
+
content: templateAppDelegate,
|
|
2399
|
+
platform: 'ios',
|
|
2400
|
+
app,
|
|
2401
|
+
})
|
|
2402
|
+
expect(plain.content).not.toContain('OneUpdatesBundleURL')
|
|
2403
|
+
expect(plain.content).toContain('Bundle.main.url(forResource: "main"')
|
|
2404
|
+
})
|
|
2405
|
+
|
|
2406
|
+
it('points every app target at the One bridging header', () => {
|
|
2407
|
+
const configs = `buildSettings = {
|
|
2408
|
+
\t\t\t\tINFOPLIST_FILE = MyApp/Info.plist;
|
|
2409
|
+
\t\t\t};
|
|
2410
|
+
\t\t\tname = Debug;
|
|
2411
|
+
buildSettings = {
|
|
2412
|
+
\t\t\t\tINFOPLIST_FILE = MyApp/Info.plist;
|
|
2413
|
+
\t\t\t};
|
|
2414
|
+
\t\t\tname = Release;`
|
|
2415
|
+
const project = `shellScript = ${JSON.stringify('REACT_NATIVE_XCODE="$REACT_NATIVE_PATH/scripts/react-native-xcode.sh"\n/bin/sh -c "\\""$WITH_ENVIRONMENT\\"" \\""$REACT_NATIVE_XCODE\\"""\n')};\n${'/* AppDelegate.swift in Sources */ = {isa = PBXBuildFile;'}\n${'/* AppDelegate.swift */ = {isa = PBXFileReference;'}\n${'/* AppDelegate.swift */,'}\n${'/* AppDelegate.swift in Sources */,'}\n${configs}`
|
|
2416
|
+
const setting = 'SWIFT_OBJC_BRIDGING_HEADER = "MyApp/One-Bridging-Header.h";'
|
|
2417
|
+
// the launch screen hold needs the header with or without updates.
|
|
2418
|
+
for (const target of [app, updatesApp]) {
|
|
2419
|
+
const rendered = renderPrebuildFile({
|
|
2420
|
+
relativePath: 'MyApp.xcodeproj/project.pbxproj',
|
|
2421
|
+
content: project,
|
|
2422
|
+
platform: 'ios',
|
|
2423
|
+
app: target,
|
|
2424
|
+
})
|
|
2425
|
+
expect((rendered.content ?? '').split(setting).length - 1).toBe(2)
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
const missing = project.replaceAll('INFOPLIST_FILE = MyApp/Info.plist;', 'INFOPLIST = x;')
|
|
2429
|
+
expect(() =>
|
|
2430
|
+
renderPrebuildFile({
|
|
2431
|
+
relativePath: 'MyApp.xcodeproj/project.pbxproj',
|
|
2432
|
+
content: missing,
|
|
2433
|
+
platform: 'ios',
|
|
2434
|
+
app: updatesApp,
|
|
2435
|
+
})
|
|
2436
|
+
).toThrow('bridging header')
|
|
2437
|
+
})
|
|
2438
|
+
|
|
2439
|
+
it('throws instead of shipping a stock bundleURL with updates configured', () => {
|
|
2440
|
+
expect(() =>
|
|
2441
|
+
renderPrebuildFile({
|
|
2442
|
+
relativePath: 'HelloWorld/AppDelegate.swift',
|
|
2443
|
+
content: templateAppDelegate.replace('Bundle.main.url', 'Bundle.main.path'),
|
|
2444
|
+
platform: 'ios',
|
|
2445
|
+
app: updatesApp,
|
|
2446
|
+
})
|
|
2447
|
+
).toThrow('One.Updates')
|
|
2448
|
+
})
|
|
2449
|
+
|
|
2450
|
+
it('locks userInterfaceStyle on both platforms, and follows the system when unset', () => {
|
|
2451
|
+
const plist = (userInterfaceStyle?: 'light' | 'dark' | 'automatic') =>
|
|
2452
|
+
renderPrebuildFile({
|
|
2453
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
2454
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
2455
|
+
platform: 'ios',
|
|
2456
|
+
app: { ...app, userInterfaceStyle },
|
|
2457
|
+
}).content
|
|
2458
|
+
expect(plist('light')).toContain('<key>UIUserInterfaceStyle</key>\n\t<string>Light</string>')
|
|
2459
|
+
expect(plist('dark')).toContain('<string>Dark</string>')
|
|
2460
|
+
expect(plist('automatic')).toContain('<string>Automatic</string>')
|
|
2461
|
+
expect(plist()).not.toContain('UIUserInterfaceStyle')
|
|
2462
|
+
|
|
2463
|
+
const styles =
|
|
2464
|
+
'<resources>\n <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">\n </style>\n</resources>'
|
|
2465
|
+
const theme = (userInterfaceStyle?: 'light' | 'dark' | 'automatic') =>
|
|
2466
|
+
renderPrebuildFile({
|
|
2467
|
+
relativePath: 'app/src/main/res/values/styles.xml',
|
|
2468
|
+
content: styles,
|
|
2469
|
+
platform: 'android',
|
|
2470
|
+
app: { ...app, userInterfaceStyle },
|
|
2471
|
+
}).content
|
|
2472
|
+
expect(theme('light')).toContain('parent="Theme.AppCompat.Light.NoActionBar"')
|
|
2473
|
+
expect(theme('dark')).toContain('parent="Theme.AppCompat.NoActionBar"')
|
|
2474
|
+
expect(theme('automatic')).toContain('parent="Theme.AppCompat.DayNight.NoActionBar"')
|
|
2475
|
+
expect(theme()).toContain('parent="Theme.AppCompat.DayNight.NoActionBar"')
|
|
2476
|
+
})
|
|
2477
|
+
|
|
2478
|
+
it('writes the app entitlements file for links, apple sign in and custom keys', async () => {
|
|
2479
|
+
const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
|
2480
|
+
const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-entitlements-'))
|
|
2481
|
+
const linked = {
|
|
2482
|
+
...app,
|
|
2483
|
+
notifications: undefined,
|
|
2484
|
+
ios: {
|
|
2485
|
+
...app.ios,
|
|
2486
|
+
associatedDomains: ['applinks:example.com'],
|
|
2487
|
+
usesAppleSignIn: true,
|
|
2488
|
+
entitlements: { 'com.apple.developer.icloud-container-identifiers': ['iCloud.dev.one'] },
|
|
2489
|
+
},
|
|
2490
|
+
}
|
|
2491
|
+
await generateForPlatform(workspaceRoot, 'ios', linked, join(output, 'ios'))
|
|
2492
|
+
const entitlements = readFileSync(join(output, 'ios', 'MyApp', 'MyApp.entitlements'), 'utf8')
|
|
2493
|
+
expect(entitlements).toContain(
|
|
2494
|
+
'<key>com.apple.developer.associated-domains</key>\n\t<array>\n\t\t<string>applinks:example.com</string>'
|
|
2495
|
+
)
|
|
2496
|
+
expect(entitlements).toContain('<key>com.apple.developer.applesignin</key>')
|
|
2497
|
+
expect(entitlements).toContain('<string>iCloud.dev.one</string>')
|
|
2498
|
+
expect(entitlements).not.toContain('aps-environment')
|
|
2499
|
+
const project = readFileSync(join(output, 'ios', 'MyApp.xcodeproj', 'project.pbxproj'), 'utf8')
|
|
2500
|
+
expect(project).toContain('CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;')
|
|
2501
|
+
|
|
2502
|
+
await expect(
|
|
2503
|
+
generateForPlatform(
|
|
2504
|
+
workspaceRoot,
|
|
2505
|
+
'ios',
|
|
2506
|
+
{ ...linked, ios: { ...linked.ios, entitlements: { 'com.apple.developer.applesignin': [] } } },
|
|
2507
|
+
join(output, 'ios-dup')
|
|
2508
|
+
)
|
|
2509
|
+
).rejects.toThrow('native.app already writes')
|
|
2510
|
+
}, 180000)
|
|
2511
|
+
|
|
2512
|
+
it('stamps extra Info.plist keys and rejects ones already written', () => {
|
|
2513
|
+
const plist = renderPrebuildFile({
|
|
2514
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
2515
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
2516
|
+
platform: 'ios',
|
|
2517
|
+
app: {
|
|
2518
|
+
...app,
|
|
2519
|
+
ios: {
|
|
2520
|
+
...app.ios,
|
|
2521
|
+
infoPlist: {
|
|
2522
|
+
NSUserTrackingUsageDescription: 'Measure ads & installs',
|
|
2523
|
+
GADIsAdManagerApp: true,
|
|
2524
|
+
SKAdNetworkItems: [{ SKAdNetworkIdentifier: 'abc.skadnetwork' }],
|
|
2525
|
+
},
|
|
2526
|
+
},
|
|
2527
|
+
},
|
|
2528
|
+
}).content
|
|
2529
|
+
expect(plist).toContain(
|
|
2530
|
+
'<key>NSUserTrackingUsageDescription</key>\n\t<string>Measure ads & installs</string>'
|
|
2531
|
+
)
|
|
2532
|
+
expect(plist).toContain('<key>GADIsAdManagerApp</key>\n\t<true/>')
|
|
2533
|
+
expect(plist).toContain(
|
|
2534
|
+
'<key>SKAdNetworkItems</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>SKAdNetworkIdentifier</key>'
|
|
2535
|
+
)
|
|
2536
|
+
expect(() =>
|
|
2537
|
+
renderPrebuildFile({
|
|
2538
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
2539
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
2540
|
+
platform: 'ios',
|
|
2541
|
+
app: { ...app, ios: { ...app.ios, infoPlist: { CFBundleURLTypes: [] } } },
|
|
2542
|
+
})
|
|
2543
|
+
).toThrow('already writes')
|
|
2544
|
+
})
|
|
2545
|
+
|
|
2546
|
+
it('stamps android permissions, blocked permissions, app links and sdk levels', () => {
|
|
2547
|
+
const manifest = renderPrebuildFile({
|
|
2548
|
+
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
2549
|
+
content:
|
|
2550
|
+
'<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n <uses-permission android:name="android.permission.INTERNET" />\n <application>\n <activity\n android:name=".MainActivity">\n </activity>\n </application>\n</manifest>',
|
|
2551
|
+
platform: 'android',
|
|
2552
|
+
app: {
|
|
2553
|
+
...app,
|
|
2554
|
+
notifications: undefined,
|
|
2555
|
+
imagePicker: undefined,
|
|
2556
|
+
speech: undefined,
|
|
2557
|
+
scheme: undefined,
|
|
2558
|
+
android: {
|
|
2559
|
+
...app.android,
|
|
2560
|
+
permissions: ['VIBRATE', 'com.example.permission.CUSTOM'],
|
|
2561
|
+
blockedPermissions: ['RECORD_AUDIO'],
|
|
2562
|
+
appLinks: [{ host: 'example.com', pathPrefix: '/invite' }],
|
|
2563
|
+
},
|
|
2564
|
+
},
|
|
2565
|
+
}).content
|
|
2566
|
+
expect(manifest).toContain('<uses-permission android:name="android.permission.VIBRATE" />')
|
|
2567
|
+
expect(manifest).toContain('<uses-permission android:name="com.example.permission.CUSTOM" />')
|
|
2568
|
+
expect(manifest).toContain(
|
|
2569
|
+
'<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove" />'
|
|
2570
|
+
)
|
|
2571
|
+
expect(manifest).toContain('xmlns:tools="http://schemas.android.com/tools"')
|
|
2572
|
+
expect(manifest).toContain('<intent-filter android:autoVerify="true">')
|
|
2573
|
+
expect(manifest).toContain('<data android:host="example.com" android:pathPrefix="/invite" />')
|
|
2574
|
+
|
|
2575
|
+
const gradle = renderPrebuildFile({
|
|
2576
|
+
relativePath: 'build.gradle',
|
|
2577
|
+
content: 'minSdkVersion = 24\ncompileSdkVersion = 37\ntargetSdkVersion = 36',
|
|
2578
|
+
platform: 'android',
|
|
2579
|
+
app: { ...app, android: { ...app.android, targetSdk: 35, compileSdk: 36 } },
|
|
2580
|
+
}).content
|
|
2581
|
+
expect(gradle).toBe('minSdkVersion = 28\ncompileSdkVersion = 36\ntargetSdkVersion = 35')
|
|
2582
|
+
})
|
|
2583
|
+
|
|
2584
|
+
it('turns on release minify, resource shrinking and extra proguard rules', () => {
|
|
2585
|
+
const android = {
|
|
2586
|
+
...app.android,
|
|
2587
|
+
minify: true,
|
|
2588
|
+
shrinkResources: true,
|
|
2589
|
+
proguardRules: '-keep class com.example.** { *; }',
|
|
2590
|
+
}
|
|
2591
|
+
const gradle = renderPrebuildFile({
|
|
2592
|
+
relativePath: 'app/build.gradle',
|
|
2593
|
+
content:
|
|
2594
|
+
'react {\n autolinkLibrariesWithApp()\n}\ndef enableProguardInReleaseBuilds = false\n release {\n minifyEnabled enableProguardInReleaseBuilds\n }',
|
|
2595
|
+
platform: 'android',
|
|
2596
|
+
app: { ...app, android },
|
|
2597
|
+
}).content
|
|
2598
|
+
expect(gradle).toContain('def enableProguardInReleaseBuilds = true')
|
|
2599
|
+
expect(gradle).toContain(
|
|
2600
|
+
'minifyEnabled enableProguardInReleaseBuilds\n shrinkResources true'
|
|
2601
|
+
)
|
|
2602
|
+
const rules = renderPrebuildFile({
|
|
2603
|
+
relativePath: 'app/proguard-rules.pro',
|
|
2604
|
+
content: '# Add project specific ProGuard rules here.\n',
|
|
2605
|
+
platform: 'android',
|
|
2606
|
+
app: { ...app, android },
|
|
2607
|
+
}).content
|
|
2608
|
+
expect(rules).toBe(
|
|
2609
|
+
'# Add project specific ProGuard rules here.\n\n-keep class com.example.** { *; }\n'
|
|
2610
|
+
)
|
|
2611
|
+
expect(() =>
|
|
2612
|
+
renderPrebuildFile({
|
|
2613
|
+
relativePath: 'app/build.gradle',
|
|
2614
|
+
content: 'react {\n autolinkLibrariesWithApp()\n}\nno switch',
|
|
2615
|
+
platform: 'android',
|
|
2616
|
+
app: { ...app, android },
|
|
2617
|
+
})
|
|
2618
|
+
).toThrow('proguard switch')
|
|
2619
|
+
})
|
|
2620
|
+
|
|
2621
|
+
it('stamps orientation as expo does, and nothing when unset', () => {
|
|
2622
|
+
const plistTemplate =
|
|
2623
|
+
'<dict>\n\t<key>LSRequiresIPhoneOS</key>\n\t<key>UISupportedInterfaceOrientations</key>\n\t<array>\n\t\t<string>UIInterfaceOrientationPortrait</string>\n\t</array>\n</dict>'
|
|
2624
|
+
const landscape = renderPrebuildFile({
|
|
2625
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
2626
|
+
content: plistTemplate,
|
|
2627
|
+
platform: 'ios',
|
|
2628
|
+
app: { ...app, orientation: 'landscape' },
|
|
2629
|
+
})
|
|
2630
|
+
expect(landscape.content).toContain(
|
|
2631
|
+
'<array>\n\t\t<string>UIInterfaceOrientationLandscapeLeft</string>\n\t\t<string>UIInterfaceOrientationLandscapeRight</string>\n\t</array>'
|
|
2632
|
+
)
|
|
2633
|
+
expect(landscape.content).not.toContain('UIInterfaceOrientationPortrait<')
|
|
2634
|
+
const unset = renderPrebuildFile({
|
|
2635
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
2636
|
+
content: plistTemplate,
|
|
2637
|
+
platform: 'ios',
|
|
2638
|
+
app,
|
|
2639
|
+
})
|
|
2640
|
+
expect(unset.content).toContain('<string>UIInterfaceOrientationPortrait</string>')
|
|
2641
|
+
expect(unset.content).not.toContain('PortraitUpsideDown')
|
|
2642
|
+
|
|
2643
|
+
const manifestTemplate =
|
|
2644
|
+
'<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <application>\n <activity\n android:name=".MainActivity"\n android:exported="true">\n </activity>\n </application>\n</manifest>'
|
|
2645
|
+
for (const [orientation, attribute] of [
|
|
2646
|
+
['portrait', 'portrait'],
|
|
2647
|
+
['landscape', 'landscape'],
|
|
2648
|
+
['default', 'unspecified'],
|
|
2649
|
+
] as const) {
|
|
2650
|
+
const rendered = renderPrebuildFile({
|
|
2651
|
+
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
2652
|
+
content: manifestTemplate,
|
|
2653
|
+
platform: 'android',
|
|
2654
|
+
app: {
|
|
2655
|
+
...app,
|
|
2656
|
+
notifications: undefined,
|
|
2657
|
+
imagePicker: undefined,
|
|
2658
|
+
speech: undefined,
|
|
2659
|
+
orientation,
|
|
2660
|
+
},
|
|
2661
|
+
})
|
|
2662
|
+
expect(rendered.content).toContain(`android:screenOrientation="${attribute}"`)
|
|
2663
|
+
}
|
|
2664
|
+
expect(() =>
|
|
2665
|
+
validatePrebuildApp({ ...app, orientation: 'sideways' as 'default' })
|
|
2666
|
+
).toThrow('orientation "sideways"')
|
|
2667
|
+
})
|
|
2668
|
+
|
|
2669
|
+
it('stamps the updates url and runtime version into Info.plist', () => {
|
|
2670
|
+
const plist = renderPrebuildFile({
|
|
2671
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
2672
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
2673
|
+
platform: 'ios',
|
|
2674
|
+
app: updatesApp,
|
|
2675
|
+
})
|
|
2676
|
+
expect(plist.content).toContain('<key>OneUpdatesURL</key>')
|
|
2677
|
+
expect(plist.content).toContain('<string>https://updates.example.com</string>')
|
|
2678
|
+
expect(plist.content).toContain('<key>OneUpdatesRuntimeVersion</key>')
|
|
2679
|
+
expect(plist.content).toContain('<string>test-1</string>')
|
|
2680
|
+
|
|
2681
|
+
const noUrl = renderPrebuildFile({
|
|
2682
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
2683
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
2684
|
+
platform: 'ios',
|
|
2685
|
+
app: { ...app, updates: { runtimeVersion: 'test-1' } },
|
|
2686
|
+
})
|
|
2687
|
+
expect(noUrl.content).not.toContain('<key>OneUpdatesURL</key>')
|
|
2688
|
+
expect(noUrl.content).toContain('<key>OneUpdatesRuntimeVersion</key>')
|
|
2689
|
+
|
|
2690
|
+
const plain = renderPrebuildFile({
|
|
2691
|
+
relativePath: 'HelloWorld/Info.plist',
|
|
2692
|
+
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
2693
|
+
platform: 'ios',
|
|
2694
|
+
app,
|
|
2695
|
+
})
|
|
2696
|
+
expect(plain.content).not.toContain('OneUpdates')
|
|
2697
|
+
})
|
|
2698
|
+
|
|
2699
|
+
it('writes the embedded manifest from the ios bundle phase', () => {
|
|
2700
|
+
const phase = `shellScript = ${JSON.stringify('REACT_NATIVE_XCODE="$REACT_NATIVE_PATH/scripts/react-native-xcode.sh"\n/bin/sh -c "\\""$WITH_ENVIRONMENT\\"" \\""$REACT_NATIVE_XCODE\\"""\n')};\n${'/* AppDelegate.swift in Sources */ = {isa = PBXBuildFile;'}\n${'/* AppDelegate.swift */ = {isa = PBXFileReference;'}\n${'/* AppDelegate.swift */,'}\n${'/* AppDelegate.swift in Sources */,'}\nINFOPLIST_FILE = MyApp/Info.plist;\nINFOPLIST_FILE = MyApp/Info.plist;`
|
|
2701
|
+
const rendered = renderPrebuildFile({
|
|
2702
|
+
relativePath: 'HelloWorld.xcodeproj/project.pbxproj',
|
|
2703
|
+
content: phase,
|
|
2704
|
+
platform: 'ios',
|
|
2705
|
+
app: updatesApp,
|
|
2706
|
+
})
|
|
2707
|
+
expect(rendered.content).toContain(
|
|
2708
|
+
'[vxrn/one] the embedded update manifest lands beside the release bundle'
|
|
2709
|
+
)
|
|
2710
|
+
expect(rendered.content).toContain('one-updates-embedded.json')
|
|
2711
|
+
expect(rendered.content).toContain('runtimeVersion: \\"test-1\\"')
|
|
2712
|
+
|
|
2713
|
+
const plain = renderPrebuildFile({
|
|
2714
|
+
relativePath: 'HelloWorld.xcodeproj/project.pbxproj',
|
|
2715
|
+
content: phase,
|
|
2716
|
+
platform: 'ios',
|
|
2717
|
+
app,
|
|
2718
|
+
})
|
|
2719
|
+
expect(plain.content).not.toContain('one-updates-embedded.json')
|
|
2720
|
+
})
|
|
2721
|
+
|
|
2722
|
+
it('points MainApplication at the One host factory', () => {
|
|
2723
|
+
const templateMainApplication = `package com.helloworld
|
|
2724
|
+
|
|
2725
|
+
import android.app.Application
|
|
2726
|
+
import com.facebook.react.PackageList
|
|
2727
|
+
import com.facebook.react.ReactApplication
|
|
2728
|
+
import com.facebook.react.ReactHost
|
|
2729
|
+
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
|
2730
|
+
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
|
2731
|
+
`
|
|
2732
|
+
const rendered = renderPrebuildFile({
|
|
2733
|
+
relativePath: 'app/src/main/java/com/helloworld/MainApplication.kt',
|
|
2734
|
+
content: templateMainApplication,
|
|
2735
|
+
platform: 'android',
|
|
2736
|
+
app: updatesApp,
|
|
2737
|
+
})
|
|
2738
|
+
expect(rendered.content).toContain(
|
|
2739
|
+
'import com.margelo.nitro.one.OneUpdatesReactHost.getDefaultReactHost'
|
|
2740
|
+
)
|
|
2741
|
+
expect(rendered.content).not.toContain('DefaultReactHost.getDefaultReactHost')
|
|
2742
|
+
|
|
2743
|
+
const plain = renderPrebuildFile({
|
|
2744
|
+
relativePath: 'app/src/main/java/com/helloworld/MainApplication.kt',
|
|
2745
|
+
content: templateMainApplication,
|
|
2746
|
+
platform: 'android',
|
|
2747
|
+
app,
|
|
2748
|
+
})
|
|
2749
|
+
expect(plain.content).toContain('DefaultReactHost.getDefaultReactHost')
|
|
2750
|
+
|
|
2751
|
+
expect(() =>
|
|
2752
|
+
renderPrebuildFile({
|
|
2753
|
+
relativePath: 'app/src/main/java/com/helloworld/MainApplication.kt',
|
|
2754
|
+
content: 'package com.helloworld\n',
|
|
2755
|
+
platform: 'android',
|
|
2756
|
+
app: updatesApp,
|
|
2757
|
+
})
|
|
2758
|
+
).toThrow('One.Updates')
|
|
2759
|
+
})
|
|
2760
|
+
|
|
2761
|
+
it('stamps the updates config into the android manifest', () => {
|
|
2762
|
+
const manifest = `<manifest>
|
|
2763
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
2764
|
+
<application>
|
|
2765
|
+
<activity android:name=".MainActivity">
|
|
2766
|
+
</activity>
|
|
2767
|
+
</application>
|
|
2768
|
+
</manifest>`
|
|
2769
|
+
const rendered = renderPrebuildFile({
|
|
2770
|
+
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
2771
|
+
content: manifest,
|
|
2772
|
+
platform: 'android',
|
|
2773
|
+
app: updatesApp,
|
|
2774
|
+
})
|
|
2775
|
+
expect(rendered.content).toContain(
|
|
2776
|
+
'<meta-data android:name="dev.onejs.updates.url" android:value="https://updates.example.com" />'
|
|
2777
|
+
)
|
|
2778
|
+
expect(rendered.content).toContain(
|
|
2779
|
+
'<meta-data android:name="dev.onejs.updates.runtimeVersion" android:value="@string/one_updates_runtime_version" />'
|
|
2780
|
+
)
|
|
2781
|
+
})
|
|
2782
|
+
|
|
2783
|
+
it('writes the embedded manifest from the android bundle task', () => {
|
|
2784
|
+
const rendered = renderPrebuildFile({
|
|
2785
|
+
relativePath: 'app/build.gradle',
|
|
2786
|
+
content: 'react {\n entryFile = file("x")\n}\n',
|
|
2787
|
+
platform: 'android',
|
|
2788
|
+
app: updatesApp,
|
|
2789
|
+
})
|
|
2790
|
+
expect(rendered.content).toContain(
|
|
2791
|
+
'[vxrn/one] the embedded update manifest lands beside the release bundle'
|
|
2792
|
+
)
|
|
2793
|
+
expect(rendered.content).toContain('one-updates-embedded.json')
|
|
2794
|
+
expect(rendered.content).toContain('runtimeVersion: "test-1"')
|
|
2795
|
+
|
|
2796
|
+
const plain = renderPrebuildFile({
|
|
2797
|
+
relativePath: 'app/build.gradle',
|
|
2798
|
+
content: 'react {\n entryFile = file("x")\n}\n',
|
|
2799
|
+
platform: 'android',
|
|
2800
|
+
app,
|
|
2801
|
+
})
|
|
2802
|
+
expect(plain.content).not.toContain('one-updates-embedded.json')
|
|
2803
|
+
})
|
|
2804
|
+
})
|
|
2805
|
+
|
|
2806
|
+
describe('kotlin compose app integration', () => {
|
|
2807
|
+
it('enables compose plugin, buildFeatures, and dependencies in gradle files', () => {
|
|
2808
|
+
const dest = mkdtempSync(join(tmpdir(), 'vxrn-compose-gradle-'))
|
|
2809
|
+
const rootGradle = join(dest, 'build.gradle')
|
|
2810
|
+
const appDir = join(dest, 'app')
|
|
2811
|
+
const appGradle = join(appDir, 'build.gradle')
|
|
2812
|
+
mkdirSync(appDir, { recursive: true })
|
|
2813
|
+
|
|
2814
|
+
writeFileSync(
|
|
2815
|
+
rootGradle,
|
|
2816
|
+
`buildscript {
|
|
2817
|
+
dependencies {
|
|
2818
|
+
classpath("com.android.tools.build:gradle")
|
|
2819
|
+
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
`
|
|
2823
|
+
)
|
|
2824
|
+
writeFileSync(
|
|
2825
|
+
appGradle,
|
|
2826
|
+
`apply plugin: "com.android.application"
|
|
2827
|
+
apply plugin: "org.jetbrains.kotlin.android"
|
|
2828
|
+
apply plugin: "com.facebook.react"
|
|
2829
|
+
|
|
2830
|
+
android {
|
|
2831
|
+
namespace "com.example.app"
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
dependencies {
|
|
2835
|
+
implementation("com.facebook.react:react-android")
|
|
2836
|
+
}
|
|
2837
|
+
`
|
|
2838
|
+
)
|
|
2839
|
+
|
|
2840
|
+
enableAppComposeIntegration(dest)
|
|
2841
|
+
|
|
2842
|
+
const updatedRoot = readFileSync(rootGradle, 'utf8')
|
|
2843
|
+
expect(updatedRoot).toContain('org.jetbrains.kotlin.plugin.compose.gradle.plugin')
|
|
2844
|
+
|
|
2845
|
+
const updatedApp = readFileSync(appGradle, 'utf8')
|
|
2846
|
+
expect(updatedApp).toContain('apply plugin: "org.jetbrains.kotlin.plugin.compose"')
|
|
2847
|
+
expect(updatedApp).toContain('buildFeatures {\n compose true\n }')
|
|
2848
|
+
expect(updatedApp).toContain('androidx.compose.ui:ui:$composeUiVersion')
|
|
2849
|
+
expect(updatedApp).toContain('androidx.compose.foundation:foundation:$composeUiVersion')
|
|
2850
|
+
expect(updatedApp).toContain('androidx.compose.material3:material3:$material3Version')
|
|
2851
|
+
expect(updatedApp).toContain(
|
|
2852
|
+
'androidx.navigation:navigation-compose:$navigationComposeVersion'
|
|
2853
|
+
)
|
|
2854
|
+
})
|
|
2855
|
+
|
|
2856
|
+
it('enables compose only when .kt has views, keeping module-only prebuild unchanged', async () => {
|
|
2857
|
+
const root = mkdtempSync(join(tmpdir(), 'vxrn-compose-views-root-'))
|
|
2858
|
+
const dest = mkdtempSync(join(tmpdir(), 'vxrn-compose-views-dest-'))
|
|
2859
|
+
const rootGradle = join(dest, 'build.gradle')
|
|
2860
|
+
const appDir = join(dest, 'app')
|
|
2861
|
+
const appGradle = join(appDir, 'build.gradle')
|
|
2862
|
+
mkdirSync(appDir, { recursive: true })
|
|
2863
|
+
|
|
2864
|
+
const initialRootGradle = `buildscript {
|
|
2865
|
+
dependencies {
|
|
2866
|
+
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
`
|
|
2870
|
+
const initialAppGradle = `apply plugin: "org.jetbrains.kotlin.android"
|
|
2871
|
+
android {
|
|
2872
|
+
namespace "com.example"
|
|
2873
|
+
}
|
|
2874
|
+
dependencies {
|
|
2875
|
+
implementation("com.facebook.react:react-android")
|
|
2876
|
+
}
|
|
2877
|
+
`
|
|
2878
|
+
writeFileSync(rootGradle, initialRootGradle)
|
|
2879
|
+
writeFileSync(appGradle, initialAppGradle)
|
|
2880
|
+
|
|
2881
|
+
// 1. Module-only source: prebuild must leave gradle unchanged
|
|
2882
|
+
writeFileSync(
|
|
2883
|
+
join(root, 'MathModule.kt'),
|
|
2884
|
+
`package com.example
|
|
2885
|
+
object MathModule : dev.onejs.one.source.OneModule {
|
|
2886
|
+
fun add(a: Int, b: Int): Int = a + b
|
|
2887
|
+
}
|
|
2888
|
+
`
|
|
2889
|
+
)
|
|
2890
|
+
|
|
2891
|
+
await generateKotlinSources({ root, dest })
|
|
2892
|
+
|
|
2893
|
+
expect(readFileSync(rootGradle, 'utf8')).toBe(initialRootGradle)
|
|
2894
|
+
expect(readFileSync(appGradle, 'utf8')).toBe(initialAppGradle)
|
|
2895
|
+
|
|
2896
|
+
// 2. Add a composable view source: prebuild must enable Compose
|
|
2897
|
+
writeFileSync(
|
|
2898
|
+
join(root, 'Counter.kt'),
|
|
2899
|
+
`package com.example
|
|
2900
|
+
import androidx.compose.runtime.Composable
|
|
2901
|
+
|
|
2902
|
+
@Composable
|
|
2903
|
+
fun Counter(label: String, onIncrement: () -> Unit) {
|
|
2904
|
+
}
|
|
2905
|
+
`
|
|
2906
|
+
)
|
|
2907
|
+
|
|
2908
|
+
await generateKotlinSources({ root, dest })
|
|
2909
|
+
|
|
2910
|
+
const updatedRoot = readFileSync(rootGradle, 'utf8')
|
|
2911
|
+
const updatedApp = readFileSync(appGradle, 'utf8')
|
|
2912
|
+
expect(updatedRoot).toContain('org.jetbrains.kotlin.plugin.compose')
|
|
2913
|
+
expect(updatedApp).toContain('apply plugin: "org.jetbrains.kotlin.plugin.compose"')
|
|
2914
|
+
expect(updatedApp).toContain('compose true')
|
|
2915
|
+
expect(updatedApp).toContain('androidx.compose.ui:ui')
|
|
2916
|
+
})
|
|
2917
|
+
|
|
2918
|
+
it('adds material-icons-core only when a kotlin source imports material icons', async () => {
|
|
2919
|
+
const root = mkdtempSync(join(tmpdir(), 'vxrn-compose-icons-root-'))
|
|
2920
|
+
const dest = mkdtempSync(join(tmpdir(), 'vxrn-compose-icons-dest-'))
|
|
2921
|
+
const appDir = join(dest, 'app')
|
|
2922
|
+
const appGradle = join(appDir, 'build.gradle')
|
|
2923
|
+
mkdirSync(appDir, { recursive: true })
|
|
2924
|
+
|
|
2925
|
+
const initialAppGradle = `apply plugin: "org.jetbrains.kotlin.android"
|
|
2926
|
+
android {
|
|
2927
|
+
namespace "com.example"
|
|
2928
|
+
}
|
|
2929
|
+
dependencies {
|
|
2930
|
+
implementation("com.facebook.react:react-android")
|
|
2931
|
+
}
|
|
2932
|
+
`
|
|
2933
|
+
writeFileSync(appGradle, initialAppGradle)
|
|
2934
|
+
writeFileSync(
|
|
2935
|
+
join(root, 'Counter.kt'),
|
|
2936
|
+
`package com.example\nimport androidx.compose.runtime.Composable\n\n@Composable\nfun Counter(label: String) {\n}\n`
|
|
2937
|
+
)
|
|
2938
|
+
|
|
2939
|
+
await generateKotlinSources({ root, dest })
|
|
2940
|
+
|
|
2941
|
+
expect(readFileSync(appGradle, 'utf8')).not.toContain('material-icons-core')
|
|
2942
|
+
|
|
2943
|
+
writeFileSync(
|
|
2944
|
+
join(root, 'Resources.kt'),
|
|
2945
|
+
`package com.example\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.runtime.Composable\n\n@Composable\nfun Resources() {\n}\n`
|
|
2946
|
+
)
|
|
2947
|
+
|
|
2948
|
+
await generateKotlinSources({ root, dest })
|
|
2949
|
+
|
|
2950
|
+
const updated = readFileSync(appGradle, 'utf8')
|
|
2951
|
+
expect(updated).toContain('androidx.compose.material:material-icons-core:$materialIconsVersion')
|
|
2952
|
+
expect(updated).toContain('materialIconsVersion')
|
|
2953
|
+
// the base block still lands once on a re-prebuild
|
|
2954
|
+
expect(updated.split('androidx.compose.ui:ui').length - 1).toBe(1)
|
|
2955
|
+
|
|
2956
|
+
await generateKotlinSources({ root, dest })
|
|
2957
|
+
|
|
2958
|
+
expect(readFileSync(appGradle, 'utf8')).toBe(updated)
|
|
2959
|
+
})
|
|
2960
|
+
})
|