Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cachi Attachment Viewer - Example</title>
</head>
<body>
<noscript>This app requires JavaScript.</noscript>
<script>
(function () {
var s = document.createElement('script');
s.src = '/attachment-viewer/script?viewer=json&attachment_filename=data.json';
s.attachmentPath = 'resultId/attachmentHash';
s.onload = function(){};
document.body.appendChild(s);
})();
</script>
</body>
</html>
```

- 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`
Expand Down
68 changes: 68 additions & 0 deletions Sources/Cachi/AttachmentViewerConfiguration.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
34 changes: 32 additions & 2 deletions Sources/Cachi/Commands/RootCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>(name: "path", kind: .positional, optional: false, help: "Path to location containing .xcresult bundles (will search recursively)")
let parseDepthArgument = Argument<Int>(name: "level", kind: .named(short: "d", long: "search_depth"), optional: true, help: "Location path traversing depth (Default: 2)")
let port = Argument<Int>(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 // 🤷‍♂️
Expand All @@ -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
Expand All @@ -32,12 +43,31 @@ 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 {
print("Failed listening on port \(port.value!).\n\n\(error)")
}
return true
}

private static func parseAttachmentViewerArguments(_ values: [String]) throws -> [AttachmentViewerConfiguration] {
var configurations = [AttachmentViewerConfiguration]()
var seenExtensions = Set<String>()

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
}
}
3 changes: 1 addition & 2 deletions Sources/Cachi/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,7 @@ struct ResultBundle: Codable {
endDate == nil,
sourceBasePath == nil,
githubBaseUrl == nil,
xcresultPathToFailedTestName == nil
{
xcresultPathToFailedTestName == nil {
throw Error.empty
}
}
Expand Down
27 changes: 27 additions & 0 deletions Sources/Cachi/Server/AttachmentFileLocator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import CachiKit
import Foundation

enum 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
}
}
18 changes: 4 additions & 14 deletions Sources/Cachi/Server/Routes/AttachmentRoute.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import CachiKit
import Foundation
import os
import Vapor
Expand Down Expand Up @@ -26,23 +25,14 @@ 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)
Expand Down
Loading