also looking at this
test(ios): install the Watch-enabled app and read the APNs environment it signs
#218
6 files
+248
-12
| @@ -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) |