Add unit test for dev server (#466)
* make CartonCore module and move Environment * refactor DevCommandTests.checkForExpectedContent * add DevServerTestApp Fixture * add FrontendDevServerTests
This commit is contained in:
parent
078f1edf58
commit
eba0fdb76c
|
@ -64,7 +64,10 @@ let package = Package(
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
dependencies: ["carton-frontend"],
|
dependencies: ["carton-frontend"],
|
||||||
exclude: ["CartonPluginShared/README.md"]
|
exclude: [
|
||||||
|
"CartonCore/README.md",
|
||||||
|
"CartonPluginShared/README.md"
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.plugin(
|
.plugin(
|
||||||
name: "CartonTestPlugin",
|
name: "CartonTestPlugin",
|
||||||
|
@ -75,7 +78,10 @@ let package = Package(
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
dependencies: ["carton-frontend"],
|
dependencies: ["carton-frontend"],
|
||||||
exclude: ["CartonPluginShared/README.md"]
|
exclude: [
|
||||||
|
"CartonCore/README.md",
|
||||||
|
"CartonPluginShared/README.md"
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.plugin(
|
.plugin(
|
||||||
name: "CartonDevPlugin",
|
name: "CartonDevPlugin",
|
||||||
|
@ -86,7 +92,10 @@ let package = Package(
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
dependencies: ["carton-frontend"],
|
dependencies: ["carton-frontend"],
|
||||||
exclude: ["CartonPluginShared/README.md"]
|
exclude: [
|
||||||
|
"CartonCore/README.md",
|
||||||
|
"CartonPluginShared/README.md"
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.executableTarget(name: "carton-plugin-helper"),
|
.executableTarget(name: "carton-plugin-helper"),
|
||||||
.target(
|
.target(
|
||||||
|
@ -131,10 +140,15 @@ let package = Package(
|
||||||
name: "CartonHelpers",
|
name: "CartonHelpers",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"TSCclibc",
|
"TSCclibc",
|
||||||
"TSCLibc"
|
"TSCLibc",
|
||||||
|
"CartonCore"
|
||||||
],
|
],
|
||||||
exclude: ["Basics/README.md"]
|
exclude: ["Basics/README.md"]
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "CartonCore",
|
||||||
|
exclude: ["README.md"]
|
||||||
|
),
|
||||||
.target(name: "WebDriverClient", dependencies: []),
|
.target(name: "WebDriverClient", dependencies: []),
|
||||||
// This target is used only for release automation tasks and
|
// This target is used only for release automation tasks and
|
||||||
// should not be installed by `carton` users.
|
// should not be installed by `carton` users.
|
||||||
|
@ -158,6 +172,7 @@ let package = Package(
|
||||||
name: "CartonCommandTests",
|
name: "CartonCommandTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"CartonFrontend",
|
"CartonFrontend",
|
||||||
|
"SwiftToolchain",
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
../../Sources/CartonCore
|
|
@ -0,0 +1 @@
|
||||||
|
../../Sources/CartonCore
|
|
@ -0,0 +1 @@
|
||||||
|
../../Sources/CartonCore
|
|
@ -14,12 +14,16 @@
|
||||||
|
|
||||||
/// The target environment to build for.
|
/// The target environment to build for.
|
||||||
/// `Environment` doesn't specify the concrete environment, but the type of environments enough for build planning.
|
/// `Environment` doesn't specify the concrete environment, but the type of environments enough for build planning.
|
||||||
internal enum Environment: String, CaseIterable {
|
public enum Environment: String, CaseIterable {
|
||||||
|
public static var allCasesNames: [String] {
|
||||||
|
allCases.map(\.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
case command
|
case command
|
||||||
case node
|
case node
|
||||||
case browser
|
case browser
|
||||||
|
|
||||||
static func parse(_ string: String) -> (Environment?, diagnostics: String?) {
|
public static func parse(_ string: String) -> (Environment?, diagnostics: String?) {
|
||||||
// Find from canonical names
|
// Find from canonical names
|
||||||
if let found = allCases.first(where: { $0.rawValue == string }) {
|
if let found = allCases.first(where: { $0.rawValue == string }) {
|
||||||
return (found, nil)
|
return (found, nil)
|
||||||
|
@ -36,12 +40,24 @@ internal enum Environment: String, CaseIterable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Parameters {
|
public struct Parameters {
|
||||||
var otherSwiftcFlags: [String] = []
|
public init(otherSwiftcFlags: [String] = [], otherLinkerFlags: [String] = []) {
|
||||||
var otherLinkerFlags: [String] = []
|
self.otherSwiftcFlags = otherSwiftcFlags
|
||||||
|
self.otherLinkerFlags = otherLinkerFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
public var otherSwiftcFlags: [String] = []
|
||||||
|
public var otherLinkerFlags: [String] = []
|
||||||
|
|
||||||
|
public func asBuildArguments() -> [String] {
|
||||||
|
var args: [String] = []
|
||||||
|
args += otherSwiftcFlags.flatMap { ["-Xswiftc", $0] }
|
||||||
|
args += otherLinkerFlags.flatMap { ["-Xlinker", $0] }
|
||||||
|
return args
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyBuildParameters(_ parameters: inout Parameters) {
|
public func applyBuildParameters(_ parameters: inout Parameters) {
|
||||||
// NOTE: We only support static linking for now, and the new SwiftDriver
|
// NOTE: We only support static linking for now, and the new SwiftDriver
|
||||||
// does not infer `-static-stdlib` for WebAssembly targets intentionally
|
// does not infer `-static-stdlib` for WebAssembly targets intentionally
|
||||||
// for future dynamic linking support.
|
// for future dynamic linking support.
|
||||||
|
@ -59,4 +75,10 @@ internal enum Environment: String, CaseIterable {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func buildParameters() -> Parameters {
|
||||||
|
var p = Parameters()
|
||||||
|
applyBuildParameters(&p)
|
||||||
|
return p
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
Place the source files to be used by the driver, plugin, and frontend in this directory.
|
||||||
|
To reduce the build time of the plugin, please include only the minimum necessary content.
|
|
@ -15,16 +15,7 @@
|
||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CartonHelpers
|
import CartonHelpers
|
||||||
import CartonKit
|
import CartonKit
|
||||||
|
import CartonCore
|
||||||
/// The target environment to build for.
|
|
||||||
/// `Environment` doesn't specify the concrete environment, but the type of environments enough for build planning.
|
|
||||||
enum Environment: String, CaseIterable, ExpressibleByArgument {
|
|
||||||
public static var allCasesNames: [String] { Environment.allCases.map { $0.rawValue } }
|
|
||||||
|
|
||||||
case command
|
|
||||||
case node
|
|
||||||
case browser
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SanitizeVariant: String, CaseIterable, ExpressibleByArgument {
|
enum SanitizeVariant: String, CaseIterable, ExpressibleByArgument {
|
||||||
case stackOverflow
|
case stackOverflow
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
import ArgumentParser
|
||||||
|
import CartonCore
|
||||||
|
|
||||||
|
extension Environment: ExpressibleByArgument {}
|
|
@ -16,6 +16,10 @@ import ArgumentParser
|
||||||
import XCTest
|
import XCTest
|
||||||
import CartonHelpers
|
import CartonHelpers
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
struct CommandTestError: Swift.Error & CustomStringConvertible {
|
struct CommandTestError: Swift.Error & CustomStringConvertible {
|
||||||
init(_ description: String) {
|
init(_ description: String) {
|
||||||
self.description = description
|
self.description = description
|
||||||
|
@ -24,6 +28,22 @@ struct CommandTestError: Swift.Error & CustomStringConvertible {
|
||||||
var description: String
|
var description: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Optional {
|
||||||
|
func unwrap(_ name: String) throws -> Wrapped {
|
||||||
|
guard let self else {
|
||||||
|
throw CommandTestError("\(name) is none")
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Duration {
|
||||||
|
var asTimeInterval: TimeInterval {
|
||||||
|
let (sec, atto) = components
|
||||||
|
return TimeInterval(sec) + TimeInterval(atto) / 1e18
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func findExecutable(name: String) throws -> AbsolutePath {
|
func findExecutable(name: String) throws -> AbsolutePath {
|
||||||
let whichBin = "/usr/bin/which"
|
let whichBin = "/usr/bin/which"
|
||||||
let process = Process()
|
let process = Process()
|
||||||
|
@ -107,3 +127,39 @@ func swiftRun(_ arguments: [String], packageDirectory: URL) async throws
|
||||||
result.setOutput(.success(process.output()))
|
result.setOutput(.success(process.output()))
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchWebContent(at url: URL, timeout: Duration) async throws -> (response: HTTPURLResponse, body: Data) {
|
||||||
|
let session = URLSession.shared
|
||||||
|
|
||||||
|
let request = URLRequest(
|
||||||
|
url: url, cachePolicy: .reloadIgnoringCacheData,
|
||||||
|
timeoutInterval: timeout.asTimeInterval
|
||||||
|
)
|
||||||
|
|
||||||
|
let (body, response) = try await session.data(for: request)
|
||||||
|
|
||||||
|
guard let response = response as? HTTPURLResponse else {
|
||||||
|
throw CommandTestError("Response from \(url.absoluteString) is not HTTPURLResponse")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response: response, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withRetry<R>(maxAttempts: Int, delay: Duration, body: () async throws -> R) async throws -> R {
|
||||||
|
var attempt = 0
|
||||||
|
while true {
|
||||||
|
try await Task.sleep(for: delay)
|
||||||
|
|
||||||
|
attempt += 1
|
||||||
|
do {
|
||||||
|
return try await body()
|
||||||
|
} catch {
|
||||||
|
if attempt < maxAttempts {
|
||||||
|
print("attempt \(attempt) failed: \(error), retrying...")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -25,8 +25,6 @@ import XCTest
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
final class DevCommandTests: XCTestCase {
|
final class DevCommandTests: XCTestCase {
|
||||||
private var client: URLSession?
|
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
func testWithNoArguments() async throws {
|
func testWithNoArguments() async throws {
|
||||||
// FIXME: Don't assume a specific port is available since it can be used by others or tests
|
// FIXME: Don't assume a specific port is available since it can be used by others or tests
|
||||||
|
@ -36,7 +34,7 @@ final class DevCommandTests: XCTestCase {
|
||||||
packageDirectory: packageDirectory.url
|
packageDirectory: packageDirectory.url
|
||||||
)
|
)
|
||||||
|
|
||||||
await checkForExpectedContent(process: process, at: "http://127.0.0.1:8080")
|
try await checkForExpectedContent(process: process, at: "http://127.0.0.1:8080")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,23 +46,44 @@ final class DevCommandTests: XCTestCase {
|
||||||
packageDirectory: packageDirectory.url
|
packageDirectory: packageDirectory.url
|
||||||
)
|
)
|
||||||
|
|
||||||
await checkForExpectedContent(process: process, at: "http://127.0.0.1:8081")
|
try await checkForExpectedContent(process: process, at: "http://127.0.0.1:8081")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
func checkForExpectedContent(process: SwiftRunProcess, at url: String) async {
|
private func fetchDevServerWithRetry(at url: URL) async throws -> (response: HTTPURLResponse, body: Data) {
|
||||||
// client time out for connecting and responding
|
// client time out for connecting and responding
|
||||||
let timeOut: Int64 = 60
|
let timeOut: Duration = .seconds(60)
|
||||||
|
|
||||||
// client delay... let the server start up
|
// client delay... let the server start up
|
||||||
let delay: UInt32 = 30
|
let delay: Duration = .seconds(30)
|
||||||
|
|
||||||
// only try 5 times.
|
// only try 5 times.
|
||||||
let polls = 5
|
let count = 5
|
||||||
|
|
||||||
let expectedHtml =
|
do {
|
||||||
"""
|
return try await withRetry(maxAttempts: count, delay: delay) {
|
||||||
|
try await fetchWebContent(at: url, timeout: timeOut)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw CommandTestError(
|
||||||
|
"Could not reach server.\n" +
|
||||||
|
"No response from server after \(count) tries or \(count * Int(delay.components.seconds)) seconds.\n" +
|
||||||
|
"Last error: \(error)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkForExpectedContent(process: SwiftRunProcess, at url: String) async throws {
|
||||||
|
defer {
|
||||||
|
// end the process regardless of success
|
||||||
|
process.process.signal(SIGTERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
let (response, data) = try await fetchDevServerWithRetry(at: try URL(string: url).unwrap("url"))
|
||||||
|
XCTAssertEqual(response.statusCode, 200, "Response was not ok")
|
||||||
|
|
||||||
|
let expectedHtml = """
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
@ -77,53 +96,11 @@ final class DevCommandTests: XCTestCase {
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
client = .shared
|
guard let actualHtml = String(data: data, encoding: .utf8) else {
|
||||||
|
throw CommandTestError("Could not decode as UTF-8 string")
|
||||||
var response: HTTPURLResponse?
|
|
||||||
var responseBody: Data?
|
|
||||||
var count = 0
|
|
||||||
|
|
||||||
// give the server some time to start
|
|
||||||
repeat {
|
|
||||||
sleep(delay)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
guard
|
|
||||||
let (body, urlResponse) = try? await client?.data(
|
|
||||||
for: URLRequest(
|
|
||||||
url: URL(string: url)!,
|
|
||||||
cachePolicy: .reloadIgnoringCacheData,
|
|
||||||
timeoutInterval: TimeInterval(timeOut)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
response = urlResponse as? HTTPURLResponse
|
|
||||||
responseBody = body
|
|
||||||
} while count < polls && response == nil
|
|
||||||
|
|
||||||
// end the process regardless of success
|
|
||||||
process.process.signal(SIGTERM)
|
|
||||||
|
|
||||||
if let response = response {
|
|
||||||
XCTAssertTrue(response.statusCode == 200, "Response was not ok")
|
|
||||||
|
|
||||||
guard let data = responseBody else {
|
|
||||||
XCTFail("Could not map data")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let actualHtml = String(data: data, encoding: .utf8) else {
|
|
||||||
XCTFail("Could not convert data to string")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// test may be brittle as the template may change over time.
|
|
||||||
XCTAssertEqual(actualHtml, expectedHtml, "HTML output does not match")
|
|
||||||
|
|
||||||
} else {
|
|
||||||
print("no response from server after \(count) tries or \(Int(count) * Int(delay)) seconds")
|
|
||||||
XCTFail("Could not reach server")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// test may be brittle as the template may change over time.
|
||||||
|
XCTAssertEqual(actualHtml, expectedHtml, "HTML output does not match")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,107 @@
|
||||||
|
import XCTest
|
||||||
|
import CartonCore
|
||||||
|
import CartonHelpers
|
||||||
|
import CartonKit
|
||||||
|
import SwiftToolchain
|
||||||
|
|
||||||
|
final class FrontendDevServerTests: XCTestCase {
|
||||||
|
func testDevServerPublish() async throws {
|
||||||
|
let fs = localFileSystem
|
||||||
|
let terminal = InteractiveWriter.stdout
|
||||||
|
let projectDir = try testFixturesDirectory.appending(component: "DevServerTestApp")
|
||||||
|
let buildDir = projectDir.appending(components: [".build", "wasm32-unknown-wasi", "debug"])
|
||||||
|
let wasmFile = buildDir.appending(component: "app.wasm")
|
||||||
|
let resourcesDir = buildDir.appending(component: "DevServerTestApp_app.resources")
|
||||||
|
|
||||||
|
try fs.changeCurrentWorkingDirectory(to: projectDir)
|
||||||
|
|
||||||
|
if !fs.exists(wasmFile) {
|
||||||
|
let tools = try ToolchainSystem(fileSystem: fs)
|
||||||
|
let (builderSwift, _) = try await tools.inferSwiftPath(terminal)
|
||||||
|
|
||||||
|
var args: [String] = [
|
||||||
|
builderSwift.pathString, "build", "--triple", "wasm32-unknown-wasi"
|
||||||
|
]
|
||||||
|
args += Environment.browser.buildParameters().asBuildArguments()
|
||||||
|
|
||||||
|
try await Process.run(args, terminal)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Process.run(["swift", "build", "--target", "carton-frontend"], terminal)
|
||||||
|
|
||||||
|
let devServer = Process(
|
||||||
|
arguments: [
|
||||||
|
"swift", "run", "carton-frontend", "dev",
|
||||||
|
"--skip-auto-open", "--verbose",
|
||||||
|
"--main-wasm-path", wasmFile.pathString,
|
||||||
|
"--resources", resourcesDir.pathString
|
||||||
|
]
|
||||||
|
)
|
||||||
|
try devServer.launch()
|
||||||
|
defer {
|
||||||
|
devServer.signal(SIGINT)
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .seconds(3))
|
||||||
|
|
||||||
|
let host = try URL(string: "http://127.0.0.1:8080").unwrap("url")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let indexHtml = try await fetchString(at: host)
|
||||||
|
XCTAssertEqual(indexHtml, """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script type="module" src="dev.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let devJs = try await fetchString(at: host.appendingPathComponent("dev.js"))
|
||||||
|
let expected = try XCTUnwrap(String(data: StaticResource.dev, encoding: .utf8))
|
||||||
|
XCTAssertEqual(devJs, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let mainWasm = try await fetchBinary(at: host.appendingPathComponent("main.wasm"))
|
||||||
|
let expected = try Data(contentsOf: wasmFile.asURL)
|
||||||
|
XCTAssertEqual(mainWasm, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let name = "style.css"
|
||||||
|
let styleCss = try await fetchString(at: host.appendingPathComponent(name))
|
||||||
|
let expected = try String(contentsOf: resourcesDir.appending(component: name).asURL)
|
||||||
|
XCTAssertEqual(styleCss, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchBinary(
|
||||||
|
at url: URL,
|
||||||
|
file: StaticString = #file, line: UInt = #line
|
||||||
|
) async throws -> Data {
|
||||||
|
let (response, body) = try await fetchWebContent(at: url, timeout: .seconds(10))
|
||||||
|
XCTAssertEqual(response.statusCode, 200, file: file, line: line)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchString(
|
||||||
|
at url: URL,
|
||||||
|
file: StaticString = #file, line: UInt = #line
|
||||||
|
) async throws -> String? {
|
||||||
|
let data = try await fetchBinary(at: url)
|
||||||
|
|
||||||
|
guard let string = String(data: data, encoding: .utf8) else {
|
||||||
|
XCTFail("not UTF-8 string content", file: file, line: line)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm
|
||||||
|
.netrc
|
|
@ -0,0 +1,21 @@
|
||||||
|
// swift-tools-version: 5.9
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "DevServerTestApp",
|
||||||
|
products: [
|
||||||
|
.executable(name: "app", targets: ["app"])
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: "../../..")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "app",
|
||||||
|
resources: [
|
||||||
|
.copy("style.css")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1 @@
|
||||||
|
print("hello dev server")
|
|
@ -0,0 +1,4 @@
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
Loading…
Reference in New Issue