mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
bazel: add PowerShell to Wine test harness (#28120)
## Why Cross-OS tests in the wine environment will be much more faithful if we can also test powershell integration. ## What Add an x86_64 powershell binary to the bazel wine environment and include smoke tests.
This commit is contained in:
committed by
GitHub
Unverified
parent
740c4f269d
commit
42dec90bc4
@@ -11,3 +11,16 @@ http_archive(
|
||||
"https://github.com/Kron4ek/Wine-Builds/releases/download/11.0/wine-11.0-amd64-wow64.tar.xz",
|
||||
],
|
||||
)
|
||||
|
||||
# Pin the self-contained Windows distribution so Wine tests need neither a
|
||||
# system PowerShell installation nor a separate .NET runtime. This intentionally
|
||||
# stays on 7.2.24 for the test fixture: 7.4.16 and 7.6.2 currently fail during
|
||||
# CLR startup under the pinned Wine 11 runtime, while 7.2.24 runs successfully.
|
||||
http_archive(
|
||||
name = "powershell_windows_x86_64",
|
||||
build_file = "//third_party/powershell:BUILD.bazel",
|
||||
sha256 = "a1ccb6d8ad52f917470a136c3752af4465f261bcbe570cf44f52aa69ae6e867e",
|
||||
urls = [
|
||||
"https://github.com/PowerShell/PowerShell/releases/download/v7.2.24/PowerShell-7.2.24-win-x64.zip",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@ load("//:defs.bzl", "WINDOWS_GNULLVM_RUSTC_LINK_FLAGS")
|
||||
load(":foreign_platform_binary.bzl", "foreign_platform_binary")
|
||||
|
||||
_WINE_RUNTIME_BINARIES = {
|
||||
"pwsh": "@powershell_windows_x86_64//:pwsh",
|
||||
"pwsh-runtime-marker": "@powershell_windows_x86_64//:runtime_marker",
|
||||
"wine": "@wine_linux_x86_64//:wine",
|
||||
"wine-runtime-marker": "@wine_linux_x86_64//:runtime_marker",
|
||||
"wineserver": "@wine_linux_x86_64//:wineserver",
|
||||
@@ -28,6 +30,9 @@ def wine_rust_test(
|
||||
* `CARGO_BIN_EXE_wine` and `CARGO_BIN_EXE_wineserver` identify Wine tools.
|
||||
* `CARGO_BIN_EXE_wine-runtime-marker` identifies a file whose parent is the
|
||||
Wine DLL directory to use as `WINEDLLPATH`.
|
||||
* `CARGO_BIN_EXE_pwsh` identifies the pinned PowerShell executable and
|
||||
`CARGO_BIN_EXE_pwsh-runtime-marker` identifies a file whose parent is the
|
||||
complete PowerShell runtime.
|
||||
|
||||
These are Bazel runfile locations. Resolve binaries with
|
||||
`codex_utils_cargo_bin::cargo_bin`; `:wine_test_support` resolves the fixed
|
||||
@@ -42,9 +47,14 @@ def wine_rust_test(
|
||||
**kwargs: Remaining attributes forwarded to `rust_test`.
|
||||
"""
|
||||
binaries = dict(_WINE_RUNTIME_BINARIES)
|
||||
runtime_data = [
|
||||
"@powershell_windows_x86_64//:runtime",
|
||||
"@wine_linux_x86_64//:runtime",
|
||||
]
|
||||
|
||||
for binary_name in sorted(host_binaries.keys()):
|
||||
if binary_name in binaries:
|
||||
fail("host test binary name collides with Wine runtime: {}".format(binary_name))
|
||||
fail("host test binary name collides with test runtime: {}".format(binary_name))
|
||||
binaries[binary_name] = host_binaries[binary_name]
|
||||
|
||||
for index, binary_name in enumerate(sorted(windows_binaries.keys())):
|
||||
@@ -68,9 +78,7 @@ def wine_rust_test(
|
||||
|
||||
rust_test(
|
||||
name = name,
|
||||
data = data + [
|
||||
"@wine_linux_x86_64//:runtime",
|
||||
] + [binary for binary in binaries.values()],
|
||||
data = data + runtime_data + [binary for binary in binaries.values()],
|
||||
env = {
|
||||
"CARGO_BIN_EXE_{}".format(binary_name): "$(rlocationpath {})".format(binary)
|
||||
for binary_name, binary in binaries.items()
|
||||
|
||||
@@ -16,6 +16,7 @@ rust_library(
|
||||
target_compatible_with = ["@platforms//os:linux"],
|
||||
deps = [
|
||||
"//codex-rs/utils/cargo-bin",
|
||||
"//codex-rs/utils/pty",
|
||||
"@crates//:anyhow",
|
||||
"@crates//:tempfile",
|
||||
"@crates//:tokio",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
compile_error!("wine_test_support can only run on Linux");
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::future::Future;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
@@ -42,6 +43,7 @@ struct WineProcesses {
|
||||
|
||||
struct WineRuntimePaths {
|
||||
dll_path: PathBuf,
|
||||
powershell_runtime: PathBuf,
|
||||
wine: PathBuf,
|
||||
wineserver: PathBuf,
|
||||
}
|
||||
@@ -74,6 +76,7 @@ impl WineTestCommand {
|
||||
pub fn spawn(self) -> Result<WineTestProcess> {
|
||||
let runtime = WineRuntimePaths::from_runfiles()?;
|
||||
let prefix = TempDir::new().context("create isolated Wine prefix")?;
|
||||
install_powershell_runtime(prefix.path(), &runtime.powershell_runtime)?;
|
||||
let mut command = StdCommand::new(&runtime.wine);
|
||||
configure_wine_environment(&mut command, &runtime, prefix.path());
|
||||
command
|
||||
@@ -164,8 +167,13 @@ impl WineRuntimePaths {
|
||||
.context("locate Wine runtime directory")?
|
||||
.to_path_buf();
|
||||
let wineserver = codex_utils_cargo_bin::cargo_bin("wineserver")?;
|
||||
let powershell_runtime = codex_utils_cargo_bin::cargo_bin("pwsh-runtime-marker")?
|
||||
.parent()
|
||||
.context("locate PowerShell runtime directory")?
|
||||
.to_path_buf();
|
||||
Ok(Self {
|
||||
dll_path,
|
||||
powershell_runtime,
|
||||
wine,
|
||||
wineserver,
|
||||
})
|
||||
@@ -298,6 +306,77 @@ fn configure_wine_environment(command: &mut StdCommand, runtime: &WineRuntimePat
|
||||
.env("TMP", r"C:\windows\temp");
|
||||
}
|
||||
|
||||
/// Installs the complete pinned PowerShell distribution where Windows tooling
|
||||
/// expects to discover PowerShell 7.
|
||||
///
|
||||
/// `pwsh.exe` is not a standalone executable: it loads its adjacent .NET host,
|
||||
/// managed assemblies, native libraries, modules, and configuration files at
|
||||
/// startup. The Bazel archive is exposed through runfiles rather than a normal
|
||||
/// Windows installation, while shell detection deliberately probes the
|
||||
/// conventional `C:\Program Files\PowerShell\7` fallback. We therefore have to
|
||||
/// reproduce the archive's directory tree inside each isolated Wine prefix;
|
||||
/// copying only the executable would fail before a command could run.
|
||||
fn install_powershell_runtime(prefix: &Path, runtime: &Path) -> Result<()> {
|
||||
let powershell_parent = prefix
|
||||
.join("drive_c")
|
||||
.join("Program Files")
|
||||
.join("PowerShell");
|
||||
fs::create_dir_all(&powershell_parent).context("create PowerShell installation parent")?;
|
||||
let destination = powershell_parent.join("7");
|
||||
materialize_runtime_directory(runtime, &destination)
|
||||
}
|
||||
|
||||
/// Recursively reproduces a runfiles directory in a writable Wine prefix.
|
||||
///
|
||||
/// Bazel runfiles may be immutable and may contain the PowerShell distribution
|
||||
/// on a different filesystem from the temporary prefix. Hard links avoid
|
||||
/// repeatedly copying the roughly hundred-megabyte runtime when both locations
|
||||
/// share a filesystem; the copy fallback preserves correctness for sandbox or
|
||||
/// remote-execution layouts where cross-device hard links are unavailable.
|
||||
fn materialize_runtime_directory(source: &Path, destination: &Path) -> Result<()> {
|
||||
fs::create_dir_all(destination).with_context(|| {
|
||||
format!(
|
||||
"create PowerShell runtime directory {}",
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
for entry in fs::read_dir(source)
|
||||
.with_context(|| format!("read PowerShell runtime directory {}", source.display()))?
|
||||
{
|
||||
let entry = entry.context("read PowerShell runtime entry")?;
|
||||
let source_path = entry.path();
|
||||
let destination_path = destination.join(entry.file_name());
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.with_context(|| format!("inspect PowerShell runtime entry {}", source_path.display()))?;
|
||||
if file_type.is_dir() {
|
||||
// PowerShell resolves assemblies and modules by their relative
|
||||
// locations, so flattening the archive is not an option.
|
||||
materialize_runtime_directory(&source_path, &destination_path)?;
|
||||
} else if file_type.is_file() {
|
||||
// A hard link gives each prefix the expected installation layout
|
||||
// without duplicating the large runtime in the common local case.
|
||||
if fs::hard_link(&source_path, &destination_path).is_err() {
|
||||
// Cross-device links are common under Bazel sandboxing and
|
||||
// remote execution, where an ordinary copy is still valid.
|
||||
fs::copy(&source_path, &destination_path).with_context(|| {
|
||||
format!(
|
||||
"copy PowerShell runtime file {} to {}",
|
||||
source_path.display(),
|
||||
destination_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"unsupported PowerShell runtime entry type at {}",
|
||||
source_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lib_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::fs;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_utils_pty::SpawnedProcess;
|
||||
use codex_utils_pty::TerminalSize;
|
||||
use futures::FutureExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::WineTestCommand;
|
||||
use super::WineTestProcess;
|
||||
use super::WineRuntimePaths;
|
||||
use super::install_powershell_runtime;
|
||||
|
||||
async fn waiting_smoke_process() -> Result<WineTestProcess> {
|
||||
let executable = codex_utils_cargo_bin::cargo_bin("wine-smoke")?;
|
||||
@@ -230,3 +239,183 @@ async fn shutdown_returns_teardown_error() -> Result<()> {
|
||||
assert_prefix_removed(&prefix);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_runtime_is_materialized_at_the_windows_fallback_path() -> Result<()> {
|
||||
let prefix = TempDir::new()?;
|
||||
let runtime = TempDir::new()?;
|
||||
fs::create_dir(runtime.path().join("Modules"))?;
|
||||
fs::write(runtime.path().join("pwsh.exe"), b"pwsh")?;
|
||||
fs::write(runtime.path().join("Modules").join("marker.txt"), b"module")?;
|
||||
|
||||
install_powershell_runtime(prefix.path(), runtime.path())?;
|
||||
|
||||
let installed = prefix
|
||||
.path()
|
||||
.join("drive_c")
|
||||
.join("Program Files")
|
||||
.join("PowerShell")
|
||||
.join("7");
|
||||
assert_eq!(fs::read(installed.join("pwsh.exe"))?, b"pwsh");
|
||||
assert_eq!(
|
||||
fs::read(installed.join("Modules").join("marker.txt"))?,
|
||||
b"module"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pinned_powershell_runs_under_wine_with_a_pty() -> Result<()> {
|
||||
// Keep this integration smoke test local to the Wine support crate. The
|
||||
// production-shaped PowerShell launch path belongs to exec-server tests.
|
||||
// The marker makes the assertion resilient to Wine or PTY startup chatter.
|
||||
const POWERSHELL_SMOKE_MARKER: &str = "WINE_PWSH_SMOKE";
|
||||
// Besides proving that the pinned runtime starts, report the properties
|
||||
// that shell detection and command construction rely on: PowerShell 7 Core
|
||||
// running with Windows semantics and a backslash path separator.
|
||||
const POWERSHELL_SMOKE_SCRIPT: &str = concat!(
|
||||
"$ErrorActionPreference = 'Stop'; ",
|
||||
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ",
|
||||
"$separatorCode = [int]([System.IO.Path]::DirectorySeparatorChar); ",
|
||||
"if ($PSVersionTable.PSVersion.Major -ne 7) { throw 'expected PowerShell 7' }; ",
|
||||
"if ($PSVersionTable.PSEdition -ne 'Core') { throw 'expected PowerShell Core' }; ",
|
||||
"if (-not $IsWindows) { throw 'expected Windows semantics' }; ",
|
||||
"if ($separatorCode -ne 92) { throw 'expected backslash path separator' }; ",
|
||||
"Write-Output ('WINE_PWSH_SMOKE|' + ",
|
||||
"$PSVersionTable.PSVersion.ToString() + '|' + ",
|
||||
"$PSVersionTable.PSEdition + '|' + ",
|
||||
"$IsWindows.ToString().ToLowerInvariant() + '|' + $separatorCode)",
|
||||
);
|
||||
let runtime = WineRuntimePaths::from_runfiles()?;
|
||||
let prefix = TempDir::new()?;
|
||||
install_powershell_runtime(prefix.path(), &runtime.powershell_runtime)?;
|
||||
let mut env = std::env::vars().collect::<HashMap<_, _>>();
|
||||
env.remove("DISPLAY");
|
||||
env.extend([
|
||||
("HOME".to_string(), prefix.path().to_string_lossy().into_owned()),
|
||||
(
|
||||
"XDG_RUNTIME_DIR".to_string(),
|
||||
prefix.path().to_string_lossy().into_owned(),
|
||||
),
|
||||
("WINEARCH".to_string(), "win64".to_string()),
|
||||
(
|
||||
"WINEPREFIX".to_string(),
|
||||
prefix.path().to_string_lossy().into_owned(),
|
||||
),
|
||||
(
|
||||
"WINEDLLPATH".to_string(),
|
||||
runtime.dll_path.to_string_lossy().into_owned(),
|
||||
),
|
||||
(
|
||||
"WINESERVER".to_string(),
|
||||
runtime.wineserver.to_string_lossy().into_owned(),
|
||||
),
|
||||
("WINEDEBUG".to_string(), "-all".to_string()),
|
||||
(
|
||||
"WINEDLLOVERRIDES".to_string(),
|
||||
"mscoree,mshtml,winegstreamer=".to_string(),
|
||||
),
|
||||
("LANG".to_string(), "C.UTF-8".to_string()),
|
||||
("LC_ALL".to_string(), "C.UTF-8".to_string()),
|
||||
("LC_CTYPE".to_string(), "C.UTF-8".to_string()),
|
||||
("TEMP".to_string(), r"C:\windows\temp".to_string()),
|
||||
("TMP".to_string(), r"C:\windows\temp".to_string()),
|
||||
]);
|
||||
let args = [
|
||||
r"C:\Program Files\PowerShell\7\pwsh.exe".to_string(),
|
||||
"-NoLogo".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-NonInteractive".to_string(),
|
||||
"-Command".to_string(),
|
||||
POWERSHELL_SMOKE_SCRIPT.to_string(),
|
||||
];
|
||||
let wine = runtime.wine.to_string_lossy().into_owned();
|
||||
let SpawnedProcess {
|
||||
session,
|
||||
mut stdout_rx,
|
||||
mut stderr_rx,
|
||||
exit_rx,
|
||||
} = codex_utils_pty::spawn_pty_process(
|
||||
&wine,
|
||||
&args,
|
||||
prefix.path(),
|
||||
&env,
|
||||
/*arg0*/ &None,
|
||||
TerminalSize::default(),
|
||||
)
|
||||
.await?;
|
||||
let command_result = timeout(Duration::from_secs(30), async {
|
||||
let stdout = async {
|
||||
let mut output = Vec::new();
|
||||
while let Some(chunk) = stdout_rx.recv().await {
|
||||
output.extend(chunk);
|
||||
}
|
||||
output
|
||||
};
|
||||
let stderr = async {
|
||||
let mut output = Vec::new();
|
||||
while let Some(chunk) = stderr_rx.recv().await {
|
||||
output.extend(chunk);
|
||||
}
|
||||
output
|
||||
};
|
||||
let (stdout, stderr, exit_code) = tokio::join!(stdout, stderr, exit_rx);
|
||||
Ok::<_, anyhow::Error>((stdout, stderr, exit_code.context("wait for PowerShell")?))
|
||||
})
|
||||
.await
|
||||
.context("PowerShell smoke test timed out")
|
||||
.and_then(std::convert::identity);
|
||||
drop(session);
|
||||
let shutdown_result = timeout(Duration::from_secs(10), async {
|
||||
let mut command = TokioCommand::new(&runtime.wineserver);
|
||||
command
|
||||
.args(["-k", "-w"])
|
||||
.env("HOME", prefix.path())
|
||||
.env("WINEPREFIX", prefix.path())
|
||||
.env("XDG_RUNTIME_DIR", prefix.path())
|
||||
.kill_on_drop(true);
|
||||
let status = command.status().await.context("stop isolated wineserver")?;
|
||||
anyhow::ensure!(
|
||||
status.success() || status.code() == Some(1),
|
||||
"wineserver exited with {status}"
|
||||
);
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.context("stop isolated wineserver timed out")
|
||||
.and_then(std::convert::identity);
|
||||
let (stdout, stderr, exit_code) = match (command_result, shutdown_result) {
|
||||
(Ok(output), Ok(())) => output,
|
||||
(Err(error), Ok(())) => return Err(error),
|
||||
(Ok(_), Err(error)) => return Err(error),
|
||||
(Err(error), Err(shutdown_error)) => {
|
||||
return Err(error.context(format!("Wine teardown also failed: {shutdown_error:#}")));
|
||||
}
|
||||
};
|
||||
anyhow::ensure!(
|
||||
exit_code == 0,
|
||||
"PowerShell exited with {}; stderr: {}",
|
||||
exit_code,
|
||||
String::from_utf8_lossy(&stderr),
|
||||
);
|
||||
let output = String::from_utf8(stdout)?;
|
||||
let marker_start = output
|
||||
.find(POWERSHELL_SMOKE_MARKER)
|
||||
.with_context(|| format!("PowerShell smoke marker was missing from {output:?}"))?;
|
||||
let smoke = output[marker_start..]
|
||||
.lines()
|
||||
.next()
|
||||
.context("PowerShell smoke marker line was incomplete")?
|
||||
.trim_end_matches('\r');
|
||||
let fields = smoke.split('|').collect::<Vec<_>>();
|
||||
assert_eq!(fields.len(), 5, "unexpected PowerShell smoke output: {smoke}");
|
||||
assert_eq!(fields[0], POWERSHELL_SMOKE_MARKER);
|
||||
assert_eq!(
|
||||
fields[1].split('.').next(),
|
||||
Some("7"),
|
||||
"expected PowerShell 7.x, got {}",
|
||||
fields[1],
|
||||
);
|
||||
assert_eq!(&fields[2..], &["Core", "true", "92"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
exports_files(["pwsh.exe"])
|
||||
|
||||
filegroup(
|
||||
name = "pwsh",
|
||||
srcs = ["pwsh.exe"],
|
||||
tags = ["manual"],
|
||||
)
|
||||
|
||||
# The executable is also a stable marker whose parent is the runtime root.
|
||||
filegroup(
|
||||
name = "runtime_marker",
|
||||
srcs = ["pwsh.exe"],
|
||||
tags = ["manual"],
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "runtime",
|
||||
srcs = glob(
|
||||
["**"],
|
||||
# This BUILD file is also loaded from the source tree, where the
|
||||
# archive-only paths are intentionally absent.
|
||||
allow_empty = True,
|
||||
),
|
||||
tags = ["manual"],
|
||||
)
|
||||
Reference in New Issue
Block a user