vxrn 2.0.0-beta.99.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 +1116 -178
- package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
- package/dist/exports/prebuildWithoutExpo.native.js +1592 -243
- 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 +1588 -48
- package/src/exports/prebuildWithoutExpo.ts +1573 -213
- 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'"
|
|
@@ -199,6 +229,158 @@ ${APP_DELEGATE_PBXPROJ}`,
|
|
|
199
229
|
).toThrow('lost its LSRequiresIPhoneOS anchor')
|
|
200
230
|
expect(infoPlist.content).toContain('<key>NSCameraUsageDescription</key>')
|
|
201
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
|
+
)
|
|
202
384
|
|
|
203
385
|
const androidManifest = renderPrebuildFile({
|
|
204
386
|
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
@@ -210,6 +392,12 @@ ${APP_DELEGATE_PBXPROJ}`,
|
|
|
210
392
|
expect(androidManifest.content).toContain(
|
|
211
393
|
'<uses-permission android:name="android.permission.CAMERA" />'
|
|
212
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
|
+
)
|
|
213
401
|
expect(androidManifest.content).toContain(
|
|
214
402
|
'<action android:name="android.intent.action.VIEW" />'
|
|
215
403
|
)
|
|
@@ -241,7 +429,8 @@ ${APP_DELEGATE_PBXPROJ}`,
|
|
|
241
429
|
const androidSettings = renderPrebuildFile({
|
|
242
430
|
relativePath: 'settings.gradle',
|
|
243
431
|
content: `pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
|
|
244
|
-
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() }`,
|
|
245
434
|
platform: 'android',
|
|
246
435
|
app,
|
|
247
436
|
})
|
|
@@ -254,6 +443,48 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
254
443
|
expect(androidSettings.content).not.toContain('../node_modules')
|
|
255
444
|
})
|
|
256
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
|
+
|
|
257
488
|
it('stamps marketing and build versions from the manifest', () => {
|
|
258
489
|
const stamped = {
|
|
259
490
|
...app,
|
|
@@ -308,8 +539,37 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
308
539
|
expect(android.content).toContain('versionName "1.0"')
|
|
309
540
|
})
|
|
310
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
|
+
|
|
311
571
|
it('omits camera entries when imagePicker.camera is unset', () => {
|
|
312
|
-
const bare = { ...app, imagePicker: undefined, notifications: undefined }
|
|
572
|
+
const bare = { ...app, imagePicker: undefined, speech: undefined, notifications: undefined }
|
|
313
573
|
const infoPlist = renderPrebuildFile({
|
|
314
574
|
relativePath: 'HelloWorld/Info.plist',
|
|
315
575
|
content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
|
|
@@ -317,6 +577,7 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
317
577
|
app: bare,
|
|
318
578
|
})
|
|
319
579
|
expect(infoPlist.content).not.toContain('NSCameraUsageDescription')
|
|
580
|
+
expect(infoPlist.content).not.toContain('NSMicrophoneUsageDescription')
|
|
320
581
|
const androidManifest = renderPrebuildFile({
|
|
321
582
|
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
322
583
|
content:
|
|
@@ -325,6 +586,90 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
325
586
|
app: bare,
|
|
326
587
|
})
|
|
327
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')
|
|
328
673
|
})
|
|
329
674
|
|
|
330
675
|
it('stamps the maps key and flag only when googleMapsApiKey is set', () => {
|
|
@@ -404,7 +749,7 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
404
749
|
})
|
|
405
750
|
|
|
406
751
|
it('fails loudly when a notification anchor is missing', () => {
|
|
407
|
-
const noCamera = { ...app, imagePicker: undefined }
|
|
752
|
+
const noCamera = { ...app, imagePicker: undefined, speech: undefined }
|
|
408
753
|
expect(() =>
|
|
409
754
|
renderPrebuildFile({
|
|
410
755
|
relativePath: 'app/src/main/AndroidManifest.xml',
|
|
@@ -488,17 +833,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
|
488
833
|
|
|
489
834
|
return true
|
|
490
835
|
}
|
|
491
|
-
}
|
|
836
|
+
}
|
|
837
|
+
class ReactNativeDelegate:${templateRnDelegate}`,
|
|
492
838
|
platform: 'ios',
|
|
493
839
|
app: push,
|
|
494
840
|
})
|
|
495
|
-
|
|
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(
|
|
496
844
|
'didRegisterForRemoteNotificationsWithDeviceToken'
|
|
497
845
|
)
|
|
498
|
-
expect(appDelegate.content).toContain('OneNativePushTokenDidRegister')
|
|
499
|
-
expect(appDelegate.content).toContain(
|
|
500
|
-
'didFailToRegisterForRemoteNotificationsWithError'
|
|
501
|
-
)
|
|
502
846
|
|
|
503
847
|
const bareProps = renderPrebuildFile({
|
|
504
848
|
relativePath: 'gradle.properties',
|
|
@@ -566,11 +910,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
|
566
910
|
app,
|
|
567
911
|
})
|
|
568
912
|
).toThrow(
|
|
569
|
-
'[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'
|
|
570
914
|
)
|
|
571
915
|
})
|
|
572
916
|
|
|
573
|
-
it('resolves
|
|
917
|
+
it('resolves build tools from their owning packages without hoisting', () => {
|
|
574
918
|
const root = mkdtempSync(join(tmpdir(), 'vxrn-gradle-plugin-'))
|
|
575
919
|
const settingsDir = join(root, 'android')
|
|
576
920
|
mkdirSync(settingsDir, { recursive: true })
|
|
@@ -593,10 +937,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
|
593
937
|
JSON.stringify({ name: '@react-native/gradle-plugin', version: '0.0.0' })
|
|
594
938
|
)
|
|
595
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
|
+
|
|
596
960
|
const rendered = renderPrebuildFile({
|
|
597
961
|
relativePath: 'settings.gradle',
|
|
598
962
|
content: `pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
|
|
599
|
-
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() }`,
|
|
600
965
|
platform: 'android',
|
|
601
966
|
app,
|
|
602
967
|
})
|
|
@@ -609,6 +974,98 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
|
|
|
609
974
|
encoding: 'utf8',
|
|
610
975
|
}).trim()
|
|
611
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()
|
|
612
1069
|
|
|
613
1070
|
// negative control: a bare top-level resolution fails here, proving the
|
|
614
1071
|
// fixture is genuinely non-hoisted and the old snippet would break
|
|
@@ -717,8 +1174,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
|
717
1174
|
const rendered = renderPrebuildFile({
|
|
718
1175
|
relativePath: 'HelloWorld/AppDelegate.swift',
|
|
719
1176
|
content: `${templateAppDelegate}
|
|
720
|
-
class ReactNativeDelegate
|
|
721
|
-
}`,
|
|
1177
|
+
class ReactNativeDelegate:${templateRnDelegate}`,
|
|
722
1178
|
platform: 'ios',
|
|
723
1179
|
app,
|
|
724
1180
|
})
|
|
@@ -727,10 +1183,18 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
|
|
727
1183
|
expect(rendered.content).not.toContain('startReactNative')
|
|
728
1184
|
expect(rendered.content).not.toContain('UIWindow(frame:')
|
|
729
1185
|
expect(rendered.content).not.toContain('var window')
|
|
730
|
-
expect(rendered.content).
|
|
731
|
-
expect(rendered.content).toContain('
|
|
732
|
-
expect(rendered.content).toContain('
|
|
733
|
-
|
|
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')
|
|
734
1198
|
})
|
|
735
1199
|
|
|
736
1200
|
it('throws instead of shipping a non-scene AppDelegate', () => {
|
|
@@ -765,6 +1229,7 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
|
|
765
1229
|
expect(sceneDelegate).toContain('openURLContexts')
|
|
766
1230
|
expect(sceneDelegate).toContain('continue userActivity')
|
|
767
1231
|
expect(sceneDelegate).toContain('RCTLinkingManager')
|
|
1232
|
+
expect(sceneDelegate).toContain('appDelegate.reactNativeFactory')
|
|
768
1233
|
})
|
|
769
1234
|
|
|
770
1235
|
it('throws when the template loses a scene anchor', () => {
|
|
@@ -786,10 +1251,79 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
|
|
786
1251
|
).toThrow('AppDelegate.swift anchor')
|
|
787
1252
|
})
|
|
788
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
|
+
|
|
789
1318
|
it('generates a scene project from the real template', async () => {
|
|
790
1319
|
const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
|
791
1320
|
const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-scene-'))
|
|
792
|
-
await generateForPlatform(
|
|
1321
|
+
await generateForPlatform(
|
|
1322
|
+
workspaceRoot,
|
|
1323
|
+
'ios',
|
|
1324
|
+
{ ...app, orientation: 'default' },
|
|
1325
|
+
join(output, 'ios')
|
|
1326
|
+
)
|
|
793
1327
|
|
|
794
1328
|
const sceneDelegate = readFileSync(
|
|
795
1329
|
join(output, 'ios', 'MyApp', 'SceneDelegate.swift'),
|
|
@@ -808,6 +1342,21 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
|
|
808
1342
|
const infoPlist = readFileSync(join(output, 'ios', 'MyApp', 'Info.plist'), 'utf8')
|
|
809
1343
|
expect(infoPlist).toContain('<key>UIApplicationSceneManifest</key>')
|
|
810
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>')
|
|
811
1360
|
|
|
812
1361
|
const project = readFileSync(
|
|
813
1362
|
join(output, 'ios', 'MyApp.xcodeproj', 'project.pbxproj'),
|
|
@@ -864,24 +1413,16 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
|
|
864
1413
|
})
|
|
865
1414
|
|
|
866
1415
|
describe('community autolink inventory', () => {
|
|
867
|
-
it('discovers
|
|
1416
|
+
it('generates both projects and discovers native packages without an app cli', async () => {
|
|
868
1417
|
const root = mkdtempSync(join(tmpdir(), 'vxrn-autolink-'))
|
|
869
|
-
//
|
|
870
|
-
//
|
|
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.
|
|
871
1420
|
const workspaceModules = fileURLToPath(
|
|
872
1421
|
new URL('../../../../node_modules', import.meta.url)
|
|
873
1422
|
)
|
|
874
1423
|
mkdirSync(join(root, 'node_modules', '@react-native-community'), { recursive: true })
|
|
875
1424
|
const { symlinkSync } = await import('node:fs')
|
|
876
|
-
for (const name of [
|
|
877
|
-
'cli',
|
|
878
|
-
'cli-config',
|
|
879
|
-
'cli-config-android',
|
|
880
|
-
'cli-config-apple',
|
|
881
|
-
'cli-tools',
|
|
882
|
-
'cli-types',
|
|
883
|
-
'template',
|
|
884
|
-
]) {
|
|
1425
|
+
for (const name of ['template']) {
|
|
885
1426
|
symlinkSync(
|
|
886
1427
|
join(workspaceModules, '@react-native-community', name),
|
|
887
1428
|
join(root, 'node_modules', '@react-native-community', name)
|
|
@@ -991,8 +1532,22 @@ describe('generateForPlatform determinism', () => {
|
|
|
991
1532
|
const appWithIcon = {
|
|
992
1533
|
...app,
|
|
993
1534
|
version: '9.9.9',
|
|
994
|
-
ios: { ...app.ios, buildNumber: '4242'
|
|
995
|
-
|
|
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
|
+
},
|
|
996
1551
|
icon: {
|
|
997
1552
|
source: fileURLToPath(
|
|
998
1553
|
new URL('../../../../examples/one-basic/public/app-icon.png', import.meta.url)
|
|
@@ -1044,6 +1599,8 @@ describe('generateForPlatform determinism', () => {
|
|
|
1044
1599
|
expect(generatedPbxproj).toContain('CURRENT_PROJECT_VERSION = 4242;')
|
|
1045
1600
|
expect(generatedPbxproj).not.toContain('MARKETING_VERSION = 1.0;')
|
|
1046
1601
|
expect(generatedPbxproj).not.toContain('CURRENT_PROJECT_VERSION = 1;')
|
|
1602
|
+
expect(generatedPbxproj.match(/ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "TestAlternate";/g))
|
|
1603
|
+
.toHaveLength(2)
|
|
1047
1604
|
const generatedGradle = readFileSync(
|
|
1048
1605
|
join(output, 'android', 'app', 'build.gradle'),
|
|
1049
1606
|
'utf8'
|
|
@@ -1061,10 +1618,23 @@ describe('generateForPlatform determinism', () => {
|
|
|
1061
1618
|
const iosContents: { images: Array<{ filename?: string }> } = JSON.parse(
|
|
1062
1619
|
readFileSync(join(iosIconDir, 'Contents.json'), 'utf8')
|
|
1063
1620
|
)
|
|
1064
|
-
expect(iosContents.images).toHaveLength(
|
|
1621
|
+
expect(iosContents.images).toHaveLength(18)
|
|
1065
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 })
|
|
1066
1628
|
const iosMarketing = await sharp(join(iosIconDir, 'icon-1024.png')).metadata()
|
|
1067
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 })
|
|
1068
1638
|
|
|
1069
1639
|
const androidIcon = await sharp(
|
|
1070
1640
|
join(
|
|
@@ -1080,11 +1650,16 @@ describe('generateForPlatform determinism', () => {
|
|
|
1080
1650
|
).metadata()
|
|
1081
1651
|
expect(androidIcon).toMatchObject({ width: 192, height: 192 })
|
|
1082
1652
|
const iosApp = join(output, 'ios', 'MyApp')
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
+
}
|
|
1088
1663
|
const launchStoryboard = readFileSync(join(iosApp, 'LaunchScreen.storyboard'), 'utf8')
|
|
1089
1664
|
expect(launchStoryboard).toContain('image="Splash"')
|
|
1090
1665
|
expect(launchStoryboard).toContain('firstAttribute="centerX"')
|
|
@@ -1117,6 +1692,21 @@ describe('generateForPlatform determinism', () => {
|
|
|
1117
1692
|
expect(readFileSync(join(androidRes, 'values', 'styles.xml'), 'utf8')).toContain(
|
|
1118
1693
|
'@drawable/launch_screen'
|
|
1119
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
|
+
}
|
|
1120
1710
|
// android 12+ uses the configured splash image, never the launcher icon
|
|
1121
1711
|
const stylesV31 = readFileSync(join(androidRes, 'values-v31', 'styles.xml'), 'utf8')
|
|
1122
1712
|
expect(stylesV31).toContain('@color/splash_background')
|
|
@@ -1126,6 +1716,188 @@ describe('generateForPlatform determinism', () => {
|
|
|
1126
1716
|
expect(stylesV31).not.toContain('ic_launcher')
|
|
1127
1717
|
}, 180000)
|
|
1128
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
|
+
|
|
1129
1901
|
it('regenerates byte-identical projects from the same manifest', async () => {
|
|
1130
1902
|
// root stays the workspace so the installed community template resolves;
|
|
1131
1903
|
// output goes to isolated temp dirs, never the repo.
|
|
@@ -1207,6 +1979,8 @@ describe('generateForPlatform determinism', () => {
|
|
|
1207
1979
|
'utf8'
|
|
1208
1980
|
)
|
|
1209
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\)/)
|
|
1210
1984
|
}, 180000)
|
|
1211
1985
|
})
|
|
1212
1986
|
|
|
@@ -1280,7 +2054,7 @@ describe('ios widgets', () => {
|
|
|
1280
2054
|
const readGenerated = (relativePath: string) =>
|
|
1281
2055
|
readFileSync(join(output, 'ios', relativePath), 'utf8')
|
|
1282
2056
|
const rendered = readGenerated('OneWidgets/OneWidget.swift')
|
|
1283
|
-
// every node type packages/
|
|
2057
|
+
// every node type packages/one/src/platform/widgets/view.ts can emit needs a
|
|
1284
2058
|
// Swift decode path, or the extension renders a blank subtree
|
|
1285
2059
|
for (const type of [
|
|
1286
2060
|
'text',
|
|
@@ -1363,7 +2137,7 @@ describe('ios widgets', () => {
|
|
|
1363
2137
|
await generateForPlatform(
|
|
1364
2138
|
workspaceRoot,
|
|
1365
2139
|
'ios',
|
|
1366
|
-
{ ...widgetsApp, notifications: { push: true } },
|
|
2140
|
+
{ ...widgetsApp, notifications: { push: true, apsEnvironment: 'production' } },
|
|
1367
2141
|
join(output, 'ios')
|
|
1368
2142
|
)
|
|
1369
2143
|
|
|
@@ -1381,12 +2155,122 @@ describe('ios widgets', () => {
|
|
|
1381
2155
|
join(output, 'ios', 'MyApp', 'OneAppWidgets.entitlements'),
|
|
1382
2156
|
'utf8'
|
|
1383
2157
|
)
|
|
1384
|
-
expect(appEntitlements).toContain('<key>aps-environment</key>')
|
|
2158
|
+
expect(appEntitlements).toContain('<key>aps-environment</key>\n\t<string>production</string>')
|
|
1385
2159
|
expect(appEntitlements).toContain('group.dev.one.myapp')
|
|
1386
2160
|
}, 180000)
|
|
1387
2161
|
})
|
|
1388
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
|
+
|
|
1389
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
|
+
|
|
1390
2274
|
it('emits cxx flags for swift packages', async () => {
|
|
1391
2275
|
const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-swift-cxx-'))
|
|
1392
2276
|
const workspaceModules = fileURLToPath(
|
|
@@ -1405,7 +2289,7 @@ describe('swift cxx interop', () => {
|
|
|
1405
2289
|
writeFileSync(join(pkgDir, 'Package.swift'), '// swift-tools-version: 5.9\n')
|
|
1406
2290
|
writeFileSync(
|
|
1407
2291
|
join(pkgDir, 'Sources', 'Feature.swift'),
|
|
1408
|
-
'import
|
|
2292
|
+
'import One\npublic func probe() { OneNativeRegisteredValue.register("x", for: "x") }\n'
|
|
1409
2293
|
)
|
|
1410
2294
|
await generateForPlatform(root, 'ios', app)
|
|
1411
2295
|
const podspec = readFileSync(
|
|
@@ -1415,6 +2299,662 @@ describe('swift cxx interop', () => {
|
|
|
1415
2299
|
expect(podspec).toContain(
|
|
1416
2300
|
'$(inherited) -cxx-interoperability-mode=default -Xcc -std=c++20'
|
|
1417
2301
|
)
|
|
1418
|
-
expect(podspec).toContain('-Xfrontend -import-module -Xfrontend
|
|
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')
|
|
1419
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
|
+
})
|
|
1420
2960
|
})
|