diff --git a/Services/Website/.dockerignore b/Services/Website/.dockerignore index 5842fb7..1f3233b 100644 --- a/Services/Website/.dockerignore +++ b/Services/Website/.dockerignore @@ -1,5 +1,6 @@ .build .swiftpm +.DS_Store .env.local docker-compose.* Makefile diff --git a/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme new file mode 100644 index 0000000..01a4a46 --- /dev/null +++ b/Services/Website/.swiftpm/xcode/xcshareddata/xcschemes/Website.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index a08d2fb..e3f71bf 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -41,9 +41,9 @@ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ # Copy resources bundled by SPM to staging area RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; -# Copy any resouces from the public directory and views directory if the directories exist +# Copy the static files directory (served by FileMiddleware) if it exists # Ensure that by default, neither the directory nor any of its contents are writable. -RUN [ -d /build/public ] && { mv /build/public ./public && chmod -R a-w ./public; } || true +RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true # ================================ # Run image diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index a6d6bf0..e44f6bb 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -36,6 +36,7 @@ let package = Package( .executableTarget( name: "Website", dependencies: [ + .byName(name: "WebsiteCore"), .product( name: "Configuration", package: "swift-configuration" @@ -49,17 +50,22 @@ let package = Package( ), .target( name: "WebsiteCore", - dependencies: [], + dependencies: [ + .product( + name: "Configuration", + package: "swift-configuration" + ), + ], path: "Sources/Library" ), .testTarget( name: "WebsiteTests", dependencies: [ + .byName(name: "Website"), .product( name: "HummingbirdTesting", package: "hummingbird" ), - .byName(name: "Website"), ], path: "Tests/App" ), diff --git a/Services/Website/Resources/Static/404.html b/Services/Website/Resources/Static/404.html new file mode 100644 index 0000000..260cc4c --- /dev/null +++ b/Services/Website/Resources/Static/404.html @@ -0,0 +1,62 @@ + + + + + + Page Not Found + + + + + +

Page Not Found

+

Sorry, but the page you were trying to view does not exist.

+ + + + diff --git a/Services/Website/Resources/Static/css/style.css b/Services/Website/Resources/Static/css/style.css new file mode 100644 index 0000000..ba80dc8 --- /dev/null +++ b/Services/Website/Resources/Static/css/style.css @@ -0,0 +1,246 @@ +/*! HTML5 Boilerplate v9.0.1 | MIT License | https://html5boilerplate.com/ */ + +/* main.css 3.0.0 | MIT License | https://github.com/h5bp/main.css#readme */ +/* + * What follows is the result of much research on cross-browser styling. + * Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal, + * Kroc Camen, and the H5BP dev community and team. + */ + +/* ========================================================================== + Base styles: opinionated defaults + ========================================================================== */ + +html { + color: #222; + font-size: 1em; + line-height: 1.4; +} + +/* + * Remove text-shadow in selection highlight: + * https://twitter.com/miketaylr/status/12228805301 + * + * Customize the background color to match your design. + */ + +::-moz-selection { + background: #b3d4fc; + text-shadow: none; +} + +::selection { + background: #b3d4fc; + text-shadow: none; +} + +/* + * A better looking default horizontal rule + */ + +hr { + display: block; + height: 1px; + border: 0; + border-top: 1px solid #ccc; + margin: 1em 0; + padding: 0; +} + +/* + * Remove the gap between audio, canvas, iframes, + * images, videos and the bottom of their containers: + * https://github.com/h5bp/html5-boilerplate/issues/440 + */ + +audio, +canvas, +iframe, +img, +svg, +video { + vertical-align: middle; +} + +/* + * Remove default fieldset styles. + */ + +fieldset { + border: 0; + margin: 0; + padding: 0; +} + +/* + * Allow only vertical resizing of textareas. + */ + +textarea { + resize: vertical; +} + +/* ========================================================================== + Author's custom styles + ========================================================================== */ + +/* ========================================================================== + Helper classes + ========================================================================== */ + +/* + * Hide visually and from screen readers + */ + +.hidden, +[hidden] { + display: none !important; +} + +/* + * Hide only visually, but have it available for screen readers: + * https://snook.ca/archives/html_and_css/hiding-content-for-accessibility + * + * 1. For long content, line feeds are not interpreted as spaces and small width + * causes content to wrap 1 word per line: + * https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe + */ + +.visually-hidden { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + /* 1 */ +} + +/* + * Extends the .visually-hidden class to allow the element + * to be focusable when navigated to via the keyboard: + * https://www.drupal.org/node/897638 + */ + +.visually-hidden.focusable:active, +.visually-hidden.focusable:focus { + clip: auto; + height: auto; + margin: 0; + overflow: visible; + position: static; + white-space: inherit; + width: auto; +} + +/* + * Hide visually and from screen readers, but maintain layout + */ + +.invisible { + visibility: hidden; +} + +/* + * Clearfix: contain floats + * + * The use of `table` rather than `block` is only necessary if using + * `::before` to contain the top-margins of child elements. + */ + +.clearfix::before, +.clearfix::after { + content: ""; + display: table; +} + +.clearfix::after { + clear: both; +} + +/* ========================================================================== + EXAMPLE Media Queries for Responsive Design. + These examples override the primary ('mobile first') styles. + Modify as content requires. + ========================================================================== */ + +@media only screen and (min-width: 35em) { + /* Style adjustments for viewports that meet the condition */ +} + +@media print, + (min-resolution: 1.25dppx), + (min-resolution: 120dpi) { + /* Style adjustments for high resolution devices */ +} + +/* ========================================================================== + Print styles. + Inlined to avoid the additional HTTP request: + https://www.phpied.com/delay-loading-your-print-css/ + ========================================================================== */ + +@media print { + *, + *::before, + *::after { + background: #fff !important; + color: #000 !important; + /* Black prints faster */ + box-shadow: none !important; + text-shadow: none !important; + } + + a, + a:visited { + text-decoration: underline; + } + + a[href]::after { + content: " (" attr(href) ")"; + } + + abbr[title]::after { + content: " (" attr(title) ")"; + } + + /* + * Don't show links that are fragment identifiers, + * or use the `javascript:` pseudo protocol + */ + a[href^="#"]::after, + a[href^="javascript:"]::after { + content: ""; + } + + pre { + white-space: pre-wrap !important; + } + + pre, + blockquote { + border: 1px solid #999; + page-break-inside: avoid; + } + + tr, + img { + page-break-inside: avoid; + } + + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + + h2, + h3 { + page-break-after: avoid; + } +} + diff --git a/Services/Website/Resources/Static/favicon.ico b/Services/Website/Resources/Static/favicon.ico new file mode 100644 index 0000000..be74abd Binary files /dev/null and b/Services/Website/Resources/Static/favicon.ico differ diff --git a/Services/Website/Resources/Static/icon.png b/Services/Website/Resources/Static/icon.png new file mode 100644 index 0000000..8a42581 Binary files /dev/null and b/Services/Website/Resources/Static/icon.png differ diff --git a/Services/Website/Resources/Static/icon.svg b/Services/Website/Resources/Static/icon.svg new file mode 100644 index 0000000..f232922 --- /dev/null +++ b/Services/Website/Resources/Static/icon.svg @@ -0,0 +1 @@ + diff --git a/Services/Website/Resources/Static/index.html b/Services/Website/Resources/Static/index.html new file mode 100644 index 0000000..f3add49 --- /dev/null +++ b/Services/Website/Resources/Static/index.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Hello world! This is HTML5 Boilerplate.

+ + + + + diff --git a/Services/Website/Resources/Static/js/app.js b/Services/Website/Resources/Static/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/Services/Website/Resources/Static/robots.txt b/Services/Website/Resources/Static/robots.txt new file mode 100644 index 0000000..51d2d2e --- /dev/null +++ b/Services/Website/Resources/Static/robots.txt @@ -0,0 +1,5 @@ +# https://www.robotstxt.org/ + +# Allow crawling of all content +User-agent: * +Disallow: diff --git a/Services/Website/Resources/Static/site.webmanifest b/Services/Website/Resources/Static/site.webmanifest new file mode 100644 index 0000000..222ae16 --- /dev/null +++ b/Services/Website/Resources/Static/site.webmanifest @@ -0,0 +1,12 @@ +{ + "short_name": "", + "name": "", + "icons": [{ + "src": "icon.png", + "type": "image/png", + "sizes": "192x192" + }], + "start_url": "/?utm_source=homescreen", + "background_color": "#fafafa", + "theme_color": "#fafafa" +} diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index d5df224..3561f8b 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -1,24 +1,37 @@ import Configuration import Hummingbird import Logging +import WebsiteCore -/// Build application -/// - Parameter reader: configuration reader +/// Builds the website application. +/// +/// Reads the log level, server name, and static files location from the configuration, +/// then assembles the router, server configuration, and logger. +/// - Parameter reader: the configuration reader the values are read from. +/// - Returns: the configured application, ready to run as a service. +/// - Throws: an error if the router fails to build. func application( reader: ConfigReader ) async throws -> some ApplicationProtocol { let logLevel = reader.string( - forKey: "log.level", + forKey: .Log.level, as: Logger.Level.self, default: .info ) let serverName = reader.string( - forKey: "http.serverName", - default: "LoudWebsite" + forKey: .HTTP.serverName, + default: .Server.name + ) + let staticFilesPath = reader.string( + forKey: .Path.staticFiles, + default: .Path.staticResources ) return Application( - router: try router(), + router: router( + staticFilesPath: staticFilesPath, + logLevel: logLevel + ), configuration: ApplicationConfiguration( reader: reader.scoped(to: "http") ), @@ -34,7 +47,11 @@ func application( // Request context used by application private typealias AppRequestContext = BasicRequestContext -/// Build logger +/// Builds the application's logger. +/// - Parameters: +/// - serverName: the label applied to the logger. +/// - logLevel: the minimum level the logger emits. +/// - Returns: the configured logger. private func logger( serverName: String, logLevel: Logger.Level @@ -46,16 +63,26 @@ private func logger( return logger } -/// Build router -private func router() throws -> Router { +/// Builds the application's router. +/// +/// Registers the request-logging middleware and the static file middleware that serves +/// the contents of `staticFilesPath`. +/// - Parameters: +/// - staticFilesPath: the folder, relative to the working directory, the static files are served from. +/// - logLevel: the level the request-logging middleware logs at. +/// - Returns: the configured router. +private func router( + staticFilesPath: String, + logLevel: Logger.Level +) -> Router { let router = Router(context: AppRequestContext.self) router.addMiddleware { - LogRequestsMiddleware(.info) - } - - router.get("/") { _, _ in - return "Hello!" + LogRequestsMiddleware(logLevel) + FileMiddleware( + staticFilesPath, + searchForIndexHtml: false + ) } return router diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift index c46a698..335916c 100644 --- a/Services/Website/Sources/App/App.swift +++ b/Services/Website/Sources/App/App.swift @@ -14,7 +14,7 @@ struct App { allowMissing: true ), InMemoryProvider(values: [ - "http.serverName": "LoudWebsite" + .HTTP.serverName: .HTTP.serverName ]), ] ) diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift new file mode 100644 index 0000000..656f369 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -0,0 +1,166 @@ +import Foundation + +/// A static file shipped with the website service. +/// +/// Each case identifies a file stored under the static files root (the `Resources/Static` +/// directory) and served by Hummingbird's `FileMiddleware` middleware. +enum StaticFile: CaseIterable, Sendable { + /// The `js/app.js` script. + case appJS + /// The `404.html` error page. + case errorHTML + /// The `favicon.ico` icon. + case faviconICO + /// The `icon.png` icon. + case iconPNG + /// The `icon.svg` icon. + case iconSVG + /// The `index.html` landing page. + case indexHTML + /// The `robots.txt` crawler directives. + case robotsTXT + /// The `site.webmanifest` web application manifest. + case siteWebmanifest + /// The `css/style.css` stylesheet. + case styleCSS +} + +// MARK: - Enumerations + +extension StaticFile { + /// A file extension used by a ``StaticFile``. + enum Extension: String, Sendable { + /// A Cascading Style Sheets file. + case css + /// A HyperText Markup Language file. + case html + /// A JavaScript file. + case js + /// A Portable Network Graphics image. + case png + /// A Windows icon image. + case ico + /// A Scalable Vector Graphics image. + case svg + /// A plain text file. + case txt + /// A web application manifest file. + case webmanifest + } +} + +// MARK: - Extensions + +extension StaticFile { + + // MARK: Computed + + /// The file's content type. + var contentType: String { + switch fileExtension { + case .css: "text/css" + case .html: "text/html" + case .js: "text/javascript" + case .png: "image/png" + case .ico: "image/vnd.microsoft.icon" + case .svg: "image/svg+xml" + case .txt: "text/plain" + case .webmanifest: "application/manifest+json" + } + } + + /// The file's extension. + var fileExtension: Extension { + switch self { + case .styleCSS: .css + case .appJS: .js + case .errorHTML, + .indexHTML: .html + case .faviconICO: .ico + case .iconPNG: .png + case .iconSVG: .svg + case .robotsTXT: .txt + case .siteWebmanifest: .webmanifest + } + } + + /// The file's name, without extension. + var fileName: String { + switch self { + case .appJS: "app" + case .errorHTML: "404" + case .faviconICO: "favicon" + case .iconPNG, + .iconSVG: "icon" + case .indexHTML: "index" + case .robotsTXT: "robots" + case .siteWebmanifest: "site" + case .styleCSS: "style" + } + } + + /// The path relative to the static files root (e.g. `"css/style.css"`). + /// + /// This also matches the URL path the file is served at by `FileMiddleware`. + var relativePath: String { + let file = String( + format: Constant.Format.file, + fileName, + fileExtension.rawValue + ) + + return subdirectory + .map { .init( + format: Constant.Format.path, + $0, + file + )} ?? file + } + + // MARK: Methods + + /// Resolves the file's path against the given base directory. + /// + /// - Parameter basePath: the directory the static files are served from. + /// - Returns: the path to the file, relative to the `basePath` path. + public func path( + relativeTo basePath: String + ) -> String { + guard !basePath.isEmpty else { + return relativePath + } + + return .init( + format: Constant.Format.path, + basePath, + relativePath + ) + } + +} + +// MARK: - Helpers + +private extension StaticFile { + + // MARK: Computed + + /// The sub-directory within the static root that holds the file, if any. + var subdirectory: String? { + switch self { + case .appJS: "js" + case .styleCSS: "css" + default: nil + } + } + +} + +// MARK: - Constants + +private enum Constant { + enum Format { + static let file = "%@.%@" + static let path = "%@/%@" + } +} diff --git a/Services/Website/Sources/Library/Library.swift b/Services/Website/Sources/Library/Library.swift deleted file mode 100644 index 139597f..0000000 --- a/Services/Website/Sources/Library/Library.swift +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift new file mode 100644 index 0000000..94a8cbf --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -0,0 +1,23 @@ +import Configuration + +extension AbsoluteConfigKey { + /// A namespace for the HTTP server configuration keys, as absolute keys. + public enum HTTP { + /// The absolute configuration key for the host the server binds to. + public static let host: AbsoluteConfigKey = .init(.HTTP.host) + /// The absolute configuration key for the port the server listens on. + public static let port: AbsoluteConfigKey = .init(.HTTP.port) + /// The absolute configuration key for the server's name. + public static let serverName: AbsoluteConfigKey = .init(.HTTP.serverName) + } + /// A namespace for the logging configuration keys, as absolute keys. + public enum Log { + /// The absolute configuration key for the minimum log level. + public static let level: AbsoluteConfigKey = .init(.Log.level) + } + /// A namespace for the path configuration keys, as absolute keys. + public enum Path { + /// The absolute configuration key for the directory the static files are served from. + public static let staticFiles: AbsoluteConfigKey = .init(.Path.staticFiles) + } +} diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift new file mode 100644 index 0000000..55c6f7d --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -0,0 +1,23 @@ +import Configuration + +extension ConfigKey { + /// A namespace for the HTTP server configuration keys. + public enum HTTP { + /// The configuration key for the host the server binds to. + public static let host: ConfigKey = "http.host" + /// The configuration key for the port the server listens on. + public static let port: ConfigKey = "http.port" + /// The configuration key for the server's name. + public static let serverName: ConfigKey = "http.serverName" + } + /// A namespace for the logging configuration keys. + public enum Log { + /// The configuration key for the minimum log level. + public static let level: ConfigKey = "log.level" + } + /// A namespace for the path configuration keys. + public enum Path { + /// The configuration key for the directory the static files are served from. + public static let staticFiles: ConfigKey = "path.staticFiles" + } +} diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift new file mode 100644 index 0000000..58973bf --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigValue+Constants.swift @@ -0,0 +1,18 @@ +import Configuration + +extension ConfigValue { + /// A namespace for the HTTP server's default configuration values. + public enum HTTP { + /// The default host the server binds to. + public static let host: ConfigValue = .init(stringLiteral: "127.0.0.1") + /// The default port the server listens on. + public static let port: ConfigValue = .init(stringLiteral: "0") + /// The default server name. + public static let serverName: ConfigValue = .init(stringLiteral: .Server.name) + } + /// A namespace for the logging default configuration values. + enum Log { + /// The default minimum log level. + public static let level: ConfigValue = .init(stringLiteral: "trace") + } +} diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift new file mode 100644 index 0000000..22a1e0f --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift @@ -0,0 +1,12 @@ +extension String { + /// A namespace for well-known path string constants. + public enum Path { + /// The directory, relative to the working directory, that the website's static files are served from. + public static let staticResources = "Resources/Static" + } + /// A namespace for the server string constants. + public enum Server { + /// The website server's name. + public static let name = "LoudWebsite" + } +} diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 379de9f..c784d94 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -1,34 +1,69 @@ import Configuration +import Foundation import Hummingbird import HummingbirdTesting import Logging import Testing @testable import Website +@testable import WebsiteCore -private let reader = ConfigReader(providers: [ - InMemoryProvider(values: [ - "http.host": "127.0.0.1", - "http.port": "0", - "log.level": "trace", - ]) -]) - -@Suite +@Suite("App executable") struct AppTests { - @Test - func hello() async throws { + + // MARK: Constants + + // Absolute path to the package's "Resources/Static" folder, derived from this + // file's location so the static files resolve regardless of the working directory. + private let staticFilesPath = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/App + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // package root + .appendingPathComponent(.Path.staticResources) + .path + + // MARK: Functional tests + + @Test(arguments: StaticFile.allCases) + func `static files to be served`( + staticFile file: StaticFile + ) async throws { let app = try await application( - reader: reader + reader: reader( + staticFilesPath: staticFilesPath + ) ) try await app.test(.router) { client in try await client.execute( - uri: "/", + uri: "/\(file.relativePath)", method: .get ) { response in - #expect(response.body == ByteBuffer(string: "Hello!")) + #expect(response.status == .ok) + #expect(response.headers[.contentType] == file.contentType) } } } + +} + +// MARK: - Helpers + +private extension AppTests { + + // MARK: Methods + + func reader( + staticFilesPath: String + ) -> ConfigReader { + ConfigReader(providers: [ + InMemoryProvider(values: [ + .HTTP.host: .HTTP.host, + .HTTP.port: .HTTP.port, + .Log.level: .Log.level, + .Path.staticFiles: .init(stringLiteral: staticFilesPath), + ]) + ]) + } + } diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift new file mode 100644 index 0000000..1ac3872 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing + +@testable import WebsiteCore + +@Suite("StaticFile enumeration") +struct StaticFileTests { + + // MARK: Type aliases + + typealias File = StaticFile + typealias FileExtension = StaticFile.Extension + + // MARK: Computed tests + + @Test(arguments: zip( + File.allCases, + Self.contentTypes + )) + func `content type`( + for file: File, + expects contentType: String + ) { + #expect(file.contentType == contentType) + } + + @Test(arguments: zip( + File.allCases, + Self.fileExtensions + )) + func `file extension`( + for file: File, + expects `extension`: FileExtension + ) { + #expect(file.fileExtension == `extension`) + } + + @Test(arguments: zip( + File.allCases, + Self.fileNames + )) + func `file name`( + for file: File, + expects fileName: String + ) { + #expect(file.fileName == fileName) + } + + @Test(arguments: zip( + File.allCases, + Self.relativePaths + )) + func `relative path`( + for file: File, + expects relativePath: String + ) { + #expect(file.relativePath == relativePath) + } + + // MARK: Method tests + + @Test(arguments: [ + "", + ".", + "Resources/Static" + ]) + func `path relative to`( + _ basePath: String + ) { + for file in File.allCases { + let pathRelativeToBasePath = file.path(relativeTo: basePath) + + if basePath.isEmpty { + #expect(pathRelativeToBasePath == file.relativePath) + } else { + #expect(pathRelativeToBasePath == "\(basePath)/\(file.relativePath)") + } + } + } + + // MARK: CaseIterable tests + + @Test + func `all cases`() { + #expect(File.allCases.count == 9) + } + +} + +// MARK: - Helpers + +private extension StaticFileTests { + + // MARK: Constants + + static let contentTypes: [String] = [ + "text/javascript", + "text/html", + "image/vnd.microsoft.icon", + "image/png", + "image/svg+xml", + "text/html", + "text/plain", + "application/manifest+json", + "text/css" + ] + static let fileExtensions: [FileExtension] = [ + .js, + .html, + .ico, + .png, + .svg, + .html, + .txt, + .webmanifest, + .css + ] + static let fileNames: [String] = [ + "app", + "404", + "favicon", + "icon", + "icon", + "index", + "robots", + "site", + "style" + ] + static let relativePaths: [String] = [ + "js/app.js", + "404.html", + "favicon.ico", + "icon.png", + "icon.svg", + "index.html", + "robots.txt", + "site.webmanifest", + "css/style.css" + ] + +} diff --git a/Services/Website/Tests/Library/LibraryTest.swift b/Services/Website/Tests/Library/LibraryTest.swift deleted file mode 100644 index 8b13789..0000000 --- a/Services/Website/Tests/Library/LibraryTest.swift +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Services/Website/Website.xctestplan b/Services/Website/Website.xctestplan new file mode 100644 index 0000000..5156232 --- /dev/null +++ b/Services/Website/Website.xctestplan @@ -0,0 +1,37 @@ +{ + "configurations" : [ + { + "id" : "F3231E68-357B-4ED7-98E9-F5767AB17024", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "performanceAntipatternCheckerEnabled" : true, + "targetForVariableExpansion" : { + "containerPath" : "container:", + "identifier" : "Website", + "name" : "Website" + }, + "testInteropMode" : "complete" + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "WebsiteTests", + "name" : "WebsiteTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "WebsiteCoreTests", + "name" : "WebsiteCoreTests" + } + } + ], + "version" : 1 +}