diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d011bd2e..f2f13c91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,34 @@ jobs: working-directory: packages/typescript run: bun run build + # `packages/ios` had a test suite that nothing ran. That is how its + # generator kept a defect nobody saw: `init()` read `CRAFT_IOS_RUNTIME` + # straight from the environment, so the suite passed 12/12 in a clean shell + # and 8/12 in a developer's, and CI never had an opinion either way. + ios-builder: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Setup Pantry (provides Bun, Zig, etc.) + uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64 + + - name: Install dependencies + run: bun install + + # Unset deliberately. The suite must not depend on it, and this is the + # job that would notice if it started to again. + - name: Test iOS builder + working-directory: packages/ios + run: bun test + env: + CRAFT_IOS_RUNTIME: '' + + - name: Build iOS builder + working-directory: packages/ios + run: bun run build + create-craft: runs-on: ubuntu-latest @@ -219,7 +247,7 @@ jobs: run: bun run verify publish-commit: - needs: [zig-core, typescript-sdk, create-craft, ts-maps, lint, integration] + needs: [zig-core, typescript-sdk, ios-builder, create-craft, ts-maps, lint, integration] runs-on: ubuntu-latest steps: diff --git a/packages/ios/src/index.test.ts b/packages/ios/src/index.test.ts index ec9f7abd..e8a74bf0 100644 --- a/packages/ios/src/index.test.ts +++ b/packages/ios/src/index.test.ts @@ -5,6 +5,9 @@ import { describe, expect, it } from 'bun:test' import { build, init, + installRuntime, + renderRuntimeSettings, + resolveRuntimeDir, orderSimulators, renderBackgroundModes, renderEntitlements, @@ -83,6 +86,7 @@ describe('Craft iOS builder', () => { it('generates a production project whose bundled index lives under dist', async () => { const output = mkdtempSync(join(tmpdir(), 'craft-ios-project-')) await init({ + runtimeDir: null, name: 'WildLoop', bundleId: 'org.wildloop.app', output, @@ -127,7 +131,7 @@ describe('Craft iOS builder', () => { const output = join(root, 'ios') Bun.spawnSync(['mkdir', '-p', web]) writeFileSync(join(web, 'index.html'), '
Available offline
') - await init({ name: 'WildLoop', bundleId: 'org.wildloop.app', output }) + await init({ runtimeDir: null, name: 'WildLoop', bundleId: 'org.wildloop.app', output }) await build({ htmlPath: web, devServer: 'https://wildloop.org', output, generateProject: false }) const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8') @@ -138,6 +142,7 @@ describe('Craft iOS builder', () => { it('generates a native Live Activity extension when enabled', async () => { const output = mkdtempSync(join(tmpdir(), 'craft-ios-live-activity-')) await init({ + runtimeDir: null, name: 'WildLoop', bundleId: 'org.wildloop.app', output, @@ -161,6 +166,7 @@ describe('Craft iOS builder', () => { it('generates an embedded watchOS companion when enabled', async () => { const output = mkdtempSync(join(tmpdir(), 'craft-ios-watch-')) await init({ + runtimeDir: null, name: 'WildLoop', bundleId: 'org.wildloop.app', output, @@ -243,3 +249,140 @@ describe('choosing a simulator', () => { expect(devices[0]?.name).toBe('iPad Air') }) }) + +describe('Zig runtime installation', () => { + // A runtime directory holding *one* simulator slice, so `installRuntime` + // takes the `cpSync` branch. The two-slice branch shells out to `lipo`, + // which needs genuine Mach-O input and does not exist off macOS — it is + // covered by building a real generated app, not from here. What these do + // cover is everything around it: resolution, ordering, and the warning. + function fakeRuntime(archives = ['libcraft-ios.a', 'libcraft-ios-simulator-arm64.a']): string { + const dir = mkdtempSync(join(tmpdir(), 'craft-rt-')) + for (const a of archives) writeFileSync(join(dir, a), 'stand-in for an archive') + return dir + } + + it('resolves an explicit directory, and null means no runtime whatever the environment says', () => { + const dir = fakeRuntime() + const saved = process.env.CRAFT_IOS_RUNTIME + process.env.CRAFT_IOS_RUNTIME = dir + try { + // The gap this closes: the suite used to inherit the developer's shell, + // and went from 12 passing to 8 passing and 4 failing when this variable + // happened to be set. + expect(resolveRuntimeDir(null)).toBeNull() + expect(resolveRuntimeDir(dir)).toBe(dir) + expect(resolveRuntimeDir()).toBe(dir) + } + finally { + if (saved === undefined) delete process.env.CRAFT_IOS_RUNTIME + else process.env.CRAFT_IOS_RUNTIME = saved + } + }) + + it('names the source in the error, so a bad path says which knob set it', () => { + expect(() => resolveRuntimeDir('/no/such/runtime')).toThrow(/runtimeDir is/) + }) + + it('writes one archive name per SDK, which is what a single -lcraft-ios needs', async () => { + const output = mkdtempSync(join(tmpdir(), 'craft-out-')) + expect(await installRuntime(output, fakeRuntime())).toBe(true) + expect(existsSync(join(output, 'Runtime', 'device', 'libcraft-ios.a'))).toBe(true) + expect(existsSync(join(output, 'Runtime', 'simulator', 'libcraft-ios.a'))).toBe(true) + }) + + it('warns when only one simulator slice is present, rather than shipping it silently', async () => { + const warnings: string[] = [] + const saved = console.warn + console.warn = (...args: unknown[]) => void warnings.push(args.join(' ')) + try { + await installRuntime(mkdtempSync(join(tmpdir(), 'craft-out-')), fakeRuntime()) + } + finally { + console.warn = saved + } + // RUNTIME_ARCHIVES' own comment calls this the failure that "only shows up + // on someone else's laptop", so it must not be silent. + expect(warnings.join('\n')).toContain('libcraft-ios-simulator-x64.a') + }) + + it('leaves a working install alone when the source directory is incomplete', async () => { + // The ordering bug: validation ran after the wipe, so a bad runtime dir + // destroyed the archives already in place and left project.yml linking + // against a Runtime/ that no longer existed. + const output = mkdtempSync(join(tmpdir(), 'craft-out-')) + await installRuntime(output, fakeRuntime()) + const installed = join(output, 'Runtime', 'device', 'libcraft-ios.a') + expect(existsSync(installed)).toBe(true) + + const empty = mkdtempSync(join(tmpdir(), 'craft-rt-empty-')) + await expect(installRuntime(output, empty)).rejects.toThrow(/has none of/) + expect(existsSync(installed)).toBe(true) + }) + + it('renders both SDK search paths and forces the four entry points', () => { + const settings = renderRuntimeSettings() + expect(settings).toContain('LIBRARY_SEARCH_PATHS[sdk=iphoneos*]') + expect(settings).toContain('LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]') + // Without -u the linker drops the whole archive as unreachable, because + // nothing in the Swift references these symbols — both seams use dlsym. + for (const sym of ['handle_action', 'set_webview', 'deliver_result', 'deliver_error']) { + expect(settings).toContain(`-Wl,-u,_craft_ios_${sym}`) + } + // Six-space indent: this is spliced into project.yml under `settings:`. + for (const line of settings.split('\n')) expect(line.startsWith(' ')).toBe(true) + }) + + it('generates a project whose settings match whether a runtime was installed', async () => { + const output = mkdtempSync(join(tmpdir(), 'craft-app-')) + await init({ runtimeDir: fakeRuntime(), name: 'HasRuntime', output }) + expect(readFileSync(join(output, 'project.yml'), 'utf8')).toContain('-lcraft-ios') + + // And re-running without one leaves no orphaned archives claiming otherwise. + await init({ runtimeDir: null, name: 'HasRuntime', output }) + expect(readFileSync(join(output, 'project.yml'), 'utf8')).not.toContain('-lcraft-ios') + expect(existsSync(join(output, 'Runtime'))).toBe(false) + }) +}) + +describe('runtime staleness', () => { + function runtimeWith(marker: string): string { + const dir = mkdtempSync(join(tmpdir(), 'craft-rt-')) + for (const a of ['libcraft-ios.a', 'libcraft-ios-simulator-arm64.a']) { + writeFileSync(join(dir, a), marker) + } + return dir + } + + it('build picks up a rebuilt runtime instead of relinking the one init copied', async () => { + const output = mkdtempSync(join(tmpdir(), 'craft-app-')) + await init({ runtimeDir: runtimeWith('first build'), name: 'Stale', output }) + const installed = join(output, 'Runtime', 'device', 'libcraft-ios.a') + expect(readFileSync(installed, 'utf8')).toBe('first build') + + // The dev loop: edit Zig, rebuild the archives, run the app again. Before + // this, xcodebuild relinked the copy from init and the change was absent + // with no error anywhere. + await build({ output, runtimeDir: runtimeWith('second build'), generateProject: false }) + expect(readFileSync(installed, 'utf8')).toBe('second build') + }) + + it('build leaves a runtimeless project alone rather than installing one behind init', async () => { + const output = mkdtempSync(join(tmpdir(), 'craft-app-')) + await init({ runtimeDir: null, name: 'NoRuntime', output }) + expect(existsSync(join(output, 'Runtime'))).toBe(false) + + // A runtime is available, but this project does not link one: its + // project.yml has no link settings, so archives here would be dead weight. + await build({ output, runtimeDir: runtimeWith('ignored'), generateProject: false }) + expect(existsSync(join(output, 'Runtime'))).toBe(false) + }) + + it('build keeps the installed runtime when no runtime directory is configured', async () => { + const output = mkdtempSync(join(tmpdir(), 'craft-app-')) + await init({ runtimeDir: runtimeWith('from init'), name: 'Keep', output }) + // A shell that forgot the variable must not quietly turn the runtime off. + await build({ output, runtimeDir: null, generateProject: false }) + expect(readFileSync(join(output, 'Runtime', 'device', 'libcraft-ios.a'), 'utf8')).toBe('from init') + }) +}) diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts index d98c1a39..b9ac808a 100644 --- a/packages/ios/src/index.ts +++ b/packages/ios/src/index.ts @@ -91,6 +91,20 @@ export interface InitOptions { teamId?: string output: string config?: Partial + /** + * Where the Zig runtime archives live, overriding `CRAFT_IOS_RUNTIME`. + * + * `null` means "no runtime, whatever the environment says" — the shape + * `AppConfig.craftPath` has for `CRAFT_BIN`, and the reason it exists here + * is the same: a caller that does not opt in should not be steered by an + * ambient variable. Without it this package's own tests inherited whatever + * the developer's shell exported, and went from 12 passing to 8 passing and + * 4 failing when `CRAFT_IOS_RUNTIME` happened to be set. + * + * Omitted (`undefined`) keeps the environment lookup, which is what the + * monorepo dev loop uses. + */ + runtimeDir?: string | null } export interface BuildOptions { @@ -98,6 +112,12 @@ export interface BuildOptions { devServer?: string output: string generateProject?: boolean + /** + * Where to re-read the Zig runtime archives from, overriding + * `CRAFT_IOS_RUNTIME`. Same meaning as `InitOptions.runtimeDir`; `null` + * leaves whatever `init` installed exactly as it is. + */ + runtimeDir?: string | null } export interface OpenOptions { @@ -380,12 +400,18 @@ export function syncWebAssets(source: string, output: string): void { * dev loop, not a lookup path. Shipping the runtime to real apps means putting * these archives in the pantry package beside the `craft` binary, which is a * distribution decision this function does not make. + * + * `override` is what `InitOptions.runtimeDir` passes: a path to use instead of + * the variable, or `null` to declare there is no runtime regardless of what the + * environment says. `undefined` falls through to `CRAFT_IOS_RUNTIME`. */ -export function resolveRuntimeDir(): string | null { - const dir = process.env.CRAFT_IOS_RUNTIME +export function resolveRuntimeDir(override?: string | null): string | null { + if (override === null) return null + const dir = override ?? process.env.CRAFT_IOS_RUNTIME if (!dir) return null if (!existsSync(dir)) { - throw new Error(`CRAFT_IOS_RUNTIME points at ${dir}, which does not exist.`) + const source = override === undefined ? 'CRAFT_IOS_RUNTIME points at' : 'runtimeDir is' + throw new Error(`${source} ${dir}, which does not exist.`) } return dir } @@ -411,23 +437,44 @@ const RUNTIME_ARCHIVES = { * Returns true when a runtime was installed. */ export async function installRuntime(output: string, runtimeDir: string): Promise { - const dest = join(output, 'Runtime') - rmSync(dest, { recursive: true, force: true }) - - for (const [sdk, archives] of Object.entries(RUNTIME_ARCHIVES)) { + // Every SDK resolved before anything is written, and long before the + // `rmSync` below. The check used to run per-SDK *after* the wipe, so a + // runtime directory missing an archive destroyed a working install on its + // way to throwing, and left `project.yml` linking `-lcraft-ios` against a + // `Runtime/` that no longer existed. + const resolved = Object.entries(RUNTIME_ARCHIVES).map(([sdk, archives]) => { const present = archives.filter(a => existsSync(join(runtimeDir, a))) if (present.length === 0) { throw new Error( - `CRAFT_IOS_RUNTIME=${runtimeDir} has none of ${archives.join(', ')}. ` + `${runtimeDir} has none of ${archives.join(', ')}. ` + `Run \`zig build build-ios-all\` in packages/zig and point at its zig-out/lib.`, ) } + return { sdk, archives, present } + }) + + const dest = join(output, 'Runtime') + rmSync(dest, { recursive: true, force: true }) + for (const { sdk, archives, present } of resolved) { const sdkDir = join(dest, sdk) mkdirSync(sdkDir, { recursive: true }) const target = join(sdkDir, 'libcraft-ios.a') if (present.length === 1) { + // Said out loud, because the comment on RUNTIME_ARCHIVES describes this + // exact outcome as the thing to avoid: a single-slice simulator archive + // links on the machine that built it and fails on the other kind, which + // is a break that only ever shows up on someone else's laptop. Copying + // is still better than refusing — a one-arch dev loop is legitimate — + // but it should not happen silently. + const missing = archives.filter(a => !present.includes(a)) + if (missing.length > 0) { + console.warn( + ` ⚠ ${sdk}: only ${present[0]} was found; ${missing.join(', ')} is missing. ` + + `The generated project will not link on the other architecture.`, + ) + } cpSync(join(runtimeDir, present[0]!), target) } else { @@ -439,6 +486,37 @@ export async function installRuntime(output: string, runtimeDir: string): Promis return true } +/** + * Re-copy the Zig runtime into a project that already links one. + * + * The archives are *copied* by `init`, not symlinked, so without this a + * rebuilt runtime never reached the app: edit `packages/zig/src`, run `zig + * build build-ios-all`, then `craft ios run`, and xcodebuild happily relinks + * the archive that was copied when the project was first generated. The build + * succeeds, the app launches, and the change is simply absent — the worst + * shape a stale artefact can take, because there is no error to chase. + * + * Only refreshes; never installs and never removes. A project with no + * `Runtime/` was generated without a runtime and its `project.yml` carries no + * link settings, so copying archives in would leave them unreferenced — that + * decision belongs to `init`. And a project that *does* link one keeps working + * from the archives it already has when no runtime directory is configured, + * because a shell that forgot the variable should not quietly turn the runtime + * off. + */ +async function refreshRuntime(output: string, override?: string | null): Promise { + if (!existsSync(join(output, 'Runtime'))) return + + const dir = resolveRuntimeDir(override) + if (!dir) { + console.log(' Keeping the Zig runtime installed at init (no runtime directory configured)') + return + } + + await installRuntime(output, dir) + console.log(' Refreshed the Zig runtime from', dir) +} + export async function init(options: InitOptions): Promise { const { name, bundleId, teamId, output } = options @@ -533,9 +611,18 @@ export async function init(options: InitOptions): Promise { // The Zig runtime, when this build has one. Installed before the project is // rendered because the settings below name the directories it creates. - const runtimeDir = resolveRuntimeDir() + const runtimeDir = resolveRuntimeDir(options.runtimeDir) const hasRuntime = runtimeDir ? await installRuntime(output, runtimeDir) : false - if (hasRuntime) console.log(' Linked the Zig runtime from', runtimeDir) + if (hasRuntime) { + console.log(' Linked the Zig runtime from', runtimeDir) + } + else { + // Re-running init without a runtime used to strip the link settings from + // project.yml and leave the archives behind, so the directory said one + // thing and the project said another. Whatever this run decides, the tree + // agrees with it. + rmSync(join(output, 'Runtime'), { recursive: true, force: true }) + } const projectYml = projectYmlTemplate .replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : '') @@ -656,6 +743,8 @@ export async function build(options: BuildOptions): Promise { console.log(` Synced: ${htmlPath} → dist/`) } + await refreshRuntime(output, options.runtimeDir) + if (!generateProject) return // Generate Xcode project using xcodegen