ReviewOS

also looking at this

craft-native/craft

fix(ios): give the generator a runtimeDir option, and run its tests in CI

#108
Merged glennmichael123 wants to merge fix/ios-generator-runtime-dir into main
3 files +272 -12
.github/workflows/ci.ymlmodified+29-1
Changes to .github/workflows/ci.yml
@@ -109,6 +109,34 @@ jobs:
109109 working-directory: packages/typescript
110110 run: bun run build
111111
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
112140 create-craft:
113141 runs-on: ubuntu-latest
114142
@@ -219,7 +247,7 @@ jobs:
219247 run: bun run verify
220248
221249 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]
223251 runs-on: ubuntu-latest
224252
225253 steps:
packages/ios/src/index.test.tsmodified+144-1
Changes to packages/ios/src/index.test.ts
@@ -5,6 +5,9 @@ import { describe, expect, it } from 'bun:test'
55import {
66 build,
77 init,
8 installRuntime,
9 renderRuntimeSettings,
10 resolveRuntimeDir,
811 orderSimulators,
912 renderBackgroundModes,
1013 renderEntitlements,
@@ -83,6 +86,7 @@ describe('Craft iOS builder', () => {
8386 it('generates a production project whose bundled index lives under dist', async () => {
8487 const output = mkdtempSync(join(tmpdir(), 'craft-ios-project-'))
8588 await init({
89 runtimeDir: null,
8690 name: 'WildLoop',
8791 bundleId: 'org.wildloop.app',
8892 output,
@@ -127,7 +131,7 @@ describe('Craft iOS builder', () => {
127131 const output = join(root, 'ios')
128132 Bun.spawnSync(['mkdir', '-p', web])
129133 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 })
131135 await build({ htmlPath: web, devServer: 'https://wildloop.org', output, generateProject: false })
132136
133137 const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8')
@@ -138,6 +142,7 @@ describe('Craft iOS builder', () => {
138142 it('generates a native Live Activity extension when enabled', async () => {
139143 const output = mkdtempSync(join(tmpdir(), 'craft-ios-live-activity-'))
140144 await init({
145 runtimeDir: null,
141146 name: 'WildLoop',
142147 bundleId: 'org.wildloop.app',
143148 output,
@@ -161,6 +166,7 @@ describe('Craft iOS builder', () => {
161166 it('generates an embedded watchOS companion when enabled', async () => {
162167 const output = mkdtempSync(join(tmpdir(), 'craft-ios-watch-'))
163168 await init({
169 runtimeDir: null,
164170 name: 'WildLoop',
165171 bundleId: 'org.wildloop.app',
166172 output,
@@ -243,3 +249,140 @@ describe('choosing a simulator', () => {
243249 expect(devices[0]?.name).toBe('iPad Air')
244250 })
245251})
252
253describe('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
348describe('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})
packages/ios/src/index.tsmodified+99-10
Changes to packages/ios/src/index.ts
@@ -91,6 +91,20 @@ export interface InitOptions {
9191 teamId?: string
9292 output: string
9393 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
94108}
95109
96110export interface BuildOptions {
@@ -98,6 +112,12 @@ export interface BuildOptions {
98112 devServer?: string
99113 output: string
100114 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
101121}
102122
103123export interface OpenOptions {
@@ -380,12 +400,18 @@ export function syncWebAssets(source: string, output: string): void {
380400 * dev loop, not a lookup path. Shipping the runtime to real apps means putting
381401 * these archives in the pantry package beside the `craft` binary, which is a
382402 * 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`.
383407 */
384export function resolveRuntimeDir(): string | null {
385 const dir = process.env.CRAFT_IOS_RUNTIME
408export function resolveRuntimeDir(override?: string | null): string | null {
409 if (override === null) return null
410 const dir = override ?? process.env.CRAFT_IOS_RUNTIME
386411 if (!dir) return null
387412 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.`)
389415 }
390416 return dir
391417}
@@ -411,23 +437,44 @@ const RUNTIME_ARCHIVES = {
411437 * Returns true when a runtime was installed.
412438 */
413439export 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]) => {
418446 const present = archives.filter(a => existsSync(join(runtimeDir, a)))
419447 if (present.length === 0) {
420448 throw new Error(
421 `CRAFT_IOS_RUNTIME=${runtimeDir} has none of ${archives.join(', ')}. `
449 `${runtimeDir} has none of ${archives.join(', ')}. `
422450 + `Run \`zig build build-ios-all\` in packages/zig and point at its zig-out/lib.`,
423451 )
424452 }
453 return { sdk, archives, present }
454 })
455
456 const dest = join(output, 'Runtime')
457 rmSync(dest, { recursive: true, force: true })
425458
459 for (const { sdk, archives, present } of resolved) {
426460 const sdkDir = join(dest, sdk)
427461 mkdirSync(sdkDir, { recursive: true })
428462 const target = join(sdkDir, 'libcraft-ios.a')
429463
430464 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 }
431478 cpSync(join(runtimeDir, present[0]!), target)
432479 }
433480 else {
@@ -439,6 +486,37 @@ export async function installRuntime(output: string, runtimeDir: string): Promis
439486 return true
440487}
441488
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 */
507async 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
442520export async function init(options: InitOptions): Promise<void> {
443521 const { name, bundleId, teamId, output } = options
444522
@@ -533,9 +611,18 @@ export async function init(options: InitOptions): Promise<void> {
533611
534612 // The Zig runtime, when this build has one. Installed before the project is
535613 // rendered because the settings below name the directories it creates.
536 const runtimeDir = resolveRuntimeDir()
614 const runtimeDir = resolveRuntimeDir(options.runtimeDir)
537615 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 }
539626
540627 const projectYml = projectYmlTemplate
541628 .replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : '')
@@ -656,6 +743,8 @@ export async function build(options: BuildOptions): Promise<void> {
656743 console.log(` Synced: ${htmlPath} → dist/`)
657744 }
658745
746 await refreshRuntime(output, options.runtimeDir)
747
659748 if (!generateProject) return
660749
661750 // Generate Xcode project using xcodegen