Files
Andrew Heard bc9f9ea1da feat(ai): add FoundationModelsFake for GLM tests (#16995)
Add FoundationModelsFake, a nested Swift package that fakes Apple's
Foundation Models framework, so that GeminiLanguageModel (GLM) can build
and run its unit tests on Linux and on OS versions without the real
framework. It's used only for development and testing.

- GLM depends on the fake through a `FoundationModelsFake` package
  trait, which is on by default where Darwin isn't available (e.g.,
  Linux). With the trait off, the fake isn't built. The root
  `Package.swift` and podspecs can't reach it.
- The fake mirrors Apple's public API and availability in the 27.2
  SDKs, including `@Generable` macro expansions. It omits
  PrivateCloudComputeLanguageModel, OCRTool, BarcodeReaderTool, the
  adapter and feedback APIs, and deprecated APIs.
  SystemLanguageModel is always unavailable. Below Swift 6.4 it
  compiles to an empty module.
- Decoding rejects transcript images with `file:` URLs and schemas
  whose references never resolve, instead of reading local files or
  overflowing the stack.
- Without ImageIO, GLM passes PNG, JPEG, WebP, HEIC, and HEIF images
  through to Gemini after checking their signatures, and rejects
  images that would need re-encoding.
- Add CI jobs that run both packages' tests against the fake: one on
  Linux in a digest-pinned `swift:6.4.2` container, and one on macOS
  with Xcode 27.2. Skip `sdk.ai` for changes that only touch the fake.
2026-11-05 14:13:50 -05:00

435 lines
14 KiB
Swift

// Copyright 2026 Google LLC
//
// 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
//
// http://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.
#if compiler(>=6.4)
import Foundation
import Testing
@testable import FoundationModelsFake
#if canImport(CoreGraphics)
import CoreGraphics
#endif
#if canImport(ImageIO)
import ImageIO
#endif
@Suite("Transcript model")
struct TranscriptModelTests {
/// Returns a text entry for collection tests.
///
/// - Parameter id: The id of the response and its segment.
/// - Returns: A response entry with one text segment.
static func entry(_ id: String) -> Transcript.Entry {
.response(
Transcript.Response(id: id, segments: [.text(Transcript.TextSegment(id: id, content: id))])
)
}
// MARK: - Collection conformances
@Test
func transcriptIsARandomAccessCollectionOfEntries() {
let transcript = Transcript(entries: ["a", "b", "c"].map(Self.entry))
let ids = transcript.map(\.id)
#expect(ids == ["a", "b", "c"])
#expect(transcript.startIndex == 0)
#expect(transcript.endIndex == 3)
#expect(transcript.count == 3)
#expect(transcript.last?.id == "c")
#expect(transcript[1...].map(\.id) == ["b", "c"])
}
@Test
func transcriptIsMutableAndRangeReplaceable() {
var transcript = Transcript()
transcript.append(Self.entry("a"))
transcript.append(contentsOf: [Self.entry("b"), Self.entry("c")])
transcript[0] = Self.entry("z")
transcript.insert(Self.entry("y"), at: 1)
transcript.remove(at: 2)
transcript.replaceSubrange(0..<1, with: [Self.entry("x"), Self.entry("w")])
#expect(transcript.map(\.id) == ["x", "w", "y", "c"])
#expect(transcript.entries.map(\.id) == ["x", "w", "y", "c"])
}
@Test
func emptyTranscriptsAreEqual() {
let empty = Transcript()
let seeded = Transcript(entries: [])
#expect(empty == seeded)
#expect(empty.isEmpty)
}
@Test
func toolCallsIsARandomAccessCollection() {
let calls = ["a", "b"].map {
Transcript.ToolCall(id: $0, toolName: "tool", arguments: GeneratedContent($0))
}
let toolCalls = Transcript.ToolCalls(id: "calls", calls)
#expect(toolCalls.map(\.id) == ["a", "b"])
#expect(toolCalls.startIndex == 0)
#expect(toolCalls.endIndex == 2)
#expect(toolCalls[1].id == "b")
#expect(Transcript.Entry.toolCalls(toolCalls).id == "calls")
}
// MARK: - Identity and equality
@Test
func entryAndSegmentIDsComeFromTheirPayloads() throws {
let segments: [Transcript.Segment] = [
.text(Transcript.TextSegment(id: "t", content: "text")),
.structure(
Transcript.StructuredSegment(id: "s", schemaName: "S", content: GeneratedContent(1))),
.attachment(
Transcript.AttachmentSegment(
id: "a",
content: .image(Transcript.ImageAttachment(imageURL: try Self.dataURL()))
)
),
]
let ids = segments.map(\.id)
#expect(ids == ["t", "s", "a"])
}
@Test
func segmentsOfDifferentKindsAreUnequal() {
let text = Transcript.Segment.text(Transcript.TextSegment(id: "x", content: "1"))
let structure = Transcript.Segment.structure(
Transcript.StructuredSegment(id: "x", schemaName: "Int", content: GeneratedContent(1))
)
#expect(text != structure)
#expect(text == .text(Transcript.TextSegment(id: "x", content: "1")))
}
@Test
func promptEqualityComparesEveryProperty() {
let base = Transcript.Prompt(id: "p", segments: [])
var changedMetadata = base
changedMetadata.metadata = ["key": GeneratedContent("value")]
var changedOptions = base
changedOptions.options = GenerationOptions(temperature: 0.5)
var changedContext = base
changedContext.contextOptions = ContextOptions(reasoningLevel: .deep)
var changedFormat = base
changedFormat.responseFormat = Transcript.ResponseFormat(type: String.self)
#expect(base == Transcript.Prompt(id: "p", segments: []))
#expect(base != changedMetadata)
#expect(base != changedOptions)
#expect(base != changedContext)
#expect(base != changedFormat)
}
@Test
func responseFormatFromTypeEqualsFormatFromItsSchema() {
let fromType = Transcript.ResponseFormat(type: String.self)
let fromSchema = Transcript.ResponseFormat(schema: String.generationSchema)
#expect(fromType == fromSchema)
#expect(fromType.kind == fromSchema.kind)
#expect(fromType != Transcript.ResponseFormat(type: Int.self))
}
@Test
func toolCallEqualityComparesArgumentsAndMetadata() throws {
let arguments = GeneratedContent(properties: ["city": "Paris"])
let call = Transcript.ToolCall(id: "c", toolName: "weather", arguments: arguments)
let withMetadata = Transcript.ToolCall(
id: "c",
metadata: ["signature": "abc"],
toolName: "weather",
arguments: arguments
)
let parsedArguments = Transcript.ToolCall(
id: "c",
toolName: "weather",
arguments: try GeneratedContent(json: #"{"city": "Paris"}"#)
)
#expect(call == Transcript.ToolCall(id: "c", toolName: "weather", arguments: arguments))
#expect(call != withMetadata)
#expect(withMetadata.metadata["signature"] == GeneratedContent("abc"))
#expect(call != parsedArguments)
}
@Test
func toolDefinitionsCompareTheirSchemas() {
let definition = Transcript.ToolDefinition(
name: "echo",
description: "Echoes",
parameters: String.generationSchema
)
let other = Transcript.ToolDefinition(
name: "echo",
description: "Echoes",
parameters: Int.generationSchema
)
#expect(definition == definition)
#expect(definition != other)
#expect(definition.description == "Echoes")
}
// MARK: - Response and reasoning
@Test
func responseWithAssetIDsMirrorsThemIntoMetadata() {
let response = Transcript.Response(id: "r", assetIDs: ["a", "b"], segments: [])
let metadata = response.metadata
#expect(response.assetIDs == ["a", "b"])
#expect(metadata == ["assetIDs": GeneratedContent(["a", "b"])])
}
@Test
func responseWithMetadataHasNoAssetIDs() {
let response = Transcript.Response(id: "r", metadata: ["tokens": 12], segments: [])
let metadata = response.metadata
#expect(response.assetIDs.isEmpty)
#expect(metadata == ["tokens": GeneratedContent(12)])
#expect(response != Transcript.Response(id: "r", segments: []))
}
@Test
func reasoningEqualityComparesSignatureAndMetadata() {
let reasoning = Transcript.Reasoning(id: "r", segments: [], signature: Data([1]))
let otherSignature = Transcript.Reasoning(id: "r", segments: [], signature: Data([2]))
let otherMetadata = Transcript.Reasoning(
id: "r",
metadata: ["k": "v"],
segments: [],
signature: Data([1])
)
#expect(reasoning == Transcript.Reasoning(id: "r", segments: [], signature: Data([1])))
#expect(reasoning != otherSignature)
#expect(reasoning != otherMetadata)
}
// MARK: - Images
/// Returns a small `data:` URL.
static func dataURL() throws -> URL {
try #require(URL(string: "data:image/png;base64,iVBORw0KGgo="))
}
@Test
func imageAttachmentKeepsURLAndOrientation() throws {
let url = try Self.dataURL()
let image = Transcript.ImageAttachment(imageURL: url, orientation: .right)
#expect(image.url == url)
#expect(image.orientation == .right)
#expect(Transcript.ImageAttachment(imageURL: url).orientation == .up)
}
@Test
func imageAttachmentsAreEqualOnlyToCopies() throws {
let url = try Self.dataURL()
let image = Transcript.ImageAttachment(imageURL: url)
let copy = image
let sameURL = Transcript.ImageAttachment(imageURL: url)
#expect(image == copy)
#expect(image != sameURL)
#expect(Transcript.Attachment.image(image) == .image(copy))
}
@Test
func fileImagesEncodeAsDataURLsWithSniffedMIMEType() throws {
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01])
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("transcript-\(UUID().uuidString).png")
try png.write(to: fileURL)
defer { try? FileManager.default.removeItem(at: fileURL) }
let transcript = Self.transcript(withImageURL: fileURL, label: "photo")
let attachment = try Self.encodedAttachment(of: transcript)
#expect(attachment["type"] as? String == "image")
#expect(attachment["label"] as? String == "photo")
#expect(attachment["data"] as? String == "data:image/png;base64,\(png.base64EncodedString())")
}
@Test
func dataAndRemoteImageURLsEncodeVerbatim() throws {
let urls = [
try Self.dataURL(),
try #require(URL(string: "https://example.com/image.jpg")),
]
let encoded = try urls.map {
try Self.encodedAttachment(of: Self.transcript(withImageURL: $0, label: nil))
}
#expect(encoded.map { $0["data"] as? String } == urls.map(\.absoluteString))
#expect(encoded.allSatisfy { $0["label"] == nil })
}
@Test(arguments: [
"data:image/png;base64,iVBORw0KGgo=",
"https://example.com/image.jpg",
"gs://bucket/image.jpg",
"image.jpg",
])
func decodesNonFileImageURLs(urlString: String) throws {
let url = try #require(URL(string: urlString))
let json = try JSONEncoder().encode(Self.transcript(withImageURL: url, label: nil))
let decoded = try JSONDecoder().decode(Transcript.self, from: json)
#expect(Self.imageURL(in: decoded) == url)
}
@Test(arguments: ["file:///etc/hosts", "FILE:///etc/hosts"])
func rejectsDecodingFileImageURLs(urlString: String) throws {
let placeholderURL = "https://example.com/placeholder.jpg"
let url = try #require(URL(string: placeholderURL))
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
let encoded = try encoder.encode(Self.transcript(withImageURL: url, label: nil))
let json = String(decoding: encoded, as: UTF8.self).replacing(placeholderURL, with: urlString)
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(Transcript.self, from: Data(json.utf8))
}
}
/// Image signatures and the MIME types sniffed from them.
static let imageSignatures: [([UInt8], String)] = {
let webP: [UInt8] = Array("RIFF".utf8) + [0, 0, 0, 0] + Array("WEBP".utf8)
let heic: [UInt8] = [0, 0, 0, 0x18] + Array("ftypheic".utf8)
return [
([0xFF, 0xD8, 0xFF, 0xE0], "image/jpeg"),
(Array("GIF89a".utf8), "image/gif"),
(webP, "image/webp"),
(heic, "image/heic"),
([0x49, 0x49, 0x2A, 0x00], "image/tiff"),
([0x00, 0x01], "application/octet-stream"),
]
}()
@Test(arguments: imageSignatures)
func sniffsImageMIMETypes(bytes: [UInt8], mimeType: String) {
let sniffed = ImageAttachmentStorage.mimeType(sniffing: Data(bytes))
#expect(sniffed == mimeType)
}
@Test
func decodesPercentEncodedDataURLs() {
let payload = ImageAttachmentStorage.dataURLPayload("data:text/plain,a%20b")
#expect(payload == Data("a b".utf8))
}
#if canImport(CoreGraphics) && canImport(ImageIO)
@Test
func cgImageAttachmentsEncodeAsJPEGDataURLs() throws {
let image = Transcript.ImageAttachment(ImageAttachmentStorage.placeholderImage)
let transcript = Transcript(entries: [
.prompt(
Transcript.Prompt(
id: "p",
segments: [.attachment(Transcript.AttachmentSegment(id: "a", content: .image(image)))]
)
)
])
let attachment = try Self.encodedAttachment(of: transcript)
#expect((attachment["data"] as? String)?.hasPrefix("data:image/jpeg;base64,/9j/") == true)
#expect(image.url == nil)
}
#endif
/// Returns a transcript with one prompt that holds one image.
///
/// - Parameters:
/// - url: The image URL.
/// - label: The attachment label.
/// - Returns: The transcript.
static func transcript(withImageURL url: URL, label: String?) -> Transcript {
Transcript(entries: [
.prompt(
Transcript.Prompt(
id: "p",
segments: [
.attachment(
Transcript.AttachmentSegment(
id: "a",
content: .image(Transcript.ImageAttachment(imageURL: url)),
label: label
)
)
]
)
)
])
}
/// Returns the URL of the image in the first prompt segment of a transcript.
///
/// - Parameter transcript: A transcript whose first entry is a prompt with an image.
/// - Returns: The image's URL, or `nil` if there's no URL-backed image.
static func imageURL(in transcript: Transcript) -> URL? {
guard case .prompt(let prompt) = transcript.first,
case .attachment(let segment) = prompt.segments.first,
case .image(let image) = segment.content
else {
return nil
}
return image.url
}
/// Encodes a transcript and returns the `attachment` object of its first prompt segment.
///
/// - Parameter transcript: A transcript whose first entry is a prompt with an attachment.
/// - Returns: The JSON object of the attachment.
static func encodedAttachment(of transcript: Transcript) throws -> [String: Any] {
let data = try JSONEncoder().encode(transcript)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let entries = try #require(
(object["transcript"] as? [String: Any])?["entries"] as? [[String: Any]]
)
let contents = try #require(entries.first?["contents"] as? [[String: Any]])
return try #require(contents.first?["attachment"] as? [String: Any])
}
}
#endif // compiler(>=6.4)