build_test.v

12.1 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_every_defined_build_language_and_alias_is_accepted() {174	profiles := map[string]Lang{175		'v':          .v176		'zig':        .zig177		'rust':       .rust178		'go':         .go179		'c':          .c180		'cpp':        .cpp181		'c++':        .cpp182		'node':       .node183		'javascript': .node184		'typescript': .node185		'java':       .java186		'kotlin':     .kotlin187		'scala':      .scala188		'csharp':     .csharp189		'c#':         .csharp190		'cs':         .csharp191		'swift':      .swift192		'python':     .python193		'ruby':       .ruby194		'php':        .php195		'dart':       .dart196		'elixir':     .elixir197		'lua':        .lua198		'r':          .r199		'shell':      .shell200		'make':       .make201		'gradle':     .gradle202	}203	for name, want in profiles {204		assert lang_of(name)? == want, name205	}206}207208fn test_mainstream_project_markers_are_detected() {209	markers := map[string]Lang{210		'v.mod':         .v211		'pom.xml':       .java212		'build.sbt':     .scala213		'Gemfile':       .ruby214		'composer.json': .php215		'pubspec.yaml':  .dart216		'mix.exs':       .elixir217		'DESCRIPTION':   .r218	}219	for file, want in markers {220		root := scratch('detect_marker')!221		write(root, file, '')!222		assert detect(root) == want, file223		os.rmdir_all(root) or {}224	}225}226227fn test_read_config_refuses_what_it_cannot_build() {228	root := scratch('config_bad')!229	defer {230		os.rmdir_all(root) or {}231	}232	if _ := read_config(root) {233		assert false, 'a missing koh.build must be refused'234	} else {235		assert err.msg().contains('koh.build')236	}237}238239fn test_read_config_refuses_each_missing_key() {240	root := scratch('config_keys')!241	defer {242		os.rmdir_all(root) or {}243	}244	cases := [245		['command = true\nplatforms = macos-aarch64\n', 'has no output'],246		['output = a\nplatforms = macos-aarch64\n', 'has no command'],247		['command = true\noutput = a\n', 'names no platforms'],248		['command = true\noutput = a\nplatforms = macos-aarch64, amiga\n', 'amiga'],249		['command = true\noutput = a\nplatforms = macos-aarch64, macos-aarch64\n', 'twice'],250		['command = true\noutput = a\nplatforms = macos-aarch64\nlang = cobol\n', 'cobol'],251	]252	for case in cases {253		write(root, 'koh.build', case[0])!254		if _ := read_config(root) {255			assert false, case[1]256		} else {257			assert err.msg().contains(case[1]), err.msg()258		}259	}260}261262fn test_the_build_environment_is_koh_2s() {263	zig := env_for(.zig, 'linux-aarch64')264	assert zig['KOH_TARGET'] == 'aarch64-linux-musl'265	assert zig['KEPR_TARGET'] == 'aarch64-linux-musl'266	// `go` is a V keyword, so the map needs another name.267	go_env := env_for(.go, 'linux-x86_64')268	assert go_env['KOH_TARGET'] == 'linux-x86_64'269	assert go_env['KEPR_TARGET'] == 'linux-x86_64'270	assert go_env['GOOS'] == 'linux'271	assert go_env['GOARCH'] == 'amd64'272	assert env_for(.rust, 'windows-x86_64')['CARGO_BUILD_TARGET'] == 'x86_64-pc-windows-gnu'273	assert env_for(.rust, 'macos-aarch64')['CARGO_BUILD_TARGET'] == 'aarch64-apple-darwin'274	assert env_for(.c, 'linux-x86_64')['CC'] == 'x86_64-linux-musl-gcc'275	// macOS has no musl cc, and Android carries the targets and nothing else.276	assert 'CC' !in env_for(.c, 'macos-x86_64')277	assert env_for(.gradle, 'android-arm64').len == 2278	assert env_for(.node, 'android-x86')['KEPR_TARGET'] == 'android-x86'279}280281fn test_this_machine_is_one_of_the_platforms() {282	assert this_machine()! in platforms283}284285// A real build, small enough for a unit test: the command writes the file286// `output` names, and the environment it saw is in that file. Only `output`287// takes the tokens — a build script learns the version from the project itself,288// the way Peach Android's reads VERSION.289fn test_build_one_runs_the_command_and_finds_the_output() {290	root := scratch('build_one')!291	defer {292		os.rmdir_all(root) or {}293	}294	write(root, 'koh.build', 'command = mkdir -p out && env > out/demo-1.2.3295output = out/demo-{version}296platforms = macos-aarch64297lang = make298')!299	config := read_config(root)!300	built := build_one(root, config, 'macos-aarch64', '1.2.3')!301	assert built.platform == 'macos-aarch64'302	assert built.path == 'out/demo-1.2.3'303	assert built.size > 0304	seen := os.read_file(os.join_path(root, 'out/demo-1.2.3'))!305	assert seen.contains('KEPR_TARGET=macos-aarch64')306	assert seen.contains('KOH_TARGET=macos-aarch64')307}308309fn test_build_one_refuses_a_failing_build_and_shows_what_it_said() {310	root := scratch('build_fail')!311	defer {312		os.rmdir_all(root) or {}313	}314	write(root, 'koh.build', 'command = sh -c \'echo "no compiler here" >&2; exit 1\'315output = out/demo316platforms = macos-aarch64317')318	config := read_config(root)!319	if _ := build_one(root, config, 'macos-aarch64', '1.2.3') {320		assert false, 'a failing build must be refused'321	} else {322		assert err.msg().contains('no compiler here')323	}324}325326// A build that leaves nothing behind is a build failure, not a release.327fn test_build_one_refuses_a_build_that_creates_nothing() {328	root := scratch('build_empty')!329	defer {330		os.rmdir_all(root) or {}331	}332	write(root, 'koh.build', 'command = true333output = out/demo334platforms = macos-aarch64335')336	config := read_config(root)!337	if _ := build_one(root, config, 'macos-aarch64', '1.2.3') {338		assert false, 'a build with no output must be refused'339	} else {340		assert err.msg().contains('did not create')341	}342}343344// A build that leaves an empty file did not build anything: 0 bytes would be345// published as a release for that platform.346fn test_build_one_refuses_an_empty_artifact() {347	root := scratch('build_zero')!348	defer {349		os.rmdir_all(root) or {}350	}351	write(root, 'koh.build', 'command = mkdir -p out && : > out/demo352output = out/demo353platforms = macos-aarch64354')!355	config := read_config(root)!356	if _ := build_one(root, config, 'macos-aarch64', '1.2.3') {357		assert false, 'an empty artifact must be refused'358	} else {359		assert err.msg().contains('produced an empty')360	}361}362363// A command that leaves something running in the background must not outlive364// the build: it could still be writing the artifact after it was hashed.365fn test_build_one_takes_background_children_with_it() {366	root := scratch('build_orphan')!367	defer {368		os.rmdir_all(root) or {}369	}370	write(root, 'koh.build', "command = mkdir -p out && printf early > out/demo && { sh -c 'sleep 2; printf late > out/demo' & }371output = out/demo372platforms = macos-aarch64373")!374	config := read_config(root)!375	built := build_one(root, config, 'macos-aarch64', '1.2.3')!376	assert built.size == 5377	// Give the background child its two seconds: it must be gone by now.378	time.sleep(3 * time.second)379	assert os.read_file(os.join_path(root, 'out/demo'))! == 'early'380}