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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/workflows/README.mdmodified+1-1
Changes to .github/workflows/README.md
@@ -2,7 +2,7 @@
22
33| Workflow | What it proves |
44|---|---|
5| [CI](./ci.yml) | Lint, typecheck, the Zig and TypeScript suites, and that the generated iOS, Watch, Live Activity and Android projects still **compile** |
5| [CI](./ci.yml) | Lint, typecheck, the Zig and TypeScript suites, and that the generated iOS, Watch, Live Activity and Android projects still **compile**; that a Watch-enabled iOS app **installs** on a simulator; and that release builds sign the production APNs environment |
66| [Mobile E2E Testing](./mobile-e2e.yml) | That a generated app **runs**: a call from JavaScript reaches native code and comes back, on a real simulator and a real emulator |
77| [Native Package Lifecycle](./native-lifecycle.yml) | Install, update, rollback and uninstall of the packaged app on macOS, Linux and Windows |
88| [Benchmark](./benchmarks.yml) | Startup and runtime numbers, tracked over time |
.github/workflows/ci.ymlmodified+4-2
Changes to .github/workflows/ci.yml
@@ -153,8 +153,10 @@ jobs:
153153 run: bun install
154154
155155 # Build generated iOS, Live Activity, and Watch targets against device
156 # SDKs. A sibling iOS fixture avoids the embedded-Watch scheme's runtime
157 # requirement while compiling the same capability-heavy Swift source.
156 # SDKs; build the Watch-enabled app with its Watch app embedded and
157 # install it on an iOS simulator, using the watchOS simulator runtime the
158 # macOS image ships (#193, #194, #195); and read the aps-environment
159 # Debug and Release builds actually sign with (#196).
158160 - name: Compile generated iOS and Watch projects
159161 working-directory: packages/ios
160162 run: bun run test:templates
packages/ios/README.mdmodified+15-4
Changes to packages/ios/README.md
@@ -620,10 +620,21 @@ On macOS with Xcode and XcodeGen installed, run `bun run test:templates` from
620620`packages/ios`. It generates a minimal app, a capability-heavy iOS app, and the
621621same capability-heavy app with a Watch companion. It builds iOS, Live Activity,
622622Watch extension, and Watch app targets against device SDKs with code signing
623disabled. The Watch-enabled iOS target is not built because Xcode requires a
624watchOS runtime to resolve its embedded-app graph; its identical Swift source
625is built in the sibling capability fixture. This checks the actual generated
626Swift and Xcode projects; `bun test` checks the builder's behavior separately.
623disabled.
624
625The Watch-enabled app is also built the way Xcode builds it for a person, with
626the Watch app embedded. The script checks that the bundle carries
627`Watch/<App>Watch.app` with `WKWatchKitApp` and its WatchKit extension, then
628installs the app on an iOS simulator. That needs a watchOS simulator runtime
629matching the selected Xcode (`xcodebuild -downloadPlatform watchOS`), and the
630script fails without one rather than skipping.
631
632Finally, a push-enabled app is built for the simulator in Debug and Release with
633ad-hoc signing. The script reads the `aps-environment` Xcode actually signed
634into each: `development` for Debug, `production` for Release.
635
636This checks the actual generated Swift and Xcode projects; `bun test` checks the
637builder's behavior separately.
627638Temporary projects are removed after the run. Pass `--keep` to retain them for
628639debugging.
629640
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)
packages/ios/scripts/watch-bundle.test.tsadded+61-0
Changes to packages/ios/scripts/watch-bundle.test.ts
@@ -0,0 +1,61 @@
1import { afterEach, describe, expect, it } from 'bun:test'
2import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { watchCompanionProblems } from './watch-bundle'
6
7const roots: string[] = []
8afterEach(() => {
9 for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
10})
11
12/** A built app's directory shape, with plists served from memory. */
13function bundle(parts: { watch?: boolean, extension?: boolean }) {
14 const root = mkdtempSync(join(tmpdir(), 'craft-watch-bundle-'))
15 roots.push(root)
16 const app = join(root, 'Probe.app')
17 mkdirSync(app, { recursive: true })
18 if (parts.watch) mkdirSync(join(app, 'Watch', 'ProbeWatch.app'), { recursive: true })
19 if (parts.extension) mkdirSync(join(app, 'Watch', 'ProbeWatch.app', 'PlugIns', 'ProbeWatchExtension.appex'), { recursive: true })
20 return app
21}
22
23const complete = (path: string): Record<string, unknown> | null =>
24 path.includes('.appex')
25 ? { NSExtension: { NSExtensionPointIdentifier: 'com.apple.watchkit' } }
26 : { WKWatchKitApp: true, WKCompanionAppBundleIdentifier: 'dev.craft.probe' }
27
28describe('watchCompanionProblems', () => {
29 it('passes the WatchKit 2 companion the generator now emits', () => {
30 expect(watchCompanionProblems(bundle({ watch: true, extension: true }), 'Probe', complete)).toEqual([])
31 })
32
33 it('names #194: a Watch app with no WatchKit marker', () => {
34 const noMarker = (path: string) => (path.includes('.appex') ? complete(path) : { WKCompanionAppBundleIdentifier: 'dev.craft.probe' })
35 expect(watchCompanionProblems(bundle({ watch: true, extension: true }), 'Probe', noMarker)).toEqual([
36 'Watch/ProbeWatch.app sets neither WKWatchKitApp nor WKApplication to true, so iOS will not install it (#194)',
37 ])
38 })
39
40 it('accepts WKApplication, the single-target marker, in place of WKWatchKitApp', () => {
41 const modern = (path: string) => (path.includes('.appex') ? complete(path) : { WKApplication: true })
42 expect(watchCompanionProblems(bundle({ watch: true, extension: true }), 'Probe', modern)).toEqual([])
43 })
44
45 it('names #195: a Watch app with no extension inside it', () => {
46 expect(watchCompanionProblems(bundle({ watch: true }), 'Probe', complete)).toEqual([
47 'Watch/ProbeWatch.app has no PlugIns/ProbeWatchExtension.appex, so it is not a WatchKit 2 app (#195)',
48 ])
49 })
50
51 it('says when there is no Watch app to check at all', () => {
52 expect(watchCompanionProblems(bundle({}), 'Probe', complete)).toEqual(['Probe.app embeds no Watch/ProbeWatch.app'])
53 })
54
55 it('checks the extension is a WatchKit extension, not merely present', () => {
56 const wrongPoint = (path: string) => (path.includes('.appex') ? { NSExtension: { NSExtensionPointIdentifier: 'com.apple.widgetkit-extension' } } : complete(path))
57 expect(watchCompanionProblems(bundle({ watch: true, extension: true }), 'Probe', wrongPoint)).toEqual([
58 'ProbeWatchExtension.appex declares extension point "com.apple.widgetkit-extension", not com.apple.watchkit',
59 ])
60 })
61})
packages/ios/scripts/watch-bundle.tsadded+55-0
Changes to packages/ios/scripts/watch-bundle.ts
@@ -0,0 +1,55 @@
1import { existsSync } from 'node:fs'
2import { join } from 'node:path'
3
4/** A plist as JSON, or null when it cannot be read. */
5export type PlistReader = (path: string) => Record<string, unknown> | null
6
7/**
8 * `plutil`, which reads the binary plists Xcode writes into a built bundle.
9 * macOS only, like the builds whose output it reads.
10 */
11export const readPlistWithPlutil: PlistReader = (path) => {
12 const converted = Bun.spawnSync(['plutil', '-convert', 'json', '-o', '-', path])
13 if (converted.exitCode !== 0) return null
14 try {
15 return JSON.parse(converted.stdout.toString()) as Record<string, unknown>
16 }
17 catch {
18 return null
19 }
20}
21
22/**
23 * Why a built iOS app's embedded Watch companion would not install, one line
24 * per reason.
25 *
26 * Each line is a message `simctl install` gave for a generated app (#194,
27 * #195), checked in the bundle first so a failure names the missing piece
28 * rather than only quoting simctl. The install itself still runs afterwards;
29 * this does not replace it.
30 */
31export function watchCompanionProblems(app: string, name: string, readPlist: PlistReader = readPlistWithPlutil): string[] {
32 const watch = join(app, 'Watch', `${name}Watch.app`)
33 if (!existsSync(watch))
34 return [`${name}.app embeds no Watch/${name}Watch.app`]
35
36 const problems: string[] = []
37 const watchInfo = readPlist(join(watch, 'Info.plist'))
38 if (!watchInfo)
39 problems.push(`Watch/${name}Watch.app has no readable Info.plist`)
40 else if (watchInfo.WKWatchKitApp !== true && watchInfo.WKApplication !== true)
41 problems.push(`Watch/${name}Watch.app sets neither WKWatchKitApp nor WKApplication to true, so iOS will not install it (#194)`)
42
43 const extension = join(watch, 'PlugIns', `${name}WatchExtension.appex`)
44 if (!existsSync(extension)) {
45 problems.push(`Watch/${name}Watch.app has no PlugIns/${name}WatchExtension.appex, so it is not a WatchKit 2 app (#195)`)
46 return problems
47 }
48
49 const extensionInfo = readPlist(join(extension, 'Info.plist'))
50 const point = (extensionInfo?.NSExtension as Record<string, unknown> | undefined)?.NSExtensionPointIdentifier
51 if (point !== 'com.apple.watchkit')
52 problems.push(`${name}WatchExtension.appex declares extension point ${JSON.stringify(point)}, not com.apple.watchkit`)
53
54 return problems
55}