ReviewOS

also looking at this

craft-native/craft

feat: finish native customization and platform seams

#401
Merged glennmichael123 wants to merge codex/issues-388-400-344 into main
21 files +707 -17
packages/ios/src/index.test.tsmodified+88-2
Changes to packages/ios/src/index.test.ts
@@ -1,4 +1,4 @@
1import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
1import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
22import { join, resolve } from 'node:path'
33import { afterAll, describe, expect, it } from 'bun:test'
44import { addSchemeBuildTargets, addSchemeTestTargets, insertProjectTargetsBeforeSchemes } from '../scripts/insert-project-targets'
@@ -29,6 +29,7 @@ import {
2929 renderWatchEntitlements,
3030 resolveCraftVersion,
3131 restoreShell,
32 runPostGenerateHook,
3233 syncWebAssets,
3334 syncNativeFonts,
3435} from './index'
@@ -38,6 +39,68 @@ import { createTempDir, removeAllTempDirs } from '../../typescript/src/temp-dir'
3839afterAll(removeAllTempDirs)
3940
4041describe('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
41104 it('restores the shell with the actual Xcode Cloud build identity without changing other resources', () => {
42105 const output = createTempDir('craft-cloud-identity-')
43106 const file = join(output, 'craft.config.json')
@@ -799,7 +862,7 @@ describe('Craft iOS builder', () => {
799862 const launch = JSON.parse(readFileSync(join(output, 'Assets.xcassets', 'LaunchBackground.colorset', 'Contents.json'), 'utf8'))
800863 expect(plist).toContain('<key>UIUserInterfaceStyle</key>\n <string>Automatic</string>')
801864 expect(plist).toContain('<string>UIStatusBarStyleDefault</string>')
802 expect(swift).toContain('.preferredColorScheme(appState.config.colorScheme)')
865 expect(swift).toContain('.preferredColorScheme(appState.appearanceScheme)')
803866 expect(launch.colors).toHaveLength(2)
804867 expect(launch.colors[1].appearances).toEqual([{ appearance: 'luminosity', value: 'dark' }])
805868 expect(launch.colors[1].color.components.red).toBe('0.008')
@@ -830,6 +893,27 @@ describe('Craft iOS builder', () => {
830893 expect(source).toContain('renderCommitted(document)')
831894 })
832895
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
833917 it('emits native layout measurements only when a view changes frame', async () => {
834918 const output = createTempDir('craft-ios-native-layout-')
835919 await init({
@@ -2567,6 +2651,8 @@ describe('Craft iOS builder', () => {
25672651 expect(watch).toContain('protocolVersion')
25682652 expect(watch).toContain('requestId')
25692653 expect(watch).toContain('stateVersion')
2654 expect(watch).toContain('guard incomingVersion >= stateVersion else { return }')
2655 expect(watch).toContain('stateVersion = incomingVersion')
25702656 expect(watch).toContain('planned-session')
25712657 expect(watch).toContain('craft.watch.state.v1')
25722658 expect(watch).toContain('updateApplicationContext(state)')