build_test.v

10.9 KB · raw

1//! Unit tests for the build module: the version a project declares, what2//! `koh.build` says, the environment a build gets, and one real build.34module build56import os7import time89fn scratch(name string) !string {10	dir := os.join_path(os.temp_dir(), 'koh3-build-${name}-${time.now().unix_nano()}')11	os.mkdir_all(dir, os.MkdirParams{})!12	return dir13}1415fn write(root string, rel string, text string) ! {16	full := os.join_path(root, rel)17	os.mkdir_all(os.dir(full), os.MkdirParams{})!18	os.write_file(full, text)!19}2021struct SourceCase {22	file    string23	content string24	want    string25}2627fn test_every_version_source_is_read() {28	zon := '.{\n    .name = "demo",\n    .minimum_zig_version = "0.16.0",\n    .version = "2.0.1",\n}\n'29	plist := '<?xml version="1.0"?>\n<dict>\n<key>CFBundleShortVersionString</key>\n<string>6.0.5</string>\n</dict>\n'30	cases := [31		SourceCase{'VERSION', '1.0.0\n', '1.0.0'},32		SourceCase{'build.zig.zon', zon, '2.0.1'},33		SourceCase{'v.mod', "Module {\n\tname: 'demo'\n\tversion: '3.0.2'\n}", '3.0.2'},34		SourceCase{'package.json', '{\n  "name": "demo",\n  "version": "4.0.3"\n}', '4.0.3'},35		SourceCase{'Cargo.toml', '[package]\nname = "demo"\nversion = "5.0.4"\n', '5.0.4'},36		SourceCase{'Info.plist', plist, '6.0.5'},37		SourceCase{'build.gradle.kts', 'versionName = "7.0.6"\n', '7.0.6'},38		SourceCase{'build.gradle', 'versionName "8.0.7"\n', '8.0.7'},39		SourceCase{'build.gradle', "versionName '9.0.8'\n", '9.0.8'},40		SourceCase{'app/build.gradle.kts', 'versionName = "10.0.9"\n', '10.0.9'},41		SourceCase{'app/build.gradle', "versionName '10.0.9'\n", '10.0.9'},42	]43	for case in cases {44		root := scratch('source')!45		defer {46			os.rmdir_all(root) or {}47		}48		write(root, case.file, case.content)!49		assert project_version(root)! == case.want, case.file50	}51}5253// A VERSION file is the operator's override, so it wins over everything else.54fn test_a_version_file_wins_over_every_source() {55	root := scratch('override')!56	defer {57		os.rmdir_all(root) or {}58	}59	write(root, 'build.zig.zon', '.{\n    .version = "2.0.1",\n}\n')!60	write(root, 'v.mod', "Module {\n\tversion: '3.0.2'\n}")!61	write(root, 'VERSION', '9.9.9\n')!62	assert project_version(root)! == '9.9.9'63}6465// Nothing to read is refused, never published as 0.0.0.66fn test_a_project_with_no_version_is_refused() {67	root := scratch('no_version')!68	defer {69		os.rmdir_all(root) or {}70	}71	if _ := project_version(root) {72		assert false, 'a project with no version must be refused'73	} else {74		assert err.msg().contains('VERSION')75	}76}7778// A value that is not a version would become a file name, so it is refused79// where it was found rather than used.80fn test_a_value_that_is_not_a_version_is_refused() {81	root := scratch('bad_version')!82	defer {83		os.rmdir_all(root) or {}84	}85	write(root, 'VERSION', '1.0.0 build 7\n')!86	if _ := project_version(root) {87		assert false, 'a value that is not a version must be refused'88	} else {89		assert err.msg().contains('VERSION')90	}91	write(root, 'VERSION', 'abcdefghijklmnopqrstuvwxyz0123456789\n')!92	if _ := project_version(root) {93		assert false, 'a version longer than 32 characters must be refused'94	} else {95		assert err.msg().contains('VERSION')96	}97}9899// Koh never runs Gradle: a versionName built from a variable is not a version,100// so the next Gradle file is tried, and the next source after that.101fn test_a_variable_version_name_falls_through() {102	root := scratch('gradle_variable')!103	defer {104		os.rmdir_all(root) or {}105	}106	write(root, 'app/build.gradle.kts', 'versionName = project.ext.version\n')!107	write(root, 'build.gradle', 'versionName "2.17.43"\n')!108	assert project_version(root)! == '2.17.43'109110	// A commented-out literal is not read, versionNameSuffix is not a111	// versionName, and a call is not a literal however many strings it passes:112	// with nothing left, the refusal names a VERSION file.113	write(root, 'build.gradle', '// versionName "9.9.9"\nversionNameSuffix = "-beta"\n')!114	if _ := project_version(root) {115		assert false, 'nothing was readable, so the version must be refused'116	} else {117		assert err.msg().contains('VERSION')118	}119120	// A quoted argument inside a call is not the version.121	write(root, 'build.gradle', "versionName = providers.gradleProperty('VERSION').get()\n")122	if _ := project_version(root) {123		assert false, 'a versionName built from a function call must not be read'124	} else {125		assert err.msg().contains('VERSION')126	}127	// `version.workspace = true` is Cargo delegating to the workspace, not a128	// version either.129	write(root, 'Cargo.toml', '[package]\nversion.workspace = true\n')130	if _ := project_version(root) {131		assert false, 'a version delegated to the workspace must not be read'132	} else {133		assert err.msg().contains('VERSION')134	}135}136137fn test_read_config_parses_the_file_and_ignores_what_it_does_not_use() {138	root := scratch('config')!139	defer {140		os.rmdir_all(root) or {}141	}142	write(root, 'koh.build', '# how demo is built143enabled         = true144trigger         = on-offer145timeout_seconds = 900146command         = ./make.sh147output          = out/demo-{version}-{platform}148platforms       = macos-aarch64, linux-aarch64149lang            = Zig150')151	config := read_config(root)!152	assert config.command == './make.sh'153	assert config.output == 'out/demo-{version}-{platform}'154	assert config.platforms == ['macos-aarch64', 'linux-aarch64']155	assert config.lang == .zig156	assert output_path(config, 'linux-aarch64', '1.2.3') == 'out/demo-1.2.3-linux-aarch64'157}158159// The language is detected when the file does not name one, so an existing160// Zig project's build file needs no edit to keep cross-compiling.161fn test_the_language_is_detected_from_the_project() {162	root := scratch('detect')!163	defer {164		os.rmdir_all(root) or {}165	}166	write(root, 'koh.build', 'command = true\noutput = a\nplatforms = macos-aarch64\n')!167	write(root, 'build.zig', '// a zig project\n')!168	assert read_config(root)!.lang == .zig169	write(root, 'build.sh', '#!/bin/sh\n')!170	assert read_config(root)!.lang == .shell171}172173fn test_read_config_refuses_what_it_cannot_build() {174	root := scratch('config_bad')!175	defer {176		os.rmdir_all(root) or {}177	}178	if _ := read_config(root) {179		assert false, 'a missing koh.build must be refused'180	} else {181		assert err.msg().contains('koh.build')182	}183}184185fn test_read_config_refuses_each_missing_key() {186	root := scratch('config_keys')!187	defer {188		os.rmdir_all(root) or {}189	}190	cases := [191		['command = true\nplatforms = macos-aarch64\n', 'has no output'],192		['output = a\nplatforms = macos-aarch64\n', 'has no command'],193		['command = true\noutput = a\n', 'names no platforms'],194		['command = true\noutput = a\nplatforms = macos-aarch64, amiga\n', 'amiga'],195		['command = true\noutput = a\nplatforms = macos-aarch64, macos-aarch64\n', 'twice'],196		['command = true\noutput = a\nplatforms = macos-aarch64\nlang = cobol\n', 'cobol'],197	]198	for case in cases {199		write(root, 'koh.build', case[0])!200		if _ := read_config(root) {201			assert false, case[1]202		} else {203			assert err.msg().contains(case[1]), err.msg()204		}205	}206}207208fn test_the_build_environment_is_koh_2s() {209	zig := env_for(.zig, 'linux-aarch64')210	assert zig['KOH_TARGET'] == 'aarch64-linux-musl'211	assert zig['KEPR_TARGET'] == 'aarch64-linux-musl'212	// `go` is a V keyword, so the map needs another name.213	go_env := env_for(.go, 'linux-x86_64')214	assert go_env['KOH_TARGET'] == 'linux-x86_64'215	assert go_env['KEPR_TARGET'] == 'linux-x86_64'216	assert go_env['GOOS'] == 'linux'217	assert go_env['GOARCH'] == 'amd64'218	assert env_for(.rust, 'windows-x86_64')['CARGO_BUILD_TARGET'] == 'x86_64-pc-windows-gnu'219	assert env_for(.rust, 'macos-aarch64')['CARGO_BUILD_TARGET'] == 'aarch64-apple-darwin'220	assert env_for(.c, 'linux-x86_64')['CC'] == 'x86_64-linux-musl-gcc'221	// macOS has no musl cc, and Android carries the targets and nothing else.222	assert 'CC' !in env_for(.c, 'macos-x86_64')223	assert env_for(.gradle, 'android-arm64').len == 2224	assert env_for(.node, 'android-x86')['KEPR_TARGET'] == 'android-x86'225}226227fn test_this_machine_is_one_of_the_platforms() {228	assert this_machine()! in platforms229}230231// A real build, small enough for a unit test: the command writes the file232// `output` names, and the environment it saw is in that file. Only `output`233// takes the tokens — a build script learns the version from the project itself,234// the way Peach Android's reads VERSION.235fn test_build_one_runs_the_command_and_finds_the_output() {236	root := scratch('build_one')!237	defer {238		os.rmdir_all(root) or {}239	}240	write(root, 'koh.build', 'command = mkdir -p out && env > out/demo-1.2.3241output = out/demo-{version}242platforms = macos-aarch64243lang = make244')!245	config := read_config(root)!246	built := build_one(root, config, 'macos-aarch64', '1.2.3')!247	assert built.platform == 'macos-aarch64'248	assert built.path == 'out/demo-1.2.3'249	assert built.size > 0250	seen := os.read_file(os.join_path(root, 'out/demo-1.2.3'))!251	assert seen.contains('KEPR_TARGET=macos-aarch64')252	assert seen.contains('KOH_TARGET=macos-aarch64')253}254255fn test_build_one_refuses_a_failing_build_and_shows_what_it_said() {256	root := scratch('build_fail')!257	defer {258		os.rmdir_all(root) or {}259	}260	write(root, 'koh.build', 'command = sh -c \'echo "no compiler here" >&2; exit 1\'261output = out/demo262platforms = macos-aarch64263')264	config := read_config(root)!265	if _ := build_one(root, config, 'macos-aarch64', '1.2.3') {266		assert false, 'a failing build must be refused'267	} else {268		assert err.msg().contains('no compiler here')269	}270}271272// A build that leaves nothing behind is a build failure, not a release.273fn test_build_one_refuses_a_build_that_creates_nothing() {274	root := scratch('build_empty')!275	defer {276		os.rmdir_all(root) or {}277	}278	write(root, 'koh.build', 'command = true279output = out/demo280platforms = macos-aarch64281')282	config := read_config(root)!283	if _ := build_one(root, config, 'macos-aarch64', '1.2.3') {284		assert false, 'a build with no output must be refused'285	} else {286		assert err.msg().contains('did not create')287	}288}289290// A build that leaves an empty file did not build anything: 0 bytes would be291// published as a release for that platform.292fn test_build_one_refuses_an_empty_artifact() {293	root := scratch('build_zero')!294	defer {295		os.rmdir_all(root) or {}296	}297	write(root, 'koh.build', 'command = mkdir -p out && : > out/demo298output = out/demo299platforms = macos-aarch64300')!301	config := read_config(root)!302	if _ := build_one(root, config, 'macos-aarch64', '1.2.3') {303		assert false, 'an empty artifact must be refused'304	} else {305		assert err.msg().contains('produced an empty')306	}307}308309// A command that leaves something running in the background must not outlive310// the build: it could still be writing the artifact after it was hashed.311fn test_build_one_takes_background_children_with_it() {312	root := scratch('build_orphan')!313	defer {314		os.rmdir_all(root) or {}315	}316	write(root, 'koh.build', "command = mkdir -p out && printf early > out/demo && { sh -c 'sleep 2; printf late > out/demo' & }317output = out/demo318platforms = macos-aarch64319")!320	config := read_config(root)!321	built := build_one(root, config, 'macos-aarch64', '1.2.3')!322	assert built.size == 5323	// Give the background child its two seconds: it must be gone by now.324	time.sleep(3 * time.second)325	assert os.read_file(os.join_path(root, 'out/demo'))! == 'early'326}