cranpose 0.9.21

Build desktop, mobile and web apps with composable Rust functions
Documentation
import importlib.util
from pathlib import Path
import sys
import tempfile
import tomllib
import unittest
from unittest.mock import patch

spec = importlib.util.spec_from_file_location('watchos_build', Path(__file__).parents[1] / 'build.py')
build = importlib.util.module_from_spec(spec)
spec.loader.exec_module(build)


class PackagerTest(unittest.TestCase):
    def setUp(self):
        self.scratch = tempfile.TemporaryDirectory()
        self.addCleanup(self.scratch.cleanup)
        self.root = Path(self.scratch.name)
        self.manifest = self.root / 'app/Cargo.toml'
        self.manifest.parent.mkdir()
        self.original = '[package]\nname="test-app"\nversion="1.0.0"\n'
        self.manifest.write_text(self.original)
        self.destination = self.root / 'output'
        self.args = ['build.py', '--manifest', str(self.manifest), '--entry', 'ui::Root',
                     '--bundle-id', 'org.cranpose.test', '--name', 'Test',
                     '--output', str(self.destination), '--cranpose-version', '0.1.177', '--prepare-only']

    def invoke(self):
        with patch.object(sys, 'argv', self.args), patch.object(build, 'run') as commands:
            build.main()
            return commands

    def test_preparation_preserves_application_and_owns_all_native_sources(self):
        self.invoke()
        self.assertEqual(self.manifest.read_text(), self.original)
        self.assertEqual(list(self.manifest.parent.iterdir()), [self.manifest])
        for name in ('Host.swift', 'Host.mm', 'Host.h', 'runner/src/lib.rs', 'runner/build.rs'):
            self.assertTrue((self.destination / name).is_file(), name)

    def test_unrelated_output_is_never_overwritten(self):
        self.destination.mkdir()
        saved = self.destination / 'important.txt'
        saved.write_text('preserve')
        with self.assertRaises(SystemExit):
            self.invoke()
        self.assertEqual(saved.read_text(), 'preserve')
        self.assertEqual(list(self.destination.iterdir()), [saved])

    def test_invalid_rust_entry_is_rejected_before_writing(self):
        self.args[self.args.index('--entry') + 1] = 'ui::Root); panic!("injected")'
        with self.assertRaises(SystemExit):
            self.invoke()
        self.assertFalse(self.destination.exists())

    def test_metadata_supplies_app_identity_without_native_sources(self):
        self.manifest.write_text(self.original + '''
[package.metadata.cranpose.watchos]
entry = "ui::Root"
bundle-id = "org.cranpose.metadata"
name = "Metadata App"
''')
        for option in ('--entry', '--bundle-id', '--name'):
            offset = self.args.index(option)
            del self.args[offset:offset + 2]
        self.invoke()
        bridge = (self.destination / 'runner/src/lib.rs').read_text()
        self.assertIn('application::ui::Root', bridge)
        self.assertIn('org.cranpose.metadata', bridge)

    def test_configuration_upgrade_keeps_output_usable(self):
        self.test_preparation_recovers_after_interrupted_dependency_setup()
        self.args[self.args.index('--cranpose-version') + 1] = '0.9.1'
        commands = self.invoke()
        self.assertEqual(commands.call_args.args[0], ['cargo', 'update', '--workspace'])
        self.assertIn('=0.9.1', (self.destination / 'runner/Cargo.toml').read_text())

    def test_framework_source_patches_each_path_dependency_by_package_name(self):
        framework = self.root / 'framework'
        framework.mkdir()
        (framework / 'Cargo.toml').write_text('''[workspace.package]
version = "0.9.4"

[workspace.dependencies]
cranpose-core = { path = "crates/cranpose-core", version = "0.9.4" }
wgpu = { package = "cranpose-wgpu", path = "forks/wgpu/wgpu", version = "30.0.1" }
log = "0.4"
''')
        offset = self.args.index('--cranpose-version')
        self.args[offset:offset + 2] = ['--framework-source', str(framework)]
        self.invoke()
        runner = tomllib.loads((self.destination / 'runner/Cargo.toml').read_text())
        source = framework.resolve()
        self.assertEqual(runner['patch']['crates-io'], {
            'cranpose-core': {'path': str(source / 'crates/cranpose-core')},
            'cranpose-wgpu': {'path': str(source / 'forks/wgpu/wgpu')},
        })

    def test_device_rejects_unsupported_deployment_target(self):
        self.args.extend(['--target', 'device', '--deployment-target', '10.0'])
        with self.assertRaises(SystemExit):
            self.invoke()
        self.assertFalse(self.destination.exists())

    def test_companion_must_prefix_the_watch_bundle_id(self):
        self.args.extend(['--companion', 'org.cranpose.other'])
        with self.assertRaises(SystemExit):
            self.invoke()
        self.assertFalse(self.destination.exists())
        self.args[-1] = 'org.cranpose'
        self.invoke()
        self.assertTrue((self.destination / 'runner/src/lib.rs').is_file())

    def test_info_plist_entries_must_be_a_table(self):
        self.manifest.write_text(self.original + '\n[package.metadata.cranpose.watchos]\ninfo-plist = "audio"\n')
        with self.assertRaises(SystemExit):
            self.invoke()
        self.assertFalse(self.destination.exists())

    def test_missing_icon_is_rejected_before_writing(self):
        self.args.extend(['--icon', 'icon.png'])
        with self.assertRaises(SystemExit):
            self.invoke()
        self.assertFalse(self.destination.exists())

    def test_runner_inherits_application_lock_without_modifying_it(self):
        lock = self.manifest.parent / 'Cargo.lock'
        lock.write_text('# the application dependency resolution\nversion = 4\n')
        self.invoke()
        self.assertEqual(lock.read_text(), (self.destination / 'runner/Cargo.lock').read_text())

    def test_preparation_recovers_after_interrupted_dependency_setup(self):
        self.invoke()
        manifest = self.destination / 'runner/Cargo.toml'
        manifest.write_text(manifest.read_text().replace('[dependencies]', '[dependencies]\ncxx = "1"'))
        commands = self.invoke()
        self.assertEqual(commands.call_count, 1)
        self.assertEqual(commands.call_args.args[0], ['cargo', 'add', 'cxx-build@1', '--build'])
        manifest.write_text(manifest.read_text() + '\n[build-dependencies]\ncxx-build = "1"\n')
        before = manifest.read_text()
        self.assertEqual(self.invoke().call_count, 0)
        self.assertEqual(manifest.read_text(), before)


if __name__ == '__main__':
    unittest.main()