diff options
| author | Leitwolf11 | 2026-11-12 18:54:01 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-11-12 18:54:01 +0300 |
| commit | 9cff1e2b6a37959abc8a8b66946df61850a0aa47 (patch) | |
| tree | d15985510bec5aa895c1934ce8a1f429887de448 /sources/test/kotlin | |
| parent | 6c7e9ad4e59c46da58a121d70caa17c01006273b (diff) | |
| parent | 01036658064b95c24c1136d2bba9403c81c3337d (diff) | |
| download | tazel-feature/plugin-api-and-gradle-plugin.tar.gz | |
Merge main into feature/plugin-api-and-gradle-pluginfeature/plugin-api-and-gradle-plugin
Keeps both changelog entries: Tazel Custom Commands and Options from
main, then the plugin API and the Gradle plugin.
Diffstat (limited to 'sources/test/kotlin')
3 files changed, 533 insertions, 0 deletions
diff --git a/sources/test/kotlin/com/progmasoft/tazel/cli/CommandFixture.kt b/sources/test/kotlin/com/progmasoft/tazel/cli/CommandFixture.kt new file mode 100644 index 0000000..2563a44 --- /dev/null +++ b/sources/test/kotlin/com/progmasoft/tazel/cli/CommandFixture.kt @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + */ + +package com.progmasoft.tazel.cli + +import com.progmasoft.tazel.driver.Processes +import com.progmasoft.tazel.driver.Programs +import com.progmasoft.tazel.dsl.Os +import java.nio.file.Files +import java.nio.file.Path + +/** What a command wrote. */ +internal class Recorder { + val out = StringBuilder() + val err = StringBuilder() + + fun stdout() = out.toString() + + fun stderr() = err.toString() +} + +/** + * Runs whole commands in a directory on disk. Programs are stand-ins: they record the command line + * they were started with; one under `tools/` succeeds, any other exits with [programExitCode]. + */ +internal class Fixture(val root: Path) : Processes { + val recorder = Recorder() + val commands: MutableList<Pair<List<String>, Path>> = mutableListOf() + var programExitCode = 0 + + init { + Files.createDirectories(root.resolve("tools")) + } + + fun write(file: String, text: String = "") { + val path = root.resolve(file) + Files.createDirectories(path.parent) + Files.writeString(path, text.trimIndent()) + } + + fun run(vararg arguments: String, directory: String = "."): Int = + run( + arguments.toList(), + recorder.out, + recorder.err, + Environment( + directory = root.resolve(directory).normalize(), + os = Os.LINUX, + programs = Programs(Os.LINUX, mapOf("PATH" to root.resolve("tools").toString())), + processes = this, + ), + ) + + /** The recorded command lines of one program, without the path of the program. */ + fun callsOf(program: String) = + commands.map { it.first }.filter { it.first().endsWith(program) }.map { it.drop(1) } + + override fun run(command: List<String>, directory: Path): Int { + commands += command to directory + return if (command.first().contains("tools")) 0 else programExitCode + } + + override fun capture(command: List<String>): String? = null +} + +internal fun fixture(block: Fixture.() -> Unit) { + val root = Files.createTempDirectory("tazel-command-").toRealPath() + try { + Fixture(root).block() + } finally { + root.toFile().deleteRecursively() + } +} diff --git a/sources/test/kotlin/com/progmasoft/tazel/cli/CustomCommandsTest.kt b/sources/test/kotlin/com/progmasoft/tazel/cli/CustomCommandsTest.kt new file mode 100644 index 0000000..bf9025d --- /dev/null +++ b/sources/test/kotlin/com/progmasoft/tazel/cli/CustomCommandsTest.kt @@ -0,0 +1,328 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + */ + +package com.progmasoft.tazel.cli + +import com.progmasoft.tazel.driver.Programs +import com.progmasoft.tazel.dsl.Os +import com.progmasoft.tazel.dsl.custom.CustomArgument +import com.progmasoft.tazel.dsl.custom.CustomCommand +import com.progmasoft.tazel.dsl.custom.CustomCommandsException +import com.progmasoft.tazel.dsl.custom.CustomStep +import com.progmasoft.tazel.dsl.custom.parser.parseCustomCommands +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class CustomCommandsTest { + private fun program(vararg arguments: String) = + CustomStep.Program(arguments.map(CustomArgument::Text)) + + private fun rejection(text: String) = + assertFailsWith<CustomCommandsException> { parseCustomCommands(text) }.message.orEmpty() + + @Test + fun readsTheThreeKindsOfQuote() { + assertEquals( + listOf( + CustomCommand( + "build-no-cache", + listOf(program("tazel", "build", "--no-cache")), + ) + ), + parseCustomCommands("""build-no-cache: ("tazel" 'build' `--no-cache`);"""), + ) + } + + @Test + fun aCommandIsInsensitiveToNewlinesAndComments() { + val text = + """ + # a comment + <# a comment + of two lines: (not, a, command); #> + fetch: + ("cargo" "fetch") # after a step + ("tazel" + "fetch"); + """ + assertEquals( + listOf( + CustomCommand( + "fetch", + listOf( + program("cargo", "fetch"), + program("tazel", "fetch"), + ), + ) + ), + parseCustomCommands(text), + ) + } + + @Test + fun aShellStepIsOneString() { + assertEquals( + listOf( + CustomCommand( + "fetch", + listOf(CustomStep.Shell("cargo fetch"), program("tazel", "fetch")), + ) + ), + parseCustomCommands("""fetch: s("cargo fetch") ("tazel" "fetch");"""), + ) + assertContains(rejection("""dev: s("npm" "dev");"""), "s(...) takes one string") + assertContains(rejection("dev: s();"), "s(...) takes one string") + } + + @Test + fun aStringHasNoEscapes() { + val steps = parseCustomCommands("""say: ('echo' "it's" `a "word"` "#1");""").single().steps + assertEquals(program("echo", "it's", "a \"word\"", "#1"), steps.single()) + assertEquals( + program("echo\\"), + parseCustomCommands("""say: ("echo\");""").single().steps.single(), + ) + assertContains(rejection("""say: ("echo);"""), "no closing") + } + + @Test + fun aNameMayHoldSeveralWords() { + val command = + parseCustomCommands("""build no-cache: ("tazel" "build" "--no-cache");""").single() + assertEquals("build no-cache", command.name) + assertEquals(listOf("build", "no-cache"), command.words) + assertContains(rejection("a b: ('c'); a b: ('d');"), "defined twice") + assertContains(rejection(List(192) { "w" }.joinToString(" ") + ": ('c');"), "at most 191") + } + + @Test + fun placeholdersAreReadInPrograms() { + val step = parseCustomCommands("""with: ("tazel" <1> "x" <190> <*>);""").single().steps.single() + assertEquals( + CustomStep.Program( + listOf( + CustomArgument.Text("tazel"), + CustomArgument.Position(1), + CustomArgument.Text("x"), + CustomArgument.Position(190), + CustomArgument.All, + ) + ), + step, + ) + assertContains(rejection("""a: ("b" <0>);"""), "runs from 1") + assertContains(rejection("""a: ("b" <192>);"""), "runs from 1") + assertContains(rejection("""a: ("b" <191>);"""), "leaves room for 190") + assertContains(rejection("""a b: ("c" <190>);"""), "leaves room for 189") + assertContains(rejection("""a: ("b" <01>);"""), "runs from 1") + assertContains(rejection("""a: ("b" <x>);"""), "runs from 1") + assertContains(rejection("""a: ("b" <1);"""), "expected <N>") + assertContains(rejection("""a: (<1> "b");"""), "name of its program") + assertContains(rejection("""a: s(<1>);"""), "s(...) takes no arguments") + assertContains(rejection("""a: s("b" <*>);"""), "s(...) takes no arguments") + // In a string it is text, and `<#` still opens a comment. + assertEquals( + program("b", "<1>"), + parseCustomCommands("""a: ("b" "<1>");""").single().steps.single(), + ) + assertEquals( + program("b"), + parseCustomCommands("""a: ("b" <# not an argument #>);""").single().steps.single(), + ) + } + + @Test + fun theArgumentsGoWhereTheCommandSays() = fixture { + write("tazel_custom.txt", """build with: ("bin/tool" "build" <2> <1>);""") + assertEquals(ExitCode.SUCCESS, run("custom", "build", "with", "-e", "MyApp")) + assertEquals(listOf(listOf("build", "MyApp", "-e")), callsOf("tool")) + } + + @Test + fun allTheArgumentsGoTogether() = fixture { + write("tazel_custom.txt", """pass: ("bin/tool" <*>) ("bin/other" "first" <*> "last");""") + assertEquals(ExitCode.SUCCESS, run("custom", "pass", "--flag", "a b")) + assertEquals(listOf(listOf("--flag", "a b")), callsOf("tool")) + assertEquals(listOf(listOf("first", "--flag", "a b", "last")), callsOf("other")) + assertEquals(ExitCode.SUCCESS, run("custom", "pass")) + assertEquals(listOf("--flag", "a b"), callsOf("tool").first()) + assertEquals(emptyList(), callsOf("tool").last()) + } + + @Test + fun theLongestNameWins() = fixture { + write( + "tazel_custom.txt", + """ + build: ("bin/short" <*>); + build no-cache: ("bin/long" <*>); + """, + ) + assertEquals(ExitCode.SUCCESS, run("custom", "build", "no-cache", "x")) + assertEquals(listOf(listOf("x")), callsOf("long")) + assertEquals(ExitCode.SUCCESS, run("custom", "build", "other")) + assertEquals(listOf(listOf("other")), callsOf("short")) + } + + @Test + fun theSeparatorEndsTheName() = fixture { + write( + "tazel_custom.txt", + """ + first: ("bin/short" <*>); + first second: ("bin/long" <*>); + """, + ) + assertEquals(ExitCode.SUCCESS, run("custom", "first", "second")) + assertEquals(listOf(emptyList<String>()), callsOf("long")) + assertEquals(ExitCode.SUCCESS, run("custom", "first", "--", "second")) + assertEquals(listOf(listOf("second")), callsOf("short")) + assertEquals(ExitCode.SUCCESS, run("custom", "first", "a", "--", "second", "--")) + assertEquals(listOf("a", "second", "--"), callsOf("short").last()) + } + + @Test + fun aNameCannotHoldTheSeparator() { + assertContains(rejection("""a -- b: ("c");"""), "cannot hold it") + } + + @Test + fun aShellLineIsNotChanged() { + val line = """"a b" \c\""" + assertEquals(listOf("sh", "-c", line), CustomStep.Shell(line).started(Os.LINUX)) + assertEquals( + listOf("cmd.exe", "/d", "/s", "/c", "\"$line\""), + CustomStep.Shell(line).started(Os.WINDOWS), + ) + } + + @Test + fun strictProcessesAreRefusedOnlyForLinesTheyChange() { + val command = { line: String -> CustomCommand("a", listOf(CustomStep.Shell(line))) } + assertEquals(null, command("echo a b").lineUnfitForStrictProcesses) + assertEquals("echo \"a\"", command("echo \"a\"").lineUnfitForStrictProcesses) + assertEquals("dir a\\", command("dir a\\").lineUnfitForStrictProcesses) + } + + @Test + fun cmdReceivesTheLineAsWritten() { + if (!System.getProperty("os.name").startsWith("Windows")) return + val started = CustomStep.Shell("echo trailing\\").started(Os.WINDOWS) + val process = ProcessBuilder(started).redirectErrorStream(true).start() + assertEquals("trailing\\", process.inputStream.bufferedReader().readText().trim()) + } + + @Test + fun theNumberOfArgumentsIsChecked() = fixture { + write( + "tazel_custom.txt", + """ + two: ("bin/tool" <1> <2>); + none: ("bin/tool"); + """, + ) + assertEquals(ExitCode.FAILURE, run("custom", "two", "a")) + assertContains(recorder.stderr(), "needs 2 argument(s), not 1") + assertEquals(ExitCode.FAILURE, run("custom", "two", "a", "b", "c")) + assertContains(recorder.stderr(), "takes 2 argument(s), not 3") + assertEquals(ExitCode.FAILURE, run("custom", "none", "a")) + assertContains(recorder.stderr(), "takes 0 argument(s), not 1") + assertEquals(0, commands.size) + } + + @Test + fun aMistakeNamesItsPlace() { + assertContains(rejection("first: ();"), "tazel_custom.txt:1:8:") + assertContains(rejection("first: ();"), "at least a program") + assertContains(rejection("first ('a');"), "':' after the name") + assertContains(rejection("first: ('a')"), "';' at the end") + assertContains(rejection("first: ;"), "'(' of a command") + assertContains(rejection("a: ('b'); a: ('c');"), "defined twice") + assertContains(rejection("<# open"), "no closing #>") + assertContains(rejection("a! ('b');"), "has no meaning") + } + + @Test + fun runsTheStepsInOrderInTheDirectoryOfTheFile() = fixture { + write( + "tazel_custom.txt", + """ + fetch: + ("tools/first" "fetch") + ("tools/second" 'fetch'); + """, + ) + Files.createDirectories(root.resolve("Sources")) + + assertEquals(ExitCode.SUCCESS, run("custom", "fetch", directory = "Sources")) + assertEquals( + listOf(listOf("fetch"), listOf("fetch")), + callsOf("first") + callsOf("second"), + ) + assertEquals(root, commands.first().second.toRealPath()) + } + + @Test + fun aFailedStepEndsTheCommandWithItsExitCode() = fixture { + programExitCode = 3 + write("tazel_custom.txt", """all: ("bin/first") ("bin/second");""") + + assertEquals(3, run("custom", "all")) + assertEquals(1, commands.size) + } + + @Test + fun aShellStepGoesToTheShellOfTheHost() = fixture { + write("tazel_custom.txt", """dev: s("npm dev");""") + assertEquals(ExitCode.SUCCESS, run("custom", "dev")) + assertEquals(listOf("sh", "-c", "npm dev"), commands.single().first) + } + + @Test + fun anUnknownCommandSuggestsOne() = fixture { + write("tazel_custom.txt", """npm-dev: ("npm" "dev");""") + assertEquals(ExitCode.FAILURE, run("custom", "npm-dew")) + assertContains(recorder.stderr(), "no command is named 'npm-dew'") + assertContains(recorder.stderr(), "npm-dev") + } + + @Test + fun withoutANameItListsTheCommands() = fixture { + write("tazel_custom.txt", """a: ("x"); b: s("y");""") + assertEquals(ExitCode.SUCCESS, run("custom")) + assertContains(recorder.stdout(), "a") + assertContains(recorder.stdout(), "b") + } + + @Test + fun aMissingFileIsAnError() = fixture { + assertEquals(ExitCode.FAILURE, run("custom", "dev")) + assertContains(recorder.stderr(), "no tazel_custom.txt") + } + + @Test + fun aProgramThatIsNotThereIsNamed() = fixture { + write("tazel_custom.txt", """dev: ("no-such-program");""") + assertEquals(ExitCode.FAILURE, run("custom", "dev")) + assertContains(recorder.stderr(), "no-such-program") + } + + @Test + fun windowsFindsTheScriptsAProgramIsOften() { + val directory = Files.createTempDirectory("tazel-path") + Files.writeString(directory.resolve("npm.cmd"), "") + Files.writeString(directory.resolve("tool.bat"), "") + Files.writeString(directory.resolve("both.exe"), "") + Files.writeString(directory.resolve("both.cmd"), "") + val programs = Programs(Os.WINDOWS, mapOf("PATH" to directory.toString())) + assertEquals(directory.resolve("npm.cmd"), programs.find("npm")) + assertEquals(directory.resolve("npm.cmd"), programs.find("npm.cmd")) + assertEquals(directory.resolve("tool.bat"), programs.find("tool")) + assertEquals(directory.resolve("both.exe"), programs.find("both")) + } +} diff --git a/sources/test/kotlin/com/progmasoft/tazel/cli/TazelOptionsTest.kt b/sources/test/kotlin/com/progmasoft/tazel/cli/TazelOptionsTest.kt new file mode 100644 index 0000000..c0d8716 --- /dev/null +++ b/sources/test/kotlin/com/progmasoft/tazel/cli/TazelOptionsTest.kt @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + */ + +package com.progmasoft.tazel.cli + +import com.progmasoft.tazel.dsl.options.Allocation +import com.progmasoft.tazel.dsl.options.TazelOptions +import com.progmasoft.tazel.dsl.options.TazelOptionsException +import com.progmasoft.tazel.dsl.options.effectiveTazelOptions +import com.progmasoft.tazel.dsl.options.parseTazelOptions +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class TazelOptionsTest { + private fun rejection(text: String) = + assertFailsWith<TazelOptionsException> { parseTazelOptions(text) }.message.orEmpty() + + @Test + fun readsTheOptionsAndTheirComments() { + val text = + """ + # comment + <# comment #> + + sandboxing-with-microvm: true; # a comment after an option + microvm-network: true; + microvm-memory: 2048; + microvm-cpus: 1 + """ + assertEquals( + TazelOptions(true, true, Allocation.Fixed(2048), Allocation.Fixed(1)), + parseTazelOptions(text), + ) + } + + @Test + fun whatIsNotWrittenKeepsItsDefault() { + assertEquals(TazelOptions(), parseTazelOptions("# nothing\n")) + assertEquals(false, TazelOptions().sandboxingWithMicrovm) + assertEquals(false, TazelOptions().microvmNetwork) + assertEquals(Allocation.Auto, TazelOptions().microvmMemory) + assertEquals(Allocation.Auto, TazelOptions().microvmCpus) + } + + @Test + fun autoIsAStringInAnyQuote() { + for (quote in listOf("\"", "'", "`")) { + val options = + parseTazelOptions("microvm-memory: ${quote}auto$quote; microvm-cpus: ${quote}auto$quote;") + assertEquals(Allocation.Auto, options.microvmMemory) + assertEquals(Allocation.Auto, options.microvmCpus) + } + assertContains(rejection("microvm-cpus: auto;"), "expected a number") + assertContains(rejection("microvm-cpus: \"two\";"), "only string here is \"auto\"") + } + + @Test + fun mistakesNameTheirPlace() { + assertContains(rejection("sandbox: true;"), "'sandbox' is not an option") + assertContains(rejection("microvm-network: true; microvm-network: false;"), "set twice") + assertContains(rejection("microvm-network: yes;"), "expected true or false") + assertContains(rejection("microvm-network: \"true\";"), "expected true or false") + assertContains(rejection("microvm-network true;"), "expected ':'") + assertContains(rejection("microvm-network: true microvm-cpus: 1"), "expected ';'") + assertContains(rejection("microvm-cpus: 0;"), "expected a number") + assertContains(rejection("microvm-cpus: 257;"), "outside 1 to 256") + assertContains(rejection("microvm-memory: 100;"), "outside 256 to 1048576") + assertContains(rejection("microvm-memory:"), "expected a value") + assertContains( + rejection("microvm-memory: \"auto"), + "tazel_options.txt:1:17: this string has no closing", + ) + } + + @Test + fun aSandboxThatCannotStartStopsTheCommand() = fixture { + write("tazel_custom.txt", """dev: ("bin/tool");""") + write("tazel_options.txt", "sandboxing-with-microvm: true;") + assertEquals(ExitCode.FAILURE, run("custom", "dev")) + assertContains(recorder.stderr(), "has no MicroVM to start, so nothing ran") + assertEquals(0, commands.size) + assertEquals(ExitCode.SUCCESS, run("version")) + } + + @Test + fun withoutTheSandboxCommandsRunAsBefore() = fixture { + write("tazel_custom.txt", """dev: ("bin/tool");""") + write("tazel_options.txt", "sandboxing-with-microvm: false; microvm-cpus: 2;") + assertEquals(ExitCode.SUCCESS, run("custom", "dev")) + assertEquals(1, commands.size) + } + + @Test + fun aCloserFileCannotTurnOffASandboxAskedForAbove() = fixture { + write("tazel_options.txt", "sandboxing-with-microvm: true; microvm-cpus: 4;") + write("project/tazel_custom.txt", """dev: ("../bin/tool");""") + write("project/tazel_options.txt", "sandboxing-with-microvm: false; microvm-cpus: 2;") + assertEquals(ExitCode.FAILURE, run("custom", "dev", directory = "project")) + assertContains(recorder.stderr(), "has no MicroVM to start, so nothing ran") + assertEquals(0, commands.size) + val (options, asking) = assertNotNull(effectiveTazelOptions(root.resolve("project"))) + assertEquals(true, options.sandboxingWithMicrovm) + assertEquals(Allocation.Fixed(2), options.microvmCpus) + assertEquals(root.resolve("tazel_options.txt").toAbsolutePath(), asking) + } + + @Test + fun anUnreadableOptionsFileStopsTheCommand() = fixture { + write("tazel_custom.txt", """dev: ("bin/tool");""") + Files.write(root.resolve("tazel_options.txt"), byteArrayOf(0xFF.toByte(), 0xFE.toByte())) + assertEquals(ExitCode.FAILURE, run("custom", "dev")) + assertContains(recorder.stderr(), "could not be read") + assertEquals(0, commands.size) + } + + @Test + fun aBrokenOptionsFileStopsTheCommand() = fixture { + write("tazel_custom.txt", """dev: ("bin/tool");""") + write("tazel_options.txt", "sandboxing-with-microvm: maybe;") + assertEquals(ExitCode.FAILURE, run("custom", "dev")) + assertContains(recorder.stderr(), "tazel_options.txt:1:26: expected true or false") + assertEquals(0, commands.size) + } +} |