also looking at this
feat: finish native customization and platform seams
#401
21 files
+707
-17
| @@ -1,4 +1,4 @@ | ||
| 1 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' | |
| 1 | import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' | |
| 2 | 2 | import { join, resolve } from 'node:path' |
| 3 | 3 | import { afterAll, describe, expect, it } from 'bun:test' |
| 4 | 4 | import { addSchemeBuildTargets, addSchemeTestTargets, insertProjectTargetsBeforeSchemes } from '../scripts/insert-project-targets' |
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | renderWatchEntitlements, |
| 30 | 30 | resolveCraftVersion, |
| 31 | 31 | restoreShell, |
| 32 | runPostGenerateHook, | |
| 32 | 33 | syncWebAssets, |
| 33 | 34 | syncNativeFonts, |
| 34 | 35 | } from './index' |
| @@ -38,6 +39,68 @@ import { createTempDir, removeAllTempDirs } from '../../typescript/src/temp-dir' | ||
| 38 | 39 | afterAll(removeAllTempDirs) |
| 39 | 40 | |
| 40 | 41 | describe('Craft iOS builder', () => { |
| 42 | it('runs a declared postGenerate command from the project root with metadata', async () => { | |
| 43 | const output = createTempDir('craft-ios-post-generate-') | |
| 44 | await init({ name: 'Hook App', output, runtimeDir: null, config: { | |
| 45 | postGenerate: { command: 'sh', args: ['-c', 'printf "%s|%s|%s" "$PWD" "$CRAFT_PLATFORM" "$CRAFT_GENERATE_PHASE" > hook.txt'] }, | |
| 46 | } }) | |
| 47 | const config = JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8')) | |
| 48 | expect(config.postGenerate).toEqual({ command: 'sh', args: ['-c', 'printf "%s|%s|%s" "$PWD" "$CRAFT_PLATFORM" "$CRAFT_GENERATE_PHASE" > hook.txt'] }) | |
| 49 | runPostGenerateHook(config.postGenerate, output) | |
| 50 | expect(readFileSync(join(output, 'hook.txt'), 'utf8')).toBe(`${realpathSync(output)}|ios|build`) | |
| 51 | }) | |
| 52 | ||
| 53 | it('runs the postGenerate hook after the iOS builder generates its Xcode project', async () => { | |
| 54 | const root = createTempDir('craft-ios-post-generate-build-') | |
| 55 | const output = join(root, 'app') | |
| 56 | const xcodegen = join(root, 'xcodegen') | |
| 57 | writeFileSync(xcodegen, '#!/bin/sh\nmkdir -p HookBuild.xcodeproj\n') | |
| 58 | chmodSync(xcodegen, 0o755) | |
| 59 | const previousPath = process.env.PATH | |
| 60 | process.env.PATH = `${root}:${previousPath ?? ''}` | |
| 61 | try { | |
| 62 | await init({ name: 'Hook Build', output, runtimeDir: null, config: { | |
| 63 | postGenerate: { command: 'sh', args: ['-c', 'test -d HookBuild.xcodeproj && printf "%s" "$CRAFT_GENERATE_PHASE" > hook-phase.txt'] }, | |
| 64 | } }) | |
| 65 | await build({ output, runtimeDir: null }) | |
| 66 | expect(readFileSync(join(output, 'hook-phase.txt'), 'utf8')).toBe('build') | |
| 67 | } | |
| 68 | finally { | |
| 69 | if (previousPath === undefined) delete process.env.PATH | |
| 70 | else process.env.PATH = previousPath | |
| 71 | } | |
| 72 | }) | |
| 73 | ||
| 74 | it('reports invalid iOS postGenerate commands and non-zero exits', () => { | |
| 75 | const output = createTempDir('craft-ios-post-generate-errors-') | |
| 76 | expect(() => runPostGenerateHook({ command: '' }, output)).toThrow('postGenerate.command must be a non-empty executable') | |
| 77 | expect(() => runPostGenerateHook({ command: join(output, 'missing-hook') }, output)).toThrow('postGenerate hook could not start') | |
| 78 | expect(() => runPostGenerateHook({ command: 'sh', args: ['-c', 'printf "patch failed" >&2; exit 7'] }, output)) | |
| 79 | .toThrow('postGenerate hook failed') | |
| 80 | }) | |
| 81 | ||
| 82 | it('generates static tab-bar selection appearance and validates its shape', async () => { | |
| 83 | const output = createTempDir('craft-ios-tab-bar-appearance-') | |
| 84 | await init({ name: 'Tab Bar', output, runtimeDir: null, config: { | |
| 85 | tabBar: { selection: { cornerRadius: 8, color: '#123456' } }, | |
| 86 | } }) | |
| 87 | const config = JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8')) | |
| 88 | const swift = readFileSync(join(output, 'Sources', 'TabBarApp.swift'), 'utf8') | |
| 89 | const hybrid = readFileSync(join(output, 'Sources', 'CraftHybrid.swift'), 'utf8') | |
| 90 | expect(config.tabBar).toEqual({ selection: { cornerRadius: 8, color: '#123456' } }) | |
| 91 | expect(swift).toContain('private(set) var tabSelectionColor: UIColor?') | |
| 92 | expect(swift).toContain('appearance: CraftConfig.TabBar? = nil') | |
| 93 | expect(swift).toContain('chrome.tabSelectionColor.map { Color($0) }') | |
| 94 | expect(swift).toContain('@ObservedObject private var chrome = CraftChrome.shared') | |
| 95 | expect(hybrid).toContain('appearance: config.tabBar') | |
| 96 | await expect(init({ name: 'Invalid Tab Bar', output: join(output, 'invalid'), runtimeDir: null, config: { | |
| 97 | tabBar: { selection: { cornerRadius: -1 } }, | |
| 98 | } })).rejects.toThrow('cornerRadius must be a finite non-negative number') | |
| 99 | await expect(init({ name: 'Invalid Tab Color', output: join(output, 'invalid-color'), runtimeDir: null, config: { | |
| 100 | tabBar: { selection: { color: 'red' } }, | |
| 101 | } })).rejects.toThrow('color must be a #RRGGBB color') | |
| 102 | }) | |
| 103 | ||
| 41 | 104 | it('restores the shell with the actual Xcode Cloud build identity without changing other resources', () => { |
| 42 | 105 | const output = createTempDir('craft-cloud-identity-') |
| 43 | 106 | const file = join(output, 'craft.config.json') |
| @@ -799,7 +862,7 @@ describe('Craft iOS builder', () => { | ||
| 799 | 862 | const launch = JSON.parse(readFileSync(join(output, 'Assets.xcassets', 'LaunchBackground.colorset', 'Contents.json'), 'utf8')) |
| 800 | 863 | expect(plist).toContain('<key>UIUserInterfaceStyle</key>\n <string>Automatic</string>') |
| 801 | 864 | expect(plist).toContain('<string>UIStatusBarStyleDefault</string>') |
| 802 | expect(swift).toContain('.preferredColorScheme(appState.config.colorScheme)') | |
| 865 | expect(swift).toContain('.preferredColorScheme(appState.appearanceScheme)') | |
| 803 | 866 | expect(launch.colors).toHaveLength(2) |
| 804 | 867 | expect(launch.colors[1].appearances).toEqual([{ appearance: 'luminosity', value: 'dark' }]) |
| 805 | 868 | expect(launch.colors[1].color.components.red).toBe('0.008') |
| @@ -830,6 +893,27 @@ describe('Craft iOS builder', () => { | ||
| 830 | 893 | expect(source).toContain('renderCommitted(document)') |
| 831 | 894 | }) |
| 832 | 895 | |
| 896 | it('lets native screens pin UIKit appearance at launch and at runtime', async () => { | |
| 897 | const output = createTempDir('craft-ios-native-appearance-') | |
| 898 | await init({ | |
| 899 | runtimeDir: null, | |
| 900 | name: 'NativeAppearance', | |
| 901 | bundleId: 'org.example.native-appearance', | |
| 902 | output, | |
| 903 | config: { renderer: 'native', appearance: 'system' }, | |
| 904 | }) | |
| 905 | const swift = readFileSync(join(output, 'Sources', 'NativeAppearanceApp.swift'), 'utf8') | |
| 906 | const screen = readFileSync(join(output, 'Sources', 'CraftNativeScreen.swift'), 'utf8') | |
| 907 | expect(swift).toContain('CraftInterfaceStyleController.configure(config)') | |
| 908 | expect(swift).toContain('CraftInterfaceStyleController.set(appearance)') | |
| 909 | expect(swift).toContain('.preferredColorScheme(appState.appearanceScheme)') | |
| 910 | expect(swift).toContain('CraftInterfaceStyleController.colorScheme(for: self.config)') | |
| 911 | expect(swift).toContain('case "getAppearance":') | |
| 912 | expect(screen).toContain('CraftInterfaceStyleController.apply(to: self, config: config)') | |
| 913 | expect(screen).toContain('craftNativeSetAppearance') | |
| 914 | expect(screen).toContain("keep('app', {") | |
| 915 | }) | |
| 916 | ||
| 833 | 917 | it('emits native layout measurements only when a view changes frame', async () => { |
| 834 | 918 | const output = createTempDir('craft-ios-native-layout-') |
| 835 | 919 | await init({ |
| @@ -2567,6 +2651,8 @@ describe('Craft iOS builder', () => { | ||
| 2567 | 2651 | expect(watch).toContain('protocolVersion') |
| 2568 | 2652 | expect(watch).toContain('requestId') |
| 2569 | 2653 | expect(watch).toContain('stateVersion') |
| 2654 | expect(watch).toContain('guard incomingVersion >= stateVersion else { return }') | |
| 2655 | expect(watch).toContain('stateVersion = incomingVersion') | |
| 2570 | 2656 | expect(watch).toContain('planned-session') |
| 2571 | 2657 | expect(watch).toContain('craft.watch.state.v1') |
| 2572 | 2658 | expect(watch).toContain('updateApplicationContext(state)') |