Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/sources/test/kotlin
diff options
context:
space:
mode:
authorLeitwolf112026-11-12 18:54:01 +0300
committerLeitwolf112026-11-12 18:54:01 +0300
commit9cff1e2b6a37959abc8a8b66946df61850a0aa47 (patch)
treed15985510bec5aa895c1934ce8a1f429887de448 /sources/test/kotlin
parent6c7e9ad4e59c46da58a121d70caa17c01006273b (diff)
parent01036658064b95c24c1136d2bba9403c81c3337d (diff)
downloadtazel-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')
-rw-r--r--sources/test/kotlin/com/progmasoft/tazel/cli/CommandFixture.kt75
-rw-r--r--sources/test/kotlin/com/progmasoft/tazel/cli/CustomCommandsTest.kt328
-rw-r--r--sources/test/kotlin/com/progmasoft/tazel/cli/TazelOptionsTest.kt130
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)
+ }
+}