mirror of
https://github.com/apple/container.git
synced 2026-07-14 21:47:01 +00:00
d2eb350393
Permissions should be preserved when copying by default. This PR also fixes other warnings while building and testing.
623 lines
24 KiB
Swift
623 lines
24 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
|
|
//
|
|
// 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
|
|
|
|
@testable import ContainerBuildIR
|
|
|
|
struct SerializationTests {
|
|
|
|
// MARK: - JSONIRCoder Tests
|
|
|
|
@Test func jsonCoderBasicRoundTrip() throws {
|
|
guard let imageRef = ImageReference(parsing: "alpine:latest") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
|
|
let originalGraph = try GraphBuilder.singleStage(from: imageRef) { builder in
|
|
try builder
|
|
.run("apk add --no-cache curl")
|
|
.workdir("/app")
|
|
.copyFromContext(paths: ["src/"], to: "/app/")
|
|
.env("NODE_ENV", "production")
|
|
.expose([PortSpec(port: 3000)])
|
|
.cmd(.exec(["node", "index.js"]))
|
|
}
|
|
|
|
let coder = JSONIRCoder()
|
|
let encodedData = try coder.encode(originalGraph)
|
|
let decodedGraph = try coder.decode(encodedData)
|
|
|
|
// Verify structure preservation
|
|
#expect(decodedGraph.stages.count == originalGraph.stages.count)
|
|
#expect(decodedGraph.buildArgs == originalGraph.buildArgs)
|
|
#expect(decodedGraph.targetPlatforms == originalGraph.targetPlatforms)
|
|
|
|
// Verify stage content
|
|
let originalStage = originalGraph.stages[0]
|
|
let decodedStage = decodedGraph.stages[0]
|
|
#expect(decodedStage.nodes.count == originalStage.nodes.count)
|
|
#expect(decodedStage.name == originalStage.name)
|
|
}
|
|
|
|
@Test func jsonCoderPrettyPrint() throws {
|
|
guard let imageRef = ImageReference(parsing: "ubuntu:22.04") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
|
|
let graph = try GraphBuilder.singleStage(from: imageRef) { builder in
|
|
try builder
|
|
.run("apt-get update")
|
|
.run("apt-get install -y python3")
|
|
}
|
|
|
|
let prettyPrintCoder = JSONIRCoder(prettyPrint: true)
|
|
let compactCoder = JSONIRCoder(prettyPrint: false)
|
|
|
|
let prettyData = try prettyPrintCoder.encode(graph)
|
|
let compactData = try compactCoder.encode(graph)
|
|
|
|
// Pretty printed should be larger (contains whitespace)
|
|
#expect(prettyData.count > compactData.count)
|
|
|
|
// Both should decode to the same graph
|
|
let prettyGraph = try prettyPrintCoder.decode(prettyData)
|
|
let compactGraph = try compactCoder.decode(compactData)
|
|
|
|
#expect(prettyGraph.stages.count == compactGraph.stages.count)
|
|
#expect(prettyGraph.buildArgs == compactGraph.buildArgs)
|
|
|
|
// Verify JSON is actually pretty printed
|
|
let prettyJson = String(data: prettyData, encoding: .utf8)!
|
|
#expect(prettyJson.contains("\n"), "Pretty printed JSON should contain newlines")
|
|
#expect(prettyJson.contains(" "), "Pretty printed JSON should contain indentation")
|
|
}
|
|
|
|
@Test func jsonCoderMultiStageGraph() throws {
|
|
guard let nodeRef = ImageReference(parsing: "node:18"),
|
|
let nginxRef = ImageReference(parsing: "nginx:alpine")
|
|
else {
|
|
Issue.record("Failed to parse image references")
|
|
return
|
|
}
|
|
|
|
let originalGraph = try GraphBuilder.multiStage { builder in
|
|
try builder
|
|
.stage(name: "build", from: nodeRef)
|
|
.workdir("/app")
|
|
.copyFromContext(paths: ["package.json"], to: "./")
|
|
.run("npm install")
|
|
.copyFromContext(paths: ["src/"], to: "./src/")
|
|
.run("npm run build")
|
|
|
|
try builder
|
|
.stage(from: nginxRef)
|
|
.copyFromStage(.named("build"), paths: ["/app/dist"], to: "/usr/share/nginx/html")
|
|
.expose([PortSpec(port: 80)])
|
|
}
|
|
|
|
let coder = JSONIRCoder()
|
|
let encodedData = try coder.encode(originalGraph)
|
|
let decodedGraph = try coder.decode(encodedData)
|
|
|
|
#expect(decodedGraph.stages.count == 2)
|
|
#expect(decodedGraph.stages[0].name == "build")
|
|
#expect(decodedGraph.stages[1].name == nil)
|
|
|
|
// Verify stage dependencies are preserved
|
|
let runtimeStage = decodedGraph.stages[1]
|
|
let stageDeps = runtimeStage.stageDependencies()
|
|
#expect(stageDeps.contains(.named("build")))
|
|
}
|
|
|
|
@Test func jsonCoderWithBuildArgs() throws {
|
|
guard let nodeRef = ImageReference(parsing: "node:18") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
|
|
let originalGraph = try GraphBuilder.singleStage(from: nodeRef) { builder in
|
|
try builder
|
|
.arg("NODE_ENV", defaultValue: "development")
|
|
.arg("BUILD_VERSION")
|
|
.env("NODE_ENV", "production")
|
|
.env("BUILD_VERSION", "1.2.3")
|
|
.run("npm install")
|
|
}
|
|
|
|
let coder = JSONIRCoder()
|
|
let encodedData = try coder.encode(originalGraph)
|
|
let decodedGraph = try coder.decode(encodedData)
|
|
|
|
// Verify build args preservation
|
|
#expect(decodedGraph.buildArgs.count >= 0)
|
|
|
|
// Verify target platforms preservation
|
|
#expect(decodedGraph.targetPlatforms.count >= 0)
|
|
}
|
|
|
|
// MARK: - BinaryIRCoder Tests
|
|
|
|
@Test func binaryCoderBasicRoundTrip() throws {
|
|
guard let imageRef = ImageReference(parsing: "alpine:latest") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
|
|
let originalGraph = try GraphBuilder.singleStage(from: imageRef) { builder in
|
|
try builder
|
|
.run("apk add --no-cache git")
|
|
.workdir("/app")
|
|
.copyFromContext(paths: ["main.go"], to: "/app/")
|
|
.run("go build -o app main.go")
|
|
.entrypoint(.exec(["/app/app"]))
|
|
}
|
|
|
|
let coder = BinaryIRCoder()
|
|
let encodedData = try coder.encode(originalGraph)
|
|
let decodedGraph = try coder.decode(encodedData)
|
|
|
|
// Verify structure preservation
|
|
#expect(decodedGraph.stages.count == originalGraph.stages.count)
|
|
#expect(decodedGraph.buildArgs == originalGraph.buildArgs)
|
|
#expect(decodedGraph.targetPlatforms == originalGraph.targetPlatforms)
|
|
|
|
// Verify stage content
|
|
let originalStage = originalGraph.stages[0]
|
|
let decodedStage = decodedGraph.stages[0]
|
|
#expect(decodedStage.nodes.count == originalStage.nodes.count)
|
|
}
|
|
|
|
@Test func binaryCoderCompression() throws {
|
|
guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
|
|
// Create a larger graph to test compression effectiveness
|
|
let originalGraph = try GraphBuilder.multiStage { builder in
|
|
for i in 0..<3 {
|
|
try builder
|
|
.stage(name: "stage\(i)", from: ubuntuRef)
|
|
.run("apt-get update")
|
|
.run("apt-get install -y curl wget git vim")
|
|
.workdir("/app")
|
|
.copyFromContext(paths: ["file\(i).txt"], to: "/app/")
|
|
.env("STAGE_NUMBER", "\(i)")
|
|
.label("stage.number", "\(i)")
|
|
.label("stage.description", "This is stage number \(i) with detailed information")
|
|
}
|
|
}
|
|
|
|
let jsonCoder = JSONIRCoder(prettyPrint: false)
|
|
let binaryCoder = BinaryIRCoder()
|
|
|
|
let jsonData = try jsonCoder.encode(originalGraph)
|
|
let binaryData = try binaryCoder.encode(originalGraph)
|
|
|
|
// Binary format should typically be smaller than JSON
|
|
#expect(
|
|
binaryData.count <= jsonData.count,
|
|
"Binary format should be more compact (JSON: \(jsonData.count), Binary: \(binaryData.count))")
|
|
|
|
// Verify both decode correctly
|
|
let jsonGraph = try jsonCoder.decode(jsonData)
|
|
let binaryGraph = try binaryCoder.decode(binaryData)
|
|
|
|
#expect(jsonGraph.stages.count == binaryGraph.stages.count)
|
|
#expect(jsonGraph.buildArgs == binaryGraph.buildArgs)
|
|
}
|
|
|
|
@Test func binaryCoderComplexOperations() throws {
|
|
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
|
|
let originalGraph = try GraphBuilder.singleStage(from: alpineRef) { builder in
|
|
try builder
|
|
.run(
|
|
"apk add --no-cache build-tools",
|
|
mounts: [
|
|
Mount(type: .cache, target: "/var/cache/apk", source: .local("apk-cache")),
|
|
Mount(type: .secret, target: "/run/secrets/token", source: .secret("github-token")),
|
|
]
|
|
)
|
|
.copyFromContext(
|
|
paths: ["src/**/*.c", "include/**/*.h"],
|
|
to: "/app/"
|
|
)
|
|
.healthcheck(
|
|
test: .command(.exec(["./healthcheck.sh"])),
|
|
interval: 30,
|
|
timeout: 5,
|
|
retries: 3
|
|
)
|
|
.user(.uidGid(uid: 1000, gid: 1000))
|
|
}
|
|
|
|
let coder = BinaryIRCoder()
|
|
let encodedData = try coder.encode(originalGraph)
|
|
let decodedGraph = try coder.decode(encodedData)
|
|
|
|
// Verify complex operations are preserved
|
|
let stage = decodedGraph.stages[0]
|
|
|
|
// Check for RUN operation with mounts
|
|
let runOps = stage.nodes.compactMap { $0.operation as? ExecOperation }
|
|
let runWithMounts = runOps.first { !$0.mounts.isEmpty }
|
|
#expect(runWithMounts != nil, "RUN operation with mounts should be preserved")
|
|
#expect(runWithMounts!.mounts.count == 2, "Both mounts should be preserved")
|
|
|
|
// Check for COPY operation with metadata
|
|
let copyOps = stage.nodes.compactMap { $0.operation as? FilesystemOperation }
|
|
let copyWithMetadata = copyOps.first {
|
|
$0.fileMetadata.permissions != nil
|
|
}
|
|
#expect(copyWithMetadata != nil, "COPY operation should preserve file permissions by default")
|
|
|
|
// Check for metadata operations
|
|
let metaOps = stage.nodes.compactMap { $0.operation as? MetadataOperation }
|
|
let hasHealthcheck = metaOps.contains {
|
|
if case .setHealthcheck = $0.action { return true }
|
|
return false
|
|
}
|
|
#expect(hasHealthcheck, "Healthcheck metadata should be preserved")
|
|
}
|
|
|
|
// MARK: - Format Comparison Tests
|
|
|
|
@Test func formatSizeComparison() throws {
|
|
guard let golangRef = ImageReference(parsing: "golang:1.21"),
|
|
let alpineRef = ImageReference(parsing: "alpine:latest")
|
|
else {
|
|
Issue.record("Failed to parse image references")
|
|
return
|
|
}
|
|
|
|
// Create a realistic build graph
|
|
let graph = try GraphBuilder.multiStage { builder in
|
|
try builder
|
|
.stage(name: "builder", from: golangRef)
|
|
.workdir("/src")
|
|
.copyFromContext(paths: ["go.mod", "go.sum"], to: "./")
|
|
.run("go mod download")
|
|
.copyFromContext(paths: ["cmd/", "internal/", "pkg/"], to: "./")
|
|
.run("CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /app cmd/main.go")
|
|
|
|
try builder
|
|
.stage(from: alpineRef)
|
|
.run("apk add --no-cache ca-certificates")
|
|
.copyFromStage(.named("builder"), paths: ["/app"], to: "/usr/local/bin/")
|
|
.user(.uid(65534))
|
|
.expose([PortSpec(port: 8080)])
|
|
.entrypoint(.exec(["/usr/local/bin/app"]))
|
|
}
|
|
|
|
let jsonCompactCoder = JSONIRCoder(prettyPrint: false)
|
|
let jsonPrettyCoder = JSONIRCoder(prettyPrint: true)
|
|
let binaryCoder = BinaryIRCoder()
|
|
|
|
let jsonCompactData = try jsonCompactCoder.encode(graph)
|
|
let jsonPrettyData = try jsonPrettyCoder.encode(graph)
|
|
let binaryData = try binaryCoder.encode(graph)
|
|
|
|
print("Format size comparison:")
|
|
print("- JSON (compact): \(jsonCompactData.count) bytes")
|
|
print("- JSON (pretty): \(jsonPrettyData.count) bytes")
|
|
print("- Binary: \(binaryData.count) bytes")
|
|
|
|
// Expected size ordering
|
|
#expect(binaryData.count <= jsonCompactData.count)
|
|
#expect(jsonCompactData.count < jsonPrettyData.count)
|
|
|
|
// All should decode to equivalent graphs
|
|
let graphs = [
|
|
try jsonCompactCoder.decode(jsonCompactData),
|
|
try jsonPrettyCoder.decode(jsonPrettyData),
|
|
try binaryCoder.decode(binaryData),
|
|
]
|
|
|
|
for graph in graphs {
|
|
#expect(graph.stages.count == 2)
|
|
#expect(graph.stages[0].name == "builder")
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Handling Tests
|
|
|
|
@Test func corruptedDataHandling() throws {
|
|
let coder = JSONIRCoder()
|
|
|
|
// Test completely invalid data
|
|
let invalidData = "not valid json".data(using: .utf8)!
|
|
#expect(throws: Error.self) {
|
|
try coder.decode(invalidData)
|
|
}
|
|
|
|
// Test valid JSON but invalid structure
|
|
let invalidStructure = """
|
|
{
|
|
"version": "1.0",
|
|
"graph": {
|
|
"stages": "this should be an array"
|
|
}
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
#expect(throws: Error.self) {
|
|
try coder.decode(invalidStructure)
|
|
}
|
|
}
|
|
|
|
@Test func binaryCorruptedDataHandling() throws {
|
|
let coder = BinaryIRCoder()
|
|
|
|
// Test invalid binary data
|
|
let invalidData = Data(repeating: 0xFF, count: 100)
|
|
#expect(throws: Error.self) {
|
|
try coder.decode(invalidData)
|
|
}
|
|
|
|
// Test truncated data
|
|
let validGraph: BuildGraph
|
|
do {
|
|
validGraph = try GraphBuilder.singleStage(
|
|
from: ImageReference(parsing: "alpine")!
|
|
) { builder in
|
|
try builder.run("echo test")
|
|
}
|
|
} catch {
|
|
Issue.record("Failed to create test graph")
|
|
return
|
|
}
|
|
|
|
let validData = try coder.encode(validGraph)
|
|
let truncatedData = validData.prefix(validData.count / 2)
|
|
|
|
#expect(throws: Error.self) {
|
|
try coder.decode(Data(truncatedData))
|
|
}
|
|
}
|
|
|
|
@Test func versionHandling() throws {
|
|
// Test that we can detect version information in JSON format
|
|
let graph: BuildGraph
|
|
do {
|
|
graph = try GraphBuilder.singleStage(
|
|
from: ImageReference(parsing: "alpine")!
|
|
) { builder in
|
|
try builder.run("echo version test")
|
|
}
|
|
} catch {
|
|
Issue.record("Failed to create test graph")
|
|
return
|
|
}
|
|
|
|
let coder = JSONIRCoder(prettyPrint: true)
|
|
let data = try coder.encode(graph)
|
|
let jsonString = String(data: data, encoding: .utf8)!
|
|
|
|
#expect(jsonString.contains("\"version\""), "JSON should contain version information")
|
|
#expect(jsonString.contains("\"1.0\""), "Should use version 1.0")
|
|
|
|
// Should decode correctly
|
|
let decodedGraph = try coder.decode(data)
|
|
#expect(decodedGraph.stages.count == graph.stages.count)
|
|
}
|
|
|
|
// MARK: - File I/O Tests
|
|
|
|
@Test func saveAndLoadGraph() throws {
|
|
guard let imageRef = ImageReference(parsing: "python:3.11") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
|
|
let originalGraph = try GraphBuilder.singleStage(from: imageRef) { builder in
|
|
try builder
|
|
.workdir("/app")
|
|
.copyFromContext(paths: ["requirements.txt"], to: "./")
|
|
.run("pip install -r requirements.txt")
|
|
.copyFromContext(paths: ["src/"], to: "./src/")
|
|
.cmd(.exec(["python", "src/main.py"]))
|
|
}
|
|
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
let jsonURL = tempDir.appendingPathComponent("test-graph.json")
|
|
let binaryURL = tempDir.appendingPathComponent("test-graph.bin")
|
|
|
|
// Save using different formats
|
|
try originalGraph.save(to: jsonURL, using: JSONIRCoder(prettyPrint: true))
|
|
try originalGraph.save(to: binaryURL, using: BinaryIRCoder())
|
|
|
|
// Verify files exist and have content
|
|
#expect(FileManager.default.fileExists(atPath: jsonURL.path))
|
|
#expect(FileManager.default.fileExists(atPath: binaryURL.path))
|
|
|
|
let jsonFileSize = try FileManager.default.attributesOfItem(atPath: jsonURL.path)[.size] as! Int
|
|
let binaryFileSize = try FileManager.default.attributesOfItem(atPath: binaryURL.path)[.size] as! Int
|
|
|
|
#expect(jsonFileSize > 0)
|
|
#expect(binaryFileSize > 0)
|
|
|
|
// Load and verify
|
|
let jsonGraph = try BuildGraph.load(from: jsonURL, using: JSONIRCoder())
|
|
let binaryGraph = try BuildGraph.load(from: binaryURL, using: BinaryIRCoder())
|
|
|
|
#expect(jsonGraph.stages.count == originalGraph.stages.count)
|
|
#expect(binaryGraph.stages.count == originalGraph.stages.count)
|
|
|
|
// Cleanup
|
|
try? FileManager.default.removeItem(at: jsonURL)
|
|
try? FileManager.default.removeItem(at: binaryURL)
|
|
}
|
|
|
|
// MARK: - Performance Tests
|
|
|
|
@Test func serializationPerformance() throws {
|
|
// Create a large graph for performance testing
|
|
guard let baseRef = ImageReference(parsing: "ubuntu:22.04") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
|
|
var stages: [BuildStage] = []
|
|
|
|
// Create 5 stages with 10 operations each
|
|
for stageIndex in 0..<5 {
|
|
var nodes: [BuildNode] = []
|
|
|
|
for opIndex in 0..<10 {
|
|
let operation: any ContainerBuildIR.Operation
|
|
switch opIndex % 4 {
|
|
case 0:
|
|
operation = ExecOperation(command: .shell("echo 'stage \(stageIndex) op \(opIndex)'"))
|
|
case 1:
|
|
operation = FilesystemOperation(
|
|
action: .copy,
|
|
source: .context(ContextSource(paths: ["file\(opIndex).txt"])),
|
|
destination: "/app/file\(opIndex).txt"
|
|
)
|
|
case 2:
|
|
operation = MetadataOperation(action: .setEnv(key: "VAR\(opIndex)", value: .literal("value\(opIndex)")))
|
|
default:
|
|
operation = MetadataOperation(action: .setLabel(key: "label\(opIndex)", value: "value\(opIndex)"))
|
|
}
|
|
|
|
nodes.append(BuildNode(operation: operation, dependencies: []))
|
|
}
|
|
|
|
stages.append(
|
|
BuildStage(
|
|
name: "stage\(stageIndex)",
|
|
base: ImageOperation(source: .registry(baseRef)),
|
|
nodes: nodes
|
|
))
|
|
}
|
|
|
|
let largeGraph = try BuildGraph(stages: stages)
|
|
|
|
let jsonCoder = JSONIRCoder()
|
|
let binaryCoder = BinaryIRCoder()
|
|
|
|
// Measure JSON encoding time
|
|
let jsonStartTime = Date()
|
|
let jsonData = try jsonCoder.encode(largeGraph)
|
|
let jsonEncodeTime = Date().timeIntervalSince(jsonStartTime)
|
|
|
|
// Measure binary encoding time
|
|
let binaryStartTime = Date()
|
|
let binaryData = try binaryCoder.encode(largeGraph)
|
|
let binaryEncodeTime = Date().timeIntervalSince(binaryStartTime)
|
|
|
|
print("Encoding performance for large graph:")
|
|
print("- JSON: \(String(format: "%.3f", jsonEncodeTime))s (\(jsonData.count) bytes)")
|
|
print("- Binary: \(String(format: "%.3f", binaryEncodeTime))s (\(binaryData.count) bytes)")
|
|
|
|
// Both should complete in reasonable time (under 1 second for this size)
|
|
#expect(jsonEncodeTime < 1.0, "JSON encoding should be fast")
|
|
#expect(binaryEncodeTime < 1.0, "Binary encoding should be fast")
|
|
|
|
// Measure decoding performance
|
|
let jsonDecodeStart = Date()
|
|
let _ = try jsonCoder.decode(jsonData)
|
|
let jsonDecodeTime = Date().timeIntervalSince(jsonDecodeStart)
|
|
|
|
let binaryDecodeStart = Date()
|
|
let _ = try binaryCoder.decode(binaryData)
|
|
let binaryDecodeTime = Date().timeIntervalSince(binaryDecodeStart)
|
|
|
|
print("Decoding performance for large graph:")
|
|
print("- JSON: \(String(format: "%.3f", jsonDecodeTime))s")
|
|
print("- Binary: \(String(format: "%.3f", binaryDecodeTime))s")
|
|
|
|
#expect(jsonDecodeTime < 1.0, "JSON decoding should be fast")
|
|
#expect(binaryDecodeTime < 1.0, "Binary decoding should be fast")
|
|
}
|
|
|
|
// MARK: - Edge Cases
|
|
|
|
@Test func emptyGraphSerialization() throws {
|
|
// Test edge case of empty graph
|
|
let emptyGraph = try BuildGraph(stages: [])
|
|
|
|
let jsonCoder = JSONIRCoder()
|
|
let binaryCoder = BinaryIRCoder()
|
|
|
|
let jsonData = try jsonCoder.encode(emptyGraph)
|
|
let binaryData = try binaryCoder.encode(emptyGraph)
|
|
|
|
let jsonDecoded = try jsonCoder.decode(jsonData)
|
|
let binaryDecoded = try binaryCoder.decode(binaryData)
|
|
|
|
#expect(jsonDecoded.stages.isEmpty)
|
|
#expect(binaryDecoded.stages.isEmpty)
|
|
#expect(jsonDecoded.buildArgs.isEmpty)
|
|
#expect(binaryDecoded.buildArgs.isEmpty)
|
|
}
|
|
|
|
@Test func graphWithSpecialCharacters() throws {
|
|
guard let baseRef = ImageReference(parsing: "alpine") else {
|
|
Issue.record("Failed to parse image reference")
|
|
return
|
|
}
|
|
|
|
let graph = try GraphBuilder.singleStage(from: baseRef) { builder in
|
|
try builder
|
|
.run("echo 'Special chars: 中文 émojis 🚀 newlines\n and quotes \"test\"'")
|
|
.env("UNICODE", "Contains unicode: ñáéíóú 中文字符 🌍")
|
|
.label("description", "Multi-line\ndescription with\ttabs and spaces")
|
|
.workdir("/app with spaces/and-symbols!@#$%")
|
|
}
|
|
|
|
let jsonCoder = JSONIRCoder(prettyPrint: true)
|
|
let binaryCoder = BinaryIRCoder()
|
|
|
|
let jsonData = try jsonCoder.encode(graph)
|
|
let binaryData = try binaryCoder.encode(graph)
|
|
|
|
let jsonDecoded = try jsonCoder.decode(jsonData)
|
|
let binaryDecoded = try binaryCoder.decode(binaryData)
|
|
|
|
#expect(jsonDecoded.stages.count == 1)
|
|
#expect(binaryDecoded.stages.count == 1)
|
|
|
|
// Check that the operations contain the expected special characters
|
|
let stage = jsonDecoded.stages[0]
|
|
let execOp = stage.nodes.compactMap { $0.operation as? ExecOperation }.first!
|
|
let envOp = stage.nodes.compactMap { $0.operation as? MetadataOperation }.first { op in
|
|
if case .setEnv(let key, _) = op.action, key == "UNICODE" { return true }
|
|
return false
|
|
}!
|
|
|
|
// Verify special characters are preserved
|
|
if case .shell(let command) = execOp.command {
|
|
#expect(command.contains("中文"), "Unicode characters should be preserved in command")
|
|
#expect(command.contains("🚀"), "Emoji should be preserved in command")
|
|
#expect(command.contains("\n"), "Newlines should be preserved in command")
|
|
}
|
|
|
|
if case .setEnv(_, let value) = envOp.action, case .literal(let envValue) = value {
|
|
#expect(envValue.contains("中文字符"), "Unicode characters should be preserved in env")
|
|
#expect(envValue.contains("🌍"), "Emoji should be preserved in env")
|
|
}
|
|
}
|
|
}
|