Inherit all env-var from host and add `--env` option to `carton-test` (#495)

This change introduces a new feature in the carton-test command, allowing users
to pass environment variables to the test process via a new `--env` option.
Additionally, it ensures that all environment variables from the host system are
inherited by the guest test process.
This commit is contained in:
Yuta Saito 2024-08-26 21:32:48 +09:00 committed by GitHub
parent 348d9f471e
commit 1b2abfd880
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 152 additions and 22 deletions

View File

@ -16,6 +16,7 @@ import ArgumentParser
import CartonHelpers
import CartonKit
import CartonCore
import Foundation
enum SanitizeVariant: String, CaseIterable, ExpressibleByArgument {
case stackOverflow
@ -38,6 +39,24 @@ struct CartonFrontendTestCommand: AsyncParsableCommand {
@Flag(name: .shortAndLong, help: "When specified, list all available test cases.")
var list = false
@Option(name: .long, help: ArgumentHelp(
"""
Pass an environment variable to the test process.
--env NAME=VALUE will set the environment variable NAME to VALUE.
--env NAME will inherit the environment variable NAME from the parent process.
""",
valueName: "NAME=VALUE or NAME"
), transform: Self.parseEnvOption(_:))
var env: [(key: String, value: String?)] = []
static func parseEnvOption(_ value: String) -> (key: String, value: String?) {
let parts = value.split(separator: "=", maxSplits: 1)
if parts.count == 1 {
return (String(parts[0]), nil)
}
return (String(parts[0]), String(parts[1]))
}
@Argument(help: "The list of test cases to run in the test suite.")
var testCases = [String]()
@ -119,16 +138,26 @@ struct CartonFrontendTestCommand: AsyncParsableCommand {
throw ExitCode.failure
}
let runner = try deriveRunner(bundlePath: bundlePath, terminal: terminal, cwd: cwd)
let options = deriveRunnerOptions()
try await runner.run(options: options)
}
func deriveRunner(
bundlePath: AbsolutePath,
terminal: InteractiveWriter,
cwd: AbsolutePath
) throws -> TestRunner {
switch environment {
case .command:
try await CommandTestRunner(
return CommandTestRunner(
testFilePath: bundlePath,
listTestCases: list,
testCases: testCases,
terminal: terminal
).run()
)
case .browser:
try await BrowserTestRunner(
return BrowserTestRunner(
testFilePath: bundlePath,
bindingAddress: bind,
host: Server.Configuration.host(bindOption: bind, hostOption: host),
@ -137,15 +166,28 @@ struct CartonFrontendTestCommand: AsyncParsableCommand {
resourcesPaths: resources,
pid: pid,
terminal: terminal
).run()
)
case .node:
try await NodeTestRunner(
return try NodeTestRunner(
pluginWorkDirectory: AbsolutePath(validating: pluginWorkDirectory, relativeTo: cwd),
testFilePath: bundlePath,
listTestCases: list,
testCases: testCases,
terminal: terminal
).run()
)
}
}
func deriveRunnerOptions() -> TestRunnerOptions {
let parentEnv = ProcessInfo.processInfo.environment
var env: [String: String] = parentEnv
for (key, value) in self.env {
if let value = value {
env[key] = value
} else {
env[key] = parentEnv[key]
}
}
return TestRunnerOptions(env: env)
}
}

View File

@ -77,7 +77,7 @@ struct BrowserTestRunner: TestRunner {
self.terminal = terminal
}
func run() async throws {
func run(options: TestRunnerOptions) async throws {
let server = try await Server(
.init(
builder: nil,
@ -86,6 +86,7 @@ struct BrowserTestRunner: TestRunner {
bindingAddress: bindingAddress,
port: port,
host: host,
env: options.env,
customIndexPath: nil,
resourcesPaths: resourcesPaths,
entrypoint: Constants.entrypoint,

View File

@ -30,7 +30,7 @@ struct CommandTestRunner: TestRunner {
let testCases: [String]
let terminal: InteractiveWriter
func run() async throws {
func run(options: TestRunnerOptions) async throws {
let program = try ProcessInfo.processInfo.environment["CARTON_TEST_RUNNER"] ?? defaultWASIRuntime()
terminal.write("\nRunning the test bundle with \"\(program)\":\n", inColor: .yellow)
@ -43,6 +43,9 @@ struct CommandTestRunner: TestRunner {
if programName == "wasmtime" {
arguments += ["--dir", "."]
}
for (key, value) in options.env {
arguments += ["--env", "\(key)=\(value)"]
}
if !testCases.isEmpty {
xctestArgs.append("--")

View File

@ -28,7 +28,7 @@ struct NodeTestRunner: TestRunner {
let testCases: [String]
let terminal: InteractiveWriter
func run() async throws {
func run(options: TestRunnerOptions) async throws {
terminal.write("\nRunning the test bundle with Node.js:\n", inColor: .yellow)
let entrypointPath = try Constants.entrypoint.write(
@ -63,6 +63,6 @@ struct NodeTestRunner: TestRunner {
} else if !testCases.isEmpty {
nodeArguments.append(contentsOf: testCases)
}
try await Process.run(nodeArguments, terminal)
try await Process.run(nodeArguments, environment: options.env, terminal)
}
}

View File

@ -12,6 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
protocol TestRunner {
func run() async throws
struct TestRunnerOptions {
/// The environment variables to pass to the test process.
let env: [String: String]
}
protocol TestRunner {
func run(options: TestRunnerOptions) async throws
}

View File

@ -181,6 +181,8 @@ public actor Server {
let bindingAddress: String
let port: Int
let host: String
/// Environment variables to be passed to the test process.
let env: [String: String]?
let customIndexPath: AbsolutePath?
let resourcesPaths: [String]
let entrypoint: Entrypoint
@ -194,6 +196,7 @@ public actor Server {
bindingAddress: String,
port: Int,
host: String,
env: [String: String]? = nil,
customIndexPath: AbsolutePath?,
resourcesPaths: [String],
entrypoint: Entrypoint,
@ -206,6 +209,7 @@ public actor Server {
self.bindingAddress = bindingAddress
self.port = port
self.host = host
self.env = env
self.customIndexPath = customIndexPath
self.resourcesPaths = resourcesPaths
self.entrypoint = entrypoint
@ -343,7 +347,8 @@ public actor Server {
customIndexPath: configuration.customIndexPath,
resourcesPaths: configuration.resourcesPaths,
entrypoint: configuration.entrypoint,
serverName: serverName.description
serverName: serverName.description,
env: configuration.env
)
let channel = try await ServerBootstrap(group: group)
// Specify backlog and enable SO_REUSEADDR for the server itself

View File

@ -29,6 +29,7 @@ final class ServerHTTPHandler: ChannelInboundHandler, RemovableChannelHandler {
let resourcesPaths: [String]
let entrypoint: Entrypoint
let serverName: String
let env: [String: String]?
}
struct ServerError: Error, CustomStringConvertible {
@ -87,6 +88,8 @@ final class ServerHTTPHandler: ChannelInboundHandler, RemovableChannelHandler {
bytes: localFileSystem.readFileContents(configuration.mainWasmPath).contents
)
)
case "/process-info.json":
response = try respondProcessInfo(context: context)
case "/" + configuration.entrypoint.fileName:
response = StaticResponse(
contentType: "application/javascript",
@ -218,6 +221,18 @@ final class ServerHTTPHandler: ChannelInboundHandler, RemovableChannelHandler {
)
}
private func respondProcessInfo(context: ChannelHandlerContext) throws -> StaticResponse {
struct ProcessInfoBody: Encodable {
let env: [String: String]?
}
let config = ProcessInfoBody(env: configuration.env)
let json = try JSONEncoder().encode(config)
return StaticResponse(
contentType: "application/json", contentSize: json.count,
body: context.channel.allocator.buffer(bytes: json)
)
}
private func respondEmpty(context: ChannelHandlerContext, status: HTTPResponseStatus) {
var headers = HTTPHeaders()
headers.add(name: "Connection", value: "close")

File diff suppressed because one or more lines are too long

View File

@ -79,14 +79,21 @@ struct SwiftRunProcess {
func swiftRunProcess(
_ arguments: [String],
packageDirectory: URL
packageDirectory: URL,
environment: [String: String]? = nil
) throws -> SwiftRunProcess {
let swiftBin = try findSwiftExecutable().pathString
var outputBuffer = Array<UInt8>()
var environmentBlock = ProcessEnv.block
for (key, value) in environment ?? [:] {
environmentBlock[ProcessEnvironmentKey(key)] = value
}
let process = CartonHelpers.Process(
arguments: [swiftBin, "run"] + arguments,
environmentBlock: environmentBlock,
workingDirectory: try AbsolutePath(validating: packageDirectory.path),
outputRedirection: .stream(
stdout: { (chunk) in
@ -112,10 +119,10 @@ func swiftRunProcess(
}
@discardableResult
func swiftRun(_ arguments: [String], packageDirectory: URL) async throws
func swiftRun(_ arguments: [String], packageDirectory: URL, environment: [String: String]? = nil) async throws
-> CartonHelpers.ProcessResult
{
let process = try swiftRunProcess(arguments, packageDirectory: packageDirectory)
let process = try swiftRunProcess(arguments, packageDirectory: packageDirectory, environment: environment)
var result = try await process.process.waitUntilExit()
result.setOutput(.success(process.output()))
return result

View File

@ -25,6 +25,7 @@ private enum Constants {
static let nodeJSKitPackageName = "NodeJSKitTest"
static let crashTestPackageName = "CrashTest"
static let failTestPackageName = "FailTest"
static let envVarTestPackageName = "EnvVarTest"
}
final class TestCommandTests: XCTestCase {
@ -92,6 +93,23 @@ final class TestCommandTests: XCTestCase {
}
}
func testEnvVar() async throws {
var environmentsToTest: [String] = ["node", "command"]
if Process.findExecutable("safaridriver") != nil {
environmentsToTest.append("browser")
}
for environment in environmentsToTest {
try await withFixture(Constants.envVarTestPackageName) { packageDirectory in
let result = try await swiftRun(
["carton", "test", "--environment", environment, "--env", "FOO=BAR"],
packageDirectory: packageDirectory.asURL,
environment: ["BAZ": "QUX"]
)
try result.checkNonZeroExit()
}
}
}
func testHeadlessBrowserWithCrash() async throws {
try await checkCartonTestFail(fixture: Constants.crashTestPackageName)
}

View File

@ -0,0 +1,8 @@
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "Test",
dependencies: [.package(path: "../../..")],
targets: [.testTarget(name: "EnvVarTest", path: "Tests")]
)

View File

@ -0,0 +1,8 @@
import XCTest
class Tests: XCTestCase {
func testEnvVar() {
XCTAssertEqual(ProcessInfo.processInfo.environment["FOO"], "BAR")
XCTAssertEqual(ProcessInfo.processInfo.environment["BAZ"], "QUX")
}
}

View File

@ -46,6 +46,7 @@ export class LineDecoder {
export type Options = {
args?: string[];
env?: Record<string, string>;
onStdout?: (chunk: Uint8Array) => void;
onStdoutLine?: (line: string) => void;
onStderr?: (chunk: Uint8Array) => void;
@ -90,7 +91,10 @@ export const WasmRunner = (rawOptions: Options, SwiftRuntime: SwiftRuntimeConstr
new PreopenDirectory("/", new Map()),
];
const wasi = new WASI(args, [], fds, {
// Convert env Record to array of "key=value" strings
const envs = options.env ? Object.entries(options.env).map(([key, value]) => `${key}=${value}`) : [];
const wasi = new WASI(args, envs, fds, {
debug: false
});

View File

@ -42,9 +42,13 @@ const startWasiTask = async () => {
);
}
// Load configuration from the server
const config = await fetch("/process-info.json").then((response) => response.json());
let testRunOutput = "";
const wasmRunner = WasmRunner(
{
env: config.env,
onStdoutLine: (line) => {
console.log(line);
testRunOutput += line + "\n";
@ -134,4 +138,4 @@ async function main(): Promise<void> {
}
}
main();
main();

View File

@ -46,8 +46,18 @@ const startWasiTask = async () => {
// No JavaScriptKit module found, run the Wasm module without JSKit
}
// carton-frontend passes all environment variables to the test Node process.
const env: Record<string, string> = {};
for (const key in process.env) {
const value = process.env[key];
if (value) {
env[key] = value;
}
}
const wasmRunner = WasmRunner({
args: testArgs,
env,
onStdoutLine: (line) => {
console.log(line);
},