ReviewOS

also looking at this

craft-native/craft

test(ios): install the Watch-enabled app and read the APNs environment it signs

#218
Merged glennmichael123 wants to merge fix/ios-apns-environment-resolved into main
6 files +248 -12
packages/ios/scripts/compile-templates.tsmodified+112-5
Changes to packages/ios/scripts/compile-templates.ts
@@ -1,7 +1,9 @@
11import { mkdtempSync, rmSync } from 'node:fs'
22import { tmpdir } from 'node:os'
33import { join } from 'node:path'
4import { init, type CraftConfig } from '../src/index'
4import { Glob } from 'bun'
5import { bootSimulator, init, pickSimulator, type CraftConfig } from '../src/index'
6import { watchCompanionProblems } from './watch-bundle'
57
68interface CompileFixture {
79 bundleId: string
@@ -56,6 +58,13 @@ const fixtures: CompileFixture[] = [
5658 },
5759]
5860
61/** Push on and nothing else, so the entitlement builds stay quick. */
62const pushFixture: CompileFixture = {
63 name: 'PushProbe',
64 bundleId: 'dev.craft.templates.push',
65 config: { enablePushNotifications: true },
66}
67
5968const keepProjects = process.argv.includes('--keep')
6069const workspace = mkdtempSync(join(tmpdir(), 'craft-ios-templates-'))
6170
@@ -71,6 +80,90 @@ function run(args: string[], cwd: string, label: string): void {
7180 throw new Error(`${label} exited with ${result.exitCode}`)
7281}
7382
83/**
84 * The `aps-environment` a configuration actually signs with (#196).
85 *
86 * The generator writes `$(CRAFT_APNS_ENVIRONMENT)` into the entitlements and
87 * sets it per configuration in project.yml. `index.test.ts` checks both
88 * strings, which proves what was written, not what Xcode makes of it. An
89 * entitlement Xcode does not expand, or a configuration name xcodegen maps
90 * differently, would pass those tests and still ship `development` to the
91 * App Store, the exact bug reported.
92 *
93 * So this builds for the simulator with ad-hoc signing, which needs no team
94 * and still runs entitlement processing, and reads the processed entitlements
95 * Xcode wrote (`<app>.app-Simulated.xcent`). The substitution is the same one
96 * device signing performs; only the signature differs.
97 */
98function signedApnsEnvironment(output: string, projectName: string, configuration: 'Debug' | 'Release'): string {
99 run([
100 'xcodebuild',
101 '-quiet',
102 '-project', `${projectName}.xcodeproj`,
103 '-target', projectName,
104 '-configuration', configuration,
105 '-sdk', 'iphonesimulator',
106 'CODE_SIGN_IDENTITY=-',
107 'CODE_SIGNING_REQUIRED=NO',
108 'build',
109 ], output, `Signing ${projectName} (${configuration}) to read its entitlements`)
110
111 const pattern = `build/${projectName}.build/${configuration}-iphonesimulator/${projectName}.build/${projectName}.app-Simulated.xcent`
112 const [path] = [...new Glob(pattern).scanSync({ cwd: output, absolute: true })]
113 if (!path)
114 throw new Error(`${configuration} produced no ${pattern}; entitlement processing did not run`)
115
116 const converted = Bun.spawnSync(['plutil', '-convert', 'json', '-o', '-', path])
117 if (converted.exitCode !== 0)
118 throw new Error(`plutil could not read ${path}: ${converted.stderr.toString()}`)
119 const environment = (JSON.parse(converted.stdout.toString()) as Record<string, unknown>)['aps-environment']
120 return typeof environment === 'string' ? environment : `(${JSON.stringify(environment)})`
121}
122
123/**
124 * Build the Watch-enabled app the way Xcode builds it for a person, with the
125 * Watch app embedded, and install it on an iOS simulator (#193, #194, #195).
126 *
127 * The three reports came from exactly this path. The phone target did not
128 * compile, then the built app would not install ("does not have a
129 * WKWatchKitApp or WKApplication key", then "not a WatchKit 2 app"). Building
130 * the Watch targets on their own, below, proves they compile and proves
131 * nothing about the bundle they end up in. So the scheme builds the whole
132 * embedded graph, `watchCompanionProblems` names any missing piece of the
133 * bundle, and `simctl install` has the last word.
134 *
135 * It needs a watchOS simulator runtime matching the selected Xcode, and
136 * refuses without one rather than skipping: Xcode's error names the version.
137 * GitHub's macOS images ship them.
138 */
139async function installEmbeddedWatchApp(output: string, projectName: string, bundleId: string): Promise<void> {
140 const derived = join(output, 'DerivedData-embedded')
141 run([
142 'xcodebuild',
143 '-quiet',
144 '-project', `${projectName}.xcodeproj`,
145 '-scheme', projectName,
146 '-configuration', 'Debug',
147 '-destination', 'generic/platform=iOS Simulator',
148 '-derivedDataPath', derived,
149 'CODE_SIGNING_ALLOWED=NO',
150 'build',
151 ], output, `Building ${projectName} with its Watch app embedded`)
152
153 const app = join(derived, 'Build', 'Products', 'Debug-iphonesimulator', `${projectName}.app`)
154 const problems = watchCompanionProblems(app, projectName)
155 if (problems.length)
156 throw new Error(`${projectName}.app: ${problems.join('; ')}`)
157
158 const device = await pickSimulator()
159 if (!device)
160 throw new Error('no iOS simulator is available to install the Watch-enabled app on')
161 await bootSimulator(device)
162 run(['xcrun', 'simctl', 'install', device.udid, app], output, `Installing ${projectName}.app on ${device.name}`)
163 run(['xcrun', 'simctl', 'uninstall', device.udid, bundleId], output, `Removing ${projectName}.app again`)
164 console.log(`ok: ${projectName}.app installed with its Watch companion`)
165}
166
74167function buildTarget(output: string, projectName: string, targetName: string, sdk: string): void {
75168 run([
76169 'xcodebuild',
@@ -96,10 +189,11 @@ try {
96189 })
97190
98191 run(['xcodegen', 'generate'], output, `Generating ${fixture.name}.xcodeproj`)
99 // The app target embeds the Watch app when enabled. Xcode refuses to
100 // resolve that mixed-platform graph without a watchOS runtime, even for
101 // device-SDK builds, so compile the identical iOS source in the sibling
102 // capability fixture and test Watch targets directly here.
192 // The app target embeds the Watch app when enabled, and building it for a
193 // device SDK by target cannot resolve that mixed-platform graph, so the
194 // device-SDK compile of the identical iOS source happens in the sibling
195 // capability fixture. The embedded graph is built and installed on a
196 // simulator by installEmbeddedWatchApp instead.
103197 if (!fixture.config?.enableWatchApp)
104198 buildTarget(output, fixture.name, fixture.name, 'iphoneos')
105199
@@ -109,8 +203,21 @@ try {
109203 if (fixture.config?.enableWatchApp) {
110204 buildTarget(output, fixture.name, `${fixture.name}WatchExtension`, 'watchos')
111205 buildTarget(output, fixture.name, `${fixture.name}Watch`, 'watchos')
206 await installEmbeddedWatchApp(output, fixture.name, fixture.bundleId)
112207 }
113208 }
209
210 // A Release build is what gets archived for the App Store and TestFlight,
211 // and APNs refuses production pushes to an app signed for development.
212 const pushOutput = join(workspace, pushFixture.name)
213 await init({ name: pushFixture.name, bundleId: pushFixture.bundleId, output: pushOutput, config: pushFixture.config, runtimeDir: null })
214 run(['xcodegen', 'generate'], pushOutput, `Generating ${pushFixture.name}.xcodeproj`)
215 for (const [configuration, expected] of [['Debug', 'development'], ['Release', 'production']] as const) {
216 const signed = signedApnsEnvironment(pushOutput, pushFixture.name, configuration)
217 if (signed !== expected)
218 throw new Error(`${configuration} signs aps-environment=${signed}, expected ${expected}`)
219 console.log(`ok: ${configuration} signs aps-environment=${signed}`)
220 }
114221}
115222finally {
116223 if (keepProjects)