also looking at this
fix(ios): give the generator a runtimeDir option, and run its tests in CI
#108
3 files
+272
-12
| @@ -109,6 +109,34 @@ jobs: | ||
| 109 | 109 | working-directory: packages/typescript |
| 110 | 110 | run: bun run build |
| 111 | 111 | |
| 112 | # `packages/ios` had a test suite that nothing ran. That is how its | |
| 113 | # generator kept a defect nobody saw: `init()` read `CRAFT_IOS_RUNTIME` | |
| 114 | # straight from the environment, so the suite passed 12/12 in a clean shell | |
| 115 | # and 8/12 in a developer's, and CI never had an opinion either way. | |
| 116 | ios-builder: | |
| 117 | runs-on: ubuntu-latest | |
| 118 | ||
| 119 | steps: | |
| 120 | - uses: actions/checkout@v6 | |
| 121 | ||
| 122 | - name: Setup Pantry (provides Bun, Zig, etc.) | |
| 123 | uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64 | |
| 124 | ||
| 125 | - name: Install dependencies | |
| 126 | run: bun install | |
| 127 | ||
| 128 | # Unset deliberately. The suite must not depend on it, and this is the | |
| 129 | # job that would notice if it started to again. | |
| 130 | - name: Test iOS builder | |
| 131 | working-directory: packages/ios | |
| 132 | run: bun test | |
| 133 | env: | |
| 134 | CRAFT_IOS_RUNTIME: '' | |
| 135 | ||
| 136 | - name: Build iOS builder | |
| 137 | working-directory: packages/ios | |
| 138 | run: bun run build | |
| 139 | ||
| 112 | 140 | create-craft: |
| 113 | 141 | runs-on: ubuntu-latest |
| 114 | 142 | |
| @@ -219,7 +247,7 @@ jobs: | ||
| 219 | 247 | run: bun run verify |
| 220 | 248 | |
| 221 | 249 | publish-commit: |
| 222 | needs: [zig-core, typescript-sdk, create-craft, ts-maps, lint, integration] | |
| 250 | needs: [zig-core, typescript-sdk, ios-builder, create-craft, ts-maps, lint, integration] | |
| 223 | 251 | runs-on: ubuntu-latest |
| 224 | 252 | |
| 225 | 253 | steps: |
| @@ -5,6 +5,9 @@ import { describe, expect, it } from 'bun:test' | ||
| 5 | 5 | import { |
| 6 | 6 | build, |
| 7 | 7 | init, |
| 8 | installRuntime, | |
| 9 | renderRuntimeSettings, | |
| 10 | resolveRuntimeDir, | |
| 8 | 11 | orderSimulators, |
| 9 | 12 | renderBackgroundModes, |
| 10 | 13 | renderEntitlements, |
| @@ -83,6 +86,7 @@ describe('Craft iOS builder', () => { | ||
| 83 | 86 | it('generates a production project whose bundled index lives under dist', async () => { |
| 84 | 87 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-project-')) |
| 85 | 88 | await init({ |
| 89 | runtimeDir: null, | |
| 86 | 90 | name: 'WildLoop', |
| 87 | 91 | bundleId: 'org.wildloop.app', |
| 88 | 92 | output, |
| @@ -127,7 +131,7 @@ describe('Craft iOS builder', () => { | ||
| 127 | 131 | const output = join(root, 'ios') |
| 128 | 132 | Bun.spawnSync(['mkdir', '-p', web]) |
| 129 | 133 | writeFileSync(join(web, 'index.html'), '<main>Available offline</main>') |
| 130 | await init({ name: 'WildLoop', bundleId: 'org.wildloop.app', output }) | |
| 134 | await init({ runtimeDir: null, name: 'WildLoop', bundleId: 'org.wildloop.app', output }) | |
| 131 | 135 | await build({ htmlPath: web, devServer: 'https://wildloop.org', output, generateProject: false }) |
| 132 | 136 | |
| 133 | 137 | const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8') |
| @@ -138,6 +142,7 @@ describe('Craft iOS builder', () => { | ||
| 138 | 142 | it('generates a native Live Activity extension when enabled', async () => { |
| 139 | 143 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-live-activity-')) |
| 140 | 144 | await init({ |
| 145 | runtimeDir: null, | |
| 141 | 146 | name: 'WildLoop', |
| 142 | 147 | bundleId: 'org.wildloop.app', |
| 143 | 148 | output, |
| @@ -161,6 +166,7 @@ describe('Craft iOS builder', () => { | ||
| 161 | 166 | it('generates an embedded watchOS companion when enabled', async () => { |
| 162 | 167 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-watch-')) |
| 163 | 168 | await init({ |
| 169 | runtimeDir: null, | |
| 164 | 170 | name: 'WildLoop', |
| 165 | 171 | bundleId: 'org.wildloop.app', |
| 166 | 172 | output, |
| @@ -243,3 +249,140 @@ describe('choosing a simulator', () => { | ||
| 243 | 249 | expect(devices[0]?.name).toBe('iPad Air') |
| 244 | 250 | }) |
| 245 | 251 | }) |
| 252 | ||
| 253 | describe('Zig runtime installation', () => { | |
| 254 | // A runtime directory holding *one* simulator slice, so `installRuntime` | |
| 255 | // takes the `cpSync` branch. The two-slice branch shells out to `lipo`, | |
| 256 | // which needs genuine Mach-O input and does not exist off macOS — it is | |
| 257 | // covered by building a real generated app, not from here. What these do | |
| 258 | // cover is everything around it: resolution, ordering, and the warning. | |
| 259 | function fakeRuntime(archives = ['libcraft-ios.a', 'libcraft-ios-simulator-arm64.a']): string { | |
| 260 | const dir = mkdtempSync(join(tmpdir(), 'craft-rt-')) | |
| 261 | for (const a of archives) writeFileSync(join(dir, a), 'stand-in for an archive') | |
| 262 | return dir | |
| 263 | } | |
| 264 | ||
| 265 | it('resolves an explicit directory, and null means no runtime whatever the environment says', () => { | |
| 266 | const dir = fakeRuntime() | |
| 267 | const saved = process.env.CRAFT_IOS_RUNTIME | |
| 268 | process.env.CRAFT_IOS_RUNTIME = dir | |
| 269 | try { | |
| 270 | // The gap this closes: the suite used to inherit the developer's shell, | |
| 271 | // and went from 12 passing to 8 passing and 4 failing when this variable | |
| 272 | // happened to be set. | |
| 273 | expect(resolveRuntimeDir(null)).toBeNull() | |
| 274 | expect(resolveRuntimeDir(dir)).toBe(dir) | |
| 275 | expect(resolveRuntimeDir()).toBe(dir) | |
| 276 | } | |
| 277 | finally { | |
| 278 | if (saved === undefined) delete process.env.CRAFT_IOS_RUNTIME | |
| 279 | else process.env.CRAFT_IOS_RUNTIME = saved | |
| 280 | } | |
| 281 | }) | |
| 282 | ||
| 283 | it('names the source in the error, so a bad path says which knob set it', () => { | |
| 284 | expect(() => resolveRuntimeDir('/no/such/runtime')).toThrow(/runtimeDir is/) | |
| 285 | }) | |
| 286 | ||
| 287 | it('writes one archive name per SDK, which is what a single -lcraft-ios needs', async () => { | |
| 288 | const output = mkdtempSync(join(tmpdir(), 'craft-out-')) | |
| 289 | expect(await installRuntime(output, fakeRuntime())).toBe(true) | |
| 290 | expect(existsSync(join(output, 'Runtime', 'device', 'libcraft-ios.a'))).toBe(true) | |
| 291 | expect(existsSync(join(output, 'Runtime', 'simulator', 'libcraft-ios.a'))).toBe(true) | |
| 292 | }) | |
| 293 | ||
| 294 | it('warns when only one simulator slice is present, rather than shipping it silently', async () => { | |
| 295 | const warnings: string[] = [] | |
| 296 | const saved = console.warn | |
| 297 | console.warn = (...args: unknown[]) => void warnings.push(args.join(' ')) | |
| 298 | try { | |
| 299 | await installRuntime(mkdtempSync(join(tmpdir(), 'craft-out-')), fakeRuntime()) | |
| 300 | } | |
| 301 | finally { | |
| 302 | console.warn = saved | |
| 303 | } | |
| 304 | // RUNTIME_ARCHIVES' own comment calls this the failure that "only shows up | |
| 305 | // on someone else's laptop", so it must not be silent. | |
| 306 | expect(warnings.join('\n')).toContain('libcraft-ios-simulator-x64.a') | |
| 307 | }) | |
| 308 | ||
| 309 | it('leaves a working install alone when the source directory is incomplete', async () => { | |
| 310 | // The ordering bug: validation ran after the wipe, so a bad runtime dir | |
| 311 | // destroyed the archives already in place and left project.yml linking | |
| 312 | // against a Runtime/ that no longer existed. | |
| 313 | const output = mkdtempSync(join(tmpdir(), 'craft-out-')) | |
| 314 | await installRuntime(output, fakeRuntime()) | |
| 315 | const installed = join(output, 'Runtime', 'device', 'libcraft-ios.a') | |
| 316 | expect(existsSync(installed)).toBe(true) | |
| 317 | ||
| 318 | const empty = mkdtempSync(join(tmpdir(), 'craft-rt-empty-')) | |
| 319 | await expect(installRuntime(output, empty)).rejects.toThrow(/has none of/) | |
| 320 | expect(existsSync(installed)).toBe(true) | |
| 321 | }) | |
| 322 | ||
| 323 | it('renders both SDK search paths and forces the four entry points', () => { | |
| 324 | const settings = renderRuntimeSettings() | |
| 325 | expect(settings).toContain('LIBRARY_SEARCH_PATHS[sdk=iphoneos*]') | |
| 326 | expect(settings).toContain('LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]') | |
| 327 | // Without -u the linker drops the whole archive as unreachable, because | |
| 328 | // nothing in the Swift references these symbols — both seams use dlsym. | |
| 329 | for (const sym of ['handle_action', 'set_webview', 'deliver_result', 'deliver_error']) { | |
| 330 | expect(settings).toContain(`-Wl,-u,_craft_ios_${sym}`) | |
| 331 | } | |
| 332 | // Six-space indent: this is spliced into project.yml under `settings:`. | |
| 333 | for (const line of settings.split('\n')) expect(line.startsWith(' ')).toBe(true) | |
| 334 | }) | |
| 335 | ||
| 336 | it('generates a project whose settings match whether a runtime was installed', async () => { | |
| 337 | const output = mkdtempSync(join(tmpdir(), 'craft-app-')) | |
| 338 | await init({ runtimeDir: fakeRuntime(), name: 'HasRuntime', output }) | |
| 339 | expect(readFileSync(join(output, 'project.yml'), 'utf8')).toContain('-lcraft-ios') | |
| 340 | ||
| 341 | // And re-running without one leaves no orphaned archives claiming otherwise. | |
| 342 | await init({ runtimeDir: null, name: 'HasRuntime', output }) | |
| 343 | expect(readFileSync(join(output, 'project.yml'), 'utf8')).not.toContain('-lcraft-ios') | |
| 344 | expect(existsSync(join(output, 'Runtime'))).toBe(false) | |
| 345 | }) | |
| 346 | }) | |
| 347 | ||
| 348 | describe('runtime staleness', () => { | |
| 349 | function runtimeWith(marker: string): string { | |
| 350 | const dir = mkdtempSync(join(tmpdir(), 'craft-rt-')) | |
| 351 | for (const a of ['libcraft-ios.a', 'libcraft-ios-simulator-arm64.a']) { | |
| 352 | writeFileSync(join(dir, a), marker) | |
| 353 | } | |
| 354 | return dir | |
| 355 | } | |
| 356 | ||
| 357 | it('build picks up a rebuilt runtime instead of relinking the one init copied', async () => { | |
| 358 | const output = mkdtempSync(join(tmpdir(), 'craft-app-')) | |
| 359 | await init({ runtimeDir: runtimeWith('first build'), name: 'Stale', output }) | |
| 360 | const installed = join(output, 'Runtime', 'device', 'libcraft-ios.a') | |
| 361 | expect(readFileSync(installed, 'utf8')).toBe('first build') | |
| 362 | ||
| 363 | // The dev loop: edit Zig, rebuild the archives, run the app again. Before | |
| 364 | // this, xcodebuild relinked the copy from init and the change was absent | |
| 365 | // with no error anywhere. | |
| 366 | await build({ output, runtimeDir: runtimeWith('second build'), generateProject: false }) | |
| 367 | expect(readFileSync(installed, 'utf8')).toBe('second build') | |
| 368 | }) | |
| 369 | ||
| 370 | it('build leaves a runtimeless project alone rather than installing one behind init', async () => { | |
| 371 | const output = mkdtempSync(join(tmpdir(), 'craft-app-')) | |
| 372 | await init({ runtimeDir: null, name: 'NoRuntime', output }) | |
| 373 | expect(existsSync(join(output, 'Runtime'))).toBe(false) | |
| 374 | ||
| 375 | // A runtime is available, but this project does not link one: its | |
| 376 | // project.yml has no link settings, so archives here would be dead weight. | |
| 377 | await build({ output, runtimeDir: runtimeWith('ignored'), generateProject: false }) | |
| 378 | expect(existsSync(join(output, 'Runtime'))).toBe(false) | |
| 379 | }) | |
| 380 | ||
| 381 | it('build keeps the installed runtime when no runtime directory is configured', async () => { | |
| 382 | const output = mkdtempSync(join(tmpdir(), 'craft-app-')) | |
| 383 | await init({ runtimeDir: runtimeWith('from init'), name: 'Keep', output }) | |
| 384 | // A shell that forgot the variable must not quietly turn the runtime off. | |
| 385 | await build({ output, runtimeDir: null, generateProject: false }) | |
| 386 | expect(readFileSync(join(output, 'Runtime', 'device', 'libcraft-ios.a'), 'utf8')).toBe('from init') | |
| 387 | }) | |
| 388 | }) | |
| @@ -91,6 +91,20 @@ export interface InitOptions { | ||
| 91 | 91 | teamId?: string |
| 92 | 92 | output: string |
| 93 | 93 | config?: Partial<CraftConfig> |
| 94 | /** | |
| 95 | * Where the Zig runtime archives live, overriding `CRAFT_IOS_RUNTIME`. | |
| 96 | * | |
| 97 | * `null` means "no runtime, whatever the environment says" — the shape | |
| 98 | * `AppConfig.craftPath` has for `CRAFT_BIN`, and the reason it exists here | |
| 99 | * is the same: a caller that does not opt in should not be steered by an | |
| 100 | * ambient variable. Without it this package's own tests inherited whatever | |
| 101 | * the developer's shell exported, and went from 12 passing to 8 passing and | |
| 102 | * 4 failing when `CRAFT_IOS_RUNTIME` happened to be set. | |
| 103 | * | |
| 104 | * Omitted (`undefined`) keeps the environment lookup, which is what the | |
| 105 | * monorepo dev loop uses. | |
| 106 | */ | |
| 107 | runtimeDir?: string | null | |
| 94 | 108 | } |
| 95 | 109 | |
| 96 | 110 | export interface BuildOptions { |
| @@ -98,6 +112,12 @@ export interface BuildOptions { | ||
| 98 | 112 | devServer?: string |
| 99 | 113 | output: string |
| 100 | 114 | generateProject?: boolean |
| 115 | /** | |
| 116 | * Where to re-read the Zig runtime archives from, overriding | |
| 117 | * `CRAFT_IOS_RUNTIME`. Same meaning as `InitOptions.runtimeDir`; `null` | |
| 118 | * leaves whatever `init` installed exactly as it is. | |
| 119 | */ | |
| 120 | runtimeDir?: string | null | |
| 101 | 121 | } |
| 102 | 122 | |
| 103 | 123 | export interface OpenOptions { |
| @@ -380,12 +400,18 @@ export function syncWebAssets(source: string, output: string): void { | ||
| 380 | 400 | * dev loop, not a lookup path. Shipping the runtime to real apps means putting |
| 381 | 401 | * these archives in the pantry package beside the `craft` binary, which is a |
| 382 | 402 | * distribution decision this function does not make. |
| 403 | * | |
| 404 | * `override` is what `InitOptions.runtimeDir` passes: a path to use instead of | |
| 405 | * the variable, or `null` to declare there is no runtime regardless of what the | |
| 406 | * environment says. `undefined` falls through to `CRAFT_IOS_RUNTIME`. | |
| 383 | 407 | */ |
| 384 | export function resolveRuntimeDir(): string | null { | |
| 385 | const dir = process.env.CRAFT_IOS_RUNTIME | |
| 408 | export function resolveRuntimeDir(override?: string | null): string | null { | |
| 409 | if (override === null) return null | |
| 410 | const dir = override ?? process.env.CRAFT_IOS_RUNTIME | |
| 386 | 411 | if (!dir) return null |
| 387 | 412 | if (!existsSync(dir)) { |
| 388 | throw new Error(`CRAFT_IOS_RUNTIME points at ${dir}, which does not exist.`) | |
| 413 | const source = override === undefined ? 'CRAFT_IOS_RUNTIME points at' : 'runtimeDir is' | |
| 414 | throw new Error(`${source} ${dir}, which does not exist.`) | |
| 389 | 415 | } |
| 390 | 416 | return dir |
| 391 | 417 | } |
| @@ -411,23 +437,44 @@ const RUNTIME_ARCHIVES = { | ||
| 411 | 437 | * Returns true when a runtime was installed. |
| 412 | 438 | */ |
| 413 | 439 | export async function installRuntime(output: string, runtimeDir: string): Promise<boolean> { |
| 414 | const dest = join(output, 'Runtime') | |
| 415 | rmSync(dest, { recursive: true, force: true }) | |
| 416 | ||
| 417 | for (const [sdk, archives] of Object.entries(RUNTIME_ARCHIVES)) { | |
| 440 | // Every SDK resolved before anything is written, and long before the | |
| 441 | // `rmSync` below. The check used to run per-SDK *after* the wipe, so a | |
| 442 | // runtime directory missing an archive destroyed a working install on its | |
| 443 | // way to throwing, and left `project.yml` linking `-lcraft-ios` against a | |
| 444 | // `Runtime/` that no longer existed. | |
| 445 | const resolved = Object.entries(RUNTIME_ARCHIVES).map(([sdk, archives]) => { | |
| 418 | 446 | const present = archives.filter(a => existsSync(join(runtimeDir, a))) |
| 419 | 447 | if (present.length === 0) { |
| 420 | 448 | throw new Error( |
| 421 | `CRAFT_IOS_RUNTIME=${runtimeDir} has none of ${archives.join(', ')}. ` | |
| 449 | `${runtimeDir} has none of ${archives.join(', ')}. ` | |
| 422 | 450 | + `Run \`zig build build-ios-all\` in packages/zig and point at its zig-out/lib.`, |
| 423 | 451 | ) |
| 424 | 452 | } |
| 453 | return { sdk, archives, present } | |
| 454 | }) | |
| 455 | ||
| 456 | const dest = join(output, 'Runtime') | |
| 457 | rmSync(dest, { recursive: true, force: true }) | |
| 425 | 458 | |
| 459 | for (const { sdk, archives, present } of resolved) { | |
| 426 | 460 | const sdkDir = join(dest, sdk) |
| 427 | 461 | mkdirSync(sdkDir, { recursive: true }) |
| 428 | 462 | const target = join(sdkDir, 'libcraft-ios.a') |
| 429 | 463 | |
| 430 | 464 | if (present.length === 1) { |
| 465 | // Said out loud, because the comment on RUNTIME_ARCHIVES describes this | |
| 466 | // exact outcome as the thing to avoid: a single-slice simulator archive | |
| 467 | // links on the machine that built it and fails on the other kind, which | |
| 468 | // is a break that only ever shows up on someone else's laptop. Copying | |
| 469 | // is still better than refusing — a one-arch dev loop is legitimate — | |
| 470 | // but it should not happen silently. | |
| 471 | const missing = archives.filter(a => !present.includes(a)) | |
| 472 | if (missing.length > 0) { | |
| 473 | console.warn( | |
| 474 | ` ⚠ ${sdk}: only ${present[0]} was found; ${missing.join(', ')} is missing. ` | |
| 475 | + `The generated project will not link on the other architecture.`, | |
| 476 | ) | |
| 477 | } | |
| 431 | 478 | cpSync(join(runtimeDir, present[0]!), target) |
| 432 | 479 | } |
| 433 | 480 | else { |
| @@ -439,6 +486,37 @@ export async function installRuntime(output: string, runtimeDir: string): Promis | ||
| 439 | 486 | return true |
| 440 | 487 | } |
| 441 | 488 | |
| 489 | /** | |
| 490 | * Re-copy the Zig runtime into a project that already links one. | |
| 491 | * | |
| 492 | * The archives are *copied* by `init`, not symlinked, so without this a | |
| 493 | * rebuilt runtime never reached the app: edit `packages/zig/src`, run `zig | |
| 494 | * build build-ios-all`, then `craft ios run`, and xcodebuild happily relinks | |
| 495 | * the archive that was copied when the project was first generated. The build | |
| 496 | * succeeds, the app launches, and the change is simply absent — the worst | |
| 497 | * shape a stale artefact can take, because there is no error to chase. | |
| 498 | * | |
| 499 | * Only refreshes; never installs and never removes. A project with no | |
| 500 | * `Runtime/` was generated without a runtime and its `project.yml` carries no | |
| 501 | * link settings, so copying archives in would leave them unreferenced — that | |
| 502 | * decision belongs to `init`. And a project that *does* link one keeps working | |
| 503 | * from the archives it already has when no runtime directory is configured, | |
| 504 | * because a shell that forgot the variable should not quietly turn the runtime | |
| 505 | * off. | |
| 506 | */ | |
| 507 | async function refreshRuntime(output: string, override?: string | null): Promise<void> { | |
| 508 | if (!existsSync(join(output, 'Runtime'))) return | |
| 509 | ||
| 510 | const dir = resolveRuntimeDir(override) | |
| 511 | if (!dir) { | |
| 512 | console.log(' Keeping the Zig runtime installed at init (no runtime directory configured)') | |
| 513 | return | |
| 514 | } | |
| 515 | ||
| 516 | await installRuntime(output, dir) | |
| 517 | console.log(' Refreshed the Zig runtime from', dir) | |
| 518 | } | |
| 519 | ||
| 442 | 520 | export async function init(options: InitOptions): Promise<void> { |
| 443 | 521 | const { name, bundleId, teamId, output } = options |
| 444 | 522 | |
| @@ -533,9 +611,18 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 533 | 611 | |
| 534 | 612 | // The Zig runtime, when this build has one. Installed before the project is |
| 535 | 613 | // rendered because the settings below name the directories it creates. |
| 536 | const runtimeDir = resolveRuntimeDir() | |
| 614 | const runtimeDir = resolveRuntimeDir(options.runtimeDir) | |
| 537 | 615 | const hasRuntime = runtimeDir ? await installRuntime(output, runtimeDir) : false |
| 538 | if (hasRuntime) console.log(' Linked the Zig runtime from', runtimeDir) | |
| 616 | if (hasRuntime) { | |
| 617 | console.log(' Linked the Zig runtime from', runtimeDir) | |
| 618 | } | |
| 619 | else { | |
| 620 | // Re-running init without a runtime used to strip the link settings from | |
| 621 | // project.yml and leave the archives behind, so the directory said one | |
| 622 | // thing and the project said another. Whatever this run decides, the tree | |
| 623 | // agrees with it. | |
| 624 | rmSync(join(output, 'Runtime'), { recursive: true, force: true }) | |
| 625 | } | |
| 539 | 626 | |
| 540 | 627 | const projectYml = projectYmlTemplate |
| 541 | 628 | .replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : '') |
| @@ -656,6 +743,8 @@ export async function build(options: BuildOptions): Promise<void> { | ||
| 656 | 743 | console.log(` Synced: ${htmlPath} → dist/`) |
| 657 | 744 | } |
| 658 | 745 | |
| 746 | await refreshRuntime(output, options.runtimeDir) | |
| 747 | ||
| 659 | 748 | if (!generateProject) return |
| 660 | 749 | |
| 661 | 750 | // Generate Xcode project using xcodegen |