//! Unit tests for the build module: the version a project declares, what //! `koh.build` says, the environment a build gets, and one real build. module build import os import time fn scratch(name string) !string { dir := os.join_path(os.temp_dir(), 'koh3-build-${name}-${time.now().unix_nano()}') os.mkdir_all(dir, os.MkdirParams{})! return dir } fn write(root string, rel string, text string) ! { full := os.join_path(root, rel) os.mkdir_all(os.dir(full), os.MkdirParams{})! os.write_file(full, text)! } struct SourceCase { file string content string want string } fn test_every_version_source_is_read() { zon := '.{\n .name = "demo",\n .minimum_zig_version = "0.16.0",\n .version = "2.0.1",\n}\n' plist := '\n\nCFBundleShortVersionString\n6.0.5\n\n' cases := [ SourceCase{'VERSION', '1.0.0\n', '1.0.0'}, SourceCase{'build.zig.zon', zon, '2.0.1'}, SourceCase{'v.mod', "Module {\n\tname: 'demo'\n\tversion: '3.0.2'\n}", '3.0.2'}, SourceCase{'package.json', '{\n "name": "demo",\n "version": "4.0.3"\n}', '4.0.3'}, SourceCase{'Cargo.toml', '[package]\nname = "demo"\nversion = "5.0.4"\n', '5.0.4'}, SourceCase{'Info.plist', plist, '6.0.5'}, SourceCase{'build.gradle.kts', 'versionName = "7.0.6"\n', '7.0.6'}, SourceCase{'build.gradle', 'versionName "8.0.7"\n', '8.0.7'}, SourceCase{'build.gradle', "versionName '9.0.8'\n", '9.0.8'}, SourceCase{'app/build.gradle.kts', 'versionName = "10.0.9"\n', '10.0.9'}, SourceCase{'app/build.gradle', "versionName '10.0.9'\n", '10.0.9'}, ] for case in cases { root := scratch('source')! defer { os.rmdir_all(root) or {} } write(root, case.file, case.content)! assert project_version(root)! == case.want, case.file } } // A VERSION file is the operator's override, so it wins over everything else. fn test_a_version_file_wins_over_every_source() { root := scratch('override')! defer { os.rmdir_all(root) or {} } write(root, 'build.zig.zon', '.{\n .version = "2.0.1",\n}\n')! write(root, 'v.mod', "Module {\n\tversion: '3.0.2'\n}")! write(root, 'VERSION', '9.9.9\n')! assert project_version(root)! == '9.9.9' } // Nothing to read is refused, never published as 0.0.0. fn test_a_project_with_no_version_is_refused() { root := scratch('no_version')! defer { os.rmdir_all(root) or {} } if _ := project_version(root) { assert false, 'a project with no version must be refused' } else { assert err.msg().contains('VERSION') } } // A value that is not a version would become a file name, so it is refused // where it was found rather than used. fn test_a_value_that_is_not_a_version_is_refused() { root := scratch('bad_version')! defer { os.rmdir_all(root) or {} } write(root, 'VERSION', '1.0.0 build 7\n')! if _ := project_version(root) { assert false, 'a value that is not a version must be refused' } else { assert err.msg().contains('VERSION') } write(root, 'VERSION', 'abcdefghijklmnopqrstuvwxyz0123456789\n')! if _ := project_version(root) { assert false, 'a version longer than 32 characters must be refused' } else { assert err.msg().contains('VERSION') } } // Koh never runs Gradle: a versionName built from a variable is not a version, // so the next Gradle file is tried, and the next source after that. fn test_a_variable_version_name_falls_through() { root := scratch('gradle_variable')! defer { os.rmdir_all(root) or {} } write(root, 'app/build.gradle.kts', 'versionName = project.ext.version\n')! write(root, 'build.gradle', 'versionName "2.17.43"\n')! assert project_version(root)! == '2.17.43' // A commented-out literal is not read, versionNameSuffix is not a // versionName, and a call is not a literal however many strings it passes: // with nothing left, the refusal names a VERSION file. write(root, 'build.gradle', '// versionName "9.9.9"\nversionNameSuffix = "-beta"\n')! if _ := project_version(root) { assert false, 'nothing was readable, so the version must be refused' } else { assert err.msg().contains('VERSION') } // A quoted argument inside a call is not the version. write(root, 'build.gradle', "versionName = providers.gradleProperty('VERSION').get()\n") if _ := project_version(root) { assert false, 'a versionName built from a function call must not be read' } else { assert err.msg().contains('VERSION') } // `version.workspace = true` is Cargo delegating to the workspace, not a // version either. write(root, 'Cargo.toml', '[package]\nversion.workspace = true\n') if _ := project_version(root) { assert false, 'a version delegated to the workspace must not be read' } else { assert err.msg().contains('VERSION') } } fn test_read_config_parses_the_file_and_ignores_what_it_does_not_use() { root := scratch('config')! defer { os.rmdir_all(root) or {} } write(root, 'koh.build', '# how demo is built enabled = true trigger = on-offer timeout_seconds = 900 command = ./make.sh output = out/demo-{version}-{platform} platforms = macos-aarch64, linux-aarch64 lang = Zig ') config := read_config(root)! assert config.command == './make.sh' assert config.output == 'out/demo-{version}-{platform}' assert config.platforms == ['macos-aarch64', 'linux-aarch64'] assert config.lang == .zig assert output_path(config, 'linux-aarch64', '1.2.3') == 'out/demo-1.2.3-linux-aarch64' } // The language is detected when the file does not name one, so an existing // Zig project's build file needs no edit to keep cross-compiling. fn test_the_language_is_detected_from_the_project() { root := scratch('detect')! defer { os.rmdir_all(root) or {} } write(root, 'koh.build', 'command = true\noutput = a\nplatforms = macos-aarch64\n')! write(root, 'build.zig', '// a zig project\n')! assert read_config(root)!.lang == .zig write(root, 'build.sh', '#!/bin/sh\n')! assert read_config(root)!.lang == .shell } fn test_every_defined_build_language_and_alias_is_accepted() { profiles := map[string]Lang{ 'v': .v 'zig': .zig 'rust': .rust 'go': .go 'c': .c 'cpp': .cpp 'c++': .cpp 'node': .node 'javascript': .node 'typescript': .node 'java': .java 'kotlin': .kotlin 'scala': .scala 'csharp': .csharp 'c#': .csharp 'cs': .csharp 'swift': .swift 'python': .python 'ruby': .ruby 'php': .php 'dart': .dart 'elixir': .elixir 'lua': .lua 'r': .r 'shell': .shell 'make': .make 'gradle': .gradle } for name, want in profiles { assert lang_of(name)? == want, name } } fn test_mainstream_project_markers_are_detected() { markers := map[string]Lang{ 'v.mod': .v 'pom.xml': .java 'build.sbt': .scala 'Gemfile': .ruby 'composer.json': .php 'pubspec.yaml': .dart 'mix.exs': .elixir 'DESCRIPTION': .r } for file, want in markers { root := scratch('detect_marker')! write(root, file, '')! assert detect(root) == want, file os.rmdir_all(root) or {} } } fn test_read_config_refuses_what_it_cannot_build() { root := scratch('config_bad')! defer { os.rmdir_all(root) or {} } if _ := read_config(root) { assert false, 'a missing koh.build must be refused' } else { assert err.msg().contains('koh.build') } } fn test_read_config_refuses_each_missing_key() { root := scratch('config_keys')! defer { os.rmdir_all(root) or {} } cases := [ ['command = true\nplatforms = macos-aarch64\n', 'has no output'], ['output = a\nplatforms = macos-aarch64\n', 'has no command'], ['command = true\noutput = a\n', 'names no platforms'], ['command = true\noutput = a\nplatforms = macos-aarch64, amiga\n', 'amiga'], ['command = true\noutput = a\nplatforms = macos-aarch64, macos-aarch64\n', 'twice'], ['command = true\noutput = a\nplatforms = macos-aarch64\nlang = cobol\n', 'cobol'], ] for case in cases { write(root, 'koh.build', case[0])! if _ := read_config(root) { assert false, case[1] } else { assert err.msg().contains(case[1]), err.msg() } } } fn test_the_build_environment_is_koh_2s() { zig := env_for(.zig, 'linux-aarch64') assert zig['KOH_TARGET'] == 'aarch64-linux-musl' assert zig['KEPR_TARGET'] == 'aarch64-linux-musl' // `go` is a V keyword, so the map needs another name. go_env := env_for(.go, 'linux-x86_64') assert go_env['KOH_TARGET'] == 'linux-x86_64' assert go_env['KEPR_TARGET'] == 'linux-x86_64' assert go_env['GOOS'] == 'linux' assert go_env['GOARCH'] == 'amd64' assert env_for(.rust, 'windows-x86_64')['CARGO_BUILD_TARGET'] == 'x86_64-pc-windows-gnu' assert env_for(.rust, 'macos-aarch64')['CARGO_BUILD_TARGET'] == 'aarch64-apple-darwin' assert env_for(.c, 'linux-x86_64')['CC'] == 'x86_64-linux-musl-gcc' // macOS has no musl cc, and Android carries the targets and nothing else. assert 'CC' !in env_for(.c, 'macos-x86_64') assert env_for(.gradle, 'android-arm64').len == 2 assert env_for(.node, 'android-x86')['KEPR_TARGET'] == 'android-x86' } fn test_this_machine_is_one_of_the_platforms() { assert this_machine()! in platforms } // A real build, small enough for a unit test: the command writes the file // `output` names, and the environment it saw is in that file. Only `output` // takes the tokens — a build script learns the version from the project itself, // the way Peach Android's reads VERSION. fn test_build_one_runs_the_command_and_finds_the_output() { root := scratch('build_one')! defer { os.rmdir_all(root) or {} } write(root, 'koh.build', 'command = mkdir -p out && env > out/demo-1.2.3 output = out/demo-{version} platforms = macos-aarch64 lang = make ')! config := read_config(root)! built := build_one(root, config, 'macos-aarch64', '1.2.3')! assert built.platform == 'macos-aarch64' assert built.path == 'out/demo-1.2.3' assert built.size > 0 seen := os.read_file(os.join_path(root, 'out/demo-1.2.3'))! assert seen.contains('KEPR_TARGET=macos-aarch64') assert seen.contains('KOH_TARGET=macos-aarch64') } fn test_build_one_refuses_a_failing_build_and_shows_what_it_said() { root := scratch('build_fail')! defer { os.rmdir_all(root) or {} } write(root, 'koh.build', 'command = sh -c \'echo "no compiler here" >&2; exit 1\' output = out/demo platforms = macos-aarch64 ') config := read_config(root)! if _ := build_one(root, config, 'macos-aarch64', '1.2.3') { assert false, 'a failing build must be refused' } else { assert err.msg().contains('no compiler here') } } // A build that leaves nothing behind is a build failure, not a release. fn test_build_one_refuses_a_build_that_creates_nothing() { root := scratch('build_empty')! defer { os.rmdir_all(root) or {} } write(root, 'koh.build', 'command = true output = out/demo platforms = macos-aarch64 ') config := read_config(root)! if _ := build_one(root, config, 'macos-aarch64', '1.2.3') { assert false, 'a build with no output must be refused' } else { assert err.msg().contains('did not create') } } // A build that leaves an empty file did not build anything: 0 bytes would be // published as a release for that platform. fn test_build_one_refuses_an_empty_artifact() { root := scratch('build_zero')! defer { os.rmdir_all(root) or {} } write(root, 'koh.build', 'command = mkdir -p out && : > out/demo output = out/demo platforms = macos-aarch64 ')! config := read_config(root)! if _ := build_one(root, config, 'macos-aarch64', '1.2.3') { assert false, 'an empty artifact must be refused' } else { assert err.msg().contains('produced an empty') } } // A command that leaves something running in the background must not outlive // the build: it could still be writing the artifact after it was hashed. fn test_build_one_takes_background_children_with_it() { root := scratch('build_orphan')! defer { os.rmdir_all(root) or {} } write(root, 'koh.build', "command = mkdir -p out && printf early > out/demo && { sh -c 'sleep 2; printf late > out/demo' & } output = out/demo platforms = macos-aarch64 ")! config := read_config(root)! built := build_one(root, config, 'macos-aarch64', '1.2.3')! assert built.size == 5 // Give the background child its two seconds: it must be gone by now. time.sleep(3 * time.second) assert os.read_file(os.join_path(root, 'out/demo'))! == 'early' }