diff --git a/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift b/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift deleted file mode 100644 index fe326fae..00000000 --- a/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift +++ /dev/null @@ -1,70 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2026 Apple Inc. and the container project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - -import Foundation -import Testing - -class TestCLIProgressAuto: CLITest { - @Test func testAutoProgressFallsBackToPlainWhenPiped() throws { - let (_, _, error, status) = try run(arguments: [ - "image", "pull", - "--progress", "auto", - alpine, - ]) - #expect(status == 0, "image pull should succeed, stderr: \(error)") - let lines = error.components(separatedBy: .newlines) - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - #expect(!lines.isEmpty, "expected plain progress output on stderr when piped") - #expect(!error.contains("\u{1B}["), "expected no ANSI escapes in piped output") - } - - @Test func testExplicitPlainProgress() throws { - let (_, _, error, status) = try run(arguments: [ - "image", "pull", - "--progress", "plain", - alpine, - ]) - #expect(status == 0, "image pull --progress plain should succeed, stderr: \(error)") - let lines = error.components(separatedBy: .newlines) - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - #expect(!lines.isEmpty, "expected plain progress output on stderr") - #expect(!error.contains("\u{1B}["), "expected no ANSI escapes with --progress plain") - } - - @Test func testExplicitAnsiProgress() throws { - let (_, _, error, status) = try run(arguments: [ - "image", "pull", - "--progress", "ansi", - alpine, - ]) - #expect(status == 0, "image pull --progress ansi should succeed, stderr: \(error)") - let lines = error.components(separatedBy: .newlines) - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - #expect(!lines.isEmpty, "expected ansi progress output on stderr") - } - - @Test func testNoneProgressSuppressesOutput() throws { - let (_, _, error, status) = try run(arguments: [ - "image", "pull", - "--progress", "none", - alpine, - ]) - #expect(status == 0, "image pull --progress none should succeed, stderr: \(error)") - let lines = error.components(separatedBy: .newlines) - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - #expect(lines.isEmpty, "expected no progress output on stderr with --progress none") - } -} diff --git a/Tests/CLITests/Subcommands/Registry/TestCLIRegistry.swift b/Tests/CLITests/Subcommands/Registry/TestCLIRegistry.swift deleted file mode 100644 index 058f27b0..00000000 --- a/Tests/CLITests/Subcommands/Registry/TestCLIRegistry.swift +++ /dev/null @@ -1,48 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2026 Apple Inc. and the container project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - -import Foundation -import Testing - -@Suite(.serialSuites) -class TestCLIRegistry: CLITest { - @Test func testListDefaultFormat() throws { - let (_, output, error, status) = try run(arguments: ["registry", "list"]) - #expect(status == 0, "registry list should succeed, stderr: \(error)") - - let requiredHeaders = ["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"] - #expect( - requiredHeaders.allSatisfy { output.contains($0) }, - "output should contain all required headers" - ) - } - - @Test func testListJSONFormat() throws { - let (data, _, error, status) = try run(arguments: ["registry", "list", "--format", "json"]) - #expect(status == 0, "registry list --format json should succeed, stderr: \(error)") - - let json = try JSONSerialization.jsonObject(with: data, options: []) - #expect(json is [Any], "JSON output should be an array") - } - - @Test func testListQuietMode() throws { - let (_, output, error, status) = try run(arguments: ["registry", "list", "-q"]) - #expect(status == 0, "registry list -q should succeed, stderr: \(error)") - - #expect(!output.contains("HOSTNAME"), "quiet mode should not contain headers") - #expect(!output.contains("USERNAME"), "quiet mode should not contain headers") - } -} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift new file mode 100644 index 00000000..fc3e1d5c --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift @@ -0,0 +1,138 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@Suite(.serialized) +struct TestCLIBuilderEnvOnlySerial { + @Test func testBuildEnvironmentOnlyImageFromScratch() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM scratch + ARG BUILD_DATE + ARG VERSION=1.0.0 + ENV TERM=xterm \\ + BUILD_DATE=${BUILD_DATE} \\ + APP_VERSION=${VERSION} \\ + PATH=/usr/local/bin:/usr/bin:/bin + LABEL maintainer="test@example.com" version="${VERSION}" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-env-only:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir, buildArgs: ["BUILD_DATE=2025-01-01", "VERSION=2.0.0"]) + try f.assertImageBuilt(imageName) + } + } + } + + @Test func testBuildEnvironmentOnlyImageFromAlpine() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ENV APP_NAME=myapp APP_VERSION=1.0.0 APP_ENV=production + LABEL maintainer="test@example.com" version="1.0.0" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-alpine-env:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir) + try f.assertImageBuilt(imageName) + } + } + } + + @Test func testMultiStageBuildWithEnvOnlyBase() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let baseDir = try f.createTempDir() + let baseDockerfile = + """ + FROM scratch + ARG JOBS=6 + ARG ARCH=amd64 + ENV MAKEOPTS="-j${JOBS}" ARCH="${ARCH}" PATH=/usr/local/bin:/usr/bin + """ + try f.createContext(dir: baseDir, dockerfile: baseDockerfile) + let baseImageName = "test-env-base:\(UUID().uuidString)" + try f.build(tag: baseImageName, contextDir: baseDir, buildArgs: ["JOBS=8", "ARCH=arm64"]) + try f.assertImageBuilt(baseImageName) + + let downstreamDir = try f.createTempDir() + let downstreamDockerfile = + """ + FROM \(baseImageName) + LABEL test="env-inherited" + """ + try f.createContext(dir: downstreamDir, dockerfile: downstreamDockerfile) + let downstreamImageName = "test-env-child:\(UUID().uuidString)" + try f.build(tag: downstreamImageName, contextDir: downstreamDir) + try f.assertImageBuilt(downstreamImageName) + } + } + } + + @Test func testComplexArgAndEnvCombinations() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM scratch + ARG JOBS=6 + ARG MAXLOAD=7.00 + ARG ARCH=amd64 + ARG PROFILE_PATH=23.0/split-usr/no-multilib + ARG CHOST=x86_64-pc-linux-gnu + ARG CFLAGS=-O2 -pipe + ENV JOBS="${JOBS}" MAXLOAD="${MAXLOAD}" \\ + GENTOO_PROFILE="default/linux/${ARCH}/${PROFILE_PATH}" \\ + CHOST="${CHOST}" MAKEOPTS="-j${JOBS}" \\ + CFLAGS="${CFLAGS}" CXXFLAGS="${CFLAGS}" + LABEL maintainer="test@example.com" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-complex-env:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir, buildArgs: ["JOBS=12", "ARCH=arm64"]) + try f.assertImageBuilt(imageName) + } + } + } + + @Test func testLabelOnlyDockerfile() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM scratch + LABEL maintainer="test@example.com" version="1.0.0" \\ + description="Test image with only labels" \\ + org.opencontainers.image.title="Test Image" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-label-only:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir) + try f.assertImageBuilt(imageName) + } + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift new file mode 100644 index 00000000..059cb38a --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Darwin +import Foundation +import Testing + +/// Tests for `container builder start`, `stop`, and `delete` lifecycle commands. +/// +/// These tests manage the builder manually — they do not use ``withBuilder`` +/// because they are specifically testing the lifecycle commands themselves. +/// They acquire the shared builder lock via ``withBuilderLock`` to serialise +/// correctly with tests that use ``withBuilder(_:)``. +@Suite(.serialized) +struct TestCLIBuilderLifecycleSerial { + @Test func testBuilderStartStopCommand() async throws { + try await ContainerFixture.with { f in + try await f.withBuilderLock { + f.addCleanup { try? f.builderDelete(force: true) } + + try f.builderStart() + try await f.waitForBuilderRunning() + let status1 = try f.getContainerStatus("buildkit") + #expect(status1 == "running", "buildkit container should be running") + + try f.builderStop() + let status2 = try f.getContainerStatus("buildkit") + #expect(status2 == "stopped", "buildkit container should be stopped") + } + } + } + + @Test func testBuilderEnvironmentColors() async throws { + try await ContainerFixture.with { f in + try await f.withBuilderLock { + let originalColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] + let originalNoColor = ProcessInfo.processInfo.environment["NO_COLOR"] + f.addCleanup { + if let c = originalColors { setenv("BUILDKIT_COLORS", c, 1) } else { unsetenv("BUILDKIT_COLORS") } + if let n = originalNoColor { setenv("NO_COLOR", n, 1) } else { unsetenv("NO_COLOR") } + _ = try? f.builderDelete(force: true) + } + + _ = try? f.builderDelete(force: true) + setenv("BUILDKIT_COLORS", "run=green:warning=yellow:error=red:cancel=cyan", 1) + setenv("NO_COLOR", "true", 1) + + try f.run(["builder", "start"]).check() + try await f.waitForBuilderRunning() + + let container = try f.inspectContainer("buildkit") + let env = container.configuration.initProcess.environment + #expect( + env.contains("BUILDKIT_COLORS=run=green:warning=yellow:error=red:cancel=cyan"), + "BUILDKIT_COLORS should be forwarded to the buildkit container") + #expect( + env.contains("NO_COLOR=true"), + "NO_COLOR should be forwarded to the buildkit container") + } + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift new file mode 100644 index 00000000..834d8a97 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift @@ -0,0 +1,147 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@Suite(.serialized) +struct TestCLIBuilderLocalOutputSerial { + @Test func testBuildLocalOutputHappyPath() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + // Comprehensive multi-stage build with context and build args. + let dir = try f.createTempDir() + let dockerfile = + """ + ARG MESSAGE=default + FROM scratch AS builder + ADD build.txt /build.txt + ADD testfile.txt /hello.txt + FROM scratch + COPY --from=builder /build.txt /final.txt + COPY --from=builder /hello.txt /app/hello.txt + ADD message.txt /message.txt + """ + let context: [ContainerFixture.FileSystemEntry] = [ + .file("build.txt", content: .data("Building stage\n".data(using: .utf8)!)), + .file("testfile.txt", content: .data("Hello from local build\n".data(using: .utf8)!)), + .file("message.txt", content: .data("Hello from build args\n".data(using: .utf8)!)), + ] + try f.createContext(dir: dir, dockerfile: dockerfile, context: context) + let outputDir = dir.appending("comprehensive-local-output") + let imageName = "local-comprehensive-test:\(UUID().uuidString)" + let response = try f.buildWithPathsAndLocalOutput( + tag: imageName, contextDir: dir, outputDir: outputDir, + buildArgs: ["MESSAGE=Hello from build args"]) + #expect(response.contains(outputDir.string), "output should reference the export path") + #expect(FileManager.default.fileExists(atPath: outputDir.string)) + let contents = try FileManager.default.contentsOfDirectory(atPath: outputDir.string) + #expect(!contents.isEmpty, "output directory should contain files") + + // Basic local output. + let basicDir = try f.createTempDir() + try f.createContext( + dir: basicDir, + dockerfile: "FROM scratch\nADD testfile.txt /hello.txt", + context: [.file("testfile.txt", content: .data("Hello from basic build\n".data(using: .utf8)!))]) + let basicOutputDir = basicDir.appending("basic-local-output") + let basicResponse = try f.buildWithPathsAndLocalOutput( + tag: "local-basic-test:\(UUID().uuidString)", contextDir: basicDir, outputDir: basicOutputDir) + #expect(basicResponse.contains(basicOutputDir.string)) + #expect(FileManager.default.fileExists(atPath: basicOutputDir.string)) + + // Build with context (COPY instruction). + let ctxDir = try f.createTempDir() + try f.createContext( + dir: ctxDir, + dockerfile: "FROM scratch\nCOPY testfile.txt /app/testfile.txt", + context: [.file("testfile.txt", content: .data("Test content\n".data(using: .utf8)!))]) + let ctxOutputDir = ctxDir.appending("context-local-output") + let ctxResponse = try f.buildWithPathsAndLocalOutput( + tag: "local-context-test:\(UUID().uuidString)", contextDir: ctxDir, outputDir: ctxOutputDir) + #expect(ctxResponse.contains(ctxOutputDir.string)) + #expect(FileManager.default.fileExists(atPath: ctxOutputDir.string)) + } + } + } + + @Test func testBuildLocalOutputEdgeCases() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + // Different paths for Dockerfile context and build context. + let dockerfileDir = try f.createTempDir() + try f.createContext( + dir: dockerfileDir, + dockerfile: "FROM scratch\nCOPY . /app", + context: [.file("dockerfile-context.txt", content: .data("Dockerfile context\n".data(using: .utf8)!))]) + + let buildContextDir = try f.createTempDir() + try f.createContext( + dir: buildContextDir, dockerfile: "", + context: [.file("build-context.txt", content: .data("Build context\n".data(using: .utf8)!))]) + + let outputDir = dockerfileDir.appending("diffpaths-local-output") + let response = try f.buildWithPathsAndLocalOutput( + tag: "local-diffpaths-test:\(UUID().uuidString)", + contextDir: buildContextDir, + dockerfilePath: dockerfileDir.appending("Dockerfile"), + outputDir: outputDir) + #expect(response.contains(outputDir.string)) + #expect(FileManager.default.fileExists(atPath: outputDir.string)) + + // Build into an existing output directory (should merge/overwrite). + let existingDir = try f.createTempDir() + try f.createContext( + dir: existingDir, + dockerfile: "FROM scratch\nADD newfile.txt /newfile.txt", + context: [.file("newfile.txt", content: .data("New content\n".data(using: .utf8)!))]) + let existingOutputDir = existingDir.appending("existing-output") + try FileManager.default.createDirectory( + atPath: existingOutputDir.string, withIntermediateDirectories: true, attributes: nil) + try "Existing content\n".data(using: .utf8)! + .write(to: URL(filePath: existingOutputDir.appending("existing.txt").string), options: .atomic) + let existingResponse = try f.buildWithPathsAndLocalOutput( + tag: "local-existing-test:\(UUID().uuidString)", + contextDir: existingDir, outputDir: existingOutputDir) + #expect(existingResponse.contains(existingOutputDir.string)) + let contents = try FileManager.default.contentsOfDirectory(atPath: existingOutputDir.string) + #expect(!contents.isEmpty) + } + } + } + + @Test func testBuildLocalOutputFailure() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD test.txt /test.txt", + context: [.file("test.txt", content: .data("test\n".data(using: .utf8)!))]) + + // An uncreateable path should cause the build to fail. + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-t", "local-invalid-test:\(UUID().uuidString)", + "--output", "type=local,dest=/nonexistent/invalid/path", + dir.appending("context").string, + ]) + #expect(result.status != 0, "build with invalid output path should fail") + } + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift new file mode 100644 index 00000000..1e75cb78 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift @@ -0,0 +1,1104 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Darwin +import Foundation +import Testing + +// Convenience alias for the verbose entry type. +typealias FSEntry = ContainerFixture.FileSystemEntry + +@Suite(.serialized) +struct TestCLIBuilderSerial { + + // MARK: - Basic build tests + + @Test func testBuildDefaultParams() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext(dir: dir, dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20") + // No tags — runtime generates one and prints it to stdout. + let output = try f.buildWithPaths(contextDir: dir) + let generatedTag = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(!generatedTag.isEmpty, "build should print the generated image tag to stdout") + try f.assertImageBuilt(generatedTag) + } + } + } + + @Test func testBuildDotFileSucceeds() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [ + .file("emptyFile", content: .zeroFilled(size: 1)), + .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), + ]) + let image = "registry.local/dot-file:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildFromPreviousStage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 AS layer1 + RUN sh -c "echo 'layer1' > /layer1.txt" + FROM layer1 + CMD ["cat", "/layer1.txt"] + """) + let image = "registry.local/from-previous-layer:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildFromLocalImage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [ + .file("emptyFile", content: .zeroFilled(size: 0)), + .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), + ]) + let image = "local-only:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + + let dir2 = try f.createTempDir() + try f.createContext( + dir: dir2, + dockerfile: "FROM \(image)", + context: []) + let image2 = "from-local:\(UUID().uuidString)" + try f.build(tag: image2, contextDir: dir2) + try f.assertImageBuilt(image2) + } + } + } + + @Test func testBuildAddFromSpecialDirs() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/scratch-add-special-dir:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildScratchAdd() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/scratch-add:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildAddAll() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD . . + RUN cat emptyFile + RUN cat Test/testempty + """, + context: [ + .directory("Test"), + .file("Test/testempty", content: .zeroFilled(size: 1)), + .file("emptyFile", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/add-all:\(UUID().uuidString)" + let output = try f.build(tag: image, contextDir: dir) + #expect(output.contains(image)) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG TAG=unknown\nFROM ghcr.io/linuxcontainers/alpine:${TAG}") + let image = "registry.local/build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["TAG=3.20"]) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildSecret() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN --mount=type=secret,id=ENV1 \\ + --mount=type=secret,id=env2 \\ + --mount=type=secret,id=env3 \\ + test xyyzzz = "`cat /run/secrets/ENV1 /run/secrets/env2 /run/secrets/env3`" + RUN --mount=type=secret,id=file \\ + awk 'BEGIN {for(i=0; i<17; i++) for(c=0; c<256; c++) printf("%c", c)}' > /tmp/foo && \\ + cmp /tmp/foo /run/secrets/file && \\ + rm /tmp/foo + RUN --mount=type=secret,id=empty \\ + ! test -e /run/secrets/file && \\ + test -e /run/secrets/empty && \\ + cmp /dev/null /run/secrets/empty + """) + + setenv("ENV1", "x", 1) + setenv("ENV_VAR", "yy", 1) + setenv("env3", "zzz", 1) + f.addCleanup { + unsetenv("ENV1") + unsetenv("ENV_VAR") + unsetenv("env3") + } + + let testData = Data((0..<17).flatMap { _ in Array(0...255) }) + let secretFile = try f.createTempFile(suffix: " _f,i=l.e+ ", contents: testData) + let emptyFile = try f.createTempFile(suffix: "file2", contents: Data()) + + let image = "registry.local/secrets:\(UUID().uuidString)" + try f.build( + tag: image, contextDir: dir, + otherArgs: [ + "--secret", "id=ENV1", + "--secret", "id=env2,env=ENV_VAR", + "--secret", "id=env3,env=env3", + "--secret", "id=file,src=\(secretFile.string)", + "--secret", "id=empty,src=\(emptyFile.string)", + ]) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildNetworkAccess() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ARG HTTP_PROXY + ARG HTTPS_PROXY + ARG NO_PROXY + ARG http_proxy + ARG https_proxy + ARG no_proxy + RUN apk add --no-cache curl + """) + var buildArgs: [String] = [] + for key in ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] { + if let v = ProcessInfo.processInfo.environment[key] { buildArgs.append("\(key)=\(v)") } + } + let image = "registry.local/build-network-access:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: buildArgs) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildDockerfileKeywords() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG TAG=3.20 + FROM ghcr.io/linuxcontainers/alpine:${TAG} + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN echo "Hello, World!" > /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN ["sh", "-c", "echo 'Exec form' > /exec.txt"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + CMD ["echo", "Exec default"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + LABEL version="1.0" description="Test image" + FROM ghcr.io/linuxcontainers/alpine:3.20 + EXPOSE 8080 + FROM ghcr.io/linuxcontainers/alpine:3.20 + ENV MY_ENV=hello + RUN echo $MY_ENV > /env.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD emptyFile / + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY toCopy /toCopy + FROM ghcr.io/linuxcontainers/alpine:3.20 + ENTRYPOINT ["echo", "entrypoint!"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + VOLUME /data + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN adduser -D myuser + USER myuser + CMD whoami + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + RUN pwd > /pwd.out + FROM ghcr.io/linuxcontainers/alpine:3.20 + ARG MY_VAR=default + RUN echo $MY_VAR > /var.out + """, + context: [ + .file("emptyFile", content: .zeroFilled(size: 1)), + .file("toCopy", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/dockerfile-keywords:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildSymlink() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test1Source Test1Source + ADD Test1Source2 Test1Source2 + RUN cat Test1Source2/test.yaml + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test2Source Test2Source + ADD Test2Source2 Test2Source2 + RUN cat Test2Source2/Test/test.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test3Source Test3Source + ADD Test3Source2 Test3Source2 + RUN cat Test3Source2/Dest/test.txt + """ + let context: [FSEntry] = [ + .directory("Test1Source"), .directory("Test1Source2"), + .file("Test1Source/test.yaml", content: .zeroFilled(size: 200)), + .symbolicLink("Test1Source2/test.yaml", target: "Test1Source/test.yaml"), + .directory("Test2Source"), .directory("Test2Source2"), + .file("Test2Source/Test/Test/test.yaml", content: .zeroFilled(size: 300)), + .symbolicLink("Test2Source2/Test/test.yaml", target: "Test2Source/Test/Test/test.yaml"), + .directory("Test3Source/Source"), .directory("Test3Source2"), + .file("Test3Source/Source/test.txt", content: .zeroFilled(size: 1)), + .symbolicLink("Test3Source2/Dest", target: "Test3Source/Source"), + ] + try f.createContext(dir: dir, dockerfile: dockerfile, context: context) + let image = "registry.local/build-symlinks:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildAndRun() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nRUN echo \"foobar\" > /file") + let image = "\(f.testID)-build-and-run:latest" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + try await f.withContainer(image: image) { name in + let output = try f.doExec(name, cmd: ["cat", "/file"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "foobar") + } + } + } + } + + @Test func testBuildDifferentPaths() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN ls ./ + COPY . /root + RUN cat /root/Test/test.txt + """, + context: [ + .directory(".git"), + .file(".git/FETCH", content: .zeroFilled(size: 1)), + .directory("Test"), + .file("Test/test.txt", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/build-diff-context:\(UUID().uuidString)" + try f.buildWithPaths(tags: [image], contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildMultiArch() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD . . + RUN cat emptyFile + RUN cat Test/testempty + """, + context: [ + .directory("Test"), + .file("Test/testempty", content: .zeroFilled(size: 1)), + .file("emptyFile", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/multi-arch:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, otherArgs: ["--arch", "amd64,arm64"]) + try f.assertImageBuilt(image) + + let output = try f.doInspectImages(image) + #expect(output.count == 1, "expected single inspect result") + let archs = Set(output[0].variants.map { $0.platform.architecture }) + #expect(archs == Set(["amd64", "arm64"]), "expected amd64 and arm64 variants") + } + } + } + + @Test func testBuildMultipleTags() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let uuid = UUID().uuidString + let tag1 = "registry.local/multi-tag-test:\(uuid)" + let tag2 = "registry.local/multi-tag-test:latest" + let tag3 = "registry.local/multi-tag-test:v1.0.0" + let output = try f.buildWithPaths(tags: [tag1, tag2, tag3], contextDir: dir) + #expect(output.contains(tag1)) + #expect(output.contains(tag2)) + #expect(output.contains(tag3)) + try f.assertImageBuilt(tag1) + try f.assertImageBuilt(tag2) + try f.assertImageBuilt(tag3) + } + } + } + + @Test func testBuildAfterContextChange() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let initialContent = "initial".data(using: .utf8)! + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY foo /foo\nCOPY bar /bar", + context: [ + .file("foo", content: .data(Data((0..<4 * 1024 * 1024).map { UInt8($0 % 256) }))), + .file("bar", content: .data(initialContent)), + ]) + + let image1 = "\(f.testID)-build-context-change:v1" + try f.build(tag: image1, contextDir: dir) + try await f.withContainer(image: image1) { name in + let out = try f.doExec(name, cmd: ["cat", "/bar"]) + #expect(out == "initial") + } + + let contextBar = dir.appending("context").appending("bar") + try "updated".data(using: .utf8)!.write(to: URL(filePath: contextBar.string), options: .atomic) + + let image2 = "\(f.testID)-build-context-change:v2" + try f.build(tag: image2, contextDir: dir) + try await f.withContainer(image: image2) { name in + let out = try f.doExec(name, cmd: ["cat", "/bar"]) + #expect(out == "updated") + } + } + } + } + + @Test func testBuildWithDockerfileFromStdin() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM scratch\nADD emptyFile /" + try f.createContext( + dir: dir, dockerfile: "", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/stdin-file:\(UUID().uuidString)" + try f.buildWithStdin(tags: [image], contextDir: dir, dockerfileContents: dockerfile) + try f.assertImageBuilt(image) + } + } + } + + @Test func testLowercaseDockerfile() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let files: [(String, String, String)] = [ + ("COPY . /app", "copy-uppercase", "COPY"), + ("copy . /app", "copy-lowercase", "copy"), + ("ADD . /app", "add-uppercase", "ADD"), + ("add . /app", "add-lowercase", "add"), + ] + for (instruction, name, _) in files { + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + \(instruction) + RUN test -f /app/testfile.txt + """, + context: [.file("testfile.txt", content: .data("test".data(using: .utf8)!))]) + let image = "registry.local/\(name):\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + } + + @Test func testRunWithBindMount() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN --mount=type=bind,source=.,target=/mnt/context \\ + set -e; \\ + if [ ! -f /mnt/context/app.py ]; then echo "ERROR: app.py missing"; exit 1; fi; \\ + if [ ! -f /mnt/context/config.yaml ]; then echo "ERROR: config.yaml missing"; exit 1; fi; \\ + cp /mnt/context/app.py /app.py + RUN cat /app.py + """, + context: [ + .file("app.py", content: .data("print('Hello from bind mount')".data(using: .utf8)!)), + .file("config.yaml", content: .data("key: value".data(using: .utf8)!)), + ]) + let image = "registry.local/bind-mount-test:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + // MARK: - .dockerignore tests + + @Test func testBuildDockerIgnore() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerignore = """ + secret.txt + *.log + **/*.log + !important.log + *.tmp + **/*.tmp + temp/ + node_modules/ + """ + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY . /app + RUN set -e; [ ! -f /app/secret.txt ] || exit 1 + RUN set -e; [ ! -f /app/debug.log ] || exit 1 + RUN set -e; [ -f /app/important.log ] || exit 1 + RUN set -e; find /app -name "*.tmp" | grep . && exit 1; true + RUN set -e; [ ! -d /app/temp ] || exit 1 + RUN set -e; [ ! -d /app/node_modules ] || exit 1 + RUN set -e; [ -f /app/main.go ] && [ -f /app/README.md ] && [ -f /app/src/app.go ] + """, + context: [ + .file(".dockerignore", content: .data(dockerignore.data(using: .utf8)!)), + .file("secret.txt", content: .data("secret".data(using: .utf8)!)), + .file("debug.log", content: .data("debug".data(using: .utf8)!)), + .file("important.log", content: .data("important".data(using: .utf8)!)), + .file("cache.tmp", content: .data("cache".data(using: .utf8)!)), + .file("main.go", content: .data("package main".data(using: .utf8)!)), + .file("README.md", content: .data("# README".data(using: .utf8)!)), + .directory("temp"), + .file("logs/app.log", content: .data("app log".data(using: .utf8)!)), + .directory("node_modules"), + .directory("src"), + .file("src/app.go", content: .data("package src".data(using: .utf8)!)), + ]) + let image = "registry.local/dockerignore-test:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testDockerIgnoreBasic() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, + dockerfile: dockerfile, + context: [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("ignored.txt", content: .data("ignored\n".data(using: .utf8)!)), + .file(".dockerignore", content: .data("ignored.txt\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-basic:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]) + try result.check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/ignored.txt") + } + } + } + } + + @Test func testDockerIgnoreDockerfileSpecific() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), + .file("Dockerfile.dockerignore", content: .data("specific.txt\n".data(using: .utf8)!)), + .file("general.txt", content: .data("general\n".data(using: .utf8)!)), + .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-specific:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/specific.txt", "specific.txt should be ignored by Dockerfile.dockerignore") + try f.assertContainerHasFile(name, at: "/app/general.txt", "general.txt should be present (Dockerfile.dockerignore takes precedence)") + } + } + } + } + + @Test func testDockerIgnoreOutsideContext() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), + .file("general.txt", content: .data("general\n".data(using: .utf8)!)), + .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), + ]) + try "specific.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) + let image = "registry.local/dockerignore-outside:\(UUID().uuidString)" + try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/specific.txt") + try f.assertContainerHasFile(name, at: "/app/general.txt") + } + } + } + } + + @Test func testDockerIgnoreIgnoredDockerfile() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file(".dockerignore", content: .data("Dockerfile\n.dockerignore\n".data(using: .utf8)!)), + .file("test.txt", content: .data("test\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-ignored-dockerfile:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/Dockerfile") + try f.assertContainerMissingFile(name, at: "/app/.dockerignore") + try f.assertContainerHasFile(name, at: "/app/test.txt") + } + } + } + } + + @Test func testDockerIgnoreSubdirDockerfile() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file(".dockerignore", content: .data("included.txt\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), + .file("nested/secret.txt", content: .data("nested secret\n".data(using: .utf8)!)), + .file("nested/project/Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("nested/project/Dockerfile.dockerignore", content: .data("secret.txt\n**/secret.txt\n".data(using: .utf8)!)), + .file("nested/project/config.txt", content: .data("config\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let nestedDockerfile = contextDir.appending("nested").appending("project").appending("Dockerfile") + let image = "registry.local/dockerignore-subdir:\(UUID().uuidString)" + try f.run([ + "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/secret.txt") + try f.assertContainerMissingFile(name, at: "/app/nested/secret.txt") + try f.assertContainerHasFile(name, at: "/app/nested/project/config.txt") + } + } + } + } + + @Test func testDockerIgnoreCustomDockerfileName() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", // no top-level Dockerfile + context: [ + .file(".dockerignore", content: .data("generic.txt\n".data(using: .utf8)!)), + .file("app1.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("app1.Dockerfile.dockerignore", content: .data("app1-specific.txt\n".data(using: .utf8)!)), + .file("app1-specific.txt", content: .data("app1 specific\n".data(using: .utf8)!)), + .file("generic.txt", content: .data("generic\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-custom-name:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("app1.Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/app1-specific.txt") + try f.assertContainerHasFile(name, at: "/app/generic.txt") + try f.assertContainerHasFile(name, at: "/app/included.txt") + } + } + } + } + + @Test func testDockerIgnoreCustomNameSubdir() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", + context: [ + .file(".dockerignore", content: .data("from-root-ignore.txt\n".data(using: .utf8)!)), + .file("from-root-ignore.txt", content: .data("root ignore\n".data(using: .utf8)!)), + .file("from-app2-ignore.txt", content: .data("app2 ignore\n".data(using: .utf8)!)), + .file("always-included.txt", content: .data("always\n".data(using: .utf8)!)), + .file("nested/project/app2.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("nested/project/app2.Dockerfile.dockerignore", content: .data("from-app2-ignore.txt\n".data(using: .utf8)!)), + .file("nested/project/config.yaml", content: .data("config\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let nestedDockerfile = contextDir.appending("nested").appending("project").appending("app2.Dockerfile") + let image = "registry.local/dockerignore-custom-subdir:\(UUID().uuidString)" + try f.run([ + "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/from-app2-ignore.txt") + try f.assertContainerHasFile(name, at: "/app/from-root-ignore.txt") + try f.assertContainerHasFile(name, at: "/app/always-included.txt") + try f.assertContainerHasFile(name, at: "/app/nested/project/config.yaml") + } + } + } + } + + @Test func testDockerIgnoreCoexistingDockerfiles() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let appDockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", + context: [ + .file("Dockerfile", content: .data("FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . .\n".data(using: .utf8)!)), + .file("Dockerfile.dockerignore", content: .data("dockerfile-specific.txt\n".data(using: .utf8)!)), + .file("app.Dockerfile", content: .data(appDockerfile.data(using: .utf8)!)), + .file("app.Dockerfile.dockerignore", content: .data("app-specific.txt\n".data(using: .utf8)!)), + .file("dockerfile-specific.txt", content: .data("df specific\n".data(using: .utf8)!)), + .file("app-specific.txt", content: .data("app specific\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-coexisting:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("app.Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/app-specific.txt") + try f.assertContainerHasFile(name, at: "/app/dockerfile-specific.txt") + try f.assertContainerHasFile(name, at: "/app/included.txt") + } + } + } + } + + @Test func testDockerIgnoreReadonlyContext() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), + ]) + try "secret.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) + + let contextDir = dir.appending("context") + // Make the context read-only, then restore before cleanup. + try FileManager.default.setAttributes( + [.posixPermissions: 0o555], ofItemAtPath: contextDir.string) + f.addCleanup { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: contextDir.string) + } + + let image = "registry.local/dockerignore-readonly:\(UUID().uuidString.prefix(6))" + try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/secret.txt") + } + } + } + } + + @Test func testNonExistingDockerfile() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let image = "registry.local/non-existing-dockerfile:\(UUID().uuidString)" + let r1 = try f.run(["build", "-f", "non-existing-path", "-t", image, dir.string]) + #expect(r1.status != 0) + let r2 = try f.run(["build", "-t", image, dir.string]) + #expect(r2.status != 0) + } + } + } + + @Test func testBuildNoCachePullLatestImage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM \(ContainerFixture.warmupImages[0])\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/no-cache-pull:\(UUID().uuidString)" + try f.buildWithPaths(tags: [image], contextDir: dir, otherArgs: ["--pull", "--no-cache"]) + try f.assertImageBuilt(image) + } + } + } + + // MARK: - Dockerfile ARG quoting + + @Test func testBuildQuotedImageDockerfileArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG IMAGE=\"ghcr.io/linuxcontainers/alpine:3.20\"\nFROM $IMAGE\nRUN test -f /etc/alpine-release") + let image = "registry.local/quoted-image-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildQuotedStringDockerfileArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nARG MYSTRING='\"Hello, world!\"'\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") + let image = "registry.local/quoted-string-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildForwardReferencedDockerfileArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG ALPINE="ghcr.io/linuxcontainers/alpine" + ARG IMAGE="${ALPINE}:3.20" + FROM $IMAGE + RUN test -f /etc/alpine-release + """) + let image = "registry.local/forward-referenced-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildQuotedImageBuildArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG IMAGE\nFROM $IMAGE\nRUN test -f /etc/alpine-release") + let image = "registry.local/quoted-image-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["IMAGE=ghcr.io/linuxcontainers/alpine:3.20"]) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildQuotedStringBuildArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nARG MYSTRING\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") + let image = "registry.local/quoted-string-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["MYSTRING=\"Hello, world!\""]) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildForwardReferencedBuildArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG ALPINE + ARG IMAGE="$ALPINE:3.20" + FROM $IMAGE + RUN test -f /etc/alpine-release + """) + let image = "registry.local/forward-referenced-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["ALPINE=ghcr.io/linuxcontainers/alpine"]) + try f.assertImageBuilt(image) + } + } + } + + // MARK: - COPY --from tests + + @Test func testCopyFromLocalImage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let baseDir = try f.createTempDir() + let baseName = "local-base:\(UUID().uuidString)" + try f.createContext( + dir: baseDir, + dockerfile: "FROM scratch\nADD hello.txt /hello.txt", + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + try f.build(tag: baseName, contextDir: baseDir) + try f.assertImageBuilt(baseName) + + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=\(baseName) /hello.txt /copied.txt\nRUN cat /copied.txt") + let image = "registry.local/copy-from-local:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testCopyFromBuildStage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + ADD hello.txt /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /hello.txt /copied.txt + RUN cat /copied.txt + """, + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + let image = "registry.local/copy-from-stage:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testCopyRenameFromStage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + ADD hello.txt /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /hello.txt /renamed.txt + RUN cat /renamed.txt + """, + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + let image = "registry.local/copy-rename:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testCopyMissingFileFails() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /does-not-exist.txt /copied.txt + """) + let image = "registry.local/copy-missing:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail when source file is missing") + } + } + } + + @Test func testCopyInvalidStageFails() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=not_a_stage /hello.txt /copied.txt") + let image = "registry.local/copy-invalid-stage:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail with invalid stage name") + } + } + } + + @Test func testCopyFromNonexistentImageFails() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=doesnotexist:latest /hello.txt /copied.txt") + let image = "registry.local/copy-bad-image:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail when source image does not exist") + } + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift new file mode 100644 index 00000000..543afe39 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift @@ -0,0 +1,125 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@Suite(.serialized) +struct TestCLIBuilderTarExportSerial { + @Test func testBuildExportTar() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + + let exportPath = dir.appending("export.tar") + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar,dest=\(exportPath.string)", + dir.appending("context").string, + ]) + #expect(result.status == 0, "build with tar export should succeed") + #expect(FileManager.default.fileExists(atPath: exportPath.string), "tar file should exist") + #expect(result.output.contains(exportPath.string), "output should reference export path") + let attrs = try FileManager.default.attributesOfItem(atPath: exportPath.string) + #expect((attrs[.size] as? Int ?? 0) > 0, "exported tar should not be empty") + } + } + } + + @Test func testBuildExportTarToDirectory() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nRUN echo \"test\" > /test.txt") + + let exportDir = dir.appending("exports") + try FileManager.default.createDirectory( + atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil) + + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar,dest=\(exportDir.string)", + dir.appending("context").string, + ]) + #expect(result.status == 0, "build with tar export to directory should succeed") + let expectedTar = exportDir.appending("out.tar") + #expect( + FileManager.default.fileExists(atPath: expectedTar.string), + "tar file should exist at out.tar") + #expect(result.output.contains(expectedTar.string), "output should reference out.tar") + } + } + } + + @Test func testBuildExportTarMultipleRuns() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD testFile /", + context: [.file("testFile", content: .data("test data".data(using: .utf8)!))]) + + let exportDir = dir.appending("exports") + try FileManager.default.createDirectory( + atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil) + + let buildArgs = [ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar,dest=\(exportDir.string)", + dir.appending("context").string, + ] + + let r1 = try f.run(buildArgs) + #expect(r1.status == 0, "first build should succeed") + #expect(FileManager.default.fileExists(atPath: exportDir.appending("out.tar").string)) + + let r2 = try f.run(buildArgs) + #expect(r2.status == 0, "second build should succeed") + #expect( + FileManager.default.fileExists(atPath: exportDir.appending("out.tar.1").string), + "second tar should exist at out.tar.1") + } + } + } + + @Test func testBuildExportTarInvalidDest() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext(dir: dir, dockerfile: "FROM scratch") + + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar", // missing dest + dir.appending("context").string, + ]) + #expect(result.status != 0, "build without dest should fail") + #expect(result.error.contains("dest field is required")) + } + } + } +} diff --git a/Tests/IntegrationTests/Images/TestCLIProgressAuto.swift b/Tests/IntegrationTests/Images/TestCLIProgressAuto.swift new file mode 100644 index 00000000..c0363774 --- /dev/null +++ b/Tests/IntegrationTests/Images/TestCLIProgressAuto.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Testing + +@Suite +struct TestCLIProgressAuto { + private let alpine = ContainerFixture.warmupImages[0] + + @Test func testAutoProgressFallsBackToPlainWhenPiped() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "pull", "--progress", "auto", alpine]) + #expect(result.status == 0, "image pull should succeed, stderr: \(result.error)") + let lines = result.error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected plain progress output on stderr when piped") + #expect(!result.error.contains("\u{1B}["), "expected no ANSI escapes in piped output") + } + } + + @Test func testExplicitPlainProgress() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "pull", "--progress", "plain", alpine]) + #expect( + result.status == 0, + "image pull --progress plain should succeed, stderr: \(result.error)") + let lines = result.error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected plain progress output on stderr") + #expect(!result.error.contains("\u{1B}["), "expected no ANSI escapes with --progress plain") + } + } + + @Test func testExplicitAnsiProgress() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "pull", "--progress", "ansi", alpine]) + // Verify the command succeeds; ANSI output is suppressed in non-TTY contexts + // so we don't assert on stderr content here. + #expect( + result.status == 0, + "image pull --progress ansi should succeed, stderr: \(result.error)") + } + } + + @Test func testNoneProgressSuppressesOutput() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "pull", "--progress", "none", alpine]) + #expect( + result.status == 0, + "image pull --progress none should succeed, stderr: \(result.error)") + let lines = result.error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(lines.isEmpty, "expected no progress output on stderr with --progress none") + } + } +} diff --git a/Tests/IntegrationTests/Registry/TestCLIRegistry.swift b/Tests/IntegrationTests/Registry/TestCLIRegistry.swift new file mode 100644 index 00000000..0bbf5b03 --- /dev/null +++ b/Tests/IntegrationTests/Registry/TestCLIRegistry.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@Suite +struct TestCLIRegistry { + @Test func testListDefaultFormat() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["registry", "list"]) + #expect(result.status == 0, "registry list should succeed, stderr: \(result.error)") + + let requiredHeaders = ["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"] + #expect( + requiredHeaders.allSatisfy { result.output.contains($0) }, + "output should contain all required headers" + ) + } + } + + @Test func testListJSONFormat() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["registry", "list", "--format", "json"]) + #expect( + result.status == 0, + "registry list --format json should succeed, stderr: \(result.error)") + + let json = try JSONSerialization.jsonObject(with: result.outputData, options: []) + #expect(json is [Any], "JSON output should be an array") + } + } + + @Test func testListQuietMode() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["registry", "list", "-q"]) + #expect(result.status == 0, "registry list -q should succeed, stderr: \(result.error)") + #expect(!result.output.contains("HOSTNAME"), "quiet mode should not contain headers") + #expect(!result.output.contains("USERNAME"), "quiet mode should not contain headers") + } + } +} diff --git a/Tests/CLITests/Subcommands/Plugins/TestCLIPluginErrors.swift b/Tests/IntegrationTests/System/TestCLIPluginErrors.swift similarity index 59% rename from Tests/CLITests/Subcommands/Plugins/TestCLIPluginErrors.swift rename to Tests/IntegrationTests/System/TestCLIPluginErrors.swift index 901c15a6..460e3eb1 100644 --- a/Tests/CLITests/Subcommands/Plugins/TestCLIPluginErrors.swift +++ b/Tests/IntegrationTests/System/TestCLIPluginErrors.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025-2026 Apple Inc. and the container project authors. +// Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,19 +16,22 @@ import Testing +@Suite struct TestCLIPluginErrors { - @Test - func testHelpfulMessageWhenPluginsUnavailable() throws { + @Test func testHelpfulMessageWhenPluginsUnavailable() async throws { // Intentionally invoke an unknown plugin command. In CI this should run // without the APIServer started, so DefaultCommand will fail to create // a PluginLoader and emit the improved guidance. - let cli = try CLITest() - let (_, _, stderr, status) = try cli.run(arguments: ["nosuchplugin"]) // non-existent plugin name - - #expect(status != 0) - #expect(stderr.contains("container system start")) - #expect(stderr.contains("Plugins are unavailable") || stderr.contains("Plugin 'container-")) - // Should include at least one computed plugin search path hint - #expect(stderr.contains("container-plugins") || stderr.contains("container/plugins")) + try await ContainerFixture.with { f in + let result = try f.run(["nosuchplugin"]) + #expect(result.status != 0) + #expect(result.error.contains("container system start")) + #expect( + result.error.contains("Plugins are unavailable") + || result.error.contains("Plugin 'container-")) + #expect( + result.error.contains("container-plugins") + || result.error.contains("container/plugins")) + } } }