also looking at this
test(ios): install the Watch-enabled app and read the APNs environment it signs
#218
6 files
+248
-12
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | | Workflow | What it proves | |
| 4 | 4 | |---|---| |
| 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 | | |
| 6 | 6 | | [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 | |
| 7 | 7 | | [Native Package Lifecycle](./native-lifecycle.yml) | Install, update, rollback and uninstall of the packaged app on macOS, Linux and Windows | |
| 8 | 8 | | [Benchmark](./benchmarks.yml) | Startup and runtime numbers, tracked over time | |
| @@ -153,8 +153,10 @@ jobs: | ||
| 153 | 153 | run: bun install |
| 154 | 154 | |
| 155 | 155 | # 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). | |
| 158 | 160 | - name: Compile generated iOS and Watch projects |
| 159 | 161 | working-directory: packages/ios |
| 160 | 162 | run: bun run test:templates |
| @@ -620,10 +620,21 @@ On macOS with Xcode and XcodeGen installed, run `bun run test:templates` from | ||
| 620 | 620 | `packages/ios`. It generates a minimal app, a capability-heavy iOS app, and the |
| 621 | 621 | same capability-heavy app with a Watch companion. It builds iOS, Live Activity, |
| 622 | 622 | Watch extension, and Watch app targets against device SDKs with code signing |
| 623 | disabled. The Watch-enabled iOS target is not built because Xcode requires a | |
| 624 | watchOS runtime to resolve its embedded-app graph; its identical Swift source | |
| 625 | is built in the sibling capability fixture. This checks the actual generated | |
| 626 | Swift and Xcode projects; `bun test` checks the builder's behavior separately. | |
| 623 | disabled. | |
| 624 | ||
| 625 | The Watch-enabled app is also built the way Xcode builds it for a person, with | |
| 626 | the Watch app embedded. The script checks that the bundle carries | |
| 627 | `Watch/<App>Watch.app` with `WKWatchKitApp` and its WatchKit extension, then | |
| 628 | installs the app on an iOS simulator. That needs a watchOS simulator runtime | |
| 629 | matching the selected Xcode (`xcodebuild -downloadPlatform watchOS`), and the | |
| 630 | script fails without one rather than skipping. | |
| 631 | ||
| 632 | Finally, a push-enabled app is built for the simulator in Debug and Release with | |
| 633 | ad-hoc signing. The script reads the `aps-environment` Xcode actually signed | |
| 634 | into each: `development` for Debug, `production` for Release. | |
| 635 | ||
| 636 | This checks the actual generated Swift and Xcode projects; `bun test` checks the | |
| 637 | builder's behavior separately. | |
| 627 | 638 | Temporary projects are removed after the run. Pass `--keep` to retain them for |
| 628 | 639 | debugging. |
| 629 | 640 | |
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | import { mkdtempSync, rmSync } from 'node:fs' |
| 2 | 2 | import { tmpdir } from 'node:os' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | import { init, type CraftConfig } from '../src/index' | |
| 4 | import { Glob } from 'bun' | |
| 5 | import { bootSimulator, init, pickSimulator, type CraftConfig } from '../src/index' | |
| 6 | import { watchCompanionProblems } from './watch-bundle' | |
| 5 | 7 | |
| 6 | 8 | interface CompileFixture { |
| 7 | 9 | bundleId: string |
| @@ -56,6 +58,13 @@ const fixtures: CompileFixture[] = [ | ||
| 56 | 58 | }, |
| 57 | 59 | ] |
| 58 | 60 | |
| 61 | /** Push on and nothing else, so the entitlement builds stay quick. */ | |
| 62 | const pushFixture: CompileFixture = { | |
| 63 | name: 'PushProbe', | |
| 64 | bundleId: 'dev.craft.templates.push', | |
| 65 | config: { enablePushNotifications: true }, | |
| 66 | } | |
| 67 | ||
| 59 | 68 | const keepProjects = process.argv.includes('--keep') |
| 60 | 69 | const workspace = mkdtempSync(join(tmpdir(), 'craft-ios-templates-')) |
| 61 | 70 | |
| @@ -71,6 +80,90 @@ function run(args: string[], cwd: string, label: string): void { | ||
| 71 | 80 | throw new Error(`${label} exited with ${result.exitCode}`) |
| 72 | 81 | } |
| 73 | 82 | |
| 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 | */ | |
| 98 | function 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 | */ | |
| 139 | async 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 | ||
| 74 | 167 | function buildTarget(output: string, projectName: string, targetName: string, sdk: string): void { |
| 75 | 168 | run([ |
| 76 | 169 | 'xcodebuild', |
| @@ -96,10 +189,11 @@ try { | ||
| 96 | 189 | }) |
| 97 | 190 | |
| 98 | 191 | 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. | |
| 103 | 197 | if (!fixture.config?.enableWatchApp) |
| 104 | 198 | buildTarget(output, fixture.name, fixture.name, 'iphoneos') |
| 105 | 199 | |
| @@ -109,8 +203,21 @@ try { | ||
| 109 | 203 | if (fixture.config?.enableWatchApp) { |
| 110 | 204 | buildTarget(output, fixture.name, `${fixture.name}WatchExtension`, 'watchos') |
| 111 | 205 | buildTarget(output, fixture.name, `${fixture.name}Watch`, 'watchos') |
| 206 | await installEmbeddedWatchApp(output, fixture.name, fixture.bundleId) | |
| 112 | 207 | } |
| 113 | 208 | } |
| 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 | } | |
| 114 | 221 | } |
| 115 | 222 | finally { |
| 116 | 223 | if (keepProjects) |
| @@ -0,0 +1,61 @@ | ||
| 1 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 2 | import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { watchCompanionProblems } from './watch-bundle' | |
| 6 | ||
| 7 | const roots: string[] = [] | |
| 8 | afterEach(() => { | |
| 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. */ | |
| 13 | function 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 | ||
| 23 | const 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 | ||
| 28 | describe('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 | }) | |
| @@ -0,0 +1,55 @@ | ||
| 1 | import { existsSync } from 'node:fs' | |
| 2 | import { join } from 'node:path' | |
| 3 | ||
| 4 | /** A plist as JSON, or null when it cannot be read. */ | |
| 5 | export 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 | */ | |
| 11 | export 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 | */ | |
| 31 | export 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 | } | |