From 5cf68d63d58e6a419d9f3cf8496f351363ba147e Mon Sep 17 00:00:00 2001 From: Tomas Camin Date: Wed, 24 Sep 2025 11:16:00 +0200 Subject: [PATCH 1/2] Factor implementation --- .../Cachi/Server/AttachmentFileLocator.swift | 27 +++++++++++++++++++ .../Cachi/Server/Routes/AttachmentRoute.swift | 16 +++-------- 2 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 Sources/Cachi/Server/AttachmentFileLocator.swift diff --git a/Sources/Cachi/Server/AttachmentFileLocator.swift b/Sources/Cachi/Server/AttachmentFileLocator.swift new file mode 100644 index 0000000..630ef02 --- /dev/null +++ b/Sources/Cachi/Server/AttachmentFileLocator.swift @@ -0,0 +1,27 @@ +import CachiKit +import Foundation + +struct AttachmentFileLocator { + static func exportedFileUrl(resultIdentifier: String, testSummaryIdentifier: String, attachmentIdentifier: String) -> URL? { + guard let test = State.shared.test(summaryIdentifier: testSummaryIdentifier) else { + return nil + } + + let fileManager = FileManager.default + let destinationUrl = Cachi.temporaryFolderUrl + .appendingPathComponent(resultIdentifier) + .appendingPathComponent(attachmentIdentifier.md5Value) + + if !fileManager.fileExists(atPath: destinationUrl.path) { + let cachi = CachiKit(url: test.xcresultUrl) + try? fileManager.createDirectory(at: destinationUrl.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) + try? cachi.export(identifier: attachmentIdentifier, destinationPath: destinationUrl.path) + } + + guard fileManager.fileExists(atPath: destinationUrl.path) else { + return nil + } + + return destinationUrl + } +} diff --git a/Sources/Cachi/Server/Routes/AttachmentRoute.swift b/Sources/Cachi/Server/Routes/AttachmentRoute.swift index 1919cab..088c663 100644 --- a/Sources/Cachi/Server/Routes/AttachmentRoute.swift +++ b/Sources/Cachi/Server/Routes/AttachmentRoute.swift @@ -1,4 +1,3 @@ -import CachiKit import Foundation import os import Vapor @@ -26,24 +25,15 @@ struct AttachmentRoute: Routable { let benchId = benchmarkStart() defer { os_log("Attachment with id '%@' in result bundle '%@' fetched in %fms", log: .default, type: .info, attachmentIdentifier, resultIdentifier, benchmarkStop(benchId)) } - guard let test = State.shared.test(summaryIdentifier: testSummaryIdentifier) else { + guard let destinationUrl = AttachmentFileLocator.exportedFileUrl(resultIdentifier: resultIdentifier, + testSummaryIdentifier: testSummaryIdentifier, + attachmentIdentifier: attachmentIdentifier) else { return Response(status: .notFound, body: Response.Body(stringLiteral: "Not found...")) } - let destinationUrl = Cachi.temporaryFolderUrl.appendingPathComponent(resultIdentifier).appendingPathComponent(attachmentIdentifier.md5Value) let destinationPath = destinationUrl.path let filemanager = FileManager.default - if !filemanager.fileExists(atPath: destinationPath) { - let cachi = CachiKit(url: test.xcresultUrl) - try? filemanager.createDirectory(at: destinationUrl.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) - try? cachi.export(identifier: attachmentIdentifier, destinationPath: destinationPath) - } - - guard filemanager.fileExists(atPath: destinationPath) else { - return Response(status: .notFound, body: Response.Body(stringLiteral: "Not found...")) - } - var headers = [ ("Content-Type", contentType) ] From 8c5cde839060e072ed8cf7479373e9368a5b051f Mon Sep 17 00:00:00 2001 From: Tomas Camin Date: Thu, 25 Sep 2025 09:52:44 +0200 Subject: [PATCH 2/2] Wire attachmentViewers --- Package.resolved | 2 +- README.md | 41 ++++++ .../Cachi/AttachmentViewerConfiguration.swift | 68 +++++++++ Sources/Cachi/Commands/RootCommand.swift | 34 ++++- Sources/Cachi/Models.swift | 3 +- .../Cachi/Server/AttachmentFileLocator.swift | 2 +- .../Cachi/Server/Routes/AttachmentRoute.swift | 4 +- .../Server/Routes/AttachmentViewerRoute.swift | 132 ++++++++++++++++++ .../Routes/AttachmentViewerScriptRoute.swift | 51 +++++++ Sources/Cachi/Server/Routes/CSSRoute.swift | 4 + .../Cachi/Server/Routes/TestRouteHTML.swift | 41 +++++- Sources/Cachi/Server/Server.swift | 8 +- .../AttachmentViewerConfigurationTests.swift | 40 ++++++ Tests/CachiTests/CachiBrowserTests.swift | 2 + Tests/CachiTests/XCTestManifests.swift | 3 +- 15 files changed, 423 insertions(+), 12 deletions(-) create mode 100644 Sources/Cachi/AttachmentViewerConfiguration.swift create mode 100644 Sources/Cachi/Server/Routes/AttachmentViewerRoute.swift create mode 100644 Sources/Cachi/Server/Routes/AttachmentViewerScriptRoute.swift create mode 100644 Tests/CachiTests/AttachmentViewerConfigurationTests.swift diff --git a/Package.resolved b/Package.resolved index 6a39d0d..0458fc1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -24,7 +24,7 @@ "location" : "https://github.com/Subito-it/Bariloche", "state" : { "branch" : "master", - "revision" : "cf832fc53f6d003e9017df08e12b972a097bb015" + "revision" : "bb2856b0eb513b8ee7d98e4d774ee55eef62d103" } }, { diff --git a/README.md b/README.md index b8e7bd0..73cc5c7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Cachi can be launched by passing the port for the web interface and the location You can optionally pass: - `--search_depth` to specify how deep Cachi should traverse the location path. Default is 2, larger values may impact parsing speed. - `--merge` to merge multiple xcresults in the same folder as if they belong to the same test run. This can be used in advanced scenarios like for example test sharding on on multiple machines. +- `--attachment-viewer extension:/path/to/viewer.js` to register a JavaScript bundle that renders attachments with a matching file extension. Repeat the flag for multiple mappings (extensions are case-insensitive and should be provided without the leading dot). ```bash $ cachi --port number [--search_depth level] [--merge] path @@ -34,6 +35,46 @@ http://local.host:port/v1/help will return a list of available endpoint with a s # Test result customization +## Custom attachment viewers + +Use the repeatable `--attachment-viewer` option to associate one or more attachment file extensions with a JavaScript bundle. When a table entry links to an attachment whose filename ends with a registered extension, Cachi serves an auto-generated `index.html` wrapper instead of the raw file. The wrapper embeds your script and exposes the selected attachment so that custom visualizations can be rendered. + +- Scripts are proxied through the server at `/attachment-viewer/script`, so the JavaScript file can live anywhere accessible to the Cachi process. +- The generated page mirrors the following structure: + + ```html + + + + + + Cachi Attachment Viewer - Example + + + + + + + ``` + +- The relative file path (from `/tmp/Cachi`) is made available to your script as a property on the script element: + +```js +const attachmentPath = document.currentScript.attachmentPath; +``` + +**Note**: The `attachmentPath` is now a relative path from `/tmp/Cachi`. To construct the full path to the attachment file, append the `attachmentPath` to `/tmp/Cachi/`. For example, if `attachmentPath` is `resultId/attachmentHash`, the full path would be `/tmp/Cachi/resultId/attachmentHash`. + +Remember to provide the extension without a dot (`json`, `html`, `csv`, …). Cachi normalizes extensions in a case-insensitive manner and rejects duplicate registrations to surface configuration mistakes early. + The following keys can be added to the Info.plist in the .xcresult bundle which will be used when showing results: - `branchName` diff --git a/Sources/Cachi/AttachmentViewerConfiguration.swift b/Sources/Cachi/AttachmentViewerConfiguration.swift new file mode 100644 index 0000000..de3be61 --- /dev/null +++ b/Sources/Cachi/AttachmentViewerConfiguration.swift @@ -0,0 +1,68 @@ +import Foundation + +public struct AttachmentViewerConfiguration: Hashable { + public enum Error: Swift.Error, LocalizedError { + case invalidFormat(String) + case missingExtension(String) + case missingScriptPath(String) + case scriptNotFound(String) + case scriptIsDirectory(String) + case duplicateExtension(String) + + public var errorDescription: String? { + switch self { + case let .invalidFormat(value): + "Invalid attachment viewer mapping '\(value)'. Expected syntax 'extension:javascript_file'." + case let .missingExtension(value): + "Missing file extension in attachment viewer mapping '\(value)'." + case let .missingScriptPath(value): + "Missing JavaScript file path in attachment viewer mapping '\(value)'." + case let .scriptNotFound(path): + "Attachment viewer script not found at path '\(path)'." + case let .scriptIsDirectory(path): + "Attachment viewer script path '\(path)' is a directory. Please provide a file." + case let .duplicateExtension(ext): + "Multiple attachment viewers configured for extension '\(ext)'. Each extension can be mapped only once." + } + } + } + + public let fileExtension: String + public let scriptUrl: URL + + public init(argumentValue: String, fileManager: FileManager = .default) throws { + let components = argumentValue.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard components.count == 2 else { + throw Error.invalidFormat(argumentValue) + } + + let rawExtension = components[0].trimmingCharacters(in: .whitespacesAndNewlines) + let rawScriptPath = components[1].trimmingCharacters(in: .whitespacesAndNewlines) + + guard !rawExtension.isEmpty else { + throw Error.missingExtension(argumentValue) + } + guard !rawScriptPath.isEmpty else { + throw Error.missingScriptPath(argumentValue) + } + + let normalizedExtension = rawExtension.trimmingCharacters(in: CharacterSet(charactersIn: ".")).lowercased() + guard !normalizedExtension.isEmpty else { + throw Error.missingExtension(argumentValue) + } + + let expandedPath = NSString(string: rawScriptPath).expandingTildeInPath + let scriptUrl = URL(fileURLWithPath: expandedPath) + + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: scriptUrl.path, isDirectory: &isDirectory) else { + throw Error.scriptNotFound(scriptUrl.path) + } + guard !isDirectory.boolValue else { + throw Error.scriptIsDirectory(scriptUrl.path) + } + + self.fileExtension = normalizedExtension + self.scriptUrl = scriptUrl.standardizedFileURL + } +} diff --git a/Sources/Cachi/Commands/RootCommand.swift b/Sources/Cachi/Commands/RootCommand.swift index 2254cd6..a272aad 100644 --- a/Sources/Cachi/Commands/RootCommand.swift +++ b/Sources/Cachi/Commands/RootCommand.swift @@ -2,12 +2,13 @@ import Bariloche import Foundation class RootCommand: Command { - let usage: String? = "Cachi parses Xcode 11's .xcresult bundles making results accessible via a web interface. Check documentation for additional details about the exposed API" + let usage: String? = "Cachi parses Xcode's .xcresult bundles making results accessible via a web interface. Check documentation for additional details about the exposed API" let pathArgument = Argument(name: "path", kind: .positional, optional: false, help: "Path to location containing .xcresult bundles (will search recursively)") let parseDepthArgument = Argument(name: "level", kind: .named(short: "d", long: "search_depth"), optional: true, help: "Location path traversing depth (Default: 2)") let port = Argument(name: "number", kind: .named(short: "p", long: "port"), optional: false, help: "Web interface port") let mergeResultsFlag = Flag(short: "m", long: "merge", help: "Merge xcresults that are in the same folder") + let attachmentViewerArgument = Argument<[String]>(name: "extension:javascript_file", kind: .named(short: nil, long: "attachment_viewer"), optional: true, help: "Custom viewer JavaScript for attachment file extensions (repeatable)") func run() -> Bool { let basePath = NSString(string: pathArgument.value!).expandingTildeInPath // 🤷‍♂️ @@ -23,6 +24,16 @@ class RootCommand: Command { let parseDepth = parseDepthArgument.value ?? 2 let mergeResults = mergeResultsFlag.value + let attachmentViewerArguments = attachmentViewerArgument.value ?? [] + + let attachmentViewerConfigurations: [AttachmentViewerConfiguration] + do { + attachmentViewerConfigurations = try RootCommand.parseAttachmentViewerArguments(attachmentViewerArguments) + } catch { + print("\(error.localizedDescription)\n") + return false + } + guard FileManager.default.fileExists(atPath: baseUrl.path) else { print("Path '\(baseUrl.standardized)' does not exist!\n") return false @@ -32,7 +43,11 @@ class RootCommand: Command { State.shared.parse(baseUrl: baseUrl, depth: parseDepth, mergeResults: mergeResults) } - let server = Server(port: port.value!, baseUrl: baseUrl, parseDepth: parseDepth, mergeResults: mergeResults) + let server = Server(port: port.value!, + baseUrl: baseUrl, + parseDepth: parseDepth, + mergeResults: mergeResults, + attachmentViewers: attachmentViewerConfigurations) do { try server.listen() } catch { @@ -40,4 +55,19 @@ class RootCommand: Command { } return true } + + private static func parseAttachmentViewerArguments(_ values: [String]) throws -> [AttachmentViewerConfiguration] { + var configurations = [AttachmentViewerConfiguration]() + var seenExtensions = Set() + + for value in values { + let configuration = try AttachmentViewerConfiguration(argumentValue: value) + guard seenExtensions.insert(configuration.fileExtension).inserted else { + throw AttachmentViewerConfiguration.Error.duplicateExtension(configuration.fileExtension) + } + configurations.append(configuration) + } + + return configurations + } } diff --git a/Sources/Cachi/Models.swift b/Sources/Cachi/Models.swift index 619703f..3612477 100644 --- a/Sources/Cachi/Models.swift +++ b/Sources/Cachi/Models.swift @@ -108,8 +108,7 @@ struct ResultBundle: Codable { endDate == nil, sourceBasePath == nil, githubBaseUrl == nil, - xcresultPathToFailedTestName == nil - { + xcresultPathToFailedTestName == nil { throw Error.empty } } diff --git a/Sources/Cachi/Server/AttachmentFileLocator.swift b/Sources/Cachi/Server/AttachmentFileLocator.swift index 630ef02..55c9ff2 100644 --- a/Sources/Cachi/Server/AttachmentFileLocator.swift +++ b/Sources/Cachi/Server/AttachmentFileLocator.swift @@ -1,7 +1,7 @@ import CachiKit import Foundation -struct AttachmentFileLocator { +enum AttachmentFileLocator { static func exportedFileUrl(resultIdentifier: String, testSummaryIdentifier: String, attachmentIdentifier: String) -> URL? { guard let test = State.shared.test(summaryIdentifier: testSummaryIdentifier) else { return nil diff --git a/Sources/Cachi/Server/Routes/AttachmentRoute.swift b/Sources/Cachi/Server/Routes/AttachmentRoute.swift index 088c663..5f2ba04 100644 --- a/Sources/Cachi/Server/Routes/AttachmentRoute.swift +++ b/Sources/Cachi/Server/Routes/AttachmentRoute.swift @@ -27,12 +27,12 @@ struct AttachmentRoute: Routable { guard let destinationUrl = AttachmentFileLocator.exportedFileUrl(resultIdentifier: resultIdentifier, testSummaryIdentifier: testSummaryIdentifier, - attachmentIdentifier: attachmentIdentifier) else { + attachmentIdentifier: attachmentIdentifier) + else { return Response(status: .notFound, body: Response.Body(stringLiteral: "Not found...")) } let destinationPath = destinationUrl.path - let filemanager = FileManager.default var headers = [ ("Content-Type", contentType) diff --git a/Sources/Cachi/Server/Routes/AttachmentViewerRoute.swift b/Sources/Cachi/Server/Routes/AttachmentViewerRoute.swift new file mode 100644 index 0000000..540092f --- /dev/null +++ b/Sources/Cachi/Server/Routes/AttachmentViewerRoute.swift @@ -0,0 +1,132 @@ +import Foundation +import os +import Vapor + +struct AttachmentViewerRoute: Routable { + static let path = "/attachment-viewer" + + let method = HTTPMethod.GET + let description = "Attachment viewer route, delivers an HTML wrapper for custom viewers" + + private let attachmentViewers: [String: AttachmentViewerConfiguration] + + init(attachmentViewers: [String: AttachmentViewerConfiguration]) { + self.attachmentViewers = attachmentViewers + } + + func respond(to req: Request) throws -> Response { + os_log("Attachment viewer request received", log: .default, type: .info) + + guard !attachmentViewers.isEmpty, + let components = req.urlComponents(), + let queryItems = components.queryItems, + let viewerExtension = queryItems.first(where: { $0.name == "viewer" })?.value?.lowercased(), + let viewer = attachmentViewers[viewerExtension], + let resultIdentifier = queryItems.first(where: { $0.name == "result_id" })?.value, + let testSummaryIdentifier = queryItems.first(where: { $0.name == "test_id" })?.value, + let attachmentIdentifier = queryItems.first(where: { $0.name == "id" })?.value, + let filename = queryItems.first(where: { $0.name == "filename" })?.value, + let contentType = queryItems.first(where: { $0.name == "content_type" })?.value + else { + return Response(status: .notFound, body: Response.Body(stringLiteral: "Not found...")) + } + + guard AttachmentFileLocator.exportedFileUrl(resultIdentifier: resultIdentifier, + testSummaryIdentifier: testSummaryIdentifier, + attachmentIdentifier: attachmentIdentifier) != nil + else { + return Response(status: .notFound, body: Response.Body(stringLiteral: "Not found...")) + } + + let scriptSrc = AttachmentViewerScriptRoute.urlString(viewerExtension: viewer.fileExtension) + let attachmentUrl = AttachmentRoute.urlString(identifier: attachmentIdentifier, + resultIdentifier: resultIdentifier, + testSummaryIdentifier: testSummaryIdentifier, + filename: filename, + contentType: contentType) + + let attachmentTitle = queryItems.first(where: { $0.name == "title" })?.value ?? filename + let pageTitle = makeTitle(displayName: attachmentTitle) + let html = makeHtmlDocument(title: pageTitle, + scriptSrc: scriptSrc, + attachmentUrl: attachmentUrl) + + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "text/html; charset=utf-8") + headers.add(name: .cacheControl, value: "no-store") + + return Response(status: .ok, headers: headers, body: .init(string: html)) + } + + private func makeTitle(displayName: String) -> String { + let sanitizedName = displayName.removingPercentEncoding ?? displayName + if sanitizedName.isEmpty { + return "Cachi Attachment Viewer" + } + return "Cachi Attachment Viewer - \(sanitizedName)" + } + + private func makeHtmlDocument(title: String, + scriptSrc: String, + attachmentUrl: String) -> String { + let escapedTitle = title.replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + + return """ + + + + + + + \(escapedTitle) + + +
+ + + + + """ + } + + private func escapeHtmlAttribute(_ value: String) -> String { + value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } + + static func urlString(viewerExtension: String, + resultIdentifier: String, + testSummaryIdentifier: String, + attachmentIdentifier: String, + filename: String, + title: String, + contentType: String) -> String { + var components = URLComponents(string: path)! + components.queryItems = [ + .init(name: "viewer", value: viewerExtension.lowercased()), + .init(name: "result_id", value: resultIdentifier), + .init(name: "test_id", value: testSummaryIdentifier), + .init(name: "id", value: attachmentIdentifier), + .init(name: "filename", value: filename), + .init(name: "title", value: title), + .init(name: "content_type", value: contentType) + ] + + components.queryItems = components.queryItems?.filter { !($0.value?.isEmpty ?? true) } + + return components.url!.absoluteString + } +} diff --git a/Sources/Cachi/Server/Routes/AttachmentViewerScriptRoute.swift b/Sources/Cachi/Server/Routes/AttachmentViewerScriptRoute.swift new file mode 100644 index 0000000..d053d48 --- /dev/null +++ b/Sources/Cachi/Server/Routes/AttachmentViewerScriptRoute.swift @@ -0,0 +1,51 @@ +import Foundation +import os +import Vapor + +struct AttachmentViewerScriptRoute: Routable { + static let path = "/attachment-viewer/script" + + let method = HTTPMethod.GET + let description = "Attachment viewer script route, proxies JavaScript assets from disk" + + private let attachmentViewers: [String: AttachmentViewerConfiguration] + + init(attachmentViewers: [String: AttachmentViewerConfiguration]) { + self.attachmentViewers = attachmentViewers + } + + func respond(to req: Request) throws -> Response { + os_log("Attachment viewer script request received", log: .default, type: .info) + + guard !attachmentViewers.isEmpty, + let components = req.urlComponents(), + let viewerExtension = components.queryItems?.first(where: { $0.name == "viewer" })?.value?.lowercased(), + let viewer = attachmentViewers[viewerExtension] + else { + return Response(status: .notFound, body: Response.Body(stringLiteral: "Not found...")) + } + + do { + let data = try Data(contentsOf: viewer.scriptUrl, options: .mappedIfSafe) + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/javascript; charset=utf-8") + headers.add(name: .cacheControl, value: "no-store") + return Response(status: .ok, headers: headers, body: .init(data: data)) + } catch { + os_log("Failed to read attachment viewer script at %@: %@", log: .default, type: .error, viewer.scriptUrl.path, error.localizedDescription) + return Response(status: .internalServerError, body: Response.Body(stringLiteral: "Unable to load script")) + } + } + + static func urlString(viewerExtension: String) -> String { + var components = URLComponents(string: path)! + components.queryItems = [ + .init(name: "viewer", value: viewerExtension.lowercased()), + .init(name: "ts", value: String(Int(Date().timeIntervalSince1970))) // bypass browser cache + ] + + components.queryItems = components.queryItems?.filter { !($0.value?.isEmpty ?? true) } + + return components.url!.absoluteString + } +} diff --git a/Sources/Cachi/Server/Routes/CSSRoute.swift b/Sources/Cachi/Server/Routes/CSSRoute.swift index 3a14343..21c63b5 100644 --- a/Sources/Cachi/Server/Routes/CSSRoute.swift +++ b/Sources/Cachi/Server/Routes/CSSRoute.swift @@ -405,6 +405,10 @@ struct CSSRoute: Routable { position: sticky; top: 0; } + + .selected { + background: #e3f2fd; + } """ } } diff --git a/Sources/Cachi/Server/Routes/TestRouteHTML.swift b/Sources/Cachi/Server/Routes/TestRouteHTML.swift index dafb066..c53dc9c 100644 --- a/Sources/Cachi/Server/Routes/TestRouteHTML.swift +++ b/Sources/Cachi/Server/Routes/TestRouteHTML.swift @@ -10,6 +10,12 @@ struct TestRouteHTML: Routable { let method = HTTPMethod.GET let description: String = "Test details in html (pass identifier)" + private let attachmentViewers: [String: AttachmentViewerConfiguration] + + init(attachmentViewers: [String: AttachmentViewerConfiguration] = [:]) { + self.attachmentViewers = attachmentViewers + } + func respond(to req: Request) throws -> Response { os_log("HTML test stats request received", log: .default, type: .info) @@ -195,7 +201,10 @@ struct TestRouteHTML: Routable { } } } else { - return link(url: AttachmentRoute.urlString(identifier: attachment.identifier, resultIdentifier: result.identifier, testSummaryIdentifier: testSummaryIdentifier, filename: attachment.filename, contentType: attachment.contentType)) { + let destinationUrl = attachmentDestinationUrl(for: attachment, + resultIdentifier: result.identifier, + testSummaryIdentifier: testSummaryIdentifier) + return link(url: destinationUrl) { div { rowData.title }.class("color-subtext").inlineBlock() image(url: attachment.url) .iconStyleAttributes(width: attachment.width) @@ -287,4 +296,34 @@ struct TestRouteHTML: Routable { return components.url!.absoluteString } + + private func attachmentDestinationUrl(for attachment: TableRowModel.Attachment, + resultIdentifier: String, + testSummaryIdentifier: String) -> String { + guard !attachmentViewers.isEmpty, + let filenameExtension = attachment.filename.split(separator: ".").last?.lowercased() else { + return AttachmentRoute.urlString(identifier: attachment.identifier, + resultIdentifier: resultIdentifier, + testSummaryIdentifier: testSummaryIdentifier, + filename: attachment.filename, + contentType: attachment.contentType) + } + + let normalizedExtension = String(filenameExtension) + guard let viewer = attachmentViewers[normalizedExtension] else { + return AttachmentRoute.urlString(identifier: attachment.identifier, + resultIdentifier: resultIdentifier, + testSummaryIdentifier: testSummaryIdentifier, + filename: attachment.filename, + contentType: attachment.contentType) + } + + return AttachmentViewerRoute.urlString(viewerExtension: viewer.fileExtension, + resultIdentifier: resultIdentifier, + testSummaryIdentifier: testSummaryIdentifier, + attachmentIdentifier: attachment.identifier, + filename: attachment.filename, + title: attachment.title, + contentType: attachment.contentType) + } } diff --git a/Sources/Cachi/Server/Server.swift b/Sources/Cachi/Server/Server.swift index abe8f48..b734150 100644 --- a/Sources/Cachi/Server/Server.swift +++ b/Sources/Cachi/Server/Server.swift @@ -6,12 +6,16 @@ struct Server { private let port: Int private let hostname = "0.0.0.0" private let routes: [Routable] + private let attachmentViewers: [String: AttachmentViewerConfiguration] - init(port: Int, baseUrl: URL, parseDepth: Int, mergeResults: Bool) { + init(port: Int, baseUrl: URL, parseDepth: Int, mergeResults: Bool, attachmentViewers: [AttachmentViewerConfiguration]) { self.port = port + self.attachmentViewers = Dictionary(uniqueKeysWithValues: attachmentViewers.map { ($0.fileExtension, $0) }) var routes: [Routable] = [ AttachmentRoute(), + AttachmentViewerRoute(attachmentViewers: self.attachmentViewers), + AttachmentViewerScriptRoute(attachmentViewers: self.attachmentViewers), CoverageFileRouteHTML(), CoverageRoute(), CoverageRouteHTML(), @@ -30,7 +34,7 @@ struct Server { ResultsStatRouteHTML(), ScriptRoute(), TestRoute(), - TestRouteHTML(), + TestRouteHTML(attachmentViewers: self.attachmentViewers), TestSessionLogsRouteHTML(), TestStatRoute(), TestStatRouteHTML(baseUrl: baseUrl, depth: parseDepth), diff --git a/Tests/CachiTests/AttachmentViewerConfigurationTests.swift b/Tests/CachiTests/AttachmentViewerConfigurationTests.swift new file mode 100644 index 0000000..4bcc1f0 --- /dev/null +++ b/Tests/CachiTests/AttachmentViewerConfigurationTests.swift @@ -0,0 +1,40 @@ +import Cachi +import XCTest + +final class AttachmentViewerConfigurationTests: XCTestCase { + func testParsesValidArgument() throws { + let temporaryDirectory = FileManager.default.temporaryDirectory + let scriptUrl = temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("js") + try "console.log('ok');".write(to: scriptUrl, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: scriptUrl) } + + let configuration = try AttachmentViewerConfiguration(argumentValue: "json:\(scriptUrl.path)") + + XCTAssertEqual(configuration.fileExtension, "json") + XCTAssertEqual(configuration.scriptUrl, scriptUrl.standardizedFileURL) + } + + func testRejectsInvalidFormat() { + XCTAssertThrowsError(try AttachmentViewerConfiguration(argumentValue: "invalid")) { error in + guard case AttachmentViewerConfiguration.Error.invalidFormat = error else { + return XCTFail("Expected invalidFormat error, received: \(error)") + } + } + } + + func testRejectsMissingScript() { + let nonexistentPath = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("js").path + + XCTAssertThrowsError(try AttachmentViewerConfiguration(argumentValue: "png:\(nonexistentPath)")) { error in + guard case AttachmentViewerConfiguration.Error.scriptNotFound = error else { + return XCTFail("Expected scriptNotFound error, received: \(error)") + } + } + } + + static var allTests = [ + ("testParsesValidArgument", testParsesValidArgument), + ("testRejectsInvalidFormat", testRejectsInvalidFormat), + ("testRejectsMissingScript", testRejectsMissingScript) + ] +} diff --git a/Tests/CachiTests/CachiBrowserTests.swift b/Tests/CachiTests/CachiBrowserTests.swift index 68c05e1..3c349fc 100644 --- a/Tests/CachiTests/CachiBrowserTests.swift +++ b/Tests/CachiTests/CachiBrowserTests.swift @@ -3,6 +3,8 @@ import XCTest final class CachiBrowserTests: XCTestCase { func testExample() throws { + try XCTSkipIf(true, "Placeholder test is not applicable") + // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct // results. diff --git a/Tests/CachiTests/XCTestManifests.swift b/Tests/CachiTests/XCTestManifests.swift index 7acce31..b3a1ce8 100644 --- a/Tests/CachiTests/XCTestManifests.swift +++ b/Tests/CachiTests/XCTestManifests.swift @@ -3,7 +3,8 @@ import XCTest #if !canImport(ObjectiveC) public func allTests() -> [XCTestCaseEntry] { [ - testCase(CachiBrowserTests.allTests) + testCase(CachiBrowserTests.allTests), + testCase(AttachmentViewerConfigurationTests.allTests) ] } #endif