diff --git a/Sources/App/Controllers/CustomCollectionsController.swift b/Sources/App/Controllers/CustomCollectionsController.swift new file mode 100644 index 000000000..91e1fc0ca --- /dev/null +++ b/Sources/App/Controllers/CustomCollectionsController.swift @@ -0,0 +1,81 @@ +// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Fluent +import Plot +import Vapor + + +enum CustomCollectionsController { + + static func query(on database: Database, name: String, page: Int, pageSize: Int) async throws -> Page> { + try await Joined3 + .query(on: database, version: .defaultBranch) + .join(CustomCollectionPackage.self, on: \Package.$id == \CustomCollectionPackage.$package.$id) + .join(CustomCollection.self, on: \CustomCollection.$id == \CustomCollectionPackage.$customCollection.$id) + .field(Repository.self, \.$name) + .field(Repository.self, \.$owner) + .field(Repository.self, \.$lastActivityAt) + .field(Repository.self, \.$stars) + .field(Repository.self, \.$summary) + .field(Version.self, \.$packageName) + .filter(CustomCollection.self, \.$name == name) + .sort(Repository.self, \.$name) + .page(page, size: pageSize) + } + + struct Query: Codable { + var page: Int + var pageSize: Int + + static let defaultPage = 1 + static let defaultPageSize = 20 + + enum CodingKeys: CodingKey { + case page + case pageSize + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.page = try container.decodeIfPresent(Int.self, forKey: CodingKeys.page) ?? Self.defaultPage + self.pageSize = try container.decodeIfPresent(Int.self, forKey: CodingKeys.pageSize) ?? Self.defaultPageSize + } + } + + @Sendable + static func show(req: Request) async throws -> HTML { + guard let name = req.parameters.get("name") else { + throw Abort(.notFound) + } + let query = try req.query.decode(Query.self) + let page = try await Self.query(on: req.db, name: name, page: query.page, pageSize: query.pageSize) + + guard !page.results.isEmpty else { + throw Abort(.notFound) + } + + let packageInfo = page.results.compactMap(PackageInfo.init(package:)) + + let model = CustomCollectionShow.Model( + name: name, + packages: packageInfo, + page: query.page, + hasMoreResults: page.hasMoreResults + ) + + return CustomCollectionShow.View(path: req.url.path, model: model).document() + } + +} diff --git a/Sources/App/Controllers/KeywordController.swift b/Sources/App/Controllers/KeywordController.swift index 31944143e..df5b31c91 100644 --- a/Sources/App/Controllers/KeywordController.swift +++ b/Sources/App/Controllers/KeywordController.swift @@ -22,6 +22,12 @@ enum KeywordController { static func query(on database: Database, keyword: String, page: Int, pageSize: Int) async throws -> Page> { try await Joined3 .query(on: database, version: .defaultBranch) + .field(Repository.self, \.$name) + .field(Repository.self, \.$owner) + .field(Repository.self, \.$lastActivityAt) + .field(Repository.self, \.$stars) + .field(Repository.self, \.$summary) + .field(Version.self, \.$packageName) .filter(Repository.self, \.$keywords, .custom("@>"), [keyword]) .sort(\.$score, .descending) .sort(Repository.self, \.$name) diff --git a/Sources/App/Controllers/PackageCollectionController.swift b/Sources/App/Controllers/PackageCollectionController.swift index 63f832382..171f4e584 100644 --- a/Sources/App/Controllers/PackageCollectionController.swift +++ b/Sources/App/Controllers/PackageCollectionController.swift @@ -20,16 +20,40 @@ enum PackageCollectionController { static func generate(req: Request) async throws -> SignedCollection { AppMetrics.packageCollectionGetTotal?.inc() - guard let owner = req.parameters.get("owner") else { throw Abort(.notFound) } + guard let collectionType = getCollectionType(req: req) else { + throw Abort(.notFound) + } do { - return try await SignedCollection.generate( - db: req.db, - filterBy: .author(owner), - authorName: "\(owner) via the Swift Package Index" - ) + switch collectionType { + case let .author(owner): + return try await SignedCollection.generate( + db: req.db, + filterBy: .author(owner), + authorName: "\(owner) via the Swift Package Index" + ) + case let .custom(name): + return try await SignedCollection.generate( + db: req.db, + filterBy: .customCollection(name), + authorName: "Swift Package Index", + collectionName: name, + overview: "A custom package collection generated by the Swift Package Index" + ) + } } catch PackageCollection.Error.noResults { throw Abort(.notFound) } } + + enum CollectionType { + case author(String) + case custom(String) + } + + static func getCollectionType(req: Request) -> CollectionType? { + if let owner = req.parameters.get("owner") { return .author(owner) } + if let name = req.parameters.get("name") { return .custom(name) } + return nil + } } diff --git a/Sources/App/Core/Extensions/String+ext.swift b/Sources/App/Core/Extensions/String+ext.swift index 6003d57d6..54ba7f954 100644 --- a/Sources/App/Core/Extensions/String+ext.swift +++ b/Sources/App/Core/Extensions/String+ext.swift @@ -58,6 +58,10 @@ extension String { var pathEncoded: Self { replacingOccurrences(of: "/", with: "-") } + + var urlPathEncoded: Self { + addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? self + } } diff --git a/Sources/App/Core/PackageCollection+VersionResult.swift b/Sources/App/Core/PackageCollection+VersionResult.swift index 0f77a1add..ac3c69e9d 100644 --- a/Sources/App/Core/PackageCollection+VersionResult.swift +++ b/Sources/App/Core/PackageCollection+VersionResult.swift @@ -55,6 +55,11 @@ extension PackageCollection.VersionResult { switch filter { case let .author(owner): query.filter(Repository.self, \Repository.$owner, .custom("ilike"), owner) + case let .customCollection(name): + query + .join(CustomCollectionPackage.self, on: \Package.$id == \CustomCollectionPackage.$package.$id) + .join(CustomCollection.self, on: \CustomCollection.$id == \CustomCollectionPackage.$customCollection.$id) + .filter(CustomCollection.self, \.$name == name) case let .urls(packageURLs): query.filter(App.Package.self, \.$url ~~ packageURLs) } diff --git a/Sources/App/Core/PackageCollection+generate.swift b/Sources/App/Core/PackageCollection+generate.swift index a26d1a45c..c5333cc9c 100644 --- a/Sources/App/Core/PackageCollection+generate.swift +++ b/Sources/App/Core/PackageCollection+generate.swift @@ -34,6 +34,7 @@ extension PackageCollection { enum Filter { case urls([String]) case author(String) + case customCollection(String) } enum Error: Swift.Error { @@ -97,10 +98,14 @@ extension PackageCollection { return owner case (.author, .some(let label)): return label - case (.urls, .some(let label)): + case (.customCollection(let name), .none): + return name + case (.customCollection, .some(let label)): return label case (.urls(let urls), .none): return author(for: urls) + case (.urls, .some(let label)): + return label } } diff --git a/Sources/App/Core/SiteURL.swift b/Sources/App/Core/SiteURL.swift index 7a80bf335..e71a5a46a 100644 --- a/Sources/App/Core/SiteURL.swift +++ b/Sources/App/Core/SiteURL.swift @@ -113,6 +113,7 @@ enum SiteURL: Resourceable, Sendable { case blogPost(_ slug: Parameter) case buildMonitor case builds(_ id: Parameter) + case collections(_ name: Parameter) case docs(Docs) case faq case home @@ -120,7 +121,8 @@ enum SiteURL: Resourceable, Sendable { case javascripts(String) case keywords(_ keyword: Parameter) case package(_ owner: Parameter, _ repository: Parameter, PackagePathComponents?) - case packageCollection(_ owner: Parameter) + case packageCollectionAuthor(_ owner: Parameter) + case packageCollectionCustom(_ name: Parameter) case packageCollections case privacy case readyForSwift6 @@ -169,6 +171,12 @@ enum SiteURL: Resourceable, Sendable { case .buildMonitor: return "build-monitor" + case let .collections(.value(name)): + return "collections/\(name.urlPathEncoded)" + + case .collections(.key): + fatalError("path must not be called with a name parameter") + case let .docs(next): return "docs/\(next.path)" @@ -201,10 +209,16 @@ enum SiteURL: Resourceable, Sendable { case .package: fatalError("invalid path: \(self)") - case let .packageCollection(.value(owner)): + case let .packageCollectionAuthor(.value(owner)): return "\(owner)/collection.json" - case .packageCollection(.key): + case .packageCollectionAuthor(.key): + fatalError("invalid path: \(self)") + + case let .packageCollectionCustom(.value(name)): + return "collections/\(name.urlPathEncoded)/collection.json" + + case .packageCollectionCustom(.key): fatalError("invalid path: \(self)") case .packageCollections: @@ -283,6 +297,12 @@ enum SiteURL: Resourceable, Sendable { case .builds(.value): fatalError("pathComponents must not be called with a value parameter") + case .collections(.key): + return ["collections", ":name"] + + case .collections(.value): + fatalError("pathComponents must not be called with a value parameter") + case let .docs(next): return ["docs"] + next.pathComponents @@ -298,10 +318,16 @@ enum SiteURL: Resourceable, Sendable { case .package: fatalError("pathComponents must not be called with a value parameter") - case .packageCollection(.key): + case .packageCollectionAuthor(.key): return [":owner", "collection.json"] - case .packageCollection(.value): + case .packageCollectionAuthor(.value): + fatalError("pathComponents must not be called with a value parameter") + + case .packageCollectionCustom(.key): + return ["collections", ":name", "collection.json"] + + case .packageCollectionCustom(.value): fatalError("pathComponents must not be called with a value parameter") case .images, .javascripts, .stylesheets: diff --git a/Sources/App/Views/Author/AuthorShow+View.swift b/Sources/App/Views/Author/AuthorShow+View.swift index 8f62c0061..bc8a6bd52 100644 --- a/Sources/App/Views/Author/AuthorShow+View.swift +++ b/Sources/App/Views/Author/AuthorShow+View.swift @@ -59,7 +59,7 @@ enum AuthorShow { ), .copyableInputForm(buttonName: "Copy Package Collection URL", eventName: "Copy Package Collection URL Button", - valueToCopy: SiteURL.packageCollection(.value(model.owner)).absoluteURL()), + valueToCopy: SiteURL.packageCollectionAuthor(.value(model.owner)).absoluteURL()), .hr(.class("minor")), .ul( .id("package-list"), diff --git a/Sources/App/Views/CustomCollection/CustomCollectionShow+Model.swift b/Sources/App/Views/CustomCollection/CustomCollectionShow+Model.swift new file mode 100644 index 000000000..a4334ea20 --- /dev/null +++ b/Sources/App/Views/CustomCollection/CustomCollectionShow+Model.swift @@ -0,0 +1,32 @@ +// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +extension CustomCollectionShow { + struct Model { + var name: String + var packages: [PackageInfo] + var page: Int + var hasMoreResults: Bool + + internal init(name: String, + packages: [PackageInfo], + page: Int, + hasMoreResults: Bool) { + self.name = name + self.packages = packages + self.page = page + self.hasMoreResults = hasMoreResults + } + } +} diff --git a/Sources/App/Views/CustomCollection/CustomCollectionShow+View.swift b/Sources/App/Views/CustomCollection/CustomCollectionShow+View.swift new file mode 100644 index 000000000..bbe6f09ee --- /dev/null +++ b/Sources/App/Views/CustomCollection/CustomCollectionShow+View.swift @@ -0,0 +1,114 @@ +// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Plot + + +enum CustomCollectionShow { + + class View: PublicPage { + + let model: Model + + init(path: String, model: Model) { + self.model = model + super.init(path: path) + } + + override func pageTitle() -> String? { + "Packages for collection \(model.name)" + } + + override func pageDescription() -> String? { + let packagesClause = model.packages.count > 1 ? "\(model.packages.count) packages" : "1 package" + return "The Swift Package Index is indexing \(packagesClause) for collection \(model.name)." + } + + override func breadcrumbs() -> [Breadcrumb] { + [ + Breadcrumb(title: "Home", url: SiteURL.home.relativeURL()), + Breadcrumb(title: model.name) + ] + } + + override func content() -> Node { + .group( + .h2( + .class("trimmed"), + .text("Packages for collection “\(model.name)”") + ), + .p( + .text("These packages are available as a package collection, "), + .a( + .href(SiteURL.packageCollections.relativeURL()), + "usable in Xcode 13 or the Swift Package Manager 5.5" + ), + .text(".") + ), + .copyableInputForm(buttonName: "Copy Package Collection URL", + eventName: "Copy Package Collection URL Button", + valueToCopy: SiteURL.packageCollectionCustom(.value(model.name)).absoluteURL()), + .hr(.class("minor")), + .ul( + .id("package-list"), + .group( + model.packages.map { .packageListItem(linkUrl: $0.url, packageName: $0.title, summary: $0.description, repositoryOwner: $0.repositoryOwner, repositoryName: $0.repositoryName, stars: $0.stars, lastActivityAt: $0.lastActivityAt, hasDocs: $0.hasDocs ?? false) } + ) + ), + .if(model.page == 1 && !model.hasMoreResults, + .p( + .strong("\(model.packages.count) \("package".pluralized(for: model.packages.count)).") + ) + ), + .ul( + .class("pagination"), + .if(model.page > 1, .previousPage(model: model)), + .if(model.hasMoreResults, .nextPage(model: model)) + ) + ) + } + } + +} + + +fileprivate extension Node where Context == HTML.ListContext { + static func previousPage(model: CustomCollectionShow.Model) -> Node { + let parameters = [ + QueryParameter(key: "page", value: model.page - 1) + ] + return .li( + .class("previous"), + .a( + .href(SiteURL.collections(.value(model.name)) + .relativeURL(parameters: parameters)), + "Previous Page" + ) + ) + } + + static func nextPage(model: CustomCollectionShow.Model) -> Node { + let parameters = [ + QueryParameter(key: "page", value: model.page + 1) + ] + return .li( + .class("next"), + .a( + .href(SiteURL.collections(.value(model.name)) + .relativeURL(parameters: parameters)), + "Next Page" + ) + ) + } +} diff --git a/Sources/App/Views/PackageController/GetRoute.Model+ext.swift b/Sources/App/Views/PackageController/GetRoute.Model+ext.swift index c574fc40e..ea59e2b17 100644 --- a/Sources/App/Views/PackageController/GetRoute.Model+ext.swift +++ b/Sources/App/Views/PackageController/GetRoute.Model+ext.swift @@ -404,7 +404,7 @@ extension API.PackageController.GetRoute.Model { .class("custom-collections"), .forEach(customCollections, { collection in .a( - .href(collection.url), // FIXME: link to custom collection page + .href(SiteURL.collections(.value(collection.name)).relativeURL()), .text("\(collection.name)") ) }) diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 22358d0df..6790f50f9 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -84,8 +84,10 @@ func routes(_ app: Application) throws { } } - do { // package collection page - app.get(SiteURL.packageCollection(.key).pathComponents, + do { // package collection pages + app.get(SiteURL.packageCollectionAuthor(.key).pathComponents, + use: PackageCollectionController.generate).excludeFromOpenAPI() + app.get(SiteURL.packageCollectionCustom(.key).pathComponents, use: PackageCollectionController.generate).excludeFromOpenAPI() } @@ -107,11 +109,15 @@ func routes(_ app: Application) throws { app.get(SiteURL.buildMonitor.pathComponents, use: BuildMonitorController.index).excludeFromOpenAPI() } - do { // build details page + do { // Build details page app.get(SiteURL.builds(.key).pathComponents, use: BuildController.show).excludeFromOpenAPI() } - do { // search page + do { // Custom collections page + app.get(SiteURL.collections(.key).pathComponents, use: CustomCollectionsController.show).excludeFromOpenAPI() + } + + do { // Search page app.get(SiteURL.search.pathComponents, use: SearchController.show).excludeFromOpenAPI() } diff --git a/Tests/AppTests/ApiTests.swift b/Tests/AppTests/ApiTests.swift index 4a66efc7c..1afd75e0a 100644 --- a/Tests/AppTests/ApiTests.swift +++ b/Tests/AppTests/ApiTests.swift @@ -809,7 +809,6 @@ class ApiTests: AppTestCase { try await v.save(on: app.db) try await Product(version: v, type: .library(.automatic), name: "lib") .save(on: app.db) - try await Search.refresh(on: app.db) let event = App.ActorIsolated(nil) Current.postPlausibleEvent = { @Sendable _, kind, path, _ in @@ -897,7 +896,6 @@ class ApiTests: AppTestCase { try await Product(version: v, type: .library(.automatic), name: "p2") .save(on: app.db) } - try await Search.refresh(on: app.db) do { // MUT let body: ByteBuffer = .init(string: """ diff --git a/Tests/AppTests/KeywordControllerTests.swift b/Tests/AppTests/KeywordControllerTests.swift index a7fa5a014..04986eadd 100644 --- a/Tests/AppTests/KeywordControllerTests.swift +++ b/Tests/AppTests/KeywordControllerTests.swift @@ -54,7 +54,7 @@ class KeywordControllerTests: AppTestCase { pageSize: 10) // validation - XCTAssertEqual(page.results.map(\.model.url), ["1"]) + XCTAssertEqual(page.results.map(\.repository.name), ["1"]) XCTAssertEqual(page.hasMoreResults, false) } @@ -76,7 +76,7 @@ class KeywordControllerTests: AppTestCase { page: 1, pageSize: 3) // validate - XCTAssertEqual(page.results.map(\.model.url), ["0", "1", "2"]) + XCTAssertEqual(page.results.map(\.repository.name), ["0", "1", "2"]) XCTAssertEqual(page.hasMoreResults, true) } do { // second page @@ -86,7 +86,7 @@ class KeywordControllerTests: AppTestCase { page: 2, pageSize: 3) // validate - XCTAssertEqual(page.results.map(\.model.url), ["3", "4", "5"]) + XCTAssertEqual(page.results.map(\.repository.name), ["3", "4", "5"]) XCTAssertEqual(page.hasMoreResults, true) } do { // last page @@ -96,7 +96,7 @@ class KeywordControllerTests: AppTestCase { page: 3, pageSize: 3) // validate - XCTAssertEqual(page.results.map(\.model.url), ["6", "7", "8"]) + XCTAssertEqual(page.results.map(\.repository.name), ["6", "7", "8"]) XCTAssertEqual(page.hasMoreResults, false) } } diff --git a/Tests/AppTests/Mocks/CustomCollectionShow+mock.swift b/Tests/AppTests/Mocks/CustomCollectionShow+mock.swift new file mode 100644 index 000000000..c42c7a9ae --- /dev/null +++ b/Tests/AppTests/Mocks/CustomCollectionShow+mock.swift @@ -0,0 +1,33 @@ +// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import App + +import Foundation + + +extension CustomCollectionShow.Model { + static var mock: Self { + let packages = (1...5).map { PackageInfo( + title: "Package \($0)", + description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas nec orci scelerisque, interdum purus a, tempus turpis.", + repositoryOwner: "owner", + repositoryName: "name", + url: "https://example.com/owner/name.git", + stars: 4, + lastActivityAt: .t0 + ) } + return .init(name: "Some Collection", packages: packages, page: 1, hasMoreResults: false) + } +} diff --git a/Tests/AppTests/PackageCollectionControllerTests.swift b/Tests/AppTests/PackageCollectionControllerTests.swift index 6760ca89f..09f31fb13 100644 --- a/Tests/AppTests/PackageCollectionControllerTests.swift +++ b/Tests/AppTests/PackageCollectionControllerTests.swift @@ -74,6 +74,62 @@ class PackageCollectionControllerTests: AppTestCase { } } + func test_custom_request() async throws { + try XCTSkipIf(!isRunningInCI && Current.collectionSigningPrivateKey() == nil, "Skip test for local user due to unset COLLECTION_SIGNING_PRIVATE_KEY env variable") + try await withDependencies { + $0.date.now = .t0 + } operation: { + let p = try await savePackage(on: app.db, "https://github.com/foo/1") + do { + let v = try Version(id: UUID(), + package: p, + packageName: "P1-main", + reference: .branch("main"), + toolsVersion: "5.0") + try await v.save(on: app.db) + try await Product(version: v, type: .library(.automatic), name: "P1Lib") + .save(on: app.db) + } + do { + let v = try Version(id: UUID(), + package: p, + latest: .release, + packageName: "P1-tag", + reference: .tag(1, 2, 3), + toolsVersion: "5.1") + try await v.save(on: app.db) + try await Product(version: v, type: .library(.automatic), name: "P1Lib", targets: ["t1"]) + .save(on: app.db) + try await Build(version: v, + platform: .iOS, + status: .ok, + swiftVersion: .init(5, 6, 0)).save(on: app.db) + try await Target(version: v, name: "t1").save(on: app.db) + } + try await Repository(package: p, + defaultBranch: "main", + license: .mit, + licenseUrl: "https://foo/mit", + owner: "foo", + summary: "summary 1").create(on: app.db) + let collection = CustomCollection(id: .id2, .init(name: "Custom Collection", url: "https://github.com/foo/bar/list.json")) + try await collection.save(on: app.db) + try await collection.$packages.attach(p, on: app.db) + + // MUT + let encoder = self.encoder + try await app.test( + .GET, + "collections/Custom%20Collection/collection.json", + afterResponse: { @MainActor res async throws in + // validation + XCTAssertEqual(res.status, .ok) + let json = try res.content.decode(PackageCollection.self) + assertSnapshot(of: json, as: .json(encoder)) + }) + } + } + func test_nonexisting_404() throws { // Ensure a request for a non-existing collection returns a 404 // MUT diff --git a/Tests/AppTests/PackageCollectionTests.swift b/Tests/AppTests/PackageCollectionTests.swift index 562d4353f..31f789940 100644 --- a/Tests/AppTests/PackageCollectionTests.swift +++ b/Tests/AppTests/PackageCollectionTests.swift @@ -168,6 +168,46 @@ class PackageCollectionTests: AppTestCase { ["package 0", "package 1"]) } + func test_query_custom() async throws { + // Tests PackageResult.query with the custom collection filter option + // setup + let packages = try await (0..<3).mapAsync { index in + let pkg = try await savePackage(on: app.db, "url-\(index)".url) + do { + let v = try Version(package: pkg, + latest: .release, + packageName: "package \(index)", + reference: .tag(1, 2, 3), + toolsVersion: "5.4") + try await v.save(on: app.db) + try await Build(version: v, + buildCommand: "build \(index)", + platform: .iOS, + status: .ok, + swiftVersion: .v1) + .save(on: app.db) + try await Product(version: v, type: .library(.automatic), name: "product \(index)") + .save(on: app.db) + try await Target(version: v, name: "target \(index)") + .save(on: app.db) + } + try await Repository(package: pkg, name: "repo \(index)", owner: "owner") + .save(on: app.db) + return pkg + } + let collection = CustomCollection(id: .id2, .init(name: "List", url: "https://github.com/foo/bar/list.json")) + try await collection.save(on: app.db) + try await collection.$packages.attach([packages[0], packages[1]], on: app.db) + + // MUT + let res = try await VersionResult.query(on: self.app.db, + filterBy: .customCollection("List")) + + // validate selection (relationship loading is tested in test_query_filter_urls) + XCTAssertEqual(res.map(\.version.packageName), + ["package 0", "package 1"]) + } + func test_Version_init() async throws { // Tests PackageCollection.Version initialisation from App.Version // setup diff --git a/Tests/AppTests/SiteURLTests.swift b/Tests/AppTests/SiteURLTests.swift index 8285d7f77..3f43ffadb 100644 --- a/Tests/AppTests/SiteURLTests.swift +++ b/Tests/AppTests/SiteURLTests.swift @@ -140,9 +140,9 @@ class SiteURLTests: XCTestCase { } func test_packageCollectionURL() throws { - XCTAssertEqual(SiteURL.packageCollection(.value("foo")).path, + XCTAssertEqual(SiteURL.packageCollectionAuthor(.value("foo")).path, "foo/collection.json") - XCTAssertEqual(SiteURL.packageCollection(.key).pathComponents + XCTAssertEqual(SiteURL.packageCollectionAuthor(.key).pathComponents .map(\.description), [":owner", "collection.json"]) } @@ -168,4 +168,9 @@ class SiteURLTests: XCTestCase { XCTAssertEqual(SiteURL.keywords(.key).pathComponents.map(\.description), ["keywords", ":keyword"]) } + func test_collections() throws { + XCTAssertEqual(SiteURL.collections(.value("foo")).path, "collections/foo") + XCTAssertEqual(SiteURL.collections(.key).pathComponents.map(\.description), ["collections", ":name"]) + } + } diff --git a/Tests/AppTests/WebpageSnapshotTests.swift b/Tests/AppTests/WebpageSnapshotTests.swift index 56355c2f6..d62524f3b 100644 --- a/Tests/AppTests/WebpageSnapshotTests.swift +++ b/Tests/AppTests/WebpageSnapshotTests.swift @@ -452,6 +452,12 @@ class WebpageSnapshotTests: SnapshotTestCase { assertSnapshot(of: page, as: .html) } + func test_CustomCollectionShow() throws { + let page = { CustomCollectionShow.View(path: "", model: .mock).document() } + + assertSnapshot(of: page, as: .html) + } + func test_DocCTemplate() throws { let doccTemplatePath = fixturesDirectory().appendingPathComponent("docc-template.html").path let doccHtml = try String(contentsOfFile: doccTemplatePath) diff --git a/Tests/AppTests/__Snapshots__/PackageCollectionControllerTests/test_custom_request.1.json b/Tests/AppTests/__Snapshots__/PackageCollectionControllerTests/test_custom_request.1.json new file mode 100644 index 000000000..4f9332cc3 --- /dev/null +++ b/Tests/AppTests/__Snapshots__/PackageCollectionControllerTests/test_custom_request.1.json @@ -0,0 +1,71 @@ +{ + "formatVersion" : "1.0", + "generatedAt" : "1970-01-01T00:00:00Z", + "generatedBy" : { + "name" : "Swift Package Index" + }, + "name" : "Custom Collection", + "overview" : "A custom package collection generated by the Swift Package Index", + "packages" : [ + { + "license" : { + "name" : "MIT", + "url" : "https:\/\/foo\/mit" + }, + "summary" : "summary 1", + "url" : "https:\/\/github.com\/foo\/1", + "versions" : [ + { + "defaultToolsVersion" : "5.1", + "license" : { + "name" : "MIT", + "url" : "https:\/\/foo\/mit" + }, + "manifests" : { + "5.1" : { + "minimumPlatformVersions" : [ + + ], + "packageName" : "P1-tag", + "products" : [ + { + "name" : "P1Lib", + "targets" : [ + "t1" + ], + "type" : { + "library" : [ + "automatic" + ] + } + } + ], + "targets" : [ + { + "moduleName" : "t1", + "name" : "t1" + } + ], + "toolsVersion" : "5.1" + } + }, + "signer" : { + "commonName" : "Swift Package Index", + "organizationName" : "Swift Package Index", + "organizationalUnitName" : "Swift Package Index", + "type" : "ADP" + }, + "verifiedCompatibility" : [ + { + "platform" : { + "name" : "ios" + }, + "swiftVersion" : "5.6" + } + ], + "version" : "1.2.3" + } + ] + } + ] +} \ No newline at end of file diff --git a/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/test_CustomCollectionShow.1.html b/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/test_CustomCollectionShow.1.html new file mode 100644 index 000000000..fc407c80d --- /dev/null +++ b/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/test_CustomCollectionShow.1.html @@ -0,0 +1,224 @@ + + + + + + + + + + Packages for collection Some Collection – Swift Package Index + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

+ The Swift Package Index logo.Swift Package Index +

+
+ +
+
+

Track the adoption of Swift 6 strict concurrency checks for data race safety. How many packages are + Ready for Swift 6? +

+ +
+ +
+ + + + \ No newline at end of file diff --git a/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/test_PackageShowView_customCollection.1.html b/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/test_PackageShowView_customCollection.1.html index f0e0834b6..a988d08a4 100644 --- a/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/test_PackageShowView_customCollection.1.html +++ b/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/test_PackageShowView_customCollection.1.html @@ -169,7 +169,7 @@

When working with a Swift Package Manager manifest:

  • No plugins
  • 2 macros
  • - Custom Collection + Custom Collection