Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
1527 lines
56 KiB
Swift
1527 lines
56 KiB
Swift
import UIKit
|
|
import UniformTypeIdentifiers
|
|
import XCTest
|
|
|
|
final class ShareInboxSafetyTests: XCTestCase {
|
|
private func makeStore() throws -> (store: ShareInboxStore, containerURL: URL) {
|
|
let containerURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("ShareInboxSafetyTests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: containerURL, withIntermediateDirectories: true)
|
|
addTeardownBlock {
|
|
try? FileManager.default.removeItem(at: containerURL)
|
|
}
|
|
return (ShareInboxStore(fileManager: .default, containerURL: containerURL), containerURL)
|
|
}
|
|
|
|
private func v1Manifest(id: String, documentId: String) -> Data {
|
|
Data(
|
|
"""
|
|
{
|
|
"id":"\(id)",
|
|
"documentId":"\(documentId)",
|
|
"createdAt":"2026-08-27T00:00:00Z",
|
|
"title":"Original",
|
|
"content":{"kind":"url","url":"https://example.com/original?token=value"},
|
|
"previewRoute":"official",
|
|
"attachments":[]
|
|
}
|
|
""".utf8
|
|
)
|
|
}
|
|
|
|
private func makeImageData(size: Int? = nil) -> Data {
|
|
let image = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).image { context in
|
|
UIColor.red.setFill()
|
|
context.cgContext.fill(CGRect(x: 0, y: 0, width: 2, height: 2))
|
|
}
|
|
var data = image.jpegData(compressionQuality: 1)!
|
|
if let size {
|
|
data.append(Data(repeating: 0, count: max(0, size - data.count)))
|
|
}
|
|
return data
|
|
}
|
|
|
|
private func makePNGData(size: Int? = nil) -> Data {
|
|
let image = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).image { context in
|
|
UIColor.red.setFill()
|
|
context.cgContext.fill(CGRect(x: 0, y: 0, width: 2, height: 2))
|
|
}
|
|
var data = image.pngData()!
|
|
if let size {
|
|
data.append(Data(repeating: 0, count: max(0, size - data.count)))
|
|
}
|
|
return data
|
|
}
|
|
|
|
private func makePDFData(size: Int = 64 * 1024) -> Data {
|
|
let renderer = UIGraphicsPDFRenderer(
|
|
bounds: CGRect(x: 0, y: 0, width: 320, height: 480)
|
|
)
|
|
var data = renderer.pdfData { context in
|
|
context.beginPage()
|
|
"AFFiNE PDF share preview".draw(at: CGPoint(x: 24, y: 24), withAttributes: nil)
|
|
}
|
|
data.append(Data(repeating: 0x20, count: max(0, size - data.count)))
|
|
return data
|
|
}
|
|
|
|
private func makeProviderFile(data: Data? = nil, name: String = "provider-image.jpg") throws -> URL {
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("ShareInboxProviderTests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
addTeardownBlock {
|
|
try? FileManager.default.removeItem(at: directory)
|
|
}
|
|
let url = directory.appendingPathComponent(name)
|
|
try (data ?? makeImageData()).write(to: url)
|
|
return url
|
|
}
|
|
|
|
private func makeDraft(
|
|
stagingURL: URL,
|
|
name: String = "shared-image.jpg",
|
|
title: String = "Shared image"
|
|
) -> SharePayloadDraft {
|
|
SharePayloadDraft(
|
|
title: title,
|
|
content: ShareInboxContent(kind: .image, url: nil, text: nil),
|
|
previewText: "shared-image",
|
|
file: SharePayloadFile(
|
|
ownedStagingURL: stagingURL,
|
|
name: name,
|
|
mimeType: "image/jpeg",
|
|
size: (try? Data(contentsOf: stagingURL).count) ?? 0,
|
|
thumbnailData: Data([0xFF, 0xD8, 0xFF])
|
|
),
|
|
errorMessage: nil
|
|
)
|
|
}
|
|
|
|
func testBuilderStagesProviderFileBeforeTheProviderDisappears() throws {
|
|
let source = try makeProviderFile()
|
|
let expected = try Data(contentsOf: source)
|
|
|
|
let file = try SharePayloadBuilder.stageImage(from: source, suggestedName: source.lastPathComponent)
|
|
try FileManager.default.removeItem(at: source)
|
|
|
|
XCTAssertTrue(file.ownedStagingURL.path.hasPrefix(ShareInboxConstants.stagingDirectoryURL.path))
|
|
XCTAssertEqual(try Data(contentsOf: file.ownedStagingURL), expected)
|
|
XCTAssertEqual(file.size, expected.count)
|
|
XCTAssertLessThanOrEqual(file.thumbnailData.count, ShareInboxConstants.maxThumbnailBytes)
|
|
}
|
|
|
|
func testBuilderStagesAValidPDFBeforeTheProviderDisappears() throws {
|
|
let source = try makeProviderFile(data: makePDFData(), name: "report.pdf")
|
|
let expected = try Data(contentsOf: source)
|
|
|
|
let file = try SharePayloadBuilder.stagePDF(
|
|
from: source,
|
|
suggestedName: source.lastPathComponent,
|
|
declaredTypeIdentifier: UTType.pdf.identifier
|
|
)
|
|
try FileManager.default.removeItem(at: source)
|
|
|
|
XCTAssertEqual(file.mimeType, "application/pdf")
|
|
XCTAssertEqual(file.size, expected.count)
|
|
XCTAssertEqual(try Data(contentsOf: file.ownedStagingURL), expected)
|
|
XCTAssertFalse(file.thumbnailData.isEmpty)
|
|
XCTAssertLessThanOrEqual(file.thumbnailData.count, ShareInboxConstants.maxThumbnailBytes)
|
|
}
|
|
|
|
func testBuilderRejectsInvalidPDFRepresentations() throws {
|
|
let cases: [(String, Data, String)] = [
|
|
("spoofed declared type", makePDFData(), UTType.jpeg.identifier),
|
|
("missing PDF magic", Data("not a PDF".utf8), UTType.pdf.identifier),
|
|
("empty file", Data(), UTType.pdf.identifier),
|
|
]
|
|
for (name, data, declaredType) in cases {
|
|
let source = try makeProviderFile(data: data, name: "report.pdf")
|
|
XCTAssertThrowsError(try SharePayloadBuilder.stagePDF(
|
|
from: source, suggestedName: source.lastPathComponent, declaredTypeIdentifier: declaredType
|
|
), name)
|
|
}
|
|
}
|
|
|
|
func testBuilderRejectsPDFLargerThanShareAttachmentLimit() throws {
|
|
let source = try makeProviderFile(data: makePDFData(), name: "large.pdf")
|
|
let handle = try FileHandle(forWritingTo: source)
|
|
try handle.seek(toOffset: UInt64(64 * 1024 * 1024))
|
|
try handle.write(contentsOf: Data([0]))
|
|
try handle.close()
|
|
|
|
XCTAssertThrowsError(
|
|
try SharePayloadBuilder.stagePDF(
|
|
from: source,
|
|
suggestedName: source.lastPathComponent,
|
|
declaredTypeIdentifier: UTType.pdf.identifier
|
|
)
|
|
) { error in
|
|
XCTAssertEqual(error as? ShareInboxError, .payloadTooLarge)
|
|
}
|
|
}
|
|
|
|
func testBuilderKeepsAValidPDFWhenThumbnailRenderingFails() throws {
|
|
let source = try makeProviderFile(data: makePDFData(), name: "report.pdf")
|
|
|
|
let file = try SharePayloadBuilder.stagePDF(
|
|
from: source,
|
|
suggestedName: source.lastPathComponent,
|
|
declaredTypeIdentifier: UTType.pdf.identifier,
|
|
renderThumbnail: { _ in throw TestThumbnailError.failed }
|
|
)
|
|
|
|
XCTAssertEqual(file.mimeType, "application/pdf")
|
|
XCTAssertTrue(file.thumbnailData.isEmpty)
|
|
}
|
|
|
|
func testBuilderRejectsOversizedImageFromSpecificFileRepresentationBeforeGenericDataLoad() async throws {
|
|
let source = try makeProviderFile(data: makePNGData(), name: "oversized.png")
|
|
let handle = try FileHandle(forWritingTo: source)
|
|
try handle.truncate(atOffset: UInt64(12 * 1024 * 1024 + 1))
|
|
try handle.close()
|
|
let provider = NSItemProvider()
|
|
let genericData = makePNGData()
|
|
var didLoadSpecificFile = false
|
|
var didLoadGenericData = false
|
|
provider.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.png.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadSpecificFile = true
|
|
completion(source, true, nil)
|
|
return nil
|
|
}
|
|
provider.registerDataRepresentation(
|
|
forTypeIdentifier: UTType.image.identifier,
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadGenericData = true
|
|
completion(genericData, nil)
|
|
return nil
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [provider]
|
|
|
|
let draft = await SharePayloadBuilder.build(from: [extensionItem])
|
|
|
|
XCTAssertNil(draft.content)
|
|
XCTAssertEqual(draft.errorMessage, "The image must be smaller than 12 MB.")
|
|
XCTAssertTrue(didLoadSpecificFile)
|
|
XCTAssertFalse(didLoadGenericData)
|
|
}
|
|
|
|
func testBuilderStagesImageFromADataOnlyProviderThroughFileRepresentation() async throws {
|
|
let imageData = makePNGData()
|
|
let provider = NSItemProvider()
|
|
provider.registerDataRepresentation(
|
|
forTypeIdentifier: UTType.png.identifier,
|
|
visibility: .all
|
|
) { completion in
|
|
completion(imageData, nil)
|
|
return nil
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [provider]
|
|
|
|
let draft = await SharePayloadBuilder.build(from: [extensionItem])
|
|
|
|
XCTAssertEqual(draft.content?.kind, .image)
|
|
let file = try XCTUnwrap(draft.file)
|
|
XCTAssertEqual(file.mimeType, "image/png")
|
|
XCTAssertEqual(try Data(contentsOf: file.ownedStagingURL), imageData)
|
|
}
|
|
|
|
func testBuilderRejectsMultipleBinaryAttachmentsBeforeEnqueue() async {
|
|
let first = NSItemProvider()
|
|
var didLoadFirst = false
|
|
first.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.pdf.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadFirst = true
|
|
completion(nil, false, ShareInboxError.invalidPayload)
|
|
return nil
|
|
}
|
|
let second = NSItemProvider()
|
|
var didLoadSecond = false
|
|
second.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.pdf.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadSecond = true
|
|
completion(nil, false, ShareInboxError.invalidPayload)
|
|
return nil
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [first, second]
|
|
|
|
let draft = await SharePayloadBuilder.build(from: [extensionItem])
|
|
|
|
XCTAssertNil(draft.content)
|
|
XCTAssertEqual(draft.errorMessage, "Share one image or PDF at a time.")
|
|
XCTAssertFalse(didLoadFirst)
|
|
XCTAssertFalse(didLoadSecond)
|
|
}
|
|
|
|
func testBuilderStopsWaitingForPendingProviderLoadWhenTaskIsCancelled() async {
|
|
let started = expectation(description: "provider load started")
|
|
let finished = expectation(description: "builder stopped waiting")
|
|
let provider = NSItemProvider()
|
|
provider.registerDataRepresentation(
|
|
forTypeIdentifier: UTType.plainText.identifier,
|
|
visibility: .all
|
|
) { completion in
|
|
started.fulfill()
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) {
|
|
completion(Data("late provider result".utf8), nil)
|
|
}
|
|
return nil
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [provider]
|
|
|
|
let task = Task {
|
|
let draft = await SharePayloadBuilder.build(from: [extensionItem])
|
|
finished.fulfill()
|
|
return draft
|
|
}
|
|
await fulfillment(of: [started], timeout: 1)
|
|
task.cancel()
|
|
await fulfillment(of: [finished], timeout: 0.1)
|
|
_ = await task.value
|
|
}
|
|
|
|
func testBuilderCancelsPendingFileProviderProgressWhenTaskIsCancelled() async throws {
|
|
let started = expectation(description: "file provider load started")
|
|
let cancelled = expectation(description: "file provider load cancelled")
|
|
let source = try makeProviderFile(data: makePDFData(), name: "cancelled.pdf")
|
|
let provider = NSItemProvider()
|
|
provider.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.pdf.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
let progress = Progress(totalUnitCount: 1)
|
|
progress.cancellationHandler = {
|
|
cancelled.fulfill()
|
|
completion(nil, false, CancellationError())
|
|
}
|
|
started.fulfill()
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) {
|
|
guard !progress.isCancelled else { return }
|
|
completion(source, true, nil)
|
|
}
|
|
return progress
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [provider]
|
|
|
|
let task = Task {
|
|
await SharePayloadBuilder.build(from: [extensionItem])
|
|
}
|
|
await fulfillment(of: [started], timeout: 1)
|
|
task.cancel()
|
|
await fulfillment(of: [cancelled], timeout: 0.1)
|
|
_ = await task.value
|
|
}
|
|
|
|
func testBuilderRejectsAnImageAndPDFBeforeAnyBinaryLoad() async {
|
|
let image = NSItemProvider()
|
|
let imageData = makePNGData()
|
|
var didLoadImage = false
|
|
image.registerDataRepresentation(
|
|
forTypeIdentifier: UTType.png.identifier,
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadImage = true
|
|
completion(imageData, nil)
|
|
return nil
|
|
}
|
|
let pdf = NSItemProvider()
|
|
var didLoadPDF = false
|
|
pdf.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.pdf.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadPDF = true
|
|
completion(nil, false, ShareInboxError.invalidPayload)
|
|
return nil
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [image, pdf]
|
|
|
|
let draft = await SharePayloadBuilder.build(from: [extensionItem])
|
|
|
|
XCTAssertNil(draft.content)
|
|
XCTAssertEqual(draft.errorMessage, "Share one image or PDF at a time.")
|
|
XCTAssertFalse(didLoadImage)
|
|
XCTAssertFalse(didLoadPDF)
|
|
}
|
|
|
|
func testBuilderDoesNotLoadBinaryRepresentationsForARemotePDFURL() async throws {
|
|
let pdf = try makeProviderFile(data: makePDFData(), name: "report.pdf")
|
|
let png = makePNGData()
|
|
let provider = try NSItemProvider(object: XCTUnwrap(URL(string: "https://example.com/report.pdf")) as NSURL)
|
|
var didLoadImage = false
|
|
var didLoadPDF = false
|
|
provider.registerDataRepresentation(
|
|
forTypeIdentifier: UTType.png.identifier,
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadImage = true
|
|
completion(png, nil)
|
|
return nil
|
|
}
|
|
provider.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.pdf.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadPDF = true
|
|
completion(pdf, true, nil)
|
|
return nil
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [provider]
|
|
|
|
let draft = await SharePayloadBuilder.build(from: [extensionItem])
|
|
|
|
XCTAssertEqual(draft.content?.kind, .url)
|
|
XCTAssertEqual(draft.content?.url, "https://example.com/report.pdf")
|
|
XCTAssertNil(draft.file)
|
|
XCTAssertFalse(didLoadImage)
|
|
XCTAssertFalse(didLoadPDF)
|
|
}
|
|
|
|
func testBuilderDoesNotLoadPDFWhenDeclaredURLRepresentationFails() async throws {
|
|
let source = try makeProviderFile(data: makePDFData(), name: "report.pdf")
|
|
let provider = NSItemProvider()
|
|
provider.registerDataRepresentation(
|
|
forTypeIdentifier: UTType.url.identifier,
|
|
visibility: .all
|
|
) { completion in
|
|
completion(nil, ShareInboxError.invalidPayload)
|
|
return nil
|
|
}
|
|
var didLoadPDF = false
|
|
provider.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.pdf.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadPDF = true
|
|
completion(source, true, nil)
|
|
return nil
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [provider]
|
|
|
|
let draft = await SharePayloadBuilder.build(from: [extensionItem])
|
|
|
|
XCTAssertNil(draft.file)
|
|
XCTAssertFalse(didLoadPDF)
|
|
}
|
|
|
|
func testBuilderLoadsPDFWhenURLRepresentationIsALocalFile() async throws {
|
|
let source = try makeProviderFile(data: makePDFData(), name: "report.pdf")
|
|
let provider = NSItemProvider(object: source as NSURL)
|
|
var didLoadPDF = false
|
|
provider.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.pdf.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadPDF = true
|
|
completion(source, true, nil)
|
|
return nil
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [provider]
|
|
|
|
let draft = await SharePayloadBuilder.build(from: [extensionItem])
|
|
|
|
XCTAssertEqual(draft.content?.kind, .pdf)
|
|
XCTAssertEqual(draft.file?.mimeType, "application/pdf")
|
|
XCTAssertTrue(didLoadPDF)
|
|
}
|
|
|
|
func testBuilderTreatsProviderWithURLAndPDFAsURLAfterAnotherURL() async throws {
|
|
let source = try makeProviderFile(data: makePDFData(), name: "report.pdf")
|
|
let first = try NSItemProvider(object: XCTUnwrap(URL(string: "https://example.com/first")) as NSURL)
|
|
let second = try NSItemProvider(object: XCTUnwrap(URL(string: "https://example.com/report.pdf")) as NSURL)
|
|
var didLoadPDF = false
|
|
second.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.pdf.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadPDF = true
|
|
completion(source, true, nil)
|
|
return nil
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [first, second]
|
|
|
|
let draft = await SharePayloadBuilder.build(from: [extensionItem])
|
|
|
|
XCTAssertEqual(draft.content?.kind, .url)
|
|
XCTAssertEqual(draft.content?.url, "https://example.com/first")
|
|
XCTAssertNil(draft.file)
|
|
XCTAssertFalse(didLoadPDF)
|
|
}
|
|
|
|
func testBuilderDoesNotLoadSeparatePDFProviderWhenShareContainsRemoteURL() async throws {
|
|
let source = try makeProviderFile(data: makePDFData(), name: "report.pdf")
|
|
let remoteURL = try NSItemProvider(
|
|
object: XCTUnwrap(URL(string: "https://example.com/report.pdf")) as NSURL
|
|
)
|
|
let pdf = NSItemProvider()
|
|
var didLoadPDF = false
|
|
pdf.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.pdf.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadPDF = true
|
|
completion(source, true, nil)
|
|
return nil
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [remoteURL, pdf]
|
|
|
|
let draft = await SharePayloadBuilder.build(from: [extensionItem])
|
|
|
|
XCTAssertEqual(draft.content?.kind, .url)
|
|
XCTAssertEqual(draft.content?.url, "https://example.com/report.pdf")
|
|
XCTAssertNil(draft.file)
|
|
XCTAssertFalse(didLoadPDF)
|
|
}
|
|
|
|
func testBuilderRejectsTwoLocalPDFsEvenWhenShareContainsAURL() async throws {
|
|
let remoteURL = try NSItemProvider(object: XCTUnwrap(URL(string: "https://example.com")) as NSURL)
|
|
let first = NSItemProvider()
|
|
let second = NSItemProvider()
|
|
var didLoadFirst = false
|
|
var didLoadSecond = false
|
|
first.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.pdf.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadFirst = true
|
|
completion(nil, false, ShareInboxError.invalidPayload)
|
|
return nil
|
|
}
|
|
second.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.pdf.identifier,
|
|
fileOptions: [],
|
|
visibility: .all
|
|
) { completion in
|
|
didLoadSecond = true
|
|
completion(nil, false, ShareInboxError.invalidPayload)
|
|
return nil
|
|
}
|
|
let extensionItem = NSExtensionItem()
|
|
extensionItem.attachments = [remoteURL, first, second]
|
|
|
|
let draft = await SharePayloadBuilder.build(from: [extensionItem])
|
|
|
|
XCTAssertNil(draft.content)
|
|
XCTAssertEqual(draft.errorMessage, "Share one image or PDF at a time.")
|
|
XCTAssertFalse(didLoadFirst)
|
|
XCTAssertFalse(didLoadSecond)
|
|
}
|
|
|
|
func testBuilderRemovesTemporaryDirectoryWhenProviderCopyIsInterrupted() throws {
|
|
let source = try makeProviderFile()
|
|
try FileManager.default.createDirectory(
|
|
at: ShareInboxConstants.stagingDirectoryURL,
|
|
withIntermediateDirectories: true
|
|
)
|
|
let before = try Set(FileManager.default.contentsOfDirectory(
|
|
atPath: ShareInboxConstants.stagingDirectoryURL.path
|
|
))
|
|
|
|
XCTAssertThrowsError(
|
|
try SharePayloadBuilder.stageImage(
|
|
from: source,
|
|
suggestedName: source.lastPathComponent,
|
|
copyFile: { _, _ in throw TestCopyError.interrupted }
|
|
)
|
|
)
|
|
|
|
let after = try Set(FileManager.default.contentsOfDirectory(
|
|
atPath: ShareInboxConstants.stagingDirectoryURL.path
|
|
))
|
|
XCTAssertEqual(after, before)
|
|
}
|
|
|
|
func testBuilderLeavesNoStagingDirectoryWhenCoordinatedReadFails() throws {
|
|
let source = try makeProviderFile()
|
|
try FileManager.default.createDirectory(
|
|
at: ShareInboxConstants.stagingDirectoryURL,
|
|
withIntermediateDirectories: true
|
|
)
|
|
let before = try Set(FileManager.default.contentsOfDirectory(
|
|
atPath: ShareInboxConstants.stagingDirectoryURL.path
|
|
))
|
|
|
|
XCTAssertThrowsError(
|
|
try SharePayloadBuilder.stageImage(
|
|
from: source,
|
|
suggestedName: source.lastPathComponent,
|
|
coordinatedRead: { _, _ in throw TestCopyError.interrupted }
|
|
)
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
try Set(FileManager.default.contentsOfDirectory(atPath: ShareInboxConstants.stagingDirectoryURL.path)),
|
|
before
|
|
)
|
|
}
|
|
|
|
func testBuilderUsesCoordinatedURLForImageMetadataAndContents() throws {
|
|
let original = try makeProviderFile(name: "original.jpg")
|
|
let coordinated = try makeProviderFile(data: makePNGData(), name: "changed.png")
|
|
|
|
let staged = try SharePayloadBuilder.stageImage(
|
|
from: original,
|
|
suggestedName: original.lastPathComponent,
|
|
coordinatedRead: { _, read in try read(coordinated) }
|
|
)
|
|
|
|
XCTAssertEqual(staged.mimeType, "image/png")
|
|
XCTAssertEqual(staged.size, try Data(contentsOf: coordinated).count)
|
|
XCTAssertEqual(try Data(contentsOf: staged.ownedStagingURL), try Data(contentsOf: coordinated))
|
|
}
|
|
|
|
func testBuilderRemovesStagingDirectoriesOlderThanOneDay() throws {
|
|
let staleDirectory = ShareInboxConstants.stagingDirectoryURL
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: staleDirectory, withIntermediateDirectories: true)
|
|
try FileManager.default.setAttributes(
|
|
[.modificationDate: Date.now.addingTimeInterval(-ShareInboxConstants.stagingMaxAge - 1)],
|
|
ofItemAtPath: staleDirectory.path
|
|
)
|
|
|
|
_ = try SharePayloadBuilder.stageImage(
|
|
from: makeProviderFile(name: "fresh.jpg"),
|
|
suggestedName: "fresh.jpg"
|
|
)
|
|
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: staleDirectory.path))
|
|
}
|
|
|
|
func testStoreRejectsTraversalAttachmentPathBeforePublishingManifest() throws {
|
|
let (store, containerURL) = try makeStore()
|
|
let source = try makeProviderFile()
|
|
let item = ShareInboxItem(
|
|
title: "Image",
|
|
content: ShareInboxContent(kind: .image, url: nil, text: nil),
|
|
attachments: [
|
|
ShareInboxAttachment(
|
|
fileName: "image.jpg",
|
|
mimeType: "image/jpeg",
|
|
relativePath: "../image.jpg"
|
|
),
|
|
]
|
|
)
|
|
|
|
XCTAssertThrowsError(
|
|
try store.enqueue(item, attachmentFiles: [(item.attachments[0], source)])
|
|
)
|
|
XCTAssertFalse(FileManager.default.fileExists(
|
|
atPath: containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName)
|
|
.appendingPathComponent("\(item.id).json").path
|
|
))
|
|
}
|
|
|
|
func testStorePublishesManifestOnlyAfterFileCopyCompletes() throws {
|
|
let (_, containerURL) = try makeStore()
|
|
let source = try makeProviderFile()
|
|
var item = ShareInboxItem(
|
|
title: "Image",
|
|
content: ShareInboxContent(kind: .image, url: nil, text: nil)
|
|
)
|
|
let attachment = ShareInboxAttachment(
|
|
fileName: "image.jpg",
|
|
mimeType: "image/jpeg",
|
|
relativePath: "\(item.id)/image.jpg"
|
|
)
|
|
item.attachments = [attachment]
|
|
let manifestURL = containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName)
|
|
.appendingPathComponent("\(item.id).json")
|
|
var manifestWasVisibleDuringCopy = false
|
|
let store = ShareInboxStore(
|
|
fileManager: .default,
|
|
containerURL: containerURL,
|
|
copyFile: { _, destination in
|
|
manifestWasVisibleDuringCopy = FileManager.default.fileExists(atPath: manifestURL.path)
|
|
try Data([0xFF, 0xD8, 0xFF]).write(to: destination)
|
|
}
|
|
)
|
|
|
|
try store.enqueue(item, attachmentFiles: [(attachment, source)])
|
|
|
|
XCTAssertFalse(manifestWasVisibleDuringCopy)
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: manifestURL.path))
|
|
XCTAssertEqual(try Data(contentsOf: XCTUnwrap(store.attachmentURL(for: attachment))), Data([0xFF, 0xD8, 0xFF]))
|
|
}
|
|
|
|
func testStoreRemovesStaleTemporaryAndManifestlessAttachmentDirectories() throws {
|
|
let (store, containerURL) = try makeStore()
|
|
XCTAssertTrue(store.ensureDirectories())
|
|
let attachmentsDirectory = containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName, isDirectory: true)
|
|
.appendingPathComponent(ShareInboxConstants.attachmentsDirectoryName, isDirectory: true)
|
|
let staleTemporary = attachmentsDirectory
|
|
.appendingPathComponent(".\(UUID().uuidString).tmp", isDirectory: true)
|
|
let stalePublished = attachmentsDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
let freshTemporary = attachmentsDirectory
|
|
.appendingPathComponent(".\(UUID().uuidString).tmp", isDirectory: true)
|
|
for directory in [staleTemporary, stalePublished, freshTemporary] {
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
|
}
|
|
let staleDate = Date.now.addingTimeInterval(-ShareInboxConstants.stagingMaxAge - 1)
|
|
for directory in [staleTemporary, stalePublished] {
|
|
try FileManager.default.setAttributes(
|
|
[.modificationDate: staleDate],
|
|
ofItemAtPath: directory.path
|
|
)
|
|
}
|
|
|
|
_ = store.pendingItems()
|
|
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: staleTemporary.path))
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: stalePublished.path))
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: freshTemporary.path))
|
|
}
|
|
|
|
func testStoreDoesNotLeaveAttachmentDirectoryForTextItemAfterRemove() throws {
|
|
let (store, containerURL) = try makeStore()
|
|
let item = ShareInboxItem(
|
|
title: "Shared text",
|
|
content: ShareInboxContent(kind: .text, url: nil, text: "Hello")
|
|
)
|
|
let attachmentDirectory = containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName)
|
|
.appendingPathComponent(ShareInboxConstants.attachmentsDirectoryName)
|
|
.appendingPathComponent(item.id, isDirectory: true)
|
|
|
|
try store.enqueue(item)
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: attachmentDirectory.path))
|
|
try store.remove(item)
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: attachmentDirectory.path))
|
|
}
|
|
|
|
func testStoreCompletionRejectsMismatchedDocumentAndPreservesAdjacentItem() throws {
|
|
let (store, _) = try makeStore()
|
|
let itemId = "00000000-0000-4000-8000-00000000000A"
|
|
let adjacentId = "00000000-0000-4000-8000-00000000000B"
|
|
let item = ShareInboxItem(
|
|
id: itemId,
|
|
documentId: "document-a",
|
|
title: "First",
|
|
content: ShareInboxContent(kind: .text, url: nil, text: "First")
|
|
)
|
|
let adjacent = ShareInboxItem(
|
|
id: adjacentId,
|
|
documentId: "document-b",
|
|
title: "Second",
|
|
content: ShareInboxContent(kind: .text, url: nil, text: "Second")
|
|
)
|
|
try store.enqueue(item)
|
|
try store.enqueue(adjacent)
|
|
|
|
XCTAssertThrowsError(
|
|
try store.complete(
|
|
itemId: item.id,
|
|
docId: adjacent.documentId,
|
|
committedAt: Date(timeIntervalSince1970: 1_800_000_000)
|
|
)
|
|
)
|
|
|
|
let pending = store.pendingItems().compactMap { entry -> ShareInboxItem? in
|
|
guard case let .ready(item) = entry else { return nil }
|
|
return item
|
|
}
|
|
XCTAssertEqual(Set(pending.map(\.id)), Set([item.id, adjacent.id]))
|
|
XCTAssertTrue(pending.allSatisfy { $0.result == nil })
|
|
}
|
|
|
|
func testStoreCompletionRemovesOnlyMatchingOwnedFilesAfterRecordingResult() throws {
|
|
let (store, containerURL) = try makeStore()
|
|
let itemId = "00000000-0000-4000-8000-00000000000a"
|
|
let adjacentId = "00000000-0000-4000-8000-00000000000b"
|
|
let sourceA = try makeProviderFile(name: "first.jpg")
|
|
let sourceB = try makeProviderFile(name: "second.jpg")
|
|
var item = ShareInboxItem(
|
|
id: itemId,
|
|
documentId: "document-a",
|
|
title: "First",
|
|
content: ShareInboxContent(kind: .image, url: nil, text: nil)
|
|
)
|
|
let attachmentA = ShareInboxAttachment(
|
|
fileName: "first.jpg",
|
|
mimeType: "image/jpeg",
|
|
relativePath: "\(item.id)/first.jpg"
|
|
)
|
|
item.attachments = [attachmentA]
|
|
var adjacent = ShareInboxItem(
|
|
id: adjacentId,
|
|
documentId: "document-b",
|
|
title: "Second",
|
|
content: ShareInboxContent(kind: .image, url: nil, text: nil)
|
|
)
|
|
let attachmentB = ShareInboxAttachment(
|
|
fileName: "second.jpg",
|
|
mimeType: "image/jpeg",
|
|
relativePath: "\(adjacent.id)/second.jpg"
|
|
)
|
|
adjacent.attachments = [attachmentB]
|
|
try store.enqueue(item, attachmentFiles: [(attachmentA, sourceA)])
|
|
try store.enqueue(adjacent, attachmentFiles: [(attachmentB, sourceB)])
|
|
|
|
try store.complete(
|
|
itemId: item.id,
|
|
docId: item.documentId,
|
|
committedAt: Date(timeIntervalSince1970: 1_800_000_000)
|
|
)
|
|
|
|
let inbox = containerURL.appendingPathComponent(
|
|
ShareInboxConstants.inboxDirectoryName
|
|
)
|
|
let attachments = inbox.appendingPathComponent(
|
|
ShareInboxConstants.attachmentsDirectoryName
|
|
)
|
|
let itemManifestId = try XCTUnwrap(
|
|
ShareInboxSafety.normalizedManifestID(item.id)
|
|
)
|
|
let adjacentManifestId = try XCTUnwrap(
|
|
ShareInboxSafety.normalizedManifestID(adjacent.id)
|
|
)
|
|
XCTAssertFalse(FileManager.default.fileExists(
|
|
atPath: inbox.appendingPathComponent("\(itemManifestId).json").path
|
|
))
|
|
XCTAssertFalse(FileManager.default.fileExists(
|
|
atPath: attachments.appendingPathComponent(item.id).path
|
|
))
|
|
XCTAssertTrue(FileManager.default.fileExists(
|
|
atPath: inbox.appendingPathComponent("\(adjacentManifestId).json").path
|
|
))
|
|
XCTAssertTrue(FileManager.default.fileExists(
|
|
atPath: attachments.appendingPathComponent(adjacent.id).path
|
|
))
|
|
}
|
|
|
|
func testStoreCompletionMarkerSurvivesCleanupFailureAndPendingEnumerationRetriesCleanup() throws {
|
|
let itemId = "00000000-0000-4000-8000-00000000000a"
|
|
let adjacentId = "00000000-0000-4000-8000-00000000000b"
|
|
let containerURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("ShareInboxSafetyTests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: containerURL, withIntermediateDirectories: true)
|
|
addTeardownBlock {
|
|
try? FileManager.default.removeItem(at: containerURL)
|
|
}
|
|
let targetAttachmentDirectory = containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName)
|
|
.appendingPathComponent(ShareInboxConstants.attachmentsDirectoryName)
|
|
.appendingPathComponent(itemId, isDirectory: true)
|
|
var failTargetCleanup = true
|
|
let store = ShareInboxStore(
|
|
fileManager: .default,
|
|
containerURL: containerURL,
|
|
removeItem: { url in
|
|
if failTargetCleanup,
|
|
url.standardizedFileURL == targetAttachmentDirectory.standardizedFileURL
|
|
{
|
|
failTargetCleanup = false
|
|
throw TestCopyError.interrupted
|
|
}
|
|
try FileManager.default.removeItem(at: url)
|
|
}
|
|
)
|
|
let sourceA = try makeProviderFile(name: "first.jpg")
|
|
let sourceB = try makeProviderFile(name: "second.jpg")
|
|
var item = ShareInboxItem(
|
|
id: itemId,
|
|
documentId: "document-a",
|
|
title: "First",
|
|
content: ShareInboxContent(kind: .image, url: nil, text: nil)
|
|
)
|
|
let attachmentA = ShareInboxAttachment(
|
|
fileName: "first.jpg",
|
|
mimeType: "image/jpeg",
|
|
relativePath: "\(item.id)/first.jpg"
|
|
)
|
|
item.attachments = [attachmentA]
|
|
var adjacent = ShareInboxItem(
|
|
id: adjacentId,
|
|
documentId: "document-b",
|
|
title: "Second",
|
|
content: ShareInboxContent(kind: .image, url: nil, text: nil)
|
|
)
|
|
let attachmentB = ShareInboxAttachment(
|
|
fileName: "second.jpg",
|
|
mimeType: "image/jpeg",
|
|
relativePath: "\(adjacent.id)/second.jpg"
|
|
)
|
|
adjacent.attachments = [attachmentB]
|
|
try store.enqueue(item, attachmentFiles: [(attachmentA, sourceA)])
|
|
try store.enqueue(adjacent, attachmentFiles: [(attachmentB, sourceB)])
|
|
let committedAt = Date(timeIntervalSince1970: 1_800_000_000)
|
|
|
|
XCTAssertThrowsError(
|
|
try store.complete(
|
|
itemId: item.id,
|
|
docId: item.documentId,
|
|
committedAt: committedAt
|
|
)
|
|
)
|
|
|
|
let manifestURL = try containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName)
|
|
.appendingPathComponent(
|
|
"\(XCTUnwrap(ShareInboxSafety.normalizedManifestID(item.id))).json"
|
|
)
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
let persisted = try decoder.decode(
|
|
ShareInboxItem.self,
|
|
from: Data(contentsOf: manifestURL)
|
|
)
|
|
XCTAssertEqual(
|
|
persisted.result,
|
|
ShareInboxResult(docId: item.documentId, committedAt: committedAt)
|
|
)
|
|
XCTAssertTrue(FileManager.default.fileExists(
|
|
atPath: targetAttachmentDirectory.path
|
|
))
|
|
|
|
let pending = store.pendingItems().compactMap { entry -> ShareInboxItem? in
|
|
guard case let .ready(item) = entry else { return nil }
|
|
return item
|
|
}
|
|
XCTAssertEqual(pending.map(\.id), [adjacent.id])
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: manifestURL.path))
|
|
XCTAssertFalse(FileManager.default.fileExists(
|
|
atPath: targetAttachmentDirectory.path
|
|
))
|
|
XCTAssertTrue(FileManager.default.fileExists(
|
|
atPath: targetAttachmentDirectory
|
|
.deletingLastPathComponent()
|
|
.appendingPathComponent(adjacent.id).path
|
|
))
|
|
}
|
|
|
|
@MainActor
|
|
func testViewModelSavesOriginalURLAndEditedTitle() async throws {
|
|
let (store, _) = try makeStore()
|
|
let url = "https://example.com/article?source=share"
|
|
let viewModel = ShareViewModel(store: store, buildPayload: { _ in
|
|
SharePayloadDraft(title: "Original", content: ShareInboxContent(kind: .url, url: url, text: "Selected text"), previewText: url, file: nil, errorMessage: nil)
|
|
})
|
|
await viewModel.load(from: nil)
|
|
viewModel.updateTitle("My title")
|
|
let saved = await viewModel.save()
|
|
XCTAssertTrue(saved)
|
|
let item = try XCTUnwrap(store.pendingItems().compactMap { entry -> ShareInboxItem? in
|
|
guard case let .ready(item) = entry else { return nil }
|
|
return item
|
|
}.first)
|
|
XCTAssertEqual(item.title, "My title")
|
|
XCTAssertEqual(item.content.url, url)
|
|
XCTAssertEqual(item.content.text, "Selected text")
|
|
}
|
|
|
|
@MainActor
|
|
func testViewModelKeepsOwnedFileUntilDelayedSaveThenCleansItUp() async throws {
|
|
let (store, _) = try makeStore()
|
|
let source = try makeProviderFile()
|
|
let staged = try SharePayloadBuilder.stageImage(from: source, suggestedName: "shared-image.jpg")
|
|
let expected = try Data(contentsOf: staged.ownedStagingURL)
|
|
try FileManager.default.removeItem(at: source)
|
|
let viewModel = ShareViewModel(store: store, buildPayload: { _ in
|
|
self.makeDraft(stagingURL: staged.ownedStagingURL)
|
|
})
|
|
|
|
await viewModel.load(from: nil)
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: staged.ownedStagingURL.path))
|
|
let didSave = await viewModel.save()
|
|
XCTAssertTrue(didSave)
|
|
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: staged.ownedStagingURL.path))
|
|
let item = try XCTUnwrap(store.pendingItems().compactMap { entry -> ShareInboxItem? in
|
|
guard case let .ready(item) = entry else { return nil }
|
|
return item
|
|
}.first)
|
|
let attachment = try XCTUnwrap(item.attachments.first)
|
|
XCTAssertEqual(try Data(contentsOf: XCTUnwrap(store.attachmentURL(for: attachment))), expected)
|
|
}
|
|
|
|
@MainActor
|
|
func testViewModelDiscardAndDraftReplacementCleanUpOwnedStaging() async throws {
|
|
let (store, _) = try makeStore()
|
|
let first = try SharePayloadBuilder.stageImage(
|
|
from: makeProviderFile(name: "first.jpg"),
|
|
suggestedName: "first.jpg"
|
|
)
|
|
let second = try SharePayloadBuilder.stageImage(
|
|
from: makeProviderFile(name: "second.jpg"),
|
|
suggestedName: "second.jpg"
|
|
)
|
|
var buildCount = 0
|
|
let viewModel = ShareViewModel(store: store, buildPayload: { _ in
|
|
buildCount += 1
|
|
return self.makeDraft(
|
|
stagingURL: buildCount == 1 ? first.ownedStagingURL : second.ownedStagingURL,
|
|
name: buildCount == 1 ? first.name : second.name
|
|
)
|
|
})
|
|
|
|
await viewModel.load(from: nil)
|
|
await viewModel.load(from: nil)
|
|
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: first.ownedStagingURL.path))
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: second.ownedStagingURL.path))
|
|
viewModel.discard()
|
|
viewModel.discard()
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: second.ownedStagingURL.path))
|
|
}
|
|
|
|
@MainActor
|
|
func testViewModelRetainsStagingFileWhenEnqueueFailsForRetry() async throws {
|
|
let (_, containerURL) = try makeStore()
|
|
let store = ShareInboxStore(
|
|
fileManager: .default,
|
|
containerURL: containerURL,
|
|
copyFile: { _, _ in throw TestCopyError.interrupted }
|
|
)
|
|
let staged = try SharePayloadBuilder.stageImage(
|
|
from: makeProviderFile(name: "retry.jpg"),
|
|
suggestedName: "retry.jpg"
|
|
)
|
|
let viewModel = ShareViewModel(store: store, buildPayload: { _ in
|
|
self.makeDraft(stagingURL: staged.ownedStagingURL, name: staged.name)
|
|
})
|
|
|
|
await viewModel.load(from: nil)
|
|
let didSave = await viewModel.save()
|
|
|
|
XCTAssertFalse(didSave)
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: staged.ownedStagingURL.path))
|
|
}
|
|
|
|
@MainActor
|
|
func testViewModelDiscardsLateBuildAfterDiscard() async throws {
|
|
let (store, _) = try makeStore()
|
|
let staged = try SharePayloadBuilder.stageImage(
|
|
from: makeProviderFile(name: "late.jpg"),
|
|
suggestedName: "late.jpg"
|
|
)
|
|
let gate = DraftBuildGate()
|
|
let viewModel = ShareViewModel(store: store, buildPayload: { _ in
|
|
await gate.next()
|
|
})
|
|
|
|
let load = Task { await viewModel.load(from: nil) }
|
|
await gate.waitForPending(count: 1)
|
|
viewModel.discard()
|
|
await gate.resume(at: 0, with: makeDraft(stagingURL: staged.ownedStagingURL, name: staged.name))
|
|
await load.value
|
|
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: staged.ownedStagingURL.path))
|
|
XCTAssertFalse(viewModel.canSave)
|
|
XCTAssertEqual(viewModel.title, "")
|
|
}
|
|
|
|
@MainActor
|
|
func testViewModelKeepsNewestConcurrentLoadWhenOlderBuildReturnsLast() async throws {
|
|
let (store, _) = try makeStore()
|
|
let old = try SharePayloadBuilder.stageImage(
|
|
from: makeProviderFile(name: "old.jpg"),
|
|
suggestedName: "old.jpg"
|
|
)
|
|
let latest = try SharePayloadBuilder.stageImage(
|
|
from: makeProviderFile(name: "latest.jpg"),
|
|
suggestedName: "latest.jpg"
|
|
)
|
|
let gate = DraftBuildGate()
|
|
let viewModel = ShareViewModel(store: store, buildPayload: { _ in
|
|
await gate.next()
|
|
})
|
|
|
|
let firstLoad = Task { await viewModel.load(from: nil) }
|
|
await gate.waitForPending(count: 1)
|
|
let latestLoad = Task { await viewModel.load(from: nil) }
|
|
await gate.waitForPending(count: 2)
|
|
await gate.resume(
|
|
at: 1,
|
|
with: makeDraft(stagingURL: latest.ownedStagingURL, name: latest.name, title: "Latest image")
|
|
)
|
|
await latestLoad.value
|
|
await gate.resume(
|
|
at: 0,
|
|
with: makeDraft(stagingURL: old.ownedStagingURL, name: old.name, title: "Old image")
|
|
)
|
|
await firstLoad.value
|
|
|
|
XCTAssertEqual(viewModel.title, "Latest image")
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: old.ownedStagingURL.path))
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: latest.ownedStagingURL.path))
|
|
viewModel.discard()
|
|
}
|
|
|
|
@MainActor
|
|
func testViewModelDeinitDiscardsOwnedStagingFile() async throws {
|
|
let (store, _) = try makeStore()
|
|
let staged = try SharePayloadBuilder.stageImage(
|
|
from: makeProviderFile(name: "deinit.jpg"),
|
|
suggestedName: "deinit.jpg"
|
|
)
|
|
var viewModel: ShareViewModel? = ShareViewModel(store: store, buildPayload: { _ in
|
|
self.makeDraft(stagingURL: staged.ownedStagingURL, name: staged.name)
|
|
})
|
|
|
|
await viewModel?.load(from: nil)
|
|
viewModel = nil
|
|
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: staged.ownedStagingURL.path))
|
|
}
|
|
|
|
func testNewManifestEncodesFinalVersionAndImportAttemptID() throws {
|
|
let item = ShareInboxItem(
|
|
title: "Shared",
|
|
content: ShareInboxContent(kind: .url, url: "https://example.com", text: nil)
|
|
)
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
let manifest = try XCTUnwrap(
|
|
JSONSerialization.jsonObject(with: encoder.encode(item)) as? [String: Any]
|
|
)
|
|
|
|
XCTAssertEqual(manifest["schemaVersion"] as? Int, 2)
|
|
XCTAssertFalse((manifest["importAttemptId"] as? String ?? "").isEmpty)
|
|
}
|
|
|
|
func testStoreMigratesV1ManifestOnceAndAtomicallyPersistsV2BeforeReturningReady() throws {
|
|
let (store, containerURL) = try makeStore()
|
|
XCTAssertTrue(store.ensureDirectories())
|
|
let id = UUID().uuidString
|
|
let manifestURL = containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName, isDirectory: true)
|
|
.appendingPathComponent("\(id).json")
|
|
try v1Manifest(id: id, documentId: UUID().uuidString).write(to: manifestURL)
|
|
|
|
let entries = store.pendingItems()
|
|
XCTAssertEqual(entries.count, 1)
|
|
guard case let .ready(migrated) = entries[0] else {
|
|
return XCTFail("Expected the v1 manifest to migrate to a ready entry")
|
|
}
|
|
XCTAssertEqual(migrated.schemaVersion, 2)
|
|
XCTAssertFalse(migrated.importAttemptId.isEmpty)
|
|
XCTAssertEqual(migrated.content.url, "https://example.com/original?token=value")
|
|
|
|
let rewritten = try XCTUnwrap(
|
|
JSONSerialization.jsonObject(with: Data(contentsOf: manifestURL)) as? [String: Any]
|
|
)
|
|
XCTAssertEqual(rewritten["schemaVersion"] as? Int, 2)
|
|
XCTAssertEqual(rewritten["importAttemptId"] as? String, migrated.importAttemptId)
|
|
XCTAssertEqual(rewritten["previewRoute"] as? String, "official")
|
|
|
|
guard case let .ready(reloaded) = try XCTUnwrap(store.pendingItems().first) else {
|
|
return XCTFail("Expected the rewritten v2 manifest to remain ready")
|
|
}
|
|
XCTAssertEqual(reloaded.importAttemptId, migrated.importAttemptId)
|
|
XCTAssertEqual(reloaded.content.url, migrated.content.url)
|
|
}
|
|
|
|
func testCommittedReceiptIdentitySurvivesV2Reencoding() throws {
|
|
let id = UUID().uuidString
|
|
let documentId = UUID().uuidString
|
|
let importAttemptId = "committed-attempt-id"
|
|
let committedAt = "2026-08-27T01:02:03Z"
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
let item = ShareInboxItem(
|
|
id: id,
|
|
documentId: documentId,
|
|
importAttemptId: importAttemptId,
|
|
title: "Original",
|
|
content: ShareInboxContent(kind: .url, url: "https://example.com/original", text: nil)
|
|
)
|
|
var manifest = try XCTUnwrap(
|
|
JSONSerialization.jsonObject(with: encoder.encode(item)) as? [String: Any]
|
|
)
|
|
manifest["result"] = [
|
|
"docId": documentId,
|
|
"committedAt": committedAt,
|
|
]
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
|
|
let decoded = try decoder.decode(
|
|
ShareInboxItem.self,
|
|
from: JSONSerialization.data(withJSONObject: manifest)
|
|
)
|
|
let reencoded = try XCTUnwrap(
|
|
JSONSerialization.jsonObject(with: encoder.encode(decoded)) as? [String: Any]
|
|
)
|
|
let result = try XCTUnwrap(reencoded["result"] as? [String: Any])
|
|
|
|
XCTAssertEqual(decoded.importAttemptId, importAttemptId)
|
|
XCTAssertEqual(decoded.result?.docId, documentId)
|
|
XCTAssertEqual(reencoded["schemaVersion"] as? Int, 2)
|
|
XCTAssertEqual(reencoded["importAttemptId"] as? String, importAttemptId)
|
|
XCTAssertEqual(result["docId"] as? String, documentId)
|
|
XCTAssertEqual(result["committedAt"] as? String, committedAt)
|
|
}
|
|
|
|
func testStorePreservesFutureManifestsDuringEnumerationAndUpdate() throws {
|
|
for id in [UUID().uuidString, "future-item"] {
|
|
let (store, containerURL) = try makeStore()
|
|
XCTAssertTrue(store.ensureDirectories())
|
|
let manifestURL = containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName, isDirectory: true)
|
|
.appendingPathComponent("\(id).json")
|
|
let futureManifest = Data("{\"schemaVersion\":99,\"id\":\"\(id)\"}".utf8)
|
|
try futureManifest.write(to: manifestURL)
|
|
|
|
let entries = store.pendingItems()
|
|
XCTAssertEqual(entries, [.unsupportedVersion(itemId: id, schemaVersion: 99)])
|
|
XCTAssertThrowsError(
|
|
try store.update(
|
|
ShareInboxItem(
|
|
id: id,
|
|
title: "Replacement",
|
|
content: ShareInboxContent(kind: .text, url: nil, text: "replacement")
|
|
)
|
|
)
|
|
)
|
|
XCTAssertEqual(try Data(contentsOf: manifestURL), futureManifest)
|
|
XCTAssertFalse(
|
|
FileManager.default.fileExists(
|
|
atPath: containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName)
|
|
.appendingPathComponent(ShareInboxConstants.invalidDirectoryName)
|
|
.appendingPathComponent("\(id).json").path
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
func testStoreReturnsSupportedItemsBeforeFutureVersionEntries() throws {
|
|
let (store, containerURL) = try makeStore()
|
|
let ready = ShareInboxItem(
|
|
createdAt: Date(timeIntervalSince1970: 1_800_000_000),
|
|
title: "Ready",
|
|
content: ShareInboxContent(kind: .text, url: nil, text: "ready")
|
|
)
|
|
try store.enqueue(ready)
|
|
let futureId = UUID().uuidString
|
|
let futureManifestURL = containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName, isDirectory: true)
|
|
.appendingPathComponent("\(futureId).json")
|
|
try Data("{\"schemaVersion\":99,\"id\":\"\(futureId)\"}".utf8)
|
|
.write(to: futureManifestURL)
|
|
|
|
let entries = store.pendingItems()
|
|
|
|
guard case let .ready(first) = try XCTUnwrap(entries.first) else {
|
|
return XCTFail("Expected supported share before future-version entry")
|
|
}
|
|
XCTAssertEqual(first.id, ready.id)
|
|
XCTAssertEqual(
|
|
entries.last,
|
|
.unsupportedVersion(itemId: futureId, schemaVersion: 99)
|
|
)
|
|
}
|
|
|
|
func testStoreDoesNotReturnReadyWhenV1MigrationWriteFails() throws {
|
|
let (_, containerURL) = try makeStore()
|
|
let store = ShareInboxStore(
|
|
fileManager: .default,
|
|
containerURL: containerURL,
|
|
writeData: { _, _, _ in throw TestWriteError.writeFailed }
|
|
)
|
|
XCTAssertTrue(store.ensureDirectories())
|
|
let id = UUID().uuidString
|
|
let manifestURL = containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName, isDirectory: true)
|
|
.appendingPathComponent("\(id).json")
|
|
let originalManifest = v1Manifest(id: id, documentId: UUID().uuidString)
|
|
try originalManifest.write(to: manifestURL)
|
|
|
|
XCTAssertTrue(store.pendingItems().isEmpty)
|
|
XCTAssertEqual(try Data(contentsOf: manifestURL), originalManifest)
|
|
}
|
|
|
|
func testManifestTitleOnlyAcceptsExplicitEdits() {
|
|
for (edited, expected) in [(nil, "Original Safari title"), (" My explicit title ", "My explicit title")] as [(String?, String)] {
|
|
XCTAssertEqual(ShareInboxSafety.manifestTitle(original: "Original Safari title", userEdited: edited), expected)
|
|
}
|
|
}
|
|
|
|
func testShareExtensionActivationAcceptsSupportedRepresentationsAmongExtraAttachments() throws {
|
|
let plistURL = URL(fileURLWithPath: #filePath)
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
.appendingPathComponent("ShareExtension/Info.plist")
|
|
let plist = try PropertyListSerialization.propertyList(
|
|
from: Data(contentsOf: plistURL),
|
|
format: nil
|
|
) as? [String: Any]
|
|
let extensionDictionary = plist?["NSExtension"] as? [String: Any]
|
|
let attributes = extensionDictionary?["NSExtensionAttributes"] as? [String: Any]
|
|
let rule = try XCTUnwrap(attributes?["NSExtensionActivationRule"] as? String)
|
|
|
|
XCTAssertTrue(rule.contains("public.url"))
|
|
XCTAssertTrue(rule.contains("public.text"))
|
|
XCTAssertTrue(rule.contains("public.image"))
|
|
XCTAssertTrue(rule.contains("com.adobe.pdf"))
|
|
XCTAssertTrue(rule.contains("com.apple.property-list"))
|
|
XCTAssertTrue(rule.contains(".@count > 0"))
|
|
XCTAssertFalse(rule.contains("TRUEPREDICATE"))
|
|
|
|
let predicate = NSPredicate(format: rule)
|
|
let youtubePayload: [String: Any] = [
|
|
"extensionItems": [[
|
|
"attachments": [
|
|
["registeredTypeIdentifiers": ["public.url", "public.data"]],
|
|
["registeredTypeIdentifiers": ["com.google.youtube.extra"]],
|
|
],
|
|
]],
|
|
]
|
|
let supportedPDFPayload: [String: Any] = [
|
|
"extensionItems": [[
|
|
"attachments": [[
|
|
"registeredTypeIdentifiers": ["com.adobe.pdf", "public.movie"],
|
|
]],
|
|
]],
|
|
]
|
|
let unsupportedPayload: [String: Any] = [
|
|
"extensionItems": [[
|
|
"attachments": [[
|
|
"registeredTypeIdentifiers": ["public.movie", "public.audio"],
|
|
]],
|
|
]],
|
|
]
|
|
XCTAssertTrue(predicate.evaluate(with: youtubePayload))
|
|
XCTAssertTrue(predicate.evaluate(with: supportedPDFPayload))
|
|
XCTAssertFalse(predicate.evaluate(with: unsupportedPayload))
|
|
}
|
|
|
|
func testManifestIDsMustBeUUIDs() {
|
|
let id = UUID().uuidString
|
|
XCTAssertEqual(ShareInboxSafety.normalizedManifestID(id.lowercased()), id)
|
|
XCTAssertNil(ShareInboxSafety.normalizedManifestID("../item"))
|
|
XCTAssertNil(ShareInboxSafety.normalizedManifestID("nested/item"))
|
|
}
|
|
|
|
func testPreviewRouteUsesWorkspaceModeAndPublicProviderAllowlist() throws {
|
|
let (store, _) = try makeStore()
|
|
XCTAssertEqual(store.workspaceMode(), .unknown)
|
|
for mode in [ShareWorkspaceMode.unknown, .signedOut, .cloudOnly, .selfHostedPresent] {
|
|
try store.updateWorkspaceMode(mode)
|
|
XCTAssertEqual(store.workspaceMode(), mode)
|
|
XCTAssertEqual(ShareInboxSafety.previewRoute(url: "https://example.com/private", mode: mode),
|
|
mode == .cloudOnly || mode == .signedOut ? .official : .deferred)
|
|
for url in ["https://youtu.be/video", "https://youtube.com/watch?v=video", "https://x.com/person/status/123"] {
|
|
XCTAssertEqual(ShareInboxSafety.previewRoute(url: url, mode: mode), .official)
|
|
}
|
|
XCTAssertEqual(ShareInboxSafety.previewRoute(url: "file:///private/file", mode: mode), .deferred)
|
|
}
|
|
for url in ["https://x.com/person", "https://x.com/person/status/not-a-number", "https://youtube.com.example/watch?v=video"] {
|
|
XCTAssertEqual(ShareInboxSafety.previewRoute(url: url, mode: .selfHostedPresent), .deferred)
|
|
}
|
|
}
|
|
|
|
func testPreviewTransportRejectsOversizedResponsesAndUnapprovedImageURLs() async throws {
|
|
let limit = 1024 * 1024
|
|
for (size, advertised) in [(16383, false), (16384, false), (16385, false), (limit, false), (limit + 1, false), (limit + 1, true)] {
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [PreviewResponseURLProtocol.self]
|
|
configuration.httpAdditionalHeaders = [
|
|
"X-Fixture-Bytes": String(size), "X-Fixture-Advertise-Length": String(advertised),
|
|
]
|
|
let session = URLSession(configuration: configuration)
|
|
defer { session.invalidateAndCancel() }
|
|
let client = ShareLinkPreviewClient(session: session, appVersion: "test")
|
|
do {
|
|
let preview = try await client.fetch(url: "https://example.com/article")
|
|
XCTAssertLessThanOrEqual(size, limit)
|
|
XCTAssertEqual(preview.title, "Boundary")
|
|
} catch let error as URLError {
|
|
XCTAssertGreaterThan(size, limit)
|
|
XCTAssertEqual(error.code, .dataLengthExceedsMaximum)
|
|
}
|
|
}
|
|
let client = ShareLinkPreviewClient(appVersion: "test")
|
|
do {
|
|
_ = try await client.fetchImage(url: "https://example.com/image.png")
|
|
XCTFail("Expected an unapproved image URL to be rejected")
|
|
} catch let error as URLError {
|
|
XCTAssertEqual(error.code, .badURL)
|
|
}
|
|
}
|
|
|
|
func testPreviewClipsLargeTranscriptsWithoutDroppingBaseMetadata() throws {
|
|
for segments in [Array(repeating: ["text": "Caption"], count: 501), [["text": String(repeating: "中", count: 200_000)]]] {
|
|
let data = try JSONSerialization.data(withJSONObject: [
|
|
"url": "https://example.com/article", "title": String(repeating: "A", count: 1000),
|
|
"description": "Summary", "author": ["name": NSNull()], "durationSeconds": -1,
|
|
"transcript": ["segments": segments],
|
|
])
|
|
let preview = try JSONDecoder().decode(ShareLinkPreview.self, from: data)
|
|
XCTAssertEqual(preview.title, String(repeating: "A", count: 120))
|
|
XCTAssertEqual(preview.description, "Summary")
|
|
XCTAssertNil(preview.author)
|
|
XCTAssertNil(preview.durationSeconds)
|
|
XCTAssertEqual(preview.transcript?.segments.count, 1)
|
|
XCTAssertEqual(preview.transcript?.previewText?.count, 241)
|
|
}
|
|
}
|
|
|
|
func testWebURLsRejectCredentialsAndUnsupportedSchemes() {
|
|
XCTAssertEqual(
|
|
ShareInboxSafety.normalizedWebURL("https://example.com/page"),
|
|
"https://example.com/page"
|
|
)
|
|
XCTAssertNil(ShareInboxSafety.normalizedWebURL("file:///private/item"))
|
|
XCTAssertNil(ShareInboxSafety.normalizedWebURL("https://user@example.com/page"))
|
|
}
|
|
|
|
func testSupportedImagesAreDetectedByContent() {
|
|
XCTAssertEqual(
|
|
ShareInboxSafety.detectRasterImageMimeType(Data([0xFF, 0xD8, 0xFF, 0x00])),
|
|
"image/jpeg"
|
|
)
|
|
XCTAssertNil(ShareInboxSafety.detectRasterImageMimeType(Data("<svg/>".utf8)))
|
|
}
|
|
|
|
func testResolvedAttachmentRequiresTheManifestIdentityAndValidatedFileMetadata() throws {
|
|
let (store, containerURL) = try makeStore()
|
|
let source = try makeProviderFile(data: makePNGData(), name: "shared.png")
|
|
var item = ShareInboxItem(
|
|
title: "Image",
|
|
content: ShareInboxContent(kind: .image, url: nil, text: nil)
|
|
)
|
|
let attachment = ShareInboxAttachment(
|
|
fileName: "shared.png",
|
|
mimeType: "image/png",
|
|
relativePath: "\(item.id)/shared.png"
|
|
)
|
|
item.attachments = [attachment]
|
|
try store.enqueue(item, attachmentFiles: [(attachment, source)])
|
|
let persisted = try XCTUnwrap(store.pendingItems().compactMap { entry -> ShareInboxItem? in
|
|
guard case let .ready(value) = entry else { return nil }
|
|
return value
|
|
}.first)
|
|
|
|
let resolved = try XCTUnwrap(store.resolveAttachment(for: persisted))
|
|
XCTAssertEqual(resolved.itemId, persisted.id)
|
|
XCTAssertEqual(resolved.name, "shared.png")
|
|
XCTAssertEqual(resolved.mimeType, "image/png")
|
|
XCTAssertEqual(resolved.size, makePNGData().count)
|
|
XCTAssertTrue(resolved.url.path.hasPrefix(containerURL.path))
|
|
|
|
var traversal = persisted
|
|
traversal.attachments[0].relativePath = "../shared.png"
|
|
XCTAssertNil(store.resolveAttachment(for: traversal))
|
|
|
|
var mismatchedMime = persisted
|
|
mismatchedMime.attachments[0].mimeType = "image/jpeg"
|
|
XCTAssertNil(store.resolveAttachment(for: mismatchedMime))
|
|
|
|
let outside = try makeProviderFile(data: makePNGData(), name: "outside.png")
|
|
try FileManager.default.removeItem(at: resolved.url)
|
|
try FileManager.default.createSymbolicLink(at: resolved.url, withDestinationURL: outside)
|
|
XCTAssertNil(store.resolveAttachment(for: persisted))
|
|
|
|
try FileManager.default.removeItem(at: resolved.url)
|
|
try FileManager.default.createDirectory(at: resolved.url, withIntermediateDirectories: false)
|
|
XCTAssertNil(store.resolveAttachment(for: persisted))
|
|
|
|
try FileManager.default.removeItem(at: resolved.url)
|
|
try makePNGData(size: 12 * 1024 * 1024 + 1).write(to: resolved.url)
|
|
XCTAssertNil(store.resolveAttachment(for: persisted))
|
|
|
|
try FileManager.default.removeItem(at: resolved.url)
|
|
XCTAssertNil(store.resolveAttachment(for: persisted))
|
|
}
|
|
}
|
|
|
|
private enum TestWriteError: Error {
|
|
case writeFailed
|
|
}
|
|
|
|
private enum TestCopyError: Error {
|
|
case interrupted
|
|
}
|
|
|
|
private enum TestThumbnailError: Error {
|
|
case failed
|
|
}
|
|
|
|
private actor DraftBuildGate {
|
|
private var continuations: [CheckedContinuation<SharePayloadDraft, Never>] = []
|
|
private var waiters: [(Int, CheckedContinuation<Void, Never>)] = []
|
|
|
|
func next() async -> SharePayloadDraft {
|
|
await withCheckedContinuation { continuation in
|
|
continuations.append(continuation)
|
|
resumeWaiters()
|
|
}
|
|
}
|
|
|
|
func waitForPending(count: Int) async {
|
|
guard continuations.count < count else { return }
|
|
await withCheckedContinuation { continuation in
|
|
waiters.append((count, continuation))
|
|
}
|
|
}
|
|
|
|
func resume(at index: Int, with draft: SharePayloadDraft) {
|
|
continuations.remove(at: index).resume(returning: draft)
|
|
}
|
|
|
|
private func resumeWaiters() {
|
|
let ready = waiters.enumerated().filter { continuations.count >= $0.element.0 }
|
|
for (index, _) in ready.reversed() {
|
|
waiters.remove(at: index).1.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class PreviewResponseURLProtocol: URLProtocol {
|
|
override class func canInit(with _: URLRequest) -> Bool {
|
|
true
|
|
}
|
|
|
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
|
request
|
|
}
|
|
|
|
override func startLoading() {
|
|
let size = Int(request.value(forHTTPHeaderField: "X-Fixture-Bytes")!)!
|
|
let headers = request.value(forHTTPHeaderField: "X-Fixture-Advertise-Length") == "true"
|
|
? ["Content-Length": String(size)] : [:]
|
|
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil,
|
|
headerFields: headers)!
|
|
var data = Data(#"{"url":"https://example.com/article","title":"Boundary"}"#.utf8)
|
|
data.append(Data(repeating: 0x20, count: size - data.count))
|
|
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
|
client?.urlProtocol(self, didLoad: data)
|
|
client?.urlProtocolDidFinishLoading(self)
|
|
}
|
|
|
|
override func stopLoading() {}
|
|
}
|